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 * Calculates KPIs and analytics data for dashboard
*/ */
import { Injectable, Inject } from '@nestjs/common'; import { Injectable } 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 { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository'; import { TypeOrmCsvBookingRepository } from '../../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity'; import { CsvBooking, CsvBookingStatus } from '@domain/entities/csv-booking.entity';
export interface DashboardKPIs { export interface DashboardKPIs {
bookingsThisMonth: number; bookingsThisMonth: number;
totalTEUs: number; /** Volume LCL du mois, en CBM. */
volumeCBM: number;
/** Commission Xpeditis encaissee sur les reservations acceptees, en EUR. */
estimatedRevenue: number; estimatedRevenue: number;
pendingConfirmations: number; pendingConfirmations: number;
bookingsThisMonthChange: number; // % change from last month bookingsThisMonthChange: number; // % change from last month
totalTEUsChange: number; volumeCBMChange: number;
estimatedRevenueChange: number; estimatedRevenueChange: number;
pendingConfirmationsChange: number; pendingConfirmationsChange: number;
} }
export interface BookingsChartData { 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 data: number[]; // Booking counts
} }
@ -33,7 +32,7 @@ export interface TopTradeLane {
originPort: string; originPort: string;
destinationPort: string; destinationPort: string;
bookingCount: number; bookingCount: number;
totalTEUs: number; totalVolumeCBM: number;
avgPrice: number; avgPrice: number;
} }
@ -41,8 +40,10 @@ export interface DashboardAlert {
id: string; id: string;
type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info'; type: 'delay' | 'confirmation' | 'document' | 'payment' | 'info';
severity: 'low' | 'medium' | 'high' | 'critical'; severity: 'low' | 'medium' | 'high' | 'critical';
title: string; /** Cle de traduction, resolue par le client. */
message: string; titleKey: string;
messageKey: string;
messageParams?: Record<string, string | number>;
bookingId?: string; bookingId?: string;
bookingNumber?: string; bookingNumber?: string;
createdAt: Date; createdAt: Date;
@ -73,125 +74,94 @@ export interface TopCarrier {
@Injectable() @Injectable()
export class AnalyticsService { export class AnalyticsService {
constructor( constructor(private readonly csvBookingRepository: TypeOrmCsvBookingRepository) {}
@Inject(BOOKING_REPOSITORY)
private readonly bookingRepository: BookingRepository,
@Inject(RATE_QUOTE_REPOSITORY)
private readonly rateQuoteRepository: RateQuoteRepository,
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
) {}
/** /**
* Calculate dashboard KPIs * 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> { async calculateKPIs(organizationId: string): Promise<DashboardKPIs> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const now = new Date(); const now = new Date();
const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 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 thisMonth = bookings.filter((b: CsvBooking) => b.requestedAt >= thisMonthStart);
const allBookings = await this.bookingRepository.findByOrganization(organizationId); const lastMonth = bookings.filter(
(b: CsvBooking) => b.requestedAt >= lastMonthStart && b.requestedAt < thisMonthStart
// 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
); );
// Calculate total TEUs (20' = 1 TEU, 40' = 2 TEU) // LCL : le volume se mesure en CBM. Le TEU, unite du conteneur complet, ne
// Each container is an individual entity, so we count them // veut rien dire ici — c'est ce que l'ancienne version calculait.
const calculateTEUs = (bookings: typeof allBookings): number => { const volumeOf = (list: CsvBooking[]): number =>
return bookings.reduce((total, booking) => { list.reduce((sum, b) => sum + (Number(b.volumeCBM) || 0), 0);
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);
};
const totalTEUsThisMonth = calculateTEUs(thisMonthBookings); // Le chiffre d'affaires est celui d'Xpeditis : la commission percue, pas le
const totalTEUsLastMonth = calculateTEUs(lastMonthBookings); // 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 pendingOf = (list: CsvBooking[]): number =>
const calculateRevenue = async (bookings: typeof allBookings): Promise<number> => { list.filter(b => b.status === CsvBookingStatus.PENDING).length;
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 estimatedRevenueThisMonth = await calculateRevenue(thisMonthBookings); const volumeThisMonth = volumeOf(thisMonth);
const estimatedRevenueLastMonth = await calculateRevenue(lastMonthBookings); 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 change = (current: number, previous: number): number => {
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 => {
if (previous === 0) return current > 0 ? 100 : 0; if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100; return ((current - previous) / previous) * 100;
}; };
return { return {
bookingsThisMonth: thisMonthBookings.length, bookingsThisMonth: thisMonth.length,
totalTEUs: totalTEUsThisMonth, volumeCBM: volumeThisMonth,
estimatedRevenue: estimatedRevenueThisMonth, estimatedRevenue: revenueThisMonth,
pendingConfirmations: pendingThisMonth, pendingConfirmations: pendingThisMonth,
bookingsThisMonthChange: calculateChange(thisMonthBookings.length, lastMonthBookings.length), bookingsThisMonthChange: change(thisMonth.length, lastMonth.length),
totalTEUsChange: calculateChange(totalTEUsThisMonth, totalTEUsLastMonth), volumeCBMChange: change(volumeThisMonth, volumeLastMonth),
estimatedRevenueChange: calculateChange(estimatedRevenueThisMonth, estimatedRevenueLastMonth), estimatedRevenueChange: change(revenueThisMonth, revenueLastMonth),
pendingConfirmationsChange: calculateChange(pendingThisMonth, pendingLastMonth), pendingConfirmationsChange: change(pendingThisMonth, pendingLastMonth),
}; };
} }
/** /**
* Get bookings chart data for last 6 months * 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> { async getBookingsChartData(organizationId: string): Promise<BookingsChartData> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const now = new Date(); const now = new Date();
const labels: string[] = []; const labels: string[] = [];
const data: number[] = []; const data: number[] = [];
// Get bookings for last 6 months
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
for (let i = 5; i >= 0; i--) { for (let i = 5; i >= 0; i--) {
const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1); const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59); const nextMonthStart = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
// Month label (e.g., "Jan 2025") labels.push(
const monthLabel = monthDate.toLocaleDateString('en-US', { `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}`
month: 'short', );
year: 'numeric', data.push(
}); bookings.filter(
labels.push(monthLabel); (b: CsvBooking) => b.requestedAt >= monthStart && b.requestedAt < nextMonthStart
).length
// Count bookings in this month );
const count = allBookings.filter(
b => b.createdAt >= monthDate && b.createdAt <= monthEnd
).length;
data.push(count);
} }
return { labels, data }; return { labels, data };
@ -201,136 +171,133 @@ export class AnalyticsService {
* Get top 5 trade lanes * Get top 5 trade lanes
*/ */
async getTopTradeLanes(organizationId: string): Promise<TopTradeLane[]> { 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< const routeMap = new Map<
string, string,
{ {
originPort: string; originPort: string;
destinationPort: string; destinationPort: string;
bookingCount: number; bookingCount: number;
totalTEUs: number; totalVolumeCBM: number;
totalPrice: number; totalPrice: number;
} }
>(); >();
for (const booking of allBookings) { for (const booking of bookings) {
try { const originPort = booking.origin.getValue();
const rateQuote = await this.rateQuoteRepository.findById(booking.rateQuoteId); const destinationPort = booking.destination.getValue();
if (!rateQuote) continue; const routeKey = `${originPort} → ${destinationPort}`;
// Get first and last ports from route const entry = routeMap.get(routeKey) ?? {
const originPort = rateQuote.route[0]?.portCode || 'UNKNOWN';
const destinationPort = rateQuote.route[rateQuote.route.length - 1]?.portCode || 'UNKNOWN';
const routeKey = `${originPort}-${destinationPort}`;
if (!routeMap.has(routeKey)) {
routeMap.set(routeKey, {
originPort, originPort,
destinationPort, destinationPort,
bookingCount: 0, bookingCount: 0,
totalTEUs: 0, totalVolumeCBM: 0,
totalPrice: 0, totalPrice: 0,
}); };
entry.bookingCount++;
entry.totalVolumeCBM += Number(booking.volumeCBM) || 0;
entry.totalPrice += Number(booking.priceEUR) || 0;
routeMap.set(routeKey, entry);
} }
const route = routeMap.get(routeKey)!; return Array.from(routeMap.entries())
route.bookingCount++; .map(([route, entry]) => ({
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;
}
}
// Convert to array and sort by booking count
const tradeLanes: TopTradeLane[] = Array.from(routeMap.entries()).map(([route, data]) => ({
route, route,
originPort: data.originPort, originPort: entry.originPort,
destinationPort: data.destinationPort, destinationPort: entry.destinationPort,
bookingCount: data.bookingCount, bookingCount: entry.bookingCount,
totalTEUs: data.totalTEUs, totalVolumeCBM: entry.totalVolumeCBM,
avgPrice: data.totalPrice / data.bookingCount, avgPrice: entry.bookingCount > 0 ? entry.totalPrice / entry.bookingCount : 0,
})); }))
.sort((a, b) => b.bookingCount - a.bookingCount)
// Sort by booking count and return top 5 .slice(0, 5);
return tradeLanes.sort((a, b) => b.bookingCount - a.bookingCount).slice(0, 5);
} }
/** /**
* Get dashboard alerts * 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[]> { async getAlerts(organizationId: string): Promise<DashboardAlert[]> {
const bookings = await this.csvBookingRepository.findByOrganizationId(organizationId);
const alerts: DashboardAlert[] = []; const alerts: DashboardAlert[] = [];
const allBookings = await this.bookingRepository.findByOrganization(organizationId);
// Check for pending confirmations (older than 24h) const now = Date.now();
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const oneDay = 24 * 60 * 60 * 1000;
const oldPendingBookings = allBookings.filter(
b => b.status.value === 'pending_confirmation' && b.createdAt < oneDayAgo
);
for (const booking of oldPendingBookings) { for (const booking of bookings) {
const waitedMs = now - booking.requestedAt.getTime();
const waitedDays = Math.floor(waitedMs / oneDay);
// Sans reponse du transporteur au-dela de 48 h, la demande decroche.
if (booking.status === CsvBookingStatus.PENDING && waitedMs > 2 * oneDay) {
alerts.push({ alerts.push({
id: `pending-${booking.id}`, id: `pending-${booking.id}`,
type: 'confirmation', type: 'confirmation',
severity: 'medium', severity: waitedDays >= 5 ? 'high' : 'medium',
title: 'Pending Confirmation', titleKey: 'awaitingCarrier',
message: `Booking ${booking.bookingNumber.value} is awaiting carrier confirmation for over 24 hours`, messageKey: 'awaitingCarrierSince',
messageParams: { days: waitedDays, carrier: booking.carrierName },
bookingId: booking.id, bookingId: booking.id,
bookingNumber: booking.bookingNumber.value, bookingNumber: booking.bookingNumber,
createdAt: booking.createdAt, createdAt: booking.requestedAt,
isRead: false, isRead: false,
}); });
}
// 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) {
continue; 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;
} }
// Sort by severity (critical > high > medium > low) // Refus recent : il faut replacer la marchandise.
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; if (
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); 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,
});
}
}
return alerts; const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
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> = { const STATUS_COLORS: Record<BlogPostStatus, string> = {
draft: 'bg-yellow-100 text-yellow-800', 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', published: 'bg-green-100 text-green-800',
archived: 'bg-neutral-100 text-neutral-600', archived: 'bg-neutral-100 text-neutral-600',
}; };
@ -526,7 +526,7 @@ export default function AdminBlogPage() {
actions={ actions={
<button <button
onClick={openCreate} 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" /> <Plus className="w-4 h-4" />
<span>Nouvel article</span> <span>Nouvel article</span>
@ -556,7 +556,7 @@ export default function AdminBlogPage() {
onClick={() => setViewFilter(tab.value)} 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 ${ className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
viewFilter === tab.value 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' : 'bg-white text-neutral-600 border border-border hover:bg-neutral-50'
}`} }`}
> >
@ -814,7 +814,7 @@ export default function AdminBlogPage() {
required required
value={formData.title} value={formData.title}
onChange={e => handleTitleChange(e.target.value)} 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..." placeholder="Titre de l'article..."
/> />
</div> </div>
@ -1011,7 +1011,7 @@ export default function AdminBlogPage() {
<button <button
type="button" type="button"
onClick={addFaqItem} 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" /> <Plus className="w-3.5 h-3.5" />
Ajouter Ajouter
@ -1142,7 +1142,7 @@ export default function AdminBlogPage() {
<button <button
type="submit" type="submit"
disabled={saving} 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 && <Loader2 className="w-4 h-4 animate-spin" />}
{isScheduleMode ? 'Planifier' : 'Enregistrer brouillon'} {isScheduleMode ? 'Planifier' : 'Enregistrer brouillon'}
@ -1152,7 +1152,7 @@ export default function AdminBlogPage() {
<button <button
type="submit" type="submit"
disabled={saving} 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 && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? 'Enregistrement...' : 'Enregistrer'} {saving ? 'Enregistrement...' : 'Enregistrer'}
@ -1191,8 +1191,8 @@ export default function AdminBlogPage() {
onDrop={handleCoverDrop} 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 ${ 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 isDraggingCover
? 'border-brand-turquoise bg-brand-turquoise/5 text-brand-navy' ? 'border-brand-blue bg-brand-blue/5 text-brand-navy'
: 'border-neutral-300 text-neutral-400 hover:border-brand-turquoise hover:text-brand-navy' : 'border-neutral-300 text-neutral-400 hover:border-brand-blue hover:text-brand-navy'
}`} }`}
> >
{uploadingCover ? ( {uploadingCover ? (

View File

@ -148,7 +148,7 @@ export default function AdminBookingsPage() {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<div className="text-center"> <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> <p className="mt-4 text-neutral-600">{t('loading')}</p>
</div> </div>
</div> </div>
@ -211,7 +211,7 @@ export default function AdminBookingsPage() {
placeholder={t('search.placeholder')} placeholder={t('search.placeholder')}
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} 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>
<div> <div>
@ -221,7 +221,7 @@ export default function AdminBookingsPage() {
<select <select
value={filterStatus} value={filterStatus}
onChange={e => setFilterStatus(e.target.value)} 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="all">{t('filter.all')}</option>
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</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'; import { useConfirm } from '@/components/ui/use-confirm';
const DIRECTION_COLORS: Record<'EXPORT' | 'IMPORT', string> = { 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', IMPORT: 'bg-purple-100 text-purple-800',
}; };

View File

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

View File

@ -60,7 +60,7 @@ export default function AdminLoginPage() {
priority priority
className="h-auto mb-4" 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" /> <ShieldCheck className="w-5 h-5" />
<span className="text-xs font-semibold uppercase tracking-wider">{t('badge')}</span> <span className="text-xs font-semibold uppercase tracking-wider">{t('badge')}</span>
</div> </div>

View File

@ -55,7 +55,7 @@ const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 border border-red-200', error: 'bg-red-100 text-red-700 border border-red-200',
fatal: 'bg-red-200 text-red-900 border border-red-300', fatal: 'bg-red-200 text-red-900 border border-red-300',
warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200', 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', debug: 'bg-neutral-100 text-neutral-600 border border-border',
trace: 'bg-purple-100 text-purple-700 border border-purple-200', trace: 'bg-purple-100 text-purple-700 border border-purple-200',
}; };
@ -239,7 +239,7 @@ export default function AdminLogsPage() {
label={t('stats.total')} label={t('stats.total')}
value={total} value={total}
icon={Activity} icon={Activity}
color="bg-brand-turquoise/10 text-brand-navy" color="bg-brand-blue/10 text-brand-navy"
/> />
<StatCard <StatCard
label={t('stats.errors')} label={t('stats.errors')}
@ -364,7 +364,7 @@ export default function AdminLogsPage() {
<button <button
onClick={fetchLogs} onClick={fetchLogs}
disabled={loading} 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')} {t('filters.apply')}
</button> </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 ${ `mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none text-sm ${
hasError hasError
? 'border-red-400 focus:border-red-500 focus:ring-red-500' ? '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) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<div className="text-center"> <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> <p className="mt-4 text-neutral-600">{t('loading')}</p>
</div> </div>
</div> </div>
@ -340,7 +340,7 @@ export default function AdminOrganizationsPage() {
actions={ actions={
<button <button
onClick={() => setShowCreateModal(true)} 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')} {t('create')}
</button> </button>
@ -363,7 +363,7 @@ export default function AdminOrganizationsPage() {
<span <span
className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${ className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
org.type === 'FREIGHT_FORWARDER' org.type === 'FREIGHT_FORWARDER'
? 'bg-brand-turquoise/10 text-brand-navy' ? 'bg-brand-blue/10 text-brand-navy'
: org.type === 'CARRIER' : org.type === 'CARRIER'
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800' : 'bg-purple-100 text-purple-800'
@ -426,7 +426,7 @@ export default function AdminOrganizationsPage() {
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
onClick={() => openEditModal(org)} 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')} {t('edit')}
</button> </button>
@ -523,7 +523,7 @@ export default function AdminOrganizationsPage() {
<span <span
className={`px-2 py-1 text-xs font-semibold rounded-full ${ className={`px-2 py-1 text-xs font-semibold rounded-full ${
formData.type === 'FREIGHT_FORWARDER' formData.type === 'FREIGHT_FORWARDER'
? 'bg-brand-turquoise/10 text-brand-navy' ? 'bg-brand-blue/10 text-brand-navy'
: formData.type === 'CARRIER' : formData.type === 'CARRIER'
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800' : 'bg-purple-100 text-purple-800'
@ -818,7 +818,7 @@ export default function AdminOrganizationsPage() {
<button <button
type="submit" type="submit"
disabled={submitting} 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 {submitting
? t('modal.saving') ? t('modal.saving')

View File

@ -167,7 +167,7 @@ export default function AdminUsersPage() {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<div className="text-center"> <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> <p className="mt-4 text-neutral-600">{t('loading')}</p>
</div> </div>
</div> </div>
@ -182,7 +182,7 @@ export default function AdminUsersPage() {
actions={ actions={
<button <button
onClick={() => setShowCreateModal(true)} 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')} {t('create')}
</button> </button>
@ -238,7 +238,7 @@ export default function AdminUsersPage() {
user.role === 'ADMIN' user.role === 'ADMIN'
? 'bg-purple-100 text-purple-800' ? 'bg-purple-100 text-purple-800'
: user.role === 'MANAGER' : user.role === 'MANAGER'
? 'bg-brand-turquoise/10 text-brand-navy' ? 'bg-brand-blue/10 text-brand-navy'
: 'bg-neutral-100 text-neutral-800' : '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"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button <button
onClick={() => openEditModal(user)} onClick={() => openEditModal(user)}
className="text-brand-navy hover:text-brand-turquoise" className="text-brand-navy hover:text-brand-blue"
> >
{t('edit')} {t('edit')}
</button> </button>
@ -292,7 +292,7 @@ export default function AdminUsersPage() {
required required
value={formData.email} value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })} 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>
<div> <div>
@ -304,7 +304,7 @@ export default function AdminUsersPage() {
required required
value={formData.firstName} value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })} 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>
<div> <div>
@ -316,7 +316,7 @@ export default function AdminUsersPage() {
required required
value={formData.lastName} value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })} 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>
<div> <div>
@ -324,7 +324,7 @@ export default function AdminUsersPage() {
<select <select
value={formData.role} value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })} 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="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option> <option value="MANAGER">{t('roles.MANAGER')}</option>
@ -340,7 +340,7 @@ export default function AdminUsersPage() {
required required
value={formData.organizationId} value={formData.organizationId}
onChange={e => setFormData({ ...formData, organizationId: e.target.value })} 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> <option value="">{t('modal.selectOrganization')}</option>
{organizations.map(org => ( {organizations.map(org => (
@ -359,7 +359,7 @@ export default function AdminUsersPage() {
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={formData.password} value={formData.password}
onChange={e => setFormData({ ...formData, password: e.target.value })} 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 <button
type="button" type="button"
@ -385,7 +385,7 @@ export default function AdminUsersPage() {
</button> </button>
<button <button
type="submit" 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')} {t('modal.create')}
</button> </button>
@ -421,7 +421,7 @@ export default function AdminUsersPage() {
required required
value={formData.firstName} value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })} 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>
<div> <div>
@ -433,7 +433,7 @@ export default function AdminUsersPage() {
required required
value={formData.lastName} value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })} 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>
<div> <div>
@ -441,7 +441,7 @@ export default function AdminUsersPage() {
<select <select
value={formData.role} value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })} 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="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option> <option value="MANAGER">{t('roles.MANAGER')}</option>
@ -463,7 +463,7 @@ export default function AdminUsersPage() {
</button> </button>
<button <button
type="submit" 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')} {t('modal.update')}
</button> </button>

View File

@ -54,7 +54,7 @@ export default function BookingConfirmPage() {
return ( return (
<div className="min-h-[60vh] flex items-center justify-center p-4"> <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="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> <p className="text-neutral-600">{t('loading')}</p>
</div> </div>
</div> </div>
@ -205,7 +205,7 @@ export default function BookingConfirmPage() {
</div> </div>
{/* Next Steps */} {/* 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"> <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"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@ -257,7 +257,7 @@ export default function BookingConfirmPage() {
href={doc.url} href={doc.url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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')} {t('labels.download')}
</a> </a>

View File

@ -184,7 +184,7 @@ export default function BookingRejectPage() {
)} )}
</div> </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"> <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"> <svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path

View File

@ -110,7 +110,7 @@ export default function CarrierAcceptPage() {
<p className="text-neutral-600 mb-6">{error}</p> <p className="text-neutral-600 mb-6">{error}</p>
<button <button
onClick={() => router.push('/')} 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')} {t('common.backHome')}
</button> </button>

View File

@ -327,7 +327,7 @@ export default function CarrierDocumentsPage() {
return ( return (
<div className="min-h-[60vh] flex items-center justify-center bg-brand-gray"> <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"> <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> <h1 className="text-2xl font-bold text-brand-navy mb-2">{t('loading')}</h1>
<p className="text-neutral-600">{t('loadingHint')}</p> <p className="text-neutral-600">{t('loadingHint')}</p>
</div> </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="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="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
<div className="text-center mb-6"> <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"> <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-turquoise" /> <Lock className="w-8 h-8 text-brand-blue" />
</div> </div>
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('password.title')}</h1> <h1 className="text-2xl font-bold text-brand-navy mb-2">{t('password.title')}</h1>
<p className="text-neutral-600">{t('password.intro')}</p> <p className="text-neutral-600">{t('password.intro')}</p>
@ -385,7 +385,7 @@ export default function CarrierDocumentsPage() {
value={password} value={password}
onChange={e => setPassword(e.target.value.toUpperCase())} onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder={t('password.passwordPlaceholder')} 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" autoComplete="off"
autoFocus autoFocus
/> />
@ -456,7 +456,7 @@ export default function CarrierDocumentsPage() {
</div> </div>
<button <button
onClick={handleRefresh} 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')} {t('header.refresh')}
</button> </button>
@ -524,7 +524,7 @@ export default function CarrierDocumentsPage() {
<div className="bg-white rounded-xl shadow-md overflow-hidden"> <div className="bg-white rounded-xl shadow-md overflow-hidden">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-brand-navy flex items-center gap-2"> <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 })} {t('list.heading', { count: documents.length })}
</h2> </h2>
</div> </div>
@ -547,7 +547,7 @@ export default function CarrierDocumentsPage() {
<div> <div>
<p className="font-medium text-brand-navy">{doc.fileName}</p> <p className="font-medium text-brand-navy">{doc.fileName}</p>
<div className="flex items-center gap-2 mt-1"> <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)} {getDocumentTypeLabel(doc.type)}
</span> </span>
<span className="text-xs text-neutral-500">{formatFileSize(doc.size)}</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> <p className="text-neutral-600 mb-6">{error}</p>
<button <button
onClick={() => router.push('/')} 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')} {t('common.backHome')}
</button> </button>

View File

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

View File

@ -42,14 +42,14 @@ export default function PaymentSuccessPage() {
if (!sessionId) { if (!sessionId) {
return ( 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"> <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" /> <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> <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> <p className="text-neutral-600 mb-4">Aucune session de paiement trouvee.</p>
<button <button
onClick={() => router.push('/dashboard/bookings')} 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 Retour aux bookings
</button> </button>
@ -59,7 +59,7 @@ export default function PaymentSuccessPage() {
} }
return ( 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"> <div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
{status === 'confirming' && ( {status === 'confirming' && (
<> <>
@ -85,7 +85,7 @@ export default function PaymentSuccessPage() {
avec votre demande de booking. avec votre demande de booking.
</p> </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"> <div className="flex items-center justify-center space-x-2 text-brand-navy">
<Mail className="h-5 w-5" /> <Mail className="h-5 w-5" />
<span className="text-sm font-medium">Email envoye au transporteur</span> <span className="text-sm font-medium">Email envoye au transporteur</span>
@ -97,7 +97,7 @@ export default function PaymentSuccessPage() {
<button <button
onClick={() => router.push('/dashboard/bookings')} 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 Voir mes bookings
<ArrowRight className="h-4 w-4 ml-2" /> <ArrowRight className="h-4 w-4 ml-2" />
@ -127,7 +127,7 @@ export default function PaymentSuccessPage() {
setStatus('error'); 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 Reessayer
</button> </button>

View File

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

View File

@ -198,9 +198,9 @@ export default function BookingDetailPage() {
<li className="flex gap-3"> <li className="flex gap-3">
<span <span
aria-hidden="true" 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> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-body-sm font-medium text-brand-navy"> <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', 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', 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', 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 ( return (
colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300 hover:bg-neutral-100' 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`} />, booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_accepted: <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_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`} />, rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
document_uploaded: <FileText className={`${iconClass} text-neutral-600`} />, document_uploaded: <FileText className={`${iconClass} text-neutral-600`} />,
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />, 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="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 justify-between">
<div className="flex items-center space-x-4"> <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" /> <Bell className="w-8 h-8 text-brand-navy" />
</div> </div>
<div> <div>
@ -178,7 +178,7 @@ export default function NotificationsPage() {
<button <button
onClick={() => markAllAsReadMutation.mutate()} onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending} 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" /> <CheckCheck className="w-5 h-5" />
<span>{t('markAllRead')}</span> <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 ${ className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
selectedFilter === filter 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' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`} }`}
> >
@ -223,7 +223,7 @@ export default function NotificationsPage() {
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<div className="text-center"> <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> <p className="text-neutral-500">{t('loading')}</p>
</div> </div>
</div> </div>
@ -246,7 +246,7 @@ export default function NotificationsPage() {
key={notification.id} key={notification.id}
onClick={() => handleNotificationClick(notification)} onClick={() => handleNotificationClick(notification)}
className={`p-6 transition-all cursor-pointer group ${ 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')}`} } ${getPriorityColor(notification.priority || 'low')}`}
> >
<div className="flex items-start space-x-4"> <div className="flex items-start space-x-4">
@ -261,7 +261,7 @@ export default function NotificationsPage() {
{notification.title} {notification.title}
</h3> </h3>
{!notification.read && ( {!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 className="w-2 h-2 bg-white rounded-full animate-pulse" />
<span>{t('new')}</span> <span>{t('new')}</span>
</span> </span>
@ -312,7 +312,7 @@ export default function NotificationsPage() {
? 'bg-orange-100 text-orange-700' ? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium' : notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700' ? 'bg-yellow-100 text-yellow-700'
: 'bg-brand-turquoise/10 text-brand-navy' : 'bg-brand-blue/10 text-brand-navy'
}`} }`}
> >
{getPriorityLabel(notification.priority)} {getPriorityLabel(notification.priority)}
@ -361,7 +361,7 @@ export default function NotificationsPage() {
<button <button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))} onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 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" /> <ChevronLeft className="w-4 h-4" />
<span>{t('pagination.previous')}</span> <span>{t('pagination.previous')}</span>
@ -385,7 +385,7 @@ export default function NotificationsPage() {
onClick={() => setCurrentPage(pageNum)} onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${ className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
currentPage === pageNum 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' : 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
}`} }`}
> >
@ -397,7 +397,7 @@ export default function NotificationsPage() {
<button <button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages} 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> <span>{t('pagination.next')}</span>
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />

View File

@ -6,22 +6,23 @@ import { dashboardApi } from '@/lib/api';
import { Link, useRouter } from '@/i18n/navigation'; import { Link, useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl'; import { useTranslations, useLocale } from 'next-intl';
import { import {
AlertTriangle,
ArrowRight, ArrowRight,
Clock, Clock,
Container as ContainerIcon,
Euro,
Package, Package,
PackageCheck,
PackageX,
Plus, Plus,
Ship,
TrendingUp, TrendingUp,
Weight, Weight,
} from 'lucide-react'; } from 'lucide-react';
import { import {
Area,
AreaChart,
Bar, Bar,
BarChart, BarChart,
CartesianGrid, CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer, ResponsiveContainer,
Tooltip, Tooltip,
XAxis, XAxis,
@ -36,7 +37,14 @@ import { EmptyState, Skeleton } from '@/components/ui/states';
import { StatCard } from '@/components/data/stat-card'; import { StatCard } from '@/components/data/stat-card';
import { useSubscription } from '@/lib/context/subscription-context'; import { useSubscription } from '@/lib/context/subscription-context';
import ExportButton from '@/components/ExportButton'; 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() { export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
@ -50,6 +58,28 @@ export default function DashboardPage() {
} }
}, [subLoading, hasFeature, router]); }, [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({ const { data: csvKpis, isLoading: csvKpisLoading } = useQuery({
queryKey: ['dashboard', 'csv-booking-kpis'], queryKey: ['dashboard', 'csv-booking-kpis'],
queryFn: () => dashboardApi.getCsvBookingKPIs(), queryFn: () => dashboardApi.getCsvBookingKPIs(),
@ -60,39 +90,61 @@ export default function DashboardPage() {
queryFn: () => dashboardApi.getTopCarriers(), 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 // 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' });
name: t('charts.distribution.accepted'), const formatMonth = (iso: string) => {
value: csvKpis.totalAccepted, const [year, month] = iso.split('-').map(Number);
color: CHART_COLORS.success, if (!year || !month) return iso;
}, return monthFormat.format(new Date(year, month - 1, 1));
{ };
name: t('charts.distribution.rejected'),
value: csvKpis.totalRejected,
color: CHART_COLORS.danger,
},
{
name: t('charts.distribution.pending'),
value: csvKpis.totalPending,
color: CHART_COLORS.warning,
},
]
: [];
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 const laneSeries = t('charts.lanes.series');
? topCarriers.slice(0, 5).map(c => ({ const laneData = (lanes ?? []).slice(0, 5).map(lane => ({
name: c.carrierName.length > 15 ? c.carrierName.slice(0, 15) + '…' : c.carrierName, route: lane.route,
[weightDataKey]: Math.round(c.totalWeightKG), [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 ( return (
<> <>
@ -112,7 +164,7 @@ export default function DashboardPage() {
{ {
key: 'totalWeightKG', key: 'totalWeightKG',
label: t('export.totalWeight'), label: t('export.totalWeight'),
format: v => v?.toLocaleString(locale === 'fr' ? 'fr-FR' : 'en-US') || '0', format: v => v?.toLocaleString(intl) || '0',
}, },
{ {
key: 'totalVolumeCBM', key: 'totalVolumeCBM',
@ -142,40 +194,158 @@ export default function DashboardPage() {
/> />
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Indicateurs primaires : les quatre chiffres qui repondent a « ou en {/* Ce qui demande une action passe avant ce qui informe. */}
sommes-nous ». Alignes a gauche pour se comparer en colonne. */} {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"> <section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard <StatCard
label={t('performance.totalBookings')} label={t('kpi.bookingsThisMonth')}
value={numberFormat.format(totalBookings)} value={numberFormat.format(kpis?.bookingsThisMonth ?? 0)}
trend={trendOf(kpis?.bookingsThisMonth, kpis?.bookingsThisMonthChange)}
hint={t('kpi.vsLastMonth')}
icon={Package} icon={Package}
loading={csvKpisLoading} loading={kpisLoading}
/> />
<StatCard <StatCard
label={t('kpi.accepted')} label={t('kpi.volume')}
value={numberFormat.format(csvKpis?.totalAccepted ?? 0)} value={`${cbmFormat.format(kpis?.volumeCBM ?? 0)} CBM`}
hint={t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth ?? 0 })} trend={trendOf(kpis?.volumeCBM, kpis?.volumeCBMChange)}
icon={PackageCheck} hint={t('kpi.vsLastMonth')}
loading={csvKpisLoading} icon={ContainerIcon}
loading={kpisLoading}
/> />
<StatCard <StatCard
label={t('kpi.rejected')} label={t('kpi.revenue')}
value={numberFormat.format(csvKpis?.totalRejected ?? 0)} value={currencyFormat.format(kpis?.estimatedRevenue ?? 0)}
hint={t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth ?? 0 })} trend={trendOf(kpis?.estimatedRevenue, kpis?.estimatedRevenueChange)}
icon={PackageX} hint={t('kpi.vsLastMonth')}
loading={csvKpisLoading} icon={Euro}
loading={kpisLoading}
/> />
<StatCard <StatCard
label={t('kpi.pending')} label={t('kpi.pendingConfirmations')}
value={numberFormat.format(csvKpis?.totalPending ?? 0)} value={numberFormat.format(kpis?.pendingConfirmations ?? 0)}
hint={t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })} // 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} icon={Clock}
loading={csvKpisLoading} loading={kpisLoading}
/> />
</section> </section>
{/* Indicateurs secondaires : volumetrie et performance, en bandeau {/* Evolution a gauche (la question « ou va-t-on »), etat des demandes a
compact pour ne pas concurrencer la rangee primaire. */} 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"> <section className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3">
<SecondaryStat <SecondaryStat
label={t('performance.acceptanceRate')} label={t('performance.acceptanceRate')}
@ -185,7 +355,9 @@ export default function DashboardPage() {
<SecondaryStat <SecondaryStat
label={t('kpi.totalWeight')} label={t('kpi.totalWeight')}
value={ value={
csvKpisLoading ? null : `${numberFormat.format(csvKpis?.totalWeightAcceptedKG ?? 0)} kg` csvKpisLoading
? null
: `${numberFormat.format(csvKpis?.totalWeightAcceptedKG ?? 0)} kg`
} }
icon={Weight} icon={Weight}
/> />
@ -200,69 +372,49 @@ export default function DashboardPage() {
<section className="grid gap-4 lg:grid-cols-2"> <section className="grid gap-4 lg:grid-cols-2">
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-2">
<CardTitle>{t('charts.distribution.title')}</CardTitle> <CardTitle>{t('charts.lanes.title')}</CardTitle>
<CardDescription>{t('charts.distribution.description')}</CardDescription> <CardDescription>{t('charts.lanes.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{csvKpisLoading ? ( {lanesLoading ? (
<Skeleton className="h-52 w-full" /> <Skeleton className="h-56 w-full" />
) : totalBookings === 0 ? ( ) : laneData.length === 0 ? (
<EmptyState size="sm" icon={Package} title={t('topCarriers.empty.title')} /> <EmptyState size="sm" icon={Ship} title={t('charts.lanes.empty')} />
) : ( ) : (
<ResponsiveContainer width="100%" height={208}> // Barres horizontales : les libelles de route sont longs et se
<PieChart> // lisent a plat, sans rotation ni troncature.
<Pie <ResponsiveContainer width="100%" height={224}>
data={statusDistribution} <BarChart
cx="50%" data={laneData}
cy="50%" layout="vertical"
labelLine={false} margin={{ top: 4, right: 16, bottom: 0, left: 0 }}
label={({ name, percent }: any) => barCategoryGap="28%"
`${name} ${((percent || 0) * 100).toFixed(0)}%`
}
outerRadius={72}
innerRadius={44}
paddingAngle={2}
dataKey="value"
> >
{statusDistribution.map(entry => ( <CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} horizontal={false} />
<Cell key={entry.name} fill={entry.color} stroke={CHART_UI.surface} /> <XAxis type="number" allowDecimals={false} {...chartAxisProps} />
))} <YAxis
</Pie> type="category"
<Tooltip {...chartTooltipStyle} /> dataKey="route"
</PieChart> width={110}
</ResponsiveContainer> {...chartAxisProps}
)} tick={{ fontSize: 11, fill: CHART_UI.axis }}
</CardContent> />
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('charts.weightByCarrier.title')}</CardTitle>
<CardDescription>{t('charts.weightByCarrier.description')}</CardDescription>
</CardHeader>
<CardContent>
{carriersLoading ? (
<Skeleton className="h-52 w-full" />
) : carrierWeightData.length === 0 ? (
<EmptyState size="sm" icon={Weight} title={t('topCarriers.empty.title')} />
) : (
<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 }} /> <Tooltip {...chartTooltipStyle} cursor={{ fill: CHART_UI.grid }} />
<Bar dataKey={weightDataKey} fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} /> <Bar
dataKey={laneSeries}
fill={SERIES.primary}
radius={[0, MARK.barRadius, MARK.barRadius, 0]}
maxBarSize={18}
/>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
)} )}
</CardContent> </CardContent>
</Card> </Card>
</section>
<Card> <Card>
<CardHeader className="flex-row items-start justify-between gap-3 pb-3"> <CardHeader className="flex-row items-start justify-between gap-3 pb-2">
<div className="min-w-0"> <div className="min-w-0">
<CardTitle>{t('topCarriers.title')}</CardTitle> <CardTitle>{t('topCarriers.title')}</CardTitle>
<CardDescription>{t('topCarriers.description')}</CardDescription> <CardDescription>{t('topCarriers.description')}</CardDescription>
@ -341,11 +493,80 @@ export default function DashboardPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</section>
</div> </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. */ /** Indicateur secondaire, en bandeau, sans le poids visuel d'une carte. */
function SecondaryStat({ function SecondaryStat({
label, label,

View File

@ -142,7 +142,7 @@ export default function ProfilePage() {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <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> <p className="text-neutral-600">{t('loading')}</p>
</div> </div>
</div> </div>
@ -156,7 +156,7 @@ export default function ProfilePage() {
<p className="text-red-600 mb-4">{t('loadError')}</p> <p className="text-red-600 mb-4">{t('loadError')}</p>
<button <button
onClick={() => window.location.reload()} 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')} {t('retry')}
</button> </button>
@ -204,7 +204,7 @@ export default function ProfilePage() {
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-4"> <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?.firstName?.[0]}
{user?.lastName?.[0]} {user?.lastName?.[0]}
</div> </div>
@ -214,7 +214,7 @@ export default function ProfilePage() {
</h2> </h2>
<p className="text-neutral-600">{user?.email}</p> <p className="text-neutral-600">{user?.email}</p>
<div className="flex items-center space-x-2 mt-2"> <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} {user?.role}
</span> </span>
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full"> <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')} onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${ className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'profile' 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' : 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`} }`}
> >
@ -242,7 +242,7 @@ export default function ProfilePage() {
onClick={() => setActiveTab('password')} onClick={() => setActiveTab('password')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${ className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'password' 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' : 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`} }`}
> >
@ -314,7 +314,7 @@ export default function ProfilePage() {
<button <button
type="submit" type="submit"
disabled={updateProfileMutation.isPending} 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 {updateProfileMutation.isPending
? t('profileForm.saving') ? t('profileForm.saving')
@ -437,7 +437,7 @@ export default function ProfilePage() {
<button <button
type="submit" type="submit"
disabled={updatePasswordMutation.isPending} 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 {updatePasswordMutation.isPending
? t('passwordForm.submitting') ? t('passwordForm.submitting')

View File

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

View File

@ -178,10 +178,10 @@ export default function SearchResultsPage() {
if (isLoading) { if (isLoading) {
return ( 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="max-w-7xl mx-auto">
<div className="text-center"> <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-xl text-neutral-700 font-medium">{t('loadingTitle')}</p>
<p className="text-neutral-500 mt-2"> <p className="text-neutral-500 mt-2">
{origin} → {destination} {origin} → {destination}
@ -194,7 +194,7 @@ export default function SearchResultsPage() {
if (error) { if (error) {
return ( 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="max-w-7xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center"> <div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center"> <div className="mb-4 flex justify-center">
@ -220,11 +220,11 @@ export default function SearchResultsPage() {
if (results.length === 0) { if (results.length === 0) {
return ( 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="max-w-7xl mx-auto">
<button <button
onClick={() => router.back()} 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')} {t('backToSearch')}
</button> </button>
@ -247,7 +247,7 @@ export default function SearchResultsPage() {
</div> </div>
<button <button
onClick={() => router.back()} 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')} {t('modifySearch')}
</button> </button>
@ -274,10 +274,10 @@ export default function SearchResultsPage() {
type: t('options.standard'), type: t('options.standard'),
option: bestOptions?.standard, option: bestOptions?.standard,
colors: { colors: {
border: 'border-brand-turquoise/30', border: 'border-brand-blue/30',
bg: 'bg-brand-turquoise/5', bg: 'bg-brand-blue/5',
text: 'text-brand-navy', 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" />, icon: <Scale className="h-10 w-10 text-brand-navy" />,
badge: t('options.badgeBalanced'), badge: t('options.badgeBalanced'),
@ -297,13 +297,13 @@ export default function SearchResultsPage() {
]; ];
return ( 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="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
<div className="mb-8"> <div className="mb-8">
<button <button
onClick={() => router.back()} 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')} {t('backToSearch')}
</button> </button>
@ -333,7 +333,7 @@ export default function SearchResultsPage() {
</div> </div>
{editBookingId && ( {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')} {t('editMode')}
</div> </div>
)} )}
@ -501,7 +501,7 @@ export default function SearchResultsPage() {
<button <button
onClick={() => handleSelect(result, `all-${index}`)} onClick={() => handleSelect(result, `all-${index}`)}
disabled={submittingId === `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}` {submittingId === `all-${index}`
? '...' ? '...'

View File

@ -32,7 +32,7 @@ function CopyButton({ text }: { text: string }) {
return ( return (
<button <button
onClick={handleCopy} 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 ? ( {copied ? (
<> <>
@ -141,7 +141,7 @@ function CreateKeyModal({
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md"> <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 justify-between p-6 border-b">
<div className="flex items-center gap-3"> <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" /> <Key className="w-5 h-5 text-brand-navy" />
</div> </div>
<h2 className="text-lg font-semibold text-brand-navy">{t('title')}</h2> <h2 className="text-lg font-semibold text-brand-navy">{t('title')}</h2>
@ -193,7 +193,7 @@ function CreateKeyModal({
<button <button
type="button" type="button"
onClick={onClose} 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')} {t('cancel')}
</button> </button>
@ -240,7 +240,7 @@ function RevokeConfirmModal({
<div className="px-6 pb-6 flex gap-3"> <div className="px-6 pb-6 flex gap-3">
<button <button
onClick={onClose} 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')} {t('cancel')}
</button> </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" /> <ShieldCheck className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5" />
<div className="text-sm text-brand-navy"> <div className="text-sm text-brand-navy">
<p className="font-medium mb-0.5">{t('infoTitle')}</p> <p className="font-medium mb-0.5">{t('infoTitle')}</p>
<p> <p>
{t.rich('infoBody', { {t.rich('infoBody', {
code: () => ( 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_... X-API-Key: xped_live_...
</code> </code>
), ),

View File

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

View File

@ -19,7 +19,7 @@ export default function SubscriptionPage() {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-center"> <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> <p className="text-neutral-600">{t('loading')}</p>
</div> </div>
</div> </div>

View File

@ -50,7 +50,7 @@ function Pagination({
onClick={() => onPage(p)} onClick={() => onPage(p)}
className={`px-3 py-1 rounded text-sm border ${ className={`px-3 py-1 rounded text-sm border ${
p === page 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' : 'border-neutral-300 hover:bg-neutral-50'
}`} }`}
> >
@ -199,7 +199,7 @@ export default function UsersManagementPage() {
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) { if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <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> </div>
); );
} }
@ -249,7 +249,7 @@ export default function UsersManagementPage() {
const getRoleBadgeColor = (role: string) => { const getRoleBadgeColor = (role: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800', 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', USER: 'bg-green-100 text-green-800',
VIEWER: 'bg-neutral-100 text-neutral-800', VIEWER: 'bg-neutral-100 text-neutral-800',
}; };
@ -304,10 +304,10 @@ export default function UsersManagementPage() {
licenseStatus.canInvite && licenseStatus.canInvite &&
licenseStatus.availableLicenses <= 2 && licenseStatus.availableLicenses <= 2 &&
licenseStatus.maxLicenses !== -1 && ( 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 justify-between">
<div className="flex items-center"> <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 <path
fillRule="evenodd" 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" 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> </div>
<Link <Link
href="/dashboard/settings/subscription" 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')} {t('license.manageLink')}
</Link> </Link>
@ -365,7 +365,7 @@ export default function UsersManagementPage() {
{licenseStatus?.canInvite ? ( {licenseStatus?.canInvite ? (
<button <button
onClick={() => setShowInviteModal(true)} 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="mr-1.5">+</span>
<span className="hidden sm:inline">{t('actions.invite')}</span> <span className="hidden sm:inline">{t('actions.invite')}</span>
@ -408,7 +408,7 @@ export default function UsersManagementPage() {
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="px-6 py-12 text-center text-neutral-500"> <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')} {t('loading')}
</div> </div>
) : pagedUsers.length > 0 ? ( ) : pagedUsers.length > 0 ? (
@ -442,7 +442,7 @@ export default function UsersManagementPage() {
<tr key={user.id} className="hover:bg-neutral-50"> <tr key={user.id} className="hover:bg-neutral-50">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center"> <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.firstName[0]}
{user.lastName[0]} {user.lastName[0]}
</div> </div>
@ -544,7 +544,7 @@ export default function UsersManagementPage() {
{licenseStatus?.canInvite ? ( {licenseStatus?.canInvite ? (
<button <button
onClick={() => setShowInviteModal(true)} 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> <span className="mr-2">+</span>
{t('actions.invite')} {t('actions.invite')}
@ -693,7 +693,7 @@ export default function UsersManagementPage() {
setMenuPosition(null); setMenuPosition(null);
}} }}
disabled={toggleActiveMutation.isPending} 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 ? ( {users?.users.find(u => u.id === openMenuId)?.isActive ? (
<> <>
@ -802,7 +802,7 @@ export default function UsersManagementPage() {
required required
value={inviteForm.firstName} value={inviteForm.firstName}
onChange={e => setInviteForm({ ...inviteForm, firstName: e.target.value })} 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>
<div> <div>
@ -814,7 +814,7 @@ export default function UsersManagementPage() {
required required
value={inviteForm.lastName} value={inviteForm.lastName}
onChange={e => setInviteForm({ ...inviteForm, lastName: e.target.value })} 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>
</div> </div>
@ -827,7 +827,7 @@ export default function UsersManagementPage() {
required required
value={inviteForm.email} value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })} 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>
<div> <div>
@ -837,7 +837,7 @@ export default function UsersManagementPage() {
<select <select
value={inviteForm.role} value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })} 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="USER">{t('modal.roles.USER')}</option>
<option value="MANAGER">{t('modal.roles.MANAGER')}</option> <option value="MANAGER">{t('modal.roles.MANAGER')}</option>
@ -848,7 +848,7 @@ export default function UsersManagementPage() {
<button <button
type="submit" type="submit"
disabled={inviteMutation.isPending} 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')} {inviteMutation.isPending ? t('modal.submitting') : t('modal.submit')}
</button> </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', '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', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active active
? 'border-brand-turquoise bg-brand-turquoise/5' ? 'border-brand-blue bg-brand-blue/5'
: 'border-border hover:bg-neutral-50' : 'border-border hover:bg-neutral-50'
)} )}
> >
@ -340,7 +340,7 @@ export default function TrackTracePage() {
<Card className={cn('overflow-hidden', isMapFullscreen && 'h-full rounded-none')}> <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 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"> <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"> <div className="min-w-0">
<p className="truncate font-heading text-body-sm font-semibold"> <p className="truncate font-heading text-body-sm font-semibold">
{t('map.title')} {t('map.title')}
@ -387,7 +387,7 @@ export default function TrackTracePage() {
role="status" role="status"
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-neutral-50" 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-sm font-medium text-brand-navy">{t('map.loading')}</p>
<p className="text-body-xs text-neutral-500">{t('map.connecting')}</p> <p className="text-body-xs text-neutral-500">{t('map.connecting')}</p>
</div> </div>
@ -432,7 +432,7 @@ export default function TrackTracePage() {
href="https://www.marinetraffic.com" href="https://www.marinetraffic.com"
target="_blank" target="_blank"
rel="noopener noreferrer" 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')} {t('map.openOnSite')}
<ExternalLink className="size-3" aria-hidden="true" /> <ExternalLink className="size-3" aria-hidden="true" />

View File

@ -99,7 +99,7 @@ export default async function AssurancePage() {
</CardContent> </CardContent>
</Card> </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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-2">{t('assurance.valueTitle')}</h3> <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"> <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>
</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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-4">{t('conteneurs.selectionTitle')}</h3> <h3 className="font-semibold text-brand-navy mb-4">{t('conteneurs.selectionTitle')}</h3>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <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"> <th className="text-left py-2 font-medium text-brand-navy">
{t('conteneurs.colCondition')} {t('conteneurs.colCondition')}
</th> </th>
@ -98,7 +98,7 @@ export default async function ConteneursPage() {
</thead> </thead>
<tbody> <tbody>
{selectionGuide.map((row, i) => ( {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 text-brand-navy">{row.condition}</td>
<td className="py-2 font-medium text-brand-navy">{row.recommendation}</td> <td className="py-2 font-medium text-brand-navy">{row.recommendation}</td>
</tr> </tr>

View File

@ -45,7 +45,7 @@ export default async function DocumentsTransportPage() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-brand-navy">{doc.name}</h4> <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} {doc.type}
</span> </span>
</div> </div>
@ -84,14 +84,14 @@ export default async function DocumentsTransportPage() {
</div> </div>
</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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-4"> <h3 className="font-semibold text-brand-navy mb-4">
{t('documentsTransport.blFocusTitle')} {t('documentsTransport.blFocusTitle')}
</h3> </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{blFunctions.map(fn => ( {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> <h4 className="font-medium text-brand-navy mb-1">{fn.title}</h4>
<p className="text-sm text-neutral-600">{fn.description}</p> <p className="text-sm text-neutral-600">{fn.description}</p>
</div> </div>

View File

@ -96,7 +96,7 @@ export default async function ImdgPage() {
</div> </div>
</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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-2">{t('imdg.labelingTitle')}</h3> <h3 className="font-semibold text-brand-navy mb-2">{t('imdg.labelingTitle')}</h3>
<p className="text-sm text-brand-navy">{t('imdg.labelingContent')}</p> <p className="text-sm text-brand-navy">{t('imdg.labelingContent')}</p>

View File

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

View File

@ -32,7 +32,7 @@ export default async function LclVsFclPage() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <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"> <CardContent className="pt-6">
<h3 className="text-lg font-bold text-brand-navy mb-2">{t('lclVsFcl.lclTitle')}</h3> <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> <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"> <div className="space-y-3">
{lclProcess.map(step => ( {lclProcess.map(step => (
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border"> <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} {step.step}
</div> </div>
<div> <div>
@ -88,13 +88,13 @@ export default async function LclVsFclPage() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8"> <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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-3">{t('lclVsFcl.chooseLclTitle')}</h3> <h3 className="font-semibold text-brand-navy mb-3">{t('lclVsFcl.chooseLclTitle')}</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{chooseLcl.map((item, i) => ( {chooseLcl.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-brand-navy"> <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> </li>
))} ))}
</ul> </ul>

View File

@ -95,7 +95,7 @@ export default async function LettreCreditPage() {
</CardContent> </CardContent>
</Card> </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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-2">{t('lettreCredit.ucp600Title')}</h3> <h3 className="font-semibold text-brand-navy mb-2">{t('lettreCredit.ucp600Title')}</h3>
<p className="text-sm text-brand-navy">{t('lettreCredit.ucp600Content')}</p> <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; const IconComponent = topic.icon;
return ( return (
<Link key={topic.href} href={topic.href} className="block group"> <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> <CardHeader>
<div className="flex items-start justify-between"> <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" /> <IconComponent className="h-5 w-5 text-brand-navy" />
</div> </div>
</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)} {t(`topics.${topic.key}.title` as any)}
</CardTitle> </CardTitle>
<CardDescription className="text-neutral-600"> <CardDescription className="text-neutral-600">
@ -134,7 +134,7 @@ export default async function WikiPage() {
{topic.tags.map(tag => ( {topic.tags.map(tag => (
<span <span
key={tag} 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} {tag}
</span> </span>
@ -148,7 +148,7 @@ export default async function WikiPage() {
</div> </div>
{/* Footer info */} {/* 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"> <p className="text-sm text-brand-navy">
<span className="font-semibold">{t('needHelp')} </span> <span className="font-semibold">{t('needHelp')} </span>
{t('helpText')} {t('helpText')}

View File

@ -63,7 +63,7 @@ export default async function PortsRoutesPage() {
{route.majorPorts.map(port => ( {route.majorPorts.map(port => (
<span <span
key={port} 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} {port}
</span> </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> <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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{passages.map(p => ( {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"> <CardContent className="pt-4">
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div> <div>
@ -88,7 +88,7 @@ export default async function PortsRoutesPage() {
{p.location} — {t('portsRoutes.colLength')}: {p.length} {p.location} — {t('portsRoutes.colLength')}: {p.length}
</p> </p>
</div> </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} {p.keyStat}
</span> </span>
</div> </div>
@ -126,7 +126,7 @@ export default async function PortsRoutesPage() {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8"> <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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-2">{t('portsRoutes.hubTitle')}</h3> <h3 className="font-semibold text-brand-navy mb-2">{t('portsRoutes.hubTitle')}</h3>
<p className="text-sm text-brand-navy">{t('portsRoutes.hubDescription')}</p> <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> <p className="mt-3 text-neutral-600 max-w-3xl">{t('transitTime.description')}</p>
</div> </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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-3">{t('transitTime.keyTermsTitle')}</h3> <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"> <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) => ( {timeline.map((item, index) => (
<Card <Card
key={index} 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"> <CardContent className="py-4">
<div className="flex items-start gap-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} {index + 1}
</div> </div>
<div className="flex-1"> <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> <p className="mt-3 text-neutral-600 max-w-3xl">{t('vgm.description')}</p>
</div> </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"> <CardContent className="pt-6">
<h3 className="font-semibold text-brand-navy mb-3">{t('vgm.whyTitle')}</h3> <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"> <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"> <Card className="bg-white">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="bg-neutral-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg"> <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')} {t('vgm.formula')}
</span> </span>
</div> </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> <p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@ -205,7 +205,7 @@ export default function ForgotPasswordPage() {
</div> </div>
</div> </div>
<div className="flex items-start"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"

View File

@ -377,7 +377,7 @@ function LoginPageContent() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@ -403,7 +403,7 @@ function LoginPageContent() {
</div> </div>
<div className="flex items-start"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@ -427,7 +427,7 @@ function LoginPageContent() {
</div> </div>
<div className="flex items-start"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@ -456,13 +456,13 @@ function LoginPageContent() {
className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700" className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700"
> >
<div> <div>
<div className="text-display-sm text-brand-turquoise"> <div className="text-display-sm text-brand-blue">
<AnimatedCounter end={50} suffix="+" isActive={statsActive} /> <AnimatedCounter end={50} suffix="+" isActive={statsActive} />
</div> </div>
<div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.carriers')}</div> <div className="text-body-sm text-neutral-300 mt-1">{tPanel('stats.carriers')}</div>
</div> </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} /> <AnimatedCounter end={10} suffix="k+" isActive={statsActive} />
</div> </div>
<div className="text-body-sm text-neutral-300 mt-1"> <div className="text-body-sm text-neutral-300 mt-1">
@ -470,7 +470,7 @@ function LoginPageContent() {
</div> </div>
</div> </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} /> <AnimatedCounter end={99.5} decimals={1} suffix="%" isActive={statsActive} />
</div> </div>
<div className="text-body-sm text-neutral-300 mt-1"> <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> <p className="text-body-lg text-neutral-200 mb-12">{t('sidePanel.description')}</p>
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@ -238,7 +238,7 @@ function RegisterPageContent() {
</div> </div>
</div> </div>
<div className="flex items-start"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@ -263,7 +263,7 @@ function RegisterPageContent() {
</div> </div>
</div> </div>
<div className="flex items-start"> <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 <svg
className="w-6 h-6 text-white" className="w-6 h-6 text-white"
fill="none" fill="none"
@ -291,7 +291,7 @@ function RegisterPageContent() {
className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700" className="grid grid-cols-3 gap-8 mt-12 pt-12 border-t border-neutral-700"
> >
<div> <div>
<div className="text-display-sm text-brand-turquoise"> <div className="text-display-sm text-brand-blue">
<AnimatedCounter end={2} suffix="k+" isActive={statsActive} /> <AnimatedCounter end={2} suffix="k+" isActive={statsActive} />
</div> </div>
<div className="text-body-sm text-neutral-300 mt-1"> <div className="text-body-sm text-neutral-300 mt-1">
@ -299,7 +299,7 @@ function RegisterPageContent() {
</div> </div>
</div> </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} /> <AnimatedCounter end={150} suffix="+" isActive={statsActive} />
</div> </div>
<div className="text-body-sm text-neutral-300 mt-1"> <div className="text-body-sm text-neutral-300 mt-1">
@ -307,7 +307,7 @@ function RegisterPageContent() {
</div> </div>
</div> </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} /> <AnimatedCounter end={24} suffix="/7" isActive={statsActive} />
</div> </div>
<div className="text-body-sm text-neutral-300 mt-1"> <div className="text-body-sm text-neutral-300 mt-1">

View File

@ -276,7 +276,7 @@ function ResetPasswordContent() {
{tips.map(tip => ( {tips.map(tip => (
<div key={tip} className="flex items-center gap-3"> <div key={tip} className="flex items-center gap-3">
<svg <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" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@ -47,7 +47,7 @@ function VerifyEmailContent() {
<h1 className="text-4xl font-bold text-brand-navy">Xpeditis</h1> <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> <h2 className="mt-6 text-2xl font-bold text-brand-navy">{t('verifyingTitle')}</h2>
<div className="mt-4"> <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> </div>
</div> </div>
@ -89,7 +89,7 @@ function VerifyEmailContent() {
</div> </div>
<div className="text-center"> <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')} {t('goToDashboard')}
</Link> </Link>
</div> </div>
@ -132,7 +132,7 @@ function VerifyEmailContent() {
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<p className="text-sm text-neutral-600">{t('errorHint')}</p> <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')} {t('backToLogin')}
</Link> </Link>
</div> </div>

View File

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

View File

@ -157,7 +157,20 @@
"subtitle": "Compare rates from 50+ shipping lines, book online and track your shipments in real time.", "subtitle": "Compare rates from 50+ shipping lines, book online and track your shipments in real time.",
"ctaAuthenticated": "Go to dashboard", "ctaAuthenticated": "Go to dashboard",
"ctaRegister": "Create a free account", "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": { "stats": {
"carriers": "Shipping Lines", "carriers": "Shipping Lines",
@ -359,7 +372,12 @@
"pending": "Pending", "pending": "Pending",
"totalWeight": "Total Weight", "totalWeight": "Total Weight",
"thisMonth": "+{count} this month", "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": { "charts": {
"distribution": { "distribution": {
@ -373,6 +391,23 @@
"title": "Weight by Carrier", "title": "Weight by Carrier",
"description": "Top 5 carriers by weight (KG)", "description": "Top 5 carriers by weight (KG)",
"weight": "Weight" "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": { "performance": {
@ -400,6 +435,25 @@
"totalVolume": "Total Volume (CBM)", "totalVolume": "Total Volume (CBM)",
"acceptanceRate": "Acceptance rate (%)", "acceptanceRate": "Acceptance rate (%)",
"avgPrice": "Average price ($)" "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": { "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.", "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", "ctaAuthenticated": "Accéder au tableau de bord",
"ctaRegister": "Créer un compte gratuit", "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": { "stats": {
"carriers": "Compagnies Maritimes", "carriers": "Compagnies Maritimes",
@ -359,7 +372,12 @@
"pending": "En Attente", "pending": "En Attente",
"totalWeight": "Poids Total", "totalWeight": "Poids Total",
"thisMonth": "+{count} ce mois", "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": { "charts": {
"distribution": { "distribution": {
@ -373,6 +391,23 @@
"title": "Poids par Transporteur", "title": "Poids par Transporteur",
"description": "Top 5 transporteurs par poids (KG)", "description": "Top 5 transporteurs par poids (KG)",
"weight": "Poids" "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": { "performance": {
@ -400,6 +435,25 @@
"totalVolume": "Volume Total (CBM)", "totalVolume": "Volume Total (CBM)",
"acceptanceRate": "Taux d'acceptation (%)", "acceptanceRate": "Taux d'acceptation (%)",
"avgPrice": "Prix moyen ($)" "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": { "bookingsList": {

View File

@ -57,7 +57,7 @@ export default function CookieConsent() {
exit={{ scale: 0, opacity: 0 }} exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 260, damping: 20 }} transition={{ type: 'spring', stiffness: 260, damping: 20 }}
onClick={openPreferences} 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')} aria-label={t('openAria')}
> >
<Cookie className="w-5 h-5" /> <Cookie className="w-5 h-5" />

View File

@ -41,7 +41,7 @@ export default function DebugUser() {
</div> </div>
<div> <div>
<span className="text-yellow-400">localStorage user:</span> <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} {storageData.user}
</pre> </pre>
</div> </div>

View File

@ -177,8 +177,10 @@ export default function ExportButton<T extends Record<string, any>>({
onClick={() => (canExport ? setIsOpen(!isOpen) : (window.location.href = '/pricing'))} onClick={() => (canExport ? setIsOpen(!isOpen) : (window.location.href = '/pricing'))}
disabled={disabled || data.length === 0 || isExporting} disabled={disabled || data.length === 0 || isExporting}
title={!canExport ? t('lockedTooltip') : undefined} 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 ${ // Action secondaire : meme gabarit que la variante `outline` de Button,
canExport ? 'text-neutral-700 hover:bg-neutral-50' : 'text-neutral-400 hover:bg-neutral-50' // 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 ? ( {isExporting ? (

View File

@ -149,7 +149,7 @@ export default function NotificationDropdown() {
<button <button
onClick={() => markAllAsReadMutation.mutate()} onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending} 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')} {t('markAllRead')}
</button> </button>
@ -173,7 +173,7 @@ export default function NotificationDropdown() {
key={notification.id} key={notification.id}
onClick={() => handleNotificationClick(notification)} onClick={() => handleNotificationClick(notification)}
className={`block px-4 py-3 hover:bg-neutral-50 transition-colors cursor-pointer ${ 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"> <div className="flex items-start space-x-3">
@ -189,7 +189,7 @@ export default function NotificationDropdown() {
{notification.title} {notification.title}
</p> </p>
{!notification.read && ( {!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> </div>
<p className="text-sm text-neutral-600 line-clamp-2"> <p className="text-sm text-neutral-600 line-clamp-2">
@ -216,7 +216,7 @@ export default function NotificationDropdown() {
setIsOpen(false); setIsOpen(false);
setIsPanelOpen(true); 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')} {t('viewAll')}
</button> </button>

View File

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

View File

@ -192,7 +192,7 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
<button <button
type="submit" type="submit"
disabled={submitting} 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'} {submitting ? 'Saving...' : carrier ? 'Update Carrier' : 'Create Carrier'}
</button> </button>

View File

@ -168,7 +168,7 @@ export function CsvUpload() {
disabled={loading} disabled={loading}
className={`px-4 py-2 text-sm rounded-md border transition-colors disabled:opacity-50 ${ className={`px-4 py-2 text-sm rounded-md border transition-colors disabled:opacity-50 ${
direction === option.value 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' : 'border-neutral-300 hover:bg-neutral-50'
}`} }`}
> >

View File

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

View File

@ -43,7 +43,7 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => setIsExpanded(!isExpanded)} 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')} {isExpanded ? t('showLess') : t('showMore')}
</button> </button>
@ -102,7 +102,7 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
onClick={() => handleStatusChange(status)} onClick={() => handleStatusChange(status)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${ className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filters.status?.includes(status) 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' : '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} key={row.id}
onClick={() => onRowClick?.(row.original)} onClick={() => onRowClick?.(row.original)}
className={`hover:bg-neutral-50 cursor-pointer ${ 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` }} style={{ height: `${virtualRow.size}px` }}
> >
@ -265,7 +265,7 @@ function getStatusColor(status: BookingStatus): string {
case BookingStatus.DRAFT: case BookingStatus.DRAFT:
return 'bg-neutral-100 text-neutral-800'; return 'bg-neutral-100 text-neutral-800';
case BookingStatus.CONFIRMED: case BookingStatus.CONFIRMED:
return 'bg-brand-turquoise/10 text-brand-navy'; return 'bg-brand-blue/10 text-brand-navy';
case BookingStatus.IN_PROGRESS: case BookingStatus.IN_PROGRESS:
return 'bg-yellow-100 text-yellow-800'; return 'bg-yellow-100 text-yellow-800';
case BookingStatus.COMPLETED: case BookingStatus.COMPLETED:

View File

@ -40,7 +40,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
if (selectedCount === 0) return null; if (selectedCount === 0) return null;
return ( 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 justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm font-medium text-brand-navy"> <span className="text-sm font-medium text-brand-navy">
@ -48,7 +48,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
</span> </span>
<button <button
onClick={onClearSelection} 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')} {t('clearSelection')}
</button> </button>
@ -60,7 +60,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
<button <button
onClick={() => setShowExportMenu(!showExportMenu)} onClick={() => setShowExportMenu(!showExportMenu)}
disabled={exporting} 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')} {exporting ? t('exporting') : t('exportSelected')}
</button> </button>
@ -93,7 +93,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
{/* Bulk update button */} {/* Bulk update button */}
<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={() => { onClick={() => {
toast.error(t('bulkUpdateSoon')); toast.error(t('bulkUpdateSoon'));
}} }}

View File

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

View File

@ -65,7 +65,7 @@ export function FilterBar({
<SlidersHorizontal /> <SlidersHorizontal />
{labels.filters} {labels.filters}
{active.length > 0 && ( {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} {active.length}
</span> </span>
)} )}

View File

@ -23,7 +23,7 @@ export function DesignSystemShowcase() {
{/* Turquoise */} {/* Turquoise */}
<div> <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> <h4 className="mb-1">Turquoise</h4>
<p className="text-body-sm text-neutral-600">#34CCCD</p> <p className="text-body-sm text-neutral-600">#34CCCD</p>
<p className="text-body-xs text-neutral-500">Accent color for CTAs, links</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"> <div className="mt-8 p-6 bg-brand-navy rounded-lg">
<h4 className="text-white mb-4">On Dark Background</h4> <h4 className="text-white mb-4">On Dark Background</h4>
<div className="flex flex-wrap gap-4"> <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 Primary on Dark
</button> </button>
<button className="bg-white text-brand-navy font-heading font-semibold px-6 py-3 rounded-lg hover:bg-neutral-100 transition-colors"> <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>
<div> <div>
<span className="label">TOTAL PRICE</span> <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>
<div> <div>
<span className="badge-info">Best Rate</span> <span className="badge-info">Best Rate</span>
@ -275,7 +275,7 @@ export function DesignSystemShowcase() {
needs. needs.
</p> </p>
<div className="flex gap-4 justify-center"> <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 Start Free Trial
</button> </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"> <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', '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', 'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
dragging dragging
? 'border-brand-turquoise bg-brand-turquoise/5' ? 'border-brand-blue bg-brand-blue/5'
: 'border-neutral-300 hover:border-brand-turquoise hover:bg-neutral-50', : 'border-neutral-300 hover:border-brand-blue hover:bg-neutral-50',
disabled && 'pointer-events-none opacity-50' disabled && 'pointer-events-none opacity-50'
)} )}
> >
<Upload <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" aria-hidden="true"
/> />
<span className="text-body-sm text-neutral-700"> <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' ? 'bg-red-600'
: usagePercentage >= 70 : usagePercentage >= 70
? 'bg-yellow-500' ? 'bg-yellow-500'
: 'bg-brand-turquoise' : 'bg-brand-blue'
}`} }`}
style={{ width: `${Math.min(usagePercentage, 100)}%` }} style={{ width: `${Math.min(usagePercentage, 100)}%` }}
></div> ></div>
@ -111,7 +111,7 @@ export default function LicensesTab() {
</h3> </h3>
<Link <Link
href="/dashboard/settings/users" 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" /> <UserPlus className="w-4 h-4 mr-2" />
Inviter un utilisateur Inviter un utilisateur
@ -309,10 +309,10 @@ export default function LicensesTab() {
)} )}
{/* Info Box */} {/* 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">
<div className="flex-shrink-0"> <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 <path
fillRule="evenodd" 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" 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 <button
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing} 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 <svg
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
@ -241,7 +241,7 @@ export default function SubscriptionTab() {
<button <button
onClick={handleManageBilling} onClick={handleManageBilling}
disabled={portalMutation.isPending} 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')} {portalMutation.isPending ? t('manageBillingLoading') : t('manageBilling')}
</button> </button>
@ -264,7 +264,7 @@ export default function SubscriptionTab() {
? 'bg-red-600' ? 'bg-red-600'
: usagePercentage >= 70 : usagePercentage >= 70
? 'bg-yellow-500' ? 'bg-yellow-500'
: 'bg-brand-turquoise' : 'bg-brand-blue'
}`} }`}
style={{ style={{
width: width:
@ -330,7 +330,7 @@ export default function SubscriptionTab() {
key={plan.plan} key={plan.plan}
className={`bg-white rounded-lg border-2 p-5 ${ className={`bg-white rounded-lg border-2 p-5 ${
isCurrentPlan(plan.plan) isCurrentPlan(plan.plan)
? 'border-brand-turquoise shadow-md' ? 'border-brand-blue shadow-md'
: 'border-border hover:border-neutral-300 hover:shadow transition' : 'border-border hover:border-neutral-300 hover:shadow transition'
}`} }`}
> >
@ -387,7 +387,7 @@ export default function SubscriptionTab() {
) : plan.plan === 'PLATINIUM' ? ( ) : plan.plan === 'PLATINIUM' ? (
<a <a
href="mailto:sales@xpeditis.com?subject=Demande Enterprise" 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')} {t('contactUs')}
</a> </a>
@ -395,7 +395,7 @@ export default function SubscriptionTab() {
<button <button
onClick={() => handleUpgrade(plan.plan)} onClick={() => handleUpgrade(plan.plan)}
disabled={checkoutMutation.isPending && selectedPlan === 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 {checkoutMutation.isPending && selectedPlan === plan.plan
? t('upgradeLoading') ? t('upgradeLoading')

View File

@ -32,7 +32,7 @@ type SortOrder = 'asc' | 'desc';
const SERVICE_LEVEL_LABELS: Record<ServiceLevel, { label: string; color: string }> = { const SERVICE_LEVEL_LABELS: Record<ServiceLevel, { label: string; color: string }> = {
ECONOMIC: { label: 'Éco', color: 'bg-green-100 text-green-800 border-green-200' }, 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' }, RAPID: { label: 'Rapide', color: 'bg-orange-100 text-orange-800 border-orange-200' },
}; };

View File

@ -90,7 +90,7 @@ export function VolumeWeightInput({
</div> </div>
{/* Info message */} {/* 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 <svg
className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5" className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5"
fill="currentColor" fill="currentColor"

View File

@ -62,7 +62,7 @@ export function BottomNav({ pathname, labels, onMore }: BottomNavProps) {
{active && ( {active && (
<span <span
aria-hidden="true" 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> </span>

View File

@ -171,7 +171,7 @@ export function CommandMenu({
{item.groupLabel} {item.groupLabel}
</span> </span>
{active && ( {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> </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', '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', 'after:absolute after:inset-x-2 after:-bottom-px after:h-0.5 after:rounded-full',
active 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' : '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' : '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 && ( {active && (
<span <span
aria-hidden="true" aria-hidden="true"
className={cn( 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' collapsed && '-left-1'
)} )}
/> />

View File

@ -53,7 +53,7 @@ export default function FeatureGate({ feature, children, fallback }: FeatureGate
</p> </p>
<Link <Link
href="/pricing" 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 Voir les plans
</Link> </Link>

View File

@ -18,7 +18,7 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
neutral: 'border-transparent bg-neutral-100 text-neutral-700', 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', success: 'border-transparent bg-success/10 text-success',
warning: 'border-transparent bg-amber-100 text-amber-800', warning: 'border-transparent bg-amber-100 text-amber-800',
danger: 'border-transparent bg-destructive/10 text-destructive', danger: 'border-transparent bg-destructive/10 text-destructive',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,10 +36,10 @@ const TabsTrigger = React.forwardRef<
'transition-colors hover:text-brand-navy', 'transition-colors hover:text-brand-navy',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
'disabled:pointer-events-none disabled:opacity-50', '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', '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', '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 className
)} )}
{...props} {...props}

View File

@ -132,7 +132,7 @@ const variantConfig: Record<
success: { icon: CheckCircle2, iconClass: 'text-success', role: 'status' }, success: { icon: CheckCircle2, iconClass: 'text-success', role: 'status' },
error: { icon: XCircle, iconClass: 'text-destructive', role: 'alert' }, error: { icon: XCircle, iconClass: 'text-destructive', role: 'alert' },
warning: { icon: AlertTriangle, iconClass: 'text-amber-500', 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() { function Toaster() {
@ -146,8 +146,9 @@ function Toaster() {
aria-atomic="false" aria-atomic="false"
className={cn( className={cn(
'pointer-events-none fixed z-[100] flex flex-col gap-2', 'pointer-events-none fixed z-[100] flex flex-col gap-2',
// Mobile : en haut, pleine largeur. Desktop : en bas a droite. // Mobile : en haut, pleine largeur. Desktop : en bas a droite, au-dessus
'inset-x-4 top-4 sm:inset-x-auto sm:bottom-4 sm:right-4 sm:top-auto sm:w-96' // 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 => { {toasts.map(t => {
@ -174,7 +175,7 @@ function Toaster() {
t.action?.onClick(); t.action?.onClick();
dismiss(t.id); 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} {t.action.label}
</button> </button>

View File

@ -12,8 +12,10 @@ import { get } from './client';
export interface DashboardKPIs { export interface DashboardKPIs {
bookingsThisMonth: number; bookingsThisMonth: number;
bookingsThisMonthChange: number; bookingsThisMonthChange: number;
totalTEUs: number; /** Volume LCL du mois, en CBM. */
totalTEUsChange: number; volumeCBM: number;
volumeCBMChange: number;
/** Commission Xpeditis sur les reservations acceptees, en EUR. */
estimatedRevenue: number; estimatedRevenue: number;
estimatedRevenueChange: number; estimatedRevenueChange: number;
pendingConfirmations: number; pendingConfirmations: number;
@ -24,6 +26,7 @@ export interface DashboardKPIs {
* Bookings Chart Data * Bookings Chart Data
*/ */
export interface BookingsChartData { export interface BookingsChartData {
/** Cles de mois ISO `YYYY-MM` ; mises en forme cote client. */
labels: string[]; labels: string[];
data: number[]; data: number[];
} }
@ -34,8 +37,10 @@ export interface BookingsChartData {
export interface TradeLane { export interface TradeLane {
route: string; route: string;
bookingCount: number; bookingCount: number;
origin: string; originPort: string;
destination: string; destinationPort: string;
totalVolumeCBM: number;
avgPrice: number;
} }
/** /**
@ -44,8 +49,10 @@ export interface TradeLane {
export interface DashboardAlert { export interface DashboardAlert {
id: string; id: string;
severity: 'critical' | 'high' | 'medium' | 'low'; severity: 'critical' | 'high' | 'medium' | 'low';
title: string; /** Cles de traduction resolues par le client. */
message: string; titleKey: string;
messageKey: string;
messageParams?: Record<string, string | number>;
bookingId?: string; bookingId?: string;
bookingNumber?: string; bookingNumber?: string;
createdAt: 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 * Recharts n'accepte que des couleurs litterales : ces constantes centralisent
* exactement les valeurs de la charte (voir docs/design-system-audit.md §1) et * les valeurs et evitent que chaque page reinvente les siennes.
* remplacent les couleurs qui avaient ete ecrites en dur dans les pages —
* dont `#8884d8`, le violet par defaut de Recharts reste par oubli.
* *
* 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 */ /** Serie unique : magnitude, evolution, classement. */
primary: '#34CCCD', export const SERIES = {
/** brand-navy — serie de contraste */ /** brand-blue — accent applicatif */
primary: '#2563EB',
/** brand-navy — seconde serie de contraste si necessaire */
navy: '#10183A', navy: '#10183A',
/** success / brand-green */
success: '#067224',
/** --destructive */
danger: '#EF4444',
/** amber-500 — avertissement / en attente */
warning: '#F59E0B',
} as const; } 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 = { export const CHART_UI = {
/** neutral-100 */ /** neutral-100 */
grid: '#EDEEF5', grid: '#EDEEF5',
@ -32,6 +48,17 @@ export const CHART_UI = {
surface: '#FFFFFF', surface: '#FFFFFF',
} as const; } 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. */ /** Style d'infobulle commun, aux polices et couleurs de la charte. */
export const chartTooltipStyle = { export const chartTooltipStyle = {
contentStyle: { contentStyle: {
@ -40,15 +67,17 @@ export const chartTooltipStyle = {
borderRadius: '0.5rem', borderRadius: '0.5rem',
fontSize: '0.75rem', fontSize: '0.75rem',
fontFamily: 'var(--font-montserrat), sans-serif', fontFamily: 'var(--font-montserrat), sans-serif',
color: CHART_COLORS.navy, color: SERIES.navy,
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)', 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; } as const;
/** Style d'axe commun. */ /** Style d'axe commun : le trait recule, la donnee avance. */
export const chartAxisProps = { export const chartAxisProps = {
tick: { fontSize: 11, fill: CHART_UI.axis }, tick: { fontSize: 11, fill: CHART_UI.axis },
tickLine: false, tickLine: false,
axisLine: { stroke: CHART_UI.border }, axisLine: false,
} as const; } as const;

View File

@ -24,6 +24,17 @@ const config: Config = {
'brand-green': '#067224', 'brand-green': '#067224',
'brand-gray': '#F2F2F2', '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) // Shadcn UI colors (keep for components compatibility)
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',