fix ui update
This commit is contained in:
parent
af9dae72d7
commit
84c34bdc21
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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') }));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'existe pas ou n'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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
5
apps/frontend/app/[locale]/booking/layout.tsx
Normal file
5
apps/frontend/app/[locale]/booking/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { PortalShell } from '@/components/shell/portal-shell';
|
||||
|
||||
export default function BookingPortalLayout({ children }: { children: React.ReactNode }) {
|
||||
return <PortalShell>{children}</PortalShell>;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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('/')}
|
||||
|
||||
@ -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 */}
|
||||
|
||||
5
apps/frontend/app/[locale]/carrier/layout.tsx
Normal file
5
apps/frontend/app/[locale]/carrier/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { PortalShell } from '@/components/shell/portal-shell';
|
||||
|
||||
export default function CarrierPortalLayout({ children }: { children: React.ReactNode }) {
|
||||
return <PortalShell>{children}</PortalShell>;
|
||||
}
|
||||
@ -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('/')}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
“J'ai effectué le virement”.
|
||||
</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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`
|
||||
? '...'
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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 ? (
|
||||
<>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
13
apps/frontend/app/[locale]/dashboard/wiki/layout.tsx
Normal file
13
apps/frontend/app/[locale]/dashboard/wiki/layout.tsx
Normal 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>;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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">“{t('contact.quote')}”</p>
|
||||
<p className="text-neutral-600 italic mb-4">“{t('contact.quote')}”</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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1046
apps/frontend/package-lock.json
generated
1046
apps/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
134
apps/frontend/src/__tests__/components/data-table.test.tsx
Normal file
134
apps/frontend/src/__tests__/components/data-table.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
106
apps/frontend/src/__tests__/components/nav-config.test.ts
Normal file
106
apps/frontend/src/__tests__/components/nav-config.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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')}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
161
apps/frontend/src/components/bookings/BookingDetailDialog.tsx
Normal file
161
apps/frontend/src/components/bookings/BookingDetailDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
41
apps/frontend/src/components/bookings/BookingStatusBadge.tsx
Normal file
41
apps/frontend/src/components/bookings/BookingStatusBadge.tsx
Normal 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>;
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')}
|
||||
|
||||
377
apps/frontend/src/components/data/data-table.tsx
Normal file
377
apps/frontend/src/components/data/data-table.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
}
|
||||
47
apps/frontend/src/components/data/description-list.tsx
Normal file
47
apps/frontend/src/components/data/description-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
apps/frontend/src/components/data/filter-bar.tsx
Normal file
122
apps/frontend/src/components/data/filter-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/frontend/src/components/data/index.ts
Normal file
10
apps/frontend/src/components/data/index.ts
Normal 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';
|
||||
78
apps/frontend/src/components/data/search-input.tsx
Normal file
78
apps/frontend/src/components/data/search-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/frontend/src/components/data/stat-card.tsx
Normal file
80
apps/frontend/src/components/data/stat-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
120
apps/frontend/src/components/documents/AddDocumentsDialog.tsx
Normal file
120
apps/frontend/src/components/documents/AddDocumentsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
46
apps/frontend/src/components/documents/document-icon.tsx
Normal file
46
apps/frontend/src/components/documents/document-icon.tsx
Normal 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" />;
|
||||
}
|
||||
142
apps/frontend/src/components/forms/file-dropzone.tsx
Normal file
142
apps/frontend/src/components/forms/file-dropzone.tsx
Normal 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]}`;
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user