fix design

This commit is contained in:
David 2026-08-13 17:43:11 +02:00
parent 84c34bdc21
commit 8b294d6ed1
86 changed files with 1875 additions and 1066 deletions

View File

@ -4,27 +4,26 @@
* Calculates KPIs and analytics data for dashboard
*/
import { Injectable, Inject } from '@nestjs/common';
import { BOOKING_REPOSITORY } from '@domain/ports/out/booking.repository';
import { BookingRepository } from '@domain/ports/out/booking.repository';
import { RATE_QUOTE_REPOSITORY } from '@domain/ports/out/rate-quote.repository';
import { RateQuoteRepository } from '@domain/ports/out/rate-quote.repository';
import { Injectable } from '@nestjs/common';
import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
export interface DashboardKPIs {
bookingsThisMonth: number;
totalTEUs: number;
/** Volume LCL du mois, en CBM. */
volumeCBM: number;
/** Commission Xpeditis encaissee sur les reservations acceptees, en EUR. */
estimatedRevenue: number;
pendingConfirmations: number;
bookingsThisMonthChange: number; // % change from last month
totalTEUsChange: number;
volumeCBMChange: number;
estimatedRevenueChange: number;
pendingConfirmationsChange: number;
}
export interface BookingsChartData {
labels: string[]; // Month names
/** Cles de mois ISO `YYYY-MM` ; la mise en forme revient au client. */
labels: string[];
data: number[]; // Booking counts
}
@ -33,7 +32,7 @@ export interface TopTradeLane {
originPort: string;
destinationPort: string;
bookingCount: number;
totalTEUs: number;
totalVolumeCBM: number;
avgPrice: number;
}
@ -41,8 +40,10 @@ export interface DashboardAlert {
id: string;
type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
severity: 'low' | 'medium' | 'high' | 'critical';
title: string;
message: string;
/** Cle de traduction, resolue par le client. */
titleKey: string;
messageKey: string;
messageParams?: Record<string, string | number>;
bookingId?: string;
bookingNumber?: string;
createdAt: Date;
@ -73,125 +74,94 @@ export interface TopCarrier {
@Injectable()
export class AnalyticsService {
constructor(
@Inject(BOOKING_REPOSITORY)
private readonly bookingRepository: BookingRepository,
@Inject(RATE_QUOTE_REPOSITORY)
private readonly rateQuoteRepository: RateQuoteRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
) {}
constructor(private readonly csvBookingRepository: TypeOrmCsvBookingRepository) {}
/**
* Calculate dashboard KPIs
* Cached for 1 hour
*
* Source : `csv_bookings`. Les quatre methodes de ce bloc lisaient
* auparavant `bookingRepository.findByOrganization()`, c'est-a-dire la table
* `bookings` — absente du schema (aucune migration ne la cree). Les quatre
* endpoints repondaient donc 500 en permanence. Les reservations reelles du
* produit sont des reservations LCL portees par `csv_bookings`.
*/
async calculateKPIs(organizationId: string): Promise<DashboardKPIs> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const now = new Date();
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59);
// Get all bookings for organization
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
// This month bookings
const thisMonthBookings = allBookings.filter(b => b.createdAt >= thisMonthStart);
// Last month bookings
const lastMonthBookings = allBookings.filter(
b => b.createdAt >= lastMonthStart && b.createdAt <= lastMonthEnd
const thisMonth = bookings.filter((b: CsvBooking) => b.requestedAt >= thisMonthStart);
const lastMonth = bookings.filter(
(b: CsvBooking) => b.requestedAt >= lastMonthStart && b.requestedAt < thisMonthStart
);
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU)
// Each container is an individual entity, so we count them
const calculateTEUs = (bookings: typeof allBookings): number => {
return bookings.reduce((total, booking) => {
return (
total +
booking.containers.reduce((containerTotal, container) => {
const teu = container.type.startsWith('20') ? 1 : 2;
return containerTotal + teu; // Each container counts as 1 or 2 TEU
}, 0)
);
}, 0);
};
// LCL : le volume se mesure en CBM. Le TEU, unite du conteneur complet, ne
// veut rien dire ici — c'est ce que l'ancienne version calculait.
const volumeOf = (list: CsvBooking[]): number =>
list.reduce((sum, b) => sum + (Number(b.volumeCBM) || 0), 0);
const totalTEUsThisMonth = calculateTEUs(thisMonthBookings);
const totalTEUsLastMonth = calculateTEUs(lastMonthBookings);
// Le chiffre d'affaires est celui d'Xpeditis : la commission percue, pas le
// montant du fret qui revient au transporteur. Seules les reservations
// acceptees comptent.
const revenueOf = (list: CsvBooking[]): number =>
list
.filter(b => b.status === CsvBookingStatus.ACCEPTED)
.reduce((sum, b) => sum + (Number(b.commissionAmountEur) || 0), 0);
// Calculate estimated revenue (from rate quotes)
const calculateRevenue = async (bookings: typeof allBookings): Promise<number> => {
let total = 0;
for (const booking of bookings) {
try {
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
if (rateQuote) {
total += rateQuote.pricing.totalAmount;
}
} catch (error) {
// Skip if rate quote not found
continue;
}
}
return total;
};
const pendingOf = (list: CsvBooking[]): number =>
list.filter(b => b.status === CsvBookingStatus.PENDING).length;
const estimatedRevenueThisMonth = await calculateRevenue(thisMonthBookings);
const estimatedRevenueLastMonth = await calculateRevenue(lastMonthBookings);
const volumeThisMonth = volumeOf(thisMonth);
const volumeLastMonth = volumeOf(lastMonth);
const revenueThisMonth = revenueOf(thisMonth);
const revenueLastMonth = revenueOf(lastMonth);
const pendingThisMonth = pendingOf(thisMonth);
const pendingLastMonth = pendingOf(lastMonth);
// Pending confirmations (status = pending_confirmation)
const pendingThisMonth = thisMonthBookings.filter(
b => b.status.value === 'pending_confirmation'
).length;
const pendingLastMonth = lastMonthBookings.filter(
b => b.status.value === 'pending_confirmation'
).length;
// Calculate percentage changes
const calculateChange = (current: number, previous: number): number => {
const change = (current: number, previous: number): number => {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
};
return {
bookingsThisMonth: thisMonthBookings.length,
totalTEUs: totalTEUsThisMonth,
estimatedRevenue: estimatedRevenueThisMonth,
bookingsThisMonth: thisMonth.length,
volumeCBM: volumeThisMonth,
estimatedRevenue: revenueThisMonth,
pendingConfirmations: pendingThisMonth,
bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length),
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth),
estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth),
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth),
bookingsThisMonthChange: change(thisMonth.length, lastMonth.length),
volumeCBMChange: change(volumeThisMonth, volumeLastMonth),
estimatedRevenueChange: change(revenueThisMonth, revenueLastMonth),
pendingConfirmationsChange: change(pendingThisMonth, pendingLastMonth),
};
}
/**
* Get bookings chart data for last 6 months
*
* Les etiquettes sont des cles ISO `YYYY-MM` : le service ignore la langue de
* l'utilisateur, la mise en forme du mois revient au client.
*/
async getBookingsChartData(organizationId: string): Promise<BookingsChartData> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const now = new Date();
const labels: string[] = [];
const data: number[] = [];
// Get bookings for last 6 months
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
for (let i = 5; i >= 0; i--) {
const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59);
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const nextMonthStart = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
// Month label (e.g., "Jan 2025")
const monthLabel = monthDate.toLocaleDateString('en-US', {
month: 'short',
year: 'numeric',
});
labels.push(monthLabel);
// Count bookings in this month
const count = allBookings.filter(
b => b.createdAt >= monthDate && b.createdAt <= monthEnd
).length;
data.push(count);
labels.push(
`${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
);
data.push(
bookings.filter(
(b: CsvBooking) => b.requestedAt >= monthStart && b.requestedAt < nextMonthStart
).length
);
}
return { labels, data };
@ -201,136 +171,133 @@ export class AnalyticsService {
* Get top 5 trade lanes
*/
async getTopTradeLanes(organizationId: string): Promise<TopTradeLane[]> {
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
// Group by route (origin-destination)
const routeMap = new Map<
string,
{
originPort: string;
destinationPort: string;
bookingCount: number;
totalTEUs: number;
totalVolumeCBM: number;
totalPrice: number;
}
>();
for (const booking of allBookings) {
try {
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
if (!rateQuote) continue;
for (const booking of bookings) {
const originPort = booking.origin.getValue();
const destinationPort = booking.destination.getValue();
const routeKey = `${originPort} → ${destinationPort}`;
// Get first and last ports from route
const originPort = rateQuote.route[0]?.portCode || 'UNKNOWN';
const destinationPort = rateQuote.route[rateQuote.route.length - 1]?.portCode || 'UNKNOWN';
const routeKey = `${originPort}-${destinationPort}`;
const entry = routeMap.get(routeKey) ?? {
originPort,
destinationPort,
bookingCount: 0,
totalVolumeCBM: 0,
totalPrice: 0,
};
if (!routeMap.has(routeKey)) {
routeMap.set(routeKey, {
originPort,
destinationPort,
bookingCount: 0,
totalTEUs: 0,
totalPrice: 0,
});
}
const route = routeMap.get(routeKey)!;
route.bookingCount++;
route.totalPrice += rateQuote.pricing.totalAmount;
// Calculate TEUs
const teus = booking.containers.reduce((total, container) => {
const teu = container.type.startsWith('20') ? 1 : 2;
return total + teu;
}, 0);
route.totalTEUs += teus;
} catch (error) {
continue;
}
entry.bookingCount++;
entry.totalVolumeCBM += Number(booking.volumeCBM) || 0;
entry.totalPrice += Number(booking.priceEUR) || 0;
routeMap.set(routeKey, entry);
}
// Convert to array and sort by booking count
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({
route,
originPort: data.originPort,
destinationPort: data.destinationPort,
bookingCount: data.bookingCount,
totalTEUs: data.totalTEUs,
avgPrice: data.totalPrice / data.bookingCount,
}));
// Sort by booking count and return top 5
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
return Array.from(routeMap.entries())
.map(([route, entry]) => ({
route,
originPort: entry.originPort,
destinationPort: entry.destinationPort,
bookingCount: entry.bookingCount,
totalVolumeCBM: entry.totalVolumeCBM,
avgPrice: entry.bookingCount > 0 ? entry.totalPrice / entry.bookingCount : 0,
}))
.sort((a, b) => b.bookingCount - a.bookingCount)
.slice(0, 5);
}
/**
* Get dashboard alerts
*
* Uniquement ce sur quoi l'utilisateur peut agir. Les libelles sont des cles
* de traduction resolues cote client : le service ne connait pas la langue.
*/
async getAlerts(organizationId: string): Promise<DashboardAlert[]> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const alerts: DashboardAlert[] = [];
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
// Check for pending confirmations (older than 24h)
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oldPendingBookings = allBookings.filter(
b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
);
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
for (const booking of oldPendingBookings) {
alerts.push({
id: `pending-${booking.id}`,
type: 'confirmation',
severity: 'medium',
title: 'Pending Confirmation',
message: `Booking ${booking.bookingNumber.value} is awaiting carrier confirmation for over 24 hours`,
bookingId: booking.id,
bookingNumber: booking.bookingNumber.value,
createdAt: booking.createdAt,
isRead: false,
});
}
for (const booking of bookings) {
const waitedMs = now - booking.requestedAt.getTime();
const waitedDays = Math.floor(waitedMs / oneDay);
// Check for bookings departing soon (within 7 days) with pending status
const sevenDaysFromNow = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
for (const booking of allBookings) {
try {
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId);
if (rateQuote && rateQuote.route.length > 0) {
const etd = rateQuote.route[0].departure;
if (etd) {
const etdDate = new Date(etd);
if (
etdDate <= sevenDaysFromNow &&
etdDate >= new Date() &&
booking.status.value === 'pending_confirmation'
) {
alerts.push({
id: `departure-${booking.id}`,
type: 'delay',
severity: 'high',
title: 'Departure Soon - Not Confirmed',
message: `Booking ${booking.bookingNumber.value} departs in ${Math.ceil(
(etdDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000)
)} days but is not confirmed yet`,
bookingId: booking.id,
bookingNumber: booking.bookingNumber.value,
createdAt: booking.createdAt,
isRead: false,
});
}
}
}
} catch (error) {
// Sans reponse du transporteur au-dela de 48 h, la demande decroche.
if (booking.status === CsvBookingStatus.PENDING && waitedMs > 2 * oneDay) {
alerts.push({
id: `pending-${booking.id}`,
type: 'confirmation',
severity: waitedDays >= 5 ? 'high' : 'medium',
titleKey: 'awaitingCarrier',
messageKey: 'awaitingCarrierSince',
messageParams: { days: waitedDays, carrier: booking.carrierName },
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
createdAt: booking.requestedAt,
isRead: false,
});
continue;
}
// Reservation bloquee avant paiement : rien ne part tant qu'elle y reste.
if (
(booking.status === CsvBookingStatus.PENDING_PAYMENT ||
booking.status === CsvBookingStatus.PENDING_BANK_TRANSFER) &&
waitedMs > oneDay
) {
alerts.push({
id: `payment-${booking.id}`,
type: 'payment',
severity: 'critical',
titleKey: 'awaitingPayment',
messageKey: 'awaitingPaymentSince',
messageParams: { days: waitedDays },
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
createdAt: booking.requestedAt,
isRead: false,
});
continue;
}
// Refus recent : il faut replacer la marchandise.
if (
booking.status === CsvBookingStatus.REJECTED &&
booking.respondedAt &&
now - booking.respondedAt.getTime() < 7 * oneDay
) {
alerts.push({
id: `rejected-${booking.id}`,
type: 'info',
severity: 'medium',
titleKey: 'rejected',
messageKey: 'rejectedBy',
messageParams: { carrier: booking.carrierName },
bookingId: booking.id,
bookingNumber: booking.bookingNumber,
createdAt: booking.respondedAt,
isRead: false,
});
}
}
// Sort by severity (critical > high > medium > low)
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
return alerts;
return alerts.sort(
(a, b) =>
severityOrder[a.severity] - severityOrder[b.severity] ||
b.createdAt.getTime() - a.createdAt.getTime()
);
}
/**

View File

@ -68,7 +68,7 @@ const STATUS_LABELS: Record<BlogPostStatus, string> = {
const STATUS_COLORS: Record<BlogPostStatus, string> = {
draft: 'bg-yellow-100 text-yellow-800',
scheduled: 'bg-brand-turquoise/10 text-brand-navy',
scheduled: 'bg-brand-blue/10 text-brand-navy',
published: 'bg-green-100 text-green-800',
archived: 'bg-neutral-100 text-neutral-600',
};
@ -526,7 +526,7 @@ export default function AdminBlogPage() {
actions={
<button
onClick={openCreate}
className="flex items-center space-x-2 px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium text-sm"
className="flex items-center space-x-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium text-sm"
>
<Plus className="w-4 h-4" />
<span>Nouvel article</span>
@ -556,7 +556,7 @@ export default function AdminBlogPage() {
onClick={() => setViewFilter(tab.value)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
viewFilter === tab.value
? 'bg-brand-turquoise text-brand-navy'
? 'bg-brand-navy text-white'
: 'bg-white text-neutral-600 border border-border hover:bg-neutral-50'
}`}
>
@ -814,7 +814,7 @@ export default function AdminBlogPage() {
required
value={formData.title}
onChange={e => handleTitleChange(e.target.value)}
className="w-full text-3xl font-bold text-brand-navy border-0 border-b-2 border-border focus:border-brand-turquoise focus:outline-none pb-2 placeholder-neutral-300 bg-transparent"
className="w-full text-3xl font-bold text-brand-navy border-0 border-b-2 border-border focus:border-brand-blue focus:outline-none pb-2 placeholder-neutral-300 bg-transparent"
placeholder="Titre de l'article..."
/>
</div>
@ -1011,7 +1011,7 @@ export default function AdminBlogPage() {
<button
type="button"
onClick={addFaqItem}
className="inline-flex items-center gap-1 text-xs font-medium text-brand-navy hover:text-brand-turquoise"
className="inline-flex items-center gap-1 text-xs font-medium text-brand-navy hover:text-brand-blue"
>
<Plus className="w-3.5 h-3.5" />
Ajouter
@ -1142,7 +1142,7 @@ export default function AdminBlogPage() {
<button
type="submit"
disabled={saving}
className="w-full py-2.5 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
className="w-full py-2.5 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{isScheduleMode ? 'Planifier' : 'Enregistrer brouillon'}
@ -1152,7 +1152,7 @@ export default function AdminBlogPage() {
<button
type="submit"
disabled={saving}
className="w-full py-2.5 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
className="w-full py-2.5 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? 'Enregistrement...' : 'Enregistrer'}
@ -1191,8 +1191,8 @@ export default function AdminBlogPage() {
onDrop={handleCoverDrop}
className={`w-full h-32 border-2 border-dashed rounded-lg flex flex-col items-center justify-center gap-2 transition-colors disabled:opacity-50 ${
isDraggingCover
? 'border-brand-turquoise bg-brand-turquoise/5 text-brand-navy'
: 'border-neutral-300 text-neutral-400 hover:border-brand-turquoise hover:text-brand-navy'
? 'border-brand-blue bg-brand-blue/5 text-brand-navy'
: 'border-neutral-300 text-neutral-400 hover:border-brand-blue hover:text-brand-navy'
}`}
>
{uploadingCover ? (

View File

@ -148,7 +148,7 @@ export default function AdminBookingsPage() {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
<p className="mt-4 text-neutral-600">{t('loading')}</p>
</div>
</div>
@ -211,7 +211,7 @@ export default function AdminBookingsPage() {
placeholder={t('search.placeholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none text-sm"
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none text-sm"
/>
</div>
<div>
@ -221,7 +221,7 @@ export default function AdminBookingsPage() {
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none text-sm"
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none text-sm"
>
<option value="all">{t('filter.all')}</option>
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>

View File

@ -30,7 +30,7 @@ import { useToast } from '@/components/ui/toast';
import { useConfirm } from '@/components/ui/use-confirm';
const DIRECTION_COLORS: Record<'EXPORT' | 'IMPORT', string> = {
EXPORT: 'bg-brand-turquoise/10 text-brand-navy',
EXPORT: 'bg-brand-blue/10 text-brand-navy',
IMPORT: 'bg-purple-100 text-purple-800',
};

View File

@ -222,9 +222,9 @@ export default function AdminDocumentsPage() {
png: <ImageIcon className={`${cls} text-green-500`} />,
gif: <ImageIcon className={`${cls} text-green-500`} />,
image: <ImageIcon className={`${cls} text-green-500`} />,
word: <FileEdit className={`${cls} text-brand-turquoise`} />,
doc: <FileEdit className={`${cls} text-brand-turquoise`} />,
docx: <FileEdit className={`${cls} text-brand-turquoise`} />,
word: <FileEdit className={`${cls} text-brand-blue`} />,
doc: <FileEdit className={`${cls} text-brand-blue`} />,
docx: <FileEdit className={`${cls} text-brand-blue`} />,
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
@ -304,7 +304,7 @@ export default function AdminDocumentsPage() {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
<p className="mt-4 text-neutral-600">{t('loading')}</p>
</div>
</div>
@ -345,7 +345,7 @@ export default function AdminDocumentsPage() {
placeholder={t('filters.searchPlaceholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
/>
</div>
<div>
@ -357,7 +357,7 @@ export default function AdminDocumentsPage() {
placeholder={t('filters.quoteNumberPlaceholder')}
value={filterQuoteNumber}
onChange={e => setFilterQuoteNumber(e.target.value)}
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
/>
</div>
<div>
@ -367,7 +367,7 @@ export default function AdminDocumentsPage() {
<select
value={filterUserId}
onChange={e => setFilterUserId(e.target.value)}
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
>
<option value="all">{t('filters.allUsers')}</option>
{uniqueUsers.map(user => (
@ -572,7 +572,7 @@ export default function AdminDocumentsPage() {
onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-brand-turquoise/5 border-brand-turquoise text-brand-navy'
? 'z-10 bg-brand-blue/5 border-brand-blue text-brand-navy'
: 'bg-white border-neutral-300 text-neutral-500 hover:bg-neutral-50'
}`}
>

View File

@ -60,7 +60,7 @@ export default function AdminLoginPage() {
priority
className="h-auto mb-4"
/>
<div className="flex items-center gap-2 text-brand-turquoise">
<div className="flex items-center gap-2 text-brand-blue">
<ShieldCheck className="w-5 h-5" />
<span className="text-xs font-semibold uppercase tracking-wider">{t('badge')}</span>
</div>

View File

@ -55,7 +55,7 @@ const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 border border-red-200',
fatal: 'bg-red-200 text-red-900 border border-red-300',
warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200',
info: 'bg-brand-turquoise/10 text-brand-navy border border-brand-turquoise/30',
info: 'bg-brand-blue/10 text-brand-navy border border-brand-blue/30',
debug: 'bg-neutral-100 text-neutral-600 border border-border',
trace: 'bg-purple-100 text-purple-700 border border-purple-200',
};
@ -239,7 +239,7 @@ export default function AdminLogsPage() {
label={t('stats.total')}
value={total}
icon={Activity}
color="bg-brand-turquoise/10 text-brand-navy"
color="bg-brand-blue/10 text-brand-navy"
/>
<StatCard
label={t('stats.errors')}
@ -364,7 +364,7 @@ export default function AdminLogsPage() {
<button
onClick={fetchLogs}
disabled={loading}
className="px-3 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-lg hover:bg-brand-turquoise/90 transition-colors disabled:opacity-50 whitespace-nowrap"
className="px-3 py-2 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 transition-colors disabled:opacity-50 whitespace-nowrap"
>
{t('filters.apply')}
</button>

View File

@ -318,14 +318,14 @@ export default function AdminOrganizationsPage() {
`mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none text-sm ${
hasError
? 'border-red-400 focus:border-red-500 focus:ring-red-500'
: 'border-neutral-300 focus:border-brand-turquoise focus:ring-ring'
: 'border-neutral-300 focus:border-brand-blue focus:ring-ring'
}`;
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
<p className="mt-4 text-neutral-600">{t('loading')}</p>
</div>
</div>
@ -340,7 +340,7 @@ export default function AdminOrganizationsPage() {
actions={
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-brand-turquoise text-brand-navy text-sm font-medium rounded-lg hover:bg-brand-turquoise/90 transition-colors"
className="px-4 py-2 bg-brand-navy text-white text-sm font-medium rounded-lg hover:bg-brand-navy/90 transition-colors"
>
{t('create')}
</button>
@ -363,7 +363,7 @@ export default function AdminOrganizationsPage() {
<span
className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
org.type === 'FREIGHT_FORWARDER'
? 'bg-brand-turquoise/10 text-brand-navy'
? 'bg-brand-blue/10 text-brand-navy'
: org.type === 'CARRIER'
? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800'
@ -426,7 +426,7 @@ export default function AdminOrganizationsPage() {
<div className="flex space-x-2">
<button
onClick={() => openEditModal(org)}
className="flex-1 px-3 py-2 bg-brand-turquoise/5 text-brand-navy rounded-md hover:bg-brand-turquoise/10 transition-colors text-sm font-medium"
className="flex-1 px-3 py-2 bg-brand-blue/5 text-brand-navy rounded-md hover:bg-brand-blue/10 transition-colors text-sm font-medium"
>
{t('edit')}
</button>
@ -523,7 +523,7 @@ export default function AdminOrganizationsPage() {
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
formData.type === 'FREIGHT_FORWARDER'
? 'bg-brand-turquoise/10 text-brand-navy'
? 'bg-brand-blue/10 text-brand-navy'
: formData.type === 'CARRIER'
? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800'
@ -818,7 +818,7 @@ export default function AdminOrganizationsPage() {
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md text-sm font-medium hover:bg-brand-turquoise/90 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
className="px-4 py-2 bg-brand-navy text-white rounded-md text-sm font-medium hover:bg-brand-navy/90 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
>
{submitting
? t('modal.saving')

View File

@ -167,7 +167,7 @@ export default function AdminUsersPage() {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
<p className="mt-4 text-neutral-600">{t('loading')}</p>
</div>
</div>
@ -182,7 +182,7 @@ export default function AdminUsersPage() {
actions={
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-brand-turquoise text-brand-navy text-sm font-medium rounded-lg hover:bg-brand-turquoise/90 transition-colors"
className="px-4 py-2 bg-brand-navy text-white text-sm font-medium rounded-lg hover:bg-brand-navy/90 transition-colors"
>
{t('create')}
</button>
@ -238,7 +238,7 @@ export default function AdminUsersPage() {
user.role === 'ADMIN'
? 'bg-purple-100 text-purple-800'
: user.role === 'MANAGER'
? 'bg-brand-turquoise/10 text-brand-navy'
? 'bg-brand-blue/10 text-brand-navy'
: 'bg-neutral-100 text-neutral-800'
}`}
>
@ -260,7 +260,7 @@ export default function AdminUsersPage() {
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button
onClick={() => openEditModal(user)}
className="text-brand-navy hover:text-brand-turquoise"
className="text-brand-navy hover:text-brand-blue"
>
{t('edit')}
</button>
@ -292,7 +292,7 @@ export default function AdminUsersPage() {
required
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
/>
</div>
<div>
@ -304,7 +304,7 @@ export default function AdminUsersPage() {
required
value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
/>
</div>
<div>
@ -316,7 +316,7 @@ export default function AdminUsersPage() {
required
value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
/>
</div>
<div>
@ -324,7 +324,7 @@ export default function AdminUsersPage() {
<select
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
>
<option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option>
@ -340,7 +340,7 @@ export default function AdminUsersPage() {
required
value={formData.organizationId}
onChange={e => setFormData({ ...formData, organizationId: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
>
<option value="">{t('modal.selectOrganization')}</option>
{organizations.map(org => (
@ -359,7 +359,7 @@ export default function AdminUsersPage() {
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={e => setFormData({ ...formData, password: e.target.value })}
className="block w-full px-3 py-2 pr-10 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="block w-full px-3 py-2 pr-10 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
/>
<button
type="button"
@ -385,7 +385,7 @@ export default function AdminUsersPage() {
</button>
<button
type="submit"
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md hover:bg-brand-turquoise/90"
className="px-4 py-2 bg-brand-navy text-white rounded-md hover:bg-brand-navy/90"
>
{t('modal.create')}
</button>
@ -421,7 +421,7 @@ export default function AdminUsersPage() {
required
value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
/>
</div>
<div>
@ -433,7 +433,7 @@ export default function AdminUsersPage() {
required
value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
/>
</div>
<div>
@ -441,7 +441,7 @@ export default function AdminUsersPage() {
<select
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-turquoise focus:ring-ring focus:outline-none"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:border-brand-blue focus:ring-ring focus:outline-none"
>
<option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option>
@ -463,7 +463,7 @@ export default function AdminUsersPage() {
</button>
<button
type="submit"
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md hover:bg-brand-turquoise/90"
className="px-4 py-2 bg-brand-navy text-white rounded-md hover:bg-brand-navy/90"
>
{t('modal.update')}
</button>

View File

@ -54,7 +54,7 @@ export default function BookingConfirmPage() {
return (
<div className="min-h-[60vh] flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md w-full text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-turquoise mx-auto mb-4"></div>
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-blue mx-auto mb-4"></div>
<p className="text-neutral-600">{t('loading')}</p>
</div>
</div>
@ -205,7 +205,7 @@ export default function BookingConfirmPage() {
</div>
{/* Next Steps */}
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-brand-navy mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -257,7 +257,7 @@ export default function BookingConfirmPage() {
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-brand-navy hover:text-brand-turquoise text-sm font-medium"
className="text-brand-navy hover:text-brand-blue text-sm font-medium"
>
{t('labels.download')}
</a>

View File

@ -184,7 +184,7 @@ export default function BookingRejectPage() {
)}
</div>
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-brand-navy mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path

View File

@ -110,7 +110,7 @@ export default function CarrierAcceptPage() {
<p className="text-neutral-600 mb-6">{error}</p>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
className="w-full px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
>
{t('common.backHome')}
</button>

View File

@ -327,7 +327,7 @@ export default function CarrierDocumentsPage() {
return (
<div className="min-h-[60vh] flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
<Loader2 className="w-16 h-16 text-brand-blue mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('loading')}</h1>
<p className="text-neutral-600">{t('loadingHint')}</p>
</div>
@ -360,8 +360,8 @@ export default function CarrierDocumentsPage() {
<div className="min-h-[60vh] flex items-center justify-center bg-brand-gray p-4">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
<div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-brand-turquoise" />
<div className="mx-auto w-16 h-16 bg-brand-blue/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-brand-blue" />
</div>
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('password.title')}</h1>
<p className="text-neutral-600">{t('password.intro')}</p>
@ -385,7 +385,7 @@ export default function CarrierDocumentsPage() {
value={password}
onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder={t('password.passwordPlaceholder')}
className="w-full px-4 py-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
className="w-full px-4 py-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-brand-blue focus:border-brand-blue text-center text-xl tracking-widest font-mono uppercase"
autoComplete="off"
autoFocus
/>
@ -456,7 +456,7 @@ export default function CarrierDocumentsPage() {
</div>
<button
onClick={handleRefresh}
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
className="text-sm text-brand-blue hover:text-brand-navy font-medium"
>
{t('header.refresh')}
</button>
@ -524,7 +524,7 @@ export default function CarrierDocumentsPage() {
<div className="bg-white rounded-xl shadow-md overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-brand-navy flex items-center gap-2">
<FileText className="w-5 h-5 text-brand-turquoise" />
<FileText className="w-5 h-5 text-brand-blue" />
{t('list.heading', { count: documents.length })}
</h2>
</div>
@ -547,7 +547,7 @@ export default function CarrierDocumentsPage() {
<div>
<p className="font-medium text-brand-navy">{doc.fileName}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
<span className="text-xs px-2 py-0.5 bg-brand-blue/10 text-brand-navy rounded-full">
{getDocumentTypeLabel(doc.type)}
</span>
<span className="text-xs text-neutral-500">{formatFileSize(doc.size)}</span>

View File

@ -110,7 +110,7 @@ export default function CarrierRejectPage() {
<p className="text-neutral-600 mb-6">{error}</p>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
className="w-full px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
>
{t('common.backHome')}
</button>

View File

@ -164,7 +164,7 @@ export default function PayCommissionPage() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-turquoise/5">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-blue/5">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-brand-navy" />
<span className="text-neutral-600">Chargement...</span>
@ -175,13 +175,13 @@ export default function PayCommissionPage() {
if (error && !booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-turquoise/5">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-blue/5">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-center text-neutral-700">{error}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
className="mt-4 w-full px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
>
Retour aux bookings
</button>
@ -210,13 +210,13 @@ export default function PayCommissionPage() {
: (booking.priceUSD || 0) + (booking.priceEUR || 0);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-brand-turquoise/5 py-10 px-4">
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-brand-blue/5 py-10 px-4">
<div className="max-w-5xl mx-auto">
{/* Back button — returns to the previous context (list or booking detail)
instead of always jumping to the global list. */}
<button
onClick={() => router.back()}
className="mb-6 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
className="mb-6 flex items-center text-brand-navy hover:text-brand-blue font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
@ -244,7 +244,7 @@ export default function PayCommissionPage() {
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced?${params.toString()}`);
}}
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-brand-navy bg-brand-turquoise/5 hover:bg-brand-turquoise/10 rounded-lg transition-colors"
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-brand-navy bg-brand-blue/5 hover:bg-brand-blue/10 rounded-lg transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Modifier la réservation avant de payer
@ -323,7 +323,7 @@ export default function PayCommissionPage() {
onClick={() => setSelectedMethod('card')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'card'
? 'border-brand-turquoise bg-brand-turquoise/5'
? 'border-brand-blue bg-brand-blue/5'
: 'border-border bg-white hover:border-neutral-300'
}`}
>
@ -331,7 +331,7 @@ export default function PayCommissionPage() {
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'card' ? 'bg-brand-turquoise/10' : 'bg-neutral-100'
selectedMethod === 'card' ? 'bg-brand-blue/10' : 'bg-neutral-100'
}`}
>
<CreditCard
@ -347,7 +347,7 @@ export default function PayCommissionPage() {
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'card' ? 'border-brand-turquoise bg-brand-turquoise' : 'border-neutral-300'
selectedMethod === 'card' ? 'border-brand-blue bg-brand-blue' : 'border-neutral-300'
}`}
>
{selectedMethod === 'card' && <div className="w-2 h-2 rounded-full bg-white" />}
@ -360,7 +360,7 @@ export default function PayCommissionPage() {
onClick={() => setSelectedMethod('transfer')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'transfer'
? 'border-brand-turquoise bg-brand-turquoise/5'
? 'border-brand-blue bg-brand-blue/5'
: 'border-border bg-white hover:border-neutral-300'
}`}
>
@ -368,7 +368,7 @@ export default function PayCommissionPage() {
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'transfer' ? 'bg-brand-turquoise/10' : 'bg-neutral-100'
selectedMethod === 'transfer' ? 'bg-brand-blue/10' : 'bg-neutral-100'
}`}
>
<Building2
@ -385,7 +385,7 @@ export default function PayCommissionPage() {
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'transfer'
? 'border-brand-turquoise bg-brand-turquoise'
? 'border-brand-blue bg-brand-blue'
: 'border-neutral-300'
}`}
>
@ -405,7 +405,7 @@ export default function PayCommissionPage() {
<button
onClick={handlePayByCard}
disabled={paying}
className="w-full py-3 bg-brand-turquoise text-brand-navy rounded-lg font-semibold hover:bg-brand-turquoise/90 disabled:bg-neutral-300 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
className="w-full py-3 bg-brand-navy text-white rounded-lg font-semibold hover:bg-brand-navy/90 disabled:bg-neutral-300 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{paying ? (
<>
@ -575,7 +575,7 @@ export default function PayCommissionPage() {
</div>
{/* Booking fee box */}
<div className="bg-brand-turquoise rounded-xl p-5 text-white">
<div className="bg-brand-navy rounded-xl p-5 text-white">
<p className="text-sm text-white/70 mb-1">Forfait par booking — à régler maintenant</p>
<p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
<p className="text-xs text-white/70 mt-1">

View File

@ -42,14 +42,14 @@ export default function PaymentSuccessPage() {
if (!sessionId) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-turquoise/5 to-indigo-100">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-blue/5 to-indigo-100">
<div className="bg-white rounded-lg shadow-md p-8 max-w-md text-center">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-brand-navy mb-2">Session invalide</h2>
<p className="text-neutral-600 mb-4">Aucune session de paiement trouvee.</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
className="px-6 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
>
Retour aux bookings
</button>
@ -59,7 +59,7 @@ export default function PaymentSuccessPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 px-4">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-blue/5 to-indigo-100 px-4">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
{status === 'confirming' && (
<>
@ -85,7 +85,7 @@ export default function PaymentSuccessPage() {
avec votre demande de booking.
</p>
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center space-x-2 text-brand-navy">
<Mail className="h-5 w-5" />
<span className="text-sm font-medium">Email envoye au transporteur</span>
@ -97,7 +97,7 @@ export default function PaymentSuccessPage() {
<button
onClick={() => router.push('/dashboard/bookings')}
className="w-full px-6 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 font-semibold flex items-center justify-center"
className="w-full px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-semibold flex items-center justify-center"
>
Voir mes bookings
<ArrowRight className="h-4 w-4 ml-2" />
@ -127,7 +127,7 @@ export default function PaymentSuccessPage() {
setStatus('error');
});
}}
className="w-full px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
className="w-full px-6 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
>
Reessayer
</button>

View File

@ -234,13 +234,13 @@ function NewBookingPageContent() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="mb-4 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
className="mb-4 flex items-center text-brand-navy hover:text-brand-blue font-medium"
>
← Retour aux résultats
</button>
@ -263,7 +263,7 @@ function NewBookingPageContent() {
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
currentStep >= step ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-200 text-neutral-600'
currentStep >= step ? 'bg-brand-navy text-white' : 'bg-neutral-200 text-neutral-600'
}`}
>
{step}
@ -283,7 +283,7 @@ function NewBookingPageContent() {
{step < 3 && (
<div
className={`flex-1 h-1 mx-4 ${
currentStep > step ? 'bg-brand-turquoise' : 'bg-neutral-200'
currentStep > step ? 'bg-brand-blue' : 'bg-neutral-200'
}`}
/>
)}
@ -314,7 +314,7 @@ function NewBookingPageContent() {
<div className="space-y-6">
{/* Carrier Info */}
<div className="bg-brand-turquoise/5 border-2 border-brand-turquoise/30 rounded-lg p-6">
<div className="bg-brand-blue/5 border-2 border-brand-blue/30 rounded-lg p-6">
<h3 className="text-lg font-semibold text-brand-navy mb-4">Transporteur</h3>
<div className="grid grid-cols-2 gap-4">
<div>
@ -409,7 +409,7 @@ function NewBookingPageContent() {
<button
onClick={() => setCurrentStep(2)}
disabled={!canProceedToStep2}
className="px-8 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
className="px-8 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
>
Continuer →
</button>
@ -440,8 +440,8 @@ function NewBookingPageContent() {
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
isDragging
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-neutral-300 hover:border-brand-turquoise hover:bg-neutral-50'
? 'border-brand-blue bg-brand-blue/5'
: 'border-neutral-300 hover:border-brand-blue hover:bg-neutral-50'
}`}
onDragOver={e => {
e.preventDefault();
@ -470,7 +470,7 @@ function NewBookingPageContent() {
/>
<div className="flex flex-col items-center">
<svg
className={`w-16 h-16 mb-4 transition-colors ${isDragging ? 'text-brand-turquoise' : 'text-neutral-400'}`}
className={`w-16 h-16 mb-4 transition-colors ${isDragging ? 'text-brand-blue' : 'text-neutral-400'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -556,7 +556,7 @@ function NewBookingPageContent() {
value={formData.notes || ''}
onChange={e => setFormData(prev => ({ ...prev, notes: e.target.value }))}
rows={4}
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-brand-turquoise"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-brand-blue"
placeholder="Ajoutez des instructions spéciales pour le transporteur..."
/>
</div>
@ -571,7 +571,7 @@ function NewBookingPageContent() {
<button
onClick={() => setCurrentStep(3)}
disabled={!canProceedToStep3}
className="px-8 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
className="px-8 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
>
Continuer →
</button>
@ -586,7 +586,7 @@ function NewBookingPageContent() {
<div className="space-y-6 mb-8">
{/* Summary */}
<div className="bg-brand-turquoise/5 border-2 border-brand-turquoise/30 rounded-lg p-6">
<div className="bg-brand-blue/5 border-2 border-brand-blue/30 rounded-lg p-6">
<h3 className="text-lg font-semibold text-brand-navy mb-4">Récapitulatif</h3>
<div className="space-y-3">
<div className="flex justify-between">

View File

@ -198,9 +198,9 @@ export default function BookingDetailPage() {
<li className="flex gap-3">
<span
aria-hidden="true"
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-brand-turquoise/10"
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-brand-blue/10"
>
<FileText className="size-3 text-brand-turquoise" />
<FileText className="size-3 text-brand-blue" />
</span>
<div className="min-w-0">
<p className="text-body-sm font-medium text-brand-navy">

View File

@ -100,7 +100,7 @@ export default function NotificationsPage() {
urgent: 'border-l-4 border-red-500 bg-red-50 hover:bg-red-100',
high: 'border-l-4 border-orange-500 bg-orange-50 hover:bg-orange-100',
medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100',
low: 'border-l-4 border-brand-turquoise bg-brand-turquoise/5 hover:bg-brand-turquoise/10',
low: 'border-l-4 border-brand-blue bg-brand-blue/5 hover:bg-brand-blue/10',
};
return (
colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300 hover:bg-neutral-100'
@ -126,7 +126,7 @@ export default function NotificationsPage() {
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_accepted: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_rejected: <XCircle className={`${iconClass} text-red-500`} />,
csv_booking_request_sent: <Mail className={`${iconClass} text-brand-turquoise`} />,
csv_booking_request_sent: <Mail className={`${iconClass} text-brand-blue`} />,
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
document_uploaded: <FileText className={`${iconClass} text-neutral-600`} />,
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
@ -163,7 +163,7 @@ export default function NotificationsPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-brand-turquoise/10 rounded-lg">
<div className="p-3 bg-brand-blue/10 rounded-lg">
<Bell className="w-8 h-8 text-brand-navy" />
</div>
<div>
@ -178,7 +178,7 @@ export default function NotificationsPage() {
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="flex items-center space-x-2 px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 disabled:opacity-50 transition-colors"
className="flex items-center space-x-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 transition-colors"
>
<CheckCheck className="w-5 h-5" />
<span>{t('markAllRead')}</span>
@ -203,7 +203,7 @@ export default function NotificationsPage() {
}}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
selectedFilter === filter
? 'bg-brand-turquoise text-brand-navy shadow-sm'
? 'bg-brand-navy text-white shadow-sm'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
@ -223,7 +223,7 @@ export default function NotificationsPage() {
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-turquoise mx-auto mb-4" />
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-blue mx-auto mb-4" />
<p className="text-neutral-500">{t('loading')}</p>
</div>
</div>
@ -246,7 +246,7 @@ export default function NotificationsPage() {
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-6 transition-all cursor-pointer group ${
!notification.read ? 'bg-brand-turquoise/5/50' : ''
!notification.read ? 'bg-brand-blue/5/50' : ''
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
@ -261,7 +261,7 @@ export default function NotificationsPage() {
{notification.title}
</h3>
{!notification.read && (
<span className="flex items-center space-x-1 px-2 py-1 bg-brand-turquoise text-brand-navy text-xs font-medium rounded-full">
<span className="flex items-center space-x-1 px-2 py-1 bg-brand-navy text-white text-xs font-medium rounded-full">
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
<span>{t('new')}</span>
</span>
@ -312,7 +312,7 @@ export default function NotificationsPage() {
? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700'
: 'bg-brand-turquoise/10 text-brand-navy'
: 'bg-brand-blue/10 text-brand-navy'
}`}
>
{getPriorityLabel(notification.priority)}
@ -361,7 +361,7 @@ export default function NotificationsPage() {
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium text-brand-navy bg-white border border-border rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span>{t('pagination.previous')}</span>
@ -385,7 +385,7 @@ export default function NotificationsPage() {
onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
currentPage === pageNum
? 'bg-brand-turquoise text-brand-navy'
? 'bg-brand-navy text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
}`}
>
@ -397,7 +397,7 @@ export default function NotificationsPage() {
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium text-brand-navy bg-white border border-border rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span>{t('pagination.next')}</span>
<ChevronRight className="w-4 h-4" />

View File

@ -6,22 +6,23 @@ import { dashboardApi } from '@/lib/api';
import { Link, useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import {
AlertTriangle,
ArrowRight,
Clock,
Container as ContainerIcon,
Euro,
Package,
PackageCheck,
PackageX,
Plus,
Ship,
TrendingUp,
Weight,
} from 'lucide-react';
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
@ -36,7 +37,14 @@ import { EmptyState, Skeleton } from '@/components/ui/states';
import { StatCard } from '@/components/data/stat-card';
import { useSubscription } from '@/lib/context/subscription-context';
import ExportButton from '@/components/ExportButton';
import { CHART_COLORS, CHART_UI, chartAxisProps, chartTooltipStyle } from '@/lib/chart-colors';
import {
CHART_UI,
MARK,
SERIES,
STATUS,
chartAxisProps,
chartTooltipStyle,
} from '@/lib/chart-colors';
export default function DashboardPage() {
const router = useRouter();
@ -50,6 +58,28 @@ export default function DashboardPage() {
}
}, [subLoading, hasFeature, router]);
// Indicateurs de tete : ils portent une variation mensuelle, donc une
// tendance. Cet endpoint existait deja cote backend sans etre consomme.
const { data: kpis, isLoading: kpisLoading } = useQuery({
queryKey: ['dashboard', 'kpis'],
queryFn: () => dashboardApi.getKPIs(),
});
const { data: volume, isLoading: volumeLoading } = useQuery({
queryKey: ['dashboard', 'bookings-chart'],
queryFn: () => dashboardApi.getBookingsChart(),
});
const { data: lanes, isLoading: lanesLoading } = useQuery({
queryKey: ['dashboard', 'top-trade-lanes'],
queryFn: () => dashboardApi.getTopTradeLanes(),
});
const { data: alerts } = useQuery({
queryKey: ['dashboard', 'alerts'],
queryFn: () => dashboardApi.getAlerts(),
});
const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
queryKey: ['dashboard', 'csv-booking-kpis'],
queryFn: () => dashboardApi.getCsvBookingKPIs(),
@ -60,39 +90,61 @@ export default function DashboardPage() {
queryFn: () => dashboardApi.getTopCarriers(),
});
const numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
const intl = locale === 'fr' ? 'fr-FR' : 'en-US';
const numberFormat = new Intl.NumberFormat(intl);
const currencyFormat = new Intl.NumberFormat(intl, {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
});
const cbmFormat = new Intl.NumberFormat(intl, { maximumFractionDigits: 1 });
const totalBookings =
(csvKpis?.totalAccepted ?? 0) + (csvKpis?.totalRejected ?? 0) + (csvKpis?.totalPending ?? 0);
/**
* N'affiche une variation que lorsqu'elle compare quelque chose : deux
* periodes vides donnent 0 %, un badge vert qui ne veut rien dire.
*/
const trendOf = (value: number | undefined, change: number | undefined, positiveIsGood = true) =>
kpis && ((value ?? 0) !== 0 || (change ?? 0) !== 0)
? { value: change ?? 0, label: t('kpi.vsLastMonth'), positiveIsGood }
: undefined;
const statusDistribution = csvKpis
? [
{
name: t('charts.distribution.accepted'),
value: csvKpis.totalAccepted,
color: CHART_COLORS.success,
},
{
name: t('charts.distribution.rejected'),
value: csvKpis.totalRejected,
color: CHART_COLORS.danger,
},
{
name: t('charts.distribution.pending'),
value: csvKpis.totalPending,
color: CHART_COLORS.warning,
},
]
: [];
// 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' });
const formatMonth = (iso: string) => {
const [year, month] = iso.split('-').map(Number);
if (!year || !month) return iso;
return monthFormat.format(new Date(year, month - 1, 1));
};
const weightDataKey = t('charts.weightByCarrier.weight');
const volumeSeries = t('charts.volume.series');
const volumeData = (volume?.labels ?? []).map((label, i) => ({
label: formatMonth(label),
[volumeSeries]: volume?.data?.[i] ?? 0,
}));
const hasVolume = volumeData.some(d => (d[volumeSeries] as number) > 0);
const carrierWeightData = topCarriers
? topCarriers.slice(0, 5).map(c => ({
name: c.carrierName.length > 15 ? c.carrierName.slice(0, 15) + '…' : c.carrierName,
[weightDataKey]: Math.round(c.totalWeightKG),
}))
: [];
const laneSeries = t('charts.lanes.series');
const laneData = (lanes ?? []).slice(0, 5).map(lane => ({
route: lane.route,
[laneSeries]: lane.bookingCount,
}));
// Etats des demandes : trois parts d'un tout, avec le taux d'acceptation en
// chiffre de tete. Un anneau obligeait a comparer des angles ; une barre
// empilee legendee se lit d'un coup.
const accepted = csvKpis?.totalAccepted ?? 0;
const rejected = csvKpis?.totalRejected ?? 0;
const pending = csvKpis?.totalPending ?? 0;
const totalRequests = accepted + rejected + pending;
const statusParts = [
{ key: 'accepted', label: t('charts.distribution.accepted'), value: accepted, color: STATUS.good },
{ key: 'pending', label: t('charts.distribution.pending'), value: pending, color: STATUS.warning },
{ key: 'rejected', label: t('charts.distribution.rejected'), value: rejected, color: STATUS.critical },
];
const openAlerts = alerts ?? [];
return (
<>
@ -112,7 +164,7 @@ export default function DashboardPage() {
{
key: 'totalWeightKG',
label: t('export.totalWeight'),
format: v => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0',
format: v => v?.toLocaleString(intl) || '0',
},
{
key: 'totalVolumeCBM',
@ -142,40 +194,158 @@ export default function DashboardPage() {
/>
<div className="flex flex-col gap-6">
{/* Indicateurs primaires : les quatre chiffres qui repondent a « ou en
sommes-nous ». Alignes a gauche pour se comparer en colonne. */}
{/* Ce qui demande une action passe avant ce qui informe. */}
{openAlerts.length > 0 && <AlertsStrip alerts={openAlerts} />}
{/* Indicateurs de tete : chacun porte sa variation mensuelle. */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('performance.totalBookings')}
value={numberFormat.format(totalBookings)}
label={t('kpi.bookingsThisMonth')}
value={numberFormat.format(kpis?.bookingsThisMonth ?? 0)}
trend={trendOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
hint={t('kpi.vsLastMonth')}
icon={Package}
loading={csvKpisLoading}
loading={kpisLoading}
/>
<StatCard
label={t('kpi.accepted')}
value={numberFormat.format(csvKpis?.totalAccepted ?? 0)}
hint={t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth ?? 0 })}
icon={PackageCheck}
loading={csvKpisLoading}
label={t('kpi.volume')}
value={`${cbmFormat.format(kpis?.volumeCBM ?? 0)} CBM`}
trend={trendOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
hint={t('kpi.vsLastMonth')}
icon={ContainerIcon}
loading={kpisLoading}
/>
<StatCard
label={t('kpi.rejected')}
value={numberFormat.format(csvKpis?.totalRejected ?? 0)}
hint={t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth ?? 0 })}
icon={PackageX}
loading={csvKpisLoading}
label={t('kpi.revenue')}
value={currencyFormat.format(kpis?.estimatedRevenue ?? 0)}
trend={trendOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
hint={t('kpi.vsLastMonth')}
icon={Euro}
loading={kpisLoading}
/>
<StatCard
label={t('kpi.pending')}
value={numberFormat.format(csvKpis?.totalPending ?? 0)}
hint={t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
label={t('kpi.pendingConfirmations')}
value={numberFormat.format(kpis?.pendingConfirmations ?? 0)}
// 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')}
icon={Clock}
loading={csvKpisLoading}
loading={kpisLoading}
/>
</section>
{/* Indicateurs secondaires : volumetrie et performance, en bandeau
compact pour ne pas concurrencer la rangee primaire. */}
{/* Evolution a gauche (la question « ou va-t-on »), etat des demandes a
droite (la question « ou en est-on »). */}
<section className="grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle>{t('charts.volume.title')}</CardTitle>
<CardDescription>{t('charts.volume.description')}</CardDescription>
</CardHeader>
<CardContent>
{volumeLoading ? (
<Skeleton className="h-56 w-full" />
) : !hasVolume ? (
<EmptyState size="sm" icon={TrendingUp} title={t('charts.volume.empty')} />
) : (
<ResponsiveContainer width="100%" height={224}>
<AreaChart data={volumeData} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="volumeFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={SERIES.primary} stopOpacity={0.18} />
<stop offset="100%" stopColor={SERIES.primary} stopOpacity={0} />
</linearGradient>
</defs>
{/* Seules les horizontales aident a lire une hauteur. */}
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} vertical={false} />
<XAxis dataKey="label" {...chartAxisProps} />
<YAxis width={40} allowDecimals={false} {...chartAxisProps} />
<Tooltip
{...chartTooltipStyle}
cursor={{ stroke: CHART_UI.border, strokeWidth: 1 }}
/>
<Area
type="monotone"
dataKey={volumeSeries}
stroke={SERIES.primary}
strokeWidth={MARK.strokeWidth}
fill="url(#volumeFill)"
dot={false}
activeDot={{
r: MARK.activeDotRadius,
fill: SERIES.primary,
stroke: CHART_UI.surface,
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle>{t('charts.status.title')}</CardTitle>
<CardDescription>{t('charts.status.description')}</CardDescription>
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<Skeleton className="h-56 w-full" />
) : totalRequests === 0 ? (
<EmptyState size="sm" icon={Package} title={t('topCarriers.empty.title')} />
) : (
<div className="flex h-56 flex-col justify-center">
<p className="text-label uppercase text-neutral-500">
{t('charts.status.rateLabel')}
</p>
<p className="mt-1 font-heading text-4xl font-extrabold tabular-nums text-brand-navy">
{(csvKpis?.acceptanceRate ?? 0).toFixed(1)}&nbsp;%
</p>
{/* Barre empilee. L'ecart de 2 px entre segments evite que
deux etats se touchent et paraissent continus. */}
<div className="mt-5 flex h-2.5 gap-0.5 overflow-hidden rounded-full">
{statusParts
.filter(p => p.value > 0)
.map(part => (
<div
key={part.key}
className="h-full first:rounded-l-full last:rounded-r-full"
style={{
width: `${(part.value / totalRequests) * 100}%`,
backgroundColor: part.color,
}}
/>
))}
</div>
{/* Legende chiffree : la couleur ne porte jamais seule
l'information. */}
<ul className="mt-5 space-y-2.5">
{statusParts.map(part => (
<li key={part.key} className="flex items-center gap-2.5">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: part.color }}
/>
<span className="flex-1 text-body-sm text-neutral-600">{part.label}</span>
<span className="font-heading text-body-sm font-semibold tabular-nums text-brand-navy">
{numberFormat.format(part.value)}
</span>
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
</section>
{/* Volumetrie, en bandeau compact : utile mais sans tendance, donc sans
le poids visuel d'une tuile. */}
<section className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3">
<SecondaryStat
label={t('performance.acceptanceRate')}
@ -185,7 +355,9 @@ export default function DashboardPage() {
<SecondaryStat
label={t('kpi.totalWeight')}
value={
csvKpisLoading ? null : `${numberFormat.format(csvKpis?.totalWeightAcceptedKG ?? 0)} kg`
csvKpisLoading
? null
: `${numberFormat.format(csvKpis?.totalWeightAcceptedKG ?? 0)} kg`
}
icon={Weight}
/>
@ -200,152 +372,201 @@ export default function DashboardPage() {
<section className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('charts.distribution.title')}</CardTitle>
<CardDescription>{t('charts.distribution.description')}</CardDescription>
<CardHeader className="pb-2">
<CardTitle>{t('charts.lanes.title')}</CardTitle>
<CardDescription>{t('charts.lanes.description')}</CardDescription>
</CardHeader>
<CardContent>
{csvKpisLoading ? (
<Skeleton className="h-52 w-full" />
) : totalBookings === 0 ? (
<EmptyState size="sm" icon={Package} title={t('topCarriers.empty.title')} />
{lanesLoading ? (
<Skeleton className="h-56 w-full" />
) : laneData.length === 0 ? (
<EmptyState size="sm" icon={Ship} title={t('charts.lanes.empty')} />
) : (
<ResponsiveContainer width="100%" height={208}>
<PieChart>
<Pie
data={statusDistribution}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }: any) =>
`${name} ${((percent || 0) * 100).toFixed(0)}%`
}
outerRadius={72}
innerRadius={44}
paddingAngle={2}
dataKey="value"
>
{statusDistribution.map(entry => (
<Cell key={entry.name} fill={entry.color} stroke={CHART_UI.surface} />
))}
</Pie>
<Tooltip {...chartTooltipStyle} />
</PieChart>
// Barres horizontales : les libelles de route sont longs et se
// lisent a plat, sans rotation ni troncature.
<ResponsiveContainer width="100%" height={224}>
<BarChart
data={laneData}
layout="vertical"
margin={{ top: 4, right: 16, bottom: 0, left: 0 }}
barCategoryGap="28%"
>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} horizontal={false} />
<XAxis type="number" allowDecimals={false} {...chartAxisProps} />
<YAxis
type="category"
dataKey="route"
width={110}
{...chartAxisProps}
tick={{ fontSize: 11, fill: CHART_UI.axis }}
/>
<Tooltip {...chartTooltipStyle} cursor={{ fill: CHART_UI.grid }} />
<Bar
dataKey={laneSeries}
fill={SERIES.primary}
radius={[0, MARK.barRadius, MARK.barRadius, 0]}
maxBarSize={18}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('charts.weightByCarrier.title')}</CardTitle>
<CardDescription>{t('charts.weightByCarrier.description')}</CardDescription>
<CardHeader className="flex-row items-start justify-between gap-3 pb-2">
<div className="min-w-0">
<CardTitle>{t('topCarriers.title')}</CardTitle>
<CardDescription>{t('topCarriers.description')}</CardDescription>
</div>
<Button asChild variant="ghost" size="sm" className="shrink-0">
<Link href="/dashboard/bookings">
{t('topCarriers.viewAll')}
<ArrowRight />
</Link>
</Button>
</CardHeader>
<CardContent>
<CardContent className="p-0">
{carriersLoading ? (
<Skeleton className="h-52 w-full" />
) : carrierWeightData.length === 0 ? (
<EmptyState size="sm" icon={Weight} title={t('topCarriers.empty.title')} />
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-5 py-3">
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
) : topCarriers && topCarriers.length > 0 ? (
<ul className="divide-y divide-border">
{topCarriers.slice(0, 5).map((carrier, index) => (
<li
key={carrier.carrierName}
className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-neutral-50"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-neutral-100 text-body-xs font-semibold text-neutral-600">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-brand-navy">
{carrier.carrierName}
</p>
<p className="mt-0.5 truncate text-body-xs text-neutral-500">
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
{' · '}
{numberFormat.format(carrier.totalWeightKG)} kg
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="success">{carrier.acceptedBookings}</Badge>
{carrier.rejectedBookings > 0 && (
<Badge variant="danger">{carrier.rejectedBookings}</Badge>
)}
</div>
<div className="hidden w-24 shrink-0 text-right sm:block">
<p className="text-body-sm font-medium text-brand-navy">
{carrier.acceptanceRate.toFixed(0)} %
</p>
<p className="text-body-xs text-neutral-500">
${carrier.avgPriceUSD.toFixed(0)}
</p>
</div>
</li>
))}
</ul>
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart data={carrierWeightData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} vertical={false} />
<XAxis dataKey="name" interval={0} height={44} {...chartAxisProps} />
<YAxis width={48} {...chartAxisProps} />
<Tooltip {...chartTooltipStyle} cursor={{ fill: CHART_UI.grid }} />
<Bar dataKey={weightDataKey} fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<EmptyState
icon={Package}
title={t('topCarriers.empty.title')}
description={t('topCarriers.empty.description')}
action={
<Button asChild size="sm">
<Link href="/dashboard/search-advanced">
<Plus />
{t('topCarriers.empty.cta')}
</Link>
</Button>
}
/>
)}
</CardContent>
</Card>
</section>
<Card>
<CardHeader className="flex-row items-start justify-between gap-3 pb-3">
<div className="min-w-0">
<CardTitle>{t('topCarriers.title')}</CardTitle>
<CardDescription>{t('topCarriers.description')}</CardDescription>
</div>
<Button asChild variant="ghost" size="sm" className="shrink-0">
<Link href="/dashboard/bookings">
{t('topCarriers.viewAll')}
<ArrowRight />
</Link>
</Button>
</CardHeader>
<CardContent className="p-0">
{carriersLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-5 py-3">
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
) : topCarriers && topCarriers.length > 0 ? (
<ul className="divide-y divide-border">
{topCarriers.slice(0, 5).map((carrier, index) => (
<li
key={carrier.carrierName}
className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-neutral-50"
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-neutral-100 text-body-xs font-semibold text-neutral-600">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-brand-navy">
{carrier.carrierName}
</p>
<p className="mt-0.5 truncate text-body-xs text-neutral-500">
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
{' · '}
{numberFormat.format(carrier.totalWeightKG)} kg
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="success">{carrier.acceptedBookings}</Badge>
{carrier.rejectedBookings > 0 && (
<Badge variant="danger">{carrier.rejectedBookings}</Badge>
)}
</div>
<div className="hidden w-24 shrink-0 text-right sm:block">
<p className="text-body-sm font-medium text-brand-navy">
{carrier.acceptanceRate.toFixed(0)} %
</p>
<p className="text-body-xs text-neutral-500">
${carrier.avgPriceUSD.toFixed(0)}
</p>
</div>
</li>
))}
</ul>
) : (
<EmptyState
icon={Package}
title={t('topCarriers.empty.title')}
description={t('topCarriers.empty.description')}
action={
<Button asChild size="sm">
<Link href="/dashboard/search-advanced">
<Plus />
{t('topCarriers.empty.cta')}
</Link>
</Button>
}
/>
)}
</CardContent>
</Card>
</div>
</>
);
}
/** Bandeau des points a traiter, en tete de tableau de bord. */
function AlertsStrip({
alerts,
}: {
alerts: Array<{
id: string;
severity: 'critical' | 'high' | 'medium' | 'low';
titleKey: string;
messageKey: string;
messageParams?: Record<string, string | number>;
bookingId?: string;
bookingNumber?: string;
}>;
}) {
const t = useTranslations('dashboard.home.alerts');
// Le plus urgent d'abord : trois alertes suffisent en tete de page, le reste
// se consulte dans les listes concernees.
const order = { critical: 0, high: 1, medium: 2, low: 3 } as const;
const sorted = [...alerts].sort((a, b) => order[a.severity] - order[b.severity]).slice(0, 3);
const tone: Record<string, string> = {
critical: 'text-destructive',
high: 'text-destructive',
medium: 'text-amber-600',
low: 'text-neutral-500',
};
return (
<section className="overflow-hidden rounded-lg border border-border bg-white">
<div className="flex items-center gap-2 border-b border-border px-5 py-3">
<AlertTriangle className="size-4 shrink-0 text-amber-500" aria-hidden="true" />
<p className="text-body-sm font-semibold text-brand-navy">{t('title')}</p>
<p className="truncate text-body-xs text-neutral-500">
{t('description', { count: alerts.length })}
</p>
</div>
<ul className="divide-y divide-border">
{sorted.map(alert => (
<li key={alert.id} className="flex items-center gap-3 px-5 py-3">
{/* Severite : jamais la couleur seule — un libelle l'accompagne. */}
<span
className={`w-16 shrink-0 text-label uppercase ${tone[alert.severity] ?? 'text-neutral-500'}`}
>
{t(`severity.${alert.severity}` as any)}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-brand-navy">
{t(`items.${alert.titleKey}` as any)}
</p>
<p className="truncate text-body-xs text-neutral-500">
{t(`items.${alert.messageKey}` as any, alert.messageParams ?? {})}
</p>
</div>
{alert.bookingId && (
<Button asChild variant="outline" size="sm" className="shrink-0">
<Link href={`/dashboard/bookings/${alert.bookingId}`}>
{alert.bookingNumber ?? <ArrowRight />}
</Link>
</Button>
)}
</li>
))}
</ul>
</section>
);
}
/** Indicateur secondaire, en bandeau, sans le poids visuel d'une carte. */
function SecondaryStat({
label,

View File

@ -142,7 +142,7 @@ export default function ProfilePage() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto mb-4"></div>
<p className="text-neutral-600">{t('loading')}</p>
</div>
</div>
@ -156,7 +156,7 @@ export default function ProfilePage() {
<p className="text-red-600 mb-4">{t('loadError')}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
className="px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
>
{t('retry')}
</button>
@ -204,7 +204,7 @@ export default function ProfilePage() {
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-brand-turquoise rounded-full flex items-center justify-center text-white text-2xl font-bold">
<div className="w-16 h-16 bg-brand-blue rounded-full flex items-center justify-center text-white text-2xl font-bold">
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</div>
@ -214,7 +214,7 @@ export default function ProfilePage() {
</h2>
<p className="text-neutral-600">{user?.email}</p>
<div className="flex items-center space-x-2 mt-2">
<span className="px-3 py-1 text-xs font-medium text-brand-navy bg-brand-turquoise/10 rounded-full">
<span className="px-3 py-1 text-xs font-medium text-brand-navy bg-brand-blue/10 rounded-full">
{user?.role}
</span>
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
@ -232,7 +232,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'profile'
? 'border-brand-turquoise text-brand-navy'
? 'border-brand-blue text-brand-navy'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
@ -242,7 +242,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('password')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'password'
? 'border-brand-turquoise text-brand-navy'
? 'border-brand-blue text-brand-navy'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
@ -314,7 +314,7 @@ export default function ProfilePage() {
<button
type="submit"
disabled={updateProfileMutation.isPending}
className="px-6 py-2 bg-brand-turquoise text-brand-navy font-medium rounded-lg hover:bg-brand-turquoise/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-6 py-2 bg-brand-navy text-white font-medium rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updateProfileMutation.isPending
? t('profileForm.saving')
@ -437,7 +437,7 @@ export default function ProfilePage() {
<button
type="submit"
disabled={updatePasswordMutation.isPending}
className="px-6 py-2 bg-brand-turquoise text-brand-navy font-medium rounded-lg hover:bg-brand-turquoise/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-6 py-2 bg-brand-navy text-white font-medium rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{updatePasswordMutation.isPending
? t('passwordForm.submitting')

View File

@ -430,13 +430,13 @@ export default function AdvancedSearchPage() {
onClick={() => selectDirection(value)}
className={`text-left p-6 rounded-xl border-2 transition-colors ${
isSelected
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-border hover:border-brand-turquoise hover:bg-neutral-50'
? 'border-brand-blue bg-brand-blue/5'
: 'border-border hover:border-brand-blue hover:bg-neutral-50'
}`}
>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${
isSelected ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-100 text-neutral-600'
isSelected ? 'bg-brand-navy text-white' : 'bg-neutral-100 text-neutral-600'
}`}
>
<Icon className="h-6 w-6" />
@ -461,7 +461,7 @@ export default function AdvancedSearchPage() {
<button
type="button"
onClick={() => setCurrentStep(0)}
className="text-xs font-medium px-3 py-1 rounded-full bg-brand-turquoise/10 text-brand-navy hover:bg-brand-turquoise/20"
className="text-xs font-medium px-3 py-1 rounded-full bg-brand-blue/10 text-brand-navy hover:bg-brand-blue/20"
>
{t(`step0.${direction.toLowerCase() as 'export' | 'import'}.title`)} · {t('step0.change')}
</button>
@ -493,7 +493,7 @@ export default function AdvancedSearchPage() {
onFocus={() => setShowOriginDropdown(true)}
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
placeholder={t('step1.originPlaceholder')}
className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-turquoise ${
className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-blue ${
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-neutral-300'
}`}
/>
@ -517,7 +517,7 @@ export default function AdvancedSearchPage() {
setDestinationSearch('');
setShowOriginDropdown(false);
}}
className="w-full text-left px-4 py-3 hover:bg-brand-turquoise/5 border-b border-border last:border-b-0"
className="w-full text-left px-4 py-3 hover:bg-brand-blue/5 border-b border-border last:border-b-0"
>
<div className="font-medium text-brand-navy">{port.name}</div>
<div className="text-neutral-500 text-xs mt-1">
@ -574,7 +574,7 @@ export default function AdvancedSearchPage() {
? t('step1.destinationPlaceholder')
: t('step1.destinationDisabled')
}
className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-turquoise ${
className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-blue ${
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-neutral-300'
} ${!searchForm.origin ? 'bg-neutral-100 cursor-not-allowed' : ''}`}
/>
@ -604,7 +604,7 @@ export default function AdvancedSearchPage() {
setSelectedDestinationPort(port);
setShowDestinationDropdown(false);
}}
className="w-full text-left px-4 py-3 hover:bg-brand-turquoise/5 border-b border-border last:border-b-0"
className="w-full text-left px-4 py-3 hover:bg-brand-blue/5 border-b border-border last:border-b-0"
>
<div className="font-medium text-brand-navy">{port.name}</div>
<div className="text-neutral-500 text-xs mt-1">
@ -674,7 +674,7 @@ export default function AdvancedSearchPage() {
<button
type="button"
onClick={addPackage}
className="px-4 py-2 text-sm font-medium text-brand-navy bg-brand-turquoise/5 rounded-md hover:bg-brand-turquoise/10"
className="px-6 py-2 text-sm font-medium text-brand-navy bg-white border border-border rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{t('step2.addPackage')}
</button>
@ -794,7 +794,7 @@ export default function AdvancedSearchPage() {
</div>
))}
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-md p-4">
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-md p-4">
<h3 className="text-sm font-medium text-brand-navy mb-2">{t('step2.summary.title')}</h3>
<div className="text-sm text-brand-navy space-y-1">
<div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
@ -1007,14 +1007,14 @@ export default function AdvancedSearchPage() {
<div key={step} className="flex items-center">
<div
className={`flex items-center justify-center w-10 h-10 rounded-full ${
currentStep >= step ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-200 text-neutral-600'
currentStep >= step ? 'bg-brand-navy text-white' : 'bg-neutral-200 text-neutral-600'
}`}
>
{index + 1}
</div>
{index < wizardSteps.length - 1 && (
<div
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-brand-turquoise' : 'bg-neutral-200'}`}
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-brand-blue' : 'bg-neutral-200'}`}
/>
)}
</div>
@ -1046,7 +1046,7 @@ export default function AdvancedSearchPage() {
? !direction
: !searchForm.origin || !searchForm.destination
}
className="px-6 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-md hover:bg-brand-turquoise/90 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-6 py-2 text-sm font-medium text-white bg-brand-navy rounded-md hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('navigation.next')}
</button>

View File

@ -178,10 +178,10 @@ export default function SearchResultsPage() {
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-brand-turquoise mb-4"></div>
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-brand-blue mb-4"></div>
<p className="text-xl text-neutral-700 font-medium">{t('loadingTitle')}</p>
<p className="text-neutral-500 mt-2">
{origin} → {destination}
@ -194,7 +194,7 @@ export default function SearchResultsPage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center">
@ -220,11 +220,11 @@ export default function SearchResultsPage() {
if (results.length === 0) {
return (
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
className="mb-6 flex items-center text-brand-navy hover:text-brand-blue font-medium"
>
{t('backToSearch')}
</button>
@ -247,7 +247,7 @@ export default function SearchResultsPage() {
</div>
<button
onClick={() => router.back()}
className="px-6 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors"
className="px-6 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors"
>
{t('modifySearch')}
</button>
@ -274,10 +274,10 @@ export default function SearchResultsPage() {
type: t('options.standard'),
option: bestOptions?.standard,
colors: {
border: 'border-brand-turquoise/30',
bg: 'bg-brand-turquoise/5',
border: 'border-brand-blue/30',
bg: 'bg-brand-blue/5',
text: 'text-brand-navy',
button: 'bg-brand-turquoise hover:bg-brand-turquoise/90',
button: 'bg-brand-navy hover:bg-brand-navy/90',
},
icon: <Scale className="h-10 w-10 text-brand-navy" />,
badge: t('options.badgeBalanced'),
@ -297,13 +297,13 @@ export default function SearchResultsPage() {
];
return (
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-blue/5 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="mb-4 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
className="mb-4 flex items-center text-brand-navy hover:text-brand-blue font-medium"
>
{t('backToSearch')}
</button>
@ -333,7 +333,7 @@ export default function SearchResultsPage() {
</div>
{editBookingId && (
<div className="mb-6 rounded-lg border border-brand-turquoise/30 bg-brand-turquoise/5 px-4 py-3 text-sm text-brand-navy">
<div className="mb-6 rounded-lg border border-brand-blue/30 bg-brand-blue/5 px-4 py-3 text-sm text-brand-navy">
{t('editMode')}
</div>
)}
@ -501,7 +501,7 @@ export default function SearchResultsPage() {
<button
onClick={() => handleSelect(result, `all-${index}`)}
disabled={submittingId === `all-${index}`}
className="px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors disabled:opacity-60"
className="px-6 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-colors disabled:opacity-60"
>
{submittingId === `all-${index}`
? '...'

View File

@ -32,7 +32,7 @@ function CopyButton({ text }: { text: string }) {
return (
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border hover:bg-neutral-50 transition-colors"
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-brand-navy rounded-lg border border-border hover:bg-neutral-50 transition-colors"
>
{copied ? (
<>
@ -141,7 +141,7 @@ function CreateKeyModal({
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-turquoise/10 flex items-center justify-center">
<div className="w-10 h-10 rounded-xl bg-brand-blue/10 flex items-center justify-center">
<Key className="w-5 h-5 text-brand-navy" />
</div>
<h2 className="text-lg font-semibold text-brand-navy">{t('title')}</h2>
@ -193,7 +193,7 @@ function CreateKeyModal({
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 border border-border text-neutral-700 text-sm font-medium rounded-xl hover:bg-neutral-50 transition-colors"
className="flex-1 py-2.5 border border-border text-brand-navy text-sm font-medium rounded-lg hover:bg-neutral-50 transition-colors"
>
{t('cancel')}
</button>
@ -240,7 +240,7 @@ function RevokeConfirmModal({
<div className="px-6 pb-6 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 border border-border text-neutral-700 text-sm font-medium rounded-xl hover:bg-neutral-50 transition-colors"
className="flex-1 py-2.5 border border-border text-brand-navy text-sm font-medium rounded-lg hover:bg-neutral-50 transition-colors"
>
{t('cancel')}
</button>
@ -371,14 +371,14 @@ export default function ApiKeysPage() {
}
/>
<div className="mb-6 p-4 bg-brand-turquoise/5 border border-brand-turquoise/20 rounded-xl flex gap-3">
<div className="mb-6 p-4 bg-brand-blue/5 border border-brand-blue/20 rounded-xl flex gap-3">
<ShieldCheck className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5" />
<div className="text-sm text-brand-navy">
<p className="font-medium mb-0.5">{t('infoTitle')}</p>
<p>
{t.rich('infoBody', {
code: () => (
<code className="px-1.5 py-0.5 bg-brand-turquoise/10 rounded text-brand-navy font-mono text-xs">
<code className="px-1.5 py-0.5 bg-brand-blue/10 rounded text-brand-navy font-mono text-xs">
X-API-Key: xped_live_...
</code>
),

View File

@ -149,7 +149,7 @@ export default function OrganizationSettingsPage() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-turquoise mb-4"></div>
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-blue mb-4"></div>
<p className="text-neutral-600">{t('loading')}</p>
</div>
</div>
@ -288,7 +288,7 @@ export default function OrganizationSettingsPage() {
)}
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4">
<div className="mb-6 bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4">
<div className="flex items-center">
<svg
className="w-5 h-5 text-brand-navy mr-3"
@ -317,7 +317,7 @@ export default function OrganizationSettingsPage() {
onClick={() => setActiveTab(tab.id)}
className={`flex-shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-brand-turquoise text-brand-navy'
? 'border-brand-blue text-brand-navy'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
@ -498,7 +498,7 @@ export default function OrganizationSettingsPage() {
type="button"
onClick={handleCancel}
disabled={isSaving}
className="px-6 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
className="px-6 py-2 text-sm font-medium text-brand-navy bg-white border border-border rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{t('actions.cancel')}
</button>
@ -506,7 +506,7 @@ export default function OrganizationSettingsPage() {
type="button"
onClick={handleSave}
disabled={isSaving || !formData.name || !formData.address_street}
className="px-6 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-lg hover:bg-brand-turquoise/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center"
className="px-6 py-2 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center"
>
{isSaving ? (
<>

View File

@ -19,7 +19,7 @@ export default function SubscriptionPage() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-turquoise mb-4"></div>
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-blue mb-4"></div>
<p className="text-neutral-600">{t('loading')}</p>
</div>
</div>

View File

@ -50,7 +50,7 @@ function Pagination({
onClick={() => onPage(p)}
className={`px-3 py-1 rounded text-sm border ${
p === page
? 'bg-brand-turquoise text-brand-navy border-brand-turquoise'
? 'bg-brand-navy text-white border-brand-blue'
: 'border-neutral-300 hover:bg-neutral-50'
}`}
>
@ -199,7 +199,7 @@ export default function UsersManagementPage() {
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue"></div>
</div>
);
}
@ -249,7 +249,7 @@ export default function UsersManagementPage() {
const getRoleBadgeColor = (role: string) => {
const colors: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800',
MANAGER: 'bg-brand-turquoise/10 text-brand-navy',
MANAGER: 'bg-brand-blue/10 text-brand-navy',
USER: 'bg-green-100 text-green-800',
VIEWER: 'bg-neutral-100 text-neutral-800',
};
@ -304,10 +304,10 @@ export default function UsersManagementPage() {
licenseStatus.canInvite &&
licenseStatus.availableLicenses <= 2 &&
licenseStatus.maxLicenses !== -1 && (
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4">
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="h-5 w-5 text-brand-turquoise mr-2" fill="currentColor" viewBox="0 0 20 20">
<svg className="h-5 w-5 text-brand-blue mr-2" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
@ -324,7 +324,7 @@ export default function UsersManagementPage() {
</div>
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-brand-navy hover:text-brand-turquoise"
className="text-sm font-medium text-brand-navy hover:text-brand-blue"
>
{t('license.manageLink')}
</Link>
@ -365,7 +365,7 @@ export default function UsersManagementPage() {
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-brand-turquoise hover:bg-brand-turquoise/90"
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-brand-navy hover:bg-brand-navy/90"
>
<span className="mr-1.5">+</span>
<span className="hidden sm:inline">{t('actions.invite')}</span>
@ -408,7 +408,7 @@ export default function UsersManagementPage() {
</div>
{isLoading ? (
<div className="px-6 py-12 text-center text-neutral-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto mb-4"></div>
{t('loading')}
</div>
) : pagedUsers.length > 0 ? (
@ -442,7 +442,7 @@ export default function UsersManagementPage() {
<tr key={user.id} className="hover:bg-neutral-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-brand-turquoise rounded-full flex items-center justify-center text-white font-semibold">
<div className="flex-shrink-0 h-10 w-10 bg-brand-blue rounded-full flex items-center justify-center text-white font-semibold">
{user.firstName[0]}
{user.lastName[0]}
</div>
@ -544,7 +544,7 @@ export default function UsersManagementPage() {
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-turquoise hover:bg-brand-turquoise/90"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-brand-navy hover:bg-brand-navy/90"
>
<span className="mr-2">+</span>
{t('actions.invite')}
@ -693,7 +693,7 @@ export default function UsersManagementPage() {
setMenuPosition(null);
}}
disabled={toggleActiveMutation.isPending}
className="w-full px-4 py-3 text-left hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-border"
className="w-full px-4 py-3 text-left text-brand-navy hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-border"
>
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
<>
@ -802,7 +802,7 @@ export default function UsersManagementPage() {
required
value={inviteForm.firstName}
onChange={e => setInviteForm({ ...inviteForm, firstName: e.target.value })}
className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-turquoise focus:ring-ring sm:text-sm px-3 py-2 border"
className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-blue focus:ring-ring sm:text-sm px-3 py-2 border"
/>
</div>
<div>
@ -814,7 +814,7 @@ export default function UsersManagementPage() {
required
value={inviteForm.lastName}
onChange={e => setInviteForm({ ...inviteForm, lastName: e.target.value })}
className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-turquoise focus:ring-ring sm:text-sm px-3 py-2 border"
className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-blue focus:ring-ring sm:text-sm px-3 py-2 border"
/>
</div>
</div>
@ -827,7 +827,7 @@ export default function UsersManagementPage() {
required
value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-turquoise focus:ring-ring sm:text-sm px-3 py-2 border"
className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-blue focus:ring-ring sm:text-sm px-3 py-2 border"
/>
</div>
<div>
@ -837,7 +837,7 @@ export default function UsersManagementPage() {
<select
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-turquoise focus:ring-ring sm:text-sm px-3 py-2 border"
className="mt-1 block w-full rounded-md border-neutral-300 shadow-sm focus:border-brand-blue focus:ring-ring sm:text-sm px-3 py-2 border"
>
<option value="USER">{t('modal.roles.USER')}</option>
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
@ -848,7 +848,7 @@ export default function UsersManagementPage() {
<button
type="submit"
disabled={inviteMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-turquoise text-base font-medium text-white hover:bg-brand-turquoise/90 sm:col-start-2 sm:text-sm disabled:bg-neutral-300"
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-brand-navy text-base font-medium text-white hover:bg-brand-navy/90 sm:col-start-2 sm:text-sm disabled:bg-neutral-300"
>
{inviteMutation.isPending ? t('modal.submitting') : t('modal.submit')}
</button>

View File

@ -263,7 +263,7 @@ export default function TrackTracePage() {
'flex min-h-[44px] items-center gap-2 rounded-md border p-2 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active
? 'border-brand-turquoise bg-brand-turquoise/5'
? 'border-brand-blue bg-brand-blue/5'
: 'border-border hover:bg-neutral-50'
)}
>
@ -340,7 +340,7 @@ export default function TrackTracePage() {
<Card className={cn('overflow-hidden', isMapFullscreen && 'h-full rounded-none')}>
<div className="flex items-center justify-between gap-3 bg-brand-navy px-4 py-3 text-white">
<div className="flex min-w-0 items-center gap-2">
<Anchor className="size-4 shrink-0 text-brand-turquoise" aria-hidden="true" />
<Anchor className="size-4 shrink-0 text-brand-blue" aria-hidden="true" />
<div className="min-w-0">
<p className="truncate font-heading text-body-sm font-semibold">
{t('map.title')}
@ -387,7 +387,7 @@ export default function TrackTracePage() {
role="status"
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-neutral-50"
>
<Ship className="size-8 animate-pulse text-brand-turquoise" aria-hidden="true" />
<Ship className="size-8 animate-pulse text-brand-blue" aria-hidden="true" />
<p className="text-body-sm font-medium text-brand-navy">{t('map.loading')}</p>
<p className="text-body-xs text-neutral-500">{t('map.connecting')}</p>
</div>
@ -432,7 +432,7 @@ export default function TrackTracePage() {
href="https://www.marinetraffic.com"
target="_blank"
rel="noopener noreferrer"
className="flex shrink-0 items-center gap-1 rounded-md text-body-xs font-medium text-brand-turquoise underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex shrink-0 items-center gap-1 rounded-md text-body-xs font-medium text-brand-blue underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('map.openOnSite')}
<ExternalLink className="size-3" aria-hidden="true" />

View File

@ -99,7 +99,7 @@ export default async function AssurancePage() {
</CardContent>
</Card>
<Card className="mt-4 bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="mt-4 bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-2">{t('assurance.valueTitle')}</h3>
<p className="font-mono text-brand-navy bg-white p-3 rounded border text-sm">

View File

@ -82,12 +82,12 @@ export default async function ConteneursPage() {
</div>
</div>
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="mt-8 bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-4">{t('conteneurs.selectionTitle')}</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-brand-turquoise/30">
<tr className="border-b border-brand-blue/30">
<th className="text-left py-2 font-medium text-brand-navy">
{t('conteneurs.colCondition')}
</th>
@ -98,7 +98,7 @@ export default async function ConteneursPage() {
</thead>
<tbody>
{selectionGuide.map((row, i) => (
<tr key={i} className="border-b border-brand-turquoise/20 last:border-0">
<tr key={i} className="border-b border-brand-blue/20 last:border-0">
<td className="py-2 text-brand-navy">{row.condition}</td>
<td className="py-2 font-medium text-brand-navy">{row.recommendation}</td>
</tr>

View File

@ -45,7 +45,7 @@ export default async function DocumentsTransportPage() {
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-brand-navy">{doc.name}</h4>
<span className="px-2 py-0.5 text-xs bg-brand-turquoise/10 text-brand-navy rounded">
<span className="px-2 py-0.5 text-xs bg-brand-blue/10 text-brand-navy rounded">
{doc.type}
</span>
</div>
@ -84,14 +84,14 @@ export default async function DocumentsTransportPage() {
</div>
</div>
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="mt-8 bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-4">
{t('documentsTransport.blFocusTitle')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{blFunctions.map(fn => (
<div key={fn.title} className="bg-white p-4 rounded-lg border border-brand-turquoise/30">
<div key={fn.title} className="bg-white p-4 rounded-lg border border-brand-blue/30">
<h4 className="font-medium text-brand-navy mb-1">{fn.title}</h4>
<p className="text-sm text-neutral-600">{fn.description}</p>
</div>

View File

@ -96,7 +96,7 @@ export default async function ImdgPage() {
</div>
</div>
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="mt-8 bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-2">{t('imdg.labelingTitle')}</h3>
<p className="text-sm text-brand-navy">{t('imdg.labelingContent')}</p>

View File

@ -24,7 +24,7 @@ export default async function IncotermsPage() {
const categoryColors = [
'bg-green-100 text-green-800',
'bg-red-100 text-red-800',
'bg-brand-turquoise/10 text-brand-navy',
'bg-brand-blue/10 text-brand-navy',
];
return (
@ -39,13 +39,13 @@ export default async function IncotermsPage() {
<p className="mt-3 text-neutral-600 max-w-3xl">{t('incoterms.description')}</p>
</div>
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-3">{t('incoterms.keyPointsTitle')}</h3>
<ul className="space-y-2">
{keyPoints.map((point, i) => (
<li key={i} className="flex items-start gap-2 text-brand-navy">
<span className="mt-1 w-2 h-2 bg-brand-turquoise rounded-full flex-shrink-0" />
<span className="mt-1 w-2 h-2 bg-brand-blue rounded-full flex-shrink-0" />
<span className="text-sm">{point}</span>
</li>
))}

View File

@ -32,7 +32,7 @@ export default async function LclVsFclPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="text-lg font-bold text-brand-navy mb-2">{t('lclVsFcl.lclTitle')}</h3>
<p className="text-sm text-brand-navy">{t('lclVsFcl.lclDescription')}</p>
@ -75,7 +75,7 @@ export default async function LclVsFclPage() {
<div className="space-y-3">
{lclProcess.map(step => (
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border">
<div className="flex-shrink-0 w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center text-brand-navy font-bold text-sm">
<div className="flex-shrink-0 w-8 h-8 bg-brand-blue/10 rounded-full flex items-center justify-center text-brand-navy font-bold text-sm">
{step.step}
</div>
<div>
@ -88,13 +88,13 @@ export default async function LclVsFclPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-3">{t('lclVsFcl.chooseLclTitle')}</h3>
<ul className="space-y-2">
{chooseLcl.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-brand-navy">
<span className="text-brand-turquoise mt-0.5">✓</span> {item}
<span className="text-brand-blue mt-0.5">✓</span> {item}
</li>
))}
</ul>

View File

@ -95,7 +95,7 @@ export default async function LettreCreditPage() {
</CardContent>
</Card>
<Card className="mt-4 bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="mt-4 bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-2">{t('lettreCredit.ucp600Title')}</h3>
<p className="text-sm text-brand-navy">{t('lettreCredit.ucp600Content')}</p>

View File

@ -115,14 +115,14 @@ export default async function WikiPage() {
const IconComponent = topic.icon;
return (
<Link key={topic.href} href={topic.href} className="block group">
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-brand-turquoise bg-white">
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-brand-blue bg-white">
<CardHeader>
<div className="flex items-start justify-between">
<div className="h-10 w-10 rounded-lg bg-brand-turquoise/5 flex items-center justify-center">
<div className="h-10 w-10 rounded-lg bg-brand-blue/5 flex items-center justify-center">
<IconComponent className="h-5 w-5 text-brand-navy" />
</div>
</div>
<CardTitle className="mt-3 transition-colors group-hover:text-brand-turquoise">
<CardTitle className="mt-3 transition-colors group-hover:text-brand-blue">
{t(`topics.${topic.key}.title` as any)}
</CardTitle>
<CardDescription className="text-neutral-600">
@ -134,7 +134,7 @@ export default async function WikiPage() {
{topic.tags.map(tag => (
<span
key={tag}
className="px-2 py-1 text-xs font-medium bg-brand-turquoise/5 text-brand-navy rounded-full"
className="px-2 py-1 text-xs font-medium bg-brand-blue/5 text-brand-navy rounded-full"
>
{tag}
</span>
@ -148,7 +148,7 @@ export default async function WikiPage() {
</div>
{/* Footer info */}
<div className="mt-8 p-4 bg-brand-turquoise/5 rounded-lg">
<div className="mt-8 p-4 bg-brand-blue/5 rounded-lg">
<p className="text-sm text-brand-navy">
<span className="font-semibold">{t('needHelp')} </span>
{t('helpText')}

View File

@ -63,7 +63,7 @@ export default async function PortsRoutesPage() {
{route.majorPorts.map(port => (
<span
key={port}
className="px-2 py-0.5 bg-brand-turquoise/5 text-brand-navy text-xs rounded"
className="px-2 py-0.5 bg-brand-blue/5 text-brand-navy text-xs rounded"
>
{port}
</span>
@ -79,7 +79,7 @@ export default async function PortsRoutesPage() {
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('portsRoutes.passagesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{passages.map(p => (
<Card key={p.name} className="bg-white border-brand-turquoise/30">
<Card key={p.name} className="bg-white border-brand-blue/30">
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-2">
<div>
@ -88,7 +88,7 @@ export default async function PortsRoutesPage() {
{p.location} — {t('portsRoutes.colLength')}: {p.length}
</p>
</div>
<span className="px-2 py-1 bg-brand-turquoise/10 text-brand-navy text-xs rounded font-medium whitespace-nowrap">
<span className="px-2 py-1 bg-brand-blue/10 text-brand-navy text-xs rounded font-medium whitespace-nowrap">
{p.keyStat}
</span>
</div>
@ -126,7 +126,7 @@ export default async function PortsRoutesPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-2">{t('portsRoutes.hubTitle')}</h3>
<p className="text-sm text-brand-navy">{t('portsRoutes.hubDescription')}</p>

View File

@ -47,7 +47,7 @@ export default async function TransitTimePage() {
<p className="mt-3 text-neutral-600 max-w-3xl">{t('transitTime.description')}</p>
</div>
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-3">{t('transitTime.keyTermsTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@ -67,11 +67,11 @@ export default async function TransitTimePage() {
{timeline.map((item, index) => (
<Card
key={index}
className={`bg-white ${index === 5 || index === 7 ? 'border-brand-turquoise' : ''}`}
className={`bg-white ${index === 5 || index === 7 ? 'border-brand-blue' : ''}`}
>
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center text-brand-navy font-bold text-sm">
<div className="flex-shrink-0 w-8 h-8 bg-brand-blue/10 rounded-full flex items-center justify-center text-brand-navy font-bold text-sm">
{index + 1}
</div>
<div className="flex-1">

View File

@ -39,7 +39,7 @@ export default async function VGMPage() {
<p className="mt-3 text-neutral-600 max-w-3xl">{t('vgm.description')}</p>
</div>
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<Card className="bg-brand-blue/5 border-brand-blue/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-3">{t('vgm.whyTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-brand-navy">
@ -58,7 +58,7 @@ export default async function VGMPage() {
<Card className="bg-white">
<CardContent className="pt-6">
<div className="bg-neutral-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg">
<span className="bg-brand-turquoise/10 text-brand-navy px-2 py-1 rounded">
<span className="bg-brand-blue/10 text-brand-navy px-2 py-1 rounded">
{t('vgm.formula')}
</span>
</div>

View File

@ -180,7 +180,7 @@ export default function ForgotPasswordPage() {
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
<div className="space-y-6">
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
@ -205,7 +205,7 @@ export default function ForgotPasswordPage() {
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"

View File

@ -377,7 +377,7 @@ function LoginPageContent() {
<div className="space-y-6">
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
@ -403,7 +403,7 @@ function LoginPageContent() {
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
@ -427,7 +427,7 @@ function LoginPageContent() {
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
@ -456,13 +456,13 @@ function LoginPageContent() {
className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700"
>
<div>
<div className="text-display-sm text-brand-turquoise">
<div className="text-display-sm text-brand-blue">
<AnimatedCounter end={50} suffix="+" isActive={statsActive} />
</div>
<div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.carriers')}</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">
<div className="text-display-sm text-brand-blue">
<AnimatedCounter end={10} suffix="k+" isActive={statsActive} />
</div>
<div className="text-body-sm text-neutral-300 mt-1">
@ -470,7 +470,7 @@ function LoginPageContent() {
</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">
<div className="text-display-sm text-brand-blue">
<AnimatedCounter end={99.5} decimals={1} suffix="%" isActive={statsActive} />
</div>
<div className="text-body-sm text-neutral-300 mt-1">

File diff suppressed because it is too large Load Diff

View File

@ -215,7 +215,7 @@ function RegisterPageContent() {
<p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
<div className="space-y-6">
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
@ -238,7 +238,7 @@ function RegisterPageContent() {
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
@ -263,7 +263,7 @@ function RegisterPageContent() {
</div>
</div>
<div className="flex items-start">
<div className="flex-shrink-0 w-12 h-12 bg-brand-turquoise rounded-lg flex items-center justify-center">
<div className="flex-shrink-0 w-12 h-12 bg-brand-blue rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-white"
fill="none"
@ -291,7 +291,7 @@ function RegisterPageContent() {
className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700"
>
<div>
<div className="text-display-sm text-brand-turquoise">
<div className="text-display-sm text-brand-blue">
<AnimatedCounter end={2} suffix="k+" isActive={statsActive} />
</div>
<div className="text-body-sm text-neutral-300 mt-1">
@ -299,7 +299,7 @@ function RegisterPageContent() {
</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">
<div className="text-display-sm text-brand-blue">
<AnimatedCounter end={150} suffix="+" isActive={statsActive} />
</div>
<div className="text-body-sm text-neutral-300 mt-1">
@ -307,7 +307,7 @@ function RegisterPageContent() {
</div>
</div>
<div>
<div className="text-display-sm text-brand-turquoise">
<div className="text-display-sm text-brand-blue">
<AnimatedCounter end={24} suffix="/7" isActive={statsActive} />
</div>
<div className="text-body-sm text-neutral-300 mt-1">

View File

@ -276,7 +276,7 @@ function ResetPasswordContent() {
{tips.map(tip => (
<div key={tip} className="flex items-center gap-3">
<svg
className="w-5 h-5 text-brand-turquoise flex-shrink-0"
className="w-5 h-5 text-brand-blue flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"

View File

@ -47,7 +47,7 @@ function VerifyEmailContent() {
<h1 className="text-4xl font-bold text-brand-navy">Xpeditis</h1>
<h2 className="mt-6 text-2xl font-bold text-brand-navy">{t('verifyingTitle')}</h2>
<div className="mt-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto"></div>
</div>
</div>
</div>
@ -89,7 +89,7 @@ function VerifyEmailContent() {
</div>
<div className="text-center">
<Link href="/dashboard" className="font-medium text-brand-navy hover:text-brand-turquoise">
<Link href="/dashboard" className="font-medium text-brand-navy hover:text-brand-blue">
{t('goToDashboard')}
</Link>
</div>
@ -132,7 +132,7 @@ function VerifyEmailContent() {
<div className="text-center space-y-2">
<p className="text-sm text-neutral-600">{t('errorHint')}</p>
<Link href="/login" className="block font-medium text-brand-navy hover:text-brand-turquoise">
<Link href="/login" className="block font-medium text-brand-navy hover:text-brand-blue">
{t('backToLogin')}
</Link>
</div>

View File

@ -22,11 +22,12 @@
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
/* Anneau de focus = turquoise de marque #34CCCD.
Restauration de la charte : la valeur precedente etait le bleu shadcn
par defaut (221.2 83.2% 53.3%), qui n'appartient pas au design system
Xpeditis. Voir docs/design-system-audit.md §1.3. */
--ring: 180 60.5% 50.4%;
/* Anneau de focus = accent applicatif #2563EB.
Le turquoise #34CCCD reste l'accent du site vitrine, mais a l'interieur
du produit l'action est portee par le bleu : c'est la couleur d'origine
du dashboard, et elle tient le contraste que le turquoise n'atteint pas
sur fond clair (4,55:1 contre 2,05:1). */
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
}
@ -72,14 +73,13 @@
Elles seront retirees quand ces pages passeront aux primitives de
src/components/ui. Ne pas les supprimer avant : voir docs/ui-audit.md §D6. */
/* Texte navy sur turquoise : 8,49:1 (AAA). Le blanc sur turquoise ne donne
que 2,05:1 et echoue AA. Voir docs/ui-architecture.md §17. */
/* Bouton d'action : navy plein, texte blanc (14,2:1, AAA). */
.btn-primary {
@apply bg-accent text-brand-navy font-heading font-semibold px-6 py-3 rounded-lg hover:bg-accent/90 transition-colors shadow-sm hover:shadow;
@apply bg-brand-navy text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-brand-navy/90 transition-colors shadow-sm hover:shadow;
}
.btn-secondary {
@apply bg-primary text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-neutral-800 transition-colors shadow-sm hover:shadow;
@apply bg-brand-navy text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-brand-navy/90 transition-colors shadow-sm hover:shadow;
}
.btn-success {
@ -108,10 +108,8 @@
@apply badge bg-success/10 text-success;
}
/* Le turquoise en texte sur fond clair echoue AA : le libelle passe en navy,
le turquoise reste sur le fond. */
.badge-info {
@apply badge bg-accent/10 text-brand-navy;
@apply badge bg-brand-blue-soft text-brand-blue-dark;
}
.badge-warning {
@ -127,12 +125,12 @@
/* Souligne en permanence : un lien identifie par la seule couleur echoue au
critere WCAG 1.4.1. Le navy assure par ailleurs le contraste que le
turquoise ne tient pas sur fond clair. */
@apply text-brand-navy underline underline-offset-2 transition-colors hover:text-accent;
@apply text-brand-blue underline underline-offset-2 transition-colors hover:text-brand-blue-dark;
}
/* Form elements */
.input {
@apply border border-neutral-300 rounded-lg px-4 py-2.5 font-body text-body focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-colors;
@apply border border-neutral-300 rounded-lg px-4 py-2.5 font-body text-body focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent transition-colors;
}
.label {

View File

@ -157,7 +157,20 @@
"subtitle": "Compare rates from 50+ shipping lines, book online and track your shipments in real time.",
"ctaAuthenticated": "Go to dashboard",
"ctaRegister": "Create a free account",
"ctaDemo": "See the demo"
"ctaDemo": "See the demo",
"demo": {
"ariaLabel": "Demo: ocean rate search from {origin} to {destination}, three offers compared",
"label": "Rate search",
"live": "Live",
"origin": "Origin",
"destination": "Destination",
"load": "LCL · 2.4 CBM · 850 kg · 1 pallet",
"transit": "{days} days transit",
"best": "Best price",
"searching": "Querying carriers...",
"resultCount": "{count} comparable offers",
"elapsed": "1.8 s"
}
},
"stats": {
"carriers": "Shipping Lines",
@ -359,7 +372,12 @@
"pending": "Pending",
"totalWeight": "Total Weight",
"thisMonth": "+{count} this month",
"acceptanceRate": "{rate}% accepted"
"acceptanceRate": "{rate}% accepted",
"bookingsThisMonth": "Bookings this month",
"revenue": "Commission earned",
"pendingConfirmations": "Awaiting confirmation",
"vsLastMonth": "vs last month",
"volume": "Volume shipped"
},
"charts": {
"distribution": {
@ -373,6 +391,23 @@
"title": "Weight by Carrier",
"description": "Top 5 carriers by weight (KG)",
"weight": "Weight"
},
"volume": {
"title": "Booking volume",
"description": "Last six months",
"series": "Bookings",
"empty": "Not enough history yet"
},
"status": {
"title": "Request tracking",
"description": "Carrier responses",
"rateLabel": "Acceptance rate"
},
"lanes": {
"title": "Busiest trade lanes",
"description": "Top 5 by booking count",
"series": "Bookings",
"empty": "No trade lane recorded"
}
},
"performance": {
@ -400,6 +435,25 @@
"totalVolume": "Total Volume (CBM)",
"acceptanceRate": "Acceptance rate (%)",
"avgPrice": "Average price ($)"
},
"alerts": {
"title": "Needs attention",
"description": "{count, plural, =1 {# item needs your attention} other {# items need your attention}}",
"severity": {
"critical": "Critical",
"high": "High",
"medium": "Medium",
"low": "Low"
},
"empty": "Nothing to report",
"items": {
"awaitingCarrier": "No carrier response",
"awaitingCarrierSince": "Waiting {days} days on {carrier}",
"awaitingPayment": "Payment pending",
"awaitingPaymentSince": "Blocked for {days} days awaiting payment",
"rejected": "Rejected by carrier",
"rejectedBy": "{carrier} declined the request — needs rebooking"
}
}
},
"bookingsList": {

View File

@ -157,7 +157,20 @@
"subtitle": "Comparez les tarifs de 50+ compagnies maritimes, réservez en ligne et suivez vos envois en temps réel.",
"ctaAuthenticated": "Accéder au tableau de bord",
"ctaRegister": "Créer un compte gratuit",
"ctaDemo": "Voir la démo"
"ctaDemo": "Voir la démo",
"demo": {
"ariaLabel": "Démonstration : recherche de tarif maritime de {origin} vers {destination}, trois offres comparées",
"label": "Recherche de tarif",
"live": "En direct",
"origin": "Départ",
"destination": "Arrivée",
"load": "LCL · 2,4 CBM · 850 kg · 1 palette",
"transit": "{days} jours de transit",
"best": "Meilleur prix",
"searching": "Interrogation des compagnies...",
"resultCount": "{count} offres comparables",
"elapsed": "1,8 s"
}
},
"stats": {
"carriers": "Compagnies Maritimes",
@ -359,7 +372,12 @@
"pending": "En Attente",
"totalWeight": "Poids Total",
"thisMonth": "+{count} ce mois",
"acceptanceRate": "{rate}% acceptés"
"acceptanceRate": "{rate}% acceptés",
"bookingsThisMonth": "Réservations ce mois",
"revenue": "Commissions encaissées",
"pendingConfirmations": "En attente de confirmation",
"vsLastMonth": "vs mois dernier",
"volume": "Volume expédié"
},
"charts": {
"distribution": {
@ -373,6 +391,23 @@
"title": "Poids par Transporteur",
"description": "Top 5 transporteurs par poids (KG)",
"weight": "Poids"
},
"volume": {
"title": "Volume de réservations",
"description": "Six derniers mois",
"series": "Réservations",
"empty": "Pas encore assez d’historique"
},
"status": {
"title": "Suivi des demandes",
"description": "Réponses des transporteurs",
"rateLabel": "Taux d’acceptation"
},
"lanes": {
"title": "Routes les plus utilisées",
"description": "Top 5 par nombre de réservations",
"series": "Réservations",
"empty": "Aucune route enregistrée"
}
},
"performance": {
@ -400,6 +435,25 @@
"totalVolume": "Volume Total (CBM)",
"acceptanceRate": "Taux d'acceptation (%)",
"avgPrice": "Prix moyen ($)"
},
"alerts": {
"title": "À traiter",
"description": "{count, plural, =1 {# point demande votre attention} other {# points demandent votre attention}}",
"severity": {
"critical": "Critique",
"high": "Élevé",
"medium": "Moyen",
"low": "Faible"
},
"empty": "Rien à signaler",
"items": {
"awaitingCarrier": "Sans réponse du transporteur",
"awaitingCarrierSince": "En attente depuis {days} jours chez {carrier}",
"awaitingPayment": "Paiement en attente",
"awaitingPaymentSince": "Bloquée depuis {days} jours faute de règlement",
"rejected": "Refusée par le transporteur",
"rejectedBy": "{carrier} a refusé la demande — à replacer"
}
}
},
"bookingsList": {

View File

@ -57,7 +57,7 @@ export default function CookieConsent() {
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
onClick={openPreferences}
className={`fixed left-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors ${mobileButtonOffset}`}
className={`fixed right-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors ${mobileButtonOffset}`}
aria-label={t('openAria')}
>
<Cookie className="w-5 h-5" />

View File

@ -41,7 +41,7 @@ export default function DebugUser() {
</div>
<div>
<span className="text-yellow-400">localStorage user:</span>
<pre className="mt-1 text-brand-turquoise whitespace-pre-wrap break-words">
<pre className="mt-1 text-brand-blue whitespace-pre-wrap break-words">
{storageData.user}
</pre>
</div>

View File

@ -177,8 +177,10 @@ export default function ExportButton<T extends Record<string, any>>({
onClick={() => (canExport ? setIsOpen(!isOpen) : (window.location.href = '/pricing'))}
disabled={disabled || data.length === 0 || isExporting}
title={!canExport ? t('lockedTooltip') : undefined}
className={`inline-flex items-center px-4 py-2 border border-neutral-300 text-sm font-medium rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
canExport ? 'text-neutral-700 hover:bg-neutral-50' : 'text-neutral-400 hover:bg-neutral-50'
// Action secondaire : meme gabarit que la variante `outline` de Button,
// avec laquelle il se cotoie dans les en-tetes de page.
className={`inline-flex h-10 items-center rounded-lg border border-border bg-white px-6 text-body-sm font-medium transition-colors hover:bg-neutral-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${
canExport ? 'text-brand-navy' : 'text-neutral-400'
}`}
>
{isExporting ? (

View File

@ -149,7 +149,7 @@ export default function NotificationDropdown() {
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="text-xs text-brand-navy hover:text-brand-turquoise font-medium disabled:opacity-50"
className="text-xs text-brand-navy hover:text-brand-blue font-medium disabled:opacity-50"
>
{t('markAllRead')}
</button>
@ -173,7 +173,7 @@ export default function NotificationDropdown() {
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`block px-4 py-3 hover:bg-neutral-50 transition-colors cursor-pointer ${
!notification.read ? 'bg-brand-turquoise/5' : ''
!notification.read ? 'bg-brand-blue/5' : ''
}`}
>
<div className="flex items-start space-x-3">
@ -189,7 +189,7 @@ export default function NotificationDropdown() {
{notification.title}
</p>
{!notification.read && (
<span className="w-2 h-2 bg-brand-turquoise rounded-full"></span>
<span className="w-2 h-2 bg-brand-blue rounded-full"></span>
)}
</div>
<p className="text-sm text-neutral-600 line-clamp-2">
@ -216,7 +216,7 @@ export default function NotificationDropdown() {
setIsOpen(false);
setIsPanelOpen(true);
}}
className="w-full text-center text-sm text-brand-navy hover:text-brand-turquoise font-medium py-2 hover:bg-brand-turquoise/5 rounded transition-colors"
className="w-full text-center text-sm text-brand-navy hover:text-brand-blue font-medium py-2 hover:bg-brand-blue/5 rounded transition-colors"
>
{t('viewAll')}
</button>

View File

@ -111,7 +111,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
urgent: 'border-l-4 border-red-500 bg-red-50',
high: 'border-l-4 border-orange-500 bg-orange-50',
medium: 'border-l-4 border-yellow-500 bg-yellow-50',
low: 'border-l-4 border-brand-turquoise bg-brand-turquoise/5',
low: 'border-l-4 border-brand-blue bg-brand-blue/5',
};
return colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300';
};
@ -191,7 +191,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
}}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
selectedFilter === filter
? 'bg-brand-turquoise text-brand-navy'
? 'bg-brand-navy text-white'
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
}`}
>
@ -204,7 +204,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="flex items-center space-x-1 text-sm text-brand-navy hover:text-brand-turquoise font-medium disabled:opacity-50"
className="flex items-center space-x-1 text-sm text-brand-navy hover:text-brand-blue font-medium disabled:opacity-50"
>
<CheckCheck className="w-4 h-4" />
<span>{t('markAllRead')}</span>
@ -217,7 +217,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4" />
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-blue mx-auto mb-4" />
<p className="text-sm text-neutral-500">{t('loading')}</p>
</div>
</div>
@ -238,7 +238,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-6 hover:bg-neutral-50 transition-all cursor-pointer group ${
!notification.read ? 'bg-brand-turquoise/5/50' : ''
!notification.read ? 'bg-brand-blue/5/50' : ''
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
@ -258,7 +258,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
{notification.title}
</h3>
{!notification.read && (
<span className="w-2.5 h-2.5 bg-brand-turquoise rounded-full" />
<span className="w-2.5 h-2.5 bg-brand-blue rounded-full" />
)}
</div>
<button
@ -290,7 +290,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700'
: 'bg-brand-turquoise/10 text-brand-navy'
: 'bg-brand-blue/10 text-brand-navy'
}`}
>
{notification.priority.toUpperCase()}

View File

@ -192,7 +192,7 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md text-sm font-medium hover:bg-brand-turquoise/90 disabled:opacity-50"
className="px-4 py-2 bg-brand-navy text-white rounded-md text-sm font-medium hover:bg-brand-navy/90 disabled:opacity-50"
>
{submitting ? 'Saving...' : carrier ? 'Update Carrier' : 'Create Carrier'}
</button>

View File

@ -168,7 +168,7 @@ export function CsvUpload() {
disabled={loading}
className={`px-4 py-2 text-sm rounded-md border transition-colors disabled:opacity-50 ${
direction === option.value
? 'border-brand-turquoise bg-brand-turquoise/5 text-brand-navy font-medium'
? 'border-brand-blue bg-brand-blue/5 text-brand-navy font-medium'
: 'border-neutral-300 hover:bg-neutral-50'
}`}
>

View File

@ -307,7 +307,7 @@ export function RichTextEditor({
Image.configure({ inline: false, allowBase64: false }),
Link.configure({
openOnClick: false,
HTMLAttributes: { class: 'text-brand-turquoise underline' },
HTMLAttributes: { class: 'text-brand-blue underline' },
}),
Placeholder.configure({ placeholder }),
Underline,

View File

@ -43,7 +43,7 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
<div className="flex gap-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-sm text-brand-navy hover:text-brand-turquoise"
className="text-sm text-brand-navy hover:text-brand-blue"
>
{isExpanded ? t('showLess') : t('showMore')}
</button>
@ -102,7 +102,7 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
onClick={() => handleStatusChange(status)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filters.status?.includes(status)
? 'bg-brand-turquoise text-brand-navy'
? 'bg-brand-navy text-white'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>

View File

@ -232,7 +232,7 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={`hover:bg-neutral-50 cursor-pointer ${
selectedBookings.has(row.original.id) ? 'bg-brand-turquoise/5' : ''
selectedBookings.has(row.original.id) ? 'bg-brand-blue/5' : ''
}`}
style={{ height: `${virtualRow.size}px` }}
>
@ -265,7 +265,7 @@ function getStatusColor(status: BookingStatus): string {
case BookingStatus.DRAFT:
return 'bg-neutral-100 text-neutral-800';
case BookingStatus.CONFIRMED:
return 'bg-brand-turquoise/10 text-brand-navy';
return 'bg-brand-blue/10 text-brand-navy';
case BookingStatus.IN_PROGRESS:
return 'bg-yellow-100 text-yellow-800';
case BookingStatus.COMPLETED:

View File

@ -40,7 +40,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
if (selectedCount === 0) return null;
return (
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-brand-navy">
@ -48,7 +48,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
</span>
<button
onClick={onClearSelection}
className="text-sm text-brand-navy hover:text-brand-turquoise underline"
className="text-sm text-brand-navy hover:text-brand-blue underline"
>
{t('clearSelection')}
</button>
@ -60,7 +60,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
<button
onClick={() => setShowExportMenu(!showExportMenu)}
disabled={exporting}
className="px-4 py-2 bg-white border border-neutral-300 rounded-md text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-6 py-2 bg-white border border-border rounded-lg text-sm font-medium text-brand-navy hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{exporting ? t('exporting') : t('exportSelected')}
</button>
@ -93,7 +93,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
{/* Bulk update button */}
<button
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md text-sm font-medium hover:bg-brand-turquoise/90"
className="px-4 py-2 bg-brand-navy text-white rounded-md text-sm font-medium hover:bg-brand-navy/90"
onClick={() => {
toast.error(t('bulkUpdateSoon'));
}}

View File

@ -182,9 +182,9 @@ export function DataTable<TData>({
>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted === 'asc' ? (
<ArrowUp className="size-3 text-brand-turquoise" />
<ArrowUp className="size-3 text-brand-blue" />
) : sorted === 'desc' ? (
<ArrowDown className="size-3 text-brand-turquoise" />
<ArrowDown className="size-3 text-brand-blue" />
) : (
<ChevronsUpDown className="size-3 text-neutral-300" />
)}

View File

@ -65,7 +65,7 @@ export function FilterBar({
<SlidersHorizontal />
{labels.filters}
{active.length > 0 && (
<span className="ml-1 inline-flex size-4 items-center justify-center rounded-full bg-brand-turquoise text-[10px] font-semibold text-white">
<span className="ml-1 inline-flex size-4 items-center justify-center rounded-full bg-brand-blue text-[10px] font-semibold text-white">
{active.length}
</span>
)}

View File

@ -23,7 +23,7 @@ export function DesignSystemShowcase() {
{/* Turquoise */}
<div>
<div className="bg-brand-turquoise h-32 rounded-lg shadow-lg mb-3"></div>
<div className="bg-brand-blue h-32 rounded-lg shadow-lg mb-3"></div>
<h4 className="mb-1">Turquoise</h4>
<p className="text-body-sm text-neutral-600">#34CCCD</p>
<p className="text-body-xs text-neutral-500">Accent color for CTAs, links</p>
@ -150,7 +150,7 @@ export function DesignSystemShowcase() {
<div className="mt-8 p-6 bg-brand-navy rounded-lg">
<h4 className="text-white mb-4">On Dark Background</h4>
<div className="flex flex-wrap gap-4">
<button className="bg-brand-turquoise text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-brand-turquoise/90 transition-colors">
<button className="bg-brand-navy text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-brand-navy/90 transition-colors">
Primary on Dark
</button>
<button className="bg-white text-brand-navy font-heading font-semibold px-6 py-3 rounded-lg hover:bg-neutral-100 transition-colors">
@ -196,7 +196,7 @@ export function DesignSystemShowcase() {
</div>
<div>
<span className="label">TOTAL PRICE</span>
<p className="text-h3 text-brand-turquoise mt-1">$1,245 USD</p>
<p className="text-h3 text-brand-blue mt-1">$1,245 USD</p>
</div>
<div>
<span className="badge-info">Best Rate</span>
@ -275,7 +275,7 @@ export function DesignSystemShowcase() {
needs.
</p>
<div className="flex gap-4 justify-center">
<button className="bg-brand-turquoise text-white font-heading font-semibold px-8 py-4 rounded-lg hover:bg-brand-turquoise/90 transition-colors text-lg">
<button className="bg-brand-navy text-white font-heading font-semibold px-8 py-4 rounded-lg hover:bg-brand-navy/90 transition-colors text-lg">
Start Free Trial
</button>
<button className="bg-white text-brand-navy font-heading font-semibold px-8 py-4 rounded-lg hover:bg-neutral-100 transition-colors text-lg">

View File

@ -73,13 +73,13 @@ export function FileDropzone({
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
dragging
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-neutral-300 hover:border-brand-turquoise hover:bg-neutral-50',
? 'border-brand-blue bg-brand-blue/5'
: 'border-neutral-300 hover:border-brand-blue hover:bg-neutral-50',
disabled && 'pointer-events-none opacity-50'
)}
>
<Upload
className={cn('size-5', dragging ? 'text-brand-turquoise' : 'text-neutral-400')}
className={cn('size-5', dragging ? 'text-brand-blue' : 'text-neutral-400')}
aria-hidden="true"
/>
<span className="text-body-sm text-neutral-700">

View File

@ -0,0 +1,280 @@
'use client';
import { useEffect, useState } from 'react';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { ArrowRight, Check, Ship } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { EASE } from './motion';
/**
* Demonstration jouee en boucle de la recherche de tarif.
*
* C'est la promesse d'Xpeditis montree plutot que decrite : deux ports, une
* recherche, des offres comparables en moins de deux secondes — la ou le
* transitaire attend aujourd'hui plusieurs jours et cinq formats de devis
* differents. Aucune donnee reelle n'est appelee : la sequence est scriptee.
*/
const ORIGIN = { code: 'FRLEH', city: 'Le Havre' };
const DESTINATION = { code: 'CNSHA', city: 'Shanghai' };
/** Transporteurs repris du bandeau partenaires, tarifs indicatifs. */
const OFFERS = [
{ carrier: 'ICL', days: 26, price: 1390 },
{ carrier: 'ECU Line', days: 28, price: 1240 },
{ carrier: 'Vanguard', days: 31, price: 1180, best: true },
];
/**
* 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.
*/
const STEPS = [450, 900, 1000, 1100, 5000];
export function RateSearchDemo() {
const t = useTranslations('landing.hero.demo');
const prefersReducedMotion = useReducedMotion();
const [step, setStep] = useState(0);
useEffect(() => {
// Mouvement reduit : on affiche l'etat final, sans boucle.
if (prefersReducedMotion) {
setStep(4);
return;
}
const id = setTimeout(() => setStep(s => (s + 1) % STEPS.length), STEPS[step]);
return () => clearTimeout(id);
}, [step, prefersReducedMotion]);
const hasOrigin = step >= 1;
const hasDestination = step >= 2;
const isSearching = step === 3;
const hasResults = step === 4;
return (
<div
// La carte est un decor : le lecteur d'ecran recoit une phrase, pas la
// sequence entiere qui se rejoue toutes les huit secondes.
role="img"
aria-label={t('ariaLabel', { origin: ORIGIN.city, destination: DESTINATION.city })}
className="w-full max-w-md overflow-hidden rounded-2xl border border-white/15 bg-white/95 shadow-2xl shadow-brand-navy/40 backdrop-blur-sm"
>
{/* Barre de recherche */}
<div className="border-b border-border p-5">
<div className="mb-4 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-neutral-400">
{t('label')}
</span>
<span className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-brand-navy">
<span className="relative flex size-1.5">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-brand-turquoise opacity-75" />
<span className="relative inline-flex size-1.5 rounded-full bg-brand-turquoise" />
</span>
{t('live')}
</span>
</div>
{/* Ligne de route : deux escales et le trait qui les joint. */}
<div className="flex items-start gap-3">
<PortField
label={t('origin')}
code={ORIGIN.code}
city={ORIGIN.city}
filled={hasOrigin}
align="left"
/>
<div className="relative flex h-10 flex-1 items-center" aria-hidden="true">
<div className="h-px w-full bg-border" />
<motion.div
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 }}
/>
<motion.div
className="absolute left-1/2 -translate-x-1/2 rounded-full bg-white px-1"
initial={{ opacity: 0, scale: 0.6 }}
animate={{
opacity: hasDestination ? 1 : 0,
scale: hasDestination ? 1 : 0.6,
}}
transition={{ duration: 0.4, ease: EASE, delay: 0.3 }}
>
<Ship className="size-3.5 text-brand-turquoise" />
</motion.div>
</div>
<PortField
label={t('destination')}
code={DESTINATION.code}
city={DESTINATION.city}
filled={hasDestination}
align="right"
/>
</div>
<p className="mt-4 text-[11px] font-medium tabular-nums text-neutral-500">{t('load')}</p>
</div>
{/* Resultats */}
<div className="min-h-[188px] bg-neutral-50 p-4">
<AnimatePresence mode="wait">
{hasResults ? (
<motion.ul
key="results"
initial="hidden"
animate="visible"
exit={{ opacity: 0, transition: { duration: 0.2 } }}
variants={{ visible: { transition: { staggerChildren: 0.08 } } }}
className="space-y-2"
>
{OFFERS.map(offer => (
<motion.li
key={offer.carrier}
variants={{
hidden: { opacity: 0, y: 10 },
visible: { opacity: 1, y: 0, transition: { duration: 0.45, ease: EASE } },
}}
className={`flex items-center gap-3 rounded-xl border bg-white px-3.5 py-2.5 ${
offer.best ? 'border-brand-turquoise/50 ring-1 ring-brand-turquoise/30' : 'border-border'
}`}
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-brand-navy">{offer.carrier}</p>
<p className="text-[11px] tabular-nums text-neutral-500">
{t('transit', { days: offer.days })}
</p>
</div>
{offer.best && (
<span className="flex items-center gap-1 rounded-full bg-brand-green/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-brand-green">
<Check className="size-3" />
{t('best')}
</span>
)}
<p className="font-heading text-base font-extrabold tabular-nums text-brand-navy">
{offer.price.toLocaleString('fr-FR')}&nbsp;€
</p>
</motion.li>
))}
</motion.ul>
) : (
<motion.div
key="pending"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
className="space-y-2"
>
{[0, 1, 2].map(i => (
<div
key={i}
className="flex items-center gap-3 rounded-xl border border-border bg-white px-3.5 py-2.5"
>
<div className="min-w-0 flex-1 space-y-1.5">
<ShimmerBar
className="h-3 w-24"
active={isSearching}
delay={i * 0.12}
reduced={!!prefersReducedMotion}
/>
<ShimmerBar
className="h-2 w-14"
active={isSearching}
delay={i * 0.12 + 0.06}
reduced={!!prefersReducedMotion}
/>
</div>
<ShimmerBar
className="h-4 w-16"
active={isSearching}
delay={i * 0.12}
reduced={!!prefersReducedMotion}
/>
</div>
))}
</motion.div>
)}
</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">
<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" />
</p>
</div>
</div>
);
}
function PortField({
label,
code,
city,
filled,
align,
}: {
label: string;
code: string;
city: string;
filled: boolean;
align: 'left' | 'right';
}) {
return (
<div className={`w-[104px] ${align === 'right' ? 'text-right' : ''}`}>
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-neutral-400">
{label}
</p>
<motion.p
// Le code port est la donnee, pas le nom de ville : chiffres tabulaires
// et interlettrage large, comme sur un connaissement.
className="font-heading text-lg font-extrabold tracking-[0.06em] text-brand-navy"
initial={{ opacity: 0 }}
animate={{ opacity: filled ? 1 : 0.15 }}
transition={{ duration: 0.4, ease: EASE }}
>
{filled ? code : '·····'}
</motion.p>
<motion.p
className="truncate text-[11px] text-neutral-500"
initial={{ opacity: 0 }}
animate={{ opacity: filled ? 1 : 0 }}
transition={{ duration: 0.4, ease: EASE, delay: 0.1 }}
>
{city}
</motion.p>
</div>
);
}
function ShimmerBar({
className,
active,
delay,
reduced,
}: {
className: string;
active: boolean;
delay: number;
reduced: boolean;
}) {
return (
<motion.div
className={`rounded-full bg-neutral-200 ${className}`}
animate={active && !reduced ? { opacity: [0.4, 1, 0.4] } : { opacity: 0.4 }}
transition={
active && !reduced
? { duration: 1.1, repeat: Infinity, ease: 'easeInOut', delay }
: { duration: 0.2 }
}
/>
);
}

View File

@ -0,0 +1,28 @@
/**
* Grammaire de mouvement de la page d'accueil.
*
* Une seule courbe, une seule distance, un seul decalage : les animations de
* la page doivent se ressembler entre elles. Les valeurs sont volontairement
* courtes — le mouvement accompagne la lecture, il ne la retarde pas.
*/
/** Expo-out. Depart franc, arrivee amortie. */
export const EASE = [0.16, 1, 0.3, 1] as const;
/** Distance de reveal. Au-dela, le contenu semble « tomber » dans la page. */
export const RISE = 14;
/** Reveal standard, a utiliser avec `whileInView`. */
export const reveal = {
hidden: { opacity: 0, y: RISE },
visible: { opacity: 1, y: 0, transition: { duration: 0.55, ease: EASE } },
};
/** Conteneur d'une serie revelee en cascade. */
export const revealGroup = {
hidden: {},
visible: { transition: { staggerChildren: 0.07, delayChildren: 0.05 } },
};
/** Fenetre commune : le reveal se declenche un peu avant l'entree a l'ecran. */
export const VIEWPORT = { once: true, margin: '-80px' } as const;

View File

@ -94,7 +94,7 @@ export default function LicensesTab() {
? 'bg-red-600'
: usagePercentage >= 70
? 'bg-yellow-500'
: 'bg-brand-turquoise'
: 'bg-brand-blue'
}`}
style={{ width: `${Math.min(usagePercentage, 100)}%` }}
></div>
@ -111,7 +111,7 @@ export default function LicensesTab() {
</h3>
<Link
href="/dashboard/settings/users"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-lg hover:bg-brand-turquoise/90 transition-colors"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 transition-colors"
>
<UserPlus className="w-4 h-4 mr-2" />
Inviter un utilisateur
@ -309,10 +309,10 @@ export default function LicensesTab() {
)}
{/* Info Box */}
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4">
<div className="bg-brand-blue/5 border border-brand-blue/30 rounded-lg p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-brand-turquoise" viewBox="0 0 20 20" fill="currentColor">
<svg className="h-5 w-5 text-brand-blue" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"

View File

@ -201,7 +201,7 @@ export default function SubscriptionTab() {
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="text-sm text-brand-navy hover:text-brand-turquoise disabled:opacity-50 flex items-center gap-1"
className="text-sm text-brand-navy hover:text-brand-blue disabled:opacity-50 flex items-center gap-1"
>
<svg
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
@ -241,7 +241,7 @@ export default function SubscriptionTab() {
<button
onClick={handleManageBilling}
disabled={portalMutation.isPending}
className="px-4 py-2 text-sm font-medium text-brand-navy hover:text-brand-turquoise hover:bg-brand-turquoise/5 rounded-lg disabled:opacity-50 transition-colors"
className="px-4 py-2 text-sm font-medium text-brand-navy hover:bg-neutral-100 rounded-lg transition-colors disabled:opacity-50 transition-colors"
>
{portalMutation.isPending ? t('manageBillingLoading') : t('manageBilling')}
</button>
@ -264,7 +264,7 @@ export default function SubscriptionTab() {
? 'bg-red-600'
: usagePercentage >= 70
? 'bg-yellow-500'
: 'bg-brand-turquoise'
: 'bg-brand-blue'
}`}
style={{
width:
@ -330,7 +330,7 @@ export default function SubscriptionTab() {
key={plan.plan}
className={`bg-white rounded-lg border-2 p-5 ${
isCurrentPlan(plan.plan)
? 'border-brand-turquoise shadow-md'
? 'border-brand-blue shadow-md'
: 'border-border hover:border-neutral-300 hover:shadow transition'
}`}
>
@ -387,7 +387,7 @@ export default function SubscriptionTab() {
) : plan.plan === 'PLATINIUM' ? (
<a
href="mailto:sales@xpeditis.com?subject=Demande Enterprise"
className="block w-full px-4 py-2 text-sm font-medium text-center text-brand-navy bg-brand-turquoise/5 rounded-lg hover:bg-brand-turquoise/10 transition"
className="block w-full px-4 py-2 text-sm font-medium text-center text-brand-navy bg-brand-blue/5 rounded-lg hover:bg-brand-blue/10 transition"
>
{t('contactUs')}
</a>
@ -395,7 +395,7 @@ export default function SubscriptionTab() {
<button
onClick={() => handleUpgrade(plan.plan)}
disabled={checkoutMutation.isPending && selectedPlan === plan.plan}
className="w-full px-4 py-2 text-sm font-medium text-white bg-brand-turquoise rounded-lg hover:bg-brand-turquoise/90 transition disabled:opacity-50"
className="w-full px-4 py-2 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 transition disabled:opacity-50"
>
{checkoutMutation.isPending && selectedPlan === plan.plan
? t('upgradeLoading')

View File

@ -32,7 +32,7 @@ type SortOrder = 'asc' | 'desc';
const SERVICE_LEVEL_LABELS: Record<ServiceLevel, { label: string; color: string }> = {
ECONOMIC: { label: 'Éco', color: 'bg-green-100 text-green-800 border-green-200' },
STANDARD: { label: 'Standard', color: 'bg-brand-turquoise/10 text-brand-navy border-brand-turquoise/30' },
STANDARD: { label: 'Standard', color: 'bg-brand-blue/10 text-brand-navy border-brand-blue/30' },
RAPID: { label: 'Rapide', color: 'bg-orange-100 text-orange-800 border-orange-200' },
};

View File

@ -90,7 +90,7 @@ export function VolumeWeightInput({
</div>
{/* Info message */}
<div className="flex items-start gap-2 p-3 bg-brand-turquoise/5 rounded-md border border-brand-turquoise/30">
<div className="flex items-start gap-2 p-3 bg-brand-blue/5 rounded-md border border-brand-blue/30">
<svg
className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5"
fill="currentColor"

View File

@ -62,7 +62,7 @@ export function BottomNav({ pathname, labels, onMore }: BottomNavProps) {
{active && (
<span
aria-hidden="true"
className="absolute -top-1.5 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-brand-turquoise"
className="absolute -top-1.5 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-brand-blue"
/>
)}
</span>

View File

@ -171,7 +171,7 @@ export function CommandMenu({
{item.groupLabel}
</span>
{active && (
<span className="size-1.5 shrink-0 rounded-full bg-brand-turquoise" />
<span className="size-1.5 shrink-0 rounded-full bg-brand-blue" />
)}
</button>
);

View File

@ -41,7 +41,7 @@ export function SettingsNav({ hide = [] }: { hide?: string[] }) {
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
'after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:rounded-full',
active
? 'text-brand-navy after:bg-brand-turquoise'
? 'text-brand-navy after:bg-brand-blue'
: 'text-neutral-500 after:bg-transparent hover:text-brand-navy'
)}
>

View File

@ -73,12 +73,12 @@ export function SidebarNav({
: 'text-neutral-700 hover:bg-neutral-100 hover:text-brand-navy'
)}
>
{/* Filet d'accent turquoise : marque l'item actif sans aplat colore. */}
{/* Filet d'accent bleu : marque l'item actif sans aplat colore. */}
{active && (
<span
aria-hidden="true"
className={cn(
'absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-brand-turquoise',
'absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-full bg-brand-blue',
collapsed && '-left-1'
)}
/>

View File

@ -53,7 +53,7 @@ export default function FeatureGate({ feature, children, fallback }: FeatureGate
</p>
<Link
href="/pricing"
className="inline-flex items-center px-4 py-2 bg-brand-turquoise text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"
className="inline-flex items-center px-4 py-2 bg-brand-navy text-white text-sm font-medium rounded-lg hover:opacity-90 transition-opacity"
>
Voir les plans
</Link>

View File

@ -18,7 +18,7 @@ const badgeVariants = cva(
variants: {
variant: {
neutral: 'border-transparent bg-neutral-100 text-neutral-700',
info: 'border-transparent bg-brand-turquoise/10 text-brand-navy',
info: 'border-transparent bg-brand-blue/10 text-brand-blue-dark',
success: 'border-transparent bg-success/10 text-success',
warning: 'border-transparent bg-amber-100 text-amber-800',
danger: 'border-transparent bg-destructive/10 text-destructive',

View File

@ -9,17 +9,23 @@ import { cn } from '@/lib/utils';
/**
* Bouton Xpeditis.
*
* Charte : le turquoise porte l'action, le navy porte la structure.
* - `primary` : action principale de l'ecran (une seule par vue)
* - `secondary` : action structurante
* - `outline` : action secondaire
* - `ghost` : actions de table, boutons-icone
* L'action est portee par le navy plein a texte blanc. Les actions de second
* rang restent claires : peindre chaque bouton en navy noyait l'action
* principale au milieu d'aplats sombres, notamment dans les tableaux.
*
* - `primary` / `secondary` : action pleine, navy, texte blanc
* - `outline` : action secondaire, fond blanc, bordure, texte navy
* - `ghost` : actions de table, boutons-icone
* - `destructive` : suppression, rouge a texte blanc
*
* `default` est conserve comme alias de `primary` pour compatibilite.
*/
const NAVY_BUTTON =
'bg-brand-navy text-white hover:bg-brand-navy/90 active:bg-brand-navy/95 disabled:cursor-not-allowed';
const buttonVariants = cva(
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg',
'font-body font-medium',
'transition-colors duration-150 ease-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
@ -29,28 +35,25 @@ const buttonVariants = cva(
{
variants: {
variant: {
// Texte navy sur turquoise : 8,49:1 (AAA). Le blanc sur turquoise ne
// donne que 2,05:1 et echoue AA. Ce couple est deja celui employe par
// la sidebar d'administration d'origine.
primary:
'bg-brand-turquoise text-brand-navy hover:bg-brand-turquoise/90 active:bg-brand-turquoise/95',
default:
'bg-brand-turquoise text-brand-navy hover:bg-brand-turquoise/90 active:bg-brand-turquoise/95',
secondary: 'bg-brand-navy text-white hover:bg-brand-navy/90 active:bg-brand-navy/95',
outline: 'border border-border bg-white text-brand-navy hover:bg-neutral-50 active:bg-neutral-100',
primary: NAVY_BUTTON,
default: NAVY_BUTTON,
secondary: NAVY_BUTTON,
outline:
'border border-border bg-white text-brand-navy hover:bg-neutral-50 active:bg-neutral-100',
ghost: 'text-neutral-700 hover:bg-neutral-100 hover:text-brand-navy active:bg-neutral-200',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/95',
'bg-destructive text-white hover:bg-destructive/90 active:bg-destructive/95 disabled:cursor-not-allowed',
// Souligne en permanence : identifier un lien par la seule couleur
// echoue au critere WCAG 1.4.1.
link: 'text-brand-navy underline underline-offset-4 hover:text-brand-turquoise',
link: 'text-brand-navy underline underline-offset-4 hover:text-brand-blue',
},
size: {
sm: 'h-8 px-3 text-body-xs [&_svg]:size-3.5',
md: 'h-9 px-4 text-body-sm [&_svg]:size-4',
lg: 'h-10 px-6 text-body-sm [&_svg]:size-4',
icon: 'h-9 w-9 [&_svg]:size-4',
'icon-sm': 'h-8 w-8 [&_svg]:size-3.5',
/** Gabarit de reference du dashboard : 40 px de haut, texte 14 px. */
md: 'h-10 px-6 text-body-sm [&_svg]:size-4',
lg: 'h-11 px-8 text-body-sm [&_svg]:size-4',
icon: 'size-10 [&_svg]:size-4',
'icon-sm': 'size-8 [&_svg]:size-3.5',
},
},
defaultVariants: {

View File

@ -13,8 +13,8 @@ export type CalloutVariant = 'info' | 'success' | 'warning' | 'danger';
const config: Record<CalloutVariant, { icon: LucideIcon; wrapper: string; icon_: string }> = {
info: {
icon: Info,
wrapper: 'border-brand-turquoise/30 bg-brand-turquoise/5',
icon_: 'text-brand-turquoise',
wrapper: 'border-brand-blue/30 bg-brand-blue/5',
icon_: 'text-brand-blue',
},
success: { icon: CheckCircle2, wrapper: 'border-success/30 bg-success/5', icon_: 'text-success' },
warning: { icon: AlertTriangle, wrapper: 'border-amber-300 bg-amber-50', icon_: 'text-amber-600' },
@ -48,7 +48,7 @@ export function Callout({
)}
<div
className={cn(
'text-body-sm text-neutral-700 [&_a]:text-brand-turquoise [&_a]:underline-offset-4 hover:[&_a]:underline',
'text-body-sm text-neutral-700 [&_a]:text-brand-blue [&_a]:underline-offset-4 hover:[&_a]:underline',
title && 'mt-1'
)}
>

View File

@ -16,8 +16,8 @@ const Checkbox = React.forwardRef<
'transition-colors duration-150 ease-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:border-brand-turquoise data-[state=checked]:bg-brand-turquoise',
'data-[state=indeterminate]:border-brand-turquoise data-[state=indeterminate]:bg-brand-turquoise',
'data-[state=checked]:border-brand-blue data-[state=checked]:bg-brand-blue',
'data-[state=indeterminate]:border-brand-blue data-[state=indeterminate]:bg-brand-blue',
className
)}
{...props}

View File

@ -107,7 +107,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
>
<span className="absolute left-2 flex size-4 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4 text-brand-turquoise" />
<Check className="size-4 text-brand-blue" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@ -122,7 +122,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem ref={ref} className={cn(itemBase, 'pl-8', className)} {...props}>
<span className="absolute left-2 flex size-4 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-brand-turquoise text-brand-turquoise" />
<Circle className="size-2 fill-brand-blue text-brand-blue" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}

View File

@ -127,7 +127,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<span className="absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4 text-brand-turquoise" />
<Check className="size-4 text-brand-blue" />
</SelectPrimitive.ItemIndicator>
</span>
</SelectPrimitive.Item>

View File

@ -30,7 +30,7 @@ export function Spinner({
return (
<>
<Loader2
className={cn('animate-spin text-brand-turquoise', spinnerSizes[size], className)}
className={cn('animate-spin text-brand-blue', spinnerSizes[size], className)}
aria-hidden="true"
/>
<span className="sr-only">{label}</span>

View File

@ -36,10 +36,10 @@ const TabsTrigger = React.forwardRef<
'transition-colors hover:text-brand-navy',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
'disabled:pointer-events-none disabled:opacity-50',
// Filet turquoise sous l'onglet actif, plutot qu'un aplat colore.
// Filet bleu sous l'onglet actif, plutot qu'un aplat colore.
'data-[state=active]:text-brand-navy',
'after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:rounded-full after:bg-transparent',
'data-[state=active]:after:bg-brand-turquoise',
'data-[state=active]:after:bg-brand-blue',
className
)}
{...props}

View File

@ -132,7 +132,7 @@ const variantConfig: Record<
success: { icon: CheckCircle2, iconClass: 'text-success', role: 'status' },
error: { icon: XCircle, iconClass: 'text-destructive', role: 'alert' },
warning: { icon: AlertTriangle, iconClass: 'text-amber-500', role: 'alert' },
info: { icon: Info, iconClass: 'text-brand-turquoise', role: 'status' },
info: { icon: Info, iconClass: 'text-brand-blue', role: 'status' },
};
function Toaster() {
@ -146,8 +146,9 @@ function Toaster() {
aria-atomic="false"
className={cn(
'pointer-events-none fixed z-[100] flex flex-col gap-2',
// Mobile : en haut, pleine largeur. Desktop : en bas a droite.
'inset-x-4 top-4 sm:inset-x-auto sm:bottom-4 sm:right-4 sm:top-auto sm:w-96'
// Mobile : en haut, pleine largeur. Desktop : en bas a droite, au-dessus
// du bouton cookies qui occupe le coin (44 px + 16 px de marge).
'inset-x-4 top-4 sm:inset-x-auto sm:bottom-20 sm:right-4 sm:top-auto sm:w-96'
)}
>
{toasts.map(t => {
@ -174,7 +175,7 @@ function Toaster() {
t.action?.onClick();
dismiss(t.id);
}}
className="mt-2 rounded-md text-body-xs font-medium text-brand-turquoise underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="mt-2 rounded-md text-body-xs font-medium text-brand-blue underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t.action.label}
</button>

View File

@ -12,8 +12,10 @@ import { get } from './client';
export interface DashboardKPIs {
bookingsThisMonth: number;
bookingsThisMonthChange: number;
totalTEUs: number;
totalTEUsChange: number;
/** Volume LCL du mois, en CBM. */
volumeCBM: number;
volumeCBMChange: number;
/** Commission Xpeditis sur les reservations acceptees, en EUR. */
estimatedRevenue: number;
estimatedRevenueChange: number;
pendingConfirmations: number;
@ -24,6 +26,7 @@ export interface DashboardKPIs {
* Bookings Chart Data
*/
export interface BookingsChartData {
/** Cles de mois ISO `YYYY-MM` ; mises en forme cote client. */
labels: string[];
data: number[];
}
@ -34,8 +37,10 @@ export interface BookingsChartData {
export interface TradeLane {
route: string;
bookingCount: number;
origin: string;
destination: string;
originPort: string;
destinationPort: string;
totalVolumeCBM: number;
avgPrice: number;
}
/**
@ -44,8 +49,10 @@ export interface TradeLane {
export interface DashboardAlert {
id: string;
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
message: string;
/** Cles de traduction resolues par le client. */
titleKey: string;
messageKey: string;
messageParams?: Record<string, string | number>;
bookingId?: string;
bookingNumber?: string;
createdAt: string;

View File

@ -1,27 +1,43 @@
/**
* Palette des graphiques.
* Palette et specifications des graphiques.
*
* Recharts n'accepte que des couleurs litterales : ces constantes reprennent
* exactement les valeurs de la charte (voir docs/design-system-audit.md §1) et
* remplacent les couleurs qui avaient ete ecrites en dur dans les pages —
* dont `#8884d8`, le violet par defaut de Recharts reste par oubli.
* Recharts n'accepte que des couleurs litterales : ces constantes centralisent
* les valeurs et evitent que chaque page reinvente les siennes.
*
* Aucune valeur de ce fichier n'est inventee.
* Deux familles, deux roles distincts — elles ne se melangent jamais :
*
* - `SERIES` : une grandeur qui se compare a elle-meme (volume dans le temps,
* classement). Une seule teinte, le bleu applicatif.
* - `STATUS` : un etat (accepte / en attente / refuse). Ces trois couleurs sont
* reservees a cet usage et ne servent jamais de « serie 4 ». Elles sont
* toujours accompagnees d'un libelle : la couleur seule n'est pas porteuse.
*
* Palette de statut verifiee (validate_palette, mode clair) : bande de clarte,
* plancher de chroma, separation daltonisme (pire paire rouge/ambre : ΔE 13,9
* deutan) et plancher vision normale (ΔE 19,8) — tous PASS. L'ambre passe sous
* 3:1 vis-a-vis du fond, d'ou l'obligation de libelles visibles, respectee par
* la legende et les etiquettes directes.
*/
export const CHART_COLORS = {
/** brand-turquoise — serie principale */
primary: '#34CCCD',
/** brand-navy — serie de contraste */
/** Serie unique : magnitude, evolution, classement. */
export const SERIES = {
/** brand-blue — accent applicatif */
primary: '#2563EB',
/** brand-navy — seconde serie de contraste si necessaire */
navy: '#10183A',
/** success / brand-green */
success: '#067224',
/** --destructive */
danger: '#EF4444',
/** amber-500 — avertissement / en attente */
warning: '#F59E0B',
} as const;
/** Habillage : grilles, axes, infobulles. */
/** Etats. Reserves : jamais employes comme couleurs de serie. */
export const STATUS = {
/** brand-green — accepte */
good: '#067224',
/** amber-500 — en attente */
warning: '#F59E0B',
/** red-500 — refuse */
critical: '#EF4444',
} as const;
/** Habillage : grilles, axes, infobulles. Volontairement recessif. */
export const CHART_UI = {
/** neutral-100 */
grid: '#EDEEF5',
@ -32,6 +48,17 @@ export const CHART_UI = {
surface: '#FFFFFF',
} as const;
/** Specifications de marque communes a tous les graphiques. */
export const MARK = {
/** Epaisseur des courbes. */
strokeWidth: 2,
/** Extremite de barre arrondie, cote oppose a la ligne de base. */
barRadius: 4,
/** Diametre minimal d'un point survolable. */
dotRadius: 4,
activeDotRadius: 5,
} as const;
/** Style d'infobulle commun, aux polices et couleurs de la charte. */
export const chartTooltipStyle = {
contentStyle: {
@ -40,15 +67,17 @@ export const chartTooltipStyle = {
borderRadius: '0.5rem',
fontSize: '0.75rem',
fontFamily: 'var(--font-montserrat), sans-serif',
color: CHART_COLORS.navy,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
color: SERIES.navy,
boxShadow: '0 4px 12px -2px rgb(16 24 58 / 0.12)',
padding: '0.5rem 0.75rem',
},
labelStyle: { color: CHART_COLORS.navy, fontWeight: 600 },
labelStyle: { color: SERIES.navy, fontWeight: 600, marginBottom: '0.125rem' },
itemStyle: { padding: 0 },
} as const;
/** Style d'axe commun. */
/** Style d'axe commun : le trait recule, la donnee avance. */
export const chartAxisProps = {
tick: { fontSize: 11, fill: CHART_UI.axis },
tickLine: false,
axisLine: { stroke: CHART_UI.border },
axisLine: false,
} as const;

View File

@ -24,6 +24,17 @@ const config: Config = {
'brand-green': '#067224',
'brand-gray': '#F2F2F2',
// Accent applicatif (dashboard, admin, portails).
// Le turquoise reste l'accent du site vitrine ; a l'interieur du
// produit l'action est portee par le bleu historique (ancien
// `blue-600` de Tailwind, deja la couleur du dashboard d'origine).
'brand-blue': {
DEFAULT: '#2563EB',
light: '#3B82F6',
dark: '#1D4ED8',
soft: '#EFF6FF',
},
// Shadcn UI colors (keep for components compatibility)
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',