fix ui update

This commit is contained in:
David 2026-08-13 12:26:46 +02:00
parent af9dae72d7
commit 84c34bdc21
151 changed files with 12074 additions and 8333 deletions

View File

@ -1,5 +1,23 @@
# Xpeditis Design System
> ## ⚠️ Ce document n'est pas la source de vérité
>
> La source de vérité du design system est **`apps/frontend/tailwind.config.ts`**, complétée par les variables CSS de `apps/frontend/app/globals.css`.
>
> Pour travailler sur l'UI, référez-vous à :
>
> | Document | Contenu |
> |---|---|
> | [`docs/design-system-audit.md`](../../docs/design-system-audit.md) | **Charte verrouillée** — valeurs réelles relevées dans le code, 24 sections |
> | [`docs/ui-audit.md`](../../docs/ui-audit.md) | Cartographie des pages, problèmes identifiés, duplications |
> | [`docs/ui-architecture.md`](../../docs/ui-architecture.md) | Architecture UI cible, plan de refonte, indicateurs, règles de contraste |
>
> Le présent fichier est conservé pour sa description narrative de l'identité de marque. **Les valeurs ci-dessous sont exactes**, mais les exemples d'usage qu'il contient précèdent la refonte UI et ne reflètent plus les composants en place.
>
> Deux points en particulier ont évolué depuis sa rédaction — voir `docs/ui-architecture.md` §17 :
> - Le turquoise `#34CCCD` **ne doit pas porter de texte sur fond clair** (2,05:1, échec AA). Il est réservé aux fonds, bordures et anneaux de focus.
> - Sur un aplat turquoise, le texte est **navy** (8,49:1, AAA) et non blanc.
## 📐 Charte Graphique
Ce document définit la charte graphique officielle de Xpeditis pour assurer la cohérence visuelle de l'application.

View File

@ -24,7 +24,7 @@ type TimelineKey = '2023' | '2024' | '2025' | '2026';
type StatKey = 'clients' | 'carriers' | 'countries' | 'bookings';
const VALUES: { key: ValueKey; icon: LucideIcon; color: string }[] = [
{ key: 'excellence', icon: Target, color: 'from-blue-500 to-cyan-500' },
{ key: 'excellence', icon: Target, color: 'from-brand-turquoise to-cyan-500' },
{ key: 'transparency', icon: Heart, color: 'from-pink-500 to-rose-500' },
{ key: 'collaboration', icon: Users, color: 'from-purple-500 to-indigo-500' },
{ key: 'innovation', icon: TrendingUp, color: 'from-orange-500 to-amber-500' },
@ -158,7 +158,7 @@ export default function AboutPage() {
<Target className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('mission.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed">{t('mission.body')}</p>
<p className="text-neutral-600 text-lg leading-relaxed">{t('mission.body')}</p>
</motion.div>
<motion.div
@ -169,14 +169,14 @@ export default function AboutPage() {
<Eye className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('vision.title')}</h2>
<p className="text-gray-600 text-lg leading-relaxed">{t('vision.body')}</p>
<p className="text-neutral-600 text-lg leading-relaxed">{t('vision.body')}</p>
</motion.div>
</motion.div>
</div>
</section>
{/* Stats Section */}
<section ref={statsRef} className="py-16 bg-gray-50">
<section ref={statsRef} className="py-16 bg-neutral-50">
<motion.div
variants={containerVariants}
initial="hidden"
@ -194,7 +194,7 @@ export default function AboutPage() {
>
{stat.value}
</motion.div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div>
<div className="text-neutral-600 font-medium">{t(`stats.${stat.key}`)}</div>
</motion.div>
))}
</div>
@ -213,7 +213,7 @@ export default function AboutPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('valuesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('valuesSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('valuesSubtitle')}</p>
</motion.div>
<motion.div
@ -229,7 +229,7 @@ export default function AboutPage() {
key={value.key}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
className="bg-white p-8 rounded-2xl shadow-lg border border-border hover:shadow-xl transition-all"
>
<div
className={`w-14 h-14 rounded-xl bg-gradient-to-br ${value.color} flex items-center justify-center mb-4`}
@ -239,7 +239,7 @@ export default function AboutPage() {
<h3 className="text-xl font-bold text-brand-navy mb-3">
{t(`values.${value.key}.title`)}
</h3>
<p className="text-gray-600">{t(`values.${value.key}.description`)}</p>
<p className="text-neutral-600">{t(`values.${value.key}.description`)}</p>
</motion.div>
);
})}
@ -248,7 +248,7 @@ export default function AboutPage() {
</section>
{/* Timeline Section */}
<section ref={timelineRef} className="py-20 bg-gradient-to-br from-gray-50 to-white">
<section ref={timelineRef} className="py-20 bg-gradient-to-br from-neutral-50 to-white">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -259,7 +259,7 @@ export default function AboutPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('timelineTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('timelineSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('timelineSubtitle')}</p>
</motion.div>
<div className="relative">
@ -286,7 +286,7 @@ export default function AboutPage() {
<div
className={`flex-1 ${index % 2 === 0 ? 'lg:pr-12 lg:text-right' : 'lg:pl-12'}`}
>
<div className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 inline-block hover:shadow-xl transition-shadow">
<div className="bg-white p-6 rounded-2xl shadow-lg border border-border inline-block hover:shadow-xl transition-shadow">
<div
className={`flex items-center space-x-3 mb-3 ${index % 2 === 0 ? 'lg:justify-end' : ''}`}
>
@ -296,7 +296,7 @@ export default function AboutPage() {
<h3 className="text-xl font-bold text-brand-navy mb-2">
{t(`timeline.${year}.title`)}
</h3>
<p className="text-gray-600">{t(`timeline.${year}.description`)}</p>
<p className="text-neutral-600">{t(`timeline.${year}.description`)}</p>
</div>
</div>
@ -336,7 +336,7 @@ export default function AboutPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('teamTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('teamSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('teamSubtitle')}</p>
</motion.div>
<motion.div
@ -350,7 +350,7 @@ export default function AboutPage() {
key={member.key}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden group"
className="bg-white rounded-2xl shadow-lg border border-border overflow-hidden group"
>
<div className="aspect-[4/3] bg-gradient-to-br from-brand-navy to-brand-navy/80 flex items-center justify-center relative overflow-hidden">
<div className="w-24 h-24 bg-white/20 rounded-full flex items-center justify-center">
@ -370,7 +370,7 @@ export default function AboutPage() {
<p className="text-brand-turquoise font-medium mb-3">
{t(`team.${member.key}.role`)}
</p>
<p className="text-gray-600 text-sm">{t(`team.${member.key}.bio`)}</p>
<p className="text-neutral-600 text-sm">{t(`team.${member.key}.bio`)}</p>
</div>
</motion.div>
))}
@ -399,7 +399,7 @@ export default function AboutPage() {
</Link>
<Link
href="/careers"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-neutral-100 transition-all font-semibold text-lg"
>
{t('cta.viewCareers')}
</Link>

View File

@ -38,6 +38,7 @@ import {
Sparkles,
} from 'lucide-react';
import { Link } from '@/i18n/navigation';
import { useToast } from '@/components/ui/toast';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
@ -67,9 +68,9 @@ const STATUS_LABELS: Record<BlogPostStatus, string> = {
const STATUS_COLORS: Record<BlogPostStatus, string> = {
draft: 'bg-yellow-100 text-yellow-800',
scheduled: 'bg-blue-100 text-blue-800',
scheduled: 'bg-brand-turquoise/10 text-brand-navy',
published: 'bg-green-100 text-green-800',
archived: 'bg-gray-100 text-gray-600',
archived: 'bg-neutral-100 text-neutral-600',
};
interface FormData extends CreateBlogPostRequest {
@ -134,18 +135,19 @@ function SeoPreview({
const displayDesc = metaDescription || "Description de l'article...";
const displayUrl = `xpeditis.com/blog/${slug || 'votre-slug'}`;
return (
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50 text-sm">
<p className="text-xs text-gray-400 mb-2 font-medium uppercase tracking-wide">
<div className="border border-border rounded-lg p-4 bg-neutral-50 text-sm">
<p className="text-xs text-neutral-400 mb-2 font-medium uppercase tracking-wide">
Aperçu Google
</p>
<p className="text-blue-700 text-base font-medium truncate leading-tight">{displayTitle}</p>
<p className="text-brand-navy text-base font-medium truncate leading-tight">{displayTitle}</p>
<p className="text-green-700 text-xs mt-0.5 truncate">{displayUrl}</p>
<p className="text-gray-600 text-xs mt-1 line-clamp-2 leading-relaxed">{displayDesc}</p>
<p className="text-neutral-600 text-xs mt-1 line-clamp-2 leading-relaxed">{displayDesc}</p>
</div>
);
}
export default function AdminBlogPage() {
const { toast } = useToast();
const [posts, setPosts] = useState<BlogPost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -254,7 +256,7 @@ export default function AdminBlogPage() {
await fetchPosts();
closeModal();
} catch (err: any) {
alert(err.message || 'Erreur lors de la création');
toast.error(err.message || 'Erreur lors de la création');
} finally {
setSaving(false);
}
@ -272,7 +274,7 @@ export default function AdminBlogPage() {
await fetchPosts();
closeModal();
} catch (err: any) {
alert(err.message || 'Erreur lors de la mise à jour');
toast.error(err.message || 'Erreur lors de la mise à jour');
} finally {
setSaving(false);
}
@ -286,7 +288,7 @@ export default function AdminBlogPage() {
setShowDeleteConfirm(false);
setSelectedPost(null);
} catch (err: any) {
alert(err.message || 'Erreur lors de la suppression');
toast.error(err.message || 'Erreur lors de la suppression');
}
};
@ -296,7 +298,7 @@ export default function AdminBlogPage() {
await updateBlogPost(post.id, { status: nextStatus });
await fetchPosts();
} catch (err: any) {
alert(err.message || 'Erreur lors du changement de statut');
toast.error(err.message || 'Erreur lors du changement de statut');
}
};
@ -305,17 +307,17 @@ export default function AdminBlogPage() {
await updateBlogPost(post.id, { isFeatured: !post.isFeatured });
await fetchPosts();
} catch (err: any) {
alert(err.message || 'Erreur lors du changement');
toast.error(err.message || 'Erreur lors du changement');
}
};
const uploadCoverFile = async (file: File) => {
if (!file.type.startsWith('image/')) {
alert('Veuillez sélectionner une image');
toast.error('Veuillez sélectionner une image');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image trop volumineuse (max 5 Mo)');
toast.error('Image trop volumineuse (max 5 Mo)');
return;
}
@ -331,7 +333,7 @@ export default function AdminBlogPage() {
const coverUrl = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
setFormData(prev => ({ ...prev, coverImageUrl: coverUrl }));
} catch (err: any) {
alert(err.message || "Erreur lors de l'upload");
toast.error(err.message || "Erreur lors de l'upload");
} finally {
setUploadingCover(false);
if (coverInputRef.current) coverInputRef.current.value = '';
@ -375,7 +377,7 @@ export default function AdminBlogPage() {
await duplicateBlogPost(post.id);
await fetchPosts();
} catch (err: any) {
alert(err.message || 'Erreur lors de la duplication');
toast.error(err.message || 'Erreur lors de la duplication');
} finally {
setActioningId(null);
}
@ -387,7 +389,7 @@ export default function AdminBlogPage() {
await restoreBlogPost(post.id);
await fetchPosts();
} catch (err: any) {
alert(err.message || 'Erreur lors de la restauration');
toast.error(err.message || 'Erreur lors de la restauration');
} finally {
setActioningId(null);
}
@ -401,7 +403,7 @@ export default function AdminBlogPage() {
setShowDeleteConfirm(false);
setSelectedPost(null);
} catch (err: any) {
alert(err.message || 'Erreur lors de la suppression définitive');
toast.error(err.message || 'Erreur lors de la suppression définitive');
}
};
@ -502,13 +504,13 @@ export default function AdminBlogPage() {
const metaDescLen = formData.metaDescription.length;
const metaTitleColor =
metaTitleLen === 0
? 'text-gray-400'
? 'text-neutral-400'
: metaTitleLen <= 60
? 'text-green-600'
: 'text-red-500';
const metaDescColor =
metaDescLen === 0
? 'text-gray-400'
? 'text-neutral-400'
: metaDescLen <= 160
? 'text-green-600'
: 'text-red-500';
@ -524,7 +526,7 @@ export default function AdminBlogPage() {
actions={
<button
onClick={openCreate}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
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"
>
<Plus className="w-4 h-4" />
<span>Nouvel article</span>
@ -554,8 +556,8 @@ export default function AdminBlogPage() {
onClick={() => setViewFilter(tab.value)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
viewFilter === tab.value
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 border border-gray-200 hover:bg-gray-50'
? 'bg-brand-turquoise text-brand-navy'
: 'bg-white text-neutral-600 border border-border hover:bg-neutral-50'
}`}
>
{tab.value === 'trash' && <Trash2 className="w-3.5 h-3.5" />}
@ -565,36 +567,36 @@ export default function AdminBlogPage() {
</div>
{loading ? (
<div className="text-center py-12 text-gray-500">Chargement des articles...</div>
<div className="text-center py-12 text-neutral-500">Chargement des articles...</div>
) : (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-x-auto mt-4">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<div className="bg-white rounded-xl shadow-sm border border-border overflow-x-auto mt-4">
<table className="min-w-full divide-y divide-border">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Article
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Catégorie
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Statut
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Auteur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tbody className="divide-y divide-border">
{visiblePosts.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
<td colSpan={6} className="px-6 py-12 text-center text-neutral-500">
{viewFilter === 'trash'
? 'La corbeille est vide.'
: 'Aucun article. Créez votre premier article !'}
@ -602,7 +604,7 @@ export default function AdminBlogPage() {
</tr>
) : (
visiblePosts.map(post => (
<tr key={post.id} className="hover:bg-gray-50 transition-colors">
<tr key={post.id} className="hover:bg-neutral-50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-start space-x-3">
{post.coverImageUrl && (
@ -617,15 +619,15 @@ export default function AdminBlogPage() {
{post.isFeatured && (
<Star className="w-3.5 h-3.5 text-yellow-500 flex-shrink-0" />
)}
<span className="font-medium text-gray-900 line-clamp-1">
<span className="font-medium text-brand-navy line-clamp-1">
{post.title}
</span>
</div>
<div className="text-xs text-gray-400 font-mono mt-0.5">{post.slug}</div>
<div className="text-xs text-neutral-400 font-mono mt-0.5">{post.slug}</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
<td className="px-6 py-4 text-sm text-neutral-600">
{CATEGORIES.find(c => c.value === post.category)?.label ?? post.category}
</td>
<td className="px-6 py-4">
@ -636,7 +638,7 @@ export default function AdminBlogPage() {
{STATUS_LABELS[post.status]}
</span>
{post.status === 'scheduled' && post.publishedAt && (
<span className="text-xs text-gray-400 flex items-center gap-1">
<span className="text-xs text-neutral-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(post.publishedAt).toLocaleString('fr-FR', {
day: '2-digit',
@ -649,8 +651,8 @@ export default function AdminBlogPage() {
)}
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">{post.authorName}</td>
<td className="px-6 py-4 text-sm text-gray-500">
<td className="px-6 py-4 text-sm text-neutral-600">{post.authorName}</td>
<td className="px-6 py-4 text-sm text-neutral-500">
{post.publishedAt
? new Date(post.publishedAt).toLocaleDateString('fr-FR')
: new Date(post.createdAt).toLocaleDateString('fr-FR')}
@ -662,7 +664,7 @@ export default function AdminBlogPage() {
<button
onClick={() => handleRestore(post)}
disabled={actioningId === post.id}
className="p-1.5 text-gray-400 hover:text-green-600 transition-colors disabled:opacity-50"
className="p-1.5 text-neutral-400 hover:text-green-600 transition-colors disabled:opacity-50"
title="Restaurer"
>
{actioningId === post.id ? (
@ -676,7 +678,7 @@ export default function AdminBlogPage() {
setSelectedPost(post);
setShowDeleteConfirm(true);
}}
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
className="p-1.5 text-neutral-400 hover:text-red-600 transition-colors"
title="Supprimer définitivement"
>
<Trash2 className="w-4 h-4" />
@ -687,7 +689,7 @@ export default function AdminBlogPage() {
{(post.status === 'published' || post.status === 'scheduled') && (
<Link href={`/blog/${post.slug}`} target="_blank">
<button
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
className="p-1.5 text-neutral-400 hover:text-brand-navy transition-colors"
title="Voir"
>
<ExternalLink className="w-4 h-4" />
@ -696,7 +698,7 @@ export default function AdminBlogPage() {
)}
<button
onClick={() => handleToggleFeatured(post)}
className="p-1.5 text-gray-400 hover:text-yellow-500 transition-colors"
className="p-1.5 text-neutral-400 hover:text-yellow-500 transition-colors"
title={post.isFeatured ? 'Retirer de la une' : 'Mettre à la une'}
>
{post.isFeatured ? (
@ -707,7 +709,7 @@ export default function AdminBlogPage() {
</button>
<button
onClick={() => handleToggleStatus(post)}
className="p-1.5 text-gray-400 hover:text-green-600 transition-colors"
className="p-1.5 text-neutral-400 hover:text-green-600 transition-colors"
title={post.status === 'published' ? 'Dépublier' : 'Publier'}
>
{post.status === 'published' ? (
@ -719,7 +721,7 @@ export default function AdminBlogPage() {
<button
onClick={() => handleDuplicate(post)}
disabled={actioningId === post.id}
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors disabled:opacity-50"
className="p-1.5 text-neutral-400 hover:text-brand-navy transition-colors disabled:opacity-50"
title="Dupliquer"
>
{actioningId === post.id ? (
@ -730,7 +732,7 @@ export default function AdminBlogPage() {
</button>
<button
onClick={() => openEdit(post)}
className="p-1.5 text-gray-400 hover:text-blue-600 transition-colors"
className="p-1.5 text-neutral-400 hover:text-brand-navy transition-colors"
title="Modifier"
>
<Pencil className="w-4 h-4" />
@ -740,7 +742,7 @@ export default function AdminBlogPage() {
setSelectedPost(post);
setShowDeleteConfirm(true);
}}
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors"
className="p-1.5 text-neutral-400 hover:text-red-600 transition-colors"
title="Mettre à la corbeille"
>
<Trash2 className="w-4 h-4" />
@ -759,19 +761,19 @@ export default function AdminBlogPage() {
{/* Create / Edit Modal — Full-screen overlay */}
{isOpen && (
<div className="fixed inset-0 z-50 bg-gray-50 overflow-y-auto">
<div className="fixed inset-0 z-50 bg-neutral-50 overflow-y-auto">
{/* Header */}
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between shadow-sm">
<div className="sticky top-0 z-10 bg-white border-b border-border px-6 py-4 flex items-center justify-between shadow-sm">
<div className="flex items-center gap-3">
<button
type="button"
onClick={closeModal}
className="flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg px-3 py-1.5 transition-colors"
className="flex items-center gap-1.5 text-sm font-medium text-neutral-600 hover:text-brand-navy hover:bg-neutral-100 rounded-lg px-3 py-1.5 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Retour
</button>
<h2 className="text-lg font-semibold text-gray-900">
<h2 className="text-lg font-semibold text-brand-navy">
{showCreateModal ? 'Nouvel article' : `Modifier — ${selectedPost?.title}`}
</h2>
</div>
@ -780,7 +782,7 @@ export default function AdminBlogPage() {
<select
value={editStatus}
onChange={e => setEditStatus(e.target.value as BlogPostStatus)}
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="text-sm border border-neutral-300 rounded-lg px-3 py-1.5 focus:ring-2 focus:ring-ring focus:outline-none"
>
<option value="draft">Brouillon</option>
<option value="scheduled">Planifié</option>
@ -791,7 +793,7 @@ export default function AdminBlogPage() {
<button
type="button"
onClick={closeModal}
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
className="p-2 text-neutral-400 hover:text-neutral-600 hover:bg-neutral-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
@ -812,27 +814,27 @@ export default function AdminBlogPage() {
required
value={formData.title}
onChange={e => handleTitleChange(e.target.value)}
className="w-full text-3xl font-bold text-gray-900 border-0 border-b-2 border-gray-200 focus:border-blue-500 focus:outline-none pb-2 placeholder-gray-300 bg-transparent"
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"
placeholder="Titre de l'article..."
/>
</div>
{/* Excerpt */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Extrait *</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">Extrait *</label>
<textarea
required
rows={2}
value={formData.excerpt}
onChange={e => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none text-sm resize-none"
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none text-sm resize-none"
placeholder="Courte description visible dans la liste des articles..."
/>
</div>
{/* Rich Text Editor */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Contenu *</label>
<label className="block text-sm font-medium text-neutral-700 mb-2">Contenu *</label>
<RichTextEditor
content={formData.content}
onChange={html => setFormData(prev => ({ ...prev, content: html }))}
@ -842,15 +844,15 @@ export default function AdminBlogPage() {
</div>
{/* SEO Panel */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="bg-white rounded-xl border border-border shadow-sm">
<button
type="button"
onClick={() => setSeoOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 text-left"
>
<div className="flex items-center gap-2">
<Search className="w-4 h-4 text-gray-500" />
<span className="font-semibold text-gray-900 text-sm">
<Search className="w-4 h-4 text-neutral-500" />
<span className="font-semibold text-brand-navy text-sm">
SEO & Référencement
</span>
{(formData.metaTitle || formData.metaDescription || formData.primaryKeyword) && (
@ -858,14 +860,14 @@ export default function AdminBlogPage() {
)}
</div>
{seoOpen ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
<ChevronUp className="w-4 h-4 text-neutral-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
<ChevronDown className="w-4 h-4 text-neutral-400" />
)}
</button>
{seoOpen && (
<div className="px-5 pb-5 space-y-4 border-t border-gray-100">
<div className="px-5 pb-5 space-y-4 border-t border-border">
<div className="mt-4">
<SeoPreview
metaTitle={formData.metaTitle}
@ -876,7 +878,7 @@ export default function AdminBlogPage() {
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
Meta Titre
</label>
<span className={`text-xs ${metaTitleColor}`}>
@ -890,14 +892,14 @@ export default function AdminBlogPage() {
setFormData(prev => ({ ...prev, metaTitle: e.target.value }))
}
maxLength={255}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
placeholder="Titre affiché dans Google (50-60 car. idéal)"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
Meta Description
</label>
<span className={`text-xs ${metaDescColor}`}>
@ -911,13 +913,13 @@ export default function AdminBlogPage() {
setFormData(prev => ({ ...prev, metaDescription: e.target.value }))
}
maxLength={500}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none resize-none"
placeholder="Description affichée dans Google (150-160 car. idéal)"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
Mot-clé principal
</label>
<input
@ -926,15 +928,15 @@ export default function AdminBlogPage() {
onChange={e =>
setFormData(prev => ({ ...prev, primaryKeyword: e.target.value }))
}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
placeholder="ex: devis maritime instantané"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
Mots-clés secondaires{' '}
<span className="text-gray-400 font-normal">(virgule)</span>
<span className="text-neutral-400 font-normal">(virgule)</span>
</label>
<input
type="text"
@ -945,7 +947,7 @@ export default function AdminBlogPage() {
secondaryKeywordsInput: e.target.value,
}))
}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
placeholder="ex: fret maritime, suivi de marchandise, Click&Ship"
/>
</div>
@ -954,15 +956,15 @@ export default function AdminBlogPage() {
</div>
{/* GEO / AI Panel */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm">
<div className="bg-white rounded-xl border border-border shadow-sm">
<button
type="button"
onClick={() => setGeoOpen(o => !o)}
className="w-full flex items-center justify-between px-5 py-4 text-left"
>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-gray-500" />
<span className="font-semibold text-gray-900 text-sm">
<Sparkles className="w-4 h-4 text-neutral-500" />
<span className="font-semibold text-brand-navy text-sm">
GEO — Optimisation pour l&apos;IA
</span>
{(formData.aiSummary ||
@ -973,29 +975,29 @@ export default function AdminBlogPage() {
)}
</div>
{geoOpen ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
<ChevronUp className="w-4 h-4 text-neutral-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
<ChevronDown className="w-4 h-4 text-neutral-400" />
)}
</button>
{geoOpen && (
<div className="px-5 pb-5 space-y-4 border-t border-gray-100">
<p className="text-xs text-gray-400 mt-4">
<div className="px-5 pb-5 space-y-4 border-t border-border">
<p className="text-xs text-neutral-400 mt-4">
Ces champs aident les moteurs IA (ChatGPT, Perplexity…) et le référencement
structuré à comprendre et citer votre article.
</p>
{/* AI summary */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
Résumé IA (TL;DR)
</label>
<textarea
rows={3}
value={formData.aiSummary}
onChange={e => setFormData(prev => ({ ...prev, aiSummary: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none resize-none"
placeholder="Un résumé concis de l'article, optimisé pour être cité par une IA."
/>
</div>
@ -1003,39 +1005,39 @@ export default function AdminBlogPage() {
{/* FAQ */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
FAQ (questions / réponses)
</label>
<button
type="button"
onClick={addFaqItem}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700"
className="inline-flex items-center gap-1 text-xs font-medium text-brand-navy hover:text-brand-turquoise"
>
<Plus className="w-3.5 h-3.5" />
Ajouter
</button>
</div>
{formData.faqItems.length === 0 ? (
<p className="text-xs text-gray-400">Aucune question ajoutée.</p>
<p className="text-xs text-neutral-400">Aucune question ajoutée.</p>
) : (
<div className="space-y-3">
{formData.faqItems.map((item, index) => (
<div
key={index}
className="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50"
className="border border-border rounded-lg p-3 space-y-2 bg-neutral-50"
>
<div className="flex items-center gap-2">
<input
type="text"
value={item.question}
onChange={e => updateFaqItem(index, 'question', e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
placeholder="Question"
/>
<button
type="button"
onClick={() => removeFaqItem(index)}
className="p-1.5 text-gray-400 hover:text-red-600"
className="p-1.5 text-neutral-400 hover:text-red-600"
title="Retirer"
>
<X className="w-4 h-4" />
@ -1045,7 +1047,7 @@ export default function AdminBlogPage() {
rows={2}
value={item.answer}
onChange={e => updateFaqItem(index, 'answer', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none resize-none"
placeholder="Réponse"
/>
</div>
@ -1056,9 +1058,9 @@ export default function AdminBlogPage() {
{/* Key takeaways */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
Points clés à retenir{' '}
<span className="text-gray-400 font-normal">(un par ligne)</span>
<span className="text-neutral-400 font-normal">(un par ligne)</span>
</label>
<textarea
rows={3}
@ -1066,16 +1068,16 @@ export default function AdminBlogPage() {
onChange={e =>
setFormData(prev => ({ ...prev, keyTakeawaysInput: e.target.value }))
}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none resize-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none resize-none"
placeholder={'Le fret LCL est idéal pour les petits volumes\nComparez plusieurs transporteurs'}
/>
</div>
{/* AI entities */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
Entités / sujets{' '}
<span className="text-gray-400 font-normal">(virgule)</span>
<span className="text-neutral-400 font-normal">(virgule)</span>
</label>
<input
type="text"
@ -1083,7 +1085,7 @@ export default function AdminBlogPage() {
onChange={e =>
setFormData(prev => ({ ...prev, aiEntitiesInput: e.target.value }))
}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
placeholder="ex: fret maritime, LCL, incoterms, Le Havre"
/>
</div>
@ -1095,31 +1097,31 @@ export default function AdminBlogPage() {
{/* Sidebar — 1/3 */}
<div className="space-y-5">
{/* Publication */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
<h3 className="font-semibold text-gray-900">Publication</h3>
<div className="bg-white rounded-xl border border-border p-5 shadow-sm space-y-4">
<h3 className="font-semibold text-brand-navy">Publication</h3>
{/* Scheduled date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-gray-400" />
<label className="block text-sm font-medium text-neutral-700 mb-1 flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-neutral-400" />
Date de publication planifiée
</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={e => setScheduledAt(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
/>
{scheduledAt && (
<button
type="button"
onClick={() => setScheduledAt('')}
className="text-xs text-gray-400 hover:text-red-500 mt-1 underline"
className="text-xs text-neutral-400 hover:text-red-500 mt-1 underline"
>
Effacer la date
</button>
)}
<p className="text-xs text-gray-400 mt-1">
<p className="text-xs text-neutral-400 mt-1">
{isScheduleMode
? 'Le post sera publié automatiquement à cette date.'
: 'Laisser vide pour publier manuellement.'}
@ -1140,7 +1142,7 @@ export default function AdminBlogPage() {
<button
type="submit"
disabled={saving}
className="w-full py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
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"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{isScheduleMode ? 'Planifier' : 'Enregistrer brouillon'}
@ -1150,7 +1152,7 @@ export default function AdminBlogPage() {
<button
type="submit"
disabled={saving}
className="w-full py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
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"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{saving ? 'Enregistrement...' : 'Enregistrer'}
@ -1159,8 +1161,8 @@ export default function AdminBlogPage() {
</div>
{/* Cover image */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-3">Image de couverture</h3>
<div className="bg-white rounded-xl border border-border p-5 shadow-sm">
<h3 className="font-semibold text-brand-navy mb-3">Image de couverture</h3>
{formData.coverImageUrl ? (
<div className="relative">
<img
@ -1171,7 +1173,7 @@ export default function AdminBlogPage() {
<button
type="button"
onClick={() => setFormData(prev => ({ ...prev, coverImageUrl: '' }))}
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md text-gray-600 hover:text-red-600 transition-colors"
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md text-neutral-600 hover:text-red-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
@ -1189,8 +1191,8 @@ export default function AdminBlogPage() {
onDrop={handleCoverDrop}
className={`w-full h-32 border-2 border-dashed rounded-lg flex flex-col items-center justify-center gap-2 transition-colors disabled:opacity-50 ${
isDraggingCover
? 'border-blue-500 bg-blue-50 text-blue-600'
: 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'
? 'border-brand-turquoise bg-brand-turquoise/5 text-brand-navy'
: 'border-neutral-300 text-neutral-400 hover:border-brand-turquoise hover:text-brand-navy'
}`}
>
{uploadingCover ? (
@ -1220,30 +1222,30 @@ export default function AdminBlogPage() {
onChange={e =>
setFormData(prev => ({ ...prev, coverImageUrl: e.target.value }))
}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
placeholder="Ou coller une URL..."
/>
</div>
</div>
{/* Metadata */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
<h3 className="font-semibold text-gray-900">Métadonnées</h3>
<div className="bg-white rounded-xl border border-border p-5 shadow-sm space-y-4">
<h3 className="font-semibold text-brand-navy">Métadonnées</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Slug *</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">Slug *</label>
<input
type="text"
required
value={formData.slug}
onChange={e => setFormData(prev => ({ ...prev, slug: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none font-mono"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none font-mono"
placeholder="mon-article"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
Catégorie *
</label>
<select
@ -1255,7 +1257,7 @@ export default function AdminBlogPage() {
category: e.target.value as BlogPostCategory,
}))
}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
>
{CATEGORIES.map(c => (
<option key={c.value} value={c.value}>
@ -1266,26 +1268,26 @@ export default function AdminBlogPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Auteur *</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">Auteur *</label>
<input
type="text"
required
value={formData.authorName}
onChange={e => setFormData(prev => ({ ...prev, authorName: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
placeholder="Nom de l'auteur"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tags <span className="text-gray-400 font-normal">(virgule)</span>
<label className="block text-sm font-medium text-neutral-700 mb-1">
Tags <span className="text-neutral-400 font-normal">(virgule)</span>
</label>
<input
type="text"
value={tagsInput}
onChange={e => setTagsInput(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:outline-none"
placeholder="Maritime, LCL, Export"
/>
</div>
@ -1299,10 +1301,10 @@ export default function AdminBlogPage() {
{showDeleteConfirm && selectedPost && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">
<h2 className="text-lg font-semibold text-brand-navy mb-2">
{viewFilter === 'trash' ? 'Supprimer définitivement' : "Mettre à la corbeille"}
</h2>
<p className="text-gray-600 mb-6">
<p className="text-neutral-600 mb-6">
{viewFilter === 'trash' ? (
<>
Êtes-vous sûr de vouloir supprimer définitivement{' '}
@ -1322,7 +1324,7 @@ export default function AdminBlogPage() {
setShowDeleteConfirm(false);
setSelectedPost(null);
}}
className="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
className="px-4 py-2 text-sm text-neutral-700 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
>
Annuler
</button>

View File

@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
import { useConfirm } from '@/components/ui/use-confirm';
interface Booking {
id: string;
@ -32,6 +33,7 @@ interface Booking {
}
export default function AdminBookingsPage() {
const confirm = useConfirm();
const t = useTranslations('dashboard.admin.bookings');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
@ -53,7 +55,7 @@ export default function AdminBookingsPage() {
}, []);
const handleDeleteBooking = async (bookingId: string) => {
if (!window.confirm(t('confirmDelete'))) return;
if (!(await confirm({ title: t('confirmDelete'), destructive: true }))) return;
setDeletingId(bookingId);
try {
await deleteAdminBooking(bookingId);
@ -66,7 +68,7 @@ export default function AdminBookingsPage() {
};
const handleValidateTransfer = async (bookingId: string) => {
if (!window.confirm(t('confirmValidate'))) return;
if (!(await confirm({ title: t('confirmValidate') }))) return;
setValidatingId(bookingId);
try {
await validateBankTransfer(bookingId);
@ -100,7 +102,7 @@ export default function AdminBookingsPage() {
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
return colors[status.toLowerCase()] || 'bg-neutral-100 text-neutral-800';
};
const getStatusLabel = (status: string) => {
@ -146,8 +148,8 @@ export default function AdminBookingsPage() {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">{t('loading')}</p>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<p className="mt-4 text-neutral-600">{t('loading')}</p>
</div>
</div>
);
@ -157,15 +159,15 @@ export default function AdminBookingsPage() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-1 text-sm text-gray-500">{t('subtitle')}</p>
<h1 className="text-2xl font-bold text-brand-navy">{t('title')}</h1>
<p className="mt-1 text-sm text-neutral-500">{t('subtitle')}</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.total')}</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="text-xs text-neutral-500 uppercase tracking-wide">{t('stats.total')}</div>
<div className="text-2xl font-bold text-brand-navy mt-1">{bookings.length}</div>
</div>
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
<div className="text-xs text-amber-700 uppercase tracking-wide">
@ -175,22 +177,22 @@ export default function AdminBookingsPage() {
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="text-xs text-neutral-500 uppercase tracking-wide">
{t('stats.pendingCarrier')}
</div>
<div className="text-2xl font-bold text-yellow-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.accepted')}</div>
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="text-xs text-neutral-500 uppercase tracking-wide">{t('stats.accepted')}</div>
<div className="text-2xl font-bold text-green-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-xs text-gray-500 uppercase tracking-wide">{t('stats.rejected')}</div>
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="text-xs text-neutral-500 uppercase tracking-wide">{t('stats.rejected')}</div>
<div className="text-2xl font-bold text-red-600 mt-1">
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
</div>
@ -198,10 +200,10 @@ export default function AdminBookingsPage() {
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('search.label')}
</label>
<input
@ -209,17 +211,17 @@ export default function AdminBookingsPage() {
placeholder={t('search.placeholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('filter.label')}
</label>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
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"
>
<option value="all">{t('filter.all')}</option>
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
@ -243,70 +245,70 @@ export default function AdminBookingsPage() {
{/* Bookings Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border">
<thead className="bg-neutral-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.bookingNumber')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.route')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.cargo')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.carrier')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.status')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.date')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-border">
{filteredBookings.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
<td colSpan={7} className="px-4 py-8 text-center text-sm text-neutral-500">
{t('table.empty')}
</td>
</tr>
) : (
filteredBookings.map(booking => (
<tr key={booking.id} className="hover:bg-gray-50">
<tr key={booking.id} className="hover:bg-neutral-50">
{/* N° Booking */}
<td className="px-4 py-4 whitespace-nowrap">
{booking.bookingNumber && (
<div className="text-sm font-semibold text-gray-900">
<div className="text-sm font-semibold text-brand-navy">
{booking.bookingNumber}
</div>
)}
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
<div className="text-xs text-neutral-400 font-mono">{getShortId(booking)}</div>
</td>
{/* Route */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
<div className="text-sm font-medium text-brand-navy">
{booking.origin} → {booking.destination}
</div>
</td>
{/* Cargo */}
<td className="px-4 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
<div className="text-sm text-brand-navy">
{booking.containerType}
{booking.palletCount != null && (
<span className="ml-1 text-gray-500">
<span className="ml-1 text-neutral-500">
· {booking.palletCount} {t('table.pallets')}
</span>
)}
</div>
<div className="text-xs text-gray-500 space-x-2">
<div className="text-xs text-neutral-500 space-x-2">
{booking.weightKG != null && (
<span>{booking.weightKG.toLocaleString(dateLocale)} kg</span>
)}
@ -315,7 +317,7 @@ export default function AdminBookingsPage() {
</td>
{/* Transporteur */}
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
<td className="px-4 py-4 whitespace-nowrap text-sm text-brand-navy">
{booking.carrierName || '—'}
</td>
@ -329,7 +331,7 @@ export default function AdminBookingsPage() {
</td>
{/* Date */}
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-4 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString(
dateLocale
)}
@ -348,10 +350,10 @@ export default function AdminBookingsPage() {
setOpenMenuId(booking.id);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<svg
className="w-5 h-5 text-gray-600"
className="w-5 h-5 text-neutral-600"
fill="currentColor"
viewBox="0 0 20 20"
>
@ -377,7 +379,7 @@ export default function AdminBookingsPage() {
}}
/>
<div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
className="fixed w-56 bg-white border-2 border-neutral-300 rounded-lg shadow-2xl z-[999]"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
>
<div className="py-2">
@ -391,10 +393,10 @@ export default function AdminBookingsPage() {
setOpenMenuId(null);
setMenuPosition(null);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
className="w-full px-4 py-3 text-left hover:bg-neutral-50 flex items-center space-x-3 border-b border-border"
>
<svg
className="w-5 h-5 text-blue-600"
className="w-5 h-5 text-brand-navy"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -412,7 +414,7 @@ export default function AdminBookingsPage() {
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<span className="text-sm font-medium text-gray-700">{t('menu.viewDetails')}</span>
<span className="text-sm font-medium text-neutral-700">{t('menu.viewDetails')}</span>
</button>
{(() => {
const booking = bookings.find(b => b.id === openMenuId);
@ -425,7 +427,7 @@ export default function AdminBookingsPage() {
if (id) handleValidateTransfer(id);
}}
disabled={validatingId === openMenuId}
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-border"
>
<svg
className="w-5 h-5 text-green-600"
@ -481,13 +483,13 @@ export default function AdminBookingsPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
<div className="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">{t('modal.title')}</h2>
<h2 className="text-xl font-bold text-brand-navy">{t('modal.title')}</h2>
<button
onClick={() => {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="text-gray-400 hover:text-gray-600"
className="text-neutral-400 hover:text-neutral-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -503,15 +505,15 @@ export default function AdminBookingsPage() {
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.bookingNumber')}
</label>
<div className="mt-1 text-lg font-semibold text-gray-900">
<div className="mt-1 text-lg font-semibold text-brand-navy">
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.status')}
</label>
<span
@ -524,23 +526,23 @@ export default function AdminBookingsPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-4">
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">
<h3 className="text-sm font-medium text-brand-navy mb-3">
{t('modal.routeSection')}
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.origin')}
</label>
<div className="mt-1 font-semibold text-gray-900">
<div className="mt-1 font-semibold text-brand-navy">
{selectedBooking.origin || t('modal.none')}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.destination')}
</label>
<div className="mt-1 font-semibold text-gray-900">
<div className="mt-1 font-semibold text-brand-navy">
{selectedBooking.destination || t('modal.none')}
</div>
</div>
@ -548,52 +550,52 @@ export default function AdminBookingsPage() {
</div>
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">
<h3 className="text-sm font-medium text-brand-navy mb-3">
{t('modal.cargoSection')}
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.carrier')}
</label>
<div className="mt-1 font-semibold text-gray-900">
<div className="mt-1 font-semibold text-brand-navy">
{selectedBooking.carrierName || t('modal.none')}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.containerType')}
</label>
<div className="mt-1 font-semibold text-gray-900">
<div className="mt-1 font-semibold text-brand-navy">
{selectedBooking.containerType}
</div>
</div>
{selectedBooking.palletCount != null && (
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.pallets')}
</label>
<div className="mt-1 font-semibold text-gray-900">
<div className="mt-1 font-semibold text-brand-navy">
{selectedBooking.palletCount}
</div>
</div>
)}
{selectedBooking.weightKG != null && (
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.weight')}
</label>
<div className="mt-1 font-semibold text-gray-900">
<div className="mt-1 font-semibold text-brand-navy">
{selectedBooking.weightKG.toLocaleString(dateLocale)} kg
</div>
</div>
)}
{selectedBooking.volumeCBM != null && (
<div>
<label className="block text-sm font-medium text-gray-500">
<label className="block text-sm font-medium text-neutral-500">
{t('modal.volume')}
</label>
<div className="mt-1 font-semibold text-gray-900">
<div className="mt-1 font-semibold text-brand-navy">
{selectedBooking.volumeCBM} CBM
</div>
</div>
@ -606,7 +608,7 @@ export default function AdminBookingsPage() {
selectedBooking.freightTotal != null ||
selectedBooking.fobTotal != null) && (
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">
<h3 className="text-sm font-medium text-brand-navy mb-3">
{t('modal.priceSection')}
</h3>
@ -615,8 +617,8 @@ export default function AdminBookingsPage() {
<div className="space-y-2 mb-4">
{selectedBooking.freightTotal != null && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">{t('modal.freight')}</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-500">{t('modal.freight')}</span>
<span className="font-semibold text-brand-navy">
{formatMoney(
selectedBooking.freightTotal,
selectedBooking.freightCurrency ||
@ -628,8 +630,8 @@ export default function AdminBookingsPage() {
)}
{selectedBooking.fobTotal != null && selectedBooking.fobTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">{t('modal.fob')}</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-500">{t('modal.fob')}</span>
<span className="font-semibold text-brand-navy">
{formatMoney(
selectedBooking.fobTotal,
selectedBooking.fobCurrency || 'EUR'
@ -640,13 +642,13 @@ export default function AdminBookingsPage() {
{selectedBooking.commissionAmountEur != null &&
selectedBooking.commissionAmountEur > 0 && (
<div className="flex justify-between text-sm border-t pt-2">
<span className="text-gray-500">{t('modal.bookingFee')}</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-500">{t('modal.bookingFee')}</span>
<span className="font-semibold text-brand-navy">
{formatMoney(selectedBooking.commissionAmountEur, 'EUR')}
</span>
</div>
)}
<p className="text-xs text-gray-400 pt-1">{t('modal.transportNote')}</p>
<p className="text-xs text-neutral-400 pt-1">{t('modal.transportNote')}</p>
</div>
)}
@ -654,16 +656,16 @@ export default function AdminBookingsPage() {
<div className="grid grid-cols-2 gap-4">
{selectedBooking.priceEUR != null && (
<div>
<label className="block text-sm font-medium text-gray-500">EUR</label>
<div className="mt-1 text-xl font-bold text-blue-600">
<label className="block text-sm font-medium text-neutral-500">EUR</label>
<div className="mt-1 text-xl font-bold text-brand-navy">
{selectedBooking.priceEUR.toLocaleString(dateLocale)} €
</div>
</div>
)}
{selectedBooking.priceUSD != null && (
<div>
<label className="block text-sm font-medium text-gray-500">USD</label>
<div className="mt-1 text-xl font-bold text-blue-600">
<label className="block text-sm font-medium text-neutral-500">USD</label>
<div className="mt-1 text-xl font-bold text-brand-navy">
{selectedBooking.priceUSD.toLocaleString(dateLocale)} $
</div>
</div>
@ -673,13 +675,13 @@ export default function AdminBookingsPage() {
)}
<div className="border-t pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">
<h3 className="text-sm font-medium text-brand-navy mb-3">
{t('modal.datesSection')}
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<label className="block text-gray-500">{t('modal.createdAt')}</label>
<div className="mt-1 text-gray-900">
<label className="block text-neutral-500">{t('modal.createdAt')}</label>
<div className="mt-1 text-brand-navy">
{new Date(
selectedBooking.requestedAt || selectedBooking.createdAt || ''
).toLocaleString(dateLocale)}
@ -687,8 +689,8 @@ export default function AdminBookingsPage() {
</div>
{selectedBooking.updatedAt && (
<div>
<label className="block text-gray-500">{t('modal.updatedAt')}</label>
<div className="mt-1 text-gray-900">
<label className="block text-neutral-500">{t('modal.updatedAt')}</label>
<div className="mt-1 text-brand-navy">
{new Date(selectedBooking.updatedAt).toLocaleString(dateLocale)}
</div>
</div>
@ -722,7 +724,7 @@ export default function AdminBookingsPage() {
setShowDetailsModal(false);
setSelectedBooking(null);
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
className="px-4 py-2 border border-neutral-300 rounded-md text-neutral-700 hover:bg-neutral-50"
>
{t('modal.close')}
</button>

View File

@ -26,13 +26,17 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useToast } from '@/components/ui/toast';
import { useConfirm } from '@/components/ui/use-confirm';
const DIRECTION_COLORS: Record<'EXPORT' | 'IMPORT', string> = {
EXPORT: 'bg-blue-100 text-blue-800',
EXPORT: 'bg-brand-turquoise/10 text-brand-navy',
IMPORT: 'bg-purple-100 text-purple-800',
};
export default function AdminCsvRatesPage() {
const confirm = useConfirm();
const { toast } = useToast();
const t = useTranslations('dashboard.admin.csvRates');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
@ -60,16 +64,14 @@ export default function AdminCsvRatesPage() {
}, []);
const handleDelete = async (filename: string) => {
if (!confirm(t('confirmDelete', { filename }))) {
return;
}
if (!(await confirm({ title: t('confirmDelete', { filename }), destructive: true }))) return;
try {
await deleteCsvFile(filename);
alert(t('deleteSuccess', { filename }));
toast.success(t('deleteSuccess', { filename }));
fetchFiles(); // Refresh list
} catch (err: any) {
alert(t('deleteError', { message: err?.message || t('deleteFailedFallback') }));
toast.error(t('deleteError', { message: err?.message || t('deleteFailedFallback') }));
}
};

View File

@ -6,6 +6,8 @@ import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admi
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
import { useToast } from '@/components/ui/toast';
import { useConfirm } from '@/components/ui/use-confirm';
interface Document {
id: string;
@ -47,6 +49,8 @@ interface DocumentWithBooking extends Document {
}
export default function AdminDocumentsPage() {
const confirm = useConfirm();
const { toast } = useToast();
const t = useTranslations('dashboard.admin.documents');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
@ -218,17 +222,17 @@ export default function AdminDocumentsPage() {
png: <ImageIcon className={`${cls} text-green-500`} />,
gif: <ImageIcon className={`${cls} text-green-500`} />,
image: <ImageIcon className={`${cls} text-green-500`} />,
word: <FileEdit className={`${cls} text-blue-500`} />,
doc: <FileEdit className={`${cls} text-blue-500`} />,
docx: <FileEdit className={`${cls} text-blue-500`} />,
word: <FileEdit className={`${cls} text-brand-turquoise`} />,
doc: <FileEdit className={`${cls} text-brand-turquoise`} />,
docx: <FileEdit className={`${cls} text-brand-turquoise`} />,
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
text: <FileText className={`${cls} text-gray-500`} />,
txt: <FileText className={`${cls} text-gray-500`} />,
text: <FileText className={`${cls} text-neutral-500`} />,
txt: <FileText className={`${cls} text-neutral-500`} />,
};
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
return iconMap[typeLower] || <Paperclip className={`${cls} text-neutral-400`} />;
};
const getStatusColor = (status: string) => {
@ -236,13 +240,13 @@ export default function AdminDocumentsPage() {
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-800',
cancelled: 'bg-neutral-100 text-neutral-800',
};
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
return colors[status.toLowerCase()] || 'bg-neutral-100 text-neutral-800';
};
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
if (!window.confirm(t('confirmDelete'))) return;
if (!(await confirm({ title: t('confirmDelete'), destructive: true }))) return;
setDeletingId(documentId);
try {
await deleteAdminDocument(bookingId, documentId);
@ -292,7 +296,7 @@ export default function AdminDocumentsPage() {
} catch (error) {
console.error('Error downloading file:', error);
const message = error instanceof Error ? error.message : t('unknownError');
alert(t('downloadError', { message }));
toast.error(t('downloadError', { message }));
}
};
@ -300,8 +304,8 @@ export default function AdminDocumentsPage() {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">{t('loading')}</p>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<p className="mt-4 text-neutral-600">{t('loading')}</p>
</div>
</div>
);
@ -313,27 +317,27 @@ export default function AdminDocumentsPage() {
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.totalDocs')}</div>
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="text-sm text-neutral-500">{t('stats.totalDocs')}</div>
<div className="text-2xl font-bold text-brand-navy">{documents.length}</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.bookingsWithDocs')}</div>
<div className="text-2xl font-bold text-blue-600">
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="text-sm text-neutral-500">{t('stats.bookingsWithDocs')}</div>
<div className="text-2xl font-bold text-brand-navy">
{bookings.filter(b => b.documents && b.documents.length > 0).length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.filtered')}</div>
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="text-sm text-neutral-500">{t('stats.filtered')}</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="bg-white rounded-lg shadow-sm border border-border p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('filters.search')}
</label>
<input
@ -341,11 +345,11 @@ export default function AdminDocumentsPage() {
placeholder={t('filters.searchPlaceholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('filters.quoteNumber')}
</label>
<input
@ -353,17 +357,17 @@ export default function AdminDocumentsPage() {
placeholder={t('filters.quoteNumberPlaceholder')}
value={filterQuoteNumber}
onChange={e => setFilterQuoteNumber(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('filters.user')}
</label>
<select
value={filterUserId}
onChange={e => setFilterUserId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
>
<option value="all">{t('filters.allUsers')}</option>
{uniqueUsers.map(user => (
@ -385,58 +389,58 @@ export default function AdminDocumentsPage() {
{/* Documents Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.name')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.quoteNumber')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.route')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.user')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-border">
{paginatedDocuments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
<td colSpan={7} className="px-6 py-12 text-center text-neutral-500">
{t('table.empty')}
</td>
</tr>
) : (
paginatedDocuments.map((doc, index) => (
<tr key={`${doc.bookingId}-${index}`} className="hover:bg-gray-50">
<tr key={`${doc.bookingId}-${index}`} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">
<div className="text-sm font-medium text-brand-navy">
{doc.fileName || doc.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
<div className="text-xs text-neutral-500">{doc.fileType || doc.type}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{doc.quoteNumber}</div>
<div className="text-sm font-medium text-brand-navy">{doc.quoteNumber}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{doc.route}</div>
<div className="text-sm text-brand-navy">{doc.route}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
@ -446,7 +450,7 @@ export default function AdminDocumentsPage() {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
<div className="text-sm text-brand-navy">
{doc.userName || doc.userId.substring(0, 8) + '...'}
</div>
</td>
@ -463,10 +467,10 @@ export default function AdminDocumentsPage() {
setOpenMenuId(menuKey);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<svg
className="w-5 h-5 text-gray-600"
className="w-5 h-5 text-neutral-600"
fill="currentColor"
viewBox="0 0 20 20"
>
@ -482,26 +486,26 @@ export default function AdminDocumentsPage() {
{/* Pagination Controls */}
{filteredDocuments.length > 0 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-border sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative inline-flex items-center px-4 py-2 border border-neutral-300 text-sm font-medium rounded-md text-neutral-700 bg-white hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.previous')}
</button>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="ml-3 relative inline-flex items-center px-4 py-2 border border-neutral-300 text-sm font-medium rounded-md text-neutral-700 bg-white hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
<p className="text-sm text-neutral-700">
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span>{' '}
{t('pagination.to')}{' '}
<span className="font-medium">
@ -514,14 +518,14 @@ export default function AdminDocumentsPage() {
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
<label className="text-sm text-neutral-700">{t('pagination.perPage')}</label>
<select
value={itemsPerPage}
onChange={e => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="border border-gray-300 rounded-md px-2 py-1 text-sm"
className="border border-neutral-300 rounded-md px-2 py-1 text-sm"
>
<option value={5}>5</option>
<option value={10}>10</option>
@ -537,7 +541,7 @@ export default function AdminDocumentsPage() {
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-neutral-300 bg-white text-sm font-medium text-neutral-500 hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
@ -568,8 +572,8 @@ export default function AdminDocumentsPage() {
onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
? 'z-10 bg-brand-turquoise/5 border-brand-turquoise text-brand-navy'
: 'bg-white border-neutral-300 text-neutral-500 hover:bg-neutral-50'
}`}
>
{pageNum}
@ -580,7 +584,7 @@ export default function AdminDocumentsPage() {
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-neutral-300 bg-white text-sm font-medium text-neutral-500 hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
@ -608,7 +612,7 @@ export default function AdminDocumentsPage() {
}}
/>
<div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
className="fixed w-56 bg-white border-2 border-neutral-300 rounded-lg shadow-2xl z-[999]"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
>
<div className="py-2">
@ -627,10 +631,10 @@ export default function AdminDocumentsPage() {
doc.fileName || doc.name || 'document'
);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
className="w-full px-4 py-3 text-left hover:bg-neutral-50 flex items-center space-x-3 border-b border-border"
>
<svg
className="w-5 h-5 text-blue-600"
className="w-5 h-5 text-brand-navy"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -642,7 +646,7 @@ export default function AdminDocumentsPage() {
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span className="text-sm font-medium text-gray-700">
<span className="text-sm font-medium text-neutral-700">
{t('menu.download')}
</span>
</button>

View File

@ -1,41 +1,12 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/lib/context/auth-context';
import { Link, usePathname, useRouter } from '@/i18n/navigation';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import NotificationDropdown from '@/components/NotificationDropdown';
import Image from 'next/image';
import {
Users,
Building2,
Package,
FileText,
BarChart3,
Newspaper,
ScrollText,
ArrowLeft,
LogOut,
ShieldCheck,
type LucideIcon,
} from 'lucide-react';
interface AdminNavItem {
key: string;
href: string;
icon: LucideIcon;
}
const adminNavItems: AdminNavItem[] = [
{ key: 'users', href: '/admin/users', icon: Users },
{ key: 'organizations', href: '/admin/organizations', icon: Building2 },
{ key: 'bookings', href: '/admin/bookings', icon: Package },
{ key: 'documents', href: '/admin/documents', icon: FileText },
{ key: 'csvRates', href: '/admin/csv-rates', icon: BarChart3 },
{ key: 'blog', href: '/admin/blog', icon: Newspaper },
{ key: 'logs', href: '/admin/logs', icon: ScrollText },
];
import { usePathname, useRouter } from '@/i18n/navigation';
import { AppShell } from '@/components/shell/app-shell';
import { buildAdminNav } from '@/components/shell/nav-config';
import { PageSpinner } from '@/components/ui/states';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const { user, logout, loading, isAuthenticated } = useAuth();
@ -43,10 +14,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter();
const tItems = useTranslations('components.adminPanelDropdown');
const tAdmin = useTranslations('admin');
const [sidebarOpen, setSidebarOpen] = useState(false);
const tShell = useTranslations('shell');
const tDashboard = useTranslations('dashboard');
// The /admin/login page lives under this segment but must render without the
// admin chrome and without triggering the role guard.
// La page /admin/login vit sous ce segment mais doit s'afficher sans le
// chrome d'administration et sans declencher la garde de role.
const isLoginRoute = pathname === '/admin/login';
useEffect(() => {
@ -60,6 +32,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}
}, [isLoginRoute, loading, isAuthenticated, user, router]);
const groups = useMemo(() => buildAdminNav({ t: tItems, tShell }), [tItems, tShell]);
if (isLoginRoute) {
return <>{children}</>;
}
@ -68,144 +42,46 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (loading || !authorized) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-4 border-brand-turquoise border-t-transparent rounded-full animate-spin" />
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<PageSpinner />
</div>
);
}
const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
return (
<div className="min-h-screen bg-gray-50">
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-brand-navy text-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
<AppShell
variant="admin"
groups={groups}
pathname={pathname}
user={user}
onLogout={logout}
crossLink={{ href: '/dashboard', label: tAdmin('backToApp') }}
labels={{
navigation: tShell('navigation'),
openMenu: tShell('openMenu'),
closeMenu: tShell('closeMenu'),
collapseSidebar: tShell('collapseSidebar'),
expandSidebar: tShell('expandSidebar'),
userMenu: tShell('userMenu'),
breadcrumb: tShell('breadcrumb'),
rootLabel: tAdmin('title'),
search: tShell('search'),
searchPlaceholder: tShell('searchPlaceholder'),
noResults: tShell('noResults'),
lockedFeature: tShell('lockedFeature'),
profile: tShell('profile'),
logout: tAdmin('logout'),
more: tShell('more'),
moreTitle: tShell('moreTitle'),
bottomNav: {
home: tDashboard('bottomNav.home'),
bookings: tDashboard('bottomNav.bookings'),
documents: tDashboard('bottomNav.documents'),
tracking: tDashboard('bottomNav.tracking'),
},
}}
>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between h-16 px-6 border-b border-white/10">
<Link href="/admin" className="flex items-center gap-2">
<Image
src="/assets/logos/logo-white.svg"
alt="Xpeditis"
width={44}
height={52}
priority
className="h-auto"
/>
</Link>
<button
className="lg:hidden text-white/70 hover:text-white"
onClick={() => setSidebarOpen(false)}
aria-label="Close menu"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="flex items-center gap-2 px-6 py-4 text-xs font-semibold uppercase tracking-wider text-brand-turquoise">
<ShieldCheck className="w-4 h-4" />
{tAdmin('panelTitle')}
</div>
<nav className="flex-1 px-4 pb-6 space-y-1 overflow-y-auto">
{adminNavItems.map(item => {
const Icon = item.icon;
return (
<Link
key={item.key}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
isActive(item.href)
? 'bg-brand-turquoise text-brand-navy'
: 'text-white/80 hover:bg-white/10 hover:text-white'
}`}
>
<Icon className="mr-3 h-5 w-5" />
<span className="flex-1">{tItems(`items.${item.key}` as any)}</span>
</Link>
);
})}
</nav>
<div className="border-t border-white/10 p-4 space-y-3">
<Link
href="/dashboard"
className="flex items-center px-4 py-2.5 text-sm font-medium text-white/80 rounded-lg hover:bg-white/10 hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{tAdmin('backToApp')}
</Link>
<div className="flex items-center space-x-3 px-2">
<div className="w-10 h-10 bg-brand-turquoise rounded-full flex items-center justify-center text-brand-navy font-semibold">
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs text-white/60 truncate">{user?.email}</p>
</div>
</div>
<button
onClick={logout}
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-200 bg-red-500/20 rounded-lg hover:bg-red-500/30 transition-colors"
>
<LogOut className="w-4 h-4 mr-2" />
{tAdmin('logout')}
</button>
</div>
</div>
</div>
<div className="lg:pl-64">
<div className="sticky top-0 z-10 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-white border-b">
<button
className="lg:hidden text-gray-500 hover:text-gray-700 p-1"
onClick={() => setSidebarOpen(true)}
aria-label="Open menu"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="text-base lg:text-xl font-semibold text-gray-900 ml-3 lg:ml-0">
{adminNavItems.find(item => isActive(item.href))
? tItems(`items.${adminNavItems.find(item => isActive(item.href))!.key}` as any)
: tAdmin('title')}
</h1>
<div className="flex items-center space-x-3 lg:space-x-4">
<LanguageSwitcher variant="light" />
<NotificationDropdown />
</div>
</div>
<main className="p-4 lg:p-6">{children}</main>
</div>
</div>
{children}
</AppShell>
);
}

View File

@ -55,8 +55,8 @@ const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 border border-red-200',
fatal: 'bg-red-200 text-red-900 border border-red-300',
warn: 'bg-yellow-100 text-yellow-700 border border-yellow-200',
info: 'bg-blue-100 text-blue-700 border border-blue-200',
debug: 'bg-gray-100 text-gray-600 border border-gray-200',
info: 'bg-brand-turquoise/10 text-brand-navy border border-brand-turquoise/30',
debug: 'bg-neutral-100 text-neutral-600 border border-border',
trace: 'bg-purple-100 text-purple-700 border border-purple-200',
};
@ -70,7 +70,7 @@ const LEVEL_ROW_BG: Record<string, string> = {
};
function LevelBadge({ level }: { level: string }) {
const style = LEVEL_STYLES[level] || 'bg-gray-100 text-gray-600';
const style = LEVEL_STYLES[level] || 'bg-neutral-100 text-neutral-600';
return (
<span
className={`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold uppercase ${style}`}
@ -97,8 +97,8 @@ function StatCard({
<Icon className="h-5 w-5" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-sm text-gray-500">{label}</p>
<p className="text-2xl font-bold text-brand-navy">{value}</p>
<p className="text-sm text-neutral-500">{label}</p>
</div>
</div>
);
@ -199,7 +199,7 @@ export default function AdminLogsPage() {
<button
onClick={fetchLogs}
disabled={loading}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{t('refresh')}</span>
@ -207,23 +207,23 @@ export default function AdminLogsPage() {
<div className="relative group">
<button
disabled={exportLoading || loading}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-white bg-[#10183A] rounded-lg hover:bg-[#1a2550] transition-colors disabled:opacity-50"
className="flex items-center gap-2 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"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">
{exportLoading ? t('exporting') : t('export')}
</span>
</button>
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-gray-200 z-10 hidden group-hover:block">
<div className="absolute right-0 mt-1 w-36 bg-white rounded-lg shadow-lg border border-border z-10 hidden group-hover:block">
<button
onClick={() => handleExport('csv')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-50"
>
{t('downloadCsv')}
</button>
<button
onClick={() => handleExport('json')}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-50"
>
{t('downloadJson')}
</button>
@ -239,7 +239,7 @@ export default function AdminLogsPage() {
label={t('stats.total')}
value={total}
icon={Activity}
color="bg-blue-100 text-blue-600"
color="bg-brand-turquoise/10 text-brand-navy"
/>
<StatCard
label={t('stats.errors')}
@ -264,19 +264,19 @@ export default function AdminLogsPage() {
{/* Filters */}
<div className="bg-white rounded-lg border p-4">
<div className="flex items-center gap-2 mb-4">
<Filter className="h-4 w-4 text-gray-500" />
<h2 className="text-sm font-semibold text-gray-700">{t('filters.title')}</h2>
<Filter className="h-4 w-4 text-neutral-500" />
<h2 className="text-sm font-semibold text-neutral-700">{t('filters.title')}</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3">
{/* Service */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
<label className="block text-xs font-medium text-neutral-500 mb-1">
{t('filters.service')}
</label>
<select
value={filters.service}
onChange={e => setFilter('service', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
>
<option value="all">{t('filters.all')}</option>
{services.map(s => (
@ -289,13 +289,13 @@ export default function AdminLogsPage() {
{/* Level */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
<label className="block text-xs font-medium text-neutral-500 mb-1">
{t('filters.level')}
</label>
<select
value={filters.level}
onChange={e => setFilter('level', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
>
<option value="all">{t('filters.all')}</option>
<option value="error">Error</option>
@ -308,7 +308,7 @@ export default function AdminLogsPage() {
{/* Search */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
<label className="block text-xs font-medium text-neutral-500 mb-1">
{t('filters.search')}
</label>
<input
@ -317,44 +317,44 @@ export default function AdminLogsPage() {
value={filters.search}
onChange={e => setFilter('search', e.target.value)}
onKeyDown={e => e.key === 'Enter' && fetchLogs()}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* Start */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
<label className="block text-xs font-medium text-neutral-500 mb-1">
{t('filters.start')}
</label>
<input
type="datetime-local"
value={filters.startDate}
onChange={e => setFilter('startDate', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* End */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
<label className="block text-xs font-medium text-neutral-500 mb-1">
{t('filters.end')}
</label>
<input
type="datetime-local"
value={filters.endDate}
onChange={e => setFilter('endDate', e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:border-[#34CCCD] focus:outline-none"
/>
</div>
{/* Limit + Apply */}
<div className="flex flex-col justify-end gap-2">
<label className="block text-xs font-medium text-gray-500">{t('filters.limit')}</label>
<label className="block text-xs font-medium text-neutral-500">{t('filters.limit')}</label>
<div className="flex gap-2">
<select
value={filters.limit}
onChange={e => setFilter('limit', e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:outline-none"
className="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-[#34CCCD] focus:outline-none"
>
<option value="100">100</option>
<option value="500">500</option>
@ -364,7 +364,7 @@ export default function AdminLogsPage() {
<button
onClick={fetchLogs}
disabled={loading}
className="px-3 py-2 text-sm font-medium text-white bg-[#34CCCD] rounded-lg hover:bg-[#2bb8b9] transition-colors disabled:opacity-50 whitespace-nowrap"
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"
>
{t('filters.apply')}
</button>
@ -387,15 +387,15 @@ export default function AdminLogsPage() {
{/* Table */}
<div className="bg-white rounded-lg border overflow-hidden">
<div className="px-4 py-3 border-b bg-gray-50 flex items-center justify-between">
<div className="px-4 py-3 border-b bg-neutral-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
<Server className="h-4 w-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">
{loading ? t('loading') : t('entries', { count: total })}
</span>
</div>
{!loading && logs.length > 0 && (
<span className="text-xs text-gray-400">{t('clickHint')}</span>
<span className="text-xs text-neutral-400">{t('clickHint')}</span>
)}
</div>
@ -404,44 +404,44 @@ export default function AdminLogsPage() {
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#34CCCD]" />
</div>
) : logs.length === 0 && !error ? (
<div className="flex flex-col items-center justify-center h-40 text-gray-400 gap-2">
<div className="flex flex-col items-center justify-center h-40 text-neutral-400 gap-2">
<Bug className="h-8 w-8" />
<p className="text-sm">{t('empty')}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-gray-50 border-b">
<thead className="bg-neutral-50 border-b">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
<th className="px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase whitespace-nowrap">
{t('table.timestamp')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
<th className="px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase">
{t('table.service')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
<th className="px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase">
{t('table.level')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
<th className="px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase">
{t('table.context')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">
<th className="px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase">
{t('table.message')}
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">
<th className="px-4 py-2 text-left text-xs font-medium text-neutral-500 uppercase whitespace-nowrap">
{t('table.req')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tbody className="divide-y divide-border">
{logs.map((log, i) => (
<>
<tr
key={i}
onClick={() => setExpandedRow(expandedRow === i ? null : i)}
className={`cursor-pointer hover:bg-gray-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`}
className={`cursor-pointer hover:bg-neutral-50 transition-colors ${LEVEL_ROW_BG[log.level] || ''}`}
>
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
<td className="px-4 py-2 font-mono text-xs text-neutral-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleString(dateLocale, {
day: '2-digit',
month: '2-digit',
@ -451,18 +451,18 @@ export default function AdminLogsPage() {
})}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<span className="px-2 py-0.5 bg-[#10183A] text-white text-xs rounded font-mono">
<span className="px-2 py-0.5 bg-brand-navy text-white text-xs rounded font-mono">
{log.service}
</span>
</td>
<td className="px-4 py-2 whitespace-nowrap">
<LevelBadge level={log.level} />
</td>
<td className="px-4 py-2 text-xs text-gray-500 whitespace-nowrap">
<td className="px-4 py-2 text-xs text-neutral-500 whitespace-nowrap">
{log.context || '—'}
</td>
<td className="px-4 py-2 max-w-xs">
<span className="line-clamp-1 text-gray-800">
<span className="line-clamp-1 text-neutral-800">
{log.error ? (
<span className="text-red-600">{log.error}</span>
) : (
@ -470,7 +470,7 @@ export default function AdminLogsPage() {
)}
</span>
</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500 whitespace-nowrap">
<td className="px-4 py-2 font-mono text-xs text-neutral-500 whitespace-nowrap">
{log.req_method && (
<span>
<span className="font-semibold">{log.req_method}</span> {log.req_url}{' '}
@ -494,40 +494,40 @@ export default function AdminLogsPage() {
{/* Expanded detail row */}
{expandedRow === i && (
<tr key={`detail-${i}`} className="bg-gray-50">
<tr key={`detail-${i}`} className="bg-neutral-50">
<td colSpan={6} className="px-4 py-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<span className="font-semibold text-gray-600">
<span className="font-semibold text-neutral-600">
{t('detail.timestamp')}
</span>
<p className="font-mono text-gray-800 mt-0.5">{log.timestamp}</p>
<p className="font-mono text-neutral-800 mt-0.5">{log.timestamp}</p>
</div>
{log.reqId && (
<div>
<span className="font-semibold text-gray-600">
<span className="font-semibold text-neutral-600">
{t('detail.requestId')}
</span>
<p className="font-mono text-gray-800 mt-0.5 truncate">
<p className="font-mono text-neutral-800 mt-0.5 truncate">
{log.reqId}
</p>
</div>
)}
{log.response_time_ms && (
<div>
<span className="font-semibold text-gray-600">
<span className="font-semibold text-neutral-600">
{t('detail.duration')}
</span>
<p className="font-mono text-gray-800 mt-0.5">
<p className="font-mono text-neutral-800 mt-0.5">
{log.response_time_ms} ms
</p>
</div>
)}
<div className="col-span-2 md:col-span-4">
<span className="font-semibold text-gray-600">
<span className="font-semibold text-neutral-600">
{t('detail.fullMessage')}
</span>
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-gray-800 overflow-x-auto whitespace-pre-wrap break-all">
<pre className="mt-0.5 p-2 bg-white rounded border font-mono text-neutral-800 overflow-x-auto whitespace-pre-wrap break-all">
{log.error ? `[ERROR] ${log.error}\n\n${log.message}` : log.message}
</pre>
</div>

View File

@ -5,6 +5,8 @@ import { useTranslations } from 'next-intl';
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
import { PageHeader } from '@/components/ui/PageHeader';
import { useToast } from '@/components/ui/toast';
import { useConfirm } from '@/components/ui/use-confirm';
interface Organization {
id: string;
@ -65,7 +67,7 @@ const EMPTY_FORM: FormData = {
};
function FieldHint({ children }: { children: React.ReactNode }) {
return <p className="mt-1 text-xs text-gray-400">{children}</p>;
return <p className="mt-1 text-xs text-neutral-400">{children}</p>;
}
function FieldError({ message }: { message?: string }) {
@ -74,6 +76,8 @@ function FieldError({ message }: { message?: string }) {
}
export default function AdminOrganizationsPage() {
const confirm = useConfirm();
const { toast } = useToast();
const t = useTranslations('dashboard.admin.organizations');
const [organizations, setOrganizations] = useState<Organization[]>([]);
@ -226,7 +230,7 @@ export default function AdminOrganizationsPage() {
setVerifyingId(orgId);
const result = await verifySiret(orgId);
if (result.verified) {
alert(
toast.success(
t('siretVerified', {
companyName: result.companyName || 'N/A',
address: result.address || 'N/A',
@ -234,38 +238,38 @@ export default function AdminOrganizationsPage() {
);
await fetchOrganizations();
} else {
alert(result.message || t('siretInvalid'));
toast.error(result.message || t('siretInvalid'));
}
} catch (err: any) {
alert(err.message || t('siretError'));
toast.error(err.message || t('siretError'));
} finally {
setVerifyingId(null);
}
};
const handleApproveSiret = async (orgId: string) => {
if (!confirm(t('confirmApprove'))) return;
if (!(await confirm({ title: t('confirmApprove') }))) return;
try {
setVerifyingId(orgId);
const result = await approveSiret(orgId);
alert(result.message);
toast.success(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || t('siretApproveError'));
toast.error(err.message || t('siretApproveError'));
} finally {
setVerifyingId(null);
}
};
const handleRejectSiret = async (orgId: string) => {
if (!confirm(t('confirmReject'))) return;
if (!(await confirm({ title: t('confirmReject'), destructive: true }))) return;
try {
setVerifyingId(orgId);
const result = await rejectSiret(orgId);
alert(result.message);
toast.success(result.message);
await fetchOrganizations();
} catch (err: any) {
alert(err.message || t('siretRejectError'));
toast.error(err.message || t('siretRejectError'));
} finally {
setVerifyingId(null);
}
@ -314,15 +318,15 @@ export default function AdminOrganizationsPage() {
`mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none text-sm ${
hasError
? 'border-red-400 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
: 'border-neutral-300 focus:border-brand-turquoise focus:ring-ring'
}`;
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">{t('loading')}</p>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<p className="mt-4 text-neutral-600">{t('loading')}</p>
</div>
</div>
);
@ -336,7 +340,7 @@ export default function AdminOrganizationsPage() {
actions={
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
className="px-4 py-2 bg-brand-turquoise text-brand-navy text-sm font-medium rounded-lg hover:bg-brand-turquoise/90 transition-colors"
>
{t('create')}
</button>
@ -352,14 +356,14 @@ export default function AdminOrganizationsPage() {
{/* Organizations Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{organizations.map(org => (
<div key={org.id} className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div key={org.id} className="bg-white rounded-lg shadow-sm border border-border p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{org.name}</h3>
<h3 className="text-lg font-semibold text-brand-navy">{org.name}</h3>
<span
className={`inline-block mt-2 px-2 py-1 text-xs font-semibold rounded-full ${
org.type === 'FREIGHT_FORWARDER'
? 'bg-blue-100 text-blue-800'
? 'bg-brand-turquoise/10 text-brand-navy'
: org.type === 'CARRIER'
? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800'
@ -377,7 +381,7 @@ export default function AdminOrganizationsPage() {
</span>
</div>
<div className="space-y-2 text-sm text-gray-600 mb-4">
<div className="space-y-2 text-sm text-neutral-600 mb-4">
{org.scac && (
<div>
<span className="font-medium">{t('scac')}:</span> {org.scac}
@ -404,7 +408,7 @@ export default function AdminOrganizationsPage() {
)}
</>
) : (
<span className="text-gray-400">{t('notProvided')}</span>
<span className="text-neutral-400">{t('notProvided')}</span>
)}
</div>
{org.contact_email && (
@ -422,7 +426,7 @@ export default function AdminOrganizationsPage() {
<div className="flex space-x-2">
<button
onClick={() => openEditModal(org)}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-md hover:bg-blue-100 transition-colors text-sm font-medium"
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"
>
{t('edit')}
</button>
@ -477,12 +481,12 @@ export default function AdminOrganizationsPage() {
>
{/* ── Informations générales ── */}
<section>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
<h3 className="text-sm font-semibold text-neutral-500 uppercase tracking-wide mb-3">
{t('modal.sectionGeneral')}
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.name')} <span className="text-red-500">*</span>
</label>
<input
@ -501,7 +505,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.type')} <span className="text-red-500">*</span>
</label>
{showCreateModal ? (
@ -519,7 +523,7 @@ export default function AdminOrganizationsPage() {
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
formData.type === 'FREIGHT_FORWARDER'
? 'bg-blue-100 text-blue-800'
? 'bg-brand-turquoise/10 text-brand-navy'
: formData.type === 'CARRIER'
? 'bg-green-100 text-green-800'
: 'bg-purple-100 text-purple-800'
@ -527,7 +531,7 @@ export default function AdminOrganizationsPage() {
>
{getTypeLabel(formData.type)}
</span>
<span className="text-xs text-gray-400">{t('modal.typeReadOnly')}</span>
<span className="text-xs text-neutral-400">{t('modal.typeReadOnly')}</span>
</div>
)}
</div>
@ -535,7 +539,7 @@ export default function AdminOrganizationsPage() {
{/* SCAC — création uniquement, visible en lecture seule en édition si CARRIER */}
{showCreateModal && formData.type === 'CARRIER' && (
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.scacLabel')} <span className="text-red-500">*</span>
</label>
<input
@ -557,10 +561,10 @@ export default function AdminOrganizationsPage() {
)}
{showEditModal && formData.type === 'CARRIER' && formData.scac && (
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.scacLabel')}
</label>
<div className="mt-1 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-sm text-gray-700 font-mono">
<div className="mt-1 px-3 py-2 bg-neutral-50 border border-border rounded-md text-sm text-neutral-700 font-mono">
{formData.scac}
</div>
<FieldHint>{t('modal.typeReadOnly')}</FieldHint>
@ -571,12 +575,12 @@ export default function AdminOrganizationsPage() {
{/* ── Identifiants légaux ── */}
<section>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
<h3 className="text-sm font-semibold text-neutral-500 uppercase tracking-wide mb-3">
{t('modal.sectionLegal')}
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.sirenLabel')}
</label>
<input
@ -596,7 +600,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.siretLabel')}
</label>
<input
@ -616,7 +620,7 @@ export default function AdminOrganizationsPage() {
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.eoriLabel')}
</label>
<input
@ -633,12 +637,12 @@ export default function AdminOrganizationsPage() {
{/* ── Contact ── */}
<section>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
<h3 className="text-sm font-semibold text-neutral-500 uppercase tracking-wide mb-3">
{t('modal.sectionContact')}
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.contactEmail')}
</label>
<input
@ -651,7 +655,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.contactPhone')}
</label>
<input
@ -667,12 +671,12 @@ export default function AdminOrganizationsPage() {
{/* ── Adresse ── */}
<section>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
<h3 className="text-sm font-semibold text-neutral-500 uppercase tracking-wide mb-3">
{t('modal.sectionAddress')}
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.street')} <span className="text-red-500">*</span>
</label>
<input
@ -689,7 +693,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.city')} <span className="text-red-500">*</span>
</label>
<input
@ -706,7 +710,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.postalCode')} <span className="text-red-500">*</span>
</label>
<input
@ -723,7 +727,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.state')}
</label>
<input
@ -738,7 +742,7 @@ export default function AdminOrganizationsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.country')} <span className="text-red-500">*</span>
</label>
<input
@ -762,12 +766,12 @@ export default function AdminOrganizationsPage() {
{/* ── Autres ── */}
<section>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
<h3 className="text-sm font-semibold text-neutral-500 uppercase tracking-wide mb-3">
{t('modal.sectionOther')}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.logoUrl')}
</label>
<input
@ -786,9 +790,9 @@ export default function AdminOrganizationsPage() {
type="checkbox"
checked={formData.isActive}
onChange={e => setFormData({ ...formData, isActive: e.target.checked })}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
className="h-4 w-4 rounded border-neutral-300 text-brand-navy focus:ring-ring"
/>
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
<label htmlFor="isActive" className="text-sm font-medium text-neutral-700">
{t('modal.isActive')}
</label>
</div>
@ -803,18 +807,18 @@ export default function AdminOrganizationsPage() {
</div>
)}
<div className="flex justify-end space-x-3 pt-2 border-t border-gray-100">
<div className="flex justify-end space-x-3 pt-2 border-t border-border">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition-colors"
className="px-4 py-2 border border-neutral-300 rounded-md text-sm text-neutral-700 hover:bg-neutral-50 transition-colors"
>
{t('modal.cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
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"
>
{submitting
? t('modal.saving')

View File

@ -8,6 +8,7 @@ import { createUser } from '@/lib/api/users';
import { getAllOrganizations } from '@/lib/api/admin';
import type { UserRole } from '@/types/api';
import { PageHeader } from '@/components/ui/PageHeader';
import { useToast } from '@/components/ui/toast';
interface User {
id: string;
@ -27,6 +28,7 @@ interface Organization {
}
export default function AdminUsersPage() {
const { toast } = useToast();
const t = useTranslations('dashboard.admin.users');
const tCommon = useTranslations('common');
@ -87,7 +89,7 @@ export default function AdminUsersPage() {
setShowCreateModal(false);
resetForm();
} catch (err: any) {
alert(err.message || t('createError'));
toast.error(err.message || t('createError'));
}
};
@ -107,7 +109,7 @@ export default function AdminUsersPage() {
setSelectedUser(null);
resetForm();
} catch (err: any) {
alert(err.message || t('updateError'));
toast.error(err.message || t('updateError'));
}
};
@ -120,7 +122,7 @@ export default function AdminUsersPage() {
setShowDeleteConfirm(false);
setSelectedUser(null);
} catch (err: any) {
alert(err.message || t('deleteError'));
toast.error(err.message || t('deleteError'));
}
};
@ -165,8 +167,8 @@ export default function AdminUsersPage() {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">{t('loading')}</p>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
<p className="mt-4 text-neutral-600">{t('loading')}</p>
</div>
</div>
);
@ -180,7 +182,7 @@ export default function AdminUsersPage() {
actions={
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors"
className="px-4 py-2 bg-brand-turquoise text-brand-navy text-sm font-medium rounded-lg hover:bg-brand-turquoise/90 transition-colors"
>
{t('create')}
</button>
@ -196,39 +198,39 @@ export default function AdminUsersPage() {
{/* Users Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.organization')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.status')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-border">
{users.map(user => (
<tr key={user.id} className="hover:bg-gray-50">
<tr key={user.id} className="hover:bg-neutral-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
<div className="text-sm font-medium text-brand-navy">
{user.firstName} {user.lastName}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{user.email}</div>
<div className="text-sm text-neutral-500">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
@ -236,14 +238,14 @@ export default function AdminUsersPage() {
user.role === 'ADMIN'
? 'bg-purple-100 text-purple-800'
: user.role === 'MANAGER'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
? 'bg-brand-turquoise/10 text-brand-navy'
: 'bg-neutral-100 text-neutral-800'
}`}
>
{getRoleLabel(user.role)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{user.organizationName || user.organizationId}
</td>
<td className="px-6 py-4 whitespace-nowrap">
@ -258,7 +260,7 @@ export default function AdminUsersPage() {
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<button
onClick={() => openEditModal(user)}
className="text-blue-600 hover:text-blue-900"
className="text-brand-navy hover:text-brand-turquoise"
>
{t('edit')}
</button>
@ -282,7 +284,7 @@ export default function AdminUsersPage() {
<h2 className="text-xl font-bold mb-4">{t('modal.createTitle')}</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.email')}
</label>
<input
@ -290,11 +292,11 @@ export default function AdminUsersPage() {
required
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.firstName')}
</label>
<input
@ -302,11 +304,11 @@ export default function AdminUsersPage() {
required
value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.lastName')}
</label>
<input
@ -314,15 +316,15 @@ export default function AdminUsersPage() {
required
value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')}</label>
<label className="block text-sm font-medium text-neutral-700">{t('modal.role')}</label>
<select
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
>
<option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option>
@ -331,14 +333,14 @@ export default function AdminUsersPage() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.organization')}
</label>
<select
required
value={formData.organizationId}
onChange={e => setFormData({ ...formData, organizationId: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
>
<option value="">{t('modal.selectOrganization')}</option>
{organizations.map(org => (
@ -349,7 +351,7 @@ export default function AdminUsersPage() {
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.password')}
</label>
<div className="relative mt-1">
@ -357,12 +359,12 @@ export default function AdminUsersPage() {
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={e => setFormData({ ...formData, password: e.target.value })}
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={showPassword ? tCommon('hidePassword') : tCommon('showPassword')}
>
@ -377,13 +379,13 @@ export default function AdminUsersPage() {
setShowCreateModal(false);
resetForm();
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
className="px-4 py-2 border border-neutral-300 rounded-md text-neutral-700 hover:bg-neutral-50"
>
{t('modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md hover:bg-brand-turquoise/90"
>
{t('modal.create')}
</button>
@ -400,18 +402,18 @@ export default function AdminUsersPage() {
<h2 className="text-xl font-bold mb-4">{t('modal.editTitle')}</h2>
<form onSubmit={handleUpdate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.emailReadOnly')}
</label>
<input
type="email"
disabled
value={formData.email}
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-gray-100 rounded-md shadow-sm"
className="mt-1 block w-full px-3 py-2 border border-neutral-300 bg-neutral-100 rounded-md shadow-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.firstName')}
</label>
<input
@ -419,11 +421,11 @@ export default function AdminUsersPage() {
required
value={formData.firstName}
onChange={e => setFormData({ ...formData, firstName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.lastName')}
</label>
<input
@ -431,15 +433,15 @@ export default function AdminUsersPage() {
required
value={formData.lastName}
onChange={e => setFormData({ ...formData, lastName: e.target.value })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">{t('modal.role')}</label>
<label className="block text-sm font-medium text-neutral-700">{t('modal.role')}</label>
<select
value={formData.role}
onChange={e => setFormData({ ...formData, role: e.target.value as UserRole })}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
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"
>
<option value="USER">{t('roles.USER')}</option>
<option value="MANAGER">{t('roles.MANAGER')}</option>
@ -455,13 +457,13 @@ export default function AdminUsersPage() {
setSelectedUser(null);
resetForm();
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
className="px-4 py-2 border border-neutral-300 rounded-md text-neutral-700 hover:bg-neutral-50"
>
{t('modal.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md hover:bg-brand-turquoise/90"
>
{t('modal.update')}
</button>
@ -476,7 +478,7 @@ export default function AdminUsersPage() {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<h2 className="text-xl font-bold mb-4 text-red-600">{t('deleteConfirm.title')}</h2>
<p className="text-gray-700 mb-6">
<p className="text-neutral-700 mb-6">
{t('deleteConfirm.message', {
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
@ -488,7 +490,7 @@ export default function AdminUsersPage() {
setShowDeleteConfirm(false);
setSelectedUser(null);
}}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
className="px-4 py-2 border border-neutral-300 rounded-md text-neutral-700 hover:bg-neutral-50"
>
{t('deleteConfirm.cancel')}
</button>

View File

@ -43,13 +43,13 @@ function LoadingSkeleton() {
<LandingHeader />
<div className="max-w-4xl mx-auto px-6 pt-32 pb-20">
<div className="animate-pulse space-y-6">
<div className="h-4 bg-gray-200 rounded w-24" />
<div className="h-10 bg-gray-200 rounded w-3/4" />
<div className="h-5 bg-gray-200 rounded w-1/2" />
<div className="h-72 bg-gray-200 rounded-2xl mt-8" />
<div className="h-4 bg-neutral-200 rounded w-24" />
<div className="h-10 bg-neutral-200 rounded w-3/4" />
<div className="h-5 bg-neutral-200 rounded w-1/2" />
<div className="h-72 bg-neutral-200 rounded-2xl mt-8" />
<div className="space-y-3 mt-8">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded" style={{ width: `${85 + (i % 3) * 5}%` }} />
<div key={i} className="h-4 bg-neutral-200 rounded" style={{ width: `${85 + (i % 3) * 5}%` }} />
))}
</div>
</div>
@ -64,9 +64,9 @@ function NotFoundView() {
<div className="min-h-screen bg-white">
<LandingHeader />
<div className="max-w-4xl mx-auto px-6 pt-32 pb-20 text-center">
<Anchor className="w-24 h-24 text-gray-200 mx-auto mb-6" />
<Anchor className="w-24 h-24 text-neutral-200 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-brand-navy mb-4">Article introuvable</h1>
<p className="text-gray-600 mb-8">
<p className="text-neutral-600 mb-8">
Cet article n&apos;existe pas ou n&apos;est pas encore publié.
</p>
<Link href="/blog">
@ -126,10 +126,10 @@ export default function BlogPostContent({ slug }: { slug: string }) {
<LandingHeader />
{/* Hero */}
<section className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy via-brand-navy to-[#1a2a5e]">
<section className="relative pt-32 pb-20 bg-gradient-to-br from-brand-navy via-brand-navy to-brand-navy">
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-20 left-20 w-96 h-96 bg-brand-turquoise/10 rounded-full blur-3xl" />
<div className="absolute bottom-10 right-20 w-64 h-64 bg-blue-400/10 rounded-full blur-3xl" />
<div className="absolute bottom-10 right-20 w-64 h-64 bg-brand-turquoise/10 rounded-full blur-3xl" />
</div>
<div className="relative z-10 max-w-4xl mx-auto px-6 lg:px-8">
@ -220,7 +220,7 @@ export default function BlogPostContent({ slug }: { slug: string }) {
<p className="text-xs font-semibold uppercase tracking-wide text-brand-turquoise mb-2">
En bref
</p>
<p className="text-gray-700 leading-relaxed">{post.aiSummary}</p>
<p className="text-neutral-700 leading-relaxed">{post.aiSummary}</p>
</div>
)}
@ -234,11 +234,11 @@ export default function BlogPostContent({ slug }: { slug: string }) {
prose-headings:font-bold prose-headings:text-brand-navy prose-headings:tracking-tight
prose-h2:text-2xl prose-h2:mt-10 prose-h2:mb-4 prose-h2:border-l-4 prose-h2:border-brand-turquoise prose-h2:pl-4
prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-3
prose-p:text-gray-700 prose-p:leading-relaxed prose-p:mb-5
prose-p:text-neutral-700 prose-p:leading-relaxed prose-p:mb-5
prose-a:text-brand-turquoise prose-a:font-medium hover:prose-a:underline
prose-strong:text-brand-navy
prose-blockquote:not-italic
prose-ul:text-gray-700 prose-ol:text-gray-700
prose-ul:text-neutral-700 prose-ol:text-neutral-700
prose-img:rounded-xl prose-img:shadow-lg"
dangerouslySetInnerHTML={{ __html: processedContent }}
/>
@ -247,7 +247,7 @@ export default function BlogPostContent({ slug }: { slug: string }) {
<div className="hidden lg:block sticky top-24">
<button
onClick={handleShare}
className="w-10 h-10 flex items-center justify-center bg-white border border-gray-200 rounded-full shadow-sm text-gray-400 hover:text-brand-turquoise hover:border-brand-turquoise transition-colors"
className="w-10 h-10 flex items-center justify-center bg-white border border-border rounded-full shadow-sm text-neutral-400 hover:text-brand-turquoise hover:border-brand-turquoise transition-colors"
title="Partager l'article"
>
<Share2 className="w-4 h-4" />
@ -257,11 +257,11 @@ export default function BlogPostContent({ slug }: { slug: string }) {
{/* À retenir (key takeaways) */}
{post.keyTakeaways && post.keyTakeaways.length > 0 && (
<div className="mt-12 rounded-2xl border border-gray-200 bg-gray-50 p-6">
<div className="mt-12 rounded-2xl border border-border bg-neutral-50 p-6">
<h2 className="text-lg font-bold text-brand-navy mb-4">À retenir</h2>
<ul className="space-y-2">
{post.keyTakeaways.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-gray-700">
<li key={i} className="flex items-start gap-3 text-neutral-700">
<span className="mt-2 w-1.5 h-1.5 rounded-full bg-brand-turquoise flex-shrink-0" />
<span className="leading-relaxed">{item}</span>
</li>
@ -278,7 +278,7 @@ export default function BlogPostContent({ slug }: { slug: string }) {
{post.faq.map((item, i) => (
<details
key={i}
className="group rounded-xl border border-gray-200 bg-white p-5 open:shadow-sm"
className="group rounded-xl border border-border bg-white p-5 open:shadow-sm"
>
<summary className="cursor-pointer font-semibold text-brand-navy list-none flex items-center justify-between gap-4">
<span>{item.question}</span>
@ -286,7 +286,7 @@ export default function BlogPostContent({ slug }: { slug: string }) {
+
</span>
</summary>
<p className="mt-3 text-gray-700 leading-relaxed">{item.answer}</p>
<p className="mt-3 text-neutral-700 leading-relaxed">{item.answer}</p>
</details>
))}
</div>
@ -295,11 +295,11 @@ export default function BlogPostContent({ slug }: { slug: string }) {
{/* Tags */}
{post.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-gray-100">
<div className="flex flex-wrap gap-2 mt-12 pt-8 border-t border-border">
{post.tags.map(tag => (
<span
key={tag}
className="px-3 py-1.5 bg-gray-100 text-gray-600 text-sm rounded-full hover:bg-gray-200 transition-colors"
className="px-3 py-1.5 bg-neutral-100 text-neutral-600 text-sm rounded-full hover:bg-neutral-200 transition-colors"
>
#{tag}
</span>
@ -308,7 +308,7 @@ export default function BlogPostContent({ slug }: { slug: string }) {
)}
{/* CTA */}
<div className="mt-16 rounded-2xl bg-gradient-to-br from-brand-navy to-[#1a2a5e] p-8 lg:p-10 text-center">
<div className="mt-16 rounded-2xl bg-gradient-to-br from-brand-navy to-brand-navy p-8 lg:p-10 text-center">
<BookOpen className="w-8 h-8 text-brand-turquoise mx-auto mb-4" />
<h2 className="text-xl lg:text-2xl font-bold text-white mb-3">
Prêt à tester Xpeditis ?
@ -335,7 +335,7 @@ export default function BlogPostContent({ slug }: { slug: string }) {
</Link>
<button
onClick={handleShare}
className="lg:hidden inline-flex items-center space-x-2 px-4 py-2.5 border border-gray-200 rounded-xl text-gray-600 hover:border-brand-turquoise hover:text-brand-turquoise transition-colors text-sm"
className="lg:hidden inline-flex items-center space-x-2 px-4 py-2.5 border border-border rounded-xl text-neutral-600 hover:border-brand-turquoise hover:text-brand-turquoise transition-colors text-sm"
>
<Share2 className="w-4 h-4" />
<span>Partager</span>

View File

@ -162,13 +162,13 @@ export default function BlogPage() {
className="max-w-xl mx-auto"
>
<div className="relative">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-neutral-400" />
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
className="w-full pl-12 pr-4 py-4 rounded-xl bg-white text-brand-navy placeholder-neutral-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
/>
</div>
</motion.div>
@ -186,7 +186,7 @@ export default function BlogPage() {
</section>
{/* Categories */}
<section ref={categoriesRef} className="py-8 border-b border-gray-200">
<section ref={categoriesRef} className="py-8 border-b border-border">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isCategoriesInView ? { opacity: 1, y: 0 } : {}}
@ -204,7 +204,7 @@ export default function BlogPage() {
className={`flex items-center space-x-2 px-4 py-2 rounded-full transition-all ${
isActive
? 'bg-brand-turquoise text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`}
>
<IconComponent className="w-4 h-4" />
@ -284,7 +284,7 @@ export default function BlogPage() {
)}
{/* Articles Grid */}
<section ref={articlesRef} className="py-16 bg-gray-50">
<section ref={articlesRef} className="py-16 bg-neutral-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -293,7 +293,7 @@ export default function BlogPage() {
className="flex items-center justify-between mb-12"
>
<h2 className="text-3xl font-bold text-brand-navy">{t('allTitle')}</h2>
<span className="text-gray-500">{t('articlesCount', { count: posts.length })}</span>
<span className="text-neutral-500">{t('articlesCount', { count: posts.length })}</span>
</motion.div>
{loading ? (
@ -303,20 +303,20 @@ export default function BlogPage() {
key={i}
className="bg-white rounded-2xl shadow-lg overflow-hidden animate-pulse"
>
<div className="aspect-video bg-gray-200" />
<div className="aspect-video bg-neutral-200" />
<div className="p-6 space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded" />
<div className="h-3 bg-gray-200 rounded w-5/6" />
<div className="h-4 bg-neutral-200 rounded w-3/4" />
<div className="h-3 bg-neutral-200 rounded" />
<div className="h-3 bg-neutral-200 rounded w-5/6" />
</div>
</div>
))}
</div>
) : posts.length === 0 ? (
<div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">{t('noResults.title')}</h3>
<p className="text-gray-500">{t('noResults.body')}</p>
<Search className="w-16 h-16 text-neutral-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-neutral-600">{t('noResults.title')}</h3>
<p className="text-neutral-500">{t('noResults.body')}</p>
</div>
) : (
<motion.div
@ -351,20 +351,20 @@ export default function BlogPage() {
{post.title}
</h3>
<p className="text-gray-600 mb-4 line-clamp-2 flex-1">{post.excerpt}</p>
<p className="text-neutral-600 mb-4 line-clamp-2 flex-1">{post.excerpt}</p>
<div className="flex flex-wrap gap-2 mb-4">
{post.tags.map(tag => (
<span
key={tag}
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
className="px-2 py-1 bg-neutral-100 text-neutral-600 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
<div className="flex items-center justify-between text-sm text-gray-500 pt-4 border-t border-gray-100">
<div className="flex items-center justify-between text-sm text-neutral-500 pt-4 border-t border-border">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-brand-turquoise/10 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-brand-turquoise" />
@ -403,7 +403,7 @@ export default function BlogPage() {
<input
type="email"
placeholder={t('newsletter.emailPlaceholder')}
className="w-full sm:w-96 px-6 py-4 rounded-lg bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
className="w-full sm:w-96 px-6 py-4 rounded-lg bg-white text-brand-navy placeholder-neutral-400 focus:ring-2 focus:ring-brand-turquoise focus:outline-none"
/>
<button
type="submit"

View File

@ -52,10 +52,10 @@ export default function BookingConfirmPage() {
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 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="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">{t('loading')}</p>
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-turquoise mx-auto mb-4"></div>
<p className="text-neutral-600">{t('loading')}</p>
</div>
</div>
);
@ -63,7 +63,7 @@ export default function BookingConfirmPage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 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">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
@ -81,8 +81,8 @@ export default function BookingConfirmPage() {
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
<p className="text-gray-600">{error}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('errorTitle')}</h1>
<p className="text-neutral-600">{error}</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
@ -96,7 +96,7 @@ export default function BookingConfirmPage() {
</ul>
</div>
<p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
<p className="text-sm text-neutral-500 text-center">{t('errorContact')}</p>
</div>
</div>
);
@ -107,7 +107,7 @@ export default function BookingConfirmPage() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-green-50 via-white to-green-50 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-2xl w-full">
{/* Success Icon with Animation */}
<div className="text-center mb-8">
@ -130,64 +130,64 @@ export default function BookingConfirmPage() {
<div className="absolute inset-0 rounded-full border-4 border-green-200 animate-ping opacity-20"></div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">{t('successTitle')}</h1>
<p className="text-lg text-gray-600 mb-2">{t('successHeadline')}</p>
<p className="text-gray-500">{t('successBody')}</p>
<h1 className="text-3xl font-bold text-brand-navy mb-3">{t('successTitle')}</h1>
<p className="text-lg text-neutral-600 mb-2">{t('successHeadline')}</p>
<p className="text-neutral-500">{t('successBody')}</p>
</div>
{/* Booking Summary */}
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
<div className="bg-neutral-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-brand-navy mb-4">{t('summaryTitle')}</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.bookingId')}</span>
<span className="font-semibold text-gray-900">{booking.bookingId}</span>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.bookingId')}</span>
<span className="font-semibold text-brand-navy">{booking.bookingId}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.route')}</span>
<span className="font-semibold text-gray-900">
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.route')}</span>
<span className="font-semibold text-brand-navy">
{booking.origin} → {booking.destination}
</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.volume')}</span>
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.volume')}</span>
<span className="font-semibold text-brand-navy">{booking.volumeCBM} CBM</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.weight')}</span>
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.weight')}</span>
<span className="font-semibold text-brand-navy">{booking.weightKG} kg</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.pallets')}</span>
<span className="font-semibold text-gray-900">{booking.palletCount}</span>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.pallets')}</span>
<span className="font-semibold text-brand-navy">{booking.palletCount}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.containerType')}</span>
<span className="font-semibold text-gray-900">{booking.containerType}</span>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.containerType')}</span>
<span className="font-semibold text-brand-navy">{booking.containerType}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.transitDays')}</span>
<span className="font-semibold text-gray-900">
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.transitDays')}</span>
<span className="font-semibold text-brand-navy">
{t('transitDaysValue', { count: booking.transitDays })}
</span>
</div>
<div className="flex justify-between py-3">
<span className="text-gray-600 text-lg">{t('labels.price')}</span>
<span className="text-neutral-600 text-lg">{t('labels.price')}</span>
<div className="text-right">
<div className="font-bold text-xl text-green-600">
{booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}`
: `€${booking.priceEUR.toLocaleString()}`}
</div>
<div className="text-sm text-gray-500">
<div className="text-sm text-neutral-500">
{booking.primaryCurrency === 'USD'
? `(€${booking.priceEUR.toLocaleString()})`
: `($${booking.priceUSD.toLocaleString()})`}
@ -197,16 +197,16 @@ export default function BookingConfirmPage() {
</div>
{booking.notes && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-1">{t('labels.notes')}</p>
<p className="text-gray-800">{booking.notes}</p>
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm text-neutral-600 mb-1">{t('labels.notes')}</p>
<p className="text-neutral-800">{booking.notes}</p>
</div>
)}
</div>
{/* Next Steps */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-brand-navy mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@ -217,7 +217,7 @@ export default function BookingConfirmPage() {
</svg>
{t('nextStepsTitle')}
</h3>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<ul className="text-sm text-brand-navy space-y-1 list-disc list-inside">
<li>{t('nextStep1')}</li>
<li>{t('nextStep2')}</li>
<li>{t('nextStep3')}</li>
@ -226,17 +226,17 @@ export default function BookingConfirmPage() {
{/* Documents Section */}
{booking.documents && booking.documents.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('labels.documents')}</h3>
<div className="bg-neutral-50 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-brand-navy mb-3">{t('labels.documents')}</h3>
<div className="space-y-2">
{booking.documents.map((doc, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-white rounded border border-gray-200"
className="flex items-center justify-between p-3 bg-white rounded border border-border"
>
<div className="flex items-center">
<svg
className="w-5 h-5 text-gray-400 mr-3"
className="w-5 h-5 text-neutral-400 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -249,15 +249,15 @@ export default function BookingConfirmPage() {
/>
</svg>
<div>
<p className="text-sm font-medium text-gray-900">{doc.fileName}</p>
<p className="text-xs text-gray-500">{doc.type}</p>
<p className="text-sm font-medium text-brand-navy">{doc.fileName}</p>
<p className="text-xs text-neutral-500">{doc.type}</p>
</div>
</div>
<a
href={doc.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
className="text-brand-navy hover:text-brand-turquoise text-sm font-medium"
>
{t('labels.download')}
</a>
@ -268,9 +268,9 @@ export default function BookingConfirmPage() {
)}
{/* Contact Info */}
<div className="text-center text-sm text-gray-500">
<div className="text-center text-sm text-neutral-500">
<p>{tCommon('supportPrompt')}</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
<a href="mailto:support@xpeditis.com" className="text-brand-navy hover:underline">
support@xpeditis.com
</a>
</div>

View File

@ -0,0 +1,5 @@
import { PortalShell } from '@/components/shell/portal-shell';
export default function BookingPortalLayout({ children }: { children: React.ReactNode }) {
return <PortalShell>{children}</PortalShell>;
}

View File

@ -60,10 +60,10 @@ export default function BookingRejectPage() {
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 via-white to-gray-50 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="animate-spin rounded-full h-16 w-16 border-b-2 border-gray-600 mx-auto mb-4"></div>
<p className="text-gray-600">{t('loading')}</p>
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-neutral-600 mx-auto mb-4"></div>
<p className="text-neutral-600">{t('loading')}</p>
</div>
</div>
);
@ -71,7 +71,7 @@ export default function BookingRejectPage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 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">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
@ -89,8 +89,8 @@ export default function BookingRejectPage() {
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('errorTitle')}</h1>
<p className="text-gray-600">{error}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('errorTitle')}</h1>
<p className="text-neutral-600">{error}</p>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
@ -104,7 +104,7 @@ export default function BookingRejectPage() {
</ul>
</div>
<p className="text-sm text-gray-500 text-center">{t('errorContact')}</p>
<p className="text-sm text-neutral-500 text-center">{t('errorContact')}</p>
</div>
</div>
);
@ -112,7 +112,7 @@ export default function BookingRejectPage() {
if (hasRejected && booking) {
return (
<div className="min-h-screen bg-gradient-to-br from-red-50 via-white to-red-50 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-2xl w-full">
<div className="text-center mb-8">
<div className="relative inline-block">
@ -133,40 +133,40 @@ export default function BookingRejectPage() {
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-3">{t('rejectedTitle')}</h1>
<p className="text-lg text-gray-600 mb-2">{t('rejectedHeadline')}</p>
<p className="text-gray-500">{t('rejectedBody')}</p>
<h1 className="text-3xl font-bold text-brand-navy mb-3">{t('rejectedTitle')}</h1>
<p className="text-lg text-neutral-600 mb-2">{t('rejectedHeadline')}</p>
<p className="text-neutral-500">{t('rejectedBody')}</p>
</div>
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('summaryTitle')}</h2>
<div className="bg-neutral-50 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold text-brand-navy mb-4">{t('summaryTitle')}</h2>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.bookingId')}</span>
<span className="font-semibold text-gray-900">{booking.bookingId}</span>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.bookingId')}</span>
<span className="font-semibold text-brand-navy">{booking.bookingId}</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.route')}</span>
<span className="font-semibold text-gray-900">
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.route')}</span>
<span className="font-semibold text-brand-navy">
{booking.origin} → {booking.destination}
</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.volume')}</span>
<span className="font-semibold text-gray-900">{booking.volumeCBM} CBM</span>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.volume')}</span>
<span className="font-semibold text-brand-navy">{booking.volumeCBM} CBM</span>
</div>
<div className="flex justify-between py-2 border-b border-gray-200">
<span className="text-gray-600">{t('labels.weight')}</span>
<span className="font-semibold text-gray-900">{booking.weightKG} kg</span>
<div className="flex justify-between py-2 border-b border-border">
<span className="text-neutral-600">{t('labels.weight')}</span>
<span className="font-semibold text-brand-navy">{booking.weightKG} kg</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-600">{t('labels.proposedPrice')}</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-600">{t('labels.proposedPrice')}</span>
<span className="font-semibold text-brand-navy">
{booking.primaryCurrency === 'USD'
? `$${booking.priceUSD.toLocaleString()}`
: `€${booking.priceEUR.toLocaleString()}`}
@ -175,17 +175,17 @@ export default function BookingRejectPage() {
</div>
{reason && (
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm text-gray-600 mb-1">{t('labels.rejectionReason')}</p>
<p className="text-gray-800 bg-white p-3 rounded border border-gray-200">
<div className="mt-4 pt-4 border-t border-border">
<p className="text-sm text-neutral-600 mb-1">{t('labels.rejectionReason')}</p>
<p className="text-neutral-800 bg-white p-3 rounded border border-border">
{reason}
</p>
</div>
)}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center">
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-brand-navy mb-2 flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
@ -196,12 +196,12 @@ export default function BookingRejectPage() {
</svg>
{t('infoTitle')}
</h3>
<p className="text-sm text-blue-800">{t('infoBody')}</p>
<p className="text-sm text-brand-navy">{t('infoBody')}</p>
</div>
<div className="text-center text-sm text-gray-500">
<div className="text-center text-sm text-neutral-500">
<p>{tCommon('supportPrompt')}</p>
<a href="mailto:support@xpeditis.com" className="text-blue-600 hover:underline">
<a href="mailto:support@xpeditis.com" className="text-brand-navy hover:underline">
support@xpeditis.com
</a>
</div>
@ -231,7 +231,7 @@ export default function BookingRejectPage() {
}
return (
<div className="min-h-screen bg-gradient-to-br from-orange-50 via-white to-orange-50 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">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-4">
@ -249,20 +249,20 @@ export default function BookingRejectPage() {
/>
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('formTitle')}</h1>
<p className="text-gray-600">{t('formIntro')}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('formTitle')}</h1>
<p className="text-neutral-600">{t('formIntro')}</p>
</div>
<div className="mb-6">
{!showReasonField ? (
<button
onClick={() => setShowReasonField(true)}
className="w-full text-left px-4 py-3 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors"
className="w-full text-left px-4 py-3 bg-neutral-50 hover:bg-neutral-100 border border-border rounded-lg transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-gray-700">{t('addReason')}</span>
<span className="text-neutral-700">{t('addReason')}</span>
<svg
className="w-5 h-5 text-gray-400"
className="w-5 h-5 text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -278,7 +278,7 @@ export default function BookingRejectPage() {
</button>
) : (
<div>
<label htmlFor="reason" className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor="reason" className="block text-sm font-medium text-neutral-700 mb-2">
{t('reasonLabel')}
</label>
<textarea
@ -287,12 +287,12 @@ export default function BookingRejectPage() {
value={reason}
onChange={e => setReason(e.target.value)}
placeholder={t('reasonPlaceholder')}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
className="w-full px-4 py-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
maxLength={500}
/>
<div className="mt-1 flex items-center justify-between">
<p className="text-xs text-gray-500">{t('reasonHint')}</p>
<span className="text-xs text-gray-400">{reason.length}/500</span>
<p className="text-xs text-neutral-500">{t('reasonHint')}</p>
<span className="text-xs text-neutral-400">{reason.length}/500</span>
</div>
</div>
)}
@ -350,13 +350,13 @@ export default function BookingRejectPage() {
<a
href="mailto:support@xpeditis.com"
className="block w-full px-6 py-3 bg-white hover:bg-gray-50 border border-gray-300 text-gray-700 font-semibold rounded-lg transition-colors text-center"
className="block w-full px-6 py-3 bg-white hover:bg-neutral-50 border border-neutral-300 text-neutral-700 font-semibold rounded-lg transition-colors text-center"
>
{tCommon('contactSupport')}
</a>
</div>
<p className="mt-6 text-xs text-center text-gray-500">{t('helpText')}</p>
<p className="mt-6 text-xs text-center text-neutral-500">{t('helpText')}</p>
</div>
</div>
);

View File

@ -233,7 +233,7 @@ export default function CareersPage() {
</a>
<Link
href="/about"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-neutral-100 transition-all font-semibold text-lg"
>
{t('learnMore')}
</Link>
@ -253,7 +253,7 @@ export default function CareersPage() {
</section>
{/* Stats Section */}
<section className="py-16 bg-gray-50">
<section className="py-16 bg-neutral-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{STATS.map((stat, index) => (
@ -266,7 +266,7 @@ export default function CareersPage() {
className="text-center"
>
<div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div>
<div className="text-neutral-600 font-medium">{t(`stats.${stat.key}`)}</div>
</motion.div>
))}
</div>
@ -285,7 +285,7 @@ export default function CareersPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('benefitsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('benefitsSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('benefitsSubtitle')}</p>
</motion.div>
<motion.div
@ -301,7 +301,7 @@ export default function CareersPage() {
key={benefit.key}
variants={itemVariants}
whileHover={{ y: -5 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
className="bg-white p-6 rounded-2xl shadow-lg border border-border hover:shadow-xl transition-all"
>
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-7 h-7 text-brand-turquoise" />
@ -309,7 +309,7 @@ export default function CareersPage() {
<h3 className="text-xl font-bold text-brand-navy mb-2">
{t(`benefits.${benefit.key}.title`)}
</h3>
<p className="text-gray-600">{t(`benefits.${benefit.key}.description`)}</p>
<p className="text-neutral-600">{t(`benefits.${benefit.key}.description`)}</p>
</motion.div>
);
})}
@ -382,7 +382,7 @@ export default function CareersPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('jobsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('jobsSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('jobsSubtitle')}</p>
</motion.div>
{/* Filters */}
@ -396,7 +396,7 @@ export default function CareersPage() {
<select
value={selectedDepartment}
onChange={e => setSelectedDepartment(e.target.value as DepartmentValue)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
className="appearance-none px-6 py-3 pr-10 bg-white border border-neutral-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
>
{DEPARTMENT_VALUES.map(value => (
<option key={value} value={value}>
@ -406,13 +406,13 @@ export default function CareersPage() {
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-neutral-400 pointer-events-none" />
</div>
<div className="relative">
<select
value={selectedLocation}
onChange={e => setSelectedLocation(e.target.value as LocationValue)}
className="appearance-none px-6 py-3 pr-10 bg-white border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
className="appearance-none px-6 py-3 pr-10 bg-white border border-neutral-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-transparent cursor-pointer"
>
{LOCATION_VALUES.map(value => (
<option key={value} value={value}>
@ -420,7 +420,7 @@ export default function CareersPage() {
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-neutral-400 pointer-events-none" />
</div>
</motion.div>
@ -433,9 +433,9 @@ export default function CareersPage() {
>
{filteredJobs.length === 0 ? (
<div className="text-center py-12">
<Search className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-gray-600">{t('noJobs.title')}</h3>
<p className="text-gray-500">{t('noJobs.body')}</p>
<Search className="w-16 h-16 text-neutral-300 mx-auto mb-4" />
<h3 className="text-xl font-medium text-neutral-600">{t('noJobs.title')}</h3>
<p className="text-neutral-500">{t('noJobs.body')}</p>
</div>
) : (
filteredJobs.map(job => {
@ -446,10 +446,10 @@ export default function CareersPage() {
<motion.div
key={job.id}
variants={itemVariants}
className="bg-white rounded-2xl shadow-lg border border-gray-100 overflow-hidden"
className="bg-white rounded-2xl shadow-lg border border-border overflow-hidden"
>
<div
className="p-6 cursor-pointer hover:bg-gray-50 transition-colors"
className="p-6 cursor-pointer hover:bg-neutral-50 transition-colors"
onClick={() => setExpandedJob(isExpanded ? null : job.id)}
>
<div className="flex items-center justify-between">
@ -461,7 +461,7 @@ export default function CareersPage() {
<h3 className="text-xl font-bold text-brand-navy">
{t(`jobs.${job.key}.title`)}
</h3>
<div className="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<div className="flex items-center space-x-4 mt-1 text-sm text-neutral-500">
<span className="flex items-center space-x-1">
<Building2 className="w-4 h-4" />
<span>{t(`departments.${job.department}` as any)}</span>
@ -489,7 +489,7 @@ export default function CareersPage() {
</span>
</div>
<ChevronDown
className={`w-6 h-6 text-gray-400 transition-transform ${
className={`w-6 h-6 text-neutral-400 transition-transform ${
isExpanded ? 'transform rotate-180' : ''
}`}
/>
@ -504,10 +504,10 @@ export default function CareersPage() {
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className="border-t border-gray-100"
className="border-t border-border"
>
<div className="p-6 bg-gray-50">
<p className="text-gray-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
<div className="p-6 bg-neutral-50">
<p className="text-neutral-600 mb-6">{t(`jobs.${job.key}.description`)}</p>
<h4 className="font-bold text-brand-navy mb-3">
{t('jobCard.profile')}
</h4>
@ -515,7 +515,7 @@ export default function CareersPage() {
{JOB_REQ_KEYS.map(reqKey => (
<li
key={reqKey}
className="flex items-start space-x-2 text-gray-600"
className="flex items-start space-x-2 text-neutral-600"
>
<ChevronRight className="w-5 h-5 text-brand-turquoise flex-shrink-0 mt-0.5" />
<span>{t(`jobs.${job.key}.${reqKey}` as any)}</span>
@ -530,7 +530,7 @@ export default function CareersPage() {
<span>{t('jobCard.apply')}</span>
<ArrowRight className="w-4 h-4" />
</Link>
<button className="px-6 py-3 border border-gray-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-gray-700">
<button className="px-6 py-3 border border-neutral-300 rounded-lg hover:border-brand-turquoise transition-all font-medium text-neutral-700">
{t('jobCard.learnMore')}
</button>
</div>
@ -547,7 +547,7 @@ export default function CareersPage() {
</section>
{/* CTA Section */}
<section className="py-20 bg-gray-50">
<section className="py-20 bg-neutral-50">
<div className="max-w-4xl mx-auto px-6 lg:px-8 text-center">
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -556,7 +556,7 @@ export default function CareersPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-4xl font-bold text-brand-navy mb-6">{t('cta.title')}</h2>
<p className="text-xl text-gray-600 mb-10">{t('cta.body')}</p>
<p className="text-xl text-neutral-600 mb-10">{t('cta.body')}</p>
<Link
href="/contact"
className="inline-flex items-center space-x-2 px-8 py-4 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 transition-all font-semibold text-lg"

View File

@ -91,11 +91,11 @@ export default function CarrierAcceptPage() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
<div className="min-h-[60vh] flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-green-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('accept.loadingTitle')}</h1>
<p className="text-gray-600">{t('accept.loadingMessage')}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-4">{t('accept.loadingTitle')}</h1>
<p className="text-neutral-600">{t('accept.loadingMessage')}</p>
</div>
</div>
);
@ -103,14 +103,14 @@ export default function CarrierAcceptPage() {
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
<div className="min-h-[60vh] flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('common.errorTitle')}</h1>
<p className="text-gray-600 mb-6">{error}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-4">{t('common.errorTitle')}</h1>
<p className="text-neutral-600 mb-6">{error}</p>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
className="w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
>
{t('common.backHome')}
</button>
@ -120,17 +120,17 @@ export default function CarrierAcceptPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
<div className="min-h-[60vh] flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">{t('accept.thanksTitle')}</h1>
<h1 className="text-3xl font-bold text-brand-navy mb-4">{t('accept.thanksTitle')}</h1>
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
<p className="text-green-800 font-medium text-lg mb-2">{t('accept.successHeadline')}</p>
<p className="text-green-700 text-sm">{t('accept.successBody')}</p>
</div>
<p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
<p className="text-neutral-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
<button
onClick={() => router.push('/')}

View File

@ -18,6 +18,7 @@ import {
Eye,
EyeOff,
} from 'lucide-react';
import { useToast } from '@/components/ui/toast';
interface Document {
id: string;
@ -79,6 +80,7 @@ const getFileIcon = (mimeType: string) => {
};
export default function CarrierDocumentsPage() {
const { toast } = useToast();
const params = useParams();
const token = params.token as string;
const t = useTranslations('carrierPortal.documents');
@ -303,7 +305,7 @@ export default function CarrierDocumentsPage() {
window.URL.revokeObjectURL(objectUrl);
} catch (err) {
console.error('Error downloading document:', err);
alert(t('downloadError'));
toast.error(t('downloadError'));
} finally {
setTimeout(() => setDownloading(null), 500);
}
@ -323,11 +325,11 @@ export default function CarrierDocumentsPage() {
// Loading state
if (loading) {
return (
<div className="min-h-screen 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">
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('loading')}</h1>
<p className="text-gray-600">{t('loadingHint')}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('loading')}</h1>
<p className="text-neutral-600">{t('loadingHint')}</p>
</div>
</div>
);
@ -336,11 +338,11 @@ export default function CarrierDocumentsPage() {
// Error state
if (error) {
return (
<div className="min-h-screen 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">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('errorTitle')}</h1>
<p className="text-gray-600 mb-6">{error}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-4">{t('errorTitle')}</h1>
<p className="text-neutral-600 mb-6">{error}</p>
<button
onClick={handleRefresh}
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
@ -355,16 +357,16 @@ export default function CarrierDocumentsPage() {
// Password form state
if (requirements?.requiresPassword && !data) {
return (
<div className="min-h-screen 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="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-brand-turquoise" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
<p className="text-gray-600">{t('password.intro')}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-2">{t('password.title')}</h1>
<p className="text-neutral-600">{t('password.intro')}</p>
{requirements.bookingNumber && (
<p className="mt-2 text-sm text-gray-500">
<p className="mt-2 text-sm text-neutral-500">
{t('password.bookingLabel')}{' '}
<span className="font-mono font-bold">{requirements.bookingNumber}</span>
</p>
@ -373,7 +375,7 @@ export default function CarrierDocumentsPage() {
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-2">
{t('password.passwordLabel')}
</label>
<div className="relative">
@ -383,14 +385,14 @@ export default function CarrierDocumentsPage() {
value={password}
onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder={t('password.passwordPlaceholder')}
className="w-full px-4 py-3 border border-gray-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-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
autoComplete="off"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
@ -439,7 +441,7 @@ export default function CarrierDocumentsPage() {
const { booking, documents } = data;
return (
<div className="min-h-screen bg-brand-gray">
<div className="min-h-[60vh] bg-brand-gray">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
@ -464,7 +466,7 @@ export default function CarrierDocumentsPage() {
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Booking Summary Card */}
<div className="bg-white rounded-xl shadow-md overflow-hidden mb-6">
<div className="bg-gradient-to-r from-brand-navy to-brand-navy/80 px-6 py-4">
<div className="px-6 py-4">
<div className="flex items-center justify-center gap-4 text-white">
<span className="text-2xl font-bold">{booking.origin}</span>
<ArrowRight className="w-6 h-6" />
@ -479,38 +481,38 @@ export default function CarrierDocumentsPage() {
<div className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.volume')}</p>
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
<div className="text-center p-3 bg-neutral-50 rounded-lg">
<Package className="w-5 h-5 text-neutral-500 mx-auto mb-1" />
<p className="text-xs text-neutral-500">{t('summary.volume')}</p>
<p className="font-semibold text-brand-navy">{booking.volumeCBM} CBM</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.weight')}</p>
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
<div className="text-center p-3 bg-neutral-50 rounded-lg">
<Package className="w-5 h-5 text-neutral-500 mx-auto mb-1" />
<p className="text-xs text-neutral-500">{t('summary.weight')}</p>
<p className="font-semibold text-brand-navy">{booking.weightKG} kg</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.transit')}</p>
<p className="font-semibold text-gray-900">
<div className="text-center p-3 bg-neutral-50 rounded-lg">
<Clock className="w-5 h-5 text-neutral-500 mx-auto mb-1" />
<p className="text-xs text-neutral-500">{t('summary.transit')}</p>
<p className="font-semibold text-brand-navy">
{t('summary.transitDays', { count: booking.transitDays })}
</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.type')}</p>
<p className="font-semibold text-gray-900">{booking.containerType}</p>
<div className="text-center p-3 bg-neutral-50 rounded-lg">
<Ship className="w-5 h-5 text-neutral-500 mx-auto mb-1" />
<p className="text-xs text-neutral-500">{t('summary.type')}</p>
<p className="font-semibold text-brand-navy">{booking.containerType}</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<span className="text-gray-500">
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-sm">
<span className="text-neutral-500">
{t('summary.carrierLabel')}{' '}
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
<span className="text-brand-navy font-medium">{booking.carrierName}</span>
</span>
<span className="text-gray-500">
<span className="text-neutral-500">
{t('summary.refLabel')}{' '}
<span className="font-mono text-gray-900">
<span className="font-mono text-brand-navy">
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
</span>
</span>
@ -520,8 +522,8 @@ export default function CarrierDocumentsPage() {
{/* Documents Section */}
<div className="bg-white rounded-xl shadow-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold text-brand-navy flex items-center gap-2">
<FileText className="w-5 h-5 text-brand-turquoise" />
{t('list.heading', { count: documents.length })}
</h2>
@ -529,26 +531,26 @@ export default function CarrierDocumentsPage() {
{documents.length === 0 ? (
<div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">{t('list.empty')}</p>
<p className="text-gray-500 text-sm mt-1">{t('list.emptyHint')}</p>
<AlertCircle className="w-12 h-12 text-neutral-400 mx-auto mb-3" />
<p className="text-neutral-600">{t('list.empty')}</p>
<p className="text-neutral-500 text-sm mt-1">{t('list.emptyHint')}</p>
</div>
) : (
<div className="divide-y divide-gray-100">
<div className="divide-y divide-border">
{documents.map(doc => (
<div
key={doc.id}
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
className="px-6 py-4 flex items-center justify-between hover:bg-neutral-50 transition-colors"
>
<div className="flex items-center gap-4">
<span className="text-2xl">{getFileIcon(doc.mimeType)}</span>
<div>
<p className="font-medium text-gray-900">{doc.fileName}</p>
<p className="font-medium text-brand-navy">{doc.fileName}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
{getDocumentTypeLabel(doc.type)}
</span>
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
<span className="text-xs text-neutral-500">{formatFileSize(doc.size)}</span>
</div>
</div>
</div>
@ -577,7 +579,7 @@ export default function CarrierDocumentsPage() {
</div>
{/* Info */}
<p className="mt-6 text-center text-sm text-gray-500">{t('footerNote')}</p>
<p className="mt-6 text-center text-sm text-neutral-500">{t('footerNote')}</p>
</main>
{/* Footer */}

View File

@ -0,0 +1,5 @@
import { PortalShell } from '@/components/shell/portal-shell';
export default function CarrierPortalLayout({ children }: { children: React.ReactNode }) {
return <PortalShell>{children}</PortalShell>;
}

View File

@ -91,11 +91,11 @@ export default function CarrierRejectPage() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
<div className="min-h-[60vh] flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('reject.loadingTitle')}</h1>
<p className="text-gray-600">{t('reject.loadingMessage')}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-4">{t('reject.loadingTitle')}</h1>
<p className="text-neutral-600">{t('reject.loadingMessage')}</p>
</div>
</div>
);
@ -103,14 +103,14 @@ export default function CarrierRejectPage() {
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
<div className="min-h-[60vh] flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('common.errorTitle')}</h1>
<p className="text-gray-600 mb-6">{error}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-4">{t('common.errorTitle')}</h1>
<p className="text-neutral-600 mb-6">{error}</p>
<button
onClick={() => router.push('/')}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
className="w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
>
{t('common.backHome')}
</button>
@ -120,17 +120,17 @@ export default function CarrierRejectPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50">
<div className="min-h-[60vh] flex items-center justify-center">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
<h1 className="text-3xl font-bold text-gray-900 mb-4">{t('reject.thanksTitle')}</h1>
<h1 className="text-3xl font-bold text-brand-navy mb-4">{t('reject.thanksTitle')}</h1>
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
<p className="text-orange-800 font-medium text-lg mb-2">{t('reject.successHeadline')}</p>
<p className="text-orange-700 text-sm">{t('reject.successBody')}</p>
</div>
<p className="text-gray-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
<p className="text-neutral-500 text-sm mb-4">{t('common.redirecting', { countdown })}</p>
<button
onClick={() => router.push('/')}

View File

@ -151,7 +151,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('rightsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('rightsSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('rightsSubtitle')}</p>
</motion.div>
<motion.div
@ -167,7 +167,7 @@ export default function CompliancePage() {
key={right.key}
variants={itemVariants}
whileHover={{ y: -5 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all text-center"
className="bg-white p-8 rounded-2xl shadow-lg border border-border hover:shadow-xl transition-all text-center"
>
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mx-auto mb-4">
<IconComponent className="w-8 h-8 text-brand-turquoise" />
@ -175,7 +175,7 @@ export default function CompliancePage() {
<h3 className="text-xl font-bold text-brand-navy mb-3">
{t(`rights.${right.key}.title`)}
</h3>
<p className="text-gray-600">{t(`rights.${right.key}.description`)}</p>
<p className="text-neutral-600">{t(`rights.${right.key}.description`)}</p>
</motion.div>
);
})}
@ -188,7 +188,7 @@ export default function CompliancePage() {
transition={{ duration: 0.8, delay: 0.4 }}
className="mt-12 text-center"
>
<p className="text-gray-600 mb-4">{t('rightsCta.text')}</p>
<p className="text-neutral-600 mb-4">{t('rightsCta.text')}</p>
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
<Link
href="/login"
@ -208,7 +208,7 @@ export default function CompliancePage() {
</section>
{/* Principles */}
<section className="py-20 bg-gray-50">
<section className="py-20 bg-neutral-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -220,7 +220,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('principlesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('principlesSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('principlesSubtitle')}</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
@ -233,7 +233,7 @@ export default function CompliancePage() {
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-6 rounded-2xl shadow-lg border border-border"
>
<div className="w-12 h-12 bg-brand-green/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-6 h-6 text-brand-green" />
@ -241,7 +241,7 @@ export default function CompliancePage() {
<h3 className="text-lg font-bold text-brand-navy mb-2">
{t(`principles.${principle.key}.title`)}
</h3>
<p className="text-gray-600 text-sm">
<p className="text-neutral-600 text-sm">
{t(`principles.${principle.key}.description`)}
</p>
</motion.div>
@ -264,7 +264,7 @@ export default function CompliancePage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('measuresTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('measuresSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('measuresSubtitle')}</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
@ -293,14 +293,14 @@ export default function CompliancePage() {
</section>
{/* Data Processing Register */}
<section className="py-20 bg-gray-50">
<section className="py-20 bg-neutral-50">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-8 rounded-2xl shadow-lg border border-border"
>
<div className="flex items-start space-x-4">
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
@ -308,8 +308,8 @@ export default function CompliancePage() {
</div>
<div>
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('register.title')}</h3>
<p className="text-gray-600 mb-6">{t('register.body')}</p>
<ul className="space-y-3 text-gray-600">
<p className="text-neutral-600 mb-6">{t('register.body')}</p>
<ul className="space-y-3 text-neutral-600">
{REGISTER_ITEMS.map(itemKey => (
<li key={itemKey} className="flex items-center space-x-3">
<CheckCircle className="w-5 h-5 text-brand-green flex-shrink-0" />
@ -346,7 +346,7 @@ export default function CompliancePage() {
</a>
<Link
href="/privacy"
className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-colors font-medium"
className="px-6 py-3 bg-white text-brand-navy rounded-lg hover:bg-neutral-100 transition-colors font-medium"
>
{t('dpo.privacyLink')}
</Link>

View File

@ -28,7 +28,7 @@ type MethodKey = 'email' | 'phone' | 'chat' | 'support';
type SubjectKey = 'demo' | 'pricing' | 'partnership' | 'support' | 'press' | 'careers' | 'other';
const METHODS: { key: MethodKey; icon: LucideIcon; color: string }[] = [
{ key: 'email', icon: Mail, color: 'from-blue-500 to-cyan-500' },
{ key: 'email', icon: Mail, color: 'from-brand-turquoise to-cyan-500' },
{ key: 'phone', icon: Phone, color: 'from-green-500 to-emerald-500' },
{ key: 'chat', icon: MessageSquare, color: 'from-purple-500 to-pink-500' },
{ key: 'support', icon: Headphones, color: 'from-orange-500 to-red-500' },
@ -181,7 +181,7 @@ export default function ContactPage() {
</section>
{/* Contact Methods */}
<section ref={contactRef} className="py-16 bg-gray-50">
<section ref={contactRef} className="py-16 bg-neutral-50">
<motion.div
variants={containerVariants}
initial="hidden"
@ -195,7 +195,7 @@ export default function ContactPage() {
<motion.div
key={method.key}
variants={itemVariants}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-6 rounded-2xl shadow-lg border border-border"
>
<div
className={`w-12 h-12 rounded-xl bg-gradient-to-br ${method.color} flex items-center justify-center mb-4`}
@ -205,7 +205,7 @@ export default function ContactPage() {
<h3 className="text-lg font-bold text-brand-navy mb-1">
{t(`methods.${method.key}.title`)}
</h3>
<p className="text-gray-500 text-sm mb-2">
<p className="text-neutral-500 text-sm mb-2">
{t(`methods.${method.key}.description`)}
</p>
<p className="text-brand-turquoise font-medium">
@ -229,7 +229,7 @@ export default function ContactPage() {
transition={{ duration: 0.8 }}
>
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('form.title')}</h2>
<p className="text-gray-600 mb-8">{t('form.description')}</p>
<p className="text-neutral-600 mb-8">{t('form.description')}</p>
{isSubmitted ? (
<motion.div
@ -268,7 +268,7 @@ export default function ContactPage() {
<div>
<label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('form.firstName')} *
</label>
@ -279,14 +279,14 @@ export default function ContactPage() {
required
value={formData.firstName}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
className="w-full px-4 py-3 rounded-lg border border-neutral-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.firstNamePlaceholder')}
/>
</div>
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('form.lastName')} *
</label>
@ -297,7 +297,7 @@ export default function ContactPage() {
required
value={formData.lastName}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
className="w-full px-4 py-3 rounded-lg border border-neutral-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.lastNamePlaceholder')}
/>
</div>
@ -307,7 +307,7 @@ export default function ContactPage() {
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('form.email')} *
</label>
@ -318,14 +318,14 @@ export default function ContactPage() {
required
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
className="w-full px-4 py-3 rounded-lg border border-neutral-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.emailPlaceholder')}
/>
</div>
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('form.phone')}
</label>
@ -335,7 +335,7 @@ export default function ContactPage() {
name="phone"
value={formData.phone}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
className="w-full px-4 py-3 rounded-lg border border-neutral-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.phonePlaceholder')}
/>
</div>
@ -344,7 +344,7 @@ export default function ContactPage() {
<div>
<label
htmlFor="company"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('form.company')}
</label>
@ -354,7 +354,7 @@ export default function ContactPage() {
name="company"
value={formData.company}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
className="w-full px-4 py-3 rounded-lg border border-neutral-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
placeholder={t('form.companyPlaceholder')}
/>
</div>
@ -362,7 +362,7 @@ export default function ContactPage() {
<div>
<label
htmlFor="subject"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('form.subject')} *
</label>
@ -372,7 +372,7 @@ export default function ContactPage() {
required
value={formData.subject}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
className="w-full px-4 py-3 rounded-lg border border-neutral-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all"
>
<option value="">{t('subjects.placeholder')}</option>
{SUBJECTS.map(key => (
@ -386,7 +386,7 @@ export default function ContactPage() {
<div>
<label
htmlFor="message"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('form.message')} *
</label>
@ -397,7 +397,7 @@ export default function ContactPage() {
rows={5}
value={formData.message}
onChange={handleChange}
className="w-full px-4 py-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all resize-none"
className="w-full px-4 py-3 rounded-lg border border-neutral-300 focus:ring-2 focus:ring-brand-turquoise focus:border-transparent transition-all resize-none"
placeholder={t('form.messagePlaceholder')}
/>
</div>
@ -436,7 +436,7 @@ export default function ContactPage() {
transition={{ duration: 0.8, delay: 0.2 }}
>
<h2 className="text-3xl font-bold text-brand-navy mb-6">{t('office.title')}</h2>
<p className="text-gray-600 mb-8">{t('office.subtitle')}</p>
<p className="text-neutral-600 mb-8">{t('office.subtitle')}</p>
<div className="space-y-6">
<div className="bg-white p-6 rounded-2xl border-2 border-brand-turquoise shadow-lg">
@ -451,16 +451,16 @@ export default function ContactPage() {
{t('office.hqBadge')}
</span>
</div>
<div className="space-y-2 text-gray-600">
<div className="space-y-2 text-neutral-600">
<div className="flex items-center space-x-2">
<MapPin className="w-4 h-4 text-gray-400" />
<MapPin className="w-4 h-4 text-neutral-400" />
<span>{t('office.address')}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-gray-400 ml-6">{t('office.postalCode')}</span>
<span className="text-neutral-400 ml-6">{t('office.postalCode')}</span>
</div>
<div className="flex items-center space-x-2">
<Phone className="w-4 h-4 text-gray-400" />
<Phone className="w-4 h-4 text-neutral-400" />
<a
href={`tel:${t('office.phone').replace(/\s/g, '')}`}
className="hover:text-brand-turquoise"
@ -469,7 +469,7 @@ export default function ContactPage() {
</a>
</div>
<div className="flex items-center space-x-2">
<Mail className="w-4 h-4 text-gray-400" />
<Mail className="w-4 h-4 text-neutral-400" />
<a
href={`mailto:${t('office.email')}`}
className="hover:text-brand-turquoise"
@ -484,12 +484,12 @@ export default function ContactPage() {
</div>
{/* Hours */}
<div className="mt-8 bg-gray-50 p-6 rounded-2xl">
<div className="mt-8 bg-neutral-50 p-6 rounded-2xl">
<div className="flex items-center space-x-3 mb-4">
<Clock className="w-6 h-6 text-brand-turquoise" />
<h3 className="text-lg font-bold text-brand-navy">{t('hours.title')}</h3>
</div>
<div className="space-y-2 text-gray-600">
<div className="space-y-2 text-neutral-600">
<div className="flex justify-between">
<span>{t('hours.weekdays')}</span>
<span className="font-medium">{t('hours.weekdaysHours')}</span>
@ -500,10 +500,10 @@ export default function ContactPage() {
</div>
<div className="flex justify-between">
<span>{t('hours.sunday')}</span>
<span className="font-medium text-gray-400">{t('hours.closed')}</span>
<span className="font-medium text-neutral-400">{t('hours.closed')}</span>
</div>
</div>
<p className="mt-4 text-sm text-gray-500">{t('hours.supportNote')}</p>
<p className="mt-4 text-sm text-neutral-500">{t('hours.supportNote')}</p>
</div>
</motion.div>
</div>
@ -594,7 +594,7 @@ export default function ContactPage() {
</section>
{/* Section 2: Quick access */}
<section ref={quickAccessRef} className="py-16 bg-gray-50">
<section ref={quickAccessRef} className="py-16 bg-neutral-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -617,7 +617,7 @@ export default function ContactPage() {
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.6, delay: 0.15 }}
whileHover={{ y: -4 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 flex flex-col"
className="bg-white rounded-2xl shadow-lg border border-border p-8 flex flex-col"
>
<div className="w-14 h-14 bg-gradient-to-br from-brand-turquoise to-cyan-400 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<Zap className="w-7 h-7 text-white" />
@ -625,7 +625,7 @@ export default function ContactPage() {
<h3 className="text-xl font-bold text-brand-navy mb-3">
{t('quickAccess.pricingTitle')}
</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
<p className="text-neutral-600 leading-relaxed flex-1 mb-6">
{t('quickAccess.pricingBody1')}
<span className="font-semibold text-brand-navy">
{t('quickAccess.pricingHighlight')}
@ -647,7 +647,7 @@ export default function ContactPage() {
animate={isQuickAccessInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.6, delay: 0.25 }}
whileHover={{ y: -4 }}
className="bg-white rounded-2xl shadow-lg border border-gray-100 p-8 flex flex-col"
className="bg-white rounded-2xl shadow-lg border border-border p-8 flex flex-col"
>
<div className="w-14 h-14 bg-gradient-to-br from-brand-navy to-brand-navy/80 rounded-2xl flex items-center justify-center mb-6 flex-shrink-0">
<BookOpen className="w-7 h-7 text-brand-turquoise" />
@ -655,7 +655,7 @@ export default function ContactPage() {
<h3 className="text-xl font-bold text-brand-navy mb-3">
{t('quickAccess.wikiTitle')}
</h3>
<p className="text-gray-600 leading-relaxed flex-1 mb-6">
<p className="text-neutral-600 leading-relaxed flex-1 mb-6">
{t('quickAccess.wikiBody1')}
<span className="font-semibold text-brand-navy">
{t('quickAccess.wikiHighlight')}

View File

@ -161,18 +161,18 @@ export default function CookiesPage() {
</section>
{/* Introduction */}
<section className="py-16 bg-gray-50">
<section className="py-16 bg-neutral-50">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-8 rounded-2xl shadow-lg border border-border"
>
<h2 className="text-2xl font-bold text-brand-navy mb-4">{t('introBoxTitle')}</h2>
<p className="text-gray-600 leading-relaxed mb-4">{t('introBoxBody1')}</p>
<p className="text-gray-600 leading-relaxed">{t('introBoxBody2')}</p>
<p className="text-neutral-600 leading-relaxed mb-4">{t('introBoxBody1')}</p>
<p className="text-neutral-600 leading-relaxed">{t('introBoxBody2')}</p>
</motion.div>
</div>
</section>
@ -187,7 +187,7 @@ export default function CookiesPage() {
className="text-center mb-12"
>
<h2 className="text-3xl font-bold text-brand-navy mb-4">{t('typesTitle')}</h2>
<p className="text-gray-600">{t('typesSubtitle')}</p>
<p className="text-neutral-600">{t('typesSubtitle')}</p>
</motion.div>
<motion.div
@ -202,7 +202,7 @@ export default function CookiesPage() {
<motion.div
key={type.key}
variants={itemVariants}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-8 rounded-2xl shadow-lg border border-border"
>
<div className="flex items-start justify-between mb-6">
<div className="flex items-center space-x-4">
@ -213,7 +213,7 @@ export default function CookiesPage() {
<h3 className="text-xl font-bold text-brand-navy">
{t(`types.${type.key}.title`)}
</h3>
<p className="text-gray-500 text-sm">
<p className="text-neutral-500 text-sm">
{t(`types.${type.key}.description`)}
</p>
</div>
@ -224,8 +224,8 @@ export default function CookiesPage() {
</span>
) : (
<div className="flex items-center space-x-2">
<ToggleLeft className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-500">{t('optional')}</span>
<ToggleLeft className="w-8 h-8 text-neutral-400" />
<span className="text-sm text-neutral-500">{t('optional')}</span>
</div>
)}
</div>
@ -233,7 +233,7 @@ export default function CookiesPage() {
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<tr className="border-b border-border">
<th className="text-left py-3 px-4 font-semibold text-brand-navy">
{t('tableHeaders.name')}
</th>
@ -247,14 +247,14 @@ export default function CookiesPage() {
</thead>
<tbody>
{type.cookies.map(cookie => (
<tr key={cookie.name} className="border-b border-gray-100 last:border-0">
<tr key={cookie.name} className="border-b border-border last:border-0">
<td className="py-3 px-4 font-mono text-brand-turquoise">
{cookie.name}
</td>
<td className="py-3 px-4 text-gray-600">
<td className="py-3 px-4 text-neutral-600">
{t(`purposes.${cookie.purposeKey}` as any)}
</td>
<td className="py-3 px-4 text-gray-500">
<td className="py-3 px-4 text-neutral-500">
{t(`durations.${cookie.durationKey}` as any)}
</td>
</tr>
@ -272,17 +272,17 @@ export default function CookiesPage() {
initial={{ opacity: 0, y: 30 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.4 }}
className="mt-12 bg-gradient-to-br from-gray-50 to-white p-8 rounded-2xl border border-gray-200"
className="mt-12 bg-gradient-to-br from-neutral-50 to-white p-8 rounded-2xl border border-border"
>
<h3 className="text-2xl font-bold text-brand-navy mb-4">{t('manageTitle')}</h3>
<div className="space-y-4 text-gray-600">
<div className="space-y-4 text-neutral-600">
<p>{t('manageIntro')}</p>
<ul className="list-disc pl-6 space-y-2">
<li>{t('manageBullet1')}</li>
<li>{t('manageBullet2')}</li>
<li>{t('manageBullet3')}</li>
</ul>
<p className="text-sm text-gray-500 mt-4">{t('manageNote')}</p>
<p className="text-sm text-neutral-500 mt-4">{t('manageNote')}</p>
</div>
</motion.div>

View File

@ -164,10 +164,10 @@ export default function PayCommissionPage() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-turquoise/5">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-gray-600">Chargement...</span>
<Loader2 className="h-6 w-6 animate-spin text-brand-navy" />
<span className="text-neutral-600">Chargement...</span>
</div>
</div>
);
@ -175,13 +175,13 @@ export default function PayCommissionPage() {
if (error && !booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-brand-turquoise/5">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-center text-gray-700">{error}</p>
<p className="text-center text-neutral-700">{error}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
className="mt-4 w-full px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
>
Retour aux bookings
</button>
@ -210,20 +210,20 @@ export default function PayCommissionPage() {
: (booking.priceUSD || 0) + (booking.priceEUR || 0);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-brand-turquoise/5 py-10 px-4">
<div className="max-w-5xl mx-auto">
{/* Back button — returns to the previous context (list or booking detail)
instead of always jumping to the global list. */}
<button
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
className="mb-6 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
</button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
<p className="text-gray-500 mb-4">
<h1 className="text-2xl font-bold text-brand-navy mb-1">Paiement du forfait booking</h1>
<p className="text-neutral-500 mb-4">
Finalisez votre booking en réglant le forfait par booking
</p>
<button
@ -244,7 +244,7 @@ export default function PayCommissionPage() {
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced?${params.toString()}`);
}}
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 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-turquoise/5 hover:bg-brand-turquoise/10 rounded-lg transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Modifier la réservation avant de payer
@ -314,7 +314,7 @@ export default function PayCommissionPage() {
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* LEFT — Payment method selector */}
<div className="lg:col-span-3 space-y-4">
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
<h2 className="text-base font-semibold text-neutral-700 uppercase tracking-wide">
Choisir le mode de paiement
</h2>
@ -323,31 +323,31 @@ export default function PayCommissionPage() {
onClick={() => setSelectedMethod('card')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'card'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-border bg-white hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'card' ? 'bg-blue-100' : 'bg-gray-100'
selectedMethod === 'card' ? 'bg-brand-turquoise/10' : 'bg-neutral-100'
}`}
>
<CreditCard
className={`h-5 w-5 ${
selectedMethod === 'card' ? 'text-blue-600' : 'text-gray-500'
selectedMethod === 'card' ? 'text-brand-navy' : 'text-neutral-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-gray-900">Carte bancaire</p>
<p className="text-sm text-gray-500">Paiement immédiat via Stripe</p>
<p className="font-semibold text-brand-navy">Carte bancaire</p>
<p className="text-sm text-neutral-500">Paiement immédiat via Stripe</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
selectedMethod === 'card' ? 'border-brand-turquoise bg-brand-turquoise' : 'border-neutral-300'
}`}
>
{selectedMethod === 'card' && <div className="w-2 h-2 rounded-full bg-white" />}
@ -360,33 +360,33 @@ export default function PayCommissionPage() {
onClick={() => setSelectedMethod('transfer')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'transfer'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-border bg-white hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'transfer' ? 'bg-blue-100' : 'bg-gray-100'
selectedMethod === 'transfer' ? 'bg-brand-turquoise/10' : 'bg-neutral-100'
}`}
>
<Building2
className={`h-5 w-5 ${
selectedMethod === 'transfer' ? 'text-blue-600' : 'text-gray-500'
selectedMethod === 'transfer' ? 'text-brand-navy' : 'text-neutral-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-gray-900">Virement bancaire</p>
<p className="text-sm text-gray-500">Validation sous 1–3 jours ouvrables</p>
<p className="font-semibold text-brand-navy">Virement bancaire</p>
<p className="text-sm text-neutral-500">Validation sous 1–3 jours ouvrables</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'transfer'
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'
? 'border-brand-turquoise bg-brand-turquoise'
: 'border-neutral-300'
}`}
>
{selectedMethod === 'transfer' && (
@ -398,14 +398,14 @@ export default function PayCommissionPage() {
{/* Card action */}
{selectedMethod === 'card' && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<p className="text-sm text-gray-600 mb-4">
<div className="bg-white rounded-xl border border-border p-5">
<p className="text-sm text-neutral-600 mb-4">
Vous serez redirigé vers Stripe pour finaliser votre paiement en toute sécurité.
</p>
<button
onClick={handlePayByCard}
disabled={paying}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
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"
>
{paying ? (
<>
@ -424,14 +424,14 @@ export default function PayCommissionPage() {
{/* Transfer action */}
{selectedMethod === 'transfer' && (
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<p className="text-sm text-gray-600">
<div className="bg-white rounded-xl border border-border p-5 space-y-4">
<p className="text-sm text-neutral-600">
Effectuez le virement avec les coordonnées ci-dessous, puis cliquez sur
&ldquo;J&apos;ai effectué le virement&rdquo;.
</p>
{/* Bank details */}
<div className="bg-gray-50 rounded-lg divide-y divide-gray-200 text-sm">
<div className="bg-neutral-50 rounded-lg divide-y divide-border text-sm">
{[
{ label: 'Bénéficiaire', value: BANK_DETAILS.beneficiary, key: 'beneficiary' },
{ label: 'IBAN', value: BANK_DETAILS.iban, key: 'iban', mono: true },
@ -445,17 +445,17 @@ export default function PayCommissionPage() {
{ label: 'Référence', value: reference, key: 'ref', mono: true },
].map(({ label, value, key, mono, bold }) => (
<div key={key} className="flex items-center justify-between px-4 py-3">
<span className="text-gray-500">{label}</span>
<span className="text-neutral-500">{label}</span>
<div className="flex items-center space-x-2">
<span
className={`${mono ? 'font-mono' : ''} ${bold ? 'font-bold text-gray-900' : 'text-gray-800'}`}
className={`${mono ? 'font-mono' : ''} ${bold ? 'font-bold text-brand-navy' : 'text-neutral-800'}`}
>
{value}
</span>
{key !== 'amount' && (
<button
onClick={() => copyToClipboard(value, key)}
className="text-gray-400 hover:text-blue-600 transition-colors"
className="text-neutral-400 hover:text-brand-navy transition-colors"
title="Copier"
>
{copied === key ? (
@ -481,7 +481,7 @@ export default function PayCommissionPage() {
<button
onClick={handleDeclareTransfer}
disabled={declaring}
className="w-full py-3 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
className="w-full py-3 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 disabled:bg-neutral-300 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{declaring ? (
<>
@ -500,7 +500,7 @@ export default function PayCommissionPage() {
{/* Placeholder when no method selected */}
{selectedMethod === null && (
<div className="bg-white rounded-xl border-2 border-dashed border-gray-200 p-6 text-center text-gray-400 text-sm">
<div className="bg-white rounded-xl border-2 border-dashed border-border p-6 text-center text-neutral-400 text-sm">
Sélectionnez un mode de paiement ci-dessus
</div>
)}
@ -508,54 +508,54 @@ export default function PayCommissionPage() {
{/* RIGHT — Booking summary */}
<div className="lg:col-span-2 space-y-4">
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
<h2 className="text-base font-semibold text-neutral-700 uppercase tracking-wide">
Récapitulatif
</h2>
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<div className="bg-white rounded-xl border border-border p-5 space-y-4">
{booking.bookingNumber && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Numéro</span>
<span className="font-semibold text-gray-900">{booking.bookingNumber}</span>
<span className="text-neutral-500">Numéro</span>
<span className="font-semibold text-brand-navy">{booking.bookingNumber}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Transporteur</span>
<span className="font-semibold text-gray-900 text-right max-w-[55%]">
<span className="text-neutral-500">Transporteur</span>
<span className="font-semibold text-brand-navy text-right max-w-[55%]">
{booking.carrierName}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Trajet</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-500">Trajet</span>
<span className="font-semibold text-brand-navy">
{booking.origin} → {booking.destination}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Volume / Poids</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-500">Volume / Poids</span>
<span className="font-semibold text-brand-navy">
{booking.volumeCBM} CBM · {booking.weightKG} kg
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
<span className="text-neutral-500">Transit</span>
<span className="font-semibold text-brand-navy">{booking.transitDays} jours</span>
</div>
{/* Transport cost breakdown — Fret + FOB (paid to the carrier) */}
{hasBreakdown && (
<>
{freightTotal != null && (
<div className="border-t pt-3 flex justify-between text-sm">
<span className="text-gray-500">Fret</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-500">Fret</span>
<span className="font-semibold text-brand-navy">
{formatPrice(freightTotal, freightCurrency)}
</span>
</div>
)}
{fobTotal != null && fobTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Frais FOB</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-500">Frais FOB</span>
<span className="font-semibold text-brand-navy">
{formatPrice(fobTotal, fobCurrency)}
</span>
</div>
@ -564,32 +564,32 @@ export default function PayCommissionPage() {
)}
{/* Single transport total — always in USD (fret + FOB) */}
<div className="flex justify-between text-sm border-t pt-3">
<span className="text-gray-700 font-medium">Total</span>
<span className="font-bold text-gray-900">
<span className="text-neutral-700 font-medium">Total</span>
<span className="font-bold text-brand-navy">
{formatPrice(transportTotalUSD, 'USD')}
</span>
</div>
<p className="text-xs text-gray-400 pt-1">
<p className="text-xs text-neutral-400 pt-1">
Fret et frais FOB sont réglés directement au transporteur.
</p>
</div>
{/* Booking fee box */}
<div className="bg-blue-600 rounded-xl p-5 text-white">
<p className="text-sm text-blue-100 mb-1">Forfait par booking — à régler maintenant</p>
<div className="bg-brand-turquoise rounded-xl p-5 text-white">
<p className="text-sm text-white/70 mb-1">Forfait par booking — à régler maintenant</p>
<p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
<p className="text-xs text-blue-200 mt-1">
<p className="text-xs text-white/70 mt-1">
Montant fixe par booking (hors fret et frais FOB)
</p>
</div>
{/* Organisation info */}
{org && (
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-2 text-sm">
<p className="font-medium text-gray-700">{org.name}</p>
<div className="bg-white rounded-xl border border-border p-4 space-y-2 text-sm">
<p className="font-medium text-neutral-700">{org.name}</p>
{identifier && (
<div className="flex items-center justify-between">
<span className="text-gray-500">{identifier}</span>
<span className="text-neutral-500">{identifier}</span>
{org.siretVerified ? (
<span className="flex items-center space-x-1 text-green-600 text-xs font-medium">
<CheckCircle className="h-3.5 w-3.5" />
@ -606,9 +606,9 @@ export default function PayCommissionPage() {
</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
<div className="bg-white rounded-xl border border-border p-4 flex items-start space-x-3">
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-500">
<p className="text-xs text-neutral-500">
Après validation du paiement, votre demande est envoyée au transporteur (
{booking.carrierEmail}). Vous serez notifié de sa réponse.
</p>

View File

@ -42,14 +42,14 @@ export default function PaymentSuccessPage() {
if (!sessionId) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-brand-turquoise/5 to-indigo-100">
<div className="bg-white rounded-lg shadow-md p-8 max-w-md text-center">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Session invalide</h2>
<p className="text-gray-600 mb-4">Aucune session de paiement trouvee.</p>
<h2 className="text-xl font-bold text-brand-navy mb-2">Session invalide</h2>
<p className="text-neutral-600 mb-4">Aucune session de paiement trouvee.</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
className="px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
>
Retour aux bookings
</button>
@ -59,13 +59,13 @@ export default function PaymentSuccessPage() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 px-4">
<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="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
{status === 'confirming' && (
<>
<Loader2 className="h-16 w-16 animate-spin text-blue-600 mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Confirmation du paiement...</h2>
<p className="text-gray-600">
<Loader2 className="h-16 w-16 animate-spin text-brand-navy mx-auto mb-6" />
<h2 className="text-xl font-bold text-brand-navy mb-2">Confirmation du paiement...</h2>
<p className="text-neutral-600">
Veuillez patienter pendant que nous verifions votre paiement et activons votre
booking.
</p>
@ -79,25 +79,25 @@ export default function PaymentSuccessPage() {
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Paiement confirme !</h2>
<p className="text-gray-600 mb-6">
<h2 className="text-2xl font-bold text-brand-navy mb-3">Paiement confirme !</h2>
<p className="text-neutral-600 mb-6">
Votre forfait booking a ete paye avec succes. Un email a ete envoye au transporteur
avec votre demande de booking.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center space-x-2 text-blue-700">
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
<div className="flex items-center justify-center space-x-2 text-brand-navy">
<Mail className="h-5 w-5" />
<span className="text-sm font-medium">Email envoye au transporteur</span>
</div>
<p className="text-xs text-blue-600 mt-1">
<p className="text-xs text-brand-navy mt-1">
Vous recevrez une notification des que le transporteur repond (sous 7 jours max)
</p>
</div>
<button
onClick={() => router.push('/dashboard/bookings')}
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold flex items-center justify-center"
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"
>
Voir mes bookings
<ArrowRight className="h-4 w-4 ml-2" />
@ -108,9 +108,9 @@ export default function PaymentSuccessPage() {
{status === 'error' && (
<>
<AlertTriangle className="h-16 w-16 text-red-500 mx-auto mb-6" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Erreur de confirmation</h2>
<p className="text-gray-600 mb-2">{error}</p>
<p className="text-sm text-gray-500 mb-6">
<h2 className="text-xl font-bold text-brand-navy mb-2">Erreur de confirmation</h2>
<p className="text-neutral-600 mb-2">{error}</p>
<p className="text-sm text-neutral-500 mb-6">
Si votre paiement a ete debite, contactez le support. Votre booking sera active
manuellement.
</p>
@ -127,13 +127,13 @@ export default function PaymentSuccessPage() {
setStatus('error');
});
}}
className="w-full px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
className="w-full px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
>
Reessayer
</button>
<button
onClick={() => router.push('/dashboard/bookings')}
className="w-full px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300"
className="w-full px-6 py-2 bg-neutral-200 text-neutral-700 rounded-lg hover:bg-neutral-300"
>
Retour aux bookings
</button>

View File

@ -234,22 +234,22 @@ function NewBookingPageContent() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
className="mb-4 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
>
← Retour aux résultats
</button>
<div className="bg-white rounded-lg shadow-md p-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
<h1 className="text-3xl font-bold text-brand-navy mb-2">
Nouvelle demande de réservation
</h1>
<p className="text-gray-600">
<p className="text-neutral-600">
Envoyez une demande de réservation directement au transporteur
</p>
</div>
@ -263,7 +263,7 @@ function NewBookingPageContent() {
<div className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'
currentStep >= step ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-200 text-neutral-600'
}`}
>
{step}
@ -271,7 +271,7 @@ function NewBookingPageContent() {
<div className="ml-3">
<p
className={`text-sm font-medium ${
currentStep >= step ? 'text-blue-600' : 'text-gray-500'
currentStep >= step ? 'text-brand-navy' : 'text-neutral-500'
}`}
>
{step === 1 && 'Détails'}
@ -283,7 +283,7 @@ function NewBookingPageContent() {
{step < 3 && (
<div
className={`flex-1 h-1 mx-4 ${
currentStep > step ? 'bg-blue-600' : 'bg-gray-200'
currentStep > step ? 'bg-brand-turquoise' : 'bg-neutral-200'
}`}
/>
)}
@ -310,91 +310,91 @@ function NewBookingPageContent() {
{/* Step 1: Transport Details (Read-only) */}
{currentStep === 1 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Détails du transport</h2>
<h2 className="text-2xl font-bold text-brand-navy mb-6">Détails du transport</h2>
<div className="space-y-6">
{/* Carrier Info */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Transporteur</h3>
<div className="bg-brand-turquoise/5 border-2 border-brand-turquoise/30 rounded-lg p-6">
<h3 className="text-lg font-semibold text-brand-navy mb-4">Transporteur</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-600">Nom</p>
<p className="font-semibold text-gray-900">{formData.carrierName}</p>
<p className="text-sm text-neutral-600">Nom</p>
<p className="font-semibold text-brand-navy">{formData.carrierName}</p>
</div>
<div>
<p className="text-sm text-gray-600">Email</p>
<p className="font-semibold text-gray-900">{formData.carrierEmail}</p>
<p className="text-sm text-neutral-600">Email</p>
<p className="font-semibold text-brand-navy">{formData.carrierEmail}</p>
</div>
</div>
</div>
{/* Route Info */}
<div className="bg-gray-50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Trajet</h3>
<div className="bg-neutral-50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-brand-navy mb-4">Trajet</h3>
<div className="flex items-center justify-between">
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">Origine</p>
<p className="text-xl font-bold text-blue-600">{formData.origin}</p>
<p className="text-sm text-neutral-600 mb-1">Origine</p>
<p className="text-xl font-bold text-brand-navy">{formData.origin}</p>
</div>
<div className="flex-1 mx-8">
<div className="border-t-2 border-dashed border-gray-300 relative">
<div className="border-t-2 border-dashed border-neutral-300 relative">
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3">
<span className="text-sm text-gray-600">
<span className="text-sm text-neutral-600">
{formData.transitDays} jours
</span>
</div>
</div>
</div>
<div className="text-center">
<p className="text-sm text-gray-600 mb-1">Destination</p>
<p className="text-xl font-bold text-blue-600">{formData.destination}</p>
<p className="text-sm text-neutral-600 mb-1">Destination</p>
<p className="text-xl font-bold text-brand-navy">{formData.destination}</p>
</div>
</div>
</div>
{/* Shipment Details */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Volume</p>
<p className="text-lg font-bold text-gray-900">{formData.volumeCBM} CBM</p>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-xs text-neutral-600 mb-1">Volume</p>
<p className="text-lg font-bold text-brand-navy">{formData.volumeCBM} CBM</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Poids</p>
<p className="text-lg font-bold text-gray-900">{formData.weightKG} kg</p>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-xs text-neutral-600 mb-1">Poids</p>
<p className="text-lg font-bold text-brand-navy">{formData.weightKG} kg</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Palettes</p>
<p className="text-lg font-bold text-gray-900">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-xs text-neutral-600 mb-1">Palettes</p>
<p className="text-lg font-bold text-brand-navy">
{formData.palletCount || 'N/A'}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-600 mb-1">Type</p>
<p className="text-lg font-bold text-gray-900">{formData.containerType}</p>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-xs text-neutral-600 mb-1">Type</p>
<p className="text-lg font-bold text-brand-navy">{formData.containerType}</p>
</div>
</div>
{/* Pricing */}
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Prix estimé</h3>
<h3 className="text-lg font-semibold text-brand-navy mb-4">Prix estimé</h3>
<div className="flex items-center justify-between mb-4">
<div>
<p className="text-sm text-gray-600">Fret ({formData.freightCurrency})</p>
<p className="text-2xl font-bold text-gray-800">
<p className="text-sm text-neutral-600">Fret ({formData.freightCurrency})</p>
<p className="text-2xl font-bold text-neutral-800">
{formatPrice(formData.freightTotal, formData.freightCurrency)}
</p>
</div>
{formData.fobTotal > 0 && (
<div className="text-right">
<p className="text-sm text-gray-600">FOB ({formData.fobCurrency})</p>
<p className="text-xl font-semibold text-gray-700">
<p className="text-sm text-neutral-600">FOB ({formData.fobCurrency})</p>
<p className="text-xl font-semibold text-neutral-700">
{formatPrice(formData.fobTotal, formData.fobCurrency)}
</p>
</div>
)}
</div>
<div className="border-t border-green-200 pt-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-700">Prix total</p>
<p className="text-sm font-semibold text-neutral-700">Prix total</p>
<p className="text-3xl font-bold text-green-600">
{formatPrice(
formData.totalPriceForSorting,
@ -409,7 +409,7 @@ function NewBookingPageContent() {
<button
onClick={() => setCurrentStep(2)}
disabled={!canProceedToStep2}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
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"
>
Continuer →
</button>
@ -420,7 +420,7 @@ function NewBookingPageContent() {
{/* Step 2: Document Upload */}
{currentStep === 2 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Documents requis</h2>
<h2 className="text-2xl font-bold text-brand-navy mb-6">Documents requis</h2>
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
<div className="flex items-start">
@ -440,8 +440,8 @@ function NewBookingPageContent() {
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
isDragging
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-neutral-300 hover:border-brand-turquoise hover:bg-neutral-50'
}`}
onDragOver={e => {
e.preventDefault();
@ -470,7 +470,7 @@ function NewBookingPageContent() {
/>
<div className="flex flex-col items-center">
<svg
className={`w-16 h-16 mb-4 transition-colors ${isDragging ? 'text-blue-500' : 'text-gray-400'}`}
className={`w-16 h-16 mb-4 transition-colors ${isDragging ? 'text-brand-turquoise' : 'text-neutral-400'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -482,19 +482,19 @@ function NewBookingPageContent() {
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-lg font-semibold text-gray-700 mb-2">
<p className="text-lg font-semibold text-neutral-700 mb-2">
{isDragging ? 'Déposez vos fichiers ici' : 'Cliquez pour sélectionner des fichiers'}
</p>
<p className="text-sm text-gray-500">ou glissez-déposez vos documents ici</p>
<p className="text-sm text-neutral-500">ou glissez-déposez vos documents ici</p>
</div>
</div>
{/* Document Type Suggestions */}
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-semibold text-gray-700 mb-2">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm font-semibold text-neutral-700 mb-2">
Documents recommandés :
</p>
<ul className="text-sm text-gray-600 space-y-1">
<ul className="text-sm text-neutral-600 space-y-1">
{DOCUMENT_TYPES.map(type => (
<li key={type.value}>• {type.label}</li>
))}
@ -505,18 +505,18 @@ function NewBookingPageContent() {
{/* Uploaded Files List */}
{formData.documents.length > 0 && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
<h3 className="text-lg font-semibold text-brand-navy mb-4">
Documents sélectionnés ({formData.documents.length})
</h3>
<div className="space-y-2">
{formData.documents.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 rounded-lg p-4 border border-gray-200"
className="flex items-center justify-between bg-neutral-50 rounded-lg p-4 border border-border"
>
<div className="flex items-center space-x-3">
<svg
className="w-6 h-6 text-blue-600"
className="w-6 h-6 text-brand-navy"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -529,8 +529,8 @@ function NewBookingPageContent() {
/>
</svg>
<div>
<p className="font-medium text-gray-900">{file.name}</p>
<p className="text-xs text-gray-500">
<p className="font-medium text-brand-navy">{file.name}</p>
<p className="text-xs text-neutral-500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
@ -549,14 +549,14 @@ function NewBookingPageContent() {
{/* Optional Notes */}
<div className="mb-8">
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
Notes ou instructions spéciales (optionnel)
</label>
<textarea
value={formData.notes || ''}
onChange={e => setFormData(prev => ({ ...prev, notes: e.target.value }))}
rows={4}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-brand-turquoise"
placeholder="Ajoutez des instructions spéciales pour le transporteur..."
/>
</div>
@ -564,14 +564,14 @@ function NewBookingPageContent() {
<div className="flex justify-between">
<button
onClick={() => setCurrentStep(1)}
className="px-8 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold"
className="px-8 py-3 bg-neutral-200 text-neutral-700 rounded-lg hover:bg-neutral-300 font-semibold"
>
← Retour
</button>
<button
onClick={() => setCurrentStep(3)}
disabled={!canProceedToStep3}
className="px-8 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
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"
>
Continuer →
</button>
@ -582,53 +582,53 @@ function NewBookingPageContent() {
{/* Step 3: Review & Submit */}
{currentStep === 3 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Révision et envoi</h2>
<h2 className="text-2xl font-bold text-brand-navy mb-6">Révision et envoi</h2>
<div className="space-y-6 mb-8">
{/* Summary */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Récapitulatif</h3>
<div className="bg-brand-turquoise/5 border-2 border-brand-turquoise/30 rounded-lg p-6">
<h3 className="text-lg font-semibold text-brand-navy mb-4">Récapitulatif</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">Transporteur :</span>
<span className="font-semibold text-gray-900">{formData.carrierName}</span>
<span className="text-neutral-600">Transporteur :</span>
<span className="font-semibold text-brand-navy">{formData.carrierName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Trajet :</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-600">Trajet :</span>
<span className="font-semibold text-brand-navy">
{formData.origin} → {formData.destination}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Volume :</span>
<span className="font-semibold text-gray-900">{formData.volumeCBM} CBM</span>
<span className="text-neutral-600">Volume :</span>
<span className="font-semibold text-brand-navy">{formData.volumeCBM} CBM</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Poids :</span>
<span className="font-semibold text-gray-900">{formData.weightKG} kg</span>
<span className="text-neutral-600">Poids :</span>
<span className="font-semibold text-brand-navy">{formData.weightKG} kg</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Documents :</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-600">Documents :</span>
<span className="font-semibold text-brand-navy">
{formData.documents.length} fichier(s)
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Fret :</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-600">Fret :</span>
<span className="font-semibold text-brand-navy">
{formatPrice(formData.freightTotal, formData.freightCurrency)}
</span>
</div>
{formData.fobTotal > 0 && (
<div className="flex justify-between">
<span className="text-gray-600">FOB :</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-600">FOB :</span>
<span className="font-semibold text-brand-navy">
{formatPrice(formData.fobTotal, formData.fobCurrency)}
</span>
</div>
)}
<div className="flex justify-between border-t pt-3 mt-3">
<span className="text-gray-900 font-semibold">Prix total :</span>
<span className="text-brand-navy font-semibold">Prix total :</span>
<span className="text-2xl font-bold text-green-600">
{formatPrice(
formData.totalPriceForSorting,
@ -641,10 +641,10 @@ function NewBookingPageContent() {
{/* What happens next */}
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">
<h3 className="text-lg font-semibold text-brand-navy mb-3">
<Mail className="h-5 w-5 mr-2 inline-block" /> Que se passe-t-il ensuite ?
</h3>
<ul className="space-y-2 text-sm text-gray-700">
<ul className="space-y-2 text-sm text-neutral-700">
<li className="flex items-start">
<span className="mr-2">1.</span>
<span>
@ -676,17 +676,17 @@ function NewBookingPageContent() {
</div>
{/* Terms */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="bg-neutral-50 rounded-lg p-4">
<label className="flex items-start cursor-pointer">
<input
type="checkbox"
checked={termsAccepted}
onChange={e => setTermsAccepted(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
className="mt-1 h-4 w-4 text-brand-navy focus:ring-ring border-neutral-300 rounded"
/>
<span className="ml-3 text-sm text-gray-700">
<span className="ml-3 text-sm text-neutral-700">
Je confirme que les informations fournies sont exactes et que j'accepte les{' '}
<a href="#" className="text-blue-600 hover:underline">
<a href="#" className="text-brand-navy hover:underline">
conditions générales
</a>
</span>
@ -698,14 +698,14 @@ function NewBookingPageContent() {
<button
onClick={() => setCurrentStep(2)}
disabled={isSubmitting}
className="px-8 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold disabled:opacity-50"
className="px-8 py-3 bg-neutral-200 text-neutral-700 rounded-lg hover:bg-neutral-300 font-semibold disabled:opacity-50"
>
← Retour
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold"
className="px-8 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-neutral-300 disabled:cursor-not-allowed font-semibold"
>
{isSubmitting ? (
<span className="flex items-center">

View File

@ -1,17 +1,37 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { ArrowLeft, Download, FileText, Package } from 'lucide-react';
import { getBooking } from '@/lib/api';
import { Link } from '@/i18n/navigation';
import { useParams } from 'next/navigation';
import { useTranslations, useLocale } from 'next-intl';
import { Badge, type BadgeProps } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/ui/PageHeader';
import { EmptyState, PageSpinner } from '@/components/ui/states';
import { useToast } from '@/components/ui/toast';
import { DescriptionList } from '@/components/data/description-list';
const STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
draft: 'neutral',
pending: 'warning',
confirmed: 'info',
in_transit: 'info',
delivered: 'success',
cancelled: 'danger',
};
export default function BookingDetailPage() {
const t = useTranslations('dashboard.bookingDetail');
const tCommon = useTranslations('common');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const params = useParams();
const bookingId = params.id as string;
const { toast } = useToast();
const { data: booking, isLoading } = useQuery({
queryKey: ['booking', bookingId],
@ -19,266 +39,201 @@ export default function BookingDetailPage() {
enabled: !!bookingId,
});
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-800',
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-green-100 text-green-800',
in_transit: 'bg-blue-100 text-blue-800',
delivered: 'bg-purple-100 text-purple-800',
cancelled: 'bg-red-100 text-red-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const getStatusLabel = (status: string) => {
const key = `status.${status}`;
const statusLabel = (status: string) => {
try {
return t(key as any);
return t(`status.${status}` as any);
} catch {
return status;
}
};
const downloadPDF = async () => {
try {
alert(t('pdfNotImplemented'));
console.log('Download PDF for booking:', bookingId);
} catch (error) {
console.error('Failed to download PDF:', error);
}
};
// Le telechargement PDF n'est pas encore branche cote back : on le signale
// par une notification plutot que par une alerte bloquante du navigateur.
const downloadPDF = () => toast.info(t('pdfNotImplemented'));
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (isLoading) return <PageSpinner label={tCommon('loading')} />;
if (!booking) {
return (
<div className="text-center py-12">
<h2 className="text-2xl font-semibold text-gray-900">{t('notFound')}</h2>
<Link
href="/dashboard/bookings"
className="mt-4 inline-block text-blue-600 hover:text-blue-700"
>
<EmptyState
icon={Package}
title={t('notFound')}
action={
<Button asChild variant="outline">
<Link href="/dashboard/bookings">
<ArrowLeft />
{t('back')}
</Link>
</div>
</Button>
}
/>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Link
href="/dashboard/bookings"
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
>
{t('back')}
</Link>
<div className="flex items-center space-x-4">
<h1 className="text-2xl font-bold text-gray-900">{booking.bookingNumber}</h1>
<span
className={`px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full ${getStatusColor(
booking.status
)}`}
>
{getStatusLabel(booking.status)}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
{t('createdOn', { date: new Date(booking.createdAt).toLocaleDateString(dateLocale) })}
</p>
</div>
<div className="flex space-x-3">
<button
onClick={downloadPDF}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<svg className="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<>
<PageHeader
title={booking.bookingNumber}
description={t('createdOn', {
date: new Date(booking.createdAt).toLocaleDateString(dateLocale),
})}
actions={
<>
<Badge variant={STATUS_VARIANT[booking.status] ?? 'neutral'}>
{statusLabel(booking.status)}
</Badge>
<Button variant="outline" onClick={downloadPDF}>
<Download />
{t('downloadPdf')}
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('cargo.title')}</h2>
<dl className="grid grid-cols-1 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">{t('cargo.description')}</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.cargoDescription}</dd>
</div>
{booking.specialInstructions && (
<div>
<dt className="text-sm font-medium text-gray-500">
{t('cargo.specialInstructions')}
</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.specialInstructions}</dd>
</div>
)}
</dl>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
{t('containers.title', { count: booking.containers?.length || 0 })}
</h2>
<div className="space-y-3">
{booking.containers?.map((container, index) => (
<div key={container.id || index} className="border rounded-lg p-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">{t('containers.type')}</p>
<p className="text-sm text-gray-900">{container.type}</p>
</div>
{container.containerNumber && (
<div>
<p className="text-sm font-medium text-gray-500">
{t('containers.number')}
</p>
<p className="text-sm text-gray-900">{container.containerNumber}</p>
</div>
)}
{container.sealNumber && (
<div>
<p className="text-sm font-medium text-gray-500">{t('containers.seal')}</p>
<p className="text-sm text-gray-900">{container.sealNumber}</p>
</div>
)}
{container.vgm && (
<div>
<p className="text-sm font-medium text-gray-500">{t('containers.vgm')}</p>
<p className="text-sm text-gray-900">{container.vgm}</p>
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('shipper.title')}</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.name')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.contact')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.email')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactEmail}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('shipper.phone')}</dt>
<dd className="text-sm text-gray-900">{booking.shipper.contactPhone}</dd>
</div>
</dl>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('consignee.title')}</h2>
<dl className="space-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.name')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.contact')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.email')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactEmail}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('consignee.phone')}</dt>
<dd className="text-sm text-gray-900">{booking.consignee.contactPhone}</dd>
</div>
</dl>
</div>
</div>
</div>
<div className="space-y-6">
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('timeline.title')}</h2>
<div className="flow-root">
<ul className="-mb-8">
<li>
<div className="relative pb-8">
<span
className="absolute top-4 left-4 -ml-px h-full w-0.5 bg-gray-200"
aria-hidden="true"
></span>
<div className="relative flex space-x-3">
<div>
<span className="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center ring-8 ring-white">
<svg
className="h-5 w-5 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
</Button>
</>
}
/>
</svg>
</span>
<div className="grid gap-4 lg:grid-cols-3">
<div className="flex flex-col gap-4 lg:col-span-2">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('cargo.title')}</CardTitle>
</CardHeader>
<CardContent>
<DescriptionList
columns={1}
items={[
{ label: t('cargo.description'), value: booking.cargoDescription },
...(booking.specialInstructions
? [
{
label: t('cargo.specialInstructions'),
value: booking.specialInstructions,
},
]
: []),
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>
{t('containers.title', { count: booking.containers?.length || 0 })}
</CardTitle>
</CardHeader>
<CardContent>
{booking.containers && booking.containers.length > 0 ? (
<ul className="flex flex-col gap-3">
{booking.containers.map((container, index) => (
<li
key={container.id || index}
className="rounded-md border border-border p-4"
>
<DescriptionList
items={[
{ label: t('containers.type'), value: container.type },
...(container.containerNumber
? [{ label: t('containers.number'), value: container.containerNumber }]
: []),
...(container.sealNumber
? [{ label: t('containers.seal'), value: container.sealNumber }]
: []),
...(container.vgm
? [{ label: t('containers.vgm'), value: container.vgm }]
: []),
]}
/>
</li>
))}
</ul>
) : (
<EmptyState size="sm" icon={Package} title={t('containers.title', { count: 0 })} />
)}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('shipper.title')}</CardTitle>
</CardHeader>
<CardContent>
<DescriptionList
columns={1}
items={[
{ label: t('shipper.name'), value: booking.shipper.name, emphasis: true },
{ label: t('shipper.contact'), value: booking.shipper.contactName },
{ label: t('shipper.email'), value: booking.shipper.contactEmail },
{ label: t('shipper.phone'), value: booking.shipper.contactPhone },
]}
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('consignee.title')}</CardTitle>
</CardHeader>
<CardContent>
<DescriptionList
columns={1}
items={[
{ label: t('consignee.name'), value: booking.consignee.name, emphasis: true },
{ label: t('consignee.contact'), value: booking.consignee.contactName },
{ label: t('consignee.email'), value: booking.consignee.contactEmail },
{ label: t('consignee.phone'), value: booking.consignee.contactPhone },
]}
/>
</CardContent>
</Card>
</div>
<div className="min-w-0 flex-1 pt-1.5">
<div>
<p className="text-sm text-gray-900 font-medium">
</div>
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('timeline.title')}</CardTitle>
</CardHeader>
<CardContent>
<ol className="relative flex flex-col gap-4">
<li className="flex gap-3">
<span
aria-hidden="true"
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-brand-turquoise/10"
>
<FileText className="size-3 text-brand-turquoise" />
</span>
<div className="min-w-0">
<p className="text-body-sm font-medium text-brand-navy">
{t('timeline.created')}
</p>
<p className="text-sm text-gray-500">
<p className="mt-0.5 text-body-xs text-neutral-500">
{new Date(booking.createdAt).toLocaleString(dateLocale)}
</p>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</ol>
</CardContent>
</Card>
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('info.title')}</h2>
<dl className="space-y-3">
<div>
<dt className="text-sm font-medium text-gray-500">{t('info.bookingId')}</dt>
<dd className="mt-1 text-sm text-gray-900">{booking.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">{t('info.lastUpdated')}</dt>
<dd className="mt-1 text-sm text-gray-900">
{new Date(booking.updatedAt).toLocaleString(dateLocale)}
</dd>
</div>
</dl>
</div>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('info.title')}</CardTitle>
</CardHeader>
<CardContent>
<DescriptionList
columns={1}
items={[
{ label: t('info.bookingId'), value: booking.id },
{
label: t('info.lastUpdated'),
value: new Date(booking.updatedAt).toLocaleString(dateLocale),
},
]}
/>
</CardContent>
</Card>
</div>
</div>
</>
);
}

View File

@ -1,871 +0,0 @@
/**
* Multi-Step Booking Form
*
* Create a new booking in 4 steps:
* 1. Select Rate Quote
* 2. Shipper & Consignee Information
* 3. Container Details
* 4. Review & Confirmation
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Image from 'next/image';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createBooking } from '@/lib/api';
type Step = 1 | 2 | 3 | 4;
interface Party {
name: string;
address: string;
city: string;
postalCode: string;
country: string;
contactName: string;
contactEmail: string;
contactPhone: string;
}
interface Container {
type: string;
quantity: number;
weight?: number;
temperature?: number;
isHazmat: boolean;
hazmatClass?: string;
commodityDescription: string;
}
interface BookingFormData {
rateQuoteId: string;
shipper: Party;
consignee: Party;
containers: Container[];
specialInstructions?: string;
}
const emptyParty: Party = {
name: '',
address: '',
city: '',
postalCode: '',
country: '',
contactName: '',
contactEmail: '',
contactPhone: '',
};
const emptyContainer: Container = {
type: '40HC',
quantity: 1,
isHazmat: false,
commodityDescription: '',
};
export default function NewBookingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const preselectedQuoteId = searchParams.get('quoteId');
const [currentStep, setCurrentStep] = useState<Step>(1);
const [formData, setFormData] = useState<BookingFormData>({
rateQuoteId: preselectedQuoteId || '',
shipper: { ...emptyParty },
consignee: { ...emptyParty },
containers: [{ ...emptyContainer }],
specialInstructions: '',
});
const [error, setError] = useState('');
// Fetch preselected quote if provided
// TODO: Implement rate quote getById API endpoint
const { data: preselectedQuote } = useQuery({
queryKey: ['rate-quote', preselectedQuoteId],
queryFn: async () => {
// Temporarily disabled - API endpoint not yet implemented
return null;
},
enabled: false, // Disabled until API is implemented
});
useEffect(() => {
if (preselectedQuote) {
setFormData(prev => ({ ...prev, rateQuoteId: preselectedQuote.id }));
}
}, [preselectedQuote]);
// Create booking mutation
const createBookingMutation = useMutation({
mutationFn: (data: BookingFormData) => {
// TODO: Transform BookingFormData to CreateBookingRequest format
// Temporary type assertion until proper transformation is implemented
return createBooking(data as any);
},
onSuccess: booking => {
router.push(`/dashboard/bookings/${booking.id}`);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to create booking');
},
});
const handleNext = () => {
setError('');
if (currentStep < 4) {
setCurrentStep(prev => (prev + 1) as Step);
}
};
const handleBack = () => {
setError('');
if (currentStep > 1) {
setCurrentStep(prev => (prev - 1) as Step);
}
};
const handleSubmit = () => {
setError('');
createBookingMutation.mutate(formData);
};
const updateParty = (type: 'shipper' | 'consignee', field: keyof Party, value: string) => {
setFormData(prev => ({
...prev,
[type]: {
...prev[type],
[field]: value,
},
}));
};
const updateContainer = (index: number, field: keyof Container, value: any) => {
setFormData(prev => ({
...prev,
containers: prev.containers.map((c, i) => (i === index ? { ...c, [field]: value } : c)),
}));
};
const addContainer = () => {
setFormData(prev => ({
...prev,
containers: [...prev.containers, { ...emptyContainer }],
}));
};
const removeContainer = (index: number) => {
if (formData.containers.length > 1) {
setFormData(prev => ({
...prev,
containers: prev.containers.filter((_, i) => i !== index),
}));
}
};
const isStepValid = (step: Step): boolean => {
switch (step) {
case 1:
return !!formData.rateQuoteId;
case 2:
return (
formData.shipper.name.trim() !== '' &&
formData.shipper.contactEmail.trim() !== '' &&
formData.consignee.name.trim() !== '' &&
formData.consignee.contactEmail.trim() !== ''
);
case 3:
return formData.containers.every(
c => c.commodityDescription.trim() !== '' && c.quantity > 0
);
case 4:
return true;
default:
return false;
}
};
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Create New Booking</h1>
<p className="text-sm text-gray-500 mt-1">Complete the booking process in 4 simple steps</p>
</div>
{/* Progress Steps */}
<div className="bg-white rounded-lg shadow p-6">
<nav aria-label="Progress">
<ol className="flex items-center justify-between">
{[
{ number: 1, name: 'Rate Quote' },
{ number: 2, name: 'Parties' },
{ number: 3, name: 'Containers' },
{ number: 4, name: 'Review' },
].map((step, idx) => (
<li key={step.number} className={`flex items-center ${idx !== 3 ? 'flex-1' : ''}`}>
<div className="flex flex-col items-center">
<div
className={`flex items-center justify-center w-10 h-10 rounded-full border-2 ${
currentStep === step.number
? 'border-blue-600 bg-blue-600 text-white'
: currentStep > step.number
? 'border-green-600 bg-green-600 text-white'
: 'border-gray-300 bg-white text-gray-500'
}`}
>
{currentStep > step.number ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
) : (
<span className="text-sm font-semibold">{step.number}</span>
)}
</div>
<span
className={`mt-2 text-xs font-medium ${
currentStep === step.number
? 'text-blue-600'
: currentStep > step.number
? 'text-green-600'
: 'text-gray-500'
}`}
>
{step.name}
</span>
</div>
{idx !== 3 && (
<div
className={`flex-1 h-0.5 mx-4 ${
currentStep > step.number ? 'bg-green-600' : 'bg-gray-300'
}`}
/>
)}
</li>
))}
</ol>
</nav>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-800">{error}</div>
</div>
)}
{/* Step Content */}
<div className="bg-white rounded-lg shadow p-6">
{/* Step 1: Rate Quote Selection */}
{currentStep === 1 && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Step 1: Select Rate Quote
</h2>
{preselectedQuote ? (
<div className="border border-green-200 bg-green-50 rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
{preselectedQuote.carrier.logoUrl ? (
<Image
src={preselectedQuote.carrier.logoUrl}
alt={preselectedQuote.carrier.name}
width={48}
height={48}
className="h-12 w-12 object-contain"
/>
) : (
<div className="h-12 w-12 bg-blue-100 rounded flex items-center justify-center text-blue-600 font-semibold">
{preselectedQuote.carrier.name.substring(0, 2).toUpperCase()}
</div>
)}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{preselectedQuote.carrier.name}
</h3>
<p className="text-sm text-gray-500">
{preselectedQuote.route.originPort} →{' '}
{preselectedQuote.route.destinationPort}
</p>
</div>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-blue-600">
${preselectedQuote.pricing.totalAmount.toLocaleString()}
</div>
<div className="text-sm text-gray-500">
{preselectedQuote.pricing.currency}
</div>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-gray-500">ETD:</span>{' '}
<span className="font-medium">
{new Date(preselectedQuote.route.etd).toLocaleDateString()}
</span>
</div>
<div>
<span className="text-gray-500">Transit:</span>{' '}
<span className="font-medium">{preselectedQuote.route.transitDays} days</span>
</div>
<div>
<span className="text-gray-500">ETA:</span>{' '}
<span className="font-medium">
{new Date(preselectedQuote.route.eta).toLocaleDateString()}
</span>
</div>
</div>
</div>
) : (
<div className="text-center py-8">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No rate quote selected</h3>
<p className="mt-1 text-sm text-gray-500">
Please search for rates first and select a quote to book
</p>
<div className="mt-6">
<a
href="/dashboard/search"
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Search Rates
</a>
</div>
</div>
)}
</div>
</div>
)}
{/* Step 2: Shipper & Consignee */}
{currentStep === 2 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">
Step 2: Shipper & Consignee Information
</h2>
{/* Shipper */}
<div>
<h3 className="text-md font-medium text-gray-900 mb-4">Shipper Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Company Name *</label>
<input
type="text"
required
value={formData.shipper.name}
onChange={e => updateParty('shipper', 'name', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Address *</label>
<input
type="text"
required
value={formData.shipper.address}
onChange={e => updateParty('shipper', 'address', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">City *</label>
<input
type="text"
required
value={formData.shipper.city}
onChange={e => updateParty('shipper', 'city', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Postal Code *</label>
<input
type="text"
required
value={formData.shipper.postalCode}
onChange={e => updateParty('shipper', 'postalCode', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Country *</label>
<input
type="text"
required
value={formData.shipper.country}
onChange={e => updateParty('shipper', 'country', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Name *</label>
<input
type="text"
required
value={formData.shipper.contactName}
onChange={e => updateParty('shipper', 'contactName', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Email *</label>
<input
type="email"
required
value={formData.shipper.contactEmail}
onChange={e => updateParty('shipper', 'contactEmail', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Contact Phone *</label>
<input
type="tel"
required
value={formData.shipper.contactPhone}
onChange={e => updateParty('shipper', 'contactPhone', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
</div>
</div>
<hr className="border-gray-200" />
{/* Consignee */}
<div>
<h3 className="text-md font-medium text-gray-900 mb-4">Consignee Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Company Name *</label>
<input
type="text"
required
value={formData.consignee.name}
onChange={e => updateParty('consignee', 'name', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Address *</label>
<input
type="text"
required
value={formData.consignee.address}
onChange={e => updateParty('consignee', 'address', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">City *</label>
<input
type="text"
required
value={formData.consignee.city}
onChange={e => updateParty('consignee', 'city', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Postal Code *</label>
<input
type="text"
required
value={formData.consignee.postalCode}
onChange={e => updateParty('consignee', 'postalCode', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Country *</label>
<input
type="text"
required
value={formData.consignee.country}
onChange={e => updateParty('consignee', 'country', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Name *</label>
<input
type="text"
required
value={formData.consignee.contactName}
onChange={e => updateParty('consignee', 'contactName', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Contact Email *</label>
<input
type="email"
required
value={formData.consignee.contactEmail}
onChange={e => updateParty('consignee', 'contactEmail', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">Contact Phone *</label>
<input
type="tel"
required
value={formData.consignee.contactPhone}
onChange={e => updateParty('consignee', 'contactPhone', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
</div>
</div>
</div>
)}
{/* Step 3: Container Details */}
{currentStep === 3 && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Step 3: Container Details</h2>
<button
type="button"
onClick={addContainer}
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
<span className="mr-1">➕</span>
Add Container
</button>
</div>
{formData.containers.map((container, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-md font-medium text-gray-900">Container {index + 1}</h3>
{formData.containers.length > 1 && (
<button
type="button"
onClick={() => removeContainer(index)}
className="text-red-600 hover:text-red-800 text-sm"
>
Remove
</button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Container Type *
</label>
<select
value={container.type}
onChange={e => updateContainer(index, 'type', e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
>
<option value="20GP">20' GP</option>
<option value="40GP">40' GP</option>
<option value="40HC">40' HC</option>
<option value="45HC">45' HC</option>
<option value="20RF">20' Reefer</option>
<option value="40RF">40' Reefer</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Quantity *</label>
<input
type="number"
min="1"
value={container.quantity}
onChange={e =>
updateContainer(index, 'quantity', parseInt(e.target.value) || 1)
}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Weight (kg)</label>
<input
type="number"
min="0"
value={container.weight || ''}
onChange={e =>
updateContainer(
index,
'weight',
e.target.value ? parseFloat(e.target.value) : undefined
)
}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
{(container.type === '20RF' || container.type === '40RF') && (
<div>
<label className="block text-sm font-medium text-gray-700">
Temperature (°C)
</label>
<input
type="number"
value={container.temperature || ''}
onChange={e =>
updateContainer(
index,
'temperature',
e.target.value ? parseFloat(e.target.value) : undefined
)
}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
)}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">
Commodity Description *
</label>
<textarea
required
rows={2}
value={container.commodityDescription}
onChange={e => updateContainer(index, 'commodityDescription', e.target.value)}
placeholder="e.g., Electronics, Textiles, Machinery..."
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div className="md:col-span-2">
<div className="flex items-center">
<input
type="checkbox"
id={`hazmat-${index}`}
checked={container.isHazmat}
onChange={e => updateContainer(index, 'isHazmat', e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label
htmlFor={`hazmat-${index}`}
className="ml-2 block text-sm text-gray-900"
>
Contains Hazardous Materials
</label>
</div>
</div>
{container.isHazmat && (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700">
Hazmat Class (IMO)
</label>
<input
type="text"
value={container.hazmatClass || ''}
onChange={e => updateContainer(index, 'hazmatClass', e.target.value)}
placeholder="e.g., Class 3, Class 8"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Step 4: Review & Confirmation */}
{currentStep === 4 && (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Step 4: Review & Confirmation</h2>
{/* Rate Quote Summary */}
{preselectedQuote && (
<div>
<h3 className="text-md font-medium text-gray-900 mb-3">Rate Quote</h3>
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">{preselectedQuote.carrier.name}</div>
<div className="text-sm text-gray-600">
{preselectedQuote.route.originPort} →{' '}
{preselectedQuote.route.destinationPort}
</div>
<div className="text-sm text-gray-600">
Transit: {preselectedQuote.route.transitDays} days
</div>
</div>
<div className="text-right">
<div className="text-xl font-bold text-blue-600">
${preselectedQuote.pricing.totalAmount.toLocaleString()}
</div>
<div className="text-sm text-gray-500">
{preselectedQuote.pricing.currency}
</div>
</div>
</div>
</div>
</div>
)}
{/* Shipper */}
<div>
<h3 className="text-md font-medium text-gray-900 mb-3">Shipper</h3>
<div className="bg-gray-50 rounded-lg p-4 text-sm">
<div className="font-semibold">{formData.shipper.name}</div>
<div className="text-gray-600">
{formData.shipper.address}, {formData.shipper.city}, {formData.shipper.postalCode}
, {formData.shipper.country}
</div>
<div className="text-gray-600 mt-2">
Contact: {formData.shipper.contactName} ({formData.shipper.contactEmail},{' '}
{formData.shipper.contactPhone})
</div>
</div>
</div>
{/* Consignee */}
<div>
<h3 className="text-md font-medium text-gray-900 mb-3">Consignee</h3>
<div className="bg-gray-50 rounded-lg p-4 text-sm">
<div className="font-semibold">{formData.consignee.name}</div>
<div className="text-gray-600">
{formData.consignee.address}, {formData.consignee.city},{' '}
{formData.consignee.postalCode}, {formData.consignee.country}
</div>
<div className="text-gray-600 mt-2">
Contact: {formData.consignee.contactName} ({formData.consignee.contactEmail},{' '}
{formData.consignee.contactPhone})
</div>
</div>
</div>
{/* Containers */}
<div>
<h3 className="text-md font-medium text-gray-900 mb-3">Containers</h3>
<div className="space-y-2">
{formData.containers.map((container, index) => (
<div key={index} className="bg-gray-50 rounded-lg p-4 text-sm">
<div className="font-semibold">
{container.quantity}x {container.type}
</div>
<div className="text-gray-600">Commodity: {container.commodityDescription}</div>
{container.weight && (
<div className="text-gray-600">Weight: {container.weight} kg</div>
)}
{container.isHazmat && (
<div className="text-red-600 font-medium">
⚠️ Hazmat {container.hazmatClass && `- ${container.hazmatClass}`}
</div>
)}
</div>
))}
</div>
</div>
{/* Special Instructions */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Special Instructions (Optional)
</label>
<textarea
rows={4}
value={formData.specialInstructions || ''}
onChange={e => setFormData({ ...formData, specialInstructions: e.target.value })}
placeholder="Any special handling requirements, pickup instructions, etc."
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
{/* Terms */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="text-sm text-yellow-800">
<p className="font-semibold mb-2">Please review carefully:</p>
<ul className="list-disc list-inside space-y-1">
<li>All information provided is accurate and complete</li>
<li>You agree to the carrier's terms and conditions</li>
<li>Final booking confirmation will be sent via email</li>
<li>Payment details will be provided separately</li>
</ul>
</div>
</div>
</div>
)}
</div>
{/* Navigation Buttons */}
<div className="flex items-center justify-between bg-white rounded-lg shadow p-6">
<button
type="button"
onClick={handleBack}
disabled={currentStep === 1}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Back
</button>
{currentStep < 4 ? (
<button
type="button"
onClick={handleNext}
disabled={!isStepValid(currentStep)}
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
<svg className="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
) : (
<button
type="button"
onClick={handleSubmit}
disabled={createBookingMutation.isPending || !isStepValid(4)}
className="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{createBookingMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Creating Booking...
</>
) : (
<>
<span className="mr-2">✓</span>
Confirm Booking
</>
)}
</button>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,13 @@
'use client';
import { useAuth } from '@/lib/context/auth-context';
import { Link, usePathname, useRouter } from '@/i18n/navigation';
import { useState, useEffect } from 'react';
import { useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import NotificationDropdown from '@/components/NotificationDropdown';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import Image from 'next/image';
import {
BarChart3,
Package,
FileText,
Search,
BookOpen,
Building2,
Users,
LogOut,
Lock,
Key,
Home,
User,
ShieldCheck,
} from 'lucide-react';
import { useAuth } from '@/lib/context/auth-context';
import { usePathname, useRouter } from '@/i18n/navigation';
import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge';
import type { PlanFeature } from '@/lib/api/subscriptions';
import { AppShell } from '@/components/shell/app-shell';
import { buildAppNav } from '@/components/shell/nav-config';
import { PageSpinner } from '@/components/ui/states';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, logout, loading, isAuthenticated } = useAuth();
@ -32,7 +15,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
const pathname = usePathname();
const router = useRouter();
const t = useTranslations('dashboard');
const [sidebarOpen, setSidebarOpen] = useState(false);
const tShell = useTranslations('shell');
useEffect(() => {
if (!loading && !isAuthenticated) {
@ -40,10 +23,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
}, [loading, isAuthenticated, router, pathname]);
const groups = useMemo(
() => buildAppNav({ t, tShell, role: user?.role }),
[t, tShell, user?.role]
);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-4 border-brand-turquoise border-t-transparent rounded-full animate-spin" />
<div className="flex min-h-screen items-center justify-center bg-neutral-50">
<PageSpinner />
</div>
);
}
@ -52,217 +40,45 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return null;
}
const navigation: Array<{
name: string;
href: string;
icon: any;
requiredFeature?: PlanFeature;
}> = [
{ name: t('nav.dashboard'), href: '/dashboard', icon: BarChart3, requiredFeature: 'dashboard' },
{ name: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
{ name: t('nav.documents'), href: '/dashboard/documents', icon: FileText },
{
name: t('nav.tracking'),
href: '/dashboard/track-trace',
icon: Search,
requiredFeature: 'dashboard',
},
{ name: t('nav.wiki'), href: '/dashboard/wiki', icon: BookOpen, requiredFeature: 'wiki' },
{ name: t('nav.organization'), href: '/dashboard/settings/organization', icon: Building2 },
{
name: t('nav.apiKeys'),
href: '/dashboard/settings/api-keys',
icon: Key,
requiredFeature: 'api_access' as PlanFeature,
},
...(user?.role === 'ADMIN' || user?.role === 'MANAGER'
? [
{
name: t('nav.users'),
href: '/dashboard/settings/users',
icon: Users,
requiredFeature: 'user_management' as PlanFeature,
},
]
: []),
];
const isActive = (href: string) => {
if (href === '/dashboard') {
return pathname === href;
return (
<AppShell
variant="app"
groups={groups}
pathname={pathname}
user={user}
statusBadge={subscription?.planDetails?.statusBadge}
hasFeature={hasFeature}
onLogout={logout}
showBottomNav
crossLink={
user?.role === 'ADMIN' ? { href: '/admin', label: t('nav.admin') } : undefined
}
return pathname.startsWith(href);
};
return (
<div className="min-h-screen bg-gray-50">
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
labels={{
navigation: tShell('navigation'),
openMenu: tShell('openMenu'),
closeMenu: tShell('closeMenu'),
collapseSidebar: tShell('collapseSidebar'),
expandSidebar: tShell('expandSidebar'),
userMenu: tShell('userMenu'),
breadcrumb: tShell('breadcrumb'),
rootLabel: t('nav.dashboard'),
search: tShell('search'),
searchPlaceholder: tShell('searchPlaceholder'),
noResults: tShell('noResults'),
lockedFeature: tShell('lockedFeature'),
profile: tShell('profile'),
logout: t('logout'),
more: tShell('more'),
moreTitle: tShell('moreTitle'),
bottomNav: {
home: t('bottomNav.home'),
bookings: t('bottomNav.bookings'),
documents: t('bottomNav.documents'),
tracking: t('bottomNav.tracking'),
},
}}
>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between h-16 px-6 border-b">
<Link href="/dashboard" className="text-2xl font-bold text-blue-600">
<Image
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={50}
height={60}
priority
className="h-auto"
/>
</Link>
<button
className="lg:hidden text-gray-500 hover:text-gray-700"
onClick={() => setSidebarOpen(false)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
{navigation.map(item => {
const locked = item.requiredFeature && !hasFeature(item.requiredFeature);
return (
<Link
key={item.name}
href={locked ? '/pricing' : item.href}
className={`flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
locked
? 'text-gray-400 hover:bg-gray-50'
: isActive(item.href)
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
<item.icon className="mr-3 h-5 w-5" />
<span className="flex-1">{item.name}</span>
{locked && <Lock className="w-4 h-4 text-gray-300" />}
</Link>
);
})}
{user?.role === 'ADMIN' && (
<div className="pt-4 mt-4 border-t">
<Link
href="/admin"
className="flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors bg-brand-navy text-white hover:bg-brand-navy/90"
>
<ShieldCheck className="mr-3 h-5 w-5" />
<span className="flex-1">{t('nav.admin')}</span>
</Link>
</div>
)}
</nav>
<div className="border-t p-4">
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<p className="text-sm font-medium text-gray-900 truncate">
{user?.firstName} {user?.lastName}
</p>
{subscription?.planDetails?.statusBadge &&
subscription.planDetails.statusBadge !== 'none' && (
<StatusBadge badge={subscription.planDetails.statusBadge} size="sm" />
)}
</div>
<p className="text-xs text-gray-500 truncate">{user?.email}</p>
</div>
</div>
<button
onClick={logout}
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
<LogOut className="w-4 h-4 mr-2" />
{t('logout')}
</button>
</div>
</div>
</div>
<div className="lg:pl-64">
<div className="sticky top-0 z-10 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-white border-b">
<button
className="lg:hidden text-gray-500 hover:text-gray-700 p-1"
onClick={() => setSidebarOpen(true)}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<div className="flex-1 lg:flex-none">
<h1 className="text-base lg:text-xl font-semibold text-gray-900 ml-3 lg:ml-0">
{navigation.find(item => isActive(item.href))?.name || t('topbar.defaultTitle')}
</h1>
</div>
<div className="flex items-center space-x-3 lg:space-x-4">
<LanguageSwitcher variant="light" />
<NotificationDropdown />
<Link
href="/dashboard/profile"
className="w-8 h-8 lg:w-9 lg:h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors"
>
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</Link>
</div>
</div>
<main className="p-4 lg:p-6 pb-24 lg:pb-6">{children}</main>
</div>
<nav className="fixed bottom-0 left-0 right-0 z-30 bg-white border-t border-gray-200 lg:hidden">
<div className="grid grid-cols-5 h-16">
{[
{ href: '/dashboard', icon: Home, label: t('bottomNav.home') },
{ href: '/dashboard/bookings', icon: Package, label: t('bottomNav.bookings') },
{ href: '/dashboard/documents', icon: FileText, label: t('bottomNav.documents') },
{ href: '/dashboard/track-trace', icon: Search, label: t('bottomNav.tracking') },
{ href: '/dashboard/profile', icon: User, label: t('bottomNav.profile') },
].map(item => {
const active =
item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center space-y-0.5 transition-colors ${
active ? 'text-blue-600' : 'text-gray-500 hover:text-gray-700'
}`}
>
<item.icon className="w-5 h-5" />
<span className="text-[10px] font-medium leading-tight">{item.label}</span>
</Link>
);
})}
</div>
</nav>
</div>
{children}
</AppShell>
);
}

View File

@ -11,6 +11,7 @@ import {
deleteNotification,
} from '@/lib/api';
import type { NotificationResponse } from '@/types/api';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
Trash2,
CheckCheck,
@ -39,6 +40,8 @@ export default function NotificationsPage() {
const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
const router = useRouter();
const tCommon = useTranslations('common');
const [notificationToDelete, setNotificationToDelete] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['notifications', 'page', selectedFilter, currentPage],
@ -86,11 +89,10 @@ export default function NotificationsPage() {
}
};
// Remplace le dialogue natif : non bloquant et traduit dans son entierete.
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm(t('deleteConfirm'))) {
deleteNotificationMutation.mutate(notificationId);
}
setNotificationToDelete(notificationId);
};
const getPriorityColor = (priority: string) => {
@ -98,10 +100,10 @@ export default function NotificationsPage() {
urgent: 'border-l-4 border-red-500 bg-red-50 hover:bg-red-100',
high: 'border-l-4 border-orange-500 bg-orange-50 hover:bg-orange-100',
medium: 'border-l-4 border-yellow-500 bg-yellow-50 hover:bg-yellow-100',
low: 'border-l-4 border-blue-500 bg-blue-50 hover:bg-blue-100',
low: 'border-l-4 border-brand-turquoise bg-brand-turquoise/5 hover:bg-brand-turquoise/10',
};
return (
colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100'
colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300 hover:bg-neutral-100'
);
};
@ -118,20 +120,20 @@ export default function NotificationsPage() {
const getNotificationIcon = (type: string): ReactNode => {
const iconClass = 'h-8 w-8';
const icons: Record<string, ReactNode> = {
booking_created: <Package className={`${iconClass} text-blue-600`} />,
booking_created: <Package className={`${iconClass} text-brand-navy`} />,
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
booking_cancelled: <XCircle className={`${iconClass} text-red-500`} />,
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_accepted: <CheckCircle className={`${iconClass} text-green-500`} />,
csv_booking_rejected: <XCircle className={`${iconClass} text-red-500`} />,
csv_booking_request_sent: <Mail className={`${iconClass} text-blue-500`} />,
csv_booking_request_sent: <Mail className={`${iconClass} text-brand-turquoise`} />,
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
document_uploaded: <FileText className={`${iconClass} text-gray-600`} />,
document_uploaded: <FileText className={`${iconClass} text-neutral-600`} />,
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
user_invited: <User className={`${iconClass} text-teal-500`} />,
organization_update: <Building2 className={`${iconClass} text-indigo-500`} />,
};
return icons[type.toLowerCase()] || <Bell className={`${iconClass} text-gray-500`} />;
return icons[type.toLowerCase()] || <Bell className={`${iconClass} text-neutral-500`} />;
};
const formatTime = (dateString: string) => {
@ -156,17 +158,17 @@ export default function NotificationsPage() {
};
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-neutral-50">
<div className="bg-white border-b shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-blue-100 rounded-lg">
<Bell className="w-8 h-8 text-blue-600" />
<div className="p-3 bg-brand-turquoise/10 rounded-lg">
<Bell className="w-8 h-8 text-brand-navy" />
</div>
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-600 mt-1">
<h1 className="text-3xl font-bold text-brand-navy">{t('title')}</h1>
<p className="text-sm text-neutral-600 mt-1">
{t('totalLabel', { count: total })}
{unreadCount > 0 && t('unreadSuffix', { count: unreadCount })}
</p>
@ -176,7 +178,7 @@ export default function NotificationsPage() {
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
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"
>
<CheckCheck className="w-5 h-5" />
<span>{t('markAllRead')}</span>
@ -189,8 +191,8 @@ export default function NotificationsPage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
<div className="flex items-center space-x-3">
<Filter className="w-5 h-5 text-gray-500" />
<span className="text-sm font-medium text-gray-700">{t('filter.label')}</span>
<Filter className="w-5 h-5 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700">{t('filter.label')}</span>
<div className="flex space-x-2">
{(['all', 'unread', 'read'] as const).map(filter => (
<button
@ -201,8 +203,8 @@ export default function NotificationsPage() {
}}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
selectedFilter === filter
? 'bg-blue-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
? 'bg-brand-turquoise text-brand-navy shadow-sm'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
{t(`filter.${filter}` as any)}
@ -221,18 +223,18 @@ export default function NotificationsPage() {
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-gray-500">{t('loading')}</p>
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-brand-turquoise mx-auto mb-4" />
<p className="text-neutral-500">{t('loading')}</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="mb-4 flex justify-center">
<Bell className="h-16 w-16 text-gray-300" />
<Bell className="h-16 w-16 text-neutral-300" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t('empty.title')}</h3>
<p className="text-gray-500">
<h3 className="text-xl font-semibold text-brand-navy mb-2">{t('empty.title')}</h3>
<p className="text-neutral-500">
{selectedFilter === 'unread' ? t('empty.upToDate') : t('empty.none')}
</p>
</div>
@ -244,7 +246,7 @@ export default function NotificationsPage() {
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-6 transition-all cursor-pointer group ${
!notification.read ? 'bg-blue-50/50' : ''
!notification.read ? 'bg-brand-turquoise/5/50' : ''
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
@ -255,11 +257,11 @@ export default function NotificationsPage() {
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-3">
<h3 className="text-lg font-semibold text-gray-900">
<h3 className="text-lg font-semibold text-brand-navy">
{notification.title}
</h3>
{!notification.read && (
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
<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="w-2 h-2 bg-white rounded-full animate-pulse" />
<span>{t('new')}</span>
</span>
@ -274,13 +276,13 @@ export default function NotificationsPage() {
</button>
</div>
<p className="text-base text-gray-700 mb-4 leading-relaxed">
<p className="text-base text-neutral-700 mb-4 leading-relaxed">
{notification.message}
</p>
<div className="flex items-center justify-between">
<div className="flex items-center flex-wrap gap-3 text-xs">
<span className="flex items-center space-x-1 text-gray-600">
<span className="flex items-center space-x-1 text-neutral-600">
<svg
className="w-4 h-4"
fill="none"
@ -298,7 +300,7 @@ export default function NotificationsPage() {
{formatTime(notification.createdAt)}
</span>
</span>
<span className="px-3 py-1 bg-gray-100 rounded-full text-gray-700 font-medium">
<span className="px-3 py-1 bg-neutral-100 rounded-full text-neutral-700 font-medium">
{notification.type.replace(/_/g, ' ').toUpperCase()}
</span>
{notification.priority && (
@ -310,7 +312,7 @@ export default function NotificationsPage() {
? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700'
: 'bg-blue-100 text-blue-700'
: 'bg-brand-turquoise/10 text-brand-navy'
}`}
>
{getPriorityLabel(notification.priority)}
@ -318,7 +320,7 @@ export default function NotificationsPage() {
)}
</div>
{notification.actionUrl && (
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
<span className="text-sm text-brand-navy font-medium group-hover:underline flex items-center space-x-1">
<span>{t('viewDetails')}</span>
<svg
className="w-4 h-4"
@ -347,7 +349,7 @@ export default function NotificationsPage() {
{totalPages > 1 && (
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
<div className="text-sm text-neutral-600">
{t.rich('pagination.info', {
current: currentPage,
total: totalPages,
@ -359,7 +361,7 @@ export default function NotificationsPage() {
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-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 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
<span>{t('pagination.previous')}</span>
@ -383,8 +385,8 @@ export default function NotificationsPage() {
onClick={() => setCurrentPage(pageNum)}
className={`w-10 h-10 text-sm font-medium rounded-lg transition-colors ${
currentPage === pageNum
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
? 'bg-brand-turquoise text-brand-navy'
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
}`}
>
{pageNum}
@ -395,7 +397,7 @@ export default function NotificationsPage() {
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-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 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<span>{t('pagination.next')}</span>
<ChevronRight className="w-4 h-4" />
@ -405,6 +407,19 @@ export default function NotificationsPage() {
</div>
)}
</div>
<ConfirmDialog
open={Boolean(notificationToDelete)}
onOpenChange={open => !open && setNotificationToDelete(null)}
title={t('deleteConfirm')}
confirmLabel={tCommon('delete')}
cancelLabel={tCommon('cancel')}
variant="destructive"
onConfirm={() => {
if (notificationToDelete) deleteNotificationMutation.mutate(notificationToDelete);
setNotificationToDelete(null);
}}
/>
</div>
);
}

View File

@ -5,34 +5,39 @@ import { useQuery } from '@tanstack/react-query';
import { dashboardApi } from '@/lib/api';
import { Link, useRouter } from '@/i18n/navigation';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
ArrowRight,
Clock,
Package,
PackageCheck,
PackageX,
Clock,
Weight,
TrendingUp,
Plus,
ArrowRight,
TrendingUp,
Weight,
} from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import ExportButton from '@/components/ExportButton';
import {
PieChart,
Pie,
Cell,
BarChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/ui/PageHeader';
import { EmptyState, Skeleton } from '@/components/ui/states';
import { StatCard } from '@/components/data/stat-card';
import { useSubscription } from '@/lib/context/subscription-context';
import ExportButton from '@/components/ExportButton';
import { CHART_COLORS, CHART_UI, chartAxisProps, chartTooltipStyle } from '@/lib/chart-colors';
export default function DashboardPage() {
const router = useRouter();
const { hasFeature, loading: subLoading } = useSubscription();
@ -57,33 +62,45 @@ export default function DashboardPage() {
const numberFormat = new Intl.NumberFormat(locale === 'fr' ? 'fr-FR' : 'en-US');
const totalBookings =
(csvKpis?.totalAccepted ?? 0) + (csvKpis?.totalRejected ?? 0) + (csvKpis?.totalPending ?? 0);
const statusDistribution = csvKpis
? [
{ name: t('charts.distribution.accepted'), value: csvKpis.totalAccepted, color: '#10b981' },
{ name: t('charts.distribution.rejected'), value: csvKpis.totalRejected, color: '#ef4444' },
{ name: t('charts.distribution.pending'), value: csvKpis.totalPending, color: '#f59e0b' },
{
name: t('charts.distribution.accepted'),
value: csvKpis.totalAccepted,
color: CHART_COLORS.success,
},
{
name: t('charts.distribution.rejected'),
value: csvKpis.totalRejected,
color: CHART_COLORS.danger,
},
{
name: t('charts.distribution.pending'),
value: csvKpis.totalPending,
color: CHART_COLORS.warning,
},
]
: [];
const carrierWeightData = topCarriers
? topCarriers.slice(0, 5).map(c => ({
name: c.carrierName.length > 15 ? c.carrierName.substring(0, 15) + '...' : c.carrierName,
[t('charts.weightByCarrier.weight')]: Math.round(c.totalWeightKG),
}))
: [];
const weightDataKey = t('charts.weightByCarrier.weight');
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 sm:py-8 space-y-4 sm:space-y-6">
<div className="flex items-center justify-between pb-3 sm:pb-4 border-b border-gray-200">
<div>
<h1 className="text-xl sm:text-3xl font-semibold text-gray-900">{t('title')}</h1>
<p className="text-gray-600 mt-0.5 sm:mt-1 text-xs sm:text-sm">{t('subtitle')}</p>
</div>
const carrierWeightData = topCarriers
? topCarriers.slice(0, 5).map(c => ({
name: c.carrierName.length > 15 ? c.carrierName.slice(0, 15) + '…' : c.carrierName,
[weightDataKey]: Math.round(c.totalWeightKG),
}))
: [];
<div className="flex items-center space-x-2 sm:space-x-3">
return (
<>
<PageHeader
title={t('title')}
description={t('subtitle')}
actions={
<>
<ExportButton
data={topCarriers || []}
filename={t('exportFilename')}
@ -114,122 +131,86 @@ export default function DashboardPage() {
},
]}
/>
<Button asChild>
<Link href="/dashboard/search-advanced">
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg font-semibold px-3 sm:px-6 py-2 sm:py-5 text-sm sm:text-base">
<Plus className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" />
<span className="hidden sm:inline">{t('newBooking')}</span>
</Button>
<Plus />
{t('newBooking')}
</Link>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
<PackageCheck className="h-5 w-5 text-green-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.accepted')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
<>
<p className="text-2xl font-bold text-gray-900">
{csvKpis?.totalAccepted || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth || 0 })}
</p>
</Button>
</>
)}
</div>
</CardContent>
</Card>
}
/>
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-red-100 flex items-center justify-center mb-2">
<PackageX className="h-5 w-5 text-red-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.rejected')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
<>
<p className="text-2xl font-bold text-gray-900">
{csvKpis?.totalRejected || 0}
</p>
<p className="text-xs text-gray-500 mt-1">
{t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth || 0 })}
</p>
</>
)}
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-6">
{/* Indicateurs primaires : les quatre chiffres qui repondent a « ou en
sommes-nous ». Alignes a gauche pour se comparer en colonne. */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('performance.totalBookings')}
value={numberFormat.format(totalBookings)}
icon={Package}
loading={csvKpisLoading}
/>
<StatCard
label={t('kpi.accepted')}
value={numberFormat.format(csvKpis?.totalAccepted ?? 0)}
hint={t('kpi.thisMonth', { count: csvKpis?.acceptedThisMonth ?? 0 })}
icon={PackageCheck}
loading={csvKpisLoading}
/>
<StatCard
label={t('kpi.rejected')}
value={numberFormat.format(csvKpis?.totalRejected ?? 0)}
hint={t('kpi.thisMonth', { count: csvKpis?.rejectedThisMonth ?? 0 })}
icon={PackageX}
loading={csvKpisLoading}
/>
<StatCard
label={t('kpi.pending')}
value={numberFormat.format(csvKpis?.totalPending ?? 0)}
hint={t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
icon={Clock}
loading={csvKpisLoading}
/>
</section>
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-amber-100 flex items-center justify-center mb-2">
<Clock className="h-5 w-5 text-amber-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.pending')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
<>
<p className="text-2xl font-bold text-gray-900">{csvKpis?.totalPending || 0}</p>
<p className="text-xs text-gray-500 mt-1">
{t('kpi.acceptanceRate', { rate: (csvKpis?.acceptanceRate ?? 0).toFixed(1) })}
</p>
</>
)}
</div>
</CardContent>
</Card>
{/* Indicateurs secondaires : volumetrie et performance, en bandeau
compact pour ne pas concurrencer la rangee primaire. */}
<section className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-3">
<SecondaryStat
label={t('performance.acceptanceRate')}
value={csvKpisLoading ? null : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)} %`}
icon={TrendingUp}
/>
<SecondaryStat
label={t('kpi.totalWeight')}
value={
csvKpisLoading ? null : `${numberFormat.format(csvKpis?.totalWeightAcceptedKG ?? 0)} kg`
}
icon={Weight}
/>
<SecondaryStat
label={t('performance.totalVolume')}
value={
csvKpisLoading ? null : `${(csvKpis?.totalVolumeAcceptedCBM ?? 0).toFixed(1)} CBM`
}
icon={Package}
/>
</section>
<Card className="border border-gray-200 shadow-sm hover:shadow-md transition-shadow bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Weight className="h-5 w-5 text-blue-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">{t('kpi.totalWeight')}</p>
{csvKpisLoading ? (
<div className="h-8 w-16 bg-gray-100 animate-pulse rounded" />
) : (
<>
<p className="text-2xl font-bold text-gray-900">
{numberFormat.format(csvKpis?.totalWeightAcceptedKG || 0)}
</p>
<p className="text-xs text-gray-500 mt-1">
KG • {(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)} CBM
</p>
</>
)}
</div>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-base font-semibold text-gray-900">
{t('charts.distribution.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600">
{t('charts.distribution.description')}
</CardDescription>
<section className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('charts.distribution.title')}</CardTitle>
<CardDescription>{t('charts.distribution.description')}</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<CardContent>
{csvKpisLoading ? (
<div className="h-48 bg-gray-50 animate-pulse rounded" />
<Skeleton className="h-52 w-full" />
) : totalBookings === 0 ? (
<EmptyState size="sm" icon={Package} title={t('topCarriers.empty.title')} />
) : (
<ResponsiveContainer width="100%" height={200}>
<ResponsiveContainer width="100%" height={208}>
<PieChart>
<Pie
data={statusDistribution}
@ -239,216 +220,153 @@ export default function DashboardPage() {
label={({ name, percent }: any) =>
`${name} ${((percent || 0) * 100).toFixed(0)}%`
}
outerRadius={70}
fill="#8884d8"
outerRadius={72}
innerRadius={44}
paddingAngle={2}
dataKey="value"
>
{statusDistribution.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
{statusDistribution.map(entry => (
<Cell key={entry.name} fill={entry.color} stroke={CHART_UI.surface} />
))}
</Pie>
<Tooltip />
<Tooltip {...chartTooltipStyle} />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-4 border-b border-gray-100">
<CardTitle className="text-base font-semibold text-gray-900">
{t('charts.weightByCarrier.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600">
{t('charts.weightByCarrier.description')}
</CardDescription>
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('charts.weightByCarrier.title')}</CardTitle>
<CardDescription>{t('charts.weightByCarrier.description')}</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<CardContent>
{carriersLoading ? (
<div className="h-48 bg-gray-50 animate-pulse rounded" />
<Skeleton className="h-52 w-full" />
) : carrierWeightData.length === 0 ? (
<EmptyState size="sm" icon={Weight} title={t('topCarriers.empty.title')} />
) : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={carrierWeightData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
angle={-15}
textAnchor="end"
height={50}
/>
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey={weightDataKey} fill="#3b82f6" radius={[4, 4, 0, 0]} />
<ResponsiveContainer width="100%" height={208}>
<BarChart data={carrierWeightData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART_UI.grid} vertical={false} />
<XAxis dataKey="name" interval={0} height={44} {...chartAxisProps} />
<YAxis width={48} {...chartAxisProps} />
<Tooltip {...chartTooltipStyle} cursor={{ fill: CHART_UI.grid }} />
<Bar dataKey={weightDataKey} fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
</section>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border border-gray-200 shadow-sm bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-green-100 flex items-center justify-center mb-2">
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">
{t('performance.acceptanceRate')}
</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${(csvKpis?.acceptanceRate ?? 0).toFixed(1)}%`}
</p>
</div>
</CardContent>
</Card>
<Card className="border border-gray-200 shadow-sm bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
<Package className="h-5 w-5 text-blue-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">
{t('performance.totalBookings')}
</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading
? '--'
: (csvKpis?.totalAccepted || 0) +
(csvKpis?.totalRejected || 0) +
(csvKpis?.totalPending || 0)}
</p>
</div>
</CardContent>
</Card>
<Card className="border border-gray-200 shadow-sm bg-white">
<CardContent className="p-4">
<div className="flex flex-col items-center text-center">
<div className="h-10 w-10 rounded-lg bg-purple-100 flex items-center justify-center mb-2">
<Weight className="h-5 w-5 text-purple-600" />
</div>
<p className="text-xs font-medium text-gray-600 mb-1">
{t('performance.totalVolume')}
</p>
<p className="text-2xl font-bold text-gray-900">
{csvKpisLoading ? '--' : `${(csvKpis?.totalVolumeAcceptedCBM || 0).toFixed(1)}`}
<span className="text-sm font-normal text-gray-500 ml-1">CBM</span>
</p>
</div>
</CardContent>
</Card>
</div>
<Card className="border border-gray-200 shadow-sm bg-white">
<CardHeader className="pb-3 border-b border-gray-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base font-semibold text-gray-900">
{t('topCarriers.title')}
</CardTitle>
<CardDescription className="text-xs text-gray-600 mt-1">
{t('topCarriers.description')}
</CardDescription>
<Card>
<CardHeader className="flex-row items-start justify-between gap-3 pb-3">
<div className="min-w-0">
<CardTitle>{t('topCarriers.title')}</CardTitle>
<CardDescription>{t('topCarriers.description')}</CardDescription>
</div>
<Button asChild variant="ghost" size="sm" className="shrink-0">
<Link href="/dashboard/bookings">
<Button
variant="ghost"
size="sm"
className="gap-2 text-gray-600 hover:text-gray-900 text-xs"
>
{t('topCarriers.viewAll')}
<ArrowRight className="h-3 w-3" />
</Button>
<ArrowRight />
</Link>
</div>
</Button>
</CardHeader>
<CardContent className="p-0">
{carriersLoading ? (
<div className="divide-y divide-gray-100">
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="h-12 bg-gray-50 animate-pulse rounded" />
<div key={i} className="px-5 py-3">
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
) : topCarriers && topCarriers.length > 0 ? (
<div className="divide-y divide-gray-100">
<ul className="divide-y divide-border">
{topCarriers.slice(0, 5).map((carrier, index) => (
<div
<li
key={carrier.carrierName}
className="px-4 py-3 hover:bg-gray-50 transition-colors"
className="flex items-center gap-3 px-5 py-3 transition-colors hover:bg-neutral-50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1">
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-gray-100 text-gray-700 font-semibold text-xs">
<span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-neutral-100 text-body-xs font-semibold text-neutral-600">
{index + 1}
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900 text-sm">
{carrier.carrierName}
</h3>
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
<span>
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
</span>
<span>•</span>
<span>{numberFormat.format(carrier.totalWeightKG)} KG</span>
</div>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-body-sm font-medium text-brand-navy">
{carrier.carrierName}
</p>
<p className="mt-0.5 truncate text-body-xs text-neutral-500">
{t('topCarriers.bookingsCount', { count: carrier.totalBookings })}
{' · '}
{numberFormat.format(carrier.totalWeightKG)} kg
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="flex items-center gap-1.5 justify-end mb-0.5">
<Badge
variant="secondary"
className="bg-green-50 text-green-700 border-green-200 text-xs px-1.5 py-0"
>
{carrier.acceptedBookings} ✓
</Badge>
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="success">{carrier.acceptedBookings}</Badge>
{carrier.rejectedBookings > 0 && (
<Badge
variant="secondary"
className="bg-red-50 text-red-700 border-red-200 text-xs px-1.5 py-0"
>
{carrier.rejectedBookings} ✗
</Badge>
<Badge variant="danger">{carrier.rejectedBookings}</Badge>
)}
</div>
<p className="text-xs text-gray-500">
{carrier.acceptanceRate.toFixed(0)}% • ${carrier.avgPriceUSD.toFixed(0)}
<div className="hidden w-24 shrink-0 text-right sm:block">
<p className="text-body-sm font-medium text-brand-navy">
{carrier.acceptanceRate.toFixed(0)} %
</p>
<p className="text-body-xs text-neutral-500">
${carrier.avgPriceUSD.toFixed(0)}
</p>
</div>
</div>
</div>
</div>
</li>
))}
</div>
</ul>
) : (
<div className="text-center py-12 px-4">
<div className="mx-auto mb-3 h-12 w-12 rounded-full bg-gray-100 flex items-center justify-center">
<Package className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-semibold text-gray-900 mb-1">
{t('topCarriers.empty.title')}
</h3>
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
{t('topCarriers.empty.description')}
</p>
<Link href="/dashboard/bookings">
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
<Plus className="mr-1.5 h-3 w-3" />
<EmptyState
icon={Package}
title={t('topCarriers.empty.title')}
description={t('topCarriers.empty.description')}
action={
<Button asChild size="sm">
<Link href="/dashboard/search-advanced">
<Plus />
{t('topCarriers.empty.cta')}
</Button>
</Link>
</div>
</Button>
}
/>
)}
</CardContent>
</Card>
</div>
</>
);
}
/** Indicateur secondaire, en bandeau, sans le poids visuel d'une carte. */
function SecondaryStat({
label,
value,
icon: Icon,
}: {
label: string;
value: string | null;
icon: React.ComponentType<{ className?: string }>;
}) {
return (
<div className="flex items-center gap-3 bg-white px-5 py-4">
<Icon className="size-4 shrink-0 text-neutral-300" aria-hidden="true" />
<div className="min-w-0">
<p className="text-label uppercase text-neutral-500">{label}</p>
{value === null ? (
<Skeleton className="mt-1 h-5 w-20" />
) : (
<p className="mt-0.5 font-heading text-h5 text-brand-navy">{value}</p>
)}
</div>
</div>
);
}

View File

@ -142,8 +142,8 @@ export default function ProfilePage() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">{t('loading')}</p>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4"></div>
<p className="text-neutral-600">{t('loading')}</p>
</div>
</div>
);
@ -156,7 +156,7 @@ export default function ProfilePage() {
<p className="text-red-600 mb-4">{t('loadError')}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90"
>
{t('retry')}
</button>
@ -167,9 +167,9 @@ export default function ProfilePage() {
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
<div className="bg-gradient-to-r from-brand-navy to-brand-navy rounded-lg shadow-lg p-6 text-white">
<h1 className="text-3xl font-bold mb-2">{t('header.title')}</h1>
<p className="text-blue-100">{t('header.subtitle')}</p>
<p className="text-white/70">{t('header.subtitle')}</p>
</div>
{successMessage && (
@ -204,17 +204,17 @@ export default function ProfilePage() {
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center text-white text-2xl font-bold">
<div className="w-16 h-16 bg-brand-turquoise rounded-full flex items-center justify-center text-white text-2xl font-bold">
{user?.firstName?.[0]}
{user?.lastName?.[0]}
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">
<h2 className="text-2xl font-bold text-brand-navy">
{user?.firstName} {user?.lastName}
</h2>
<p className="text-gray-600">{user?.email}</p>
<p className="text-neutral-600">{user?.email}</p>
<div className="flex items-center space-x-2 mt-2">
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
<span className="px-3 py-1 text-xs font-medium text-brand-navy bg-brand-turquoise/10 rounded-full">
{user?.role}
</span>
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
@ -232,8 +232,8 @@ export default function ProfilePage() {
onClick={() => setActiveTab('profile')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'profile'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-brand-turquoise text-brand-navy'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
{t('tabs.profile')}
@ -242,8 +242,8 @@ export default function ProfilePage() {
onClick={() => setActiveTab('password')}
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'password'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-brand-turquoise text-brand-navy'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
{t('tabs.password')}
@ -258,7 +258,7 @@ export default function ProfilePage() {
<div>
<label
htmlFor="firstName"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('profileForm.firstName')}
</label>
@ -266,7 +266,7 @@ export default function ProfilePage() {
{...profileForm.register('firstName')}
type="text"
id="firstName"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent"
/>
{profileForm.formState.errors.firstName && (
<p className="mt-1 text-sm text-red-600">
@ -278,7 +278,7 @@ export default function ProfilePage() {
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('profileForm.lastName')}
</label>
@ -286,7 +286,7 @@ export default function ProfilePage() {
{...profileForm.register('lastName')}
type="text"
id="lastName"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent"
/>
{profileForm.formState.errors.lastName && (
<p className="mt-1 text-sm text-red-600">
@ -297,7 +297,7 @@ export default function ProfilePage() {
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
<label htmlFor="email" className="block text-sm font-medium text-neutral-700 mb-2">
{t('profileForm.email')}
</label>
<input
@ -305,16 +305,16 @@ export default function ProfilePage() {
type="email"
id="email"
disabled
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg bg-neutral-50 text-neutral-500 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-gray-500">{t('profileForm.emailHelp')}</p>
<p className="mt-1 text-xs text-neutral-500">{t('profileForm.emailHelp')}</p>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={updateProfileMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
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"
>
{updateProfileMutation.isPending
? t('profileForm.saving')
@ -327,7 +327,7 @@ export default function ProfilePage() {
<div>
<label
htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('passwordForm.current')}
</label>
@ -337,12 +337,12 @@ export default function ProfilePage() {
type={showCurrentPassword ? 'text' : 'password'}
id="currentPassword"
autoComplete="current-password"
className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-4 py-2 pr-12 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent"
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={
showCurrentPassword ? tCommon('hidePassword') : tCommon('showPassword')
@ -365,7 +365,7 @@ export default function ProfilePage() {
<div>
<label
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('passwordForm.new')}
</label>
@ -375,12 +375,12 @@ export default function ProfilePage() {
type={showNewPassword ? 'text' : 'password'}
id="newPassword"
autoComplete="new-password"
className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-4 py-2 pr-12 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={showNewPassword ? tCommon('hidePassword') : tCommon('showPassword')}
>
@ -392,13 +392,13 @@ export default function ProfilePage() {
{passwordForm.formState.errors.newPassword.message}
</p>
)}
<p className="mt-1 text-xs text-gray-500">{t('passwordForm.newHint')}</p>
<p className="mt-1 text-xs text-neutral-500">{t('passwordForm.newHint')}</p>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-2"
className="block text-sm font-medium text-neutral-700 mb-2"
>
{t('passwordForm.confirm')}
</label>
@ -408,12 +408,12 @@ export default function ProfilePage() {
type={showConfirmPassword ? 'text' : 'password'}
id="confirmPassword"
autoComplete="new-password"
className="w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-4 py-2 pr-12 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={
showConfirmPassword ? tCommon('hidePassword') : tCommon('showPassword')
@ -437,7 +437,7 @@ export default function ProfilePage() {
<button
type="submit"
disabled={updatePasswordMutation.isPending}
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
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"
>
{updatePasswordMutation.isPending
? t('passwordForm.submitting')

View File

@ -19,7 +19,7 @@ import dynamic from 'next/dynamic';
const PortRouteMapLoader = () => {
const t = useTranslations('dashboard.rateSearch');
return (
<div className="h-80 bg-gray-100 rounded-lg flex items-center justify-center">
<div className="h-80 bg-neutral-100 rounded-lg flex items-center justify-center">
{t('mapLoading')}
</div>
);
@ -406,10 +406,10 @@ export default function AdvancedSearchPage() {
const renderStep0 = () => (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-gray-900">
<h2 className="text-xl font-semibold text-brand-navy">
{stepNumber(0)}. {t('step0.title')}
</h2>
<p className="text-sm text-gray-500 mt-1">{t('step0.subtitle')}</p>
<p className="text-sm text-neutral-500 mt-1">{t('step0.subtitle')}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -430,20 +430,20 @@ export default function AdvancedSearchPage() {
onClick={() => selectDirection(value)}
className={`text-left p-6 rounded-xl border-2 transition-colors ${
isSelected
? 'border-blue-600 bg-blue-50'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-border hover:border-brand-turquoise hover:bg-neutral-50'
}`}
>
<div
className={`w-12 h-12 rounded-full flex items-center justify-center mb-4 ${
isSelected ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'
isSelected ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-100 text-neutral-600'
}`}
>
<Icon className="h-6 w-6" />
</div>
<div className="font-semibold text-gray-900">{t(`step0.${key}.title`)}</div>
<div className="text-sm text-gray-600 mt-1">{t(`step0.${key}.route`)}</div>
<div className="text-xs text-gray-500 mt-2">{t(`step0.${key}.description`)}</div>
<div className="font-semibold text-brand-navy">{t(`step0.${key}.title`)}</div>
<div className="text-sm text-neutral-600 mt-1">{t(`step0.${key}.route`)}</div>
<div className="text-xs text-neutral-500 mt-2">{t(`step0.${key}.description`)}</div>
</button>
);
})}
@ -454,14 +454,14 @@ export default function AdvancedSearchPage() {
const renderStep1 = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
<h2 className="text-xl font-semibold text-brand-navy">
{stepNumber(1)}. {t('step1.title')}
</h2>
{direction && showDirectionStep && (
<button
type="button"
onClick={() => setCurrentStep(0)}
className="text-xs font-medium px-3 py-1 rounded-full bg-blue-100 text-blue-800 hover:bg-blue-200"
className="text-xs font-medium px-3 py-1 rounded-full bg-brand-turquoise/10 text-brand-navy hover:bg-brand-turquoise/20"
>
{t(`step0.${direction.toLowerCase() as 'export' | 'import'}.title`)} · {t('step0.change')}
</button>
@ -470,7 +470,7 @@ export default function AdvancedSearchPage() {
<div className="grid grid-cols-2 gap-4">
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('step1.originLabel')}{' '}
{searchForm.origin && (
<span className="text-green-600 text-xs">{t('step1.selected')}</span>
@ -493,18 +493,18 @@ export default function AdvancedSearchPage() {
onFocus={() => setShowOriginDropdown(true)}
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
placeholder={t('step1.originPlaceholder')}
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-turquoise ${
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-neutral-300'
}`}
/>
{isLoadingOrigins && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
<Loader2 className="h-4 w-4 animate-spin text-neutral-400" />
</div>
)}
</div>
{showOriginDropdown && filteredOrigins.length > 0 && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
<div className="absolute left-0 right-0 mt-2 bg-white border border-border rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
{filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
<button
key={port.code}
@ -517,16 +517,16 @@ export default function AdvancedSearchPage() {
setDestinationSearch('');
setShowOriginDropdown(false);
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
className="w-full text-left px-4 py-3 hover:bg-brand-turquoise/5 border-b border-border last:border-b-0"
>
<div className="font-medium text-gray-900">{port.name}</div>
<div className="text-gray-500 text-xs mt-1">
<div className="font-medium text-brand-navy">{port.name}</div>
<div className="text-neutral-500 text-xs mt-1">
{port.code} - {port.city}, {port.countryName}
</div>
</button>
))}
{filteredOrigins.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
<div className="px-4 py-2 text-xs text-neutral-500 bg-neutral-50">
{t('step1.moreResults', { count: filteredOrigins.length - 15 })}
</div>
)}
@ -536,8 +536,8 @@ export default function AdvancedSearchPage() {
filteredOrigins.length === 0 &&
!isLoadingOrigins &&
originsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">
<div className="absolute left-0 right-0 mt-2 bg-white border border-border rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-neutral-500">
{t('step1.noOrigin', { query: originSearch })}
</p>
</div>
@ -545,7 +545,7 @@ export default function AdvancedSearchPage() {
</div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('step1.destinationLabel')}{' '}
{searchForm.destination && (
<span className="text-green-600 text-xs">{t('step1.selected')}</span>
@ -574,18 +574,18 @@ export default function AdvancedSearchPage() {
? t('step1.destinationPlaceholder')
: t('step1.destinationDisabled')
}
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
className={`w-full px-3 py-2 border rounded-md focus:ring-ring focus:border-brand-turquoise ${
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-neutral-300'
} ${!searchForm.origin ? 'bg-neutral-100 cursor-not-allowed' : ''}`}
/>
{isLoadingDestinations && searchForm.origin && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
<Loader2 className="h-4 w-4 animate-spin text-neutral-400" />
</div>
)}
</div>
{searchForm.origin && destinationsData?.total !== undefined && (
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-neutral-500 mt-1">
{t('step1.availableDestinations', {
count: destinationsData.total,
port: selectedOriginPort?.name || searchForm.origin,
@ -593,7 +593,7 @@ export default function AdvancedSearchPage() {
</p>
)}
{showDestinationDropdown && filteredDestinations.length > 0 && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
<div className="absolute left-0 right-0 mt-2 bg-white border border-border rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
{filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => (
<button
key={port.code}
@ -604,16 +604,16 @@ export default function AdvancedSearchPage() {
setSelectedDestinationPort(port);
setShowDestinationDropdown(false);
}}
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
className="w-full text-left px-4 py-3 hover:bg-brand-turquoise/5 border-b border-border last:border-b-0"
>
<div className="font-medium text-gray-900">{port.name}</div>
<div className="text-gray-500 text-xs mt-1">
<div className="font-medium text-brand-navy">{port.name}</div>
<div className="text-neutral-500 text-xs mt-1">
{port.code} - {port.city}, {port.countryName}
</div>
</button>
))}
{filteredDestinations.length > 15 && (
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
<div className="px-4 py-2 text-xs text-neutral-500 bg-neutral-50">
{t('step1.moreResults', { count: filteredDestinations.length - 15 })}
</div>
)}
@ -624,8 +624,8 @@ export default function AdvancedSearchPage() {
!isLoadingDestinations &&
searchForm.origin &&
destinationsData && (
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-gray-500">
<div className="absolute left-0 right-0 mt-2 bg-white border border-border rounded-md shadow-lg p-4 z-50">
<p className="text-sm text-neutral-500">
{t('step1.noDestination', { query: destinationSearch })}
</p>
</div>
@ -637,15 +637,15 @@ export default function AdvancedSearchPage() {
selectedDestinationPort &&
selectedOriginPort.latitude &&
selectedDestinationPort.latitude && (
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-900">
<div className="mt-6 border border-border rounded-lg overflow-hidden">
<div className="bg-neutral-50 px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-brand-navy">
{t('step1.routeTitle', {
origin: selectedOriginPort.name,
destination: selectedDestinationPort.name,
})}
</h3>
<p className="text-xs text-gray-500 mt-1">{t('step1.routeDescription')}</p>
<p className="text-xs text-neutral-500 mt-1">{t('step1.routeDescription')}</p>
</div>
<PortRouteMap
portA={{
@ -668,22 +668,22 @@ export default function AdvancedSearchPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">
<h2 className="text-xl font-semibold text-brand-navy">
{stepNumber(2)}. {t('step2.title')}
</h2>
<button
type="button"
onClick={addPackage}
className="px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 rounded-md hover:bg-blue-100"
className="px-4 py-2 text-sm font-medium text-brand-navy bg-brand-turquoise/5 rounded-md hover:bg-brand-turquoise/10"
>
{t('step2.addPackage')}
</button>
</div>
{searchForm.packages.map((pkg, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4 space-y-4">
<div key={index} className="border border-border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">
<h3 className="font-medium text-brand-navy">
{t('step2.packageNumber', { number: index + 1 })}
</h3>
{searchForm.packages.length > 1 && (
@ -699,13 +699,13 @@ export default function AdvancedSearchPage() {
<div className="grid grid-cols-5 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
<label className="block text-xs font-medium text-neutral-700 mb-1">
{t('step2.type')}
</label>
<select
value={pkg.type}
onChange={e => updatePackage(index, 'type', e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded-md"
>
<option value="caisse">{t('step2.packageTypes.caisse')}</option>
<option value="colis">{t('step2.packageTypes.colis')}</option>
@ -715,7 +715,7 @@ export default function AdvancedSearchPage() {
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
<label className="block text-xs font-medium text-neutral-700 mb-1">
{t('step2.quantity')}
</label>
<input
@ -723,12 +723,12 @@ export default function AdvancedSearchPage() {
min="1"
value={pkg.quantity}
onChange={e => updatePackage(index, 'quantity', parseInt(e.target.value) || 1)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded-md"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
<label className="block text-xs font-medium text-neutral-700 mb-1">
{t('step2.length')}
</label>
<input
@ -736,12 +736,12 @@ export default function AdvancedSearchPage() {
min="1"
value={pkg.length}
onChange={e => updatePackage(index, 'length', parseInt(e.target.value) || 0)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded-md"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
<label className="block text-xs font-medium text-neutral-700 mb-1">
{t('step2.width')}
</label>
<input
@ -749,12 +749,12 @@ export default function AdvancedSearchPage() {
min="1"
value={pkg.width}
onChange={e => updatePackage(index, 'width', parseInt(e.target.value) || 0)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded-md"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
<label className="block text-xs font-medium text-neutral-700 mb-1">
{t('step2.height')}
</label>
<input
@ -762,14 +762,14 @@ export default function AdvancedSearchPage() {
min="1"
value={pkg.height}
onChange={e => updatePackage(index, 'height', parseInt(e.target.value) || 0)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded-md"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
<label className="block text-xs font-medium text-neutral-700 mb-1">
{t('step2.weight')}
</label>
<input
@ -777,7 +777,7 @@ export default function AdvancedSearchPage() {
min="1"
value={pkg.weight}
onChange={e => updatePackage(index, 'weight', parseInt(e.target.value) || 0)}
className="w-full px-2 py-1.5 text-sm border border-gray-300 rounded-md"
className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded-md"
/>
</div>
@ -786,17 +786,17 @@ export default function AdvancedSearchPage() {
type="checkbox"
checked={pkg.stackable}
onChange={e => updatePackage(index, 'stackable', e.target.checked)}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<label className="ml-2 text-sm text-gray-700">{t('step2.stackable')}</label>
<label className="ml-2 text-sm text-neutral-700">{t('step2.stackable')}</label>
</div>
</div>
</div>
))}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h3 className="text-sm font-medium text-blue-900 mb-2">{t('step2.summary.title')}</h3>
<div className="text-sm text-blue-800 space-y-1">
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-md p-4">
<h3 className="text-sm font-medium text-brand-navy mb-2">{t('step2.summary.title')}</h3>
<div className="text-sm text-brand-navy space-y-1">
<div>{t('step2.summary.volume', { value: totals.totalVolumeCBM.toFixed(2) })}</div>
<div>{t('step2.summary.weight', { value: totals.totalWeightKG })}</div>
<div>{t('step2.summary.pallets', { value: totals.totalPallets })}</div>
@ -808,49 +808,49 @@ export default function AdvancedSearchPage() {
const renderStep3 = () => (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-gray-900">
<h2 className="text-xl font-semibold text-brand-navy">
{stepNumber(3)}. {t('step3.title')}
</h2>
<div className="space-y-4">
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.customs.title')}</h3>
<h3 className="text-sm font-semibold text-brand-navy mb-3">{t('step3.customs.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.eurDocument}
onChange={e => setSearchForm({ ...searchForm, eurDocument: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.eurDocument')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.customs.eurDocument')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.t1Document}
onChange={e => setSearchForm({ ...searchForm, t1Document: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.t1Document')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.customs.t1Document')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.customsStop}
onChange={e => setSearchForm({ ...searchForm, customsStop: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.customs.customsStop')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.customs.customsStop')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.exportAssistance}
onChange={e => setSearchForm({ ...searchForm, exportAssistance: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">
<span className="ml-2 text-sm text-neutral-700">
{t('step3.customs.exportAssistance')}
</span>
</label>
@ -858,16 +858,16 @@ export default function AdvancedSearchPage() {
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.goods.title')}</h3>
<h3 className="text-sm font-semibold text-brand-navy mb-3">{t('step3.goods.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.dangerousGoods}
onChange={e => setSearchForm({ ...searchForm, dangerousGoods: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.dangerous')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.goods.dangerous')}</span>
</label>
<label className="flex items-center">
<input
@ -876,75 +876,75 @@ export default function AdvancedSearchPage() {
onChange={e =>
setSearchForm({ ...searchForm, regulatedProducts: e.target.checked })
}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.goods.regulated')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.goods.regulated')}</span>
</label>
</div>
</div>
<div className="border-b pb-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.handling.title')}</h3>
<h3 className="text-sm font-semibold text-brand-navy mb-3">{t('step3.handling.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.specialHandling}
onChange={e => setSearchForm({ ...searchForm, specialHandling: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.special')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.handling.special')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.tailgate}
onChange={e => setSearchForm({ ...searchForm, tailgate: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.tailgate')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.handling.tailgate')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.straps}
onChange={e => setSearchForm({ ...searchForm, straps: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.straps')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.handling.straps')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.thermalCover}
onChange={e => setSearchForm({ ...searchForm, thermalCover: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.handling.thermalCover')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.handling.thermalCover')}</span>
</label>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">{t('step3.other.title')}</h3>
<h3 className="text-sm font-semibold text-brand-navy mb-3">{t('step3.other.title')}</h3>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.appointment}
onChange={e => setSearchForm({ ...searchForm, appointment: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.appointment')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.other.appointment')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={searchForm.insurance}
onChange={e => setSearchForm({ ...searchForm, insurance: e.target.checked })}
className="h-4 w-4 text-blue-600 border-gray-300 rounded"
className="h-4 w-4 text-brand-navy border-neutral-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{t('step3.other.insurance')}</span>
<span className="ml-2 text-sm text-neutral-700">{t('step3.other.insurance')}</span>
</label>
</div>
</div>
@ -963,8 +963,8 @@ export default function AdvancedSearchPage() {
<div className="mx-auto w-14 h-14 rounded-full bg-amber-100 flex items-center justify-center mb-4">
<Lock className="h-7 w-7 text-amber-600" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{tLimit('title')}</h1>
<p className="text-gray-600 mb-6">{tLimit('message', { max: quota.max })}</p>
<h1 className="text-2xl font-bold text-brand-navy mb-2">{tLimit('title')}</h1>
<p className="text-neutral-600 mb-6">{tLimit('message', { max: quota.max })}</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
href="/dashboard/settings/subscription"
@ -975,7 +975,7 @@ export default function AdvancedSearchPage() {
</Link>
<Link
href="/dashboard/bookings"
className="inline-flex items-center justify-center px-6 py-3 bg-white border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
className="inline-flex items-center justify-center px-6 py-3 bg-white border border-neutral-300 text-neutral-700 rounded-lg font-medium hover:bg-neutral-50 transition-colors"
>
{tLimit('back')}
</Link>
@ -990,7 +990,7 @@ export default function AdvancedSearchPage() {
if (isLoadingDirections) {
return (
<div className="max-w-7xl mx-auto py-20 flex justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
<Loader2 className="h-8 w-8 animate-spin text-neutral-400" />
</div>
);
}
@ -998,8 +998,8 @@ export default function AdvancedSearchPage() {
return (
<div className="max-w-7xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="text-sm text-gray-500 mt-1">{t('subtitle')}</p>
<h1 className="text-3xl font-bold text-brand-navy">{t('title')}</h1>
<p className="text-sm text-neutral-500 mt-1">{t('subtitle')}</p>
</div>
<div className="flex items-center justify-center space-x-4">
@ -1007,14 +1007,14 @@ export default function AdvancedSearchPage() {
<div key={step} className="flex items-center">
<div
className={`flex items-center justify-center w-10 h-10 rounded-full ${
currentStep >= step ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'
currentStep >= step ? 'bg-brand-turquoise text-brand-navy' : 'bg-neutral-200 text-neutral-600'
}`}
>
{index + 1}
</div>
{index < wizardSteps.length - 1 && (
<div
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-blue-600' : 'bg-gray-200'}`}
className={`w-20 h-1 mx-2 ${currentStep > step ? 'bg-brand-turquoise' : 'bg-neutral-200'}`}
/>
)}
</div>
@ -1032,7 +1032,7 @@ export default function AdvancedSearchPage() {
type="button"
onClick={() => setCurrentStep(Math.max(firstStep, currentStep - 1))}
disabled={currentStep === firstStep}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-6 py-2 text-sm font-medium text-neutral-700 bg-neutral-100 rounded-md hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('navigation.previous')}
</button>
@ -1046,7 +1046,7 @@ export default function AdvancedSearchPage() {
? !direction
: !searchForm.origin || !searchForm.destination
}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
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"
>
{t('navigation.next')}
</button>

View File

@ -178,12 +178,12 @@ export default function SearchResultsPage() {
if (isLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-blue-600 mb-4"></div>
<p className="text-xl text-gray-700 font-medium">{t('loadingTitle')}</p>
<p className="text-gray-500 mt-2">
<div className="inline-block animate-spin rounded-full h-16 w-16 border-b-4 border-brand-turquoise mb-4"></div>
<p className="text-xl text-neutral-700 font-medium">{t('loadingTitle')}</p>
<p className="text-neutral-500 mt-2">
{origin} → {destination}
</p>
</div>
@ -194,7 +194,7 @@ export default function SearchResultsPage() {
if (error) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
<div className="mb-4 flex justify-center">
@ -220,11 +220,11 @@ export default function SearchResultsPage() {
if (results.length === 0) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
className="mb-6 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
>
{t('backToSearch')}
</button>
@ -233,13 +233,13 @@ export default function SearchResultsPage() {
<div className="mb-4 flex justify-center">
<Search className="h-16 w-16 text-yellow-500" />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">{t('noResultsTitle')}</h3>
<p className="text-gray-600 mb-4">{t('noResultsMessage', { origin, destination })}</p>
<h3 className="text-xl font-bold text-brand-navy mb-2">{t('noResultsTitle')}</h3>
<p className="text-neutral-600 mb-4">{t('noResultsMessage', { origin, destination })}</p>
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
<h4 className="font-semibold text-gray-900 mb-2 flex items-center">
<h4 className="font-semibold text-brand-navy mb-2 flex items-center">
<Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> {t('suggestions')}
</h4>
<ul className="text-sm text-gray-700 space-y-2">
<ul className="text-sm text-neutral-700 space-y-2">
<li>{t('suggestionPorts')}</li>
<li>{t('suggestionVolume')}</li>
<li>{t('suggestionWeight')}</li>
@ -247,7 +247,7 @@ export default function SearchResultsPage() {
</div>
<button
onClick={() => router.back()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
className="px-6 py-3 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors"
>
{t('modifySearch')}
</button>
@ -274,12 +274,12 @@ export default function SearchResultsPage() {
type: t('options.standard'),
option: bestOptions?.standard,
colors: {
border: 'border-blue-200',
bg: 'bg-blue-50',
text: 'text-blue-800',
button: 'bg-blue-600 hover:bg-blue-700',
border: 'border-brand-turquoise/30',
bg: 'bg-brand-turquoise/5',
text: 'text-brand-navy',
button: 'bg-brand-turquoise hover:bg-brand-turquoise/90',
},
icon: <Scale className="h-10 w-10 text-blue-600" />,
icon: <Scale className="h-10 w-10 text-brand-navy" />,
badge: t('options.badgeBalanced'),
},
{
@ -297,13 +297,13 @@ export default function SearchResultsPage() {
];
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
<div className="min-h-screen bg-gradient-to-br from-brand-turquoise/5 to-indigo-100 py-12 px-4">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.back()}
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
className="mb-4 flex items-center text-brand-navy hover:text-brand-turquoise font-medium"
>
{t('backToSearch')}
</button>
@ -311,8 +311,8 @@ export default function SearchResultsPage() {
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('resultsTitle')}</h1>
<p className="text-gray-600">
<h1 className="text-3xl font-bold text-brand-navy mb-2">{t('resultsTitle')}</h1>
<p className="text-neutral-600">
<span className="font-semibold">{origin}</span> →{' '}
<span className="font-semibold">{destination}</span> •{' '}
{palletCount > 0
@ -325,15 +325,15 @@ export default function SearchResultsPage() {
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">{t('ratesFound')}</p>
<p className="text-3xl font-bold text-blue-600">{results.length}</p>
<p className="text-sm text-neutral-500">{t('ratesFound')}</p>
<p className="text-3xl font-bold text-brand-navy">{results.length}</p>
</div>
</div>
</div>
</div>
{editBookingId && (
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800">
<div className="mb-6 rounded-lg border border-brand-turquoise/30 bg-brand-turquoise/5 px-4 py-3 text-sm text-brand-navy">
{t('editMode')}
</div>
)}
@ -346,7 +346,7 @@ export default function SearchResultsPage() {
{/* Best Options */}
{bestOptions && (
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<h2 className="text-2xl font-bold text-brand-navy mb-6 flex items-center">
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
{t('bestChoices')}
</h2>
@ -366,7 +366,7 @@ export default function SearchResultsPage() {
<span>{card.icon}</span>
<div>
<h3 className={`text-xl font-bold ${card.colors.text}`}>{card.type}</h3>
<span className="text-xs bg-white px-2 py-1 rounded-full text-gray-600 font-medium">
<span className="text-xs bg-white px-2 py-1 rounded-full text-neutral-600 font-medium">
{card.badge}
</span>
</div>
@ -375,30 +375,30 @@ export default function SearchResultsPage() {
<div className="bg-white rounded-lg p-4 mb-4">
<div className="text-center mb-3">
<p className="text-sm text-gray-600 mb-1">{t('totalPrice')}</p>
<p className="text-3xl font-bold text-gray-900">
<p className="text-sm text-neutral-600 mb-1">{t('totalPrice')}</p>
<p className="text-3xl font-bold text-brand-navy">
{formatPrice(card.option.priceBreakdown.totalPriceForSorting)}
</p>
</div>
<div className="border-t border-gray-200 pt-3 space-y-2 text-sm">
<div className="border-t border-border pt-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">{t('carrier')}</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-600">{t('carrier')}</span>
<span className="font-semibold text-brand-navy">
{card.option.companyName}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{t('transit')}</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-600">{t('transit')}</span>
<span className="font-semibold text-brand-navy">
{t('transitDays', {
days: card.option.adjustedTransitDays ?? card.option.transitDays,
})}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">{t('type')}</span>
<span className="font-semibold text-gray-900">
<span className="text-neutral-600">{t('type')}</span>
<span className="font-semibold text-brand-navy">
{card.option.containerType}
</span>
</div>
@ -426,7 +426,7 @@ export default function SearchResultsPage() {
{/* All Results */}
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">
<h2 className="text-2xl font-bold text-brand-navy mb-6">
{t('allResults', { count: results.length })}
</h2>
@ -438,50 +438,50 @@ export default function SearchResultsPage() {
>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-gray-900">{result.companyName}</h3>
<p className="text-sm text-gray-500">
<h3 className="text-xl font-bold text-brand-navy">{result.companyName}</h3>
<p className="text-sm text-neutral-500">
{result.origin} → {result.destination} • {result.containerType}
</p>
</div>
<div className="text-right">
<p className="text-3xl font-bold text-blue-600">
<p className="text-3xl font-bold text-brand-navy">
{formatPrice(result.priceBreakdown.totalPriceForSorting)}
</p>
<p className="text-sm text-gray-500">{t('totalPrice')}</p>
<p className="text-sm text-neutral-500">{t('totalPrice')}</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">
<div className="bg-neutral-50 rounded-lg p-3">
<p className="text-xs text-neutral-600 mb-1">
Fret ({result.priceBreakdown.freightCurrency})
</p>
<p className="font-semibold text-gray-900">
<p className="font-semibold text-brand-navy">
{formatPrice(result.priceBreakdown.totalFreight)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">
<div className="bg-neutral-50 rounded-lg p-3">
<p className="text-xs text-neutral-600 mb-1">
FOB ({result.priceBreakdown.fobCurrency})
</p>
<p className="font-semibold text-gray-900">
<p className="font-semibold text-brand-navy">
{formatPrice(result.priceBreakdown.totalFob)}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">Routage</p>
<p className="font-semibold text-gray-900">{result.routing}</p>
<div className="bg-neutral-50 rounded-lg p-3">
<p className="text-xs text-neutral-600 mb-1">Routage</p>
<p className="font-semibold text-brand-navy">{result.routing}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1">{t('priceBreakdown.transit')}</p>
<p className="font-semibold text-gray-900">
<div className="bg-neutral-50 rounded-lg p-3">
<p className="text-xs text-neutral-600 mb-1">{t('priceBreakdown.transit')}</p>
<p className="font-semibold text-brand-navy">
{t('transitDays', { days: result.adjustedTransitDays ?? result.transitDays })}
</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-gray-600">
<div className="flex items-center space-x-4 text-sm text-neutral-600">
<span>
{t('validUntil', {
date: new Date(result.validUntil).toLocaleDateString(dateLocale),
@ -493,7 +493,7 @@ export default function SearchResultsPage() {
</span>
)}
{result.dgSurchargeStatus === 'on_request' && (
<span className="text-blue-600 flex items-center">
<span className="text-brand-navy flex items-center">
<AlertTriangle className="h-4 w-4 mr-1" /> DG sur demande
</span>
)}
@ -501,7 +501,7 @@ export default function SearchResultsPage() {
<button
onClick={() => handleSelect(result, `all-${index}`)}
disabled={submittingId === `all-${index}`}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-60"
className="px-6 py-2 bg-brand-turquoise text-brand-navy rounded-lg hover:bg-brand-turquoise/90 transition-colors disabled:opacity-60"
>
{submittingId === `all-${index}`
? '...'

View File

@ -1,614 +0,0 @@
/**
* Rate Search Page
*
* Search and compare shipping rates from multiple carriers
*/
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Image from 'next/image';
import { searchRates } from '@/lib/api';
import { searchPorts, Port } from '@/lib/api/ports';
import { Search, Leaf, Package } from 'lucide-react';
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
interface SearchForm {
originPort: string;
destinationPort: string;
containerType: ContainerType;
departureDate: string;
mode: Mode;
isHazmat: boolean;
quantity: number;
}
export default function RateSearchPage() {
const [searchForm, setSearchForm] = useState<SearchForm>({
originPort: '',
destinationPort: '',
containerType: '40HC',
departureDate: '',
mode: 'SEA',
isHazmat: false,
quantity: 1,
});
const [hasSearched, setHasSearched] = useState(false);
const [originSearch, setOriginSearch] = useState('');
const [destinationSearch, setDestinationSearch] = useState('');
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
const [transitTimeMax, setTransitTimeMax] = useState<number>(50);
const [selectedCarriers, setSelectedCarriers] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'price' | 'transitTime' | 'co2'>('price');
// Port autocomplete
const { data: originPortsData } = useQuery({
queryKey: ['ports', originSearch],
queryFn: () => searchPorts({ query: originSearch, limit: 10 }),
enabled: originSearch.length >= 2, // Only search after 2 characters
});
const { data: destinationPortsData } = useQuery({
queryKey: ['ports', destinationSearch],
queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }),
enabled: destinationSearch.length >= 2, // Only search after 2 characters
});
const originPorts = originPortsData?.ports || [];
const destinationPorts = destinationPortsData?.ports || [];
// Rate search
const {
data: rateQuotes,
isLoading: isSearching,
error: searchError,
} = useQuery({
queryKey: ['rates', searchForm],
queryFn: () =>
searchRates({
origin: searchForm.originPort,
destination: searchForm.destinationPort,
containerType: searchForm.containerType,
departureDate: searchForm.departureDate,
mode: searchForm.mode,
isHazmat: searchForm.isHazmat,
quantity: searchForm.quantity,
}),
enabled: hasSearched && !!searchForm.originPort && !!searchForm.destinationPort,
});
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setHasSearched(true);
};
// Filter and sort results
const filteredAndSortedQuotes = rateQuotes?.rates
? rateQuotes.rates
.filter((quote: any) => {
const price = quote.pricing.totalAmount;
const inPriceRange = price >= priceRange[0] && price <= priceRange[1];
const inTransitTime = quote.route.transitDays <= transitTimeMax;
const matchesCarrier =
selectedCarriers.length === 0 || selectedCarriers.includes(quote.carrier.name);
return inPriceRange && inTransitTime && matchesCarrier;
})
.sort((a: any, b: any) => {
if (sortBy === 'price') {
return a.pricing.totalAmount - b.pricing.totalAmount;
} else if (sortBy === 'transitTime') {
return a.route.transitDays - b.route.transitDays;
} else {
return (a.co2Emissions?.value || 0) - (b.co2Emissions?.value || 0);
}
})
: [];
// Get unique carriers for filter
const availableCarriers = rateQuotes?.rates
? Array.from(new Set(rateQuotes.rates.map((q: any) => q.carrier.name)))
: [];
const toggleCarrier = (carrier: string) => {
setSelectedCarriers(prev =>
prev.includes(carrier) ? prev.filter(c => c !== carrier) : [...prev, carrier]
);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900">Recherche de Tarifs Maritime</h1>
<p className="text-sm text-gray-500 mt-1">
Comparez les tarifs de plusieurs transporteurs en temps réel
</p>
</div>
{/* Search Form */}
<div className="bg-white rounded-lg shadow p-6">
<form onSubmit={handleSearch} className="space-y-6">
{/* Ports */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Origin Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Port d'origine *
</label>
<input
type="text"
required
value={originSearch}
onChange={e => {
setOriginSearch(e.target.value);
if (e.target.value.length < 2) {
setSearchForm({ ...searchForm, originPort: '' });
}
}}
placeholder="ex : Rotterdam, Shanghai"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{originPorts && originPorts.length > 0 && (
<div className="mt-2 bg-white border border-gray-200 rounded-md shadow-sm max-h-48 overflow-y-auto">
{originPorts.map((port: Port) => (
<button
key={port.code}
type="button"
onClick={() => {
setSearchForm({ ...searchForm, originPort: port.code });
setOriginSearch(port.displayName);
}}
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm"
>
<div className="font-medium">{port.name}</div>
<div className="text-gray-500 text-xs">
{port.code} - {port.city}, {port.countryName}
</div>
</button>
))}
</div>
)}
</div>
{/* Destination Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Port de destination *
</label>
<input
type="text"
required
value={destinationSearch}
onChange={e => {
setDestinationSearch(e.target.value);
if (e.target.value.length < 2) {
setSearchForm({ ...searchForm, destinationPort: '' });
}
}}
placeholder="ex : Los Angeles, Hambourg"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
{destinationPorts && destinationPorts.length > 0 && (
<div className="mt-2 bg-white border border-gray-200 rounded-md shadow-sm max-h-48 overflow-y-auto">
{destinationPorts.map((port: Port) => (
<button
key={port.code}
type="button"
onClick={() => {
setSearchForm({ ...searchForm, destinationPort: port.code });
setDestinationSearch(port.displayName);
}}
className="w-full text-left px-4 py-2 hover:bg-gray-50 text-sm"
>
<div className="font-medium">{port.name}</div>
<div className="text-gray-500 text-xs">
{port.code} - {port.city}, {port.countryName}
</div>
</button>
))}
</div>
)}
</div>
</div>
{/* Container & Mode */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Type de conteneur *
</label>
<select
value={searchForm.containerType}
onChange={e =>
setSearchForm({ ...searchForm, containerType: e.target.value as ContainerType })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="20GP">20' GP</option>
<option value="40GP">40' GP</option>
<option value="40HC">40' HC</option>
<option value="45HC">45' HC</option>
<option value="20RF">20' Reefer</option>
<option value="40RF">40' Reefer</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Quantité *</label>
<input
type="number"
min="1"
max="100"
value={searchForm.quantity}
onChange={e =>
setSearchForm({ ...searchForm, quantity: parseInt(e.target.value) || 1 })
}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date de départ *
</label>
<input
type="date"
required
value={searchForm.departureDate}
onChange={e => setSearchForm({ ...searchForm, departureDate: e.target.value })}
min={new Date().toISOString().split('T')[0]}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Mode *</label>
<select
value={searchForm.mode}
onChange={e => setSearchForm({ ...searchForm, mode: e.target.value as Mode })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
>
<option value="FCL">FCL (Full Container Load)</option>
<option value="LCL">LCL (Less than Container Load)</option>
</select>
</div>
</div>
{/* Hazmat */}
<div className="flex items-center">
<input
type="checkbox"
id="hazmat"
checked={searchForm.isHazmat}
onChange={e => setSearchForm({ ...searchForm, isHazmat: e.target.checked })}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="hazmat" className="ml-2 block text-sm text-gray-900">
Marchandises dangereuses (manutention spéciale requise)
</label>
</div>
{/* Submit */}
<div className="flex justify-end">
<button
type="submit"
disabled={!searchForm.originPort || !searchForm.destinationPort || isSearching}
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isSearching ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
Recherche en cours...
</>
) : (
<>
<Search className="h-5 w-5 mr-2" />
Rechercher des tarifs
</>
)}
</button>
</div>
</form>
</div>
{/* Error */}
{searchError && (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<div className="text-sm text-red-800">
La recherche de tarifs a échoué. Veuillez réessayer.
</div>
</div>
)}
{/* Results */}
{hasSearched && !isSearching && rateQuotes && (
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Filters Sidebar */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow p-4 space-y-6 sticky top-4">
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Trier par</h3>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
>
<option value="price">Prix (croissant)</option>
<option value="transitTime">Temps de transit</option>
<option value="co2">Émissions CO2</option>
</select>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Fourchette de prix (USD)
</h3>
<div className="space-y-2">
<input
type="range"
min="0"
max="10000"
step="100"
value={priceRange[1]}
onChange={e => setPriceRange([0, parseInt(e.target.value)])}
className="w-full"
/>
<div className="text-sm text-gray-600">
Jusqu'à {priceRange[1].toLocaleString()} $
</div>
</div>
</div>
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">
Temps de transit max (jours)
</h3>
<div className="space-y-2">
<input
type="range"
min="1"
max="50"
value={transitTimeMax}
onChange={e => setTransitTimeMax(parseInt(e.target.value))}
className="w-full"
/>
<div className="text-sm text-gray-600">{transitTimeMax} jours</div>
</div>
</div>
{availableCarriers.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-3">Transporteurs</h3>
<div className="space-y-2">
{availableCarriers.map(carrier => (
<label key={carrier} className="flex items-center">
<input
type="checkbox"
checked={selectedCarriers.includes(carrier as string)}
onChange={() => toggleCarrier(carrier as string)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">{carrier}</span>
</label>
))}
</div>
</div>
)}
</div>
</div>
{/* Results List */}
<div className="lg:col-span-3 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{filteredAndSortedQuotes.length} tarif
{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé
{filteredAndSortedQuotes.length !== 1 ? 's' : ''}
</h2>
</div>
{filteredAndSortedQuotes.length === 0 ? (
<div className="bg-white rounded-lg shadow p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun tarif trouvé</h3>
<p className="mt-1 text-sm text-gray-500">
Essayez d'ajuster vos filtres ou vos critères de recherche
</p>
</div>
) : (
filteredAndSortedQuotes.map((quote: any) => (
<div
key={quote.id}
className="bg-white rounded-lg shadow hover:shadow-md transition-shadow p-6"
>
<div className="flex items-start justify-between">
{/* Carrier Info */}
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
{quote.carrier.logoUrl ? (
<Image
src={quote.carrier.logoUrl}
alt={quote.carrier.name}
width={48}
height={48}
className="h-12 w-12 object-contain"
/>
) : (
<div className="h-12 w-12 bg-blue-100 rounded flex items-center justify-center text-blue-600 font-semibold">
{quote.carrier.name.substring(0, 2).toUpperCase()}
</div>
)}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{quote.carrier.name}
</h3>
<p className="text-sm text-gray-500">{quote.carrier.scac}</p>
</div>
</div>
{/* Price */}
<div className="text-right">
<div className="text-2xl font-bold text-blue-600">
${quote.pricing.totalAmount.toLocaleString()}
</div>
<div className="text-sm text-gray-500">{quote.pricing.currency}</div>
</div>
</div>
{/* Route Info */}
<div className="mt-4 grid grid-cols-3 gap-4">
<div>
<div className="text-xs text-gray-500 uppercase">Départ</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{new Date(quote.route.etd).toLocaleDateString()}
</div>
</div>
<div>
<div className="text-xs text-gray-500 uppercase">Temps de transit</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{quote.route.transitDays} jours
</div>
</div>
<div>
<div className="text-xs text-gray-500 uppercase">Arrivée</div>
<div className="text-sm font-medium text-gray-900 mt-1">
{new Date(quote.route.eta).toLocaleDateString()}
</div>
</div>
</div>
{/* Route Path */}
<div className="mt-4 flex items-center text-sm text-gray-600">
<span className="font-medium">{quote.route.originPort}</span>
<svg
className="mx-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
{quote.route.transshipmentPorts &&
quote.route.transshipmentPorts.length > 0 && (
<>
<span className="text-gray-400">
via {quote.route.transshipmentPorts.join(', ')}
</span>
<svg
className="mx-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</>
)}
<span className="font-medium">{quote.route.destinationPort}</span>
</div>
{/* Additional Info */}
<div className="mt-4 flex items-center space-x-4 text-sm text-gray-500">
{quote.co2Emissions && (
<div className="flex items-center">
<Leaf className="h-4 w-4 mr-1 text-green-500" />
{quote.co2Emissions.value} kg CO2
</div>
)}
{quote.availability && (
<div className="flex items-center">
<Package className="h-4 w-4 mr-1 text-blue-500" />
{quote.availability} conteneurs disponibles
</div>
)}
</div>
{/* Surcharges */}
{quote.pricing.surcharges && quote.pricing.surcharges.length > 0 && (
<div className="mt-4 text-sm">
<div className="text-gray-500 mb-2">Surcharges incluses :</div>
<div className="flex flex-wrap gap-2">
{quote.pricing.surcharges.map((surcharge: any, idx: number) => (
<span
key={idx}
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800"
>
{surcharge.name}: ${surcharge.amount}
</span>
))}
</div>
</div>
)}
{/* Actions */}
<div className="mt-6 flex justify-end">
<a
href={`/dashboard/bookings/new?quoteId=${quote.id}`}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
Réserver
</a>
</div>
</div>
))
)}
</div>
</div>
)}
{/* Empty State */}
{!hasSearched && (
<div className="bg-white rounded-lg shadow p-12 text-center">
<svg
className="mx-auto h-16 w-16 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h3 className="mt-4 text-lg font-medium text-gray-900">
Rechercher des tarifs maritimes
</h3>
<p className="mt-2 text-sm text-gray-500">
Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de
plusieurs transporteurs
</p>
</div>
)}
</div>
);
}

View File

@ -19,6 +19,7 @@ import {
Lock,
} from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { SettingsNav } from '@/components/shell/settings-nav';
function CopyButton({ text }: { text: string }) {
const t = useTranslations('dashboard.apiKeys.copy');
@ -31,7 +32,7 @@ function CopyButton({ text }: { text: string }) {
return (
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
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"
>
{copied ? (
<>
@ -40,8 +41,8 @@ function CopyButton({ text }: { text: string }) {
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-gray-400" />
<span className="text-gray-600">{t('copy')}</span>
<Copy className="w-3.5 h-3.5 text-neutral-400" />
<span className="text-neutral-600">{t('copy')}</span>
</>
)}
</button>
@ -65,11 +66,11 @@ function CreatedKeyModal({
<ShieldCheck className="w-5 h-5 text-green-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('title')}</h2>
<p className="text-sm text-gray-500">{result.name}</p>
<h2 className="text-lg font-semibold text-brand-navy">{t('title')}</h2>
<p className="text-sm text-neutral-500">{result.name}</p>
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
@ -82,22 +83,22 @@ function CreatedKeyModal({
</div>
<div className="p-6">
<label className="block text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
<label className="block text-xs font-medium text-neutral-500 mb-2 uppercase tracking-wide">
{t('fullKey')}
</label>
<div className="flex items-center gap-2 p-3 bg-gray-950 rounded-xl border border-gray-800">
<div className="flex items-center gap-2 p-3 bg-brand-navy rounded-xl border border-neutral-800">
<code className="flex-1 text-xs font-mono text-green-400 break-all">
{result.fullKey}
</code>
<CopyButton text={result.fullKey} />
</div>
<p className="mt-3 text-xs text-gray-500">{t('storeHint')}</p>
<p className="mt-3 text-xs text-neutral-500">{t('storeHint')}</p>
</div>
<div className="p-6 pt-0">
<button
onClick={onClose}
className="w-full py-2.5 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
className="w-full py-2.5 bg-brand-navy hover:bg-brand-navy/90 text-white text-sm font-medium rounded-xl transition-colors"
>
{t('close')}
</button>
@ -140,19 +141,19 @@ function CreateKeyModal({
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-100 flex items-center justify-center">
<Key className="w-5 h-5 text-blue-600" />
<div className="w-10 h-10 rounded-xl bg-brand-turquoise/10 flex items-center justify-center">
<Key className="w-5 h-5 text-brand-navy" />
</div>
<h2 className="text-lg font-semibold text-gray-900">{t('title')}</h2>
<h2 className="text-lg font-semibold text-brand-navy">{t('title')}</h2>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors">
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
{t('name')} <span className="text-red-500">*</span>
</label>
<input
@ -162,23 +163,23 @@ function CreateKeyModal({
placeholder={t('namePlaceholder')}
maxLength={100}
required
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
className="w-full px-3.5 py-2.5 border border-border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-400">{t('nameCount', { count: name.length })}</p>
<p className="mt-1 text-xs text-neutral-400">{t('nameCount', { count: name.length })}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
{t('expiry')} <span className="text-gray-400 font-normal">{t('optional')}</span>
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
{t('expiry')} <span className="text-neutral-400 font-normal">{t('optional')}</span>
</label>
<input
type="date"
value={expiresAt}
onChange={e => setExpiresAt(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
className="w-full px-3.5 py-2.5 border border-border rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-[#34CCCD] focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-400">{t('expiryHint')}</p>
<p className="mt-1 text-xs text-neutral-400">{t('expiryHint')}</p>
</div>
{mutation.isError && (
@ -192,14 +193,14 @@ function CreateKeyModal({
<button
type="button"
onClick={onClose}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
className="flex-1 py-2.5 border border-border text-neutral-700 text-sm font-medium rounded-xl hover:bg-neutral-50 transition-colors"
>
{t('cancel')}
</button>
<button
type="submit"
disabled={!name.trim() || mutation.isPending}
className="flex-1 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
className="flex-1 py-2.5 bg-brand-navy hover:bg-brand-navy/90 disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
>
{mutation.isPending ? t('creating') : t('create')}
</button>
@ -227,11 +228,11 @@ function RevokeConfirmModal({
<div className="w-12 h-12 rounded-xl bg-red-100 flex items-center justify-center mx-auto mb-4">
<Trash2 className="w-6 h-6 text-red-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900 text-center mb-2">{t('title')}</h2>
<p className="text-sm text-gray-600 text-center mb-1">
<strong className="text-gray-900">{apiKey.name}</strong>
<h2 className="text-lg font-semibold text-brand-navy text-center mb-2">{t('title')}</h2>
<p className="text-sm text-neutral-600 text-center mb-1">
<strong className="text-brand-navy">{apiKey.name}</strong>
</p>
<p className="text-sm text-gray-500 text-center">
<p className="text-sm text-neutral-500 text-center">
{t('description')} <strong>{t('descriptionEmphasis')}</strong>
{t('descriptionRest')}
</p>
@ -239,7 +240,7 @@ function RevokeConfirmModal({
<div className="px-6 pb-6 flex gap-3">
<button
onClick={onClose}
className="flex-1 py-2.5 border border-gray-200 text-gray-700 text-sm font-medium rounded-xl hover:bg-gray-50 transition-colors"
className="flex-1 py-2.5 border border-border text-neutral-700 text-sm font-medium rounded-xl hover:bg-neutral-50 transition-colors"
>
{t('cancel')}
</button>
@ -289,7 +290,7 @@ export default function ApiKeysPage() {
const keyStatusBadge = (key: ApiKeyDto) => {
if (!key.isActive) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-neutral-100 text-neutral-600">
{t('status.revoked')}
</span>
);
@ -312,11 +313,11 @@ export default function ApiKeysPage() {
if (!hasApiAccess) {
return (
<div className="max-w-lg mx-auto mt-16 text-center">
<div className="w-16 h-16 rounded-2xl bg-gray-100 flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-gray-400" />
<div className="w-16 h-16 rounded-2xl bg-neutral-100 flex items-center justify-center mx-auto mb-6">
<Lock className="w-8 h-8 text-neutral-400" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-3">{t('noAccess.title')}</h1>
<p className="text-gray-600 mb-8">
<h1 className="text-2xl font-bold text-brand-navy mb-3">{t('noAccess.title')}</h1>
<p className="text-neutral-600 mb-8">
{t.rich('noAccess.description', {
gold: () => <strong>{t('noAccess.gold')}</strong>,
platinium: () => <strong>{t('noAccess.platinium')}</strong>,
@ -324,7 +325,7 @@ export default function ApiKeysPage() {
</p>
<a
href="/pricing"
className="inline-flex items-center gap-2 px-6 py-3 bg-[#10183A] hover:bg-[#1a2550] text-white text-sm font-medium rounded-xl transition-colors"
className="inline-flex items-center gap-2 px-6 py-3 bg-brand-navy hover:bg-brand-navy/90 text-white text-sm font-medium rounded-xl transition-colors"
>
{t('noAccess.viewPlans')}
</a>
@ -357,11 +358,12 @@ export default function ApiKeysPage() {
<PageHeader
title={t('title')}
description={t('description')}
tabs={<SettingsNav />}
actions={
<button
onClick={() => setShowCreateModal(true)}
disabled={activeKeys.length >= 20}
className="flex items-center gap-2 px-4 py-2.5 bg-[#10183A] hover:bg-[#1a2550] disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
className="flex items-center gap-2 px-4 py-2.5 bg-brand-navy hover:bg-brand-navy/90 disabled:opacity-50 text-white text-sm font-medium rounded-xl transition-colors"
>
<Plus className="w-4 h-4" />
{t('newKey')}
@ -369,14 +371,14 @@ export default function ApiKeysPage() {
}
/>
<div className="mb-6 p-4 bg-blue-50 border border-blue-100 rounded-xl flex gap-3">
<ShieldCheck className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<div className="mb-6 p-4 bg-brand-turquoise/5 border border-brand-turquoise/20 rounded-xl flex gap-3">
<ShieldCheck className="w-5 h-5 text-brand-navy flex-shrink-0 mt-0.5" />
<div className="text-sm text-brand-navy">
<p className="font-medium mb-0.5">{t('infoTitle')}</p>
<p>
{t.rich('infoBody', {
code: () => (
<code className="px-1.5 py-0.5 bg-blue-100 rounded text-blue-900 font-mono text-xs">
<code className="px-1.5 py-0.5 bg-brand-turquoise/10 rounded text-brand-navy font-mono text-xs">
X-API-Key: xped_live_...
</code>
),
@ -393,15 +395,15 @@ export default function ApiKeysPage() {
</div>
</div>
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden">
<div className="bg-white border border-border rounded-2xl overflow-hidden">
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-[#34CCCD] border-t-transparent rounded-full animate-spin" />
</div>
) : !apiKeys || apiKeys.length === 0 ? (
<div className="py-16 text-center">
<Key className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500 text-sm">{t('noKeys')}</p>
<Key className="w-10 h-10 text-neutral-300 mx-auto mb-3" />
<p className="text-neutral-500 text-sm">{t('noKeys')}</p>
<button
onClick={() => setShowCreateModal(true)}
className="mt-4 text-sm font-medium text-[#10183A] hover:underline"
@ -410,8 +412,8 @@ export default function ApiKeysPage() {
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 px-6 py-3 bg-gray-50 text-xs font-medium text-gray-500 uppercase tracking-wide">
<div className="divide-y divide-border">
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 px-6 py-3 bg-neutral-50 text-xs font-medium text-neutral-500 uppercase tracking-wide">
<span>{t('table.name')}</span>
<span>{t('table.lastUsed')}</span>
<span>{t('table.expiry')}</span>
@ -425,19 +427,19 @@ export default function ApiKeysPage() {
className="grid grid-cols-[2fr_1.5fr_1fr_1fr_auto] gap-4 items-center px-6 py-4"
>
<div>
<p className="text-sm font-medium text-gray-900">{key.name}</p>
<code className="text-xs font-mono text-gray-400">{key.keyPrefix}…</code>
<p className="text-sm font-medium text-brand-navy">{key.name}</p>
<code className="text-xs font-mono text-neutral-400">{key.keyPrefix}…</code>
</div>
<span className="text-sm text-gray-600">{formatDate(key.lastUsedAt)}</span>
<span className="text-sm text-gray-600">{formatDate(key.expiresAt)}</span>
<span className="text-sm text-neutral-600">{formatDate(key.lastUsedAt)}</span>
<span className="text-sm text-neutral-600">{formatDate(key.expiresAt)}</span>
<div>{keyStatusBadge(key)}</div>
<button
onClick={() => setRevokeTarget(key)}
disabled={!key.isActive || revokeMutation.isPending}
title={t('revoke')}
className="p-2 text-gray-400 hover:text-red-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-red-50"
className="p-2 text-neutral-400 hover:text-red-600 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
</button>
@ -448,7 +450,7 @@ export default function ApiKeysPage() {
</div>
{apiKeys && apiKeys.length > 0 && (
<p className="mt-4 text-xs text-gray-400 text-right">
<p className="mt-4 text-xs text-neutral-400 text-right">
{t('quota', { active: activeKeys.length, max: 20 })}
</p>
)}

View File

@ -1,4 +1,5 @@
'use client';
import { SettingsNav } from '@/components/shell/settings-nav';
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
@ -148,8 +149,8 @@ export default function OrganizationSettingsPage() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-blue-600 mb-4"></div>
<p className="text-gray-600">{t('loading')}</p>
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-4 border-brand-turquoise mb-4"></div>
<p className="text-neutral-600">{t('loading')}</p>
</div>
</div>
);
@ -240,8 +241,8 @@ export default function OrganizationSettingsPage() {
return (
<div className="max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('header.title')}</h1>
<p className="text-gray-600 mt-2">{t('header.subtitle')}</p>
<h1 className="font-heading text-h3 text-brand-navy">{t('header.title')}</h1>
<p className="text-neutral-600 mt-2">{t('header.subtitle')}</p>
</div>
{successMessage && (activeTab === 'information' || activeTab === 'address') && (
@ -287,10 +288,10 @@ export default function OrganizationSettingsPage() {
)}
{!canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="mb-6 bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4">
<div className="flex items-center">
<svg
className="w-5 h-5 text-blue-600 mr-3"
className="w-5 h-5 text-brand-navy mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -302,13 +303,13 @@ export default function OrganizationSettingsPage() {
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-blue-800 font-medium">{t('readOnlyWarning')}</p>
<p className="text-brand-navy font-medium">{t('readOnlyWarning')}</p>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow-md">
<div className="border-b border-gray-200">
<div className="border-b border-border">
<nav className="flex -mb-px overflow-x-auto">
{tabs.map(tab => (
<button
@ -316,8 +317,8 @@ export default function OrganizationSettingsPage() {
onClick={() => setActiveTab(tab.id)}
className={`flex-shrink-0 px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-blue-600 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
? 'border-brand-turquoise text-brand-navy'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`}
>
<div className="flex items-center space-x-2">
@ -333,7 +334,7 @@ export default function OrganizationSettingsPage() {
{activeTab === 'information' && (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('information.name')} <span className="text-red-500">*</span>
</label>
<input
@ -341,16 +342,16 @@ export default function OrganizationSettingsPage() {
value={formData.name}
onChange={e => handleChange('name', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
placeholder={t('information.namePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('information.siren')}
<span className="ml-2 text-xs text-gray-500">({t('information.sirenHint')})</span>
<span className="ml-2 text-xs text-neutral-500">({t('information.sirenHint')})</span>
</label>
<input
type="text"
@ -359,32 +360,32 @@ export default function OrganizationSettingsPage() {
handleChange('siren', e.target.value.replace(/\D/g, '').slice(0, 9))
}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
placeholder={t('information.sirenPlaceholder')}
maxLength={9}
/>
<p className="mt-1 text-xs text-gray-500">{t('information.sirenDigits')}</p>
<p className="mt-1 text-xs text-neutral-500">{t('information.sirenDigits')}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('information.eori')}
<span className="ml-2 text-xs text-gray-500">({t('information.eoriHint')})</span>
<span className="ml-2 text-xs text-neutral-500">({t('information.eoriHint')})</span>
</label>
<input
type="text"
value={formData.eori}
onChange={e => handleChange('eori', e.target.value.toUpperCase())}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
placeholder={t('information.eoriPlaceholder')}
maxLength={17}
/>
<p className="mt-1 text-xs text-gray-500">{t('information.eoriHelp')}</p>
<p className="mt-1 text-xs text-neutral-500">{t('information.eoriHelp')}</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('information.phone')}
</label>
<input
@ -392,13 +393,13 @@ export default function OrganizationSettingsPage() {
value={formData.contact_phone}
onChange={e => handleChange('contact_phone', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
placeholder={t('information.phonePlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('information.email')}
</label>
<input
@ -406,7 +407,7 @@ export default function OrganizationSettingsPage() {
value={formData.contact_email}
onChange={e => handleChange('contact_email', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
placeholder={t('information.emailPlaceholder')}
/>
</div>
@ -416,7 +417,7 @@ export default function OrganizationSettingsPage() {
{activeTab === 'address' && (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('address.street')} <span className="text-red-500">*</span>
</label>
<input
@ -424,7 +425,7 @@ export default function OrganizationSettingsPage() {
value={formData.address_street}
onChange={e => handleChange('address_street', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
placeholder={t('address.streetPlaceholder')}
required
/>
@ -432,7 +433,7 @@ export default function OrganizationSettingsPage() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('address.postalCode')} <span className="text-red-500">*</span>
</label>
<input
@ -440,13 +441,13 @@ export default function OrganizationSettingsPage() {
value={formData.address_postal_code}
onChange={e => handleChange('address_postal_code', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
placeholder={t('address.postalCodePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('address.city')} <span className="text-red-500">*</span>
</label>
<input
@ -454,7 +455,7 @@ export default function OrganizationSettingsPage() {
value={formData.address_city}
onChange={e => handleChange('address_city', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
placeholder={t('address.cityPlaceholder')}
required
/>
@ -462,14 +463,14 @@ export default function OrganizationSettingsPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('address.country')} <span className="text-red-500">*</span>
</label>
<select
value={formData.address_country}
onChange={e => handleChange('address_country', e.target.value)}
disabled={!canEdit}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
className="w-full px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-ring focus:border-transparent disabled:bg-neutral-100 disabled:cursor-not-allowed"
required
>
<option value="FR">{t('address.countries.FR')}</option>
@ -492,12 +493,12 @@ export default function OrganizationSettingsPage() {
</div>
{canEdit && (activeTab === 'information' || activeTab === 'address') && (
<div className="bg-gray-50 px-8 py-4 border-t border-gray-200 flex items-center justify-end space-x-4">
<div className="bg-neutral-50 px-8 py-4 border-t border-border flex items-center justify-end space-x-4">
<button
type="button"
onClick={handleCancel}
disabled={isSaving}
className="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
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"
>
{t('actions.cancel')}
</button>
@ -505,7 +506,7 @@ export default function OrganizationSettingsPage() {
type="button"
onClick={handleSave}
disabled={isSaving || !formData.name || !formData.address_street}
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 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-turquoise rounded-lg hover:bg-brand-turquoise/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center"
>
{isSaving ? (
<>

View File

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

View File

@ -9,6 +9,8 @@ import { useAuth } from '@/lib/context/auth-context';
import { Link, useRouter } from '@/i18n/navigation';
import ExportButton from '@/components/ExportButton';
import { PageHeader } from '@/components/ui/PageHeader';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { SettingsNav } from '@/components/shell/settings-nav';
const PAGE_SIZE = 5;
@ -26,8 +28,8 @@ function Pagination({
if (totalPages <= 1) return null;
return (
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
<p className="text-sm text-gray-500">
<div className="px-6 py-3 flex items-center justify-between border-t border-border">
<p className="text-sm text-neutral-500">
{t('info', {
from: Math.min((page - 1) * PAGE_SIZE + 1, total),
to: Math.min(page * PAGE_SIZE, total),
@ -38,7 +40,7 @@ function Pagination({
<button
onClick={() => onPage(page - 1)}
disabled={page === 1}
className="px-3 py-1 rounded text-sm border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
className="px-3 py-1 rounded text-sm border border-neutral-300 disabled:opacity-40 hover:bg-neutral-50"
>
‹
</button>
@ -48,8 +50,8 @@ function Pagination({
onClick={() => onPage(p)}
className={`px-3 py-1 rounded text-sm border ${
p === page
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 hover:bg-gray-50'
? 'bg-brand-turquoise text-brand-navy border-brand-turquoise'
: 'border-neutral-300 hover:bg-neutral-50'
}`}
>
{p}
@ -58,7 +60,7 @@ function Pagination({
<button
onClick={() => onPage(page + 1)}
disabled={page === totalPages}
className="px-3 py-1 rounded text-sm border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
className="px-3 py-1 rounded text-sm border border-neutral-300 disabled:opacity-40 hover:bg-neutral-50"
>
›
</button>
@ -67,8 +69,18 @@ function Pagination({
);
}
/** Action destructive ou sensible en attente de confirmation. */
interface PendingAction {
title: string;
confirmLabel: string;
destructive?: boolean;
run: () => void;
}
export default function UsersManagementPage() {
const t = useTranslations('dashboard.usersManagement');
const tCommon = useTranslations('common');
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const router = useRouter();
@ -187,7 +199,7 @@ export default function UsersManagementPage() {
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise"></div>
</div>
);
}
@ -205,33 +217,43 @@ export default function UsersManagementPage() {
});
};
// Les confirmations passaient par `window.confirm()` : bloquant, non
// traduisible dans son chrome, et sans libelle d'action explicite.
const handleToggleActive = (userId: string, isActive: boolean) => {
const action = isActive ? t('confirms.toggleDeactivate') : t('confirms.toggleActivate');
if (window.confirm(t('confirms.toggleActive', { action }))) {
toggleActiveMutation.mutate({ id: userId, isActive });
}
setPendingAction({
title: t('confirms.toggleActive', { action }),
confirmLabel: action,
run: () => toggleActiveMutation.mutate({ id: userId, isActive }),
});
};
const handleDelete = (userId: string) => {
if (window.confirm(t('confirms.delete'))) {
deleteMutation.mutate(userId);
}
setPendingAction({
title: t('confirms.delete'),
confirmLabel: tCommon('delete'),
destructive: true,
run: () => deleteMutation.mutate(userId),
});
};
const handleCancelInvitation = (invId: string, name: string) => {
if (window.confirm(t('confirms.cancelInvite', { name }))) {
cancelInvitationMutation.mutate(invId);
}
setPendingAction({
title: t('confirms.cancelInvite', { name }),
confirmLabel: tCommon('confirm'),
destructive: true,
run: () => cancelInvitationMutation.mutate(invId),
});
};
const getRoleBadgeColor = (role: string) => {
const colors: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800',
MANAGER: 'bg-blue-100 text-blue-800',
MANAGER: 'bg-brand-turquoise/10 text-brand-navy',
USER: 'bg-green-100 text-green-800',
VIEWER: 'bg-gray-100 text-gray-800',
VIEWER: 'bg-neutral-100 text-neutral-800',
};
return colors[role] || 'bg-gray-100 text-gray-800';
return colors[role] || 'bg-neutral-100 text-neutral-800';
};
const allUsers = users?.users || [];
@ -282,17 +304,17 @@ export default function UsersManagementPage() {
licenseStatus.canInvite &&
licenseStatus.availableLicenses <= 2 &&
licenseStatus.maxLicenses !== -1 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
<svg className="h-5 w-5 text-brand-turquoise mr-2" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
<span className="text-sm text-blue-800">
<span className="text-sm text-brand-navy">
{t('license.remaining', {
count: licenseStatus.availableLicenses,
used: licenseStatus.usedLicenses,
@ -302,7 +324,7 @@ export default function UsersManagementPage() {
</div>
<Link
href="/dashboard/settings/subscription"
className="text-sm font-medium text-blue-600 hover:text-blue-800"
className="text-sm font-medium text-brand-navy hover:text-brand-turquoise"
>
{t('license.manageLink')}
</Link>
@ -313,6 +335,7 @@ export default function UsersManagementPage() {
<PageHeader
title={t('header.title')}
description={t('header.subtitle')}
tabs={<SettingsNav />}
actions={
<>
<ExportButton
@ -342,7 +365,7 @@ export default function UsersManagementPage() {
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-3 sm:px-4 py-2 text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
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"
>
<span className="mr-1.5">+</span>
<span className="hidden sm:inline">{t('actions.invite')}</span>
@ -375,64 +398,64 @@ export default function UsersManagementPage() {
)}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">{t('users.title')}</h2>
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-medium text-brand-navy">{t('users.title')}</h2>
{allUsers.length > 0 && (
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-neutral-500 mt-1">
{t('users.membersCount', { count: allUsers.length })}
</p>
)}
</div>
{isLoading ? (
<div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<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>
{t('loading')}
</div>
) : pagedUsers.length > 0 ? (
<>
<div className="overflow-x-auto overflow-y-visible">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('users.table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('users.table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('users.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('users.table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('users.table.createdAt')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('users.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-border">
{pagedUsers.map(user => (
<tr key={user.id} className="hover:bg-gray-50">
<tr key={user.id} className="hover:bg-neutral-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
<div className="flex-shrink-0 h-10 w-10 bg-brand-turquoise rounded-full flex items-center justify-center text-white font-semibold">
{user.firstName[0]}
{user.lastName[0]}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
<div className="text-sm font-medium text-brand-navy">
{user.firstName} {user.lastName}
</div>
<div className="text-sm text-gray-500">{user.email}</div>
<div className="text-sm text-neutral-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
<div className="text-sm text-brand-navy">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
@ -460,7 +483,7 @@ export default function UsersManagementPage() {
{user.isActive ? t('users.active') : t('users.inactive')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(user.createdAt).toLocaleDateString(dateLocale)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
@ -475,10 +498,10 @@ export default function UsersManagementPage() {
setOpenMenuId(user.id);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
>
<svg
className="w-5 h-5 text-gray-600"
className="w-5 h-5 text-neutral-600"
fill="currentColor"
viewBox="0 0 20 20"
>
@ -503,7 +526,7 @@ export default function UsersManagementPage() {
) : (
<div className="px-6 py-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
className="mx-auto h-12 w-12 text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -515,13 +538,13 @@ export default function UsersManagementPage() {
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">{t('users.empty.title')}</h3>
<p className="mt-1 text-sm text-gray-500">{t('users.empty.description')}</p>
<h3 className="mt-2 text-sm font-medium text-brand-navy">{t('users.empty.title')}</h3>
<p className="mt-1 text-sm text-neutral-500">{t('users.empty.description')}</p>
<div className="mt-6">
{licenseStatus?.canInvite ? (
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
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"
>
<span className="mr-2">+</span>
{t('actions.invite')}
@ -542,55 +565,55 @@ export default function UsersManagementPage() {
{allPending.length > 0 && (
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">{t('invitations.title')}</h2>
<p className="text-sm text-gray-500 mt-1">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-medium text-brand-navy">{t('invitations.title')}</h2>
<p className="text-sm text-neutral-500 mt-1">
{t('invitations.subtitle', { count: allPending.length })}
</p>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className="min-w-full divide-y divide-border">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('invitations.table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('invitations.table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('invitations.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('invitations.table.expires')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('invitations.table.status')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('invitations.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-border">
{pagedInvitations.map(inv => {
const isExpired = new Date(inv.expiresAt) < new Date();
return (
<tr key={inv.id} className="hover:bg-gray-50">
<tr key={inv.id} className="hover:bg-neutral-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center text-gray-500 font-semibold">
<div className="flex-shrink-0 h-10 w-10 bg-neutral-200 rounded-full flex items-center justify-center text-neutral-500 font-semibold">
{inv.firstName[0]}
{inv.lastName[0]}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
<div className="text-sm font-medium text-brand-navy">
{inv.firstName} {inv.lastName}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className="px-6 py-4 whitespace-nowrap text-sm text-brand-navy">
{inv.email}
</td>
<td className="px-6 py-4 whitespace-nowrap">
@ -600,7 +623,7 @@ export default function UsersManagementPage() {
{inv.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
{new Date(inv.expiresAt).toLocaleDateString(dateLocale)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
@ -658,7 +681,7 @@ export default function UsersManagementPage() {
}}
/>
<div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
className="fixed w-56 bg-white border-2 border-neutral-300 rounded-lg shadow-2xl z-[999]"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
>
<div className="py-2">
@ -670,7 +693,7 @@ export default function UsersManagementPage() {
setMenuPosition(null);
}}
disabled={toggleActiveMutation.isPending}
className="w-full px-4 py-3 text-left hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
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"
>
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
<>
@ -687,7 +710,7 @@ export default function UsersManagementPage() {
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
<span className="text-sm font-medium text-gray-700">
<span className="text-sm font-medium text-neutral-700">
{t('users.actions.deactivate')}
</span>
</>
@ -706,7 +729,7 @@ export default function UsersManagementPage() {
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm font-medium text-gray-700">
<span className="text-sm font-medium text-neutral-700">
{t('users.actions.activate')}
</span>
</>
@ -747,16 +770,16 @@ export default function UsersManagementPage() {
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
className="fixed inset-0 transition-opacity bg-neutral-500 bg-opacity-75"
onClick={() => setShowInviteModal(false)}
/>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">{t('modal.title')}</h3>
<h3 className="text-lg font-medium text-brand-navy">{t('modal.title')}</h3>
<button
onClick={() => setShowInviteModal(false)}
className="text-gray-400 hover:text-gray-500"
className="text-neutral-400 hover:text-neutral-500"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -771,7 +794,7 @@ export default function UsersManagementPage() {
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.firstName')} *
</label>
<input
@ -779,11 +802,11 @@ export default function UsersManagementPage() {
required
value={inviteForm.firstName}
onChange={e => setInviteForm({ ...inviteForm, firstName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.lastName')} *
</label>
<input
@ -791,12 +814,12 @@ export default function UsersManagementPage() {
required
value={inviteForm.lastName}
onChange={e => setInviteForm({ ...inviteForm, lastName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
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"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.email')} *
</label>
<input
@ -804,17 +827,17 @@ export default function UsersManagementPage() {
required
value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<label className="block text-sm font-medium text-neutral-700">
{t('modal.role')} *
</label>
<select
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
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"
>
<option value="USER">{t('modal.roles.USER')}</option>
<option value="MANAGER">{t('modal.roles.MANAGER')}</option>
@ -825,14 +848,14 @@ export default function UsersManagementPage() {
<button
type="submit"
disabled={inviteMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
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"
>
{inviteMutation.isPending ? t('modal.submitting') : t('modal.submit')}
</button>
<button
type="button"
onClick={() => setShowInviteModal(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:col-start-1 sm:text-sm"
className="mt-3 w-full inline-flex justify-center rounded-md border border-neutral-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-neutral-700 hover:bg-neutral-50 sm:mt-0 sm:col-start-1 sm:text-sm"
>
{t('modal.cancel')}
</button>
@ -843,6 +866,19 @@ export default function UsersManagementPage() {
</div>
</div>
)}
<ConfirmDialog
open={Boolean(pendingAction)}
onOpenChange={open => !open && setPendingAction(null)}
title={pendingAction?.title ?? ''}
confirmLabel={pendingAction?.confirmLabel ?? tCommon('confirm')}
cancelLabel={tCommon('cancel')}
variant={pendingAction?.destructive ? 'destructive' : 'default'}
onConfirm={() => {
pendingAction?.run();
setPendingAction(null);
}}
/>
</div>
);
}

View File

@ -1,27 +1,32 @@
'use client';
import { useState, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import {
Search,
Package,
FileText,
Anchor,
ClipboardList,
Lightbulb,
History,
X,
Clock,
Ship,
ExternalLink,
FileText,
Globe,
History,
Maximize2,
Minimize2,
Globe,
Anchor,
Package,
Search,
Ship,
X,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Callout } from '@/components/ui/callout';
import { FormField } from '@/components/ui/form-field';
import { EmptyState } from '@/components/ui/states';
import { PageHeader } from '@/components/ui/PageHeader';
import { cn } from '@/lib/utils';
interface SearchHistoryItem {
id: string;
trackingNumber: string;
@ -32,6 +37,11 @@ interface SearchHistoryItem {
type CarrierDescKey = 'containerOrBl' | 'containerBlOrBooking' | 'containerOnly';
/**
* Les couleurs `color` ci-dessous sont les identites visuelles des compagnies
* maritimes. Ce sont des marques tierces : elles ne relevent pas de la charte
* Xpeditis et sont reproduites telles quelles, a l'identique de l'original.
*/
const carriers = [
{
id: 'maersk',
@ -101,7 +111,7 @@ const carriers = [
id: 'yangming',
name: 'Yang Ming',
color: '#FFD700',
textColor: 'text-gray-900',
textColor: 'text-brand-navy',
trackingUrl:
'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
placeholder: 'Ex: YMLU1234567',
@ -132,7 +142,9 @@ const HISTORY_KEY = 'xpeditis_track_history';
export default function TrackTracePage() {
const t = useTranslations('dashboard.trackTrace');
const tCommon = useTranslations('common');
const locale = useLocale();
const [trackingNumber, setTrackingNumber] = useState('');
const [selectedCarrier, setSelectedCarrier] = useState('');
const [error, setError] = useState('');
@ -142,42 +154,40 @@ export default function TrackTracePage() {
const [isMapLoading, setIsMapLoading] = useState(true);
useEffect(() => {
const savedHistory = localStorage.getItem(HISTORY_KEY);
if (savedHistory) {
const saved = localStorage.getItem(HISTORY_KEY);
if (!saved) return;
try {
const parsed = JSON.parse(savedHistory);
setSearchHistory(
parsed.map((item: any) => ({
...item,
timestamp: new Date(item.timestamp),
}))
);
const parsed = JSON.parse(saved);
setSearchHistory(parsed.map((item: any) => ({ ...item, timestamp: new Date(item.timestamp) })));
} catch (e) {
console.error('Failed to parse search history:', e);
}
}
}, []);
// Le plein ecran de la carte doit pouvoir se quitter au clavier.
useEffect(() => {
if (!isMapFullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsMapFullscreen(false);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [isMapFullscreen]);
const saveHistory = (history: SearchHistoryItem[]) => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
setSearchHistory(history);
};
const handleTrack = () => {
if (!trackingNumber.trim()) {
setError(t('errors.noTrackingNumber'));
return;
}
if (!selectedCarrier) {
setError(t('errors.noCarrier'));
return;
}
if (!trackingNumber.trim()) return setError(t('errors.noTrackingNumber'));
if (!selectedCarrier) return setError(t('errors.noCarrier'));
setError('');
const carrier = carriers.find(c => c.id === selectedCarrier);
if (carrier) {
const newHistoryItem: SearchHistoryItem = {
if (!carrier) return;
const entry: SearchHistoryItem = {
id: Date.now().toString(),
trackingNumber: trackingNumber.trim(),
carrierId: carrier.id,
@ -185,49 +195,26 @@ export default function TrackTracePage() {
timestamp: new Date(),
};
const updatedHistory = [
newHistoryItem,
saveHistory(
[
entry,
...searchHistory.filter(
h =>
!(
h.trackingNumber === newHistoryItem.trackingNumber &&
h.carrierId === newHistoryItem.carrierId
)
h => !(h.trackingNumber === entry.trackingNumber && h.carrierId === entry.carrierId)
),
].slice(0, 10);
].slice(0, 10)
);
saveHistory(updatedHistory);
const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim());
window.open(trackingUrl, '_blank', 'noopener,noreferrer');
}
};
const handleHistoryClick = (item: SearchHistoryItem) => {
setTrackingNumber(item.trackingNumber);
setSelectedCarrier(item.carrierId);
};
const handleDeleteHistory = (id: string) => {
const updatedHistory = searchHistory.filter(h => h.id !== id);
saveHistory(updatedHistory);
};
const handleClearHistory = () => {
saveHistory([]);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleTrack();
}
window.open(
carrier.trackingUrl + encodeURIComponent(trackingNumber.trim()),
'_blank',
'noopener,noreferrer'
);
};
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMs = Date.now() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
@ -239,404 +226,319 @@ export default function TrackTracePage() {
return date.toLocaleDateString(locale);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('title')}</h1>
<p className="mt-2 text-gray-600">{t('description')}</p>
</div>
const helpCards = [
{ icon: Package, key: 'containerNumber' },
{ icon: FileText, key: 'billOfLading' },
{ icon: ClipboardList, key: 'bookingRef' },
] as const;
{/* Search Form */}
<Card className="bg-white shadow-lg border-blue-100">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<Search className="h-5 w-5 text-blue-600" />
{t('searchCard.title')}
</CardTitle>
<CardDescription>{t('searchCard.description')}</CardDescription>
return (
<>
<PageHeader title={t('title')} description={t('description')} />
<div className="flex flex-col gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle>{t('searchCard.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Carrier Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
<CardContent className="flex flex-col gap-5">
<fieldset>
<legend className="mb-2 text-label-lg text-neutral-700">
{t('searchCard.selectCarrier')}
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{carriers.map(carrier => (
</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{carriers.map(carrier => {
const active = selectedCarrier === carrier.id;
return (
<button
key={carrier.id}
type="button"
aria-pressed={active}
onClick={() => {
setSelectedCarrier(carrier.id);
setError('');
}}
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all hover:scale-105 ${
selectedCarrier === carrier.id
? 'border-blue-500 shadow-lg ring-2 ring-blue-200'
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
}`}
className={cn(
'flex min-h-[44px] items-center gap-2 rounded-md border p-2 text-left transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
active
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-border hover:bg-neutral-50'
)}
>
<div
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
<span
aria-hidden="true"
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded text-[10px] font-bold',
carrier.textColor
)}
style={{ backgroundColor: carrier.color }}
>
{carrier.name.length <= 3
? carrier.name
: carrier.name.substring(0, 2).toUpperCase()}
</div>
<span className="text-xs font-semibold text-gray-800 text-center leading-tight">
: carrier.name.slice(0, 2).toUpperCase()}
</span>
<span className="min-w-0 truncate text-body-xs font-medium text-brand-navy">
{carrier.name}
</span>
</button>
))}
</div>
);
})}
</div>
</fieldset>
{/* Tracking Number Input */}
<div>
<label
htmlFor="tracking-number"
className="block text-sm font-medium text-gray-700 mb-2"
<FormField
label={t('searchCard.trackingNumber')}
hint={
selectedCarrierData
? t(`carriers.${selectedCarrierData.descKey}` as any)
: undefined
}
error={error || undefined}
>
{t('searchCard.trackingNumber')}
</label>
<div className="flex gap-3">
<div className="flex-1">
{({ id, describedBy, invalid }) => (
<div className="flex flex-col gap-2 sm:flex-row">
<Input
id="tracking-number"
type="text"
id={id}
aria-describedby={describedBy}
error={invalid}
value={trackingNumber}
onChange={e => {
setTrackingNumber(e.target.value.toUpperCase());
setError('');
}}
onKeyPress={handleKeyPress}
onKeyDown={e => e.key === 'Enter' && handleTrack()}
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
className="text-lg font-mono border-gray-300 focus:border-blue-500 h-12"
className="font-mono"
/>
{selectedCarrierData && (
<p className="mt-1 text-xs text-gray-500">
{t(`carriers.${selectedCarrierData.descKey}` as any)}
</p>
)}
</div>
<Button
onClick={handleTrack}
size="lg"
className="bg-blue-600 hover:bg-blue-700 text-white px-8 h-12 font-semibold shadow-md"
>
<Search className="mr-2 h-5 w-5" />
<Button onClick={handleTrack} className="shrink-0">
<Search />
{t('searchCard.searchButton')}
</Button>
</div>
</div>
)}
</FormField>
{/* Map Toggle */}
<div className="flex flex-wrap gap-3 pt-2">
<div>
<Button
variant={showMap ? 'default' : 'outline'}
variant={showMap ? 'secondary' : 'outline'}
onClick={() => {
setShowMap(!showMap);
if (!showMap) setIsMapLoading(true);
}}
className={
showMap
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700'
}
>
<Globe className="mr-2 h-4 w-4" />
<Globe />
{showMap ? t('searchCard.hideMap') : t('searchCard.showMap')}
</Button>
</div>
{/* Error Message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
</CardContent>
</Card>
{/* Vessel Position Map */}
{showMap && (
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
<Card
className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}
>
{/* Map Header */}
<div
className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-white/20 rounded-lg">
<Globe className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold">{t('map.title')}</h3>
<p className="text-blue-100 text-sm">{t('map.subtitle')}</p>
<div className={cn(isMapFullscreen && 'fixed inset-0 z-50 bg-brand-navy')}>
<Card className={cn('overflow-hidden', isMapFullscreen && 'h-full rounded-none')}>
<div className="flex items-center justify-between gap-3 bg-brand-navy px-4 py-3 text-white">
<div className="flex min-w-0 items-center gap-2">
<Anchor className="size-4 shrink-0 text-brand-turquoise" aria-hidden="true" />
<div className="min-w-0">
<p className="truncate font-heading text-body-sm font-semibold">
{t('map.title')}
</p>
<p className="truncate text-body-xs text-white/60">{t('map.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
size="icon-sm"
onClick={() => setIsMapFullscreen(!isMapFullscreen)}
className="text-white hover:bg-white/20"
aria-label={isMapFullscreen ? t('map.minimize') : t('map.fullscreen')}
className="text-white/70 hover:bg-white/10 hover:text-white"
>
{isMapFullscreen ? (
<>
<Minimize2 className="h-4 w-4 mr-2" />
{t('map.minimize')}
</>
) : (
<>
<Maximize2 className="h-4 w-4 mr-2" />
{t('map.fullscreen')}
</>
)}
{isMapFullscreen ? <Minimize2 /> : <Maximize2 />}
</Button>
<Button
variant="ghost"
size="sm"
size="icon-sm"
onClick={() => {
setShowMap(false);
setIsMapFullscreen(false);
}}
className="text-white hover:bg-white/20"
aria-label={tCommon('close')}
className="text-white/70 hover:bg-white/10 hover:text-white"
>
<X className="h-4 w-4" />
<X />
</Button>
</div>
</div>
{/* Map Container */}
<div
className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}
className={cn(
'relative w-full',
isMapFullscreen
? 'h-[calc(100vh-56px)]'
: 'h-[60vh] min-h-96 max-h-[42rem]'
)}
>
{isMapLoading && (
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
<div className="text-center">
<div className="relative">
<Ship className="h-16 w-16 text-blue-600 animate-pulse" />
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2">
<div className="flex gap-1">
<div
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<div
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<div
className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</div>
</div>
</div>
<p className="mt-4 text-blue-700 font-medium">{t('map.loading')}</p>
<p className="text-blue-500 text-sm">{t('map.connecting')}</p>
</div>
role="status"
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-neutral-50"
>
<Ship className="size-8 animate-pulse text-brand-turquoise" aria-hidden="true" />
<p className="text-body-sm font-medium text-brand-navy">{t('map.loading')}</p>
<p className="text-body-xs text-neutral-500">{t('map.connecting')}</p>
</div>
)}
<iframe
src="https://www.marinetraffic.com/en/ais/embed/zoom:3/centery:25/centerx:0/maptype:4/shownames:true/mmsi:0/shipid:0/fleet:/fleet_id:/vtypes:/showmenu:true/remember:false"
className="w-full h-full border-0"
className="size-full border-0"
title={t('map.iframeTitle')}
loading="lazy"
onLoad={() => setIsMapLoading(false)}
/>
{/* Map Legend Overlay */}
<div
className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}
>
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
<Anchor className="h-4 w-4 text-blue-600" />
{t('map.legend')}
</h4>
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-gray-600">{t('map.cargo')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-gray-600">{t('map.tankers')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-blue-500" />
<span className="text-gray-600">{t('map.passengers')}</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-gray-600">High Speed</span>
</div>
</div>
</div>
{/* Quick Stats Overlay */}
<div
className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}
>
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="text-2xl font-bold text-blue-600">90K+</p>
<p className="text-gray-500 text-xs">{t('map.activeVessels')}</p>
</div>
<div className="w-px h-10 bg-gray-200" />
<div className="text-center">
<p className="text-2xl font-bold text-green-600">3,500+</p>
<p className="text-gray-500 text-xs">{t('map.worldPorts')}</p>
</div>
</div>
{/* Legende : ces couleurs reproduisent celles des marqueurs de
MarineTraffic. Les aligner sur la charte rendrait la legende
fausse par rapport a la carte affichee. */}
<div className="absolute bottom-3 left-3 max-w-64 rounded-lg border border-border bg-white/95 p-3 shadow-sm">
<p className="mb-2 text-label uppercase text-neutral-500">{t('map.legend')}</p>
<ul className="flex flex-col gap-1.5 text-body-xs text-neutral-600">
{[
{ color: '#22c55e', label: t('map.cargo') },
{ color: '#ef4444', label: t('map.tankers') },
{ color: '#3b82f6', label: t('map.passengers') },
{ color: '#eab308', label: t('map.highSpeed') },
].map(item => (
<li key={item.label} className="flex items-center gap-2">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.color }}
/>
{item.label}
</li>
))}
</ul>
</div>
</div>
{/* Map Footer */}
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
<p className="text-xs text-gray-500 flex items-center gap-1">
<ExternalLink className="h-3 w-3" />
{t('map.dataSource')}
</p>
<div className="flex items-center justify-between gap-3 border-t border-border bg-neutral-50 px-4 py-2">
<p className="truncate text-body-xs text-neutral-500">{t('map.dataSource')}</p>
<a
href="https://www.marinetraffic.com"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1"
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"
>
{t('map.openOnSite')}
<ExternalLink className="h-3 w-3" />
<ExternalLink className="size-3" aria-hidden="true" />
</a>
</div>
</Card>
</div>
)}
{/* Search History */}
<Card className="bg-white shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<History className="h-5 w-5 text-gray-600" />
<Card>
<CardHeader className="flex-row items-center justify-between gap-3 pb-3">
<CardTitle className="flex items-center gap-2">
<History className="size-4 text-neutral-400" aria-hidden="true" />
{t('history.title')}
</CardTitle>
{searchHistory.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearHistory}
className="text-gray-500 hover:text-red-600 text-xs"
>
<Button variant="ghost" size="sm" onClick={() => saveHistory([])}>
{t('history.clearAll')}
</Button>
)}
</div>
</CardHeader>
<CardContent>
{searchHistory.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Clock className="h-10 w-10 mx-auto mb-3 text-gray-300" />
<p className="text-sm">{t('history.empty')}</p>
<p className="text-xs text-gray-400 mt-1">{t('history.emptyHint')}</p>
</div>
<EmptyState
size="sm"
icon={Clock}
title={t('history.empty')}
description={t('history.emptyHint')}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<ul className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{searchHistory.map(item => {
const carrier = carriers.find(c => c.id === item.carrierId);
return (
<div
<li
key={item.id}
className="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:bg-gray-50 group cursor-pointer transition-colors"
onClick={() => handleHistoryClick(item)}
className="group flex items-center gap-2 rounded-md border border-border p-2 transition-colors hover:bg-neutral-50"
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded flex items-center justify-center text-xs font-bold ${carrier?.textColor || 'text-white'}`}
style={{ backgroundColor: carrier?.color || '#666' }}
>
{item.carrierName.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="font-mono text-sm font-medium text-gray-900">
{item.trackingNumber}
</p>
<p className="text-xs text-gray-500">
{item.carrierName} • {formatTimeAgo(item.timestamp)}
</p>
</div>
</div>
<button
onClick={e => {
e.stopPropagation();
handleDeleteHistory(item.id);
type="button"
onClick={() => {
setTrackingNumber(item.trackingNumber);
setSelectedCarrier(item.carrierId);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-50 rounded transition-opacity"
className="flex min-w-0 flex-1 items-center gap-2 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="h-4 w-4 text-gray-400 hover:text-red-500" />
<span
aria-hidden="true"
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded text-[10px] font-bold',
carrier?.textColor || 'text-white'
)}
style={{ backgroundColor: carrier?.color || '#10183A' }}
>
{item.carrierName.slice(0, 2).toUpperCase()}
</span>
<span className="min-w-0">
<span className="block truncate font-mono text-body-sm font-medium text-brand-navy">
{item.trackingNumber}
</span>
<span className="block truncate text-body-xs text-neutral-500">
{item.carrierName} · {formatTimeAgo(item.timestamp)}
</span>
</span>
</button>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => saveHistory(searchHistory.filter(h => h.id !== item.id))}
aria-label={`${tCommon('delete')} : ${item.trackingNumber}`}
className="shrink-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover:opacity-100"
>
<X />
</Button>
</li>
);
})}
</div>
</ul>
)}
</CardContent>
</Card>
{/* Help Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Package className="h-5 w-5 text-blue-600" />
{t('help.containerNumber.title')}
<section className="grid gap-4 md:grid-cols-3">
{helpCards.map(({ icon: Icon, key }) => (
<Card key={key}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-h6">
<Icon className="size-4 text-neutral-400" aria-hidden="true" />
{t(`help.${key}.title` as any)}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">{t('help.containerNumber.description')}</p>
<p className="text-body-sm text-neutral-500">
{t(`help.${key}.description` as any)}
</p>
</CardContent>
</Card>
))}
</section>
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-600" />
{t('help.billOfLading.title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">{t('help.billOfLading.description')}</p>
</CardContent>
</Card>
<Card className="bg-white">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ClipboardList className="h-5 w-5 text-blue-600" />
{t('help.bookingRef.title')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600">{t('help.bookingRef.description')}</p>
</CardContent>
</Card>
</div>
{/* Info Box */}
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
<div className="flex items-start gap-3">
<Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-blue-800">{t('infoBox.title')}</p>
<p className="text-sm text-blue-700 mt-1">{t('infoBox.description')}</p>
</div>
</div>
</div>
<Callout variant="info" title={t('infoBox.title')}>
{t('infoBox.description')}
</Callout>
</div>
</>
);
}

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Shield } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function AssurancePage() {
const t = await getTranslations('dashboard.wikiPages');
@ -24,33 +24,18 @@ export default async function AssurancePage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<Shield className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('assurance.title')}</h1>
<Shield className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('assurance.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('assurance.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('assurance.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('assurance.iccTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('assurance.iccTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{clauses.map((clause, i) => (
<Card key={clause.name} className={`border-2 ${clauseColors[i]}`}>
@ -67,7 +52,7 @@ export default async function AssurancePage() {
</h4>
<ul className="text-sm space-y-1">
{clause.includes.map((item, j) => (
<li key={j} className="flex items-start gap-1 text-gray-700">
<li key={j} className="flex items-start gap-1 text-neutral-700">
<span className="text-green-500 mt-0.5">✓</span> {item}
</li>
))}
@ -77,7 +62,7 @@ export default async function AssurancePage() {
<h4 className="text-sm font-semibold text-red-700 mb-1">{t('excludesLabel')}</h4>
<ul className="text-sm space-y-1">
{clause.excludes.map((item, j) => (
<li key={j} className="flex items-start gap-1 text-gray-700">
<li key={j} className="flex items-start gap-1 text-neutral-700">
<span className="text-red-500 mt-0.5">✗</span> {item}
</li>
))}
@ -90,23 +75,23 @@ export default async function AssurancePage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('assurance.extensionsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('assurance.extensionsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{extensions.map(ext => (
<Card key={ext.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{ext.name}</h4>
<p className="text-sm text-gray-600 mt-1">{ext.description}</p>
<h4 className="font-semibold text-brand-navy">{ext.name}</h4>
<p className="text-sm text-neutral-600 mt-1">{ext.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-gray-50">
<Card className="mt-8 bg-neutral-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('assurance.processTitle')}</h3>
<ol className="list-decimal list-inside space-y-2 text-gray-700">
<h3 className="font-semibold text-brand-navy mb-3">{t('assurance.processTitle')}</h3>
<ol className="list-decimal list-inside space-y-2 text-neutral-700">
{processSteps.map((step, i) => (
<li key={i}>{step}</li>
))}
@ -114,13 +99,13 @@ export default async function AssurancePage() {
</CardContent>
</Card>
<Card className="mt-4 bg-blue-50 border-blue-200">
<Card className="mt-4 bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('assurance.valueTitle')}</h3>
<p className="font-mono text-blue-800 bg-white p-3 rounded border text-sm">
<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">
{t('assurance.valueFormula')}
</p>
<p className="text-sm text-blue-700 mt-2">{t('assurance.valueNote')}</p>
<p className="text-sm text-brand-navy mt-2">{t('assurance.valueNote')}</p>
</CardContent>
</Card>
</div>

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Calculator } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function CalculFretPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -21,36 +21,21 @@ export default async function CalculFretPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<Calculator className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('calculFret.title')}</h1>
<Calculator className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('calculFret.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('calculFret.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('calculFret.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('calculFret.surchargesTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('calculFret.surchargesTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<thead className="bg-neutral-50">
<tr>
<th className="text-left p-3 font-medium">{t('calculFret.colCode')}</th>
<th className="text-left p-3 font-medium">{t('calculFret.colName')}</th>
@ -60,11 +45,11 @@ export default async function CalculFretPage() {
</thead>
<tbody>
{surcharges.map(s => (
<tr key={s.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{s.code}</td>
<tr key={s.code} className="border-t hover:bg-neutral-50">
<td className="p-3 font-mono font-bold text-brand-navy">{s.code}</td>
<td className="p-3 font-medium">{s.name}</td>
<td className="p-3 text-gray-600">{s.description}</td>
<td className="p-3 text-gray-500 text-xs">{s.variation}</td>
<td className="p-3 text-neutral-600">{s.description}</td>
<td className="p-3 text-neutral-500 text-xs">{s.variation}</td>
</tr>
))}
</tbody>
@ -73,16 +58,16 @@ export default async function CalculFretPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<h2 className="text-xl font-bold text-brand-navy mb-4">
{t('calculFret.additionalCostsTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{additionalCosts.map(cost => (
<Card key={cost.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{cost.name}</h4>
<p className="text-sm text-gray-600 mt-1">{cost.description}</p>
<p className="text-xs text-gray-400 mt-2">
<h4 className="font-semibold text-brand-navy">{cost.name}</h4>
<p className="text-sm text-neutral-600 mt-1">{cost.description}</p>
<p className="text-xs text-neutral-400 mt-2">
{t('calculFret.colCost')}: {cost.typical}
</p>
</CardContent>
@ -91,9 +76,9 @@ export default async function CalculFretPage() {
</div>
</div>
<Card className="mt-8 bg-gray-50">
<Card className="mt-8 bg-neutral-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-4">{t('calculFret.exampleTitle')}</h3>
<h3 className="font-semibold text-brand-navy mb-4">{t('calculFret.exampleTitle')}</h3>
<table className="w-full text-sm">
<thead>
<tr className="border-b">
@ -105,7 +90,7 @@ export default async function CalculFretPage() {
{exampleItems.map((item, i) => (
<tr
key={i}
className={`border-b last:border-0 ${i === exampleItems.length - 1 ? 'font-bold text-blue-700' : ''}`}
className={`border-b last:border-0 ${i === exampleItems.length - 1 ? 'font-bold text-brand-navy' : ''}`}
>
<td className="py-2">{item.item}</td>
<td className="py-2 text-right font-mono">{item.amount}</td>

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Package } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function ConteneursPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -25,36 +25,21 @@ export default async function ConteneursPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<Package className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('conteneurs.title')}</h1>
<Package className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('conteneurs.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('conteneurs.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('conteneurs.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('conteneurs.standardTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('conteneurs.standardTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<thead className="bg-neutral-50">
<tr>
<th className="text-left p-3 font-medium">{t('conteneurs.colDimensions')}</th>
<th className="text-left p-3 font-medium">{t('conteneurs.colInternal')}</th>
@ -65,14 +50,14 @@ export default async function ConteneursPage() {
</thead>
<tbody>
{containers.map(c => (
<tr key={c.type} className="border-t hover:bg-gray-50">
<tr key={c.type} className="border-t hover:bg-neutral-50">
<td className="p-3">
<div className="font-semibold text-gray-900">{c.type}</div>
<div className="text-xs text-gray-500">{c.description}</div>
<div className="font-semibold text-brand-navy">{c.type}</div>
<div className="text-xs text-neutral-500">{c.description}</div>
</td>
<td className="p-3 font-mono text-xs">{c.internal}</td>
<td className="p-3 font-mono text-xs">{c.door}</td>
<td className="p-3 font-mono text-xs text-blue-600">{c.volume}</td>
<td className="p-3 font-mono text-xs text-brand-navy">{c.volume}</td>
<td className="p-3 font-mono text-xs">{c.payload}</td>
</tr>
))}
@ -82,40 +67,40 @@ export default async function ConteneursPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<h2 className="text-xl font-bold text-brand-navy mb-4">
{t('conteneurs.specialEquipmentTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{specialEquipment.map(eq => (
<Card key={eq.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{eq.name}</h4>
<p className="text-sm text-gray-600 mt-1">{eq.description}</p>
<h4 className="font-semibold text-brand-navy">{eq.name}</h4>
<p className="text-sm text-neutral-600 mt-1">{eq.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-blue-50 border-blue-200">
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 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">
<thead>
<tr className="border-b border-blue-200">
<th className="text-left py-2 font-medium text-blue-800">
<tr className="border-b border-brand-turquoise/30">
<th className="text-left py-2 font-medium text-brand-navy">
{t('conteneurs.colCondition')}
</th>
<th className="text-left py-2 font-medium text-blue-800">
<th className="text-left py-2 font-medium text-brand-navy">
{t('conteneurs.colRecommendation')}
</th>
</tr>
</thead>
<tbody>
{selectionGuide.map((row, i) => (
<tr key={i} className="border-b border-blue-100 last:border-0">
<td className="py-2 text-blue-700">{row.condition}</td>
<td className="py-2 font-medium text-blue-900">{row.recommendation}</td>
<tr key={i} className="border-b border-brand-turquoise/20 last:border-0">
<td className="py-2 text-brand-navy">{row.condition}</td>
<td className="py-2 font-medium text-brand-navy">{row.recommendation}</td>
</tr>
))}
</tbody>

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { ClipboardList } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function DocumentsTransportPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -23,33 +23,18 @@ export default async function DocumentsTransportPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<ClipboardList className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('documentsTransport.title')}</h1>
<ClipboardList className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('documentsTransport.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('documentsTransport.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('documentsTransport.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<h2 className="text-xl font-bold text-brand-navy mb-4">
{t('documentsTransport.mainDocumentsTitle')}
</h2>
<div className="space-y-4">
@ -59,17 +44,17 @@ export default async function DocumentsTransportPage() {
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded">
<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">
{doc.type}
</span>
</div>
<p className="text-sm text-gray-600 mb-2">{doc.description}</p>
<p className="text-sm text-neutral-600 mb-2">{doc.description}</p>
<div className="flex flex-wrap gap-1">
{doc.types.map((type, j) => (
<span
key={j}
className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded"
className="px-2 py-0.5 text-xs bg-neutral-100 text-neutral-600 rounded"
>
{type}
</span>
@ -84,31 +69,31 @@ export default async function DocumentsTransportPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<h2 className="text-xl font-bold text-brand-navy mb-4">
{t('documentsTransport.additionalDocsTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{additionalDocs.map(doc => (
<Card key={doc.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
<p className="text-sm text-gray-600 mt-1">{doc.description}</p>
<h4 className="font-semibold text-brand-navy">{doc.name}</h4>
<p className="text-sm text-neutral-600 mt-1">{doc.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
<Card className="mt-8 bg-blue-50 border-blue-200">
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-4">
<h3 className="font-semibold text-brand-navy mb-4">
{t('documentsTransport.blFocusTitle')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{blFunctions.map(fn => (
<div key={fn.title} className="bg-white p-4 rounded-lg border border-blue-200">
<h4 className="font-medium text-blue-800 mb-1">{fn.title}</h4>
<p className="text-sm text-gray-600">{fn.description}</p>
<div key={fn.title} className="bg-white p-4 rounded-lg border border-brand-turquoise/30">
<h4 className="font-medium text-brand-navy mb-1">{fn.title}</h4>
<p className="text-sm text-neutral-600">{fn.description}</p>
</div>
))}
</div>

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { ShieldCheck } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function DouanesPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -20,36 +20,21 @@ export default async function DouanesPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<ShieldCheck className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('douanes.title')}</h1>
<ShieldCheck className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('douanes.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('douanes.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('douanes.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.regimesTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('douanes.regimesTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<thead className="bg-neutral-50">
<tr>
<th className="text-left p-3 font-medium">{t('douanes.colCode')}</th>
<th className="text-left p-3 font-medium">{t('douanes.colName')}</th>
@ -58,10 +43,10 @@ export default async function DouanesPage() {
</thead>
<tbody>
{regimes.map(r => (
<tr key={r.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{r.code}</td>
<tr key={r.code} className="border-t hover:bg-neutral-50">
<td className="p-3 font-mono font-bold text-brand-navy">{r.code}</td>
<td className="p-3 font-medium">{r.name}</td>
<td className="p-3 text-gray-600">{r.description}</td>
<td className="p-3 text-neutral-600">{r.description}</td>
</tr>
))}
</tbody>
@ -70,20 +55,20 @@ export default async function DouanesPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.documentsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('douanes.documentsTitle')}</h2>
<div className="space-y-3">
{documents.map(doc => (
<Card key={doc.name} className="bg-white">
<CardContent className="py-3">
<div className="flex items-center gap-3">
<span
className={`px-2 py-0.5 text-xs rounded font-medium ${doc.mandatory ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'}`}
className={`px-2 py-0.5 text-xs rounded font-medium ${doc.mandatory ? 'bg-red-100 text-red-700' : 'bg-neutral-100 text-neutral-600'}`}
>
{doc.mandatory ? t('mandatoryLabel') : t('optionalLabel')}
</span>
<div>
<span className="font-medium text-gray-900">{doc.name}</span>
<span className="text-sm text-gray-500 ml-2">— {doc.description}</span>
<span className="font-medium text-brand-navy">{doc.name}</span>
<span className="text-sm text-neutral-500 ml-2">— {doc.description}</span>
</div>
</div>
</CardContent>
@ -93,13 +78,13 @@ export default async function DouanesPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('douanes.dutiesTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('douanes.dutiesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{duties.map(d => (
<Card key={d.type} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900 mb-2">{d.type}</h4>
<p className="text-sm text-gray-600">{d.description}</p>
<h4 className="font-semibold text-brand-navy mb-2">{d.type}</h4>
<p className="text-sm text-neutral-600">{d.description}</p>
</CardContent>
</Card>
))}

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { AlertTriangle } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function ImdgPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -20,33 +20,18 @@ export default async function ImdgPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<AlertTriangle className="w-10 h-10 text-orange-500" />
<h1 className="text-3xl font-bold text-gray-900">{t('imdg.title')}</h1>
<h1 className="text-3xl font-bold text-brand-navy">{t('imdg.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('imdg.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('imdg.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.classesTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('imdg.classesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{classes.map(cls => (
<Card key={cls.class} className="bg-white border-orange-200">
@ -55,15 +40,15 @@ export default async function ImdgPage() {
<span className="px-2 py-0.5 bg-orange-100 text-orange-800 text-xs font-bold rounded">
{cls.class}
</span>
<h4 className="font-semibold text-gray-900">{cls.name}</h4>
<h4 className="font-semibold text-brand-navy">{cls.name}</h4>
</div>
<p className="text-sm text-gray-600">{cls.description}</p>
<p className="text-sm text-neutral-600">{cls.description}</p>
{cls.subdivisions && (
<div className="mt-2">
<p className="text-xs font-medium text-gray-500 mb-1">
<p className="text-xs font-medium text-neutral-500 mb-1">
{t('imdg.subdivisionsLabel')}
</p>
<ul className="text-xs text-gray-600 space-y-0.5">
<ul className="text-xs text-neutral-600 space-y-0.5">
{cls.subdivisions.map((sub, j) => (
<li key={j}>• {sub}</li>
))}
@ -77,13 +62,13 @@ export default async function ImdgPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.documentsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('imdg.documentsTitle')}</h2>
<div className="space-y-3">
{documents.map(doc => (
<Card key={doc.name} className="bg-white">
<CardContent className="py-3">
<h4 className="font-semibold text-gray-900">{doc.name}</h4>
<p className="text-sm text-gray-600 mt-1">{doc.description}</p>
<h4 className="font-semibold text-brand-navy">{doc.name}</h4>
<p className="text-sm text-neutral-600 mt-1">{doc.description}</p>
</CardContent>
</Card>
))}
@ -91,7 +76,7 @@ export default async function ImdgPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('imdg.packagingGroupsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('imdg.packagingGroupsTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{packagingGroups.map((pg, i) => {
const colors = [
@ -102,8 +87,8 @@ export default async function ImdgPage() {
return (
<Card key={pg.group} className={`border ${colors[i]}`}>
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{pg.group}</h4>
<p className="text-sm text-gray-600 mt-1">{pg.description}</p>
<h4 className="font-semibold text-brand-navy">{pg.group}</h4>
<p className="text-sm text-neutral-600 mt-1">{pg.description}</p>
</CardContent>
</Card>
);
@ -111,17 +96,17 @@ export default async function ImdgPage() {
</div>
</div>
<Card className="mt-8 bg-blue-50 border-blue-200">
<Card className="mt-8 bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('imdg.labelingTitle')}</h3>
<p className="text-sm text-blue-800">{t('imdg.labelingContent')}</p>
<h3 className="font-semibold text-brand-navy mb-2">{t('imdg.labelingTitle')}</h3>
<p className="text-sm text-brand-navy">{t('imdg.labelingContent')}</p>
</CardContent>
</Card>
<Card className="mt-4 bg-gray-50">
<Card className="mt-4 bg-neutral-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-2">{t('imdg.segregationTitle')}</h3>
<p className="text-sm text-gray-700">{t('imdg.segregationContent')}</p>
<h3 className="font-semibold text-brand-navy mb-2">{t('imdg.segregationTitle')}</h3>
<p className="text-sm text-neutral-700">{t('imdg.segregationContent')}</p>
</CardContent>
</Card>
</div>

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { ScrollText } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function IncotermsPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -24,43 +24,28 @@ export default async function IncotermsPage() {
const categoryColors = [
'bg-green-100 text-green-800',
'bg-red-100 text-red-800',
'bg-blue-100 text-blue-800',
'bg-brand-turquoise/10 text-brand-navy',
];
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<ScrollText className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('incoterms.title')}</h1>
<ScrollText className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('incoterms.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('incoterms.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('incoterms.description')}</p>
</div>
<Card className="bg-blue-50 border-blue-200">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('incoterms.keyPointsTitle')}</h3>
<h3 className="font-semibold text-brand-navy mb-3">{t('incoterms.keyPointsTitle')}</h3>
<ul className="space-y-2">
{keyPoints.map((point, i) => (
<li key={i} className="flex items-start gap-2 text-blue-800">
<span className="mt-1 w-2 h-2 bg-blue-600 rounded-full flex-shrink-0" />
<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="text-sm">{point}</span>
</li>
))}
@ -69,13 +54,13 @@ export default async function IncotermsPage() {
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('incoterms.categoriesTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('incoterms.categoriesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{categorySections.map((cat, i) => (
<Card key={i} className="bg-white">
<CardContent className="pt-4">
<h3 className="font-semibold text-gray-900">{cat.name}</h3>
<p className="text-sm text-gray-600 mt-1">{cat.description}</p>
<h3 className="font-semibold text-brand-navy">{cat.name}</h3>
<p className="text-sm text-neutral-600 mt-1">{cat.description}</p>
<div className="flex flex-wrap gap-1 mt-3">
{cat.terms.map(term => (
<span
@ -93,10 +78,10 @@ export default async function IncotermsPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('incoterms.tableTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('incoterms.tableTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<thead className="bg-neutral-50">
<tr>
<th className="text-left p-3 font-medium">{t('incoterms.colCode')}</th>
<th className="text-left p-3 font-medium">{t('incoterms.colName')}</th>
@ -107,12 +92,12 @@ export default async function IncotermsPage() {
</thead>
<tbody>
{list.map(item => (
<tr key={item.code} className="border-t hover:bg-gray-50">
<td className="p-3 font-mono font-bold text-blue-600">{item.code}</td>
<tr key={item.code} className="border-t hover:bg-neutral-50">
<td className="p-3 font-mono font-bold text-brand-navy">{item.code}</td>
<td className="p-3 font-medium">{item.name}</td>
<td className="p-3 text-gray-600">{item.description}</td>
<td className="p-3 text-gray-500">{item.risk}</td>
<td className="p-3 text-gray-500">{item.transport}</td>
<td className="p-3 text-neutral-600">{item.description}</td>
<td className="p-3 text-neutral-500">{item.risk}</td>
<td className="p-3 text-neutral-500">{item.transport}</td>
</tr>
))}
</tbody>

View File

@ -0,0 +1,13 @@
/**
* Enveloppe de la section Wiki.
*
* Contraint la largeur de lecture : le shell applicatif va jusqu'a 1600px, ce
* qui convient aux vues de donnees mais produit des lignes de texte beaucoup
* trop longues pour du contenu redactionnel.
*
* 1024px accueille encore la grille de cartes de l'index sur trois colonnes,
* tout en gardant une longueur de ligne lisible sur les pages d'article.
*/
export default function WikiLayout({ children }: { children: React.ReactNode }) {
return <div className="mx-auto w-full max-w-5xl">{children}</div>;
}

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Scale } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function LclVsFclPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -21,36 +21,21 @@ export default async function LclVsFclPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<Scale className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('lclVsFcl.title')}</h1>
<Scale className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('lclVsFcl.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('lclVsFcl.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('lclVsFcl.description')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="bg-blue-50 border-blue-200">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="text-lg font-bold text-blue-900 mb-2">{t('lclVsFcl.lclTitle')}</h3>
<p className="text-sm text-blue-800">{t('lclVsFcl.lclDescription')}</p>
<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>
</CardContent>
</Card>
<Card className="bg-green-50 border-green-200">
@ -62,22 +47,22 @@ export default async function LclVsFclPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.comparisonTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('lclVsFcl.comparisonTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<thead className="bg-neutral-50">
<tr>
<th className="text-left p-3 font-medium">{t('lclVsFcl.colCriterion')}</th>
<th className="text-left p-3 font-medium text-blue-600">{t('lclVsFcl.colLcl')}</th>
<th className="text-left p-3 font-medium text-brand-navy">{t('lclVsFcl.colLcl')}</th>
<th className="text-left p-3 font-medium text-green-600">{t('lclVsFcl.colFcl')}</th>
</tr>
</thead>
<tbody>
{criteria.map(row => (
<tr key={row.criterion} className="border-t hover:bg-gray-50">
<td className="p-3 font-medium text-gray-900">{row.criterion}</td>
<td className="p-3 text-gray-600">{row.lcl}</td>
<td className="p-3 text-gray-600">{row.fcl}</td>
<tr key={row.criterion} className="border-t hover:bg-neutral-50">
<td className="p-3 font-medium text-brand-navy">{row.criterion}</td>
<td className="p-3 text-neutral-600">{row.lcl}</td>
<td className="p-3 text-neutral-600">{row.fcl}</td>
</tr>
))}
</tbody>
@ -86,16 +71,16 @@ export default async function LclVsFclPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lclVsFcl.lclProcessTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('lclVsFcl.lclProcessTitle')}</h2>
<div className="space-y-3">
{lclProcess.map(step => (
<div key={step.step} className="flex items-start gap-4 bg-white p-4 rounded-lg border">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
<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">
{step.step}
</div>
<div>
<h4 className="font-medium text-gray-900">{step.title}</h4>
<p className="text-sm text-gray-600">{step.description}</p>
<h4 className="font-medium text-brand-navy">{step.title}</h4>
<p className="text-sm text-neutral-600">{step.description}</p>
</div>
</div>
))}
@ -103,13 +88,13 @@ export default async function LclVsFclPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<Card className="bg-blue-50 border-blue-200">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('lclVsFcl.chooseLclTitle')}</h3>
<h3 className="font-semibold text-brand-navy mb-3">{t('lclVsFcl.chooseLclTitle')}</h3>
<ul className="space-y-2">
{chooseLcl.map((item, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-blue-800">
<span className="text-blue-500 mt-0.5">✓</span> {item}
<li key={i} className="flex items-start gap-2 text-sm text-brand-navy">
<span className="text-brand-turquoise mt-0.5">✓</span> {item}
</li>
))}
</ul>

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { CreditCard } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function LettreCreditPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -21,39 +21,24 @@ export default async function LettreCreditPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<CreditCard className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('lettreCredit.title')}</h1>
<CreditCard className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('lettreCredit.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('lettreCredit.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('lettreCredit.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.typesTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('lettreCredit.typesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{types.map(type => (
<Card key={type.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900">{type.name}</h4>
<p className="text-sm text-gray-600 mt-1">{type.description}</p>
<h4 className="font-semibold text-brand-navy">{type.name}</h4>
<p className="text-sm text-neutral-600 mt-1">{type.description}</p>
</CardContent>
</Card>
))}
@ -61,10 +46,10 @@ export default async function LettreCreditPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.partiesTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('lettreCredit.partiesTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<thead className="bg-neutral-50">
<tr>
<th className="text-left p-3 font-medium">{t('lettreCredit.colRole')}</th>
<th className="text-left p-3 font-medium">Description</th>
@ -72,9 +57,9 @@ export default async function LettreCreditPage() {
</thead>
<tbody>
{parties.map(p => (
<tr key={p.role} className="border-t hover:bg-gray-50">
<td className="p-3 font-medium text-blue-700">{p.role}</td>
<td className="p-3 text-gray-600">{p.description}</td>
<tr key={p.role} className="border-t hover:bg-neutral-50">
<td className="p-3 font-medium text-brand-navy">{p.role}</td>
<td className="p-3 text-neutral-600">{p.description}</td>
</tr>
))}
</tbody>
@ -83,14 +68,14 @@ export default async function LettreCreditPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('lettreCredit.documentsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('lettreCredit.documentsTitle')}</h2>
<div className="space-y-3">
{documents.map(doc => (
<div key={doc.name} className="flex items-start gap-3 bg-white p-4 rounded-lg border">
<span className="text-green-500 mt-0.5 flex-shrink-0">✓</span>
<div>
<span className="font-medium text-gray-900">{doc.name}</span>
<span className="text-sm text-gray-500 ml-2">— {doc.description}</span>
<span className="font-medium text-brand-navy">{doc.name}</span>
<span className="text-sm text-neutral-500 ml-2">— {doc.description}</span>
</div>
</div>
))}
@ -110,22 +95,22 @@ export default async function LettreCreditPage() {
</CardContent>
</Card>
<Card className="mt-4 bg-blue-50 border-blue-200">
<Card className="mt-4 bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('lettreCredit.ucp600Title')}</h3>
<p className="text-sm text-blue-800">{t('lettreCredit.ucp600Content')}</p>
<h3 className="font-semibold text-brand-navy mb-2">{t('lettreCredit.ucp600Title')}</h3>
<p className="text-sm text-brand-navy">{t('lettreCredit.ucp600Content')}</p>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<Card className="bg-white">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.datesTitle')}</h3>
<h3 className="font-semibold text-brand-navy mb-3">{t('lettreCredit.datesTitle')}</h3>
<div className="space-y-3">
{datesItems.map(item => (
<div key={item.label}>
<span className="font-medium text-gray-800 text-sm">{item.label}</span>
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
<span className="font-medium text-neutral-800 text-sm">{item.label}</span>
<p className="text-xs text-neutral-600 mt-0.5">{item.description}</p>
</div>
))}
</div>
@ -133,12 +118,12 @@ export default async function LettreCreditPage() {
</Card>
<Card className="bg-white">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('lettreCredit.costsTitle')}</h3>
<h3 className="font-semibold text-brand-navy mb-3">{t('lettreCredit.costsTitle')}</h3>
<div className="space-y-3">
{costsItems.map(item => (
<div key={item.label}>
<span className="font-medium text-gray-800 text-sm">{item.label}</span>
<p className="text-xs text-gray-600 mt-0.5">{item.description}</p>
<span className="font-medium text-neutral-800 text-sm">{item.label}</span>
<p className="text-xs text-neutral-600 mt-0.5">{item.description}</p>
</div>
))}
</div>

View File

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

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Globe } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function PortsRoutesPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -29,56 +29,41 @@ export default async function PortsRoutesPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<Globe className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('portsRoutes.title')}</h1>
<Globe className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('portsRoutes.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('portsRoutes.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('portsRoutes.description')}</p>
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<h2 className="text-xl font-bold text-brand-navy mb-4">
{t('portsRoutes.majorRoutesTitle')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{routes.map(route => (
<Card key={route.name} className="bg-white">
<CardContent className="pt-4">
<h4 className="font-semibold text-gray-900 mb-1">{route.name}</h4>
<p className="text-sm text-gray-600 mb-3">{route.description}</p>
<h4 className="font-semibold text-brand-navy mb-1">{route.name}</h4>
<p className="text-sm text-neutral-600 mb-3">{route.description}</p>
<div className="flex items-center gap-4 text-sm">
<span className="text-gray-500">
<span className="text-neutral-500">
{t('portsRoutes.colVia')}:{' '}
<strong className="text-gray-800">{route.via}</strong>
<strong className="text-neutral-800">{route.via}</strong>
</span>
<span className="text-gray-500">
<span className="text-neutral-500">
{t('portsRoutes.colTransit')}:{' '}
<strong className="text-blue-600">{route.transitTime}</strong>
<strong className="text-brand-navy">{route.transitTime}</strong>
</span>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{route.majorPorts.map(port => (
<span
key={port}
className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded"
className="px-2 py-0.5 bg-brand-turquoise/5 text-brand-navy text-xs rounded"
>
{port}
</span>
@ -91,23 +76,23 @@ export default async function PortsRoutesPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 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">
{passages.map(p => (
<Card key={p.name} className="bg-white border-blue-200">
<Card key={p.name} className="bg-white border-brand-turquoise/30">
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-semibold text-gray-900">{p.name}</h4>
<p className="text-sm text-gray-500">
<h4 className="font-semibold text-brand-navy">{p.name}</h4>
<p className="text-sm text-neutral-500">
{p.location} — {t('portsRoutes.colLength')}: {p.length}
</p>
</div>
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded font-medium whitespace-nowrap">
<span className="px-2 py-1 bg-brand-turquoise/10 text-brand-navy text-xs rounded font-medium whitespace-nowrap">
{p.keyStat}
</span>
</div>
<p className="text-sm text-gray-600">{p.description}</p>
<p className="text-sm text-neutral-600">{p.description}</p>
</CardContent>
</Card>
))}
@ -115,10 +100,10 @@ export default async function PortsRoutesPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('portsRoutes.portsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('portsRoutes.portsTitle')}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm bg-white rounded-lg border">
<thead className="bg-gray-50">
<thead className="bg-neutral-50">
<tr>
<th className="text-left p-3 font-medium">{t('portsRoutes.colRank')}</th>
<th className="text-left p-3 font-medium">{t('portsRoutes.colPort')}</th>
@ -128,11 +113,11 @@ export default async function PortsRoutesPage() {
</thead>
<tbody>
{ports.map(port => (
<tr key={port.rank} className="border-t hover:bg-gray-50">
<td className="p-3 font-bold text-gray-400">#{port.rank}</td>
<td className="p-3 font-medium text-gray-900">{port.port}</td>
<td className="p-3 text-gray-600">{port.country}</td>
<td className="p-3 text-right font-mono text-blue-600">{port.teu}</td>
<tr key={port.rank} className="border-t hover:bg-neutral-50">
<td className="p-3 font-bold text-neutral-400">#{port.rank}</td>
<td className="p-3 font-medium text-brand-navy">{port.port}</td>
<td className="p-3 text-neutral-600">{port.country}</td>
<td className="p-3 text-right font-mono text-brand-navy">{port.teu}</td>
</tr>
))}
</tbody>
@ -141,10 +126,10 @@ export default async function PortsRoutesPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<Card className="bg-blue-50 border-blue-200">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-2">{t('portsRoutes.hubTitle')}</h3>
<p className="text-sm text-blue-800">{t('portsRoutes.hubDescription')}</p>
<h3 className="font-semibold text-brand-navy mb-2">{t('portsRoutes.hubTitle')}</h3>
<p className="text-sm text-brand-navy">{t('portsRoutes.hubDescription')}</p>
</CardContent>
</Card>
<Card className="bg-green-50 border-green-200">

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Clock } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function TransitTimePage() {
const t = await getTranslations('dashboard.wikiPages');
@ -37,39 +37,24 @@ export default async function TransitTimePage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<Clock className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('transitTime.title')}</h1>
<Clock className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('transitTime.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('transitTime.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('transitTime.description')}</p>
</div>
<Card className="bg-blue-50 border-blue-200">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 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">
{keyTerms.map(term => (
<div key={term.key}>
<h4 className="font-medium text-blue-800">{term.key}</h4>
<p className="text-sm text-blue-700">{term.def}</p>
<h4 className="font-medium text-brand-navy">{term.key}</h4>
<p className="text-sm text-brand-navy">{term.def}</p>
</div>
))}
</div>
@ -77,27 +62,27 @@ export default async function TransitTimePage() {
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.timelineTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('transitTime.timelineTitle')}</h2>
<div className="space-y-3">
{timeline.map((item, index) => (
<Card
key={index}
className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}
className={`bg-white ${index === 5 || index === 7 ? 'border-brand-turquoise' : ''}`}
>
<CardContent className="py-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold text-sm">
<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">
{index + 1}
</div>
<div className="flex-1">
<div className="flex flex-wrap items-center gap-2 mb-1">
<h4 className="font-medium text-gray-900">{item.step}</h4>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">
<h4 className="font-medium text-brand-navy">{item.step}</h4>
<span className="text-xs bg-neutral-100 text-neutral-600 px-2 py-0.5 rounded">
{item.delay}
</span>
</div>
<p className="text-sm text-gray-600">{item.description}</p>
<p className="text-xs text-gray-400 mt-1">
<p className="text-sm text-neutral-600">{item.description}</p>
<p className="text-xs text-neutral-400 mt-1">
{t('transitTime.responsibleLabel')} : {item.responsible}
</p>
</div>
@ -109,7 +94,7 @@ export default async function TransitTimePage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">
<h2 className="text-xl font-bold text-brand-navy mb-4">
{t('transitTime.transitTimesTitle')}
</h2>
<Card className="bg-white">
@ -125,57 +110,57 @@ export default async function TransitTimePage() {
</thead>
<tbody>
{transitTimes.map(tt => (
<tr key={tt.route} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-3 text-gray-900">{tt.route}</td>
<td className="py-3 text-center font-mono text-blue-600">{tt.time}</td>
<td className="py-3 text-right text-gray-500">{tt.via}</td>
<tr key={tt.route} className="border-b last:border-0 hover:bg-neutral-50">
<td className="py-3 text-brand-navy">{tt.route}</td>
<td className="py-3 text-center font-mono text-brand-navy">{tt.time}</td>
<td className="py-3 text-right text-neutral-500">{tt.via}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-3">{t('transitTime.transitNote')}</p>
<p className="text-xs text-neutral-500 mt-3">{t('transitTime.transitNote')}</p>
</CardContent>
</Card>
</div>
<Card className="mt-8 bg-gray-50">
<Card className="mt-8 bg-neutral-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('transitTime.freeTimeTitle')}</h3>
<p className="text-gray-600 mb-4">{t('transitTime.freeTimeDescription')}</p>
<h3 className="font-semibold text-brand-navy mb-3">{t('transitTime.freeTimeTitle')}</h3>
<p className="text-neutral-600 mb-4">{t('transitTime.freeTimeDescription')}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.freeTimeStandard')}</h4>
<p className="text-2xl font-bold text-blue-600">{t('transitTime.freeTimeValue')}</p>
<p className="text-xs text-gray-500">{t('transitTime.freeTimeNote')}</p>
<h4 className="font-medium text-brand-navy">{t('transitTime.freeTimeStandard')}</h4>
<p className="text-2xl font-bold text-brand-navy">{t('transitTime.freeTimeValue')}</p>
<p className="text-xs text-neutral-500">{t('transitTime.freeTimeNote')}</p>
</div>
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.demurrageStart')}</h4>
<p className="text-sm text-gray-600">{t('transitTime.demurrageStartDesc')}</p>
<h4 className="font-medium text-brand-navy">{t('transitTime.demurrageStart')}</h4>
<p className="text-sm text-neutral-600">{t('transitTime.demurrageStartDesc')}</p>
</div>
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.detentionStart')}</h4>
<p className="text-sm text-gray-600">{t('transitTime.detentionStartDesc')}</p>
<h4 className="font-medium text-brand-navy">{t('transitTime.detentionStart')}</h4>
<p className="text-sm text-neutral-600">{t('transitTime.detentionStartDesc')}</p>
</div>
</div>
</CardContent>
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('transitTime.lateFeesTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('transitTime.lateFeesTitle')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{lateFees.map(fee => (
<Card key={fee.name} className="bg-white border-red-200">
<CardContent className="pt-4">
<h4 className="text-lg font-semibold text-red-700 mb-1">{fee.name}</h4>
<p className="text-gray-600 text-sm mb-2">{fee.definition}</p>
<p className="text-neutral-600 text-sm mb-2">{fee.definition}</p>
<div className="flex justify-between text-sm">
<span className="text-gray-500">{t('transitTime.colRate')} :</span>
<span className="text-neutral-500">{t('transitTime.colRate')} :</span>
<span className="font-mono text-red-600">{fee.rate}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-500">{t('transitTime.colLocation')} :</span>
<span className="text-gray-700">{fee.location}</span>
<span className="text-neutral-500">{t('transitTime.colLocation')} :</span>
<span className="text-neutral-700">{fee.location}</span>
</div>
</CardContent>
</Card>
@ -213,18 +198,18 @@ export default async function TransitTimePage() {
</CardContent>
</Card>
<Card className="mt-4 bg-gray-50">
<Card className="mt-4 bg-neutral-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('transitTime.rolloverTitle')}</h3>
<p className="text-gray-600 mb-3">{t('transitTime.rolloverDescription')}</p>
<h3 className="font-semibold text-brand-navy mb-3">{t('transitTime.rolloverTitle')}</h3>
<p className="text-neutral-600 mb-3">{t('transitTime.rolloverDescription')}</p>
<div className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{t('transitTime.rolloverCausesTitle')}</h4>
<ul className="text-sm text-gray-600 mt-2 space-y-1">
<h4 className="font-medium text-brand-navy">{t('transitTime.rolloverCausesTitle')}</h4>
<ul className="text-sm text-neutral-600 mt-2 space-y-1">
{rolloverCauses.map((cause, i) => (
<li key={i}>• {cause}</li>
))}
</ul>
<p className="text-xs text-gray-500 mt-3">{t('transitTime.rolloverImpact')}</p>
<p className="text-xs text-neutral-500 mt-3">{t('transitTime.rolloverImpact')}</p>
</div>
</CardContent>
</Card>

View File

@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Anchor } from 'lucide-react';
import { WikiBackLink } from '@/components/wiki/WikiBackLink';
export default async function VGMPage() {
const t = await getTranslations('dashboard.wikiPages');
@ -29,35 +29,20 @@ export default async function VGMPage() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4 mb-6">
<Link
href="/dashboard/wiki"
className="flex items-center text-blue-600 hover:text-blue-800 transition-colors"
>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
{t('backToWiki')}
</Link>
</div>
<WikiBackLink />
<div className="mb-8">
<div className="flex items-center gap-3">
<Anchor className="w-10 h-10 text-blue-600" />
<h1 className="text-3xl font-bold text-gray-900">{t('vgm.title')}</h1>
<Anchor className="w-10 h-10 text-brand-navy" />
<h1 className="text-3xl font-bold text-brand-navy">{t('vgm.title')}</h1>
</div>
<p className="mt-3 text-gray-600 max-w-3xl">{t('vgm.description')}</p>
<p className="mt-3 text-neutral-600 max-w-3xl">{t('vgm.description')}</p>
</div>
<Card className="bg-blue-50 border-blue-200">
<Card className="bg-brand-turquoise/5 border-brand-turquoise/30">
<CardContent className="pt-6">
<h3 className="font-semibold text-blue-900 mb-3">{t('vgm.whyTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
<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">
{why.map(item => (
<div key={item.title}>
<h4 className="font-medium">{item.title}</h4>
@ -69,11 +54,11 @@ export default async function VGMPage() {
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.componentsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('vgm.componentsTitle')}</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="bg-gray-50 p-4 rounded-lg border mb-4 text-center font-mono text-lg">
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
<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">
{t('vgm.formula')}
</span>
</div>
@ -84,10 +69,10 @@ export default async function VGMPage() {
className="flex items-center justify-between py-3 border-b last:border-0"
>
<div>
<h4 className="font-medium text-gray-900">{item.element}</h4>
<p className="text-sm text-gray-600">{item.description}</p>
<h4 className="font-medium text-brand-navy">{item.element}</h4>
<p className="text-sm text-neutral-600">{item.description}</p>
</div>
<span className="text-sm font-mono bg-gray-100 px-3 py-1 rounded ml-4 flex-shrink-0">
<span className="text-sm font-mono bg-neutral-100 px-3 py-1 rounded ml-4 flex-shrink-0">
{item.example}
</span>
</div>
@ -98,7 +83,7 @@ export default async function VGMPage() {
</div>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.methodsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('vgm.methodsTitle')}</h2>
<div className="space-y-4">
{methods.map(method => (
<Card key={method.method} className="bg-white">
@ -111,11 +96,11 @@ export default async function VGMPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 mb-4">{method.description}</p>
<p className="text-neutral-600 mb-4">{method.description}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h4 className="font-medium text-gray-700 mb-2">{t('vgm.processLabel')}</h4>
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
<h4 className="font-medium text-neutral-700 mb-2">{t('vgm.processLabel')}</h4>
<ol className="list-decimal list-inside text-sm text-neutral-600 space-y-1">
{method.process.map((step, i) => (
<li key={i}>{step}</li>
))}
@ -125,7 +110,7 @@ export default async function VGMPage() {
<h4 className="font-medium text-green-700 mb-2">
✓ {t('vgm.advantagesLabel')}
</h4>
<ul className="text-sm text-gray-600 space-y-1">
<ul className="text-sm text-neutral-600 space-y-1">
{method.advantages.map(adv => (
<li key={adv} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />
@ -138,7 +123,7 @@ export default async function VGMPage() {
<h4 className="font-medium text-red-700 mb-2">
✗ {t('vgm.disadvantagesLabel')}
</h4>
<ul className="text-sm text-gray-600 space-y-1">
<ul className="text-sm text-neutral-600 space-y-1">
{method.disadvantages.map(dis => (
<li key={dis} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
@ -154,32 +139,32 @@ export default async function VGMPage() {
</div>
</div>
<Card className="mt-8 bg-gray-50">
<Card className="mt-8 bg-neutral-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('vgm.responsibilityTitle')}</h3>
<h3 className="font-semibold text-brand-navy mb-3">{t('vgm.responsibilityTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{responsibilities.map(r => (
<div key={r.role} className="bg-white p-4 rounded-lg border">
<h4 className="font-medium text-gray-900">{r.role}</h4>
<p className="text-sm text-gray-600 mt-1">{r.description}</p>
<h4 className="font-medium text-brand-navy">{r.role}</h4>
<p className="text-sm text-neutral-600 mt-1">{r.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="mt-4 bg-gray-50">
<Card className="mt-4 bg-neutral-50">
<CardContent className="pt-6">
<h3 className="font-semibold text-gray-900 mb-3">{t('vgm.tolerancesTitle')}</h3>
<h3 className="font-semibold text-brand-navy mb-3">{t('vgm.tolerancesTitle')}</h3>
<div className="bg-white p-4 rounded-lg border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">{t('vgm.toleranceLabel')} :</span>
<p className="text-gray-600">{t('vgm.toleranceValue')}</p>
<p className="text-neutral-600">{t('vgm.toleranceValue')}</p>
</div>
<div>
<span className="font-medium">{t('vgm.consequenceLabel')} :</span>
<p className="text-gray-600">{t('vgm.consequenceValue')}</p>
<p className="text-neutral-600">{t('vgm.consequenceValue')}</p>
</div>
</div>
</div>
@ -187,7 +172,7 @@ export default async function VGMPage() {
</Card>
<div className="mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-4">{t('vgm.sanctionsTitle')}</h2>
<h2 className="text-xl font-bold text-brand-navy mb-4">{t('vgm.sanctionsTitle')}</h2>
<Card className="bg-white">
<CardContent className="pt-6">
<div className="space-y-3">
@ -196,7 +181,7 @@ export default async function VGMPage() {
key={s.region}
className="flex items-center justify-between py-3 border-b last:border-0"
>
<span className="font-medium text-gray-900">{s.region}</span>
<span className="font-medium text-brand-navy">{s.region}</span>
<span className="text-sm text-red-600">{s.sanction}</span>
</div>
))}

View File

@ -1,72 +0,0 @@
'use client';
import dynamic from 'next/dynamic';
// Import dynamique pour éviter les erreurs SSR avec Leaflet
const PortRouteMap = dynamic(() => import('@/components/PortRouteMap'), {
ssr: false,
loading: () => (
<div className="h-96 bg-gray-100 rounded-lg flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement de la carte...</p>
</div>
</div>
),
});
export default function DemoPage() {
const portA = { lat: 43.2965, lng: 5.3698 }; // Marseille
const portB = { lat: 41.3851, lng: 2.1734 }; // Barcelone
return (
<div className="w-full min-h-screen p-8 bg-gray-50">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Démo Carte Maritime</h1>
<p className="text-gray-600">Visualisation de la route entre Marseille et Barcelone</p>
</div>
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="bg-blue-600 px-6 py-4">
<h2 className="text-white text-lg font-semibold">
Route: Port de Marseille → Port de Barcelone
</h2>
<p className="text-blue-100 text-sm mt-1">Distance approximative: ~350 km par la mer</p>
</div>
<PortRouteMap portA={portA} portB={portB} height="600px" />
<div className="px-6 py-4 bg-gray-50 border-t">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<h3 className="font-semibold text-gray-900 mb-2">📍 Port d'origine</h3>
<p className="text-gray-600">Marseille, France</p>
<p className="text-gray-500 text-xs">
Lat: {portA.lat}, Lng: {portA.lng}
</p>
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-2">📍 Port de destination</h3>
<p className="text-gray-600">Barcelone, Espagne</p>
<p className="text-gray-500 text-xs">
Lat: {portB.lat}, Lng: {portB.lng}
</p>
</div>
</div>
</div>
</div>
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-blue-900 font-semibold mb-2">ℹ️ Informations</h3>
<ul className="text-blue-800 text-sm space-y-1">
<li>• Carte interactive OpenStreetMap</li>
<li>• Marqueurs positionnés sur les ports</li>
<li>• Ligne bleue représentant la route maritime</li>
<li>• Zoom et navigation disponibles</li>
</ul>
</div>
</div>
</div>
);
}

View File

@ -107,7 +107,7 @@ export default function LandingPage() {
icon: BarChart3,
title: t('features.dashboard.title'),
description: t('features.dashboard.description'),
color: 'from-blue-500 to-cyan-500',
color: 'from-brand-turquoise to-cyan-500',
link: '/dashboard',
},
{
@ -389,7 +389,7 @@ export default function LandingPage() {
<button
type="button"
onClick={() => setIsDemoOpen(true)}
className="px-6 sm:px-8 py-3 sm:py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-50 transition-all hover:shadow-xl font-semibold text-base sm:text-lg w-full sm:w-auto text-center"
className="px-6 sm:px-8 py-3 sm:py-4 bg-white text-brand-navy rounded-lg hover:bg-neutral-50 transition-all hover:shadow-xl font-semibold text-base sm:text-lg w-full sm:w-auto text-center"
>
{t('hero.ctaDemo')}
</button>
@ -419,7 +419,7 @@ export default function LandingPage() {
</section>
{/* Stats Section */}
<section ref={statsRef} className="py-10 sm:py-16 bg-gray-50">
<section ref={statsRef} className="py-10 sm:py-16 bg-neutral-50">
<motion.div
variants={containerVariants}
initial="hidden"
@ -455,7 +455,7 @@ export default function LandingPage() {
duration={2.2}
/>
</motion.div>
<div className="text-gray-600 font-medium">{stat.label}</div>
<div className="text-neutral-600 font-medium">{stat.label}</div>
</motion.div>
);
})}
@ -475,7 +475,7 @@ export default function LandingPage() {
<h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-brand-navy mb-3 sm:mb-4">
{t('features.title')}
</h2>
<p className="text-base sm:text-xl text-gray-600 max-w-2xl mx-auto">
<p className="text-base sm:text-xl text-neutral-600 max-w-2xl mx-auto">
{t('features.subtitle')}
</p>
</motion.div>
@ -499,7 +499,7 @@ export default function LandingPage() {
href={feature.link}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col h-full bg-white p-8 rounded-2xl shadow-lg hover:shadow-2xl transition-all border border-gray-100 hover:border-brand-turquoise/30 relative overflow-hidden"
className="group flex flex-col h-full bg-white p-8 rounded-2xl shadow-lg hover:shadow-2xl transition-all border border-border hover:border-brand-turquoise/30 relative overflow-hidden"
>
<div
className={`absolute top-0 left-0 right-0 h-1 bg-gradient-to-r ${feature.color} opacity-40 group-hover:opacity-100 transition-opacity duration-300`}
@ -512,8 +512,8 @@ export default function LandingPage() {
<h3 className="text-xl font-bold text-brand-navy mb-3 group-hover:text-brand-turquoise transition-colors">
{feature.title}
</h3>
<p className="text-gray-600 leading-relaxed flex-1">{feature.description}</p>
<div className="mt-5 pt-4 border-t border-gray-100 flex items-center justify-between">
<p className="text-neutral-600 leading-relaxed flex-1">{feature.description}</p>
<div className="mt-5 pt-4 border-t border-border flex items-center justify-between">
<span className="text-brand-turquoise font-semibold text-sm">
{tCommon('discover')}
</span>
@ -540,7 +540,7 @@ export default function LandingPage() {
className="text-center mb-12"
>
<h3 className="text-2xl font-bold text-brand-navy mb-2">{t('partners.title')}</h3>
<p className="text-gray-600">{t('partners.subtitle')}</p>
<p className="text-neutral-600">{t('partners.subtitle')}</p>
</motion.div>
<motion.div
@ -584,7 +584,7 @@ export default function LandingPage() {
<section
ref={pricingRef}
id="pricing"
className="py-12 sm:py-20 lg:py-32 bg-gradient-to-b from-white to-gray-50"
className="py-12 sm:py-20 lg:py-32 bg-gradient-to-b from-white to-neutral-50"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
@ -599,7 +599,7 @@ export default function LandingPage() {
<h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-brand-navy mb-3 sm:mb-4">
{t('pricing.title')}
</h2>
<p className="text-base sm:text-xl text-gray-600 max-w-2xl mx-auto">
<p className="text-base sm:text-xl text-neutral-600 max-w-2xl mx-auto">
{t('pricing.subtitle')}
</p>
</motion.div>
@ -611,14 +611,14 @@ export default function LandingPage() {
className="flex items-center justify-center gap-4 mb-12"
>
<span
className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}
className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-neutral-400'}`}
>
{t('pricing.monthly')}
</span>
<button
onClick={() => setBillingYearly(v => !v)}
className={`relative inline-flex h-7 w-14 items-center rounded-full transition-colors focus:outline-none ${
billingYearly ? 'bg-brand-turquoise' : 'bg-gray-300'
billingYearly ? 'bg-brand-turquoise' : 'bg-neutral-300'
}`}
>
<span
@ -628,7 +628,7 @@ export default function LandingPage() {
/>
</button>
<span
className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}
className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-neutral-400'}`}
>
{t('pricing.yearly')}
</span>
@ -665,7 +665,7 @@ export default function LandingPage() {
className={`relative flex flex-col rounded-2xl transition-all overflow-hidden ${
plan.highlighted
? 'bg-brand-navy shadow-2xl ring-2 ring-brand-turquoise'
: 'bg-white shadow-lg border border-gray-100 hover:shadow-xl hover:border-brand-turquoise/30'
: 'bg-white shadow-lg border border-border hover:shadow-xl hover:border-brand-turquoise/30'
}`}
>
<div className={`h-1.5 w-full bg-gradient-to-r ${plan.accentColor}`} />
@ -711,7 +711,7 @@ export default function LandingPage() {
{t('pricing.custom')}
</span>
<p
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-neutral-500'}`}
>
{t('pricing.customSubtitle')}
</p>
@ -724,7 +724,7 @@ export default function LandingPage() {
{t('pricing.free')}
</span>
<p
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-neutral-500'}`}
>
{t('pricing.freeSubtitle')}
</p>
@ -738,14 +738,14 @@ export default function LandingPage() {
{billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}€
</span>
<span
className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-neutral-500'}`}
>
{t('pricing.perMonth')}
</span>
</div>
{billingYearly ? (
<p
className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}
className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-neutral-500'}`}
>
{t('pricing.billedYearly', {
price: numberFormat.format(plan.yearlyPrice ?? 0),
@ -761,12 +761,12 @@ export default function LandingPage() {
</div>
<div
className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-gray-50'}`}
className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-neutral-50'}`}
>
<div className="flex items-center gap-2">
<Users className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-neutral-700'}`}
>
{planUsers}
</span>
@ -774,7 +774,7 @@ export default function LandingPage() {
<div className="flex items-center gap-2">
<Ship className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-neutral-700'}`}
>
{planShipments}
</span>
@ -782,7 +782,7 @@ export default function LandingPage() {
<div className="flex items-center gap-2">
<Package className="w-3.5 h-3.5 flex-shrink-0 text-brand-turquoise" />
<span
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}
className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-neutral-700'}`}
>
{planBookingFee}
</span>
@ -798,7 +798,7 @@ export default function LandingPage() {
/>
) : (
<X
className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-gray-300'}`}
className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-neutral-300'}`}
/>
)}
<span
@ -806,10 +806,10 @@ export default function LandingPage() {
feature.included
? plan.highlighted
? 'text-white/90'
: 'text-gray-700'
: 'text-neutral-700'
: plan.highlighted
? 'text-white/30'
: 'text-gray-400'
: 'text-neutral-400'
}`}
>
{t(`pricing.features.${feature.key}` as any)}
@ -824,7 +824,7 @@ export default function LandingPage() {
plan.highlighted
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl'
: plan.key === 'bronze'
? 'bg-gray-100 text-brand-navy hover:bg-gray-200'
? 'bg-neutral-100 text-brand-navy hover:bg-neutral-200'
: 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg'
}`}
>
@ -842,8 +842,8 @@ export default function LandingPage() {
transition={{ duration: 0.8, delay: 0.5 }}
className="mt-12 text-center space-y-2"
>
<p className="text-gray-600 text-sm">{t('pricing.noCommitment')}</p>
<p className="text-sm text-gray-500">
<p className="text-neutral-600 text-sm">{t('pricing.noCommitment')}</p>
<p className="text-sm text-neutral-500">
{t('pricing.questions')}{' '}
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
{t('pricing.contactSales')}
@ -948,7 +948,7 @@ export default function LandingPage() {
{/* Testimonials Section */}
<section
ref={testimonialsRef}
className="py-12 sm:py-20 lg:py-32 bg-gradient-to-br from-gray-50 to-white"
className="py-12 sm:py-20 lg:py-32 bg-gradient-to-br from-neutral-50 to-white"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<motion.div
@ -960,7 +960,7 @@ export default function LandingPage() {
<h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-brand-navy mb-3 sm:mb-4">
{t('testimonials.title')}
</h2>
<p className="text-base sm:text-xl text-gray-600 max-w-2xl mx-auto">
<p className="text-base sm:text-xl text-neutral-600 max-w-2xl mx-auto">
{t('testimonials.subtitle')}
</p>
</motion.div>
@ -976,7 +976,7 @@ export default function LandingPage() {
key={index}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-8 rounded-2xl shadow-lg border border-border"
>
<div className="flex mb-4">
{[...Array(5)].map((_, i) => (
@ -989,10 +989,10 @@ export default function LandingPage() {
</svg>
))}
</div>
<p className="text-gray-700 mb-6 leading-relaxed italic">"{testimonial.quote}"</p>
<p className="text-neutral-700 mb-6 leading-relaxed italic">"{testimonial.quote}"</p>
<div className="border-t pt-4">
<div className="font-bold text-brand-navy">{testimonial.author}</div>
<div className="text-sm text-gray-600">
<div className="text-sm text-neutral-600">
{testimonial.role} - {testimonial.company}
</div>
</div>
@ -1014,7 +1014,7 @@ export default function LandingPage() {
<h2 className="text-2xl sm:text-4xl lg:text-5xl font-bold text-brand-navy mb-4 sm:mb-6">
{t('cta.title')}
</h2>
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">{t('cta.subtitle')}</p>
<p className="text-base sm:text-xl text-neutral-600 mb-8 sm:mb-10">{t('cta.subtitle')}</p>
</motion.div>
<motion.div
@ -1056,7 +1056,7 @@ export default function LandingPage() {
<motion.div
variants={itemVariants}
className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8 text-sm text-gray-500"
className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8 text-sm text-neutral-500"
>
<div className="flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 text-brand-green" />

View File

@ -154,7 +154,7 @@ export default function PressPage() {
</a>
<a
href="#contact"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-gray-100 transition-all font-semibold text-lg"
className="px-8 py-4 bg-white text-brand-navy rounded-lg hover:bg-neutral-100 transition-all font-semibold text-lg"
>
{t('pressContact')}
</a>
@ -174,7 +174,7 @@ export default function PressPage() {
</section>
{/* Key Stats */}
<section className="py-16 bg-gray-50">
<section className="py-16 bg-neutral-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
{STATS.map((stat, index) => (
@ -187,7 +187,7 @@ export default function PressPage() {
className="text-center"
>
<div className="text-5xl font-bold text-brand-turquoise mb-2">{stat.value}</div>
<div className="text-gray-600 font-medium">{t(`stats.${stat.key}`)}</div>
<div className="text-neutral-600 font-medium">{t(`stats.${stat.key}`)}</div>
</motion.div>
))}
</div>
@ -206,7 +206,7 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('releasesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('releasesSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('releasesSubtitle')}</p>
</motion.div>
<motion.div
@ -219,7 +219,7 @@ export default function PressPage() {
<motion.div
key={release.id}
variants={itemVariants}
className="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 hover:shadow-xl transition-all group"
className="bg-white rounded-2xl shadow-lg border border-border p-6 hover:shadow-xl transition-all group"
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex-1">
@ -227,7 +227,7 @@ export default function PressPage() {
<span className="px-3 py-1 bg-brand-turquoise/10 text-brand-turquoise text-sm font-medium rounded-full">
{t(`releases.${release.key}.category`)}
</span>
<span className="flex items-center space-x-1 text-gray-500 text-sm">
<span className="flex items-center space-x-1 text-neutral-500 text-sm">
<Calendar className="w-4 h-4" />
<span>{t(`releases.${release.key}.date`)}</span>
</span>
@ -235,12 +235,12 @@ export default function PressPage() {
<h3 className="text-xl font-bold text-brand-navy mb-2 group-hover:text-brand-turquoise transition-colors">
{t(`releases.${release.key}.title`)}
</h3>
<p className="text-gray-600">{t(`releases.${release.key}.excerpt`)}</p>
<p className="text-neutral-600">{t(`releases.${release.key}.excerpt`)}</p>
</div>
<div className="flex items-center space-x-3">
<a
href="#"
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
className="flex items-center space-x-2 px-4 py-2 bg-neutral-100 text-neutral-700 rounded-lg hover:bg-neutral-200 transition-colors"
>
<Download className="w-4 h-4" />
<span>PDF</span>
@ -261,7 +261,7 @@ export default function PressPage() {
</section>
{/* Media Coverage */}
<section className="py-20 bg-gray-50">
<section className="py-20 bg-neutral-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -273,7 +273,7 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('coverageTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('coverageSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('coverageSubtitle')}</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -288,21 +288,21 @@ export default function PressPage() {
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
whileHover={{ y: -5 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all group"
className="bg-white p-6 rounded-2xl shadow-lg border border-border hover:shadow-xl transition-all group"
>
<div className="flex items-start space-x-4">
<div className="w-16 h-16 bg-gray-100 rounded-xl flex items-center justify-center flex-shrink-0">
<Newspaper className="w-8 h-8 text-gray-400" />
<div className="w-16 h-16 bg-neutral-100 rounded-xl flex items-center justify-center flex-shrink-0">
<Newspaper className="w-8 h-8 text-neutral-400" />
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<span className="font-bold text-brand-turquoise">{article.outlet}</span>
<ExternalLink className="w-4 h-4 text-gray-400 group-hover:text-brand-turquoise transition-colors" />
<ExternalLink className="w-4 h-4 text-neutral-400 group-hover:text-brand-turquoise transition-colors" />
</div>
<h3 className="text-lg font-bold text-brand-navy mb-2 group-hover:text-brand-turquoise transition-colors">
{t(`coverage.${article.key}.title`)}
</h3>
<span className="text-gray-500 text-sm">
<span className="text-neutral-500 text-sm">
{t(`coverage.${article.key}.date`)}
</span>
</div>
@ -323,7 +323,7 @@ export default function PressPage() {
className="text-center mb-16"
>
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">{t('kitTitle')}</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('kitSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('kitSubtitle')}</p>
</motion.div>
<motion.div
@ -339,7 +339,7 @@ export default function PressPage() {
key={item.key}
variants={itemVariants}
whileHover={{ y: -10 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
className="bg-white p-8 rounded-2xl shadow-lg border border-border hover:shadow-xl transition-all"
>
<div className="w-16 h-16 bg-brand-turquoise/10 rounded-2xl flex items-center justify-center mb-6">
<IconComponent className="w-8 h-8 text-brand-turquoise" />
@ -347,9 +347,9 @@ export default function PressPage() {
<h3 className="text-xl font-bold text-brand-navy mb-2">
{t(`kit.${item.key}.title`)}
</h3>
<p className="text-gray-600 mb-4">{t(`kit.${item.key}.description`)}</p>
<p className="text-neutral-600 mb-4">{t(`kit.${item.key}.description`)}</p>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">{t(`kit.${item.key}.format`)}</span>
<span className="text-sm text-neutral-500">{t(`kit.${item.key}.format`)}</span>
<a
href="#"
className="flex items-center space-x-2 text-brand-turquoise font-medium hover:underline"
@ -422,14 +422,14 @@ export default function PressPage() {
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
{t('contact.title')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('contact.body')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('contact.body')}</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContactInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.2 }}
className="bg-white rounded-3xl shadow-xl border border-gray-100 p-8 lg:p-12"
className="bg-white rounded-3xl shadow-xl border border-border p-8 lg:p-12"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div>
@ -442,7 +442,7 @@ export default function PressPage() {
<Mail className="w-6 h-6 text-brand-turquoise" />
</div>
<div>
<div className="text-sm text-gray-500">{t('contact.emailLabel')}</div>
<div className="text-sm text-neutral-500">{t('contact.emailLabel')}</div>
<a
href="mailto:presse@xpeditis.com"
className="text-lg font-medium text-brand-navy hover:text-brand-turquoise transition-colors"
@ -456,7 +456,7 @@ export default function PressPage() {
<Phone className="w-6 h-6 text-brand-turquoise" />
</div>
<div>
<div className="text-sm text-gray-500">{t('contact.phoneLabel')}</div>
<div className="text-sm text-neutral-500">{t('contact.phoneLabel')}</div>
<a
href="tel:+33123456790"
className="text-lg font-medium text-brand-navy hover:text-brand-turquoise transition-colors"
@ -481,20 +481,20 @@ export default function PressPage() {
<div className="text-brand-turquoise font-medium mb-2">
{t('contact.responsibleRole')}
</div>
<p className="text-gray-600 text-sm">{t('contact.responsibleBio')}</p>
<p className="text-neutral-600 text-sm">{t('contact.responsibleBio')}</p>
</div>
</div>
</div>
</div>
<div className="mt-8 pt-8 border-t border-gray-100">
<div className="flex items-start space-x-4 bg-gray-50 p-6 rounded-xl">
<div className="mt-8 pt-8 border-t border-border">
<div className="flex items-start space-x-4 bg-neutral-50 p-6 rounded-xl">
<Quote className="w-8 h-8 text-brand-turquoise flex-shrink-0" />
<div>
<p className="text-gray-600 italic mb-4">&ldquo;{t('contact.quote')}&rdquo;</p>
<p className="text-neutral-600 italic mb-4">&ldquo;{t('contact.quote')}&rdquo;</p>
<div className="text-sm">
<span className="font-bold text-brand-navy">Jean-Pierre Durand</span>
<span className="text-gray-500">{t('contact.quoteRole')}</span>
<span className="text-neutral-500">{t('contact.quoteRole')}</span>
</div>
</div>
</div>

View File

@ -41,7 +41,7 @@ const PLANS: PlanConfig[] = [
bookingFee: 15,
supportKey: 'supportNone',
badge: null,
ctaStyle: 'bg-gray-900 text-white hover:bg-gray-800',
ctaStyle: 'bg-brand-navy text-white hover:bg-neutral-800',
popular: false,
features: [
{ key: 'rates', included: true },
@ -157,7 +157,7 @@ export default function PricingPage() {
/>
</Link>
<div className="flex items-center gap-4">
<Link href="/login" className="text-sm text-gray-600 hover:text-gray-900">
<Link href="/login" className="text-sm text-neutral-600 hover:text-brand-navy">
{t('header.login')}
</Link>
<Link
@ -172,20 +172,20 @@ export default function PricingPage() {
{/* Hero */}
<section className="py-16 text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">{t('hero.title')}</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto mb-8">{t('hero.subtitle')}</p>
<h1 className="text-4xl font-bold text-brand-navy mb-4">{t('hero.title')}</h1>
<p className="text-lg text-neutral-600 max-w-2xl mx-auto mb-8">{t('hero.subtitle')}</p>
{/* Billing toggle */}
<div className="flex items-center justify-center gap-4 mb-12">
<span
className={`text-sm font-medium ${billing === 'monthly' ? 'text-gray-900' : 'text-gray-500'}`}
className={`text-sm font-medium ${billing === 'monthly' ? 'text-brand-navy' : 'text-neutral-500'}`}
>
{t('hero.monthly')}
</span>
<button
onClick={() => setBilling(billing === 'monthly' ? 'yearly' : 'monthly')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
billing === 'yearly' ? 'bg-brand-turquoise' : 'bg-gray-300'
billing === 'yearly' ? 'bg-brand-turquoise' : 'bg-neutral-300'
}`}
>
<span
@ -195,7 +195,7 @@ export default function PricingPage() {
/>
</button>
<span
className={`text-sm font-medium ${billing === 'yearly' ? 'text-gray-900' : 'text-gray-500'}`}
className={`text-sm font-medium ${billing === 'yearly' ? 'text-brand-navy' : 'text-neutral-500'}`}
>
{t('hero.yearly')}
</span>
@ -216,7 +216,7 @@ export default function PricingPage() {
className={`relative rounded-2xl border-2 p-6 flex flex-col ${
plan.popular
? 'border-brand-turquoise shadow-lg shadow-brand-turquoise/10'
: 'border-gray-200'
: 'border-border'
}`}
>
{plan.popular && (
@ -229,7 +229,7 @@ export default function PricingPage() {
{/* Plan name & badge */}
<div className="flex items-center gap-2 mb-2">
<h3 className="text-xl font-bold text-gray-900">{t(`plans.${plan.key}.name`)}</h3>
<h3 className="text-xl font-bold text-brand-navy">{t(`plans.${plan.key}.name`)}</h3>
{plan.badge && (
<Shield
className={`w-5 h-5 ${
@ -243,26 +243,26 @@ export default function PricingPage() {
)}
</div>
<p className="text-sm text-gray-500 mb-4">{t(`plans.${plan.key}.description`)}</p>
<p className="text-sm text-neutral-500 mb-4">{t(`plans.${plan.key}.description`)}</p>
{/* Price */}
<div className="mb-6">
{plan.monthlyPrice === -1 ? (
<p className="text-3xl font-bold text-gray-900">{t('currency.onQuote')}</p>
<p className="text-3xl font-bold text-brand-navy">{t('currency.onQuote')}</p>
) : plan.monthlyPrice === 0 ? (
<p className="text-3xl font-bold text-gray-900">{t('currency.free')}</p>
<p className="text-3xl font-bold text-brand-navy">{t('currency.free')}</p>
) : (
<>
<p className="text-3xl font-bold text-gray-900">
<p className="text-3xl font-bold text-brand-navy">
{billing === 'monthly'
? formatPrice(plan.monthlyPrice)
: formatPrice(Math.round(plan.yearlyPrice / 12))}
<span className="text-base font-normal text-gray-500">
<span className="text-base font-normal text-neutral-500">
{t('currency.perMonth')}
</span>
</p>
{billing === 'yearly' && (
<p className="text-sm text-gray-500 mt-1">
<p className="text-sm text-neutral-500 mt-1">
{t('currency.yearlyPrice', { price: formatPrice(plan.yearlyPrice) })}
</p>
)}
@ -273,17 +273,17 @@ export default function PricingPage() {
{/* Quick stats */}
<div className="space-y-2 mb-6 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">{t('stats.users')}</span>
<span className="text-neutral-500">{t('stats.users')}</span>
<span className="font-medium">
{plan.maxUsersKey ? t(`values.${plan.maxUsersKey}`) : plan.maxUsers}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">{t('stats.shipments')}</span>
<span className="text-neutral-500">{t('stats.shipments')}</span>
<span className="font-medium">{t(`values.${plan.shipmentsKey}`)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">{t('stats.bookingFee')}</span>
<span className="text-neutral-500">{t('stats.bookingFee')}</span>
<span className="font-medium">
{plan.bookingFee === -1
? t('values.bookingFeeCustom')
@ -291,7 +291,7 @@ export default function PricingPage() {
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">{t('stats.support')}</span>
<span className="text-neutral-500">{t('stats.support')}</span>
<span className="font-medium">{t(`values.${plan.supportKey}`)}</span>
</div>
</div>
@ -303,9 +303,9 @@ export default function PricingPage() {
{feature.included ? (
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
) : (
<X className="w-4 h-4 text-gray-300 flex-shrink-0" />
<X className="w-4 h-4 text-neutral-300 flex-shrink-0" />
)}
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
<span className={feature.included ? 'text-neutral-700' : 'text-neutral-400'}>
{t(`features.${feature.key}` as any)}
</span>
</div>
@ -326,7 +326,7 @@ export default function PricingPage() {
</section>
{/* Footer */}
<footer className="border-t py-8 text-center text-sm text-gray-500">
<footer className="border-t py-8 text-center text-sm text-neutral-500">
<p>{t('footer')}</p>
</footer>
</div>

View File

@ -135,7 +135,7 @@ export default function PrivacyPage() {
<motion.div
key={key}
variants={itemVariants}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-8 rounded-2xl shadow-lg border border-border"
>
<div className="flex items-start space-x-4">
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
@ -145,7 +145,7 @@ export default function PrivacyPage() {
<h2 className="text-2xl font-bold text-brand-navy mb-4">
{t(`sections.${key}.title`)}
</h2>
<div className="text-gray-600 leading-relaxed whitespace-pre-line prose prose-sm max-w-none">
<div className="text-neutral-600 leading-relaxed whitespace-pre-line prose prose-sm max-w-none">
{renderInlineBold(t(`sections.${key}.content`))}
</div>
</div>

View File

@ -132,7 +132,7 @@ export default function SecurityPage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('featuresTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('featuresSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('featuresSubtitle')}</p>
</motion.div>
<motion.div
@ -148,7 +148,7 @@ export default function SecurityPage() {
key={feature.key}
variants={itemVariants}
whileHover={{ y: -5 }}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100 hover:shadow-xl transition-all"
className="bg-white p-8 rounded-2xl shadow-lg border border-border hover:shadow-xl transition-all"
>
<div className="w-14 h-14 bg-brand-turquoise/10 rounded-xl flex items-center justify-center mb-4">
<IconComponent className="w-7 h-7 text-brand-turquoise" />
@ -156,7 +156,7 @@ export default function SecurityPage() {
<h3 className="text-xl font-bold text-brand-navy mb-3">
{t(`features.${feature.key}.title`)}
</h3>
<p className="text-gray-600">{t(`features.${feature.key}.description`)}</p>
<p className="text-neutral-600">{t(`features.${feature.key}.description`)}</p>
</motion.div>
);
})}
@ -165,7 +165,7 @@ export default function SecurityPage() {
</section>
{/* Certifications */}
<section className="py-20 bg-gray-50">
<section className="py-20 bg-neutral-50">
<div className="max-w-7xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}
@ -177,7 +177,7 @@ export default function SecurityPage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('certificationsTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('certificationsSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('certificationsSubtitle')}</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
@ -188,7 +188,7 @@ export default function SecurityPage() {
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="bg-white p-6 rounded-2xl shadow-lg border border-gray-100 text-center"
className="bg-white p-6 rounded-2xl shadow-lg border border-border text-center"
>
<div className="w-16 h-16 bg-brand-green/10 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-brand-green" />
@ -196,7 +196,7 @@ export default function SecurityPage() {
<h3 className="text-xl font-bold text-brand-navy mb-2">
{t(`certifications.${key}.name`)}
</h3>
<p className="text-gray-600 text-sm">{t(`certifications.${key}.description`)}</p>
<p className="text-neutral-600 text-sm">{t(`certifications.${key}.description`)}</p>
</motion.div>
))}
</div>
@ -216,7 +216,7 @@ export default function SecurityPage() {
<h2 className="text-3xl lg:text-4xl font-bold text-brand-navy mb-4">
{t('practicesTitle')}
</h2>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">{t('practicesSubtitle')}</p>
<p className="text-xl text-neutral-600 max-w-2xl mx-auto">{t('practicesSubtitle')}</p>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
@ -245,7 +245,7 @@ export default function SecurityPage() {
</section>
{/* Report Vulnerability */}
<section className="py-20 bg-gray-50">
<section className="py-20 bg-neutral-50">
<div className="max-w-4xl mx-auto px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, y: 30 }}

View File

@ -143,7 +143,7 @@ export default function TermsPage() {
<motion.div
key={key}
variants={itemVariants}
className="bg-white p-8 rounded-2xl shadow-lg border border-gray-100"
className="bg-white p-8 rounded-2xl shadow-lg border border-border"
>
<div className="flex items-start space-x-4">
<div className="w-12 h-12 bg-brand-turquoise/10 rounded-xl flex items-center justify-center flex-shrink-0">
@ -153,7 +153,7 @@ export default function TermsPage() {
<h2 className="text-2xl font-bold text-brand-navy mb-4">
{t(`sections.${key}.title`)}
</h2>
<div className="text-gray-600 leading-relaxed whitespace-pre-line prose prose-sm max-w-none">
<div className="text-neutral-600 leading-relaxed whitespace-pre-line prose prose-sm max-w-none">
{renderInlineBold(t(`sections.${key}.content`))}
</div>
</div>

View File

@ -1,49 +0,0 @@
import Image from 'next/image';
export default function TestImagePage() {
return (
<div className="min-h-screen p-8">
<h1 className="text-2xl font-bold mb-4">Test Background Image</h1>
{/* Test 1: Direct img tag */}
<div className="mb-8">
<h2 className="font-semibold mb-2">Test 1: Direct Image component</h2>
<Image
src="/assets/images/background-section-1-landingpage.png"
alt="test"
width={256}
height={128}
className="w-64 h-32 object-cover"
/>
</div>
{/* Test 2: CSS background-image */}
<div className="mb-8">
<h2 className="font-semibold mb-2">Test 2: CSS background-image</h2>
<div
className="w-64 h-32"
style={{
backgroundImage: 'url(/assets/images/background-section-1-landingpage.png)',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</div>
{/* Test 3: Absolute positioning */}
<div className="mb-8">
<h2 className="font-semibold mb-2">Test 3: Absolute positioning (like hero section)</h2>
<div className="relative w-64 h-32 bg-gray-200">
<div
className="absolute inset-0"
style={{
backgroundImage: 'url(/assets/images/background-section-1-landingpage.png)',
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
</div>
</div>
</div>
);
}

View File

@ -41,13 +41,13 @@ function VerifyEmailContent() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="min-h-screen flex items-center justify-center bg-neutral-50">
<div className="max-w-md w-full space-y-8 text-center">
<div>
<h1 className="text-4xl font-bold text-blue-600">Xpeditis</h1>
<h2 className="mt-6 text-2xl font-bold text-gray-900">{t('verifyingTitle')}</h2>
<h1 className="text-4xl font-bold text-brand-navy">Xpeditis</h1>
<h2 className="mt-6 text-2xl font-bold text-brand-navy">{t('verifyingTitle')}</h2>
<div className="mt-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto"></div>
</div>
</div>
</div>
@ -57,11 +57,11 @@ function VerifyEmailContent() {
if (success) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h1 className="text-center text-4xl font-bold text-blue-600">Xpeditis</h1>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
<h1 className="text-center text-4xl font-bold text-brand-navy">Xpeditis</h1>
<h2 className="mt-6 text-center text-3xl font-extrabold text-brand-navy">
{t('successTitle')}
</h2>
</div>
@ -89,7 +89,7 @@ function VerifyEmailContent() {
</div>
<div className="text-center">
<Link href="/dashboard" className="font-medium text-blue-600 hover:text-blue-500">
<Link href="/dashboard" className="font-medium text-brand-navy hover:text-brand-turquoise">
{t('goToDashboard')}
</Link>
</div>
@ -99,11 +99,11 @@ function VerifyEmailContent() {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h1 className="text-center text-4xl font-bold text-blue-600">Xpeditis</h1>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
<h1 className="text-center text-4xl font-bold text-brand-navy">Xpeditis</h1>
<h2 className="mt-6 text-center text-3xl font-extrabold text-brand-navy">
{t('errorTitle')}
</h2>
</div>
@ -131,8 +131,8 @@ function VerifyEmailContent() {
</div>
<div className="text-center space-y-2">
<p className="text-sm text-gray-600">{t('errorHint')}</p>
<Link href="/login" className="block font-medium text-blue-600 hover:text-blue-500">
<p className="text-sm text-neutral-600">{t('errorHint')}</p>
<Link href="/login" className="block font-medium text-brand-navy hover:text-brand-turquoise">
{t('backToLogin')}
</Link>
</div>

View File

@ -22,31 +22,13 @@
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
/* Anneau de focus = turquoise de marque #34CCCD.
Restauration de la charte : la valeur precedente etait le bleu shadcn
par defaut (221.2 83.2% 53.3%), qui n'appartient pas au design system
Xpeditis. Voir docs/design-system-audit.md §1.3. */
--ring: 180 60.5% 50.4%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
@ -69,12 +51,31 @@
h4 { @apply text-h4 text-neutral-800; }
h5 { @apply text-h5 text-neutral-700; }
h6 { @apply text-h6 text-neutral-700; }
/* Accessibilite : respecter la preference systeme de mouvement reduit.
Les animations sont neutralisees plutot que supprimees, afin que les
callbacks onAnimationEnd de Radix continuent de se declencher. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
}
@layer components {
/* Button styles with Xpeditis branding */
/* Classes historiques, encore employees par /register et /admin/login.
Elles seront retirees quand ces pages passeront aux primitives de
src/components/ui. Ne pas les supprimer avant : voir docs/ui-audit.md §D6. */
/* Texte navy sur turquoise : 8,49:1 (AAA). Le blanc sur turquoise ne donne
que 2,05:1 et echoue AA. Voir docs/ui-architecture.md §17. */
.btn-primary {
@apply bg-accent text-white font-heading font-semibold px-6 py-3 rounded-lg hover:bg-accent/90 transition-colors shadow-sm hover:shadow;
@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;
}
.btn-secondary {
@ -107,8 +108,10 @@
@apply badge bg-success/10 text-success;
}
/* Le turquoise en texte sur fond clair echoue AA : le libelle passe en navy,
le turquoise reste sur le fond. */
.badge-info {
@apply badge bg-accent/10 text-accent;
@apply badge bg-accent/10 text-brand-navy;
}
.badge-warning {
@ -121,7 +124,10 @@
/* Link styles */
.link {
@apply text-accent hover:text-accent/80 transition-colors underline-offset-2 hover:underline;
/* Souligne en permanence : un lien identifie par la seule couleur echoue au
critere WCAG 1.4.1. Le navy assure par ailleurs le contraste que le
turquoise ne tient pas sur fond clair. */
@apply text-brand-navy underline underline-offset-2 transition-colors hover:text-accent;
}
/* Form elements */

View File

@ -27,7 +27,15 @@
"continue": "Continue",
"submit": "Submit",
"showPassword": "Show password",
"hidePassword": "Hide password"
"hidePassword": "Hide password",
"filters": "Filters",
"clearFilters": "Clear all",
"removeFilter": "Remove filter",
"dismiss": "Dismiss",
"retry": "Retry",
"selectAll": "Select all",
"selectRow": "Select row",
"perPage": "Per page"
},
"admin": {
"title": "Administration",
@ -1473,7 +1481,8 @@
"worldPorts": "World ports",
"dataSource": "Data provided by MarineTraffic - Real-time updates",
"openOnSite": "Open on MarineTraffic",
"iframeTitle": "Real-time maritime map"
"iframeTitle": "Real-time maritime map",
"highSpeed": "High speed"
},
"history": {
"title": "Search history",
@ -1634,17 +1643,26 @@
{
"name": "Departure",
"description": "Minimum obligations for the seller",
"terms": ["EXW"]
"terms": [
"EXW"
]
},
{
"name": "Arrival",
"description": "Maximum obligations for the seller",
"terms": ["DDP"]
"terms": [
"DDP"
]
},
{
"name": "Maritime only",
"description": "For sea and inland waterway transport",
"terms": ["FAS", "FOB", "CFR", "CIF"]
"terms": [
"FAS",
"FOB",
"CFR",
"CIF"
]
}
],
"tableTitle": "Incoterms 2020 — Summary",
@ -1778,7 +1796,12 @@
"General Average",
"Earthquake / tidal wave"
],
"excludes": ["Theft", "Contamination", "Moisture", "War (needs extension)"]
"excludes": [
"Theft",
"Contamination",
"Moisture",
"War (needs extension)"
]
},
{
"name": "ICC C",
@ -1789,7 +1812,13 @@
"Collision",
"General Average"
],
"excludes": ["Theft", "Damage", "Moisture", "Contamination", "War (needs extension)"]
"excludes": [
"Theft",
"Damage",
"Moisture",
"Contamination",
"War (needs extension)"
]
}
],
"extensionsTitle": "Coverage Extensions",
@ -2109,7 +2138,10 @@
"name": "Packing List",
"type": "Commercial",
"description": "Detailed description of packing, quantities, weights and dimensions of each package.",
"types": ["Neutral packing list", "Detailed packing list"]
"types": [
"Neutral packing list",
"Detailed packing list"
]
},
{
"name": "Certificate of Origin",
@ -2126,13 +2158,21 @@
"name": "Insurance Certificate",
"type": "Insurance",
"description": "Proof of insurance covering the goods during transport. Often required by banks for L/C.",
"types": ["Open policy", "Individual certificate", "Insurance declaration"]
"types": [
"Open policy",
"Individual certificate",
"Insurance declaration"
]
},
{
"name": "Customs Declaration",
"type": "Customs",
"description": "Mandatory for export (EX) and import (IM) customs clearance. Filed electronically.",
"types": ["Export declaration (EX1)", "Import declaration (IM4)", "Transit (T1, T2)"]
"types": [
"Export declaration (EX1)",
"Import declaration (IM4)",
"Transit (T1, T2)"
]
}
],
"additionalDocsTitle": "Other Important Documents",
@ -2316,13 +2356,19 @@
"class": "Class 5",
"name": "Oxidizers",
"description": "Oxidizing substances and organic peroxides",
"subdivisions": ["5.1 Oxidizing substances", "5.2 Organic peroxides"]
"subdivisions": [
"5.1 Oxidizing substances",
"5.2 Organic peroxides"
]
},
{
"class": "Class 6",
"name": "Toxic / Infectious",
"description": "Toxic and infectious substances",
"subdivisions": ["6.1 Toxic substances", "6.2 Infectious substances"]
"subdivisions": [
"6.1 Toxic substances",
"6.2 Infectious substances"
]
},
{
"class": "Class 7",
@ -2621,28 +2667,52 @@
"description": "World's busiest route in terms of volume",
"via": "Suez Canal",
"transitTime": "20–35 days",
"majorPorts": ["Shanghai", "Singapore", "Rotterdam", "Hamburg", "Le Havre"]
"majorPorts": [
"Shanghai",
"Singapore",
"Rotterdam",
"Hamburg",
"Le Havre"
]
},
{
"name": "Asia — North America (West)",
"description": "Trans-Pacific — growth driven by US-China trade",
"via": "Direct Pacific",
"transitTime": "12–18 days",
"majorPorts": ["Shanghai", "Ningbo", "Los Angeles", "Long Beach", "Seattle"]
"majorPorts": [
"Shanghai",
"Ningbo",
"Los Angeles",
"Long Beach",
"Seattle"
]
},
{
"name": "Asia — North America (East)",
"description": "Via Panama or Suez Canal for large vessels",
"via": "Suez or Panama",
"transitTime": "28–45 days",
"majorPorts": ["Shanghai", "Singapore", "New York", "Savannah", "Houston"]
"majorPorts": [
"Shanghai",
"Singapore",
"New York",
"Savannah",
"Houston"
]
},
{
"name": "Europe — North America",
"description": "Trans-Atlantic — major trade route",
"via": "Direct Atlantic",
"transitTime": "10–16 days",
"majorPorts": ["Rotterdam", "Antwerp", "Hamburg", "New York", "Baltimore"]
"majorPorts": [
"Rotterdam",
"Antwerp",
"Hamburg",
"New York",
"Baltimore"
]
}
],
"passagesTitle": "Strategic Passages",
@ -2812,8 +2882,14 @@
"Weighing on a certified weighbridge",
"Transmitting the VGM"
],
"advantages": ["More accurate", "Fewer calculations"],
"disadvantages": ["Requires a weighbridge", "Container already sealed"]
"advantages": [
"More accurate",
"Fewer calculations"
],
"disadvantages": [
"Requires a weighbridge",
"Container already sealed"
]
},
{
"method": "Method 2",
@ -2825,8 +2901,14 @@
"Adding securing material",
"Adding container tare"
],
"advantages": ["No weighbridge needed", "Can be done progressively"],
"disadvantages": ["More complex", "Risk of cumulative error"]
"advantages": [
"No weighbridge needed",
"Can be done progressively"
],
"disadvantages": [
"More complex",
"Risk of cumulative error"
]
}
],
"responsibilityTitle": "Responsibilities",
@ -4594,5 +4676,32 @@
"backToLogin": "Back to sign in",
"resend": "Resend verification email"
}
},
"shell": {
"navigation": "Main navigation",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"collapseSidebar": "Collapse menu",
"expandSidebar": "Expand menu",
"userMenu": "User menu",
"breadcrumb": "Breadcrumb",
"more": "More",
"moreTitle": "All navigation",
"search": "Search",
"searchPlaceholder": "Search a page, a booking, a port…",
"searchHint": "to open search",
"noResults": "No results",
"groupPages": "Pages",
"groupActions": "Actions",
"lockedFeature": "Available on a higher plan",
"profile": "Profile",
"groups": {
"pilotage": "Overview",
"resources": "Resources",
"organization": "Organization",
"management": "Management",
"content": "Content",
"system": "System"
}
}
}

View File

@ -27,7 +27,15 @@
"continue": "Continuer",
"submit": "Envoyer",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe"
"hidePassword": "Masquer le mot de passe",
"filters": "Filtres",
"clearFilters": "Tout effacer",
"removeFilter": "Retirer le filtre",
"dismiss": "Masquer",
"retry": "Réessayer",
"selectAll": "Tout sélectionner",
"selectRow": "Sélectionner la ligne",
"perPage": "Par page"
},
"admin": {
"title": "Espace administration",
@ -1473,7 +1481,8 @@
"worldPorts": "Ports mondiaux",
"dataSource": "Données fournies par MarineTraffic - Mise à jour en temps réel",
"openOnSite": "Ouvrir sur MarineTraffic",
"iframeTitle": "Carte maritime en temps réel"
"iframeTitle": "Carte maritime en temps réel",
"highSpeed": "Navires rapides"
},
"history": {
"title": "Historique des recherches",
@ -1634,17 +1643,26 @@
{
"name": "Départ",
"description": "Obligations minimales pour le vendeur",
"terms": ["EXW"]
"terms": [
"EXW"
]
},
{
"name": "Arrivée",
"description": "Obligations maximales pour le vendeur",
"terms": ["DDP"]
"terms": [
"DDP"
]
},
{
"name": "Maritime uniquement",
"description": "Pour le transport maritime et voies navigables intérieures",
"terms": ["FAS", "FOB", "CFR", "CIF"]
"terms": [
"FAS",
"FOB",
"CFR",
"CIF"
]
}
],
"tableTitle": "Incoterms 2020 — Récapitulatif",
@ -1778,7 +1796,12 @@
"Avarie commune",
"Séisme / raz-de-marée"
],
"excludes": ["Vol", "Contamination", "Humidité", "Guerre (extension nécessaire)"]
"excludes": [
"Vol",
"Contamination",
"Humidité",
"Guerre (extension nécessaire)"
]
},
{
"name": "ICC C",
@ -2115,7 +2138,10 @@
"name": "Liste de Colisage",
"type": "Commercial",
"description": "Description détaillée du conditionnement, des quantités, poids et dimensions de chaque colis.",
"types": ["Liste neutre", "Liste détaillée"]
"types": [
"Liste neutre",
"Liste détaillée"
]
},
{
"name": "Certificat d'Origine",
@ -2132,13 +2158,21 @@
"name": "Certificat d'Assurance",
"type": "Assurance",
"description": "Preuve d'assurance couvrant les marchandises pendant le transport. Souvent exigée par les banques pour L/C.",
"types": ["Police flottante", "Certificat individuel", "Déclaration d'assurance"]
"types": [
"Police flottante",
"Certificat individuel",
"Déclaration d'assurance"
]
},
{
"name": "Déclaration en Douane",
"type": "Douanier",
"description": "Obligatoire pour le dédouanement export (EX) et import (IM). Déposée électroniquement (DELTA en France).",
"types": ["Déclaration export (EX1)", "Déclaration import (IM4)", "Transit (T1, T2)"]
"types": [
"Déclaration export (EX1)",
"Déclaration import (IM4)",
"Transit (T1, T2)"
]
}
],
"additionalDocsTitle": "Autres Documents Importants",
@ -2322,13 +2356,19 @@
"class": "Classe 5",
"name": "Comburants",
"description": "Matières comburantes et peroxydes organiques",
"subdivisions": ["5.1 Matières comburantes", "5.2 Peroxydes organiques"]
"subdivisions": [
"5.1 Matières comburantes",
"5.2 Peroxydes organiques"
]
},
{
"class": "Classe 6",
"name": "Toxiques / Infectieux",
"description": "Matières toxiques et infectieuses",
"subdivisions": ["6.1 Matières toxiques", "6.2 Matières infectieuses"]
"subdivisions": [
"6.1 Matières toxiques",
"6.2 Matières infectieuses"
]
},
{
"class": "Classe 7",
@ -2627,28 +2667,52 @@
"description": "Route la plus chargée au monde en volume",
"via": "Canal de Suez",
"transitTime": "20–35 jours",
"majorPorts": ["Shanghai", "Singapore", "Rotterdam", "Hambourg", "Le Havre"]
"majorPorts": [
"Shanghai",
"Singapore",
"Rotterdam",
"Hambourg",
"Le Havre"
]
},
{
"name": "Asie — Amérique du Nord (Ouest)",
"description": "Trans-Pacifique — croissance tirée par les échanges USA-Chine",
"via": "Pacifique direct",
"transitTime": "12–18 jours",
"majorPorts": ["Shanghai", "Ningbo", "Los Angeles", "Long Beach", "Seattle"]
"majorPorts": [
"Shanghai",
"Ningbo",
"Los Angeles",
"Long Beach",
"Seattle"
]
},
{
"name": "Asie — Amérique du Nord (Est)",
"description": "Via canal de Panama ou Suez pour les grands navires",
"via": "Suez ou Panama",
"transitTime": "28–45 jours",
"majorPorts": ["Shanghai", "Singapore", "New York", "Savannah", "Houston"]
"majorPorts": [
"Shanghai",
"Singapore",
"New York",
"Savannah",
"Houston"
]
},
{
"name": "Europe — Amérique du Nord",
"description": "Trans-Atlantique — grande route commerciale",
"via": "Atlantique direct",
"transitTime": "10–16 jours",
"majorPorts": ["Rotterdam", "Anvers", "Hambourg", "New York", "Baltimore"]
"majorPorts": [
"Rotterdam",
"Anvers",
"Hambourg",
"New York",
"Baltimore"
]
}
],
"passagesTitle": "Passages Stratégiques",
@ -2818,8 +2882,14 @@
"Pesée sur pont-bascule certifié",
"Transmission du VGM"
],
"advantages": ["Plus précis", "Moins de calculs"],
"disadvantages": ["Nécessite un pont-bascule", "Conteneur déjà scellé"]
"advantages": [
"Plus précis",
"Moins de calculs"
],
"disadvantages": [
"Nécessite un pont-bascule",
"Conteneur déjà scellé"
]
},
{
"method": "Méthode 2",
@ -2831,8 +2901,14 @@
"Ajout des matériaux d'arrimage",
"Addition de la tare conteneur"
],
"advantages": ["Pas besoin de pont-bascule", "Peut être fait progressivement"],
"disadvantages": ["Plus complexe", "Risque d'erreur cumulative"]
"advantages": [
"Pas besoin de pont-bascule",
"Peut être fait progressivement"
],
"disadvantages": [
"Plus complexe",
"Risque d'erreur cumulative"
]
}
],
"responsibilityTitle": "Responsabilités",
@ -4600,5 +4676,32 @@
"backToLogin": "Retour à la connexion",
"resend": "Renvoyer un email de vérification"
}
},
"shell": {
"navigation": "Navigation principale",
"openMenu": "Ouvrir le menu",
"closeMenu": "Fermer le menu",
"collapseSidebar": "Replier le menu",
"expandSidebar": "Deplier le menu",
"userMenu": "Menu utilisateur",
"breadcrumb": "Fil d'Ariane",
"more": "Plus",
"moreTitle": "Toute la navigation",
"search": "Rechercher",
"searchPlaceholder": "Rechercher une page, une reservation, un port…",
"searchHint": "pour ouvrir la recherche",
"noResults": "Aucun resultat",
"groupPages": "Pages",
"groupActions": "Actions",
"lockedFeature": "Disponible sur un plan superieur",
"profile": "Profil",
"groups": {
"pilotage": "Pilotage",
"resources": "Ressources",
"organization": "Organisation",
"management": "Gestion",
"content": "Contenu",
"system": "Systeme"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -15,12 +15,15 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.2.16",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
@ -48,6 +51,7 @@
"next": "^14.2.35",
"next-intl": "^4.9.1",
"react": "^18.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.64.0",
"react-leaflet": "^4.2.1",

View File

@ -0,0 +1,134 @@
import React from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import type { ColumnDef } from '@tanstack/react-table';
import { Package } from 'lucide-react';
import { DataTable } from '@/components/data/data-table';
interface Booking {
id: string;
reference: string;
carrier: string;
weight: number;
}
const rows: Booking[] = [
{ id: '1', reference: 'WCM-2026-000001', carrier: 'Maersk', weight: 1200 },
{ id: '2', reference: 'WCM-2026-000002', carrier: 'CMA CGM', weight: 800 },
];
const columns: ColumnDef<Booking, any>[] = [
{
accessorKey: 'reference',
header: 'Reference',
meta: { cardRole: 'title' },
},
{
accessorKey: 'carrier',
header: 'Transporteur',
enableSorting: false,
meta: { cardRole: 'field', headerLabel: 'Transporteur' },
},
{
accessorKey: 'weight',
header: 'Poids',
enableSorting: false,
meta: { align: 'right', hideBelow: 'lg', cardRole: 'field', headerLabel: 'Poids' },
},
];
const empty = { icon: Package, title: 'Aucune reservation', description: 'Rien a afficher' };
describe('DataTable — les quatre etats', () => {
it('affiche un squelette pendant le chargement, sans les donnees', () => {
render(<DataTable columns={columns} data={[]} loading empty={empty} />);
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(screen.queryByText('Aucune reservation')).not.toBeInTheDocument();
});
it('affiche l etat d erreur et declenche la nouvelle tentative', () => {
const onRetry = jest.fn();
render(
<DataTable
columns={columns}
data={[]}
empty={empty}
error={{ title: 'Chargement impossible', onRetry }}
/>
);
expect(screen.getByRole('alert')).toHaveTextContent('Chargement impossible');
fireEvent.click(screen.getByRole('button', { name: /reessayer/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it('affiche l etat vide quand il n y a aucune ligne', () => {
render(<DataTable columns={columns} data={[]} empty={empty} />);
expect(screen.getByText('Aucune reservation')).toBeInTheDocument();
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('affiche les donnees en table', () => {
render(<DataTable columns={columns} data={rows} empty={empty} />);
const table = screen.getByRole('table');
expect(within(table).getByText('WCM-2026-000001')).toBeInTheDocument();
expect(within(table).getByText('Maersk')).toBeInTheDocument();
expect(within(table).getAllByRole('row')).toHaveLength(rows.length + 1); // + en-tete
});
});
describe('DataTable — comportement', () => {
it('rend une carte mobile deduite des metadonnees, en plus de la table', () => {
render(<DataTable columns={columns} data={rows} empty={empty} />);
// La reference apparait deux fois : une en cellule de table, une en titre de
// carte. Le palier de bascule est gere en CSS (`display:none`), ce qui
// retire la variante masquee de l'arbre d'accessibilite.
expect(screen.getAllByText('WCM-2026-000001')).toHaveLength(2);
// L'etiquette de champ n'existe que dans la carte.
expect(screen.getAllByText('Transporteur').length).toBeGreaterThan(1);
});
it('declenche onRowClick avec la ligne d origine', () => {
const onRowClick = jest.fn();
render(<DataTable columns={columns} data={rows} empty={empty} onRowClick={onRowClick} />);
const table = screen.getByRole('table');
fireEvent.click(within(table).getByText('Maersk'));
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
});
it('rend un bouton de tri sur les seules colonnes triables', () => {
const onSortingChange = jest.fn();
render(
<DataTable
columns={columns}
data={rows}
empty={empty}
sorting={[]}
onSortingChange={onSortingChange}
/>
);
const sortButtons = screen.getAllByRole('button', { name: /trier par/i });
expect(sortButtons).toHaveLength(1);
fireEvent.click(sortButtons[0]);
expect(onSortingChange).toHaveBeenCalled();
});
it('applique la densite compacte par defaut et confortable a la demande', () => {
const { container, rerender } = render(
<DataTable columns={columns} data={rows} empty={empty} />
);
expect(container.querySelector('td')).toHaveClass('py-2.5');
rerender(<DataTable columns={columns} data={rows} empty={empty} density="comfortable" />);
expect(container.querySelector('td')).toHaveClass('py-4');
});
it('masque les colonnes secondaires sous leur palier', () => {
render(<DataTable columns={columns} data={rows} empty={empty} />);
const header = screen.getByRole('columnheader', { name: /poids/i });
expect(header).toHaveClass('hidden', 'lg:table-cell');
});
});

View File

@ -0,0 +1,106 @@
import {
buildAdminNav,
buildAppNav,
findActiveNav,
isItemActive,
} from '@/components/shell/nav-config';
const t = (key: string) => key;
const tShell = (key: string) => key;
describe('isItemActive', () => {
it('exige une correspondance exacte pour la racine de l espace', () => {
expect(isItemActive('/dashboard', '/dashboard', 'app')).toBe(true);
// Sans cette regle, « Tableau de bord » resterait actif sur toutes les pages.
expect(isItemActive('/dashboard', '/dashboard/bookings', 'app')).toBe(false);
});
it('active un item sur ses descendants', () => {
expect(isItemActive('/dashboard/bookings', '/dashboard/bookings', 'app')).toBe(true);
expect(isItemActive('/dashboard/bookings', '/dashboard/bookings/42', 'app')).toBe(true);
});
it('ne confond pas deux routes de meme prefixe textuel', () => {
// /dashboard/settings/users ne doit pas activer /dashboard/settings/user
expect(isItemActive('/dashboard/settings/user', '/dashboard/settings/users', 'app')).toBe(
false
);
});
it('applique la meme regle a la racine admin', () => {
expect(isItemActive('/admin', '/admin', 'admin')).toBe(true);
expect(isItemActive('/admin', '/admin/users', 'admin')).toBe(false);
});
});
describe('buildAppNav', () => {
it('regroupe la navigation en trois sections', () => {
const groups = buildAppNav({ t, tShell, role: 'USER' });
expect(groups.map(g => g.key)).toEqual(['pilotage', 'resources', 'organization']);
});
it('masque la gestion des utilisateurs hors ADMIN et MANAGER', () => {
const asUser = buildAppNav({ t, tShell, role: 'USER' });
const orgUser = asUser.find(g => g.key === 'organization')!;
expect(orgUser.items.map(i => i.key)).not.toContain('users');
for (const role of ['ADMIN', 'MANAGER']) {
const groups = buildAppNav({ t, tShell, role });
const org = groups.find(g => g.key === 'organization')!;
expect(org.items.map(i => i.key)).toContain('users');
}
});
it('porte les fonctionnalites requises par le plan', () => {
const groups = buildAppNav({ t, tShell, role: 'ADMIN' });
const byKey = Object.fromEntries(
groups.flatMap(g => g.items).map(i => [i.key, i.requiredFeature])
);
expect(byKey.wiki).toBe('wiki');
expect(byKey.apiKeys).toBe('api_access');
expect(byKey.users).toBe('user_management');
// Les reservations et documents restent accessibles sur tous les plans.
expect(byKey.bookings).toBeUndefined();
expect(byKey.documents).toBeUndefined();
});
});
describe('buildAdminNav', () => {
it('expose les sept destinations d administration', () => {
const groups = buildAdminNav({ t, tShell });
const keys = groups.flatMap(g => g.items).map(i => i.key);
expect(keys).toEqual([
'users',
'organizations',
'bookings',
'documents',
'csvRates',
'blog',
'logs',
]);
});
});
describe('findActiveNav', () => {
const groups = buildAppNav({ t, tShell, role: 'ADMIN' });
it('retrouve le groupe et l item de la route courante', () => {
const found = findActiveNav(groups, '/dashboard/bookings', 'app');
expect(found?.group.key).toBe('pilotage');
expect(found?.item.key).toBe('bookings');
});
it('retient la correspondance la plus specifique', () => {
const found = findActiveNav(groups, '/dashboard/settings/api-keys', 'app');
expect(found?.item.key).toBe('apiKeys');
expect(found?.group.key).toBe('organization');
});
it('resout la racine sur le tableau de bord', () => {
expect(findActiveNav(groups, '/dashboard', 'app')?.item.key).toBe('dashboard');
});
it('renvoie null sur une route hors navigation', () => {
expect(findActiveNav(groups, '/dashboard/profile', 'app')).toBeNull();
});
});

View File

@ -1,213 +0,0 @@
'use client';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Switch } from '@/components/ui/switch';
import { Loader2, Search } from 'lucide-react';
import { RateFiltersPanel } from '@/components/rate-search/RateFiltersPanel';
import { RateResultsTable } from '@/components/rate-search/RateResultsTable';
import { useCsvRateSearch } from '@/hooks/useCsvRateSearch';
import type { RateSearchFilters } from '@/types/rate-filters';
import type { CsvRateSearchResult } from '@/types/rates';
export default function CsvRateSearchPage() {
const [origin, setOrigin] = useState('FRFOS');
const [destination, setDestination] = useState('CNSHA');
const [volumeCBM, setVolumeCBM] = useState(10);
const [weightKG, setWeightKG] = useState(2500);
const [hasDangerousGoods, setHasDangerousGoods] = useState(false);
const [filters, setFilters] = useState<RateSearchFilters>({});
const { data, loading, error, search } = useCsvRateSearch();
const handleSearch = async () => {
await search({
origin,
destination,
volumeCBM,
weightKG,
containerType: 'LCL',
hasDangerousGoods,
filters,
});
};
const handleBooking = (result: CsvRateSearchResult) => {
alert(
`Réservation pour ${result.companyName}\nContact : ${result.companyEmail}\nRoute : ${result.originCFS} → ${result.destinationCFS}`
);
};
return (
<div className="container mx-auto py-8 space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Comparateur de tarifs LCL</h1>
<p className="text-muted-foreground mt-2">
Comparez les tarifs de fret maritime LCL multi-fournisseurs avec détail FOB et fret
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Filtres */}
<div className="lg:col-span-1">
<RateFiltersPanel
filters={filters}
onFiltersChange={setFilters}
resultsCount={data?.totalResults || 0}
onReset={() => setFilters({})}
/>
</div>
{/* Formulaire + résultats */}
<div className="lg:col-span-3 space-y-6">
<Card>
<CardHeader>
<CardTitle>Paramètres de recherche</CardTitle>
<CardDescription>
Indiquez votre trajet, volume et poids pour obtenir des tarifs comparés
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Origine / Destination */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="origin">
Port d'origine <span className="text-red-500">*</span>
</Label>
<Input
id="origin"
value={origin}
onChange={e => setOrigin(e.target.value.toUpperCase())}
placeholder="FRFOS"
maxLength={5}
/>
<p className="text-xs text-muted-foreground">Code UN/LOCODE (ex: FRFOS, FRLEH)</p>
</div>
<div className="space-y-2">
<Label htmlFor="destination">
Port de destination <span className="text-red-500">*</span>
</Label>
<Input
id="destination"
value={destination}
onChange={e => setDestination(e.target.value.toUpperCase())}
placeholder="CNSHA"
maxLength={5}
/>
<p className="text-xs text-muted-foreground">Code UN/LOCODE (ex: CNSHA, USNYC)</p>
</div>
</div>
{/* Volume / Poids */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="volume">
Volume (CBM) <span className="text-red-500">*</span>
</Label>
<Input
id="volume"
type="number"
min={0.1}
step={0.1}
value={volumeCBM}
onChange={e => setVolumeCBM(parseFloat(e.target.value) || 0)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="weight">
Poids (kg) <span className="text-red-500">*</span>
</Label>
<Input
id="weight"
type="number"
min={1}
step={100}
value={weightKG}
onChange={e => setWeightKG(parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
{/* Marchandises dangereuses */}
<div className="flex items-center space-x-2">
<Switch
id="dg"
checked={hasDangerousGoods}
onCheckedChange={setHasDangerousGoods}
/>
<Label htmlFor="dg" className="cursor-pointer">
Marchandises dangereuses (DG)
</Label>
</div>
{/* Recherche */}
<Button
onClick={handleSearch}
disabled={loading || !origin || !destination || volumeCBM <= 0 || weightKG <= 0}
className="w-full"
size="lg"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Recherche en cours...
</>
) : (
<>
<Search className="mr-2 h-4 w-4" />
Comparer les tarifs
</>
)}
</Button>
</CardContent>
</Card>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{data && (
<Alert>
<AlertDescription>
{new Date(data.searchedAt).toLocaleString('fr-FR')} — {data.searchedFiles.length}{' '}
fournisseur(s) analysé(s) — {data.totalResults} tarif(s) trouvé(s)
</AlertDescription>
</Alert>
)}
{data && data.results.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Résultats de comparaison</CardTitle>
<CardDescription>
{data.totalResults} tarif{data.totalResults > 1 ? 's' : ''} — prix = Fret + Frais
FOB (deux devises possibles)
</CardDescription>
</CardHeader>
<CardContent>
<RateResultsTable results={data.results} onBooking={handleBooking} />
</CardContent>
</Card>
)}
{data && data.results.length === 0 && (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Aucun tarif trouvé pour cette route.</p>
<p className="text-sm text-muted-foreground mt-2">
Vérifiez les codes UN/LOCODE saisis (ex: FRFOS, CNSHA).
</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@ -73,7 +73,7 @@ export default function CookieConsent() {
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className={`fixed left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-2xl ${mobileOffset}`}
className={`fixed left-0 right-0 z-50 bg-white border-t border-border shadow-2xl ${mobileOffset}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<AnimatePresence mode="wait">
@ -93,11 +93,11 @@ export default function CookieConsent() {
{t('banner.title')}
</h3>
</div>
<p className="text-sm text-gray-600 max-w-2xl">
<p className="text-sm text-neutral-600 max-w-2xl">
{t('banner.body')}{' '}
<Link
href="/cookies"
className="text-brand-turquoise hover:text-brand-turquoise/80 underline"
className="text-brand-turquoise hover:text-brand-navy/80 underline"
>
{t('banner.learnMore')}
</Link>
@ -107,14 +107,14 @@ export default function CookieConsent() {
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
<button
onClick={() => setShowSettings(true)}
className="flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
className="flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
>
<Settings className="w-4 h-4" />
{t('banner.customize')}
</button>
<button
onClick={acceptEssentialOnly}
className="px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
className="px-4 py-2.5 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
>
{t('banner.essentialOnly')}
</button>
@ -144,7 +144,7 @@ export default function CookieConsent() {
</div>
<button
onClick={() => setShowSettings(false)}
className="p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100 transition-colors"
className="p-1 text-neutral-400 hover:text-neutral-600 rounded-full hover:bg-neutral-100 transition-colors"
aria-label={t('settings.closeAria')}
>
<X className="w-5 h-5" />
@ -153,17 +153,17 @@ export default function CookieConsent() {
<div className="grid gap-3 mb-6 max-h-[40vh] overflow-y-auto pr-2">
{/* Essential Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex items-start justify-between p-4 bg-neutral-50 rounded-lg border border-border">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold text-gray-900">
<h4 className="text-sm font-semibold text-brand-navy">
{t('settings.essential.title')}
</h4>
<span className="px-2 py-0.5 text-xs font-medium text-brand-navy bg-brand-navy/10 rounded-full">
{t('settings.essential.alwaysOn')}
</span>
</div>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-neutral-600">
{t('settings.essential.body')}
</p>
</div>
@ -172,18 +172,18 @@ export default function CookieConsent() {
type="checkbox"
checked={true}
disabled
className="h-5 w-5 text-brand-navy border-gray-300 rounded cursor-not-allowed opacity-60"
className="h-5 w-5 text-brand-navy border-neutral-300 rounded cursor-not-allowed opacity-60"
/>
</div>
</div>
{/* Functional Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex items-start justify-between p-4 bg-neutral-50 rounded-lg border border-border">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">
<h4 className="text-sm font-semibold text-brand-navy">
{t('settings.functional.title')}
</h4>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-neutral-600">
{t('settings.functional.body')}
</p>
</div>
@ -194,18 +194,18 @@ export default function CookieConsent() {
onChange={e =>
setLocalPrefs({ ...localPrefs, functional: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
className="h-5 w-5 text-brand-navy border-neutral-300 rounded focus:ring-brand-turquoise cursor-pointer"
/>
</div>
</div>
{/* Analytics Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex items-start justify-between p-4 bg-neutral-50 rounded-lg border border-border">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">
<h4 className="text-sm font-semibold text-brand-navy">
{t('settings.analytics.title')}
</h4>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-neutral-600">
{t('settings.analytics.body')}
</p>
</div>
@ -216,18 +216,18 @@ export default function CookieConsent() {
onChange={e =>
setLocalPrefs({ ...localPrefs, analytics: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
className="h-5 w-5 text-brand-navy border-neutral-300 rounded focus:ring-brand-turquoise cursor-pointer"
/>
</div>
</div>
{/* Marketing Cookies */}
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
<div className="flex items-start justify-between p-4 bg-neutral-50 rounded-lg border border-border">
<div className="flex-1">
<h4 className="text-sm font-semibold text-gray-900">
<h4 className="text-sm font-semibold text-brand-navy">
{t('settings.marketing.title')}
</h4>
<p className="mt-1 text-sm text-gray-600">
<p className="mt-1 text-sm text-neutral-600">
{t('settings.marketing.body')}
</p>
</div>
@ -238,7 +238,7 @@ export default function CookieConsent() {
onChange={e =>
setLocalPrefs({ ...localPrefs, marketing: e.target.checked })
}
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
className="h-5 w-5 text-brand-navy border-neutral-300 rounded focus:ring-brand-turquoise cursor-pointer"
/>
</div>
</div>
@ -254,13 +254,13 @@ export default function CookieConsent() {
</button>
<button
onClick={acceptAll}
className="flex-1 px-6 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
className="flex-1 px-6 py-2.5 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
>
{t('settings.acceptAll')}
</button>
</div>
<p className="mt-4 text-xs text-gray-500 text-center">
<p className="mt-4 text-xs text-neutral-500 text-center">
{t('settings.footer')}{' '}
<Link href="/cookies" className="text-brand-turquoise hover:underline">
{t('settings.policyLink')}

View File

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

View File

@ -10,6 +10,7 @@ import { useState, useRef, useEffect } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Download, FileSpreadsheet, FileText, ChevronDown, Lock } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import { useToast } from '@/components/ui/toast';
interface ExportButtonProps<T> {
data: T[];
@ -28,6 +29,7 @@ export default function ExportButton<T extends Record<string, any>>({
columns,
disabled = false,
}: ExportButtonProps<T>) {
const { toast } = useToast();
const t = useTranslations('components.exportButton');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
@ -96,7 +98,7 @@ export default function ExportButton<T extends Record<string, any>>({
downloadFile(csv, 'text/csv', 'csv');
} catch (error) {
console.error('Export CSV error:', error);
alert(t('errorCsv'));
toast.error(t('errorCsv'));
} finally {
setIsExporting(false);
setIsOpen(false);
@ -153,7 +155,7 @@ export default function ExportButton<T extends Record<string, any>>({
downloadFile(xmlContent, 'application/vnd.ms-excel', 'xls');
} catch (error) {
console.error('Export Excel error:', error);
alert(t('errorExcel'));
toast.error(t('errorExcel'));
} finally {
setIsExporting(false);
setIsOpen(false);
@ -175,14 +177,14 @@ export default function ExportButton<T extends Record<string, any>>({
onClick={() => (canExport ? setIsOpen(!isOpen) : (window.location.href = '/pricing'))}
disabled={disabled || data.length === 0 || isExporting}
title={!canExport ? t('lockedTooltip') : undefined}
className={`inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
canExport ? 'text-gray-700 hover:bg-gray-50' : 'text-gray-400 hover:bg-gray-50'
className={`inline-flex items-center px-4 py-2 border border-neutral-300 text-sm font-medium rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
canExport ? 'text-neutral-700 hover:bg-neutral-50' : 'text-neutral-400 hover:bg-neutral-50'
}`}
>
{isExporting ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-600"
className="animate-spin -ml-1 mr-2 h-4 w-4 text-neutral-600"
fill="none"
viewBox="0 0 24 24"
>
@ -213,31 +215,31 @@ export default function ExportButton<T extends Record<string, any>>({
{/* Dropdown Menu */}
{isOpen && !isExporting && canExport && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-border z-50">
<div className="py-1">
<button
onClick={handleExportCSV}
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
className="flex items-center w-full px-4 py-2.5 text-sm text-neutral-700 hover:bg-neutral-50 transition-colors"
>
<FileText className="mr-3 h-4 w-4 text-green-600" />
<div className="text-left">
<div className="font-medium">{t('csvTitle')}</div>
<div className="text-xs text-gray-500">{t('csvSubtitle')}</div>
<div className="text-xs text-neutral-500">{t('csvSubtitle')}</div>
</div>
</button>
<button
onClick={handleExportExcel}
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
className="flex items-center w-full px-4 py-2.5 text-sm text-neutral-700 hover:bg-neutral-50 transition-colors"
>
<FileSpreadsheet className="mr-3 h-4 w-4 text-blue-600" />
<FileSpreadsheet className="mr-3 h-4 w-4 text-brand-navy" />
<div className="text-left">
<div className="font-medium">{t('excelTitle')}</div>
<div className="text-xs text-gray-500">{t('excelSubtitle')}</div>
<div className="text-xs text-neutral-500">{t('excelSubtitle')}</div>
</div>
</button>
</div>
<div className="border-t border-gray-100 px-4 py-2">
<p className="text-xs text-gray-500">{t('rowsToExport', { count: data.length })}</p>
<div className="border-t border-border px-4 py-2">
<p className="text-xs text-neutral-500">{t('rowsToExport', { count: data.length })}</p>
</div>
</div>
)}

View File

@ -67,7 +67,7 @@ export function LanguageSwitcher({ variant = 'dark', className = '' }: LanguageS
const isDark = variant === 'dark';
const buttonClasses = isDark
? 'text-white hover:text-brand-turquoise'
: 'text-gray-700 hover:text-brand-navy';
: 'text-neutral-700 hover:text-brand-navy';
return (
<div ref={containerRef} className={`relative ${className}`}>
@ -90,7 +90,7 @@ export function LanguageSwitcher({ variant = 'dark', className = '' }: LanguageS
{isOpen && (
<ul
role="listbox"
className="absolute right-0 top-full mt-2 min-w-[160px] bg-white rounded-xl shadow-xl border border-gray-100 overflow-hidden z-50"
className="absolute right-0 top-full mt-2 min-w-[160px] bg-white rounded-xl shadow-xl border border-border overflow-hidden z-50"
>
{routing.locales.map(loc => {
const active = loc === locale;
@ -104,7 +104,7 @@ export function LanguageSwitcher({ variant = 'dark', className = '' }: LanguageS
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors ${
active
? 'bg-brand-turquoise/10 text-brand-navy font-semibold'
: 'text-gray-700 hover:bg-gray-50'
: 'text-neutral-700 hover:bg-neutral-50'
}`}
>
<span aria-hidden="true">{LANGUAGE_FLAGS[loc] ?? ''}</span>

View File

@ -121,7 +121,7 @@ export default function NotificationDropdown() {
{/* Notification Bell Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
className="relative p-2 text-neutral-600 hover:text-brand-navy hover:bg-neutral-100 rounded-lg transition-colors"
aria-label={t('ariaLabel')}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -141,15 +141,15 @@ export default function NotificationDropdown() {
{/* Dropdown Panel */}
{isOpen && (
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-border z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b">
<h3 className="text-sm font-semibold text-gray-900">{t('header')}</h3>
<h3 className="text-sm font-semibold text-brand-navy">{t('header')}</h3>
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="text-xs text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
className="text-xs text-brand-navy hover:text-brand-turquoise font-medium disabled:opacity-50"
>
{t('markAllRead')}
</button>
@ -159,11 +159,11 @@ export default function NotificationDropdown() {
{/* Notifications List */}
<div className="max-h-96 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-sm text-gray-500">{t('loading')}</div>
<div className="p-4 text-center text-sm text-neutral-500">{t('loading')}</div>
) : notifications.length === 0 ? (
<div className="p-8 text-center">
<Bell className="h-10 w-10 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">{t('empty')}</p>
<Bell className="h-10 w-10 text-neutral-300 mx-auto mb-2" />
<p className="text-sm text-neutral-500">{t('empty')}</p>
</div>
) : (
<div className="divide-y">
@ -172,31 +172,31 @@ export default function NotificationDropdown() {
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`block px-4 py-3 hover:bg-gray-50 transition-colors cursor-pointer ${
!notification.read ? 'bg-blue-50' : ''
className={`block px-4 py-3 hover:bg-neutral-50 transition-colors cursor-pointer ${
!notification.read ? 'bg-brand-turquoise/5' : ''
}`}
>
<div className="flex items-start space-x-3">
<div className="flex-shrink-0">
{(() => {
const Icon = getNotificationIcon(notification.type);
return <Icon className="h-5 w-5 text-gray-500" />;
return <Icon className="h-5 w-5 text-neutral-500" />;
})()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium text-gray-900 truncate">
<p className="text-sm font-medium text-brand-navy truncate">
{notification.title}
</p>
{!notification.read && (
<span className="w-2 h-2 bg-blue-600 rounded-full"></span>
<span className="w-2 h-2 bg-brand-turquoise rounded-full"></span>
)}
</div>
<p className="text-sm text-gray-600 line-clamp-2">
<p className="text-sm text-neutral-600 line-clamp-2">
{notification.message}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
<span className="text-xs text-neutral-500">
{formatTime(notification.createdAt)}
</span>
</div>
@ -210,13 +210,13 @@ export default function NotificationDropdown() {
</div>
{/* Footer */}
<div className="px-4 py-3 border-t bg-gray-50">
<div className="px-4 py-3 border-t bg-neutral-50">
<button
onClick={() => {
setIsOpen(false);
setIsPanelOpen(true);
}}
className="w-full text-center text-sm text-blue-600 hover:text-blue-800 font-medium py-2 hover:bg-blue-50 rounded transition-colors"
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"
>
{t('viewAll')}
</button>

View File

@ -34,6 +34,7 @@ import {
User,
Building2,
} from 'lucide-react';
import { useConfirm } from '@/components/ui/use-confirm';
interface NotificationPanelProps {
isOpen: boolean;
@ -41,6 +42,7 @@ interface NotificationPanelProps {
}
export default function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
const confirm = useConfirm();
const t = useTranslations('components.notificationPanel');
const tTime = useTranslations('components.notificationDropdown.time');
const locale = useLocale();
@ -97,9 +99,9 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
}
};
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
const handleDelete = async (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
if (confirm(t('deleteConfirm'))) {
if (await confirm({ title: t('deleteConfirm'), destructive: true })) {
deleteNotificationMutation.mutate(notificationId);
}
};
@ -109,9 +111,9 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
urgent: 'border-l-4 border-red-500 bg-red-50',
high: 'border-l-4 border-orange-500 bg-orange-50',
medium: 'border-l-4 border-yellow-500 bg-yellow-50',
low: 'border-l-4 border-blue-500 bg-blue-50',
low: 'border-l-4 border-brand-turquoise bg-brand-turquoise/5',
};
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300';
return colors[priority as keyof typeof colors] || 'border-l-4 border-neutral-300';
};
const getNotificationIconComponent = (type: string) => {
@ -163,12 +165,12 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b bg-white">
<div>
<h2 className="text-xl font-bold text-gray-900">{t('title')}</h2>
<p className="text-sm text-gray-600 mt-1">{t('totalCount', { count: total })}</p>
<h2 className="text-xl font-bold text-brand-navy">{t('title')}</h2>
<p className="text-sm text-neutral-600 mt-1">{t('totalCount', { count: total })}</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors text-neutral-600"
aria-label={t('closeAria')}
>
<X className="w-6 h-6" />
@ -176,9 +178,9 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
</div>
{/* Filter Bar */}
<div className="flex items-center justify-between px-6 py-3 border-b bg-gray-50">
<div className="flex items-center justify-between px-6 py-3 border-b bg-neutral-50">
<div className="flex items-center space-x-2">
<Filter className="w-4 h-4 text-gray-500" />
<Filter className="w-4 h-4 text-neutral-500" />
<div className="flex space-x-1">
{(['all', 'unread', 'read'] as const).map(filter => (
<button
@ -189,8 +191,8 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
}}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
selectedFilter === filter
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
? 'bg-brand-turquoise text-brand-navy'
: 'bg-white text-neutral-700 hover:bg-neutral-100 border border-neutral-300'
}`}
>
{t(`filters.${filter}` as any)}
@ -202,7 +204,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<button
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
className="flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
className="flex items-center space-x-1 text-sm text-brand-navy hover:text-brand-turquoise font-medium disabled:opacity-50"
>
<CheckCheck className="w-4 h-4" />
<span>{t('markAllRead')}</span>
@ -215,16 +217,16 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
<p className="text-sm text-gray-500">{t('loading')}</p>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-turquoise mx-auto mb-4" />
<p className="text-sm text-neutral-500">{t('loading')}</p>
</div>
</div>
) : notifications.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<Bell className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('emptyTitle')}</h3>
<p className="text-sm text-gray-500">
<Bell className="h-16 w-16 text-neutral-300 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-brand-navy mb-2">{t('emptyTitle')}</h3>
<p className="text-sm text-neutral-500">
{selectedFilter === 'unread' ? t('emptyUnread') : t('emptyAll')}
</p>
</div>
@ -235,8 +237,8 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={`p-6 hover:bg-gray-50 transition-all cursor-pointer group ${
!notification.read ? 'bg-blue-50/50' : ''
className={`p-6 hover:bg-neutral-50 transition-all cursor-pointer group ${
!notification.read ? 'bg-brand-turquoise/5/50' : ''
} ${getPriorityColor(notification.priority || 'low')}`}
>
<div className="flex items-start space-x-4">
@ -244,7 +246,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<div className="flex-shrink-0">
{(() => {
const Icon = getNotificationIconComponent(notification.type);
return <Icon className="h-6 w-6 text-gray-500" />;
return <Icon className="h-6 w-6 text-neutral-500" />;
})()}
</div>
@ -252,11 +254,11 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center space-x-2">
<h3 className="text-base font-semibold text-gray-900">
<h3 className="text-base font-semibold text-brand-navy">
{notification.title}
</h3>
{!notification.read && (
<span className="w-2.5 h-2.5 bg-blue-600 rounded-full" />
<span className="w-2.5 h-2.5 bg-brand-turquoise rounded-full" />
)}
</div>
<button
@ -268,15 +270,15 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
</button>
</div>
<p className="text-sm text-gray-700 mb-3 leading-relaxed">
<p className="text-sm text-neutral-700 mb-3 leading-relaxed">
{notification.message}
</p>
{/* Metadata */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-xs text-gray-500">
<div className="flex items-center space-x-4 text-xs text-neutral-500">
<span className="font-medium">{formatTime(notification.createdAt)}</span>
<span className="px-2 py-0.5 bg-gray-200 rounded-full text-gray-700 font-medium">
<span className="px-2 py-0.5 bg-neutral-200 rounded-full text-neutral-700 font-medium">
{notification.type.replace(/_/g, ' ').toUpperCase()}
</span>
{notification.priority && (
@ -288,7 +290,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
? 'bg-orange-100 text-orange-700'
: notification.priority === 'medium'
? 'bg-yellow-100 text-yellow-700'
: 'bg-blue-100 text-blue-700'
: 'bg-brand-turquoise/10 text-brand-navy'
}`}
>
{notification.priority.toUpperCase()}
@ -296,7 +298,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
)}
</div>
{notification.actionUrl && (
<span className="text-xs text-blue-600 font-medium group-hover:underline">
<span className="text-xs text-brand-navy font-medium group-hover:underline">
{t('viewDetails')}
</span>
)}
@ -311,22 +313,22 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-4 border-t bg-gray-50">
<div className="text-sm text-gray-600">
<div className="flex items-center justify-between px-6 py-4 border-t bg-neutral-50">
<div className="text-sm text-neutral-600">
{t('pagination.page', { current: currentPage, total: totalPages })}
</div>
<div className="flex space-x-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.previous')}
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1.5 text-sm font-medium bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.next')}
</button>

View File

@ -62,7 +62,7 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
Carrier Name <span className="text-red-500">*</span>
</label>
<input
@ -70,13 +70,13 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* SCAC */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
SCAC Code <span className="text-red-500">*</span>
</label>
<input
@ -85,17 +85,17 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
maxLength={4}
value={formData.scac}
onChange={e => setFormData({ ...formData, scac: e.target.value.toUpperCase() })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">Status</label>
<select
value={formData.status}
onChange={e => setFormData({ ...formData, status: e.target.value as CarrierStatus })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value={CarrierStatus.ACTIVE}>Active</option>
<option value={CarrierStatus.INACTIVE}>Inactive</option>
@ -105,44 +105,44 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Priority</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">Priority</label>
<input
type="number"
min="1"
max="100"
value={formData.priority}
onChange={e => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* API Endpoint */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">API Endpoint</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">API Endpoint</label>
<input
type="url"
value={formData.apiEndpoint}
onChange={e => setFormData({ ...formData, apiEndpoint: e.target.value })}
placeholder="https://api.carrier.com/v1"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* API Key */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">API Key</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={formData.apiKey}
onChange={e => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Enter API key"
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 pr-10 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
className="absolute right-2 top-1/2 -translate-y-1/2 text-neutral-500 hover:text-neutral-700"
tabIndex={-1}
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
>
@ -153,7 +153,7 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
{/* Rate Limit */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
Rate Limit (req/min)
</label>
<input
@ -161,38 +161,38 @@ export const CarrierForm: React.FC<CarrierFormProps> = ({ carrier, onSubmit, onC
min="1"
value={formData.rateLimit}
onChange={e => setFormData({ ...formData, rateLimit: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Timeout */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Timeout (ms)</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">Timeout (ms)</label>
<input
type="number"
min="1000"
step="1000"
value={formData.timeout}
onChange={e => setFormData({ ...formData, timeout: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-6 border-t border-gray-200">
<div className="flex justify-end gap-3 pt-6 border-t border-border">
<button
type="button"
onClick={onCancel}
disabled={submitting}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
className="px-4 py-2 border border-neutral-300 rounded-md text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
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"
>
{submitting ? 'Saving...' : carrier ? 'Update Carrier' : 'Create Carrier'}
</button>

View File

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

View File

@ -37,6 +37,7 @@ import {
} from 'lucide-react';
import { upload } from '@/lib/api/client';
import { useState } from 'react';
import { useToast } from '@/components/ui/toast';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
@ -63,7 +64,7 @@ function ToolbarButton({ onClick, active, disabled, title, children }: ToolbarBu
disabled={disabled}
title={title}
className={`p-1.5 rounded transition-colors ${
active ? 'bg-brand-navy text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
active ? 'bg-brand-navy text-white' : 'text-neutral-600 hover:bg-neutral-100 hover:text-brand-navy'
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
>
{children}
@ -72,7 +73,7 @@ function ToolbarButton({ onClick, active, disabled, title, children }: ToolbarBu
}
function Separator() {
return <div className="w-px h-5 bg-gray-200 mx-1" />;
return <div className="w-px h-5 bg-neutral-200 mx-1" />;
}
function Toolbar({
@ -96,7 +97,7 @@ function Toolbar({
}, [editor]);
return (
<div className="flex flex-wrap items-center gap-0.5 p-2 border-b border-gray-200 bg-gray-50 rounded-t-lg">
<div className="flex flex-wrap items-center gap-0.5 p-2 border-b border-border bg-neutral-50 rounded-t-lg">
{/* Undo / Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
@ -251,7 +252,7 @@ function Toolbar({
onClick={onImageUpload}
disabled={uploading}
title="Insérer une image"
className="p-1.5 rounded text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors disabled:opacity-40"
className="p-1.5 rounded text-neutral-600 hover:bg-neutral-100 hover:text-brand-navy transition-colors disabled:opacity-40"
>
{uploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
@ -274,7 +275,7 @@ function Toolbar({
<div className="relative" title="Couleur du texte">
<input
type="color"
className="w-6 h-6 rounded cursor-pointer border border-gray-300 p-0.5"
className="w-6 h-6 rounded cursor-pointer border border-neutral-300 p-0.5"
value={editor.getAttributes('textStyle').color || '#000000'}
onChange={e => editor.chain().focus().setColor(e.target.value).run()}
title="Couleur du texte"
@ -294,6 +295,7 @@ export function RichTextEditor({
placeholder = 'Écrivez votre article...',
minHeight = 400,
}: RichTextEditorProps) {
const { toast } = useToast();
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -337,7 +339,7 @@ export function RichTextEditor({
const MAX_SIZE = 5 * 1024 * 1024;
if (file.size > MAX_SIZE) {
alert('Image trop volumineuse (max 5 Mo)');
toast.error('Image trop volumineuse (max 5 Mo)');
return;
}
@ -354,7 +356,7 @@ export function RichTextEditor({
const src = result.url.startsWith('http') ? result.url : `${API_BASE_URL}${result.url}`;
editor.chain().focus().setImage({ src, alt: file.name }).run();
} catch (err: any) {
alert(err.message || "Erreur lors de l'upload de l'image");
toast.error(err.message || "Erreur lors de l'upload de l'image");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
@ -366,7 +368,7 @@ export function RichTextEditor({
if (!editor) return null;
return (
<div className="border border-gray-300 rounded-lg overflow-hidden focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-transparent">
<div className="border border-neutral-300 rounded-lg overflow-hidden focus-within:ring-2 focus-within:ring-ring focus-within:border-transparent">
<Toolbar editor={editor} onImageUpload={handleImageUpload} uploading={uploading} />
<EditorContent editor={editor} />
<input

View File

@ -0,0 +1,161 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { CreditCard, Pencil } from 'lucide-react';
import { Link } from '@/i18n/navigation';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Callout } from '@/components/ui/callout';
import { BookingStatusBadge } from './BookingStatusBadge';
/**
* Detail d'une reservation.
*
* Extrait de la page de liste, ou il etait ecrit en ligne avec une superposition
* maison sans focus trap ni fermeture au clavier.
*/
interface BookingDetailDialogProps {
booking: any | null;
onOpenChange: (open: boolean) => void;
buildEditUrl: (booking: any) => string;
}
export function BookingDetailDialog({
booking,
onOpenChange,
buildEditUrl,
}: BookingDetailDialogProps) {
const t = useTranslations('dashboard.bookingsList');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
if (!booking) return null;
const longDate = (value?: string) =>
value
? new Date(value).toLocaleDateString(dateLocale, {
day: '2-digit',
month: 'long',
year: 'numeric',
})
: 'N/A';
const reference = booking.bookingId
? `#${booking.bookingId}`
: `#${booking.id?.slice(0, 8).toUpperCase()}`;
return (
<Dialog open={Boolean(booking)} onOpenChange={onOpenChange}>
<DialogContent size="lg">
<DialogHeader>
<DialogTitle>
{t('detail.title')} {reference}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<BookingStatusBadge status={booking.status} />
{booking.carrierName && (
<span className="text-body-sm text-neutral-500">{booking.carrierName}</span>
)}
</div>
<dl className="grid gap-4 sm:grid-cols-2">
<Field label={t('detail.route')}>
{booking.origin || booking.originCity || 'N/A'}
{' → '}
{booking.destination || booking.destinationCity || 'N/A'}
</Field>
<Field label={t('detail.date')}>
{longDate(booking.createdAt || booking.requestedAt)}
</Field>
<Field label={t('detail.cargo')}>
{booking.palletCount
? t('units.palletsCount', { count: booking.palletCount })
: '—'}
</Field>
<Field label={t('detail.weight')}>
{booking.weightKG ? t('units.kg', { value: booking.weightKG }) : 'N/A'}
{booking.volumeCBM ? ` / ${t('units.cbm', { value: booking.volumeCBM })}` : ''}
</Field>
{booking.requestedPickupDate && (
<Field label={t('detail.pickupDate')}>{longDate(booking.requestedPickupDate)}</Field>
)}
{(booking.priceEUR || booking.priceUSD) && (
<Field label={t('detail.price')} emphasis>
{booking.priceEUR ? `${booking.priceEUR} €` : `${booking.priceUSD} $`}
</Field>
)}
</dl>
{booking.status === 'PENDING_PAYMENT' && (
<Callout variant="warning">{t('detail.pendingPaymentNotice')}</Callout>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('detail.close')}
</Button>
{booking.status === 'PENDING_PAYMENT' && (
<>
<Button asChild variant="outline">
<Link href={buildEditUrl(booking)} onClick={() => onOpenChange(false)}>
<Pencil />
{t('actions.edit')}
</Link>
</Button>
<Button asChild>
<Link
href={`/dashboard/booking/${booking.id}/pay`}
onClick={() => onOpenChange(false)}
>
<CreditCard />
{t('detail.payNow')}
</Link>
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({
label,
children,
emphasis = false,
}: {
label: string;
children: React.ReactNode;
emphasis?: boolean;
}) {
return (
<div className="min-w-0">
<dt className="text-label uppercase text-neutral-400">{label}</dt>
<dd
className={
emphasis
? 'mt-1 text-body-sm font-semibold text-brand-navy'
: 'mt-1 text-body-sm text-neutral-700'
}
>
{children}
</dd>
</div>
);
}

View File

@ -37,17 +37,17 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
}).length;
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4 mb-6">
<div className="bg-white rounded-lg shadow-sm border border-border p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">{t('title')}</h3>
<h3 className="text-lg font-semibold text-brand-navy">{t('title')}</h3>
<div className="flex gap-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="text-sm text-blue-600 hover:text-blue-700"
className="text-sm text-brand-navy hover:text-brand-turquoise"
>
{isExpanded ? t('showLess') : t('showMore')}
</button>
<button onClick={onReset} className="text-sm text-gray-600 hover:text-gray-700">
<button onClick={onReset} className="text-sm text-neutral-600 hover:text-neutral-700">
{t('reset')}
</button>
</div>
@ -57,44 +57,44 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('search')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('search')}</label>
<input
type="text"
placeholder={t('searchPlaceholder')}
value={filters.search || ''}
onChange={e => onFiltersChange({ search: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Carrier */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('carrier')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('carrier')}</label>
<input
type="text"
placeholder={t('carrierPlaceholder')}
value={filters.carrier || ''}
onChange={e => onFiltersChange({ carrier: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Origin Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('originPort')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('originPort')}</label>
<input
type="text"
placeholder={t('portCodePlaceholder')}
value={filters.originPort || ''}
onChange={e => onFiltersChange({ originPort: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
{/* Status filters */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">{t('status')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-2">{t('status')}</label>
<div className="flex flex-wrap gap-2">
{Object.values(BookingStatus).map((status: BookingStatus) => (
<button
@ -102,8 +102,8 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
onClick={() => handleStatusChange(status)}
className={`px-3 py-1 rounded-full text-sm font-medium transition-colors ${
filters.status?.includes(status)
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
? 'bg-brand-turquoise text-brand-navy'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
{(status as string).replace('_', ' ').toUpperCase()}
@ -114,10 +114,10 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
{/* Expanded filters */}
{isExpanded && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 pt-4 border-t border-gray-200">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 pt-4 border-t border-border">
{/* Destination Port */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('destinationPort')}
</label>
<input
@ -125,87 +125,87 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
placeholder={t('portCodePlaceholder')}
value={filters.destinationPort || ''}
onChange={e => onFiltersChange({ destinationPort: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Shipper */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('shipper')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('shipper')}</label>
<input
type="text"
placeholder={t('shipperPlaceholder')}
value={filters.shipper || ''}
onChange={e => onFiltersChange({ shipper: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Consignee */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('consignee')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('consignee')}</label>
<input
type="text"
placeholder={t('consigneePlaceholder')}
value={filters.consignee || ''}
onChange={e => onFiltersChange({ consignee: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Created From */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('createdFrom')}
</label>
<input
type="date"
value={filters.createdFrom || ''}
onChange={e => onFiltersChange({ createdFrom: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Created To */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('createdTo')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('createdTo')}</label>
<input
type="date"
value={filters.createdTo || ''}
onChange={e => onFiltersChange({ createdTo: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* ETD From */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('etdFrom')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('etdFrom')}</label>
<input
type="date"
value={filters.etdFrom || ''}
onChange={e => onFiltersChange({ etdFrom: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* ETD To */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('etdTo')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('etdTo')}</label>
<input
type="date"
value={filters.etdTo || ''}
onChange={e => onFiltersChange({ etdTo: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
{/* Sort By */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('sortBy')}</label>
<label className="block text-sm font-medium text-neutral-700 mb-1">{t('sortBy')}</label>
<select
value={filters.sortBy || 'createdAt'}
onChange={e => onFiltersChange({ sortBy: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-neutral-300 rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="createdAt">{t('sortOptions.createdAt')}</option>
<option value="bookingNumber">{t('sortOptions.bookingNumber')}</option>
@ -219,7 +219,7 @@ export const BookingFilters: React.FC<BookingFiltersProps> = ({
{/* Active filters count */}
{activeCount > 0 && (
<div className="mt-4 text-sm text-gray-600">{t('activeCount', { count: activeCount })}</div>
<div className="mt-4 text-sm text-neutral-600">{t('activeCount', { count: activeCount })}</div>
)}
</div>
);

View File

@ -0,0 +1,41 @@
'use client';
import { useTranslations } from 'next-intl';
import { Badge, type BadgeProps } from '@/components/ui/badge';
/**
* Statut de reservation.
*
* Centralise la correspondance statut → variante semantique, qui etait
* dupliquee sous forme de tables de classes Tailwind dans plusieurs pages.
*/
export const BOOKING_STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
PENDING_PAYMENT: 'warning',
PENDING_BANK_TRANSFER: 'info',
PENDING: 'warning',
ACCEPTED: 'success',
REJECTED: 'danger',
CANCELLED: 'neutral',
};
const STATUS_KEY: Record<string, string> = {
PENDING_PAYMENT: 'pendingPayment',
PENDING_BANK_TRANSFER: 'pendingBankTransfer',
PENDING: 'pending',
ACCEPTED: 'accepted',
REJECTED: 'rejected',
CANCELLED: 'cancelled',
};
export function useBookingStatusLabel() {
const t = useTranslations('dashboard.bookingsList');
return (status: string) => {
const key = STATUS_KEY[status];
return key ? t(`status.${key}` as any) : status;
};
}
export function BookingStatusBadge({ status }: { status: string }) {
const label = useBookingStatusLabel();
return <Badge variant={BOOKING_STATUS_VARIANT[status] ?? 'neutral'}>{label(status)}</Badge>;
}

View File

@ -50,7 +50,7 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
type="checkbox"
checked={selectedBookings.size === bookings.length && bookings.length > 0}
onChange={onToggleAll}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
className="w-4 h-4 text-brand-navy rounded focus:ring-2 focus:ring-ring"
/>
),
cell: ({ row }) => (
@ -59,7 +59,7 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
checked={selectedBookings.has(row.original.id)}
onChange={() => onToggleSelection(row.original.id)}
onClick={e => e.stopPropagation()}
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
className="w-4 h-4 text-brand-navy rounded focus:ring-2 focus:ring-ring"
/>
),
size: 50,
@ -68,7 +68,7 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
accessorKey: 'bookingNumber',
header: t('headers.bookingNumber'),
cell: info => (
<span className="font-medium text-blue-600">{info.getValue() as string}</span>
<span className="font-medium text-brand-navy">{info.getValue() as string}</span>
),
size: 150,
},
@ -195,16 +195,16 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end || 0) : 0;
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div className="bg-white rounded-lg shadow-sm border border-border overflow-hidden">
<div ref={tableContainerRef} className="overflow-auto" style={{ height: '600px' }}>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50 sticky top-0 z-10">
<table className="min-w-full divide-y divide-border">
<thead className="bg-neutral-50 sticky top-0 z-10">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider cursor-pointer hover:bg-neutral-100"
style={{ width: header.getSize() }}
onClick={header.column.getToggleSortingHandler()}
>
@ -219,7 +219,7 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white divide-y divide-border">
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
@ -231,15 +231,15 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
<tr
key={row.id}
onClick={() => onRowClick?.(row.original)}
className={`hover:bg-gray-50 cursor-pointer ${
selectedBookings.has(row.original.id) ? 'bg-blue-50' : ''
className={`hover:bg-neutral-50 cursor-pointer ${
selectedBookings.has(row.original.id) ? 'bg-brand-turquoise/5' : ''
}`}
style={{ height: `${virtualRow.size}px` }}
>
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
className="px-6 py-4 whitespace-nowrap text-sm text-brand-navy"
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
@ -263,9 +263,9 @@ export const BookingsTable: React.FC<BookingsTableProps> = ({
function getStatusColor(status: BookingStatus): string {
switch (status) {
case BookingStatus.DRAFT:
return 'bg-gray-100 text-gray-800';
return 'bg-neutral-100 text-neutral-800';
case BookingStatus.CONFIRMED:
return 'bg-blue-100 text-blue-800';
return 'bg-brand-turquoise/10 text-brand-navy';
case BookingStatus.IN_PROGRESS:
return 'bg-yellow-100 text-yellow-800';
case BookingStatus.COMPLETED:
@ -273,6 +273,6 @@ function getStatusColor(status: BookingStatus): string {
case BookingStatus.CANCELLED:
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
return 'bg-neutral-100 text-neutral-800';
}
}

View File

@ -7,6 +7,7 @@
import React, { useState } from 'react';
import { useTranslations } from 'next-intl';
import { ExportFormat, ExportOptions } from '@/types/booking';
import { useToast } from '@/components/ui/toast';
interface BulkActionsProps {
selectedCount: number;
@ -19,6 +20,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
onExport,
onClearSelection,
}) => {
const { toast } = useToast();
const t = useTranslations('components.bulkActions');
const [showExportMenu, setShowExportMenu] = useState(false);
const [exporting, setExporting] = useState(false);
@ -29,7 +31,7 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
await onExport({ format });
setShowExportMenu(false);
} catch (error: any) {
alert(t('exportFailed', { message: error.message }));
toast.error(t('exportFailed', { message: error.message }));
} finally {
setExporting(false);
}
@ -38,15 +40,15 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
if (selectedCount === 0) return null;
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="bg-brand-turquoise/5 border border-brand-turquoise/30 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-blue-900">
<span className="text-sm font-medium text-brand-navy">
{t('selectedCount', { count: selectedCount })}
</span>
<button
onClick={onClearSelection}
className="text-sm text-blue-600 hover:text-blue-700 underline"
className="text-sm text-brand-navy hover:text-brand-turquoise underline"
>
{t('clearSelection')}
</button>
@ -58,29 +60,29 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
<button
onClick={() => setShowExportMenu(!showExportMenu)}
disabled={exporting}
className="px-4 py-2 bg-white border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
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"
>
{exporting ? t('exporting') : t('exportSelected')}
</button>
{showExportMenu && !exporting && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-gray-200 z-20">
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg border border-border z-20">
<div className="py-1">
<button
onClick={() => handleExport(ExportFormat.CSV)}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t('exportCsv')}
</button>
<button
onClick={() => handleExport(ExportFormat.EXCEL)}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t('exportExcel')}
</button>
<button
onClick={() => handleExport(ExportFormat.JSON)}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
className="block w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100"
>
{t('exportJson')}
</button>
@ -91,9 +93,9 @@ export const BulkActions: React.FC<BulkActionsProps> = ({
{/* Bulk update button */}
<button
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"
className="px-4 py-2 bg-brand-turquoise text-brand-navy rounded-md text-sm font-medium hover:bg-brand-turquoise/90"
onClick={() => {
alert(t('bulkUpdateSoon'));
toast.error(t('bulkUpdateSoon'));
}}
>
{t('bulkUpdate')}

View File

@ -0,0 +1,377 @@
'use client';
import * as React from 'react';
import {
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type Row,
type SortingState,
type RowSelectionState,
} from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Checkbox } from '@/components/ui/checkbox';
import { EmptyState, ErrorState, TableSkeleton, type EmptyStateProps } from '@/components/ui/states';
import { Pagination, type PaginationProps } from '@/components/ui/pagination';
/**
* Table de donnees unique de l'application.
*
* Absorbe les 26 tables ecrites a la main, leurs 7 paginations locales et leurs
* 21 conteneurs `overflow-x-auto`. Sous `md`, elle bascule en liste de cartes
* plutot que d'imposer un defilement horizontal.
*
* Metadonnees de colonne reconnues (`column.meta`) :
* - `align` : alignement du contenu
* - `hideBelow` : palier en deca duquel la colonne est masquee
* - `cardRole` : role de la colonne dans la carte mobile
* - `headerLabel`: libelle court, utilise comme etiquette dans la carte mobile
*/
declare module '@tanstack/react-table' {
// Les parametres generiques sont imposes par la signature d'origine de
// ColumnMeta ; ils ne sont pas utilises par nos metadonnees.
interface ColumnMeta<TData extends unknown, TValue> {
align?: 'left' | 'center' | 'right';
hideBelow?: 'sm' | 'md' | 'lg' | 'xl';
cardRole?: 'title' | 'subtitle' | 'field' | 'actions' | 'hidden';
headerLabel?: string;
}
}
const hideBelowClasses = {
sm: 'hidden sm:table-cell',
md: 'hidden md:table-cell',
lg: 'hidden lg:table-cell',
xl: 'hidden xl:table-cell',
} as const;
const alignClasses = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
} as const;
export interface DataTableProps<TData> {
columns: ColumnDef<TData, any>[];
data: TData[];
loading?: boolean;
error?: {
title: string;
description?: string;
detail?: string;
retryLabel?: string;
onRetry?: () => void;
} | null;
empty: EmptyStateProps;
density?: 'compact' | 'comfortable';
getRowId?: (row: TData, index: number) => string;
onRowClick?: (row: TData) => void;
sorting?: SortingState;
onSortingChange?: (sorting: SortingState) => void;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
pagination?: PaginationProps;
/** Rendu mobile personnalise. A defaut, la carte est deduite des `meta.cardRole`. */
renderCard?: (row: Row<TData>) => React.ReactNode;
/** Palier de bascule table/cartes. */
cardBreakpoint?: 'md' | 'lg';
caption?: string;
className?: string;
}
export function DataTable<TData>({
columns,
data,
loading = false,
error = null,
empty,
density = 'compact',
getRowId,
onRowClick,
sorting,
onSortingChange,
rowSelection,
onRowSelectionChange,
pagination,
renderCard,
cardBreakpoint = 'md',
caption,
className,
}: DataTableProps<TData>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
manualPagination: true,
getRowId,
state: {
...(sorting ? { sorting } : {}),
...(rowSelection ? { rowSelection } : {}),
},
onSortingChange: onSortingChange
? updater => onSortingChange(typeof updater === 'function' ? updater(sorting ?? []) : updater)
: undefined,
onRowSelectionChange: onRowSelectionChange
? updater =>
onRowSelectionChange(
typeof updater === 'function' ? updater(rowSelection ?? {}) : updater
)
: undefined,
enableRowSelection: Boolean(onRowSelectionChange),
});
const cellPadding = density === 'compact' ? 'px-4 py-2.5' : 'px-4 py-4';
const tableVisibility = cardBreakpoint === 'md' ? 'hidden md:block' : 'hidden lg:block';
const cardVisibility = cardBreakpoint === 'md' ? 'md:hidden' : 'lg:hidden';
const shell = (children: React.ReactNode) => (
<div className={cn('overflow-hidden rounded-lg border border-border bg-white', className)}>
{children}
</div>
);
if (loading) return shell(<TableSkeleton columns={Math.min(columns.length, 6)} />);
if (error) return shell(<ErrorState {...error} />);
if (data.length === 0) return shell(<EmptyState {...empty} />);
return (
<div className={cn('overflow-hidden rounded-lg border border-border bg-white', className)}>
{/* Table, a partir du palier de bascule */}
<div className={cn(tableVisibility, 'overflow-x-auto')}>
<table className="w-full border-collapse">
{caption && <caption className="sr-only">{caption}</caption>}
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id} className="border-b border-border bg-neutral-50">
{headerGroup.headers.map(header => {
const meta = header.column.columnDef.meta;
const sortable = header.column.getCanSort();
const sorted = header.column.getIsSorted();
return (
<th
key={header.id}
scope="col"
style={header.getSize() !== 150 ? { width: header.getSize() } : undefined}
className={cn(
'px-4 py-2.5 text-label uppercase text-neutral-500',
alignClasses[meta?.align ?? 'left'],
meta?.hideBelow && hideBelowClasses[meta.hideBelow]
)}
>
{header.isPlaceholder ? null : sortable ? (
<button
type="button"
onClick={header.column.getToggleSortingHandler()}
aria-label={`Trier par ${meta?.headerLabel ?? header.column.id}`}
className={cn(
'inline-flex items-center gap-1 rounded-md transition-colors hover:text-brand-navy',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{sorted === 'asc' ? (
<ArrowUp className="size-3 text-brand-turquoise" />
) : sorted === 'desc' ? (
<ArrowDown className="size-3 text-brand-turquoise" />
) : (
<ChevronsUpDown className="size-3 text-neutral-300" />
)}
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody className="divide-y divide-border">
{table.getRowModel().rows.map(row => (
<tr
key={row.id}
data-state={row.getIsSelected() ? 'selected' : undefined}
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
className={cn(
'transition-colors',
row.getIsSelected() ? 'bg-neutral-100' : 'hover:bg-neutral-50',
onRowClick && 'cursor-pointer'
)}
>
{row.getVisibleCells().map(cell => {
const meta = cell.column.columnDef.meta;
return (
<td
key={cell.id}
className={cn(
cellPadding,
'text-body-sm text-neutral-700 align-middle',
alignClasses[meta?.align ?? 'left'],
meta?.hideBelow && hideBelowClasses[meta.hideBelow]
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Cartes, en deca du palier de bascule */}
<div className={cn(cardVisibility, 'divide-y divide-border')}>
{table.getRowModel().rows.map(row => (
<div key={row.id}>{renderCard ? renderCard(row) : <AutoCard row={row} onRowClick={onRowClick} />}</div>
))}
</div>
{pagination && <Pagination {...pagination} className="border-t-0 bg-white" />}
</div>
);
}
/**
* Carte mobile deduite des metadonnees de colonne, pour ne pas obliger chaque
* appelant a ecrire un rendu mobile.
*/
function AutoCard<TData>({
row,
onRowClick,
}: {
row: Row<TData>;
onRowClick?: (row: TData) => void;
}) {
const cells = row.getVisibleCells();
const byRole = (role: string) =>
cells.filter(c => c.column.columnDef.meta?.cardRole === role);
const titles = byRole('title');
const subtitles = byRole('subtitle');
const actions = byRole('actions');
// Sans annotation explicite, on affiche les colonnes non structurelles.
const fields = cells.filter(c => {
const role = c.column.columnDef.meta?.cardRole;
if (role) return role === 'field';
return c.column.id !== 'select';
});
const selectCell = cells.find(c => c.column.id === 'select');
return (
<div
onClick={onRowClick ? () => onRowClick(row.original) : undefined}
className={cn('flex gap-3 p-4', onRowClick && 'cursor-pointer active:bg-neutral-50')}
>
{selectCell && (
<div className="pt-0.5">
{flexRender(selectCell.column.columnDef.cell, selectCell.getContext())}
</div>
)}
<div className="min-w-0 flex-1">
{titles.length > 0 && (
<div className="text-body-sm font-medium text-brand-navy">
{titles.map(c => (
<React.Fragment key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</React.Fragment>
))}
</div>
)}
{subtitles.length > 0 && (
<div className="mt-0.5 text-body-xs text-neutral-500">
{subtitles.map(c => (
<React.Fragment key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</React.Fragment>
))}
</div>
)}
{fields.length > 0 && (
<dl className="mt-2 grid grid-cols-2 gap-x-3 gap-y-1">
{fields.map(c => {
const meta = c.column.columnDef.meta;
const label =
meta?.headerLabel ??
(typeof c.column.columnDef.header === 'string'
? c.column.columnDef.header
: c.column.id);
return (
<div key={c.id} className="min-w-0">
<dt className="truncate text-label uppercase text-neutral-400">{label}</dt>
<dd className="truncate text-body-xs text-neutral-700">
{flexRender(c.column.columnDef.cell, c.getContext())}
</dd>
</div>
);
})}
</dl>
)}
</div>
{actions.length > 0 && (
<div className="shrink-0" onClick={e => e.stopPropagation()}>
{actions.map(c => (
<React.Fragment key={c.id}>
{flexRender(c.column.columnDef.cell, c.getContext())}
</React.Fragment>
))}
</div>
)}
</div>
);
}
/**
* Colonne de selection prete a l'emploi, avec case d'en-tete a trois etats.
*/
export function selectionColumn<TData>(labels: {
selectAll: string;
selectRow: string;
}): ColumnDef<TData, unknown> {
return {
id: 'select',
size: 40,
enableSorting: false,
meta: { cardRole: 'hidden' },
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={value => table.toggleAllPageRowsSelected(Boolean(value))}
aria-label={labels.selectAll}
/>
),
cell: ({ row }) => (
<span onClick={e => e.stopPropagation()}>
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(Boolean(value))}
aria-label={labels.selectRow}
/>
</span>
),
};
}

View File

@ -0,0 +1,47 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
/**
* Liste de paires libelle / valeur, pour les pages de detail.
* Remplace les `<dl>` reecrits a la main avec des classes divergentes.
*/
export interface DescriptionItem {
label: string;
value: React.ReactNode;
/** Occupe toute la largeur de la grille. */
full?: boolean;
emphasis?: boolean;
}
export function DescriptionList({
items,
columns = 2,
className,
}: {
items: DescriptionItem[];
columns?: 1 | 2 | 3;
className?: string;
}) {
const gridCols =
columns === 1 ? '' : columns === 2 ? 'sm:grid-cols-2' : 'sm:grid-cols-2 lg:grid-cols-3';
return (
<dl className={cn('grid gap-4', gridCols, className)}>
{items.map((item, index) => (
<div key={`${item.label}-${index}`} className={cn('min-w-0', item.full && 'sm:col-span-full')}>
<dt className="text-label uppercase text-neutral-400">{item.label}</dt>
<dd
className={cn(
'mt-1 break-words text-body-sm',
item.emphasis ? 'font-semibold text-brand-navy' : 'text-neutral-700'
)}
>
{item.value ?? '—'}
</dd>
</div>
))}
</dl>
);
}

View File

@ -0,0 +1,122 @@
'use client';
import * as React from 'react';
import { SlidersHorizontal, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
/**
* Barre de filtres.
*
* Rend visibles les filtres actifs sous forme de jetons retirables et offre une
* reinitialisation globale : jusqu'ici, rien n'indiquait qu'une liste etait
* filtree, ce qui faisait passer un resultat vide pour une absence de donnees.
*
* Sous `md`, les controles passent dans un panneau lateral plutot que de
* s'empiler et de repousser le contenu.
*/
export interface ActiveFilter {
key: string;
label: string;
value: string;
onRemove: () => void;
}
export interface FilterBarProps {
/** Recherche ou controles toujours visibles. */
children?: React.ReactNode;
/** Controles de filtrage, replies dans un panneau sous `md`. */
filters?: React.ReactNode;
active?: ActiveFilter[];
onClearAll?: () => void;
labels: {
filters: string;
clearAll: string;
remove: string;
};
className?: string;
}
export function FilterBar({
children,
filters,
active = [],
onClearAll,
labels,
className,
}: FilterBarProps) {
const [panelOpen, setPanelOpen] = React.useState(false);
return (
<div className={cn('flex flex-col gap-3', className)}>
<div className="flex flex-wrap items-center gap-2">
{children}
{filters && (
<>
<div className="hidden flex-wrap items-center gap-2 md:flex">{filters}</div>
<Button
variant="outline"
size="md"
onClick={() => setPanelOpen(true)}
className="md:hidden"
>
<SlidersHorizontal />
{labels.filters}
{active.length > 0 && (
<span className="ml-1 inline-flex size-4 items-center justify-center rounded-full bg-brand-turquoise text-[10px] font-semibold text-white">
{active.length}
</span>
)}
</Button>
</>
)}
</div>
{active.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{active.map(filter => (
<span
key={filter.key}
className="inline-flex items-center gap-1 rounded-full border border-border bg-white py-0.5 pl-2.5 pr-1 text-body-xs text-neutral-700"
>
<span className="text-neutral-500">{filter.label}</span>
<span className="font-medium text-brand-navy">{filter.value}</span>
<button
type="button"
onClick={filter.onRemove}
aria-label={`${labels.remove} : ${filter.label}`}
className="rounded-full p-0.5 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-brand-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" />
</button>
</span>
))}
{onClearAll && (
<Button variant="link" size="sm" onClick={onClearAll} className="h-auto px-1">
{labels.clearAll}
</Button>
)}
</div>
)}
{filters && (
<Sheet open={panelOpen} onOpenChange={setPanelOpen}>
<SheetContent side="bottom" className="p-4">
<SheetTitle className="mb-4 font-heading text-h5 text-brand-navy">
{labels.filters}
</SheetTitle>
<div className="flex flex-col gap-3">{filters}</div>
{onClearAll && active.length > 0 && (
<Button variant="outline" onClick={onClearAll} className="mt-4 w-full">
{labels.clearAll}
</Button>
)}
</SheetContent>
</Sheet>
)}
</div>
);
}

View File

@ -0,0 +1,10 @@
/**
* Couche d'affichage de donnees : table, tuiles, filtres, recherche.
* S'appuie sur les primitives de `components/ui`.
*/
export { DataTable, selectionColumn, type DataTableProps } from './data-table';
export { StatCard, type StatCardProps } from './stat-card';
export { SearchInput, type SearchInputProps } from './search-input';
export { FilterBar, type ActiveFilter, type FilterBarProps } from './filter-bar';
export { DescriptionList, type DescriptionItem } from './description-list';

View File

@ -0,0 +1,78 @@
'use client';
import * as React from 'react';
import { Search, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
/**
* Champ de recherche a declenchement differe.
*
* L'etat local suit la frappe pour que le champ reste reactif, tandis que
* `onDebouncedChange` n'est appele qu'apres la periode de calme : cela evite de
* declencher une requete a chaque caractere.
*/
export interface SearchInputProps {
value: string;
onDebouncedChange: (value: string) => void;
placeholder?: string;
ariaLabel?: string;
clearLabel?: string;
delay?: number;
className?: string;
}
export function SearchInput({
value,
onDebouncedChange,
placeholder,
ariaLabel,
clearLabel = 'Effacer',
delay = 300,
className,
}: SearchInputProps) {
const [draft, setDraft] = React.useState(value);
const onChangeRef = React.useRef(onDebouncedChange);
React.useEffect(() => {
onChangeRef.current = onDebouncedChange;
}, [onDebouncedChange]);
// Resynchronise quand la valeur change depuis l'exterieur (URL, reinitialisation).
React.useEffect(() => {
setDraft(value);
}, [value]);
React.useEffect(() => {
if (draft === value) return;
const timer = setTimeout(() => onChangeRef.current(draft), delay);
return () => clearTimeout(timer);
}, [draft, value, delay]);
return (
<div className={cn('relative w-full sm:max-w-xs', className)}>
<Input
type="search"
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder={placeholder}
aria-label={ariaLabel ?? placeholder}
leading={<Search />}
className={draft ? 'pr-9' : undefined}
/>
{draft && (
<button
type="button"
onClick={() => {
setDraft('');
onChangeRef.current('');
}}
aria-label={clearLabel}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-brand-navy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3.5" />
</button>
)}
</div>
);
}

View File

@ -0,0 +1,80 @@
'use client';
import * as React from 'react';
import { TrendingDown, TrendingUp, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/states';
/**
* Tuile d'indicateur.
*
* Alignee a gauche : des chiffres destines a etre compares se lisent sur une
* colonne commune. Le centrage precedent cassait le balayage vertical.
*
* L'icone est un reperage secondaire, pas une decoration : elle reste discrete
* et n'introduit pas de teinte hors charte.
*/
export interface StatCardProps {
label: string;
value: React.ReactNode;
hint?: React.ReactNode;
icon?: LucideIcon;
/** Variation relative, en pourcentage. Positif = hausse. */
trend?: { value: number; label?: string; /** Une hausse est-elle souhaitable ? */ positiveIsGood?: boolean };
loading?: boolean;
className?: string;
}
export function StatCard({
label,
value,
hint,
icon: Icon,
trend,
loading = false,
className,
}: StatCardProps) {
const good = trend ? (trend.positiveIsGood ?? true) === trend.value >= 0 : undefined;
return (
<div className={cn('rounded-lg border border-border bg-white p-5', className)}>
<div className="flex items-start justify-between gap-2">
<p className="text-label uppercase text-neutral-500">{label}</p>
{Icon && <Icon className="size-4 shrink-0 text-neutral-300" aria-hidden="true" />}
</div>
{loading ? (
<>
<Skeleton className="mt-3 h-7 w-20" />
<Skeleton className="mt-2 h-3 w-28" />
</>
) : (
<>
<p className="mt-2 font-heading text-h2 text-brand-navy">{value}</p>
{(hint || trend) && (
<div className="mt-1 flex items-center gap-2">
{trend && (
<span
className={cn(
'inline-flex items-center gap-0.5 text-body-xs font-medium',
good ? 'text-success' : 'text-destructive'
)}
>
{trend.value >= 0 ? (
<TrendingUp className="size-3" aria-hidden="true" />
) : (
<TrendingDown className="size-3" aria-hidden="true" />
)}
{Math.abs(trend.value).toFixed(1)}%
{trend.label && <span className="sr-only"> {trend.label}</span>}
</span>
)}
{hint && <span className="truncate text-body-xs text-neutral-500">{hint}</span>}
</div>
)}
</>
)}
</div>
);
}

View File

@ -19,20 +19,20 @@ export function CodeBlock({ code, language = 'bash', filename }: CodeBlockProps)
};
return (
<div className="rounded-xl overflow-hidden border border-gray-800 my-4">
<div className="rounded-xl overflow-hidden border border-neutral-800 my-4">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-gray-800">
<div className="flex items-center justify-between px-4 py-2 bg-[#161b22] border-b border-neutral-800">
<div className="flex items-center gap-2">
{filename && <span className="text-xs text-gray-400 font-mono">{filename}</span>}
{filename && <span className="text-xs text-neutral-400 font-mono">{filename}</span>}
{!filename && (
<span className="text-xs font-mono px-2 py-0.5 rounded bg-gray-700 text-gray-300">
<span className="text-xs font-mono px-2 py-0.5 rounded bg-neutral-800 text-neutral-300">
{language}
</span>
)}
</div>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-200 transition-colors px-2 py-1 rounded hover:bg-gray-700"
className="flex items-center gap-1.5 text-xs text-neutral-400 hover:text-neutral-200 transition-colors px-2 py-1 rounded hover:bg-neutral-800"
>
{copied ? (
<>
@ -49,7 +49,7 @@ export function CodeBlock({ code, language = 'bash', filename }: CodeBlockProps)
</div>
{/* Code */}
<div className="bg-[#0d1117] overflow-x-auto">
<pre className="p-4 text-sm font-mono text-gray-200 leading-relaxed whitespace-pre">
<pre className="p-4 text-sm font-mono text-neutral-200 leading-relaxed whitespace-pre">
{code.trim()}
</pre>
</div>

View File

@ -31,38 +31,38 @@ import { DOC_SECTIONS, ALL_NAV_ITEMS } from '@/components/docs/docsNav';
const BRAND = {
navy: '#10183A',
turquoise: '#34CCCD',
teal: '#0e9999',
teal: '#34CCCD', // brand-turquoise
};
// ─── Reusable primitives ──────────────────────────────────────────────────────
function H1({ children }: { children: React.ReactNode }) {
return <h1 className="text-3xl font-bold text-gray-900 mb-2 tracking-tight">{children}</h1>;
return <h1 className="text-3xl font-bold text-brand-navy mb-2 tracking-tight">{children}</h1>;
}
function H2({ children }: { children: React.ReactNode }) {
return (
<h2 className="text-lg font-semibold text-gray-900 mt-10 mb-3 flex items-center gap-2">
<span className="block w-1 h-5 rounded-full bg-[#34CCCD] flex-shrink-0" />
<h2 className="text-lg font-semibold text-brand-navy mt-10 mb-3 flex items-center gap-2">
<span className="block w-1 h-5 rounded-full bg-brand-turquoise flex-shrink-0" />
{children}
</h2>
);
}
function H3({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-sm font-semibold text-gray-800 uppercase tracking-wider mt-7 mb-3 opacity-70">
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wider mt-7 mb-3 opacity-70">
{children}
</h3>
);
}
function P({ children }: { children: React.ReactNode }) {
return <p className="text-gray-600 leading-relaxed mb-4 text-[15px]">{children}</p>;
return <p className="text-neutral-600 leading-relaxed mb-4 text-[15px]">{children}</p>;
}
function Divider() {
return <hr className="border-gray-100 my-8" />;
return <hr className="border-border my-8" />;
}
function InlineCode({ children }: { children: React.ReactNode }) {
return (
<code className="px-1.5 py-0.5 rounded-md bg-gray-100 text-[#10183A] text-[13px] font-mono border border-gray-200">
<code className="px-1.5 py-0.5 rounded-md bg-neutral-100 text-[#10183A] text-[13px] font-mono border border-border">
{children}
</code>
);
@ -77,11 +77,11 @@ function Callout({
}) {
const styles = {
info: {
border: 'border-blue-200',
bg: 'bg-blue-50',
text: 'text-blue-800',
border: 'border-brand-turquoise/30',
bg: 'bg-brand-turquoise/5',
text: 'text-brand-navy',
icon: Info,
dot: 'bg-blue-400',
dot: 'bg-brand-turquoise',
},
warning: {
border: 'border-amber-200',
@ -114,7 +114,7 @@ function Callout({
function HttpBadge({ method }: { method: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT' }) {
const styles: Record<string, string> = {
GET: 'bg-emerald-100 text-emerald-700 border-emerald-200',
POST: 'bg-blue-100 text-blue-700 border-blue-200',
POST: 'bg-brand-turquoise/10 text-brand-navy border-brand-turquoise/30',
PATCH: 'bg-amber-100 text-amber-700 border-amber-200',
PUT: 'bg-orange-100 text-orange-700 border-orange-200',
DELETE: 'bg-red-100 text-red-700 border-red-200',
@ -138,12 +138,12 @@ function EndpointRow({
desc: string;
}) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:bg-gray-50 hover:border-gray-200 transition-all group">
<div className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-neutral-50 hover:border-border transition-all group">
<HttpBadge method={method} />
<code className="text-sm font-mono text-gray-700 flex-1 group-hover:text-[#10183A]">
<code className="text-sm font-mono text-neutral-700 flex-1 group-hover:text-[#10183A]">
{path}
</code>
<span className="text-sm text-gray-400 hidden sm:block">{desc}</span>
<span className="text-sm text-neutral-400 hidden sm:block">{desc}</span>
</div>
);
}
@ -156,14 +156,14 @@ function ParamTable({
rows: (string | React.ReactNode)[][];
}) {
return (
<div className="overflow-x-auto my-5 rounded-xl border border-gray-200 shadow-sm">
<div className="overflow-x-auto my-5 rounded-xl border border-border shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<tr className="bg-neutral-50 border-b border-border">
{headers.map(h => (
<th
key={h}
className="text-left px-4 py-2.5 text-xs font-semibold text-gray-400 uppercase tracking-wider"
className="text-left px-4 py-2.5 text-xs font-semibold text-neutral-400 uppercase tracking-wider"
>
{h}
</th>
@ -174,10 +174,10 @@ function ParamTable({
{rows.map((row, i) => (
<tr
key={i}
className="border-b border-gray-100 last:border-0 hover:bg-gray-50/50 transition-colors"
className="border-b border-border last:border-0 hover:bg-neutral-50/50 transition-colors"
>
{row.map((cell, j) => (
<td key={j} className="px-4 py-3 text-gray-600">
<td key={j} className="px-4 py-3 text-neutral-600">
{cell}
</td>
))}
@ -199,14 +199,14 @@ function SectionHeader({
badge?: string;
}) {
return (
<div className="mb-8 pb-6 border-b border-gray-100">
<div className="mb-8 pb-6 border-b border-border">
{badge && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-[#34CCCD]/10 text-[#0e9999] border border-[#34CCCD]/30 mb-4">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-brand-turquoise/10 text-brand-navy border border-brand-turquoise/30 mb-4">
{badge}
</span>
)}
<H1>{title}</H1>
<p className="text-base text-gray-500 mt-1">{description}</p>
<p className="text-base text-neutral-500 mt-1">{description}</p>
</div>
);
}
@ -217,7 +217,7 @@ function RequiredBadge({ required }: { required: boolean }) {
requis
</span>
) : (
<span className="text-xs text-gray-400 bg-gray-50 border border-gray-100 px-1.5 py-0.5 rounded">
<span className="text-xs text-neutral-400 bg-neutral-50 border border-border px-1.5 py-0.5 rounded">
optionnel
</span>
);
@ -226,7 +226,7 @@ function RequiredBadge({ required }: { required: boolean }) {
function LabeledBlock({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="my-4">
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1.5">{label}</p>
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-1.5">{label}</p>
{children}
</div>
);
@ -250,9 +250,9 @@ function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
icon: Key,
label: 'Authentification',
desc: 'Créer et gérer vos clés API',
color: 'text-blue-500',
bg: 'bg-blue-50',
border: 'border-blue-100',
color: 'text-brand-turquoise',
bg: 'bg-brand-turquoise/5',
border: 'border-brand-turquoise/20',
},
{
id: 'bookings',
@ -277,9 +277,9 @@ function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
icon: List,
label: 'Référence complète',
desc: 'Tous les endpoints',
color: 'text-gray-500',
bg: 'bg-gray-50',
border: 'border-gray-200',
color: 'text-neutral-500',
bg: 'bg-neutral-50',
border: 'border-border',
},
{
id: 'errors',
@ -295,7 +295,7 @@ function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
return (
<div>
{/* Hero */}
<div className="relative rounded-2xl overflow-hidden mb-10 bg-[#10183A]">
<div className="relative rounded-2xl overflow-hidden mb-10 bg-brand-navy">
<div
className="absolute inset-0"
style={{
@ -314,7 +314,7 @@ function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
/>
<div className="relative px-8 py-10">
<div className="flex items-center gap-2 mb-5">
<div className="w-2 h-2 rounded-full bg-[#34CCCD] animate-pulse" />
<div className="w-2 h-2 rounded-full bg-brand-turquoise animate-pulse" />
<span className="text-xs font-medium text-[#34CCCD]/80 tracking-wider uppercase">
API v1.0 · Plans Gold & Platinium
</span>
@ -330,7 +330,7 @@ function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
<div className="flex flex-wrap gap-2 mb-8">
<button
onClick={() => onNavigate('quickstart')}
className="inline-flex items-center gap-2 px-4 py-2 bg-[#34CCCD] text-white rounded-lg text-sm font-semibold hover:bg-[#2ab8b9] transition-colors"
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-turquoise text-brand-navy rounded-lg text-sm font-semibold hover:bg-brand-turquoise/90 transition-colors"
>
Démarrer <ArrowRight className="w-3.5 h-3.5" />
</button>
@ -353,7 +353,7 @@ function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
{/* Quick links grid */}
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-4">
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider mb-4">
Explorer la documentation
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
@ -368,10 +368,10 @@ function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
>
<item.icon className={`${item.color}`} style={{ width: 16, height: 16 }} />
</div>
<p className="font-semibold text-gray-900 text-sm mb-0.5 group-hover:text-[#10183A]">
<p className="font-semibold text-brand-navy text-sm mb-0.5 group-hover:text-[#10183A]">
{item.label}
</p>
<p className="text-xs text-gray-400 leading-relaxed">{item.desc}</p>
<p className="text-xs text-neutral-400 leading-relaxed">{item.desc}</p>
</button>
))}
</div>
@ -406,14 +406,14 @@ function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
].map(item => (
<div
key={item.step}
className="flex items-start gap-4 p-4 rounded-xl bg-gray-50 border border-gray-100"
className="flex items-start gap-4 p-4 rounded-xl bg-neutral-50 border border-border"
>
<span className="text-xs font-bold text-[#34CCCD] font-mono mt-0.5 flex-shrink-0">
{item.step}
</span>
<div>
<p className="font-semibold text-gray-900 text-sm mb-0.5">{item.title}</p>
<p className="text-sm text-gray-500">{item.desc}</p>
<p className="font-semibold text-brand-navy text-sm mb-0.5">{item.title}</p>
<p className="text-sm text-neutral-500">{item.desc}</p>
</div>
</div>
))}
@ -509,13 +509,13 @@ const data = await res.json();`}
].map(step => (
<div key={step.n} className="flex gap-5 mb-8">
<div className="flex-shrink-0 flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-[#10183A] text-white flex items-center justify-center text-sm font-bold">
<div className="w-8 h-8 rounded-full bg-brand-navy text-white flex items-center justify-center text-sm font-bold">
{step.n}
</div>
{step.n < 3 && <div className="w-px flex-1 bg-gray-200 mt-2" />}
{step.n < 3 && <div className="w-px flex-1 bg-neutral-200 mt-2" />}
</div>
<div className="flex-1 pb-6">
<p className="font-semibold text-gray-900 mb-3">{step.title}</p>
<p className="font-semibold text-brand-navy mb-3">{step.title}</p>
{step.content}
</div>
</div>
@ -533,13 +533,13 @@ const data = await res.json();`}
<button
key={item.id}
onClick={() => onNavigate(item.id)}
className="flex items-center justify-between p-4 text-left bg-white rounded-xl border border-gray-200 hover:border-[#34CCCD] hover:shadow-sm transition-all group"
className="flex items-center justify-between p-4 text-left bg-white rounded-xl border border-border hover:border-[#34CCCD] hover:shadow-sm transition-all group"
>
<div>
<p className="font-medium text-gray-900 text-sm">{item.label}</p>
<p className="text-xs text-gray-400 mt-0.5">{item.desc}</p>
<p className="font-medium text-brand-navy text-sm">{item.label}</p>
<p className="text-xs text-neutral-400 mt-0.5">{item.desc}</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-[#34CCCD] transition-colors flex-shrink-0" />
<ChevronRight className="w-4 h-4 text-neutral-300 group-hover:text-[#34CCCD] transition-colors flex-shrink-0" />
</button>
))}
</div>
@ -1140,13 +1140,13 @@ function RateLimitingSection() {
desc: 'Clés séparées par service/env pour un suivi précis et une révocation ciblée.',
},
].map((item, i) => (
<div key={i} className="flex gap-4 p-4 rounded-xl bg-gray-50 border border-gray-100">
<div className="w-8 h-8 rounded-lg bg-[#34CCCD]/10 flex items-center justify-center flex-shrink-0">
<div key={i} className="flex gap-4 p-4 rounded-xl bg-neutral-50 border border-border">
<div className="w-8 h-8 rounded-lg bg-brand-turquoise/10 flex items-center justify-center flex-shrink-0">
<item.icon className="w-4 h-4 text-[#34CCCD]" />
</div>
<div>
<p className="font-semibold text-gray-900 text-sm">{item.title}</p>
<p className="text-sm text-gray-500 mt-0.5">{item.desc}</p>
<p className="font-semibold text-brand-navy text-sm">{item.title}</p>
<p className="text-sm text-neutral-500 mt-0.5">{item.desc}</p>
</div>
</div>
))}
@ -1204,15 +1204,15 @@ function PrevNext({
if (!prev && !next) return null;
return (
<div className="flex items-center justify-between mt-12 pt-6 border-t border-gray-100">
<div className="flex items-center justify-between mt-12 pt-6 border-t border-border">
{prev ? (
<button
onClick={() => onNavigate(prev.id)}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-[#10183A] transition-colors group"
className="flex items-center gap-2 text-sm text-neutral-500 hover:text-[#10183A] transition-colors group"
>
<ChevronLeft className="w-4 h-4 group-hover:-translate-x-0.5 transition-transform" />
<div className="text-left">
<p className="text-xs text-gray-400">Précédent</p>
<p className="text-xs text-neutral-400">Précédent</p>
<p className="font-medium">{prev.label}</p>
</div>
</button>
@ -1223,10 +1223,10 @@ function PrevNext({
{next ? (
<button
onClick={() => onNavigate(next.id)}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-[#10183A] transition-colors group text-right"
className="flex items-center gap-2 text-sm text-neutral-500 hover:text-[#10183A] transition-colors group text-right"
>
<div>
<p className="text-xs text-gray-400">Suivant</p>
<p className="text-xs text-neutral-400">Suivant</p>
<p className="font-medium">{next.label}</p>
</div>
<ChevronRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
@ -1276,13 +1276,13 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
const SidebarInner = () => (
<div className="flex flex-col h-full bg-white">
{/* Brand + search */}
<div className="px-4 pt-5 pb-4 border-b border-gray-100">
<div className="px-4 pt-5 pb-4 border-b border-border">
<div className="flex items-center gap-2 mb-1">
<div className="w-6 h-6 rounded-md bg-[#10183A] flex items-center justify-center flex-shrink-0">
<div className="w-6 h-6 rounded-md bg-brand-navy flex items-center justify-center flex-shrink-0">
<span className="text-[#34CCCD] text-xs font-black">X</span>
</div>
<span className="text-sm font-bold text-gray-900">Xpeditis</span>
<span className="ml-auto text-[10px] font-semibold text-[#34CCCD] bg-[#34CCCD]/10 px-1.5 py-0.5 rounded-md border border-[#34CCCD]/20">
<span className="text-sm font-bold text-brand-navy">Xpeditis</span>
<span className="ml-auto text-[10px] font-semibold text-[#34CCCD] bg-brand-turquoise/10 px-1.5 py-0.5 rounded-md border border-[#34CCCD]/20">
v1.0
</span>
</div>
@ -1290,18 +1290,18 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
{/* API status */}
<div className="flex items-center gap-1.5 mt-2 mb-3">
<Circle className="w-2 h-2 fill-emerald-400 text-emerald-400" />
<span className="text-xs text-gray-400">Tous les services opérationnels</span>
<span className="text-xs text-neutral-400">Tous les services opérationnels</span>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-400 pointer-events-none" />
<input
type="text"
placeholder="Rechercher..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#34CCCD]/30 focus:border-[#34CCCD] placeholder-gray-400 transition-all"
className="w-full pl-8 pr-3 py-1.5 text-sm bg-neutral-50 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-[#34CCCD]/30 focus:border-[#34CCCD] placeholder-neutral-400 transition-all"
/>
</div>
</div>
@ -1310,7 +1310,7 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
<nav className="flex-1 overflow-y-auto py-3 px-3">
{filteredSections.map(section => (
<div key={section.title} className="mb-5">
<p className="px-3 mb-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<p className="px-3 mb-1.5 text-[10px] font-bold text-neutral-400 uppercase tracking-widest">
{section.title}
</p>
{section.items.map(item => {
@ -1321,8 +1321,8 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
onClick={() => navigate(item.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all mb-0.5 ${
isActive
? 'bg-[#10183A] text-white font-medium shadow-sm'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
? 'bg-brand-navy text-white font-medium shadow-sm'
: 'text-neutral-500 hover:bg-neutral-100 hover:text-brand-navy'
}`}
>
<item.icon
@ -1338,12 +1338,12 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
</nav>
{/* Footer CTA */}
<div className="border-t border-gray-100 p-4">
<div className="border-t border-border p-4">
<Link
href="/dashboard/settings/api-keys"
className="flex items-center gap-2 text-xs font-medium text-gray-500 hover:text-[#10183A] transition-colors group"
className="flex items-center gap-2 text-xs font-medium text-neutral-500 hover:text-[#10183A] transition-colors group"
>
<div className="w-6 h-6 rounded-md bg-gray-100 flex items-center justify-center group-hover:bg-[#34CCCD]/10 transition-colors">
<div className="w-6 h-6 rounded-md bg-neutral-100 flex items-center justify-center group-hover:bg-brand-turquoise/10 transition-colors">
<Key className="w-3 h-3" />
</div>
<span>Gérer mes clés API</span>
@ -1359,13 +1359,13 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
<div className="-m-6 flex" style={{ height: 'calc(100vh - 64px)' }}>
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
className="fixed inset-0 z-40 bg-brand-navy/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Desktop sidebar */}
<div className="hidden lg:flex flex-col w-64 xl:w-72 flex-shrink-0 border-r border-gray-100 overflow-hidden">
<div className="hidden lg:flex flex-col w-64 xl:w-72 flex-shrink-0 border-r border-border overflow-hidden">
<SidebarInner />
</div>
@ -1377,15 +1377,15 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-w-0 bg-gray-50/50">
<div className="lg:hidden flex items-center gap-3 px-4 py-3 border-b border-gray-100 bg-white sticky top-0 z-10">
<div className="flex-1 overflow-y-auto min-w-0 bg-neutral-50/50">
<div className="lg:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-white sticky top-0 z-10">
<button
onClick={() => setSidebarOpen(true)}
className="p-1.5 rounded-lg hover:bg-gray-100"
className="p-1.5 rounded-lg hover:bg-neutral-100"
>
<Menu className="w-5 h-5 text-gray-600" />
<Menu className="w-5 h-5 text-neutral-600" />
</button>
<span className="text-sm font-medium text-gray-700">
<span className="text-sm font-medium text-neutral-700">
{ALL_NAV_ITEMS.find(i => i.id === activeSection)?.label ?? 'Documentation'}
</span>
</div>
@ -1401,11 +1401,11 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
// ── Public variant: scrollable with sticky sidebar ───────────────────────
return (
<div className="flex min-h-[calc(100vh-80px)] bg-gray-50/30">
<div className="flex min-h-[calc(100vh-80px)] bg-neutral-50/30">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
className="fixed inset-0 z-40 bg-brand-navy/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
@ -1419,7 +1419,7 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
{/* Desktop sticky sidebar */}
<aside
className={`hidden lg:flex flex-col w-64 xl:w-72 flex-shrink-0 border-r border-gray-100 sticky ${stickyTop} self-start overflow-hidden bg-white`}
className={`hidden lg:flex flex-col w-64 xl:w-72 flex-shrink-0 border-r border-border sticky ${stickyTop} self-start overflow-hidden bg-white`}
style={{ maxHeight: sidebarHeight }}
>
<SidebarInner />
@ -1428,22 +1428,22 @@ function DocsPageInner({ basePath, variant = 'public' }: DocsPageContentProps) {
{/* Main scrollable content */}
<main className="flex-1 min-w-0">
{/* Mobile top bar */}
<div className="lg:hidden flex items-center gap-3 px-4 py-3 border-b border-gray-100 bg-white sticky top-20 z-10">
<div className="lg:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-white sticky top-20 z-10">
<button
onClick={() => setSidebarOpen(true)}
className="p-1.5 rounded-lg hover:bg-gray-100"
className="p-1.5 rounded-lg hover:bg-neutral-100"
>
<Menu className="w-5 h-5 text-gray-600" />
<Menu className="w-5 h-5 text-neutral-600" />
</button>
<span className="text-sm font-medium text-gray-700">
<span className="text-sm font-medium text-neutral-700">
{ALL_NAV_ITEMS.find(i => i.id === activeSection)?.label ?? 'Documentation'}
</span>
{sidebarOpen && (
<button
onClick={() => setSidebarOpen(false)}
className="ml-auto p-1.5 rounded-lg hover:bg-gray-100"
className="ml-auto p-1.5 rounded-lg hover:bg-neutral-100"
>
<X className="w-5 h-5 text-gray-600" />
<X className="w-5 h-5 text-neutral-600" />
</button>
)}
</div>
@ -1470,16 +1470,16 @@ function Breadcrumb({
if (activeSection === 'home') return null;
const current = ALL_NAV_ITEMS.find(i => i.id === activeSection);
return (
<nav className="flex items-center gap-1.5 text-xs text-gray-400 mb-6">
<nav className="flex items-center gap-1.5 text-xs text-neutral-400 mb-6">
<button
onClick={() => onNavigate('home')}
className="hover:text-gray-600 transition-colors flex items-center gap-1"
className="hover:text-neutral-600 transition-colors flex items-center gap-1"
>
<Home className="w-3 h-3" />
<span>Docs</span>
</button>
<ChevronRight className="w-3 h-3 opacity-50" />
<span className="text-gray-600 font-medium">{current?.label}</span>
<span className="text-neutral-600 font-medium">{current?.label}</span>
</nav>
);
}
@ -1495,7 +1495,7 @@ export function DocsPageContent(props: DocsPageContentProps) {
return (
<Suspense
fallback={
<div className="flex items-center justify-center bg-gray-50" style={fallbackStyle}>
<div className="flex items-center justify-center bg-neutral-50" style={fallbackStyle}>
<div className="w-8 h-8 border-[3px] border-[#34CCCD] border-t-transparent rounded-full animate-spin" />
</div>
}

View File

@ -0,0 +1,120 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import type { CsvBookingResponse } from '@/lib/api/bookings';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FileDropzone } from '@/components/forms/file-dropzone';
const ACCEPTED = '.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif';
interface AddDocumentsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
bookings: CsvBookingResponse[];
getQuoteNumber: (booking: CsvBookingResponse) => string;
onSubmit: (bookingId: string, files: File[]) => Promise<void>;
}
export function AddDocumentsDialog({
open,
onOpenChange,
bookings,
getQuoteNumber,
onSubmit,
}: AddDocumentsDialogProps) {
const t = useTranslations('dashboard.userDocuments');
const tCommon = useTranslations('common');
const [bookingId, setBookingId] = React.useState<string>('');
const [files, setFiles] = React.useState<File[]>([]);
const [submitting, setSubmitting] = React.useState(false);
// Repart d'un formulaire vierge a chaque ouverture.
React.useEffect(() => {
if (open) {
setBookingId('');
setFiles([]);
}
}, [open]);
const canSubmit = Boolean(bookingId) && files.length > 0 && !submitting;
const submit = async () => {
if (!canSubmit) return;
setSubmitting(true);
try {
await onSubmit(bookingId, files);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={submitting ? undefined : onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('addDocument.modalTitle')}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<FormField label={t('addDocument.selectBooking')} required>
{({ id }) => (
<Select value={bookingId} onValueChange={setBookingId} disabled={submitting}>
<SelectTrigger id={id}>
<SelectValue placeholder={t('addDocument.selectBookingPlaceholder')} />
</SelectTrigger>
<SelectContent>
{bookings.map(booking => (
<SelectItem key={booking.id} value={booking.id}>
{getQuoteNumber(booking)} — {booking.origin} → {booking.destination}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
<FormField label={t('addDocument.filesToAdd')}>
<FileDropzone
multiple
accept={ACCEPTED}
files={files}
onFilesChange={setFiles}
disabled={submitting}
labels={{
dropHere: t('addDocument.dropHere'),
dragOrClick: t('addDocument.dragOrClick'),
acceptedFormats: t('addDocument.acceptedFormats'),
remove: tCommon('delete'),
}}
/>
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('addDocument.cancel')}
</Button>
<Button onClick={submit} disabled={!canSubmit} loading={submitting}>
{submitting ? t('addDocument.uploading') : t('addDocument.add')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,93 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { FileDropzone } from '@/components/forms/file-dropzone';
import { DescriptionList } from '@/components/data/description-list';
const ACCEPTED = '.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif';
interface ReplaceDocumentDialogProps {
document: { fileName: string; quoteNumber: string } | null;
onOpenChange: (open: boolean) => void;
onSubmit: (file: File) => Promise<void>;
}
export function ReplaceDocumentDialog({
document,
onOpenChange,
onSubmit,
}: ReplaceDocumentDialogProps) {
const t = useTranslations('dashboard.userDocuments');
const tCommon = useTranslations('common');
const [files, setFiles] = React.useState<File[]>([]);
const [submitting, setSubmitting] = React.useState(false);
React.useEffect(() => {
if (document) setFiles([]);
}, [document]);
const submit = async () => {
if (files.length === 0 || submitting) return;
setSubmitting(true);
try {
await onSubmit(files[0]);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={Boolean(document)} onOpenChange={submitting ? undefined : onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('replaceDocument.modalTitle')}</DialogTitle>
</DialogHeader>
{document && (
<div className="flex flex-col gap-4">
<DescriptionList
items={[
{ label: t('replaceDocument.currentDocument'), value: document.fileName },
{ label: t('replaceDocument.booking'), value: document.quoteNumber },
]}
/>
<FormField label={t('replaceDocument.newFile')} required>
<FileDropzone
accept={ACCEPTED}
files={files}
onFilesChange={setFiles}
disabled={submitting}
labels={{
dropHere: t('replaceDocument.dropHere'),
dragOrClick: t('replaceDocument.dragOrClick'),
acceptedFormats: t('replaceDocument.acceptedFormats'),
remove: tCommon('delete'),
}}
/>
</FormField>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('replaceDocument.cancel')}
</Button>
<Button onClick={submit} disabled={files.length === 0 || submitting} loading={submitting}>
{submitting ? t('replaceDocument.replacing') : t('replaceDocument.replace')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,46 @@
import { FileEdit, FileSpreadsheet, FileText, Image as ImageIcon, Paperclip } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Icone de type de fichier.
*
* La distinction passe par la **forme** de l'icone, pas par la couleur : la
* version precedente employait rouge, vert et bleu vifs, hors charte, pour
* coder le type. La forme suffit a la reconnaissance et laisse la couleur
* disponible pour ce qui porte reellement du sens (statuts, actions).
*/
const EXTENSION_LABELS: Record<string, string> = {
pdf: 'PDF',
doc: 'Word',
docx: 'Word',
xls: 'Excel',
xlsx: 'Excel',
jpg: 'Image',
jpeg: 'Image',
png: 'Image',
gif: 'Image',
txt: 'Text',
csv: 'CSV',
};
export function fileTypeLabel(fileName: string): string {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
return EXTENSION_LABELS[ext] || ext.toUpperCase();
}
const SPREADSHEET = new Set(['excel', 'xls', 'xlsx', 'csv']);
const DOCUMENT = new Set(['word', 'doc', 'docx']);
const IMAGE = new Set(['image', 'jpeg', 'jpg', 'png', 'gif']);
const TEXT = new Set(['pdf', 'text', 'txt']);
export function DocumentIcon({ type, className }: { type: string; className?: string }) {
const key = type.toLowerCase();
const normalized = key.includes('/') ? key.split('/')[1] : key;
const cls = cn('size-4 shrink-0 text-neutral-400', className);
if (TEXT.has(normalized)) return <FileText className={cls} aria-hidden="true" />;
if (IMAGE.has(normalized)) return <ImageIcon className={cls} aria-hidden="true" />;
if (DOCUMENT.has(normalized)) return <FileEdit className={cls} aria-hidden="true" />;
if (SPREADSHEET.has(normalized)) return <FileSpreadsheet className={cls} aria-hidden="true" />;
return <Paperclip className={cls} aria-hidden="true" />;
}

View File

@ -0,0 +1,142 @@
'use client';
import * as React from 'react';
import { Paperclip, Upload, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
/**
* Zone de depot de fichiers.
*
* Mutualise les quatre implementations de glisser-deposer du projet
* (documents utilisateur ×2, documents admin, documents transporteur), qui
* dupliquaient chacune leur propre gestion d'etat de survol.
*
* Le champ natif reste dans le DOM et reste atteignable au clavier : la zone
* n'est qu'une surface de depot, jamais le seul moyen de choisir un fichier.
*/
export interface FileDropzoneProps {
files: File[];
onFilesChange: (files: File[]) => void;
multiple?: boolean;
accept?: string;
disabled?: boolean;
labels: {
dropHere: string;
dragOrClick: string;
acceptedFormats?: string;
remove: string;
};
className?: string;
}
export function FileDropzone({
files,
onFilesChange,
multiple = false,
accept,
disabled = false,
labels,
className,
}: FileDropzoneProps) {
const [dragging, setDragging] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const inputId = React.useId();
const accept_ = (incoming: FileList | null) => {
if (!incoming || incoming.length === 0) return;
const list = Array.from(incoming);
onFilesChange(multiple ? [...files, ...list] : [list[0]]);
};
const removeAt = (index: number) => {
onFilesChange(files.filter((_, i) => i !== index));
// Sans ce reset, re-selectionner le meme fichier ne declenche pas `change`.
if (inputRef.current) inputRef.current.value = '';
};
return (
<div className={cn('flex flex-col gap-3', className)}>
<label
htmlFor={inputId}
onDragOver={e => {
e.preventDefault();
if (!disabled) setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={e => {
e.preventDefault();
setDragging(false);
if (!disabled) accept_(e.dataTransfer.files);
}}
className={cn(
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-8 text-center transition-colors',
'focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2',
dragging
? 'border-brand-turquoise bg-brand-turquoise/5'
: 'border-neutral-300 hover:border-brand-turquoise hover:bg-neutral-50',
disabled && 'pointer-events-none opacity-50'
)}
>
<Upload
className={cn('size-5', dragging ? 'text-brand-turquoise' : 'text-neutral-400')}
aria-hidden="true"
/>
<span className="text-body-sm text-neutral-700">
{dragging ? labels.dropHere : labels.dragOrClick}
</span>
{labels.acceptedFormats && (
<span className="text-body-xs text-neutral-400">{labels.acceptedFormats}</span>
)}
<input
id={inputId}
ref={inputRef}
type="file"
multiple={multiple}
accept={accept}
disabled={disabled}
onChange={e => accept_(e.target.files)}
className="sr-only"
/>
</label>
{files.length > 0 && (
<ul className="flex flex-col gap-1.5">
{files.map((file, index) => (
<li
key={`${file.name}-${index}`}
className="flex items-center gap-2 rounded-md border border-border bg-neutral-50 px-3 py-2"
>
<Paperclip className="size-3.5 shrink-0 text-neutral-400" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-body-xs text-neutral-700">
{file.name}
</span>
<span className="shrink-0 text-body-xs text-neutral-400">
{formatBytes(file.size)}
</span>
<Button
type="button"
variant="ghost"
size="icon-sm"
disabled={disabled}
onClick={() => removeAt(index)}
aria-label={`${labels.remove} : ${file.name}`}
>
<X />
</Button>
</li>
))}
</ul>
)}
</div>
);
}
export function formatBytes(bytes: number): string {
if (!bytes) return '0 o';
const units = ['o', 'Ko', 'Mo', 'Go'];
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, exponent);
return `${value.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
}

View File

@ -150,7 +150,7 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH
transition={{ duration: 0.2 }}
className="absolute top-full left-1/2 -translate-x-1/2 pt-4 z-50"
>
<div className="bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden min-w-[280px]">
<div className="bg-white rounded-2xl shadow-2xl border border-border overflow-hidden min-w-[280px]">
<div className="p-2">
{companyMenuItems.map(item => {
const IconComponent = item.icon;
@ -160,7 +160,7 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH
key={item.href}
href={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-xl transition-colors group ${
isActive ? 'bg-brand-turquoise/10' : 'hover:bg-gray-50'
isActive ? 'bg-brand-turquoise/10' : 'hover:bg-neutral-50'
}`}
>
<div
@ -182,7 +182,7 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH
>
{item.label}
</div>
<div className="text-sm text-gray-500">{item.description}</div>
<div className="text-sm text-neutral-500">{item.description}</div>
</div>
</Link>
);

Some files were not shown because too many files have changed in this diff Show More