From c6eaaf354a54e34bf565b501f3ba4b69f7d44446 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 13 Jun 2026 11:53:27 +0200 Subject: [PATCH] fix admin page --- apps/frontend/app/[locale]/admin/layout.tsx | 211 ++++++++++++ .../app/[locale]/admin/login/page.tsx | 128 ++++++++ apps/frontend/app/[locale]/admin/page.tsx | 8 + .../dashboard/admin/[[...rest]]/page.tsx | 11 + .../app/[locale]/dashboard/layout.tsx | 10 +- apps/frontend/messages/en.json | 299 +++++++++++++++--- apps/frontend/messages/fr.json | 242 +++++++++++--- apps/frontend/middleware.ts | 7 +- .../components/admin/AdminPanelDropdown.tsx | 117 ------- 9 files changed, 824 insertions(+), 209 deletions(-) create mode 100644 apps/frontend/app/[locale]/admin/layout.tsx create mode 100644 apps/frontend/app/[locale]/admin/login/page.tsx create mode 100644 apps/frontend/app/[locale]/admin/page.tsx create mode 100644 apps/frontend/app/[locale]/dashboard/admin/[[...rest]]/page.tsx delete mode 100644 apps/frontend/src/components/admin/AdminPanelDropdown.tsx 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)} + /> + )} + +
+
+
+ + Xpeditis + + +
+ +
+ + {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 ( +
+
+ +
+
+
+ Xpeditis +
+ + {t('badge')} +
+

{t('title')}

+

{t('subtitle')}

+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + setEmail(e.target.value)} + className="input w-full" + placeholder={t('emailPlaceholder')} + autoComplete="email" + disabled={isLoading} + required + /> +
+ +
+ + setPassword(e.target.value)} + className="input w-full" + placeholder={t('passwordPlaceholder')} + autoComplete="current-password" + disabled={isLoading} + required + /> +
+ + +
+ +
+ + {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}
-
- - ); - })} -
-
- )} -
- ); -}