diff --git a/apps/frontend/app/[locale]/admin/layout.tsx b/apps/frontend/app/[locale]/admin/layout.tsx
new file mode 100644
index 0000000..547dab6
--- /dev/null
+++ b/apps/frontend/app/[locale]/admin/layout.tsx
@@ -0,0 +1,211 @@
+'use client';
+
+import { useEffect, useState } 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 },
+];
+
+export default function AdminLayout({ children }: { children: React.ReactNode }) {
+ const { user, logout, loading, isAuthenticated } = useAuth();
+ const pathname = usePathname();
+ const router = useRouter();
+ const tItems = useTranslations('components.adminPanelDropdown');
+ const tAdmin = useTranslations('admin');
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ // The /admin/login page lives under this segment but must render without the
+ // admin chrome and without triggering the role guard.
+ const isLoginRoute = pathname === '/admin/login';
+
+ useEffect(() => {
+ if (isLoginRoute || loading) return;
+ if (!isAuthenticated) {
+ router.replace('/admin/login');
+ return;
+ }
+ if (user && user.role !== 'ADMIN') {
+ router.replace('/dashboard');
+ }
+ }, [isLoginRoute, loading, isAuthenticated, user, router]);
+
+ if (isLoginRoute) {
+ return <>{children}>;
+ }
+
+ const authorized = isAuthenticated && user?.role === 'ADMIN';
+
+ if (loading || !authorized) {
+ return (
+
+ );
+ }
+
+ const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
+
+ return (
+
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ {tAdmin('panelTitle')}
+
+
+
+
+
+
+
+ {tAdmin('backToApp')}
+
+
+
+
+ {user?.firstName?.[0]}
+ {user?.lastName?.[0]}
+
+
+
+ {user?.firstName} {user?.lastName}
+
+
{user?.email}
+
+
+
+
+
+
+
+
+
+
+
+
+ {adminNavItems.find(item => isActive(item.href))
+ ? tItems(`items.${adminNavItems.find(item => isActive(item.href))!.key}` as any)
+ : tAdmin('title')}
+
+
+
+
+
+
+
+
{children}
+
+
+ );
+}
diff --git a/apps/frontend/app/[locale]/admin/login/page.tsx b/apps/frontend/app/[locale]/admin/login/page.tsx
new file mode 100644
index 0000000..befdf59
--- /dev/null
+++ b/apps/frontend/app/[locale]/admin/login/page.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useState } from 'react';
+import { useTranslations } from 'next-intl';
+import { Link, useRouter } from '@/i18n/navigation';
+import Image from 'next/image';
+import { ShieldCheck } from 'lucide-react';
+import { login as apiLogin, getCurrentUser, logout as apiLogout } from '@/lib/api/auth';
+import { useAuth } from '@/lib/context/auth-context';
+
+export default function AdminLoginPage() {
+ const t = useTranslations('admin.login');
+ const router = useRouter();
+ const { refreshUser } = useAuth();
+
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError('');
+ setIsLoading(true);
+
+ try {
+ await apiLogin({ email, password, rememberMe: false });
+ const me = await getCurrentUser();
+
+ if (me.role !== 'ADMIN') {
+ // Valid credentials but not an administrator — drop the session.
+ await apiLogout();
+ setError(t('notAdmin'));
+ return;
+ }
+
+ await refreshUser();
+ router.replace('/admin');
+ } catch (err) {
+ setError(t('error'));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ {t('badge')}
+
+
{t('title')}
+
{t('subtitle')}
+
+
+ {error && (
+
+ )}
+
+
+
+
+
+ {t('backToApp')}
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/app/[locale]/admin/page.tsx b/apps/frontend/app/[locale]/admin/page.tsx
new file mode 100644
index 0000000..3bfe280
--- /dev/null
+++ b/apps/frontend/app/[locale]/admin/page.tsx
@@ -0,0 +1,8 @@
+import { redirect } from 'next/navigation';
+
+type Params = { locale: string };
+
+export default async function AdminIndexPage({ params }: { params: Promise
}) {
+ const { locale } = await params;
+ redirect(`/${locale}/admin/users`);
+}
diff --git a/apps/frontend/app/[locale]/dashboard/admin/[[...rest]]/page.tsx b/apps/frontend/app/[locale]/dashboard/admin/[[...rest]]/page.tsx
new file mode 100644
index 0000000..293c86d
--- /dev/null
+++ b/apps/frontend/app/[locale]/dashboard/admin/[[...rest]]/page.tsx
@@ -0,0 +1,11 @@
+import { redirect } from 'next/navigation';
+
+// Legacy redirect: the admin area moved from /dashboard/admin/* to /admin/*.
+// Keeps old links and bookmarks working.
+type Params = { locale: string; rest?: string[] };
+
+export default async function LegacyAdminRedirect({ params }: { params: Promise }) {
+ const { locale, rest } = await params;
+ const sub = rest && rest.length ? `/${rest.join('/')}` : '';
+ redirect(`/${locale}/admin${sub}`);
+}
diff --git a/apps/frontend/app/[locale]/dashboard/layout.tsx b/apps/frontend/app/[locale]/dashboard/layout.tsx
index e409c35..0b1321b 100644
--- a/apps/frontend/app/[locale]/dashboard/layout.tsx
+++ b/apps/frontend/app/[locale]/dashboard/layout.tsx
@@ -6,7 +6,6 @@ import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import NotificationDropdown from '@/components/NotificationDropdown';
import LanguageSwitcher from '@/components/LanguageSwitcher';
-import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image';
import {
BarChart3,
@@ -21,6 +20,7 @@ import {
Key,
Home,
User,
+ ShieldCheck,
} from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge';
@@ -159,7 +159,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{user?.role === 'ADMIN' && (
-
+
+
+
{t('nav.admin')}
+
)}
diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json
index a5e917c..6249a1d 100644
--- a/apps/frontend/messages/en.json
+++ b/apps/frontend/messages/en.json
@@ -27,6 +27,26 @@
"continue": "Continue",
"submit": "Submit"
},
+ "admin": {
+ "title": "Administration",
+ "panelTitle": "Administration",
+ "backToApp": "Back to app",
+ "logout": "Log out",
+ "login": {
+ "badge": "Restricted access",
+ "title": "Admin sign in",
+ "subtitle": "Access the Xpeditis administration area",
+ "emailLabel": "Email",
+ "emailPlaceholder": "you@company.com",
+ "passwordLabel": "Password",
+ "passwordPlaceholder": "********",
+ "submit": "Sign in",
+ "submitting": "Signing in...",
+ "notAdmin": "Access denied: this account is not an administrator.",
+ "error": "Sign in failed. Please check your credentials.",
+ "backToApp": "Back to site"
+ }
+ },
"language": {
"label": "Language",
"fr": "Français",
@@ -303,7 +323,8 @@
"wiki": "Maritime Wiki",
"organization": "Organization",
"apiKeys": "API Keys",
- "users": "Users"
+ "users": "Users",
+ "admin": "Administration"
},
"topbar": {
"defaultTitle": "Dashboard"
@@ -1723,7 +1744,10 @@
],
"extensionsTitle": "Coverage Extensions",
"extensions": [
- { "name": "War clause", "description": "Covers losses due to war, terrorism, piracy" },
+ {
+ "name": "War clause",
+ "description": "Covers losses due to war, terrorism, piracy"
+ },
{
"name": "Strikes clause",
"description": "Covers losses due to strikes, riots, civil commotion"
@@ -1849,15 +1873,42 @@
"colItem": "Item",
"colAmount": "Amount",
"exampleItems": [
- { "item": "Base ocean freight", "amount": "1,200 USD" },
- { "item": "BAF (Bunker)", "amount": "350 USD" },
- { "item": "CAF (Currency)", "amount": "50 USD" },
- { "item": "THC Origin", "amount": "180 USD" },
- { "item": "THC Destination", "amount": "220 USD" },
- { "item": "B/L Fee", "amount": "55 USD" },
- { "item": "ISPS", "amount": "30 USD" },
- { "item": "Pre-carriage", "amount": "250 USD" },
- { "item": "Total", "amount": "2,335 USD" }
+ {
+ "item": "Base ocean freight",
+ "amount": "1,200 USD"
+ },
+ {
+ "item": "BAF (Bunker)",
+ "amount": "350 USD"
+ },
+ {
+ "item": "CAF (Currency)",
+ "amount": "50 USD"
+ },
+ {
+ "item": "THC Origin",
+ "amount": "180 USD"
+ },
+ {
+ "item": "THC Destination",
+ "amount": "220 USD"
+ },
+ {
+ "item": "B/L Fee",
+ "amount": "55 USD"
+ },
+ {
+ "item": "ISPS",
+ "amount": "30 USD"
+ },
+ {
+ "item": "Pre-carriage",
+ "amount": "250 USD"
+ },
+ {
+ "item": "Total",
+ "amount": "2,335 USD"
+ }
]
},
"conteneurs": {
@@ -1929,7 +1980,10 @@
],
"specialEquipmentTitle": "Special Equipment",
"specialEquipment": [
- { "name": "ISO Tank", "description": "For liquids, chemicals, food products in bulk" },
+ {
+ "name": "ISO Tank",
+ "description": "For liquids, chemicals, food products in bulk"
+ },
{
"name": "Bulk Container",
"description": "For dry bulk (grains, minerals) — top hatch"
@@ -1951,11 +2005,26 @@
"condition": "Standard general cargo",
"recommendation": "20' or 40' Dry depending on volume"
},
- { "condition": "Temperature-sensitive goods", "recommendation": "Reefer 20' or 40'" },
- { "condition": "Over-height cargo (> 2.2m)", "recommendation": "Open Top or Flat Rack" },
- { "condition": "Over-length/weight cargo", "recommendation": "Flat Rack or Platform" },
- { "condition": "Bulk liquids", "recommendation": "ISO Tank" },
- { "condition": "Volume < 15 m³", "recommendation": "Consider LCL" }
+ {
+ "condition": "Temperature-sensitive goods",
+ "recommendation": "Reefer 20' or 40'"
+ },
+ {
+ "condition": "Over-height cargo (> 2.2m)",
+ "recommendation": "Open Top or Flat Rack"
+ },
+ {
+ "condition": "Over-length/weight cargo",
+ "recommendation": "Flat Rack or Platform"
+ },
+ {
+ "condition": "Bulk liquids",
+ "recommendation": "ISO Tank"
+ },
+ {
+ "condition": "Volume < 15 m³",
+ "recommendation": "Consider LCL"
+ }
]
},
"documentsTransport": {
@@ -2143,7 +2212,10 @@
"type": "VAT",
"description": "Applied on (customs value + import duties + transport). 20% standard rate."
},
- { "type": "Excise Duties", "description": "Specific to alcohol, tobacco, hydrocarbons." }
+ {
+ "type": "Excise Duties",
+ "description": "Specific to alcohol, tobacco, hydrocarbons."
+ }
]
},
"imdg": {
@@ -2202,8 +2274,16 @@
"description": "Toxic and infectious substances",
"subdivisions": ["6.1 Toxic substances", "6.2 Infectious substances"]
},
- { "class": "Class 7", "name": "Radioactive", "description": "Radioactive materials" },
- { "class": "Class 8", "name": "Corrosive", "description": "Corrosive substances" },
+ {
+ "class": "Class 7",
+ "name": "Radioactive",
+ "description": "Radioactive materials"
+ },
+ {
+ "class": "Class 8",
+ "name": "Corrosive",
+ "description": "Corrosive substances"
+ },
{
"class": "Class 9",
"name": "Miscellaneous",
@@ -2239,8 +2319,14 @@
"group": "Group I (X)",
"description": "High danger — most stringent packaging requirements"
},
- { "group": "Group II (Y)", "description": "Medium danger — standard packaging" },
- { "group": "Group III (Z)", "description": "Low danger — less stringent requirements" }
+ {
+ "group": "Group II (Y)",
+ "description": "Medium danger — standard packaging"
+ },
+ {
+ "group": "Group III (Z)",
+ "description": "Low danger — less stringent requirements"
+ }
],
"labelingTitle": "Labeling and Placarding",
"labelingContent": "Each package must display: UN number, proper shipping name, hazard labels and class. Containers must display 250mm × 250mm placards matching the IMDG class. Mixed loads require labels for each dangerous good.",
@@ -2264,7 +2350,11 @@
"lcl": "< 15 m³ or < 10 tonnes",
"fcl": "> 15 m³ or full container"
},
- { "criterion": "Price", "lcl": "Per CBM (m³) or tonne", "fcl": "Fixed per container" },
+ {
+ "criterion": "Price",
+ "lcl": "Per CBM (m³) or tonne",
+ "fcl": "Fixed per container"
+ },
{
"criterion": "Security",
"lcl": "Moderate (shared with others)",
@@ -2373,7 +2463,10 @@
"role": "Applicant (Importer)",
"description": "The buyer who requests the L/C at their bank"
},
- { "role": "Issuing Bank", "description": "The importer's bank that issues the L/C" },
+ {
+ "role": "Issuing Bank",
+ "description": "The importer's bank that issues the L/C"
+ },
{
"role": "Beneficiary (Exporter)",
"description": "The seller who benefits from the L/C"
@@ -2397,7 +2490,10 @@
"name": "Commercial Invoice",
"description": "In exact conformity with the L/C — amounts, currencies, description"
},
- { "name": "Packing List", "description": "Consistent with invoice and B/L" },
+ {
+ "name": "Packing List",
+ "description": "Consistent with invoice and B/L"
+ },
{
"name": "Insurance Certificate",
"description": "Required if CIF or CIP — amounts and coverage per L/C"
@@ -2437,7 +2533,10 @@
"label": "Presentation deadline",
"description": "Number of days after shipment to present documents (typically 21 days)"
},
- { "label": "L/C expiry", "description": "Final deadline for all document presentation" }
+ {
+ "label": "L/C expiry",
+ "description": "Final deadline for all document presentation"
+ }
],
"costsTitle": "Costs",
"costsItems": [
@@ -2449,8 +2548,14 @@
"label": "Confirmation commission",
"description": "0.2–0.5% per quarter (confirming bank)"
},
- { "label": "Amendment fee", "description": "Fixed fee per amendment" },
- { "label": "Discrepancy fee", "description": "Fixed fee in case of document discrepancy" }
+ {
+ "label": "Amendment fee",
+ "description": "Fixed fee per amendment"
+ },
+ {
+ "label": "Discrepancy fee",
+ "description": "Fixed fee in case of document discrepancy"
+ }
]
},
"portsRoutes": {
@@ -2529,16 +2634,66 @@
"colCountry": "Country",
"colTeu": "TEU / year",
"ports": [
- { "rank": 1, "port": "Shanghai", "country": "China", "teu": "47M" },
- { "rank": 2, "port": "Singapore", "country": "Singapore", "teu": "37M" },
- { "rank": 3, "port": "Ningbo-Zhoushan", "country": "China", "teu": "33M" },
- { "rank": 4, "port": "Shenzhen", "country": "China", "teu": "29M" },
- { "rank": 5, "port": "Guangzhou", "country": "China", "teu": "24M" },
- { "rank": 6, "port": "Qingdao", "country": "China", "teu": "24M" },
- { "rank": 7, "port": "Busan", "country": "South Korea", "teu": "22M" },
- { "rank": 8, "port": "Tianjin", "country": "China", "teu": "21M" },
- { "rank": 9, "port": "Dubai (Jebel Ali)", "country": "UAE", "teu": "15M" },
- { "rank": 10, "port": "Rotterdam", "country": "Netherlands", "teu": "15M" }
+ {
+ "rank": 1,
+ "port": "Shanghai",
+ "country": "China",
+ "teu": "47M"
+ },
+ {
+ "rank": 2,
+ "port": "Singapore",
+ "country": "Singapore",
+ "teu": "37M"
+ },
+ {
+ "rank": 3,
+ "port": "Ningbo-Zhoushan",
+ "country": "China",
+ "teu": "33M"
+ },
+ {
+ "rank": 4,
+ "port": "Shenzhen",
+ "country": "China",
+ "teu": "29M"
+ },
+ {
+ "rank": 5,
+ "port": "Guangzhou",
+ "country": "China",
+ "teu": "24M"
+ },
+ {
+ "rank": 6,
+ "port": "Qingdao",
+ "country": "China",
+ "teu": "24M"
+ },
+ {
+ "rank": 7,
+ "port": "Busan",
+ "country": "South Korea",
+ "teu": "22M"
+ },
+ {
+ "rank": 8,
+ "port": "Tianjin",
+ "country": "China",
+ "teu": "21M"
+ },
+ {
+ "rank": 9,
+ "port": "Dubai (Jebel Ali)",
+ "country": "UAE",
+ "teu": "15M"
+ },
+ {
+ "rank": 10,
+ "port": "Rotterdam",
+ "country": "Netherlands",
+ "teu": "15M"
+ }
],
"hubGatewayTitle": "Hub vs Gateway",
"hubTitle": "Hub Port",
@@ -2576,7 +2731,11 @@
"description": "Empty weight of the container (shown on the door)",
"example": "2,200 kg (20')"
},
- { "element": "Cargo", "description": "Gross weight of all goods", "example": "Variable" },
+ {
+ "element": "Cargo",
+ "description": "Gross weight of all goods",
+ "example": "Variable"
+ },
{
"element": "Packaging",
"description": "Pallets, cartons, plastic film...",
@@ -2642,10 +2801,22 @@
"consequenceValue": "Re-weighing at shipper's expense, possible delay",
"sanctionsTitle": "Sanctions by Region",
"sanctions": [
- { "region": "France", "sanction": "Fine up to €7,500 and refusal to load" },
- { "region": "USA", "sanction": "Refusal to load, fine from coast guard" },
- { "region": "China", "sanction": "Refusal to load, port penalties" },
- { "region": "European Union", "sanction": "Variable application by member state" }
+ {
+ "region": "France",
+ "sanction": "Fine up to €7,500 and refusal to load"
+ },
+ {
+ "region": "USA",
+ "sanction": "Refusal to load, fine from coast guard"
+ },
+ {
+ "region": "China",
+ "sanction": "Refusal to load, port penalties"
+ },
+ {
+ "region": "European Union",
+ "sanction": "Variable application by member state"
+ }
],
"tipsTitle": "Best Practices",
"tips": [
@@ -2740,13 +2911,41 @@
"colTime": "Transit Time",
"colVia": "Via",
"transitTimes": [
- { "route": "Shanghai → Rotterdam", "time": "28–32 days", "via": "Suez" },
- { "route": "Shanghai → Le Havre", "time": "30–35 days", "via": "Suez" },
- { "route": "Shanghai → Los Angeles", "time": "12–15 days", "via": "Direct Pacific" },
- { "route": "Shanghai → New York", "time": "35–40 days", "via": "Suez or Panama" },
- { "route": "Rotterdam → New York", "time": "10–14 days", "via": "Direct Atlantic" },
- { "route": "Mumbai → Rotterdam", "time": "18–22 days", "via": "Suez" },
- { "route": "Santos → Rotterdam", "time": "18–22 days", "via": "Direct Atlantic" }
+ {
+ "route": "Shanghai → Rotterdam",
+ "time": "28–32 days",
+ "via": "Suez"
+ },
+ {
+ "route": "Shanghai → Le Havre",
+ "time": "30–35 days",
+ "via": "Suez"
+ },
+ {
+ "route": "Shanghai → Los Angeles",
+ "time": "12–15 days",
+ "via": "Direct Pacific"
+ },
+ {
+ "route": "Shanghai → New York",
+ "time": "35–40 days",
+ "via": "Suez or Panama"
+ },
+ {
+ "route": "Rotterdam → New York",
+ "time": "10–14 days",
+ "via": "Direct Atlantic"
+ },
+ {
+ "route": "Mumbai → Rotterdam",
+ "time": "18–22 days",
+ "via": "Suez"
+ },
+ {
+ "route": "Santos → Rotterdam",
+ "time": "18–22 days",
+ "via": "Direct Atlantic"
+ }
],
"transitNote": "Note: These times are indicative and vary depending on rotations, transshipments and conditions.",
"freeTimeTitle": "Free Time (Free Days)",
diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json
index 62a4e8c..7bdb629 100644
--- a/apps/frontend/messages/fr.json
+++ b/apps/frontend/messages/fr.json
@@ -27,6 +27,26 @@
"continue": "Continuer",
"submit": "Envoyer"
},
+ "admin": {
+ "title": "Espace administration",
+ "panelTitle": "Administration",
+ "backToApp": "Retour à l'application",
+ "logout": "Déconnexion",
+ "login": {
+ "badge": "Accès réservé",
+ "title": "Connexion administrateur",
+ "subtitle": "Accédez à l'espace d'administration Xpeditis",
+ "emailLabel": "Email",
+ "emailPlaceholder": "vous@entreprise.com",
+ "passwordLabel": "Mot de passe",
+ "passwordPlaceholder": "********",
+ "submit": "Se connecter",
+ "submitting": "Connexion...",
+ "notAdmin": "Accès refusé : ce compte n'est pas administrateur.",
+ "error": "Échec de la connexion. Vérifiez vos identifiants.",
+ "backToApp": "Retour au site"
+ }
+ },
"language": {
"label": "Langue",
"fr": "Français",
@@ -303,7 +323,8 @@
"wiki": "Wiki Maritime",
"organization": "Organisation",
"apiKeys": "Clés API",
- "users": "Utilisateurs"
+ "users": "Utilisateurs",
+ "admin": "Administration"
},
"topbar": {
"defaultTitle": "Tableau de bord"
@@ -1858,15 +1879,42 @@
"colItem": "Poste",
"colAmount": "Montant",
"exampleItems": [
- { "item": "Fret maritime de base", "amount": "1 200 USD" },
- { "item": "BAF (Bunker)", "amount": "350 USD" },
- { "item": "CAF (Devise)", "amount": "50 USD" },
- { "item": "THC Origine", "amount": "180 USD" },
- { "item": "THC Destination", "amount": "220 USD" },
- { "item": "Frais B/L", "amount": "55 USD" },
- { "item": "ISPS", "amount": "30 USD" },
- { "item": "Pré-acheminement", "amount": "250 USD" },
- { "item": "Total", "amount": "2 335 USD" }
+ {
+ "item": "Fret maritime de base",
+ "amount": "1 200 USD"
+ },
+ {
+ "item": "BAF (Bunker)",
+ "amount": "350 USD"
+ },
+ {
+ "item": "CAF (Devise)",
+ "amount": "50 USD"
+ },
+ {
+ "item": "THC Origine",
+ "amount": "180 USD"
+ },
+ {
+ "item": "THC Destination",
+ "amount": "220 USD"
+ },
+ {
+ "item": "Frais B/L",
+ "amount": "55 USD"
+ },
+ {
+ "item": "ISPS",
+ "amount": "30 USD"
+ },
+ {
+ "item": "Pré-acheminement",
+ "amount": "250 USD"
+ },
+ {
+ "item": "Total",
+ "amount": "2 335 USD"
+ }
]
},
"conteneurs": {
@@ -1975,8 +2023,14 @@
"condition": "Marchandises hors-gabarit / très lourdes",
"recommendation": "Flat Rack ou Plateforme"
},
- { "condition": "Liquides en vrac", "recommendation": "ISO Tank" },
- { "condition": "Volume < 15 m³", "recommendation": "Envisager le LCL" }
+ {
+ "condition": "Liquides en vrac",
+ "recommendation": "ISO Tank"
+ },
+ {
+ "condition": "Volume < 15 m³",
+ "recommendation": "Envisager le LCL"
+ }
]
},
"documentsTransport": {
@@ -2226,8 +2280,16 @@
"description": "Matières toxiques et infectieuses",
"subdivisions": ["6.1 Matières toxiques", "6.2 Matières infectieuses"]
},
- { "class": "Classe 7", "name": "Radioactifs", "description": "Matières radioactives" },
- { "class": "Classe 8", "name": "Corrosifs", "description": "Matières corrosives" },
+ {
+ "class": "Classe 7",
+ "name": "Radioactifs",
+ "description": "Matières radioactives"
+ },
+ {
+ "class": "Classe 8",
+ "name": "Corrosifs",
+ "description": "Matières corrosives"
+ },
{
"class": "Classe 9",
"name": "Divers",
@@ -2263,8 +2325,14 @@
"group": "Groupe I (X)",
"description": "Grand danger — exigences d'emballage les plus strictes"
},
- { "group": "Groupe II (Y)", "description": "Danger moyen — emballage standard" },
- { "group": "Groupe III (Z)", "description": "Faible danger — exigences moins strictes" }
+ {
+ "group": "Groupe II (Y)",
+ "description": "Danger moyen — emballage standard"
+ },
+ {
+ "group": "Groupe III (Z)",
+ "description": "Faible danger — exigences moins strictes"
+ }
],
"labelingTitle": "Étiquetage et Placardage",
"labelingContent": "Chaque colis doit afficher : numéro ONU, désignation officielle de transport, étiquettes de danger et classe. Les conteneurs doivent afficher des plaques-étiquettes de 250mm × 250mm correspondant à la classe IMDG. Les chargements mixtes requièrent des étiquettes pour chaque marchandise dangereuse.",
@@ -2428,7 +2496,10 @@
"name": "Facture Commerciale",
"description": "En exacte conformité avec la L/C — montants, devises, description"
},
- { "name": "Liste de Colisage", "description": "Cohérente avec la facture et le B/L" },
+ {
+ "name": "Liste de Colisage",
+ "description": "Cohérente avec la facture et le B/L"
+ },
{
"name": "Certificat d'Assurance",
"description": "Requis si CIF ou CIP — montants et couverture selon L/C"
@@ -2483,7 +2554,10 @@
"label": "Commission de confirmation",
"description": "0,2–0,5% par trimestre (banque confirmatrice)"
},
- { "label": "Frais d'amendement", "description": "Frais fixes par amendement" },
+ {
+ "label": "Frais d'amendement",
+ "description": "Frais fixes par amendement"
+ },
{
"label": "Frais de réserve",
"description": "Frais fixes en cas de réserve sur les documents"
@@ -2566,16 +2640,66 @@
"colCountry": "Pays",
"colTeu": "TEU / an",
"ports": [
- { "rank": 1, "port": "Shanghai", "country": "Chine", "teu": "47M" },
- { "rank": 2, "port": "Singapour", "country": "Singapour", "teu": "37M" },
- { "rank": 3, "port": "Ningbo-Zhoushan", "country": "Chine", "teu": "33M" },
- { "rank": 4, "port": "Shenzhen", "country": "Chine", "teu": "29M" },
- { "rank": 5, "port": "Guangzhou", "country": "Chine", "teu": "24M" },
- { "rank": 6, "port": "Qingdao", "country": "Chine", "teu": "24M" },
- { "rank": 7, "port": "Busan", "country": "Corée du Sud", "teu": "22M" },
- { "rank": 8, "port": "Tianjin", "country": "Chine", "teu": "21M" },
- { "rank": 9, "port": "Dubaï (Jebel Ali)", "country": "EAU", "teu": "15M" },
- { "rank": 10, "port": "Rotterdam", "country": "Pays-Bas", "teu": "15M" }
+ {
+ "rank": 1,
+ "port": "Shanghai",
+ "country": "Chine",
+ "teu": "47M"
+ },
+ {
+ "rank": 2,
+ "port": "Singapour",
+ "country": "Singapour",
+ "teu": "37M"
+ },
+ {
+ "rank": 3,
+ "port": "Ningbo-Zhoushan",
+ "country": "Chine",
+ "teu": "33M"
+ },
+ {
+ "rank": 4,
+ "port": "Shenzhen",
+ "country": "Chine",
+ "teu": "29M"
+ },
+ {
+ "rank": 5,
+ "port": "Guangzhou",
+ "country": "Chine",
+ "teu": "24M"
+ },
+ {
+ "rank": 6,
+ "port": "Qingdao",
+ "country": "Chine",
+ "teu": "24M"
+ },
+ {
+ "rank": 7,
+ "port": "Busan",
+ "country": "Corée du Sud",
+ "teu": "22M"
+ },
+ {
+ "rank": 8,
+ "port": "Tianjin",
+ "country": "Chine",
+ "teu": "21M"
+ },
+ {
+ "rank": 9,
+ "port": "Dubaï (Jebel Ali)",
+ "country": "EAU",
+ "teu": "15M"
+ },
+ {
+ "rank": 10,
+ "port": "Rotterdam",
+ "country": "Pays-Bas",
+ "teu": "15M"
+ }
],
"hubGatewayTitle": "Hub vs Gateway",
"hubTitle": "Port Hub",
@@ -2683,10 +2807,22 @@
"consequenceValue": "Nouvelle pesée à la charge de l'expéditeur, retard possible",
"sanctionsTitle": "Sanctions par Région",
"sanctions": [
- { "region": "France", "sanction": "Amende jusqu'à 7 500€ et refus d'embarquement" },
- { "region": "USA", "sanction": "Refus d'embarquement, amende par la garde côtière" },
- { "region": "Chine", "sanction": "Refus d'embarquement, pénalités portuaires" },
- { "region": "Union Européenne", "sanction": "Application variable selon pays membre" }
+ {
+ "region": "France",
+ "sanction": "Amende jusqu'à 7 500€ et refus d'embarquement"
+ },
+ {
+ "region": "USA",
+ "sanction": "Refus d'embarquement, amende par la garde côtière"
+ },
+ {
+ "region": "Chine",
+ "sanction": "Refus d'embarquement, pénalités portuaires"
+ },
+ {
+ "region": "Union Européenne",
+ "sanction": "Application variable selon pays membre"
+ }
],
"tipsTitle": "Bonnes Pratiques",
"tips": [
@@ -2781,13 +2917,41 @@
"colTime": "Transit Time",
"colVia": "Via",
"transitTimes": [
- { "route": "Shanghai → Rotterdam", "time": "28–32 jours", "via": "Suez" },
- { "route": "Shanghai → Le Havre", "time": "30–35 jours", "via": "Suez" },
- { "route": "Shanghai → Los Angeles", "time": "12–15 jours", "via": "Pacifique direct" },
- { "route": "Shanghai → New York", "time": "35–40 jours", "via": "Suez ou Panama" },
- { "route": "Rotterdam → New York", "time": "10–14 jours", "via": "Atlantique direct" },
- { "route": "Mumbai → Rotterdam", "time": "18–22 jours", "via": "Suez" },
- { "route": "Santos → Rotterdam", "time": "18–22 jours", "via": "Atlantique direct" }
+ {
+ "route": "Shanghai → Rotterdam",
+ "time": "28–32 jours",
+ "via": "Suez"
+ },
+ {
+ "route": "Shanghai → Le Havre",
+ "time": "30–35 jours",
+ "via": "Suez"
+ },
+ {
+ "route": "Shanghai → Los Angeles",
+ "time": "12–15 jours",
+ "via": "Pacifique direct"
+ },
+ {
+ "route": "Shanghai → New York",
+ "time": "35–40 jours",
+ "via": "Suez ou Panama"
+ },
+ {
+ "route": "Rotterdam → New York",
+ "time": "10–14 jours",
+ "via": "Atlantique direct"
+ },
+ {
+ "route": "Mumbai → Rotterdam",
+ "time": "18–22 jours",
+ "via": "Suez"
+ },
+ {
+ "route": "Santos → Rotterdam",
+ "time": "18–22 jours",
+ "via": "Atlantique direct"
+ }
],
"transitNote": "Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.",
"freeTimeTitle": "Free Time (Jours Gratuits)",
diff --git a/apps/frontend/middleware.ts b/apps/frontend/middleware.ts
index fc46853..1d0ef69 100644
--- a/apps/frontend/middleware.ts
+++ b/apps/frontend/middleware.ts
@@ -29,6 +29,7 @@ const prefixPublicPaths = [
'/press',
'/contact',
'/carrier',
+ '/admin/login',
'/pricing',
'/docs',
'/privacy',
@@ -71,7 +72,11 @@ export default function middleware(request: NextRequest) {
const token = request.cookies.get('accessToken')?.value;
if (!isPublicPath && !token) {
- const loginUrl = new URL(`/${resolvedLocale}/login`, request.url);
+ // The admin area has its own entry point — send unauthenticated visitors
+ // to the dedicated admin login instead of the regular one.
+ const isAdminArea = pathWithoutLocale === '/admin' || pathWithoutLocale.startsWith('/admin/');
+ const loginPath = isAdminArea ? '/admin/login' : '/login';
+ const loginUrl = new URL(`/${resolvedLocale}${loginPath}`, request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
diff --git a/apps/frontend/src/components/admin/AdminPanelDropdown.tsx b/apps/frontend/src/components/admin/AdminPanelDropdown.tsx
deleted file mode 100644
index 3e53c5b..0000000
--- a/apps/frontend/src/components/admin/AdminPanelDropdown.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-'use client';
-
-import { useState, useRef, useEffect } from 'react';
-import { useTranslations } from 'next-intl';
-import { Link, usePathname } from '@/i18n/navigation';
-import {
- Users,
- Building2,
- Package,
- FileText,
- BarChart3,
- Settings,
- ScrollText,
- Newspaper,
- type LucideIcon,
-} from 'lucide-react';
-
-interface AdminMenuItem {
- key: string;
- href: string;
- icon: LucideIcon;
-}
-
-const adminMenuItems: AdminMenuItem[] = [
- { key: 'users', href: '/dashboard/admin/users', icon: Users },
- { key: 'organizations', href: '/dashboard/admin/organizations', icon: Building2 },
- { key: 'bookings', href: '/dashboard/admin/bookings', icon: Package },
- { key: 'documents', href: '/dashboard/admin/documents', icon: FileText },
- { key: 'csvRates', href: '/dashboard/admin/csv-rates', icon: BarChart3 },
- { key: 'blog', href: '/dashboard/admin/blog', icon: Newspaper },
- { key: 'logs', href: '/dashboard/admin/logs', icon: ScrollText },
-];
-
-export default function AdminPanelDropdown() {
- const t = useTranslations('components.adminPanelDropdown');
- const [isOpen, setIsOpen] = useState(false);
- const dropdownRef = useRef(null);
- const pathname = usePathname();
-
- useEffect(() => {
- function handleClickOutside(event: MouseEvent) {
- if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
- setIsOpen(false);
- }
- }
-
- if (isOpen) {
- document.addEventListener('mousedown', handleClickOutside);
- }
-
- return () => {
- document.removeEventListener('mousedown', handleClickOutside);
- };
- }, [isOpen]);
-
- useEffect(() => {
- setIsOpen(false);
- }, [pathname]);
-
- const isActiveRoute = adminMenuItems.some(item => pathname.startsWith(item.href));
-
- return (
-
- {/* Trigger Button */}
-
-
- {/* Dropdown Menu */}
- {isOpen && (
-
-
- {adminMenuItems.map(item => {
- const isActive = pathname.startsWith(item.href);
- const IconComponent = item.icon;
- const name = t(`items.${item.key}` as any);
- const description = t(`items.${item.key}Desc` as any);
- return (
-
-
-
-
- {name}
-
-
{description}
-
-
- );
- })}
-
-
- )}
-
- );
-}