fix admin page

This commit is contained in:
David 2026-06-13 11:53:27 +02:00
parent bd52819f28
commit c6eaaf354a
9 changed files with 824 additions and 209 deletions

View File

@ -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 (
<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>
);
}
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'
}`}
>
<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>
);
}

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center bg-brand-navy px-4">
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy to-neutral-900 opacity-95" />
<div className="relative w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-8 sm:p-10">
<div className="flex flex-col items-center mb-8">
<Image
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={56}
height={64}
priority
className="h-auto mb-4"
/>
<div className="flex items-center gap-2 text-brand-turquoise">
<ShieldCheck className="w-5 h-5" />
<span className="text-xs font-semibold uppercase tracking-wider">{t('badge')}</span>
</div>
<h1 className="text-h2 text-brand-navy mt-3 text-center">{t('title')}</h1>
<p className="text-body-sm text-neutral-600 mt-1 text-center">{t('subtitle')}</p>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-body-sm text-red-800">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="label">
{t('emailLabel')}
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input w-full"
placeholder={t('emailPlaceholder')}
autoComplete="email"
disabled={isLoading}
required
/>
</div>
<div>
<label htmlFor="password" className="label">
{t('passwordLabel')}
</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input w-full"
placeholder={t('passwordPlaceholder')}
autoComplete="current-password"
disabled={isLoading}
required
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full text-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? t('submitting') : t('submit')}
</button>
</form>
<div className="mt-6 text-center">
<Link href="/login" className="text-body-sm link">
{t('backToApp')}
</Link>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { redirect } from 'next/navigation';
type Params = { locale: string };
export default async function AdminIndexPage({ params }: { params: Promise<Params> }) {
const { locale } = await params;
redirect(`/${locale}/admin/users`);
}

View File

@ -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<Params> }) {
const { locale, rest } = await params;
const sub = rest && rest.length ? `/${rest.join('/')}` : '';
redirect(`/${locale}/admin${sub}`);
}

View File

@ -6,7 +6,6 @@ import { useState, useEffect } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import NotificationDropdown from '@/components/NotificationDropdown'; import NotificationDropdown from '@/components/NotificationDropdown';
import LanguageSwitcher from '@/components/LanguageSwitcher'; import LanguageSwitcher from '@/components/LanguageSwitcher';
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
import Image from 'next/image'; import Image from 'next/image';
import { import {
BarChart3, BarChart3,
@ -21,6 +20,7 @@ import {
Key, Key,
Home, Home,
User, User,
ShieldCheck,
} from 'lucide-react'; } from 'lucide-react';
import { useSubscription } from '@/lib/context/subscription-context'; import { useSubscription } from '@/lib/context/subscription-context';
import StatusBadge from '@/components/ui/StatusBadge'; import StatusBadge from '@/components/ui/StatusBadge';
@ -159,7 +159,13 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{user?.role === 'ADMIN' && ( {user?.role === 'ADMIN' && (
<div className="pt-4 mt-4 border-t"> <div className="pt-4 mt-4 border-t">
<AdminPanelDropdown /> <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> </div>
)} )}
</nav> </nav>

View File

@ -27,6 +27,26 @@
"continue": "Continue", "continue": "Continue",
"submit": "Submit" "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": { "language": {
"label": "Language", "label": "Language",
"fr": "Français", "fr": "Français",
@ -303,7 +323,8 @@
"wiki": "Maritime Wiki", "wiki": "Maritime Wiki",
"organization": "Organization", "organization": "Organization",
"apiKeys": "API Keys", "apiKeys": "API Keys",
"users": "Users" "users": "Users",
"admin": "Administration"
}, },
"topbar": { "topbar": {
"defaultTitle": "Dashboard" "defaultTitle": "Dashboard"
@ -1723,7 +1744,10 @@
], ],
"extensionsTitle": "Coverage Extensions", "extensionsTitle": "Coverage Extensions",
"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", "name": "Strikes clause",
"description": "Covers losses due to strikes, riots, civil commotion" "description": "Covers losses due to strikes, riots, civil commotion"
@ -1849,15 +1873,42 @@
"colItem": "Item", "colItem": "Item",
"colAmount": "Amount", "colAmount": "Amount",
"exampleItems": [ "exampleItems": [
{ "item": "Base ocean freight", "amount": "1,200 USD" }, {
{ "item": "BAF (Bunker)", "amount": "350 USD" }, "item": "Base ocean freight",
{ "item": "CAF (Currency)", "amount": "50 USD" }, "amount": "1,200 USD"
{ "item": "THC Origin", "amount": "180 USD" }, },
{ "item": "THC Destination", "amount": "220 USD" }, {
{ "item": "B/L Fee", "amount": "55 USD" }, "item": "BAF (Bunker)",
{ "item": "ISPS", "amount": "30 USD" }, "amount": "350 USD"
{ "item": "Pre-carriage", "amount": "250 USD" }, },
{ "item": "Total", "amount": "2,335 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": { "conteneurs": {
@ -1929,7 +1980,10 @@
], ],
"specialEquipmentTitle": "Special Equipment", "specialEquipmentTitle": "Special Equipment",
"specialEquipment": [ "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", "name": "Bulk Container",
"description": "For dry bulk (grains, minerals) — top hatch" "description": "For dry bulk (grains, minerals) — top hatch"
@ -1951,11 +2005,26 @@
"condition": "Standard general cargo", "condition": "Standard general cargo",
"recommendation": "20' or 40' Dry depending on volume" "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": "Temperature-sensitive goods",
{ "condition": "Over-length/weight cargo", "recommendation": "Flat Rack or Platform" }, "recommendation": "Reefer 20' or 40'"
{ "condition": "Bulk liquids", "recommendation": "ISO Tank" }, },
{ "condition": "Volume < 15 m³", "recommendation": "Consider LCL" } {
"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": { "documentsTransport": {
@ -2143,7 +2212,10 @@
"type": "VAT", "type": "VAT",
"description": "Applied on (customs value + import duties + transport). 20% standard rate." "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": { "imdg": {
@ -2202,8 +2274,16 @@
"description": "Toxic and infectious substances", "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", "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", "class": "Class 9",
"name": "Miscellaneous", "name": "Miscellaneous",
@ -2239,8 +2319,14 @@
"group": "Group I (X)", "group": "Group I (X)",
"description": "High danger — most stringent packaging requirements" "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", "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.", "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", "lcl": "< 15 m³ or < 10 tonnes",
"fcl": "> 15 m³ or full container" "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", "criterion": "Security",
"lcl": "Moderate (shared with others)", "lcl": "Moderate (shared with others)",
@ -2373,7 +2463,10 @@
"role": "Applicant (Importer)", "role": "Applicant (Importer)",
"description": "The buyer who requests the L/C at their bank" "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)", "role": "Beneficiary (Exporter)",
"description": "The seller who benefits from the L/C" "description": "The seller who benefits from the L/C"
@ -2397,7 +2490,10 @@
"name": "Commercial Invoice", "name": "Commercial Invoice",
"description": "In exact conformity with the L/C — amounts, currencies, description" "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", "name": "Insurance Certificate",
"description": "Required if CIF or CIP — amounts and coverage per L/C" "description": "Required if CIF or CIP — amounts and coverage per L/C"
@ -2437,7 +2533,10 @@
"label": "Presentation deadline", "label": "Presentation deadline",
"description": "Number of days after shipment to present documents (typically 21 days)" "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", "costsTitle": "Costs",
"costsItems": [ "costsItems": [
@ -2449,8 +2548,14 @@
"label": "Confirmation commission", "label": "Confirmation commission",
"description": "0.20.5% per quarter (confirming bank)" "description": "0.20.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": { "portsRoutes": {
@ -2529,16 +2634,66 @@
"colCountry": "Country", "colCountry": "Country",
"colTeu": "TEU / year", "colTeu": "TEU / year",
"ports": [ "ports": [
{ "rank": 1, "port": "Shanghai", "country": "China", "teu": "47M" }, {
{ "rank": 2, "port": "Singapore", "country": "Singapore", "teu": "37M" }, "rank": 1,
{ "rank": 3, "port": "Ningbo-Zhoushan", "country": "China", "teu": "33M" }, "port": "Shanghai",
{ "rank": 4, "port": "Shenzhen", "country": "China", "teu": "29M" }, "country": "China",
{ "rank": 5, "port": "Guangzhou", "country": "China", "teu": "24M" }, "teu": "47M"
{ "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": 2,
{ "rank": 9, "port": "Dubai (Jebel Ali)", "country": "UAE", "teu": "15M" }, "port": "Singapore",
{ "rank": 10, "port": "Rotterdam", "country": "Netherlands", "teu": "15M" } "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", "hubGatewayTitle": "Hub vs Gateway",
"hubTitle": "Hub Port", "hubTitle": "Hub Port",
@ -2576,7 +2731,11 @@
"description": "Empty weight of the container (shown on the door)", "description": "Empty weight of the container (shown on the door)",
"example": "2,200 kg (20')" "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", "element": "Packaging",
"description": "Pallets, cartons, plastic film...", "description": "Pallets, cartons, plastic film...",
@ -2642,10 +2801,22 @@
"consequenceValue": "Re-weighing at shipper's expense, possible delay", "consequenceValue": "Re-weighing at shipper's expense, possible delay",
"sanctionsTitle": "Sanctions by Region", "sanctionsTitle": "Sanctions by Region",
"sanctions": [ "sanctions": [
{ "region": "France", "sanction": "Fine up to €7,500 and refusal to load" }, {
{ "region": "USA", "sanction": "Refusal to load, fine from coast guard" }, "region": "France",
{ "region": "China", "sanction": "Refusal to load, port penalties" }, "sanction": "Fine up to €7,500 and refusal to load"
{ "region": "European Union", "sanction": "Variable application by member state" } },
{
"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", "tipsTitle": "Best Practices",
"tips": [ "tips": [
@ -2740,13 +2911,41 @@
"colTime": "Transit Time", "colTime": "Transit Time",
"colVia": "Via", "colVia": "Via",
"transitTimes": [ "transitTimes": [
{ "route": "Shanghai → Rotterdam", "time": "2832 days", "via": "Suez" }, {
{ "route": "Shanghai → Le Havre", "time": "3035 days", "via": "Suez" }, "route": "Shanghai → Rotterdam",
{ "route": "Shanghai → Los Angeles", "time": "1215 days", "via": "Direct Pacific" }, "time": "2832 days",
{ "route": "Shanghai → New York", "time": "3540 days", "via": "Suez or Panama" }, "via": "Suez"
{ "route": "Rotterdam → New York", "time": "1014 days", "via": "Direct Atlantic" }, },
{ "route": "Mumbai → Rotterdam", "time": "1822 days", "via": "Suez" }, {
{ "route": "Santos → Rotterdam", "time": "1822 days", "via": "Direct Atlantic" } "route": "Shanghai → Le Havre",
"time": "3035 days",
"via": "Suez"
},
{
"route": "Shanghai → Los Angeles",
"time": "1215 days",
"via": "Direct Pacific"
},
{
"route": "Shanghai → New York",
"time": "3540 days",
"via": "Suez or Panama"
},
{
"route": "Rotterdam → New York",
"time": "1014 days",
"via": "Direct Atlantic"
},
{
"route": "Mumbai → Rotterdam",
"time": "1822 days",
"via": "Suez"
},
{
"route": "Santos → Rotterdam",
"time": "1822 days",
"via": "Direct Atlantic"
}
], ],
"transitNote": "Note: These times are indicative and vary depending on rotations, transshipments and conditions.", "transitNote": "Note: These times are indicative and vary depending on rotations, transshipments and conditions.",
"freeTimeTitle": "Free Time (Free Days)", "freeTimeTitle": "Free Time (Free Days)",

View File

@ -27,6 +27,26 @@
"continue": "Continuer", "continue": "Continuer",
"submit": "Envoyer" "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": { "language": {
"label": "Langue", "label": "Langue",
"fr": "Français", "fr": "Français",
@ -303,7 +323,8 @@
"wiki": "Wiki Maritime", "wiki": "Wiki Maritime",
"organization": "Organisation", "organization": "Organisation",
"apiKeys": "Clés API", "apiKeys": "Clés API",
"users": "Utilisateurs" "users": "Utilisateurs",
"admin": "Administration"
}, },
"topbar": { "topbar": {
"defaultTitle": "Tableau de bord" "defaultTitle": "Tableau de bord"
@ -1858,15 +1879,42 @@
"colItem": "Poste", "colItem": "Poste",
"colAmount": "Montant", "colAmount": "Montant",
"exampleItems": [ "exampleItems": [
{ "item": "Fret maritime de base", "amount": "1 200 USD" }, {
{ "item": "BAF (Bunker)", "amount": "350 USD" }, "item": "Fret maritime de base",
{ "item": "CAF (Devise)", "amount": "50 USD" }, "amount": "1 200 USD"
{ "item": "THC Origine", "amount": "180 USD" }, },
{ "item": "THC Destination", "amount": "220 USD" }, {
{ "item": "Frais B/L", "amount": "55 USD" }, "item": "BAF (Bunker)",
{ "item": "ISPS", "amount": "30 USD" }, "amount": "350 USD"
{ "item": "Pré-acheminement", "amount": "250 USD" }, },
{ "item": "Total", "amount": "2 335 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": { "conteneurs": {
@ -1975,8 +2023,14 @@
"condition": "Marchandises hors-gabarit / très lourdes", "condition": "Marchandises hors-gabarit / très lourdes",
"recommendation": "Flat Rack ou Plateforme" "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": { "documentsTransport": {
@ -2226,8 +2280,16 @@
"description": "Matières toxiques et infectieuses", "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", "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", "class": "Classe 9",
"name": "Divers", "name": "Divers",
@ -2263,8 +2325,14 @@
"group": "Groupe I (X)", "group": "Groupe I (X)",
"description": "Grand danger — exigences d'emballage les plus strictes" "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", "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.", "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", "name": "Facture Commerciale",
"description": "En exacte conformité avec la L/C — montants, devises, description" "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", "name": "Certificat d'Assurance",
"description": "Requis si CIF ou CIP — montants et couverture selon L/C" "description": "Requis si CIF ou CIP — montants et couverture selon L/C"
@ -2483,7 +2554,10 @@
"label": "Commission de confirmation", "label": "Commission de confirmation",
"description": "0,20,5% par trimestre (banque confirmatrice)" "description": "0,20,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", "label": "Frais de réserve",
"description": "Frais fixes en cas de réserve sur les documents" "description": "Frais fixes en cas de réserve sur les documents"
@ -2566,16 +2640,66 @@
"colCountry": "Pays", "colCountry": "Pays",
"colTeu": "TEU / an", "colTeu": "TEU / an",
"ports": [ "ports": [
{ "rank": 1, "port": "Shanghai", "country": "Chine", "teu": "47M" }, {
{ "rank": 2, "port": "Singapour", "country": "Singapour", "teu": "37M" }, "rank": 1,
{ "rank": 3, "port": "Ningbo-Zhoushan", "country": "Chine", "teu": "33M" }, "port": "Shanghai",
{ "rank": 4, "port": "Shenzhen", "country": "Chine", "teu": "29M" }, "country": "Chine",
{ "rank": 5, "port": "Guangzhou", "country": "Chine", "teu": "24M" }, "teu": "47M"
{ "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": 2,
{ "rank": 9, "port": "Dubaï (Jebel Ali)", "country": "EAU", "teu": "15M" }, "port": "Singapour",
{ "rank": 10, "port": "Rotterdam", "country": "Pays-Bas", "teu": "15M" } "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", "hubGatewayTitle": "Hub vs Gateway",
"hubTitle": "Port Hub", "hubTitle": "Port Hub",
@ -2683,10 +2807,22 @@
"consequenceValue": "Nouvelle pesée à la charge de l'expéditeur, retard possible", "consequenceValue": "Nouvelle pesée à la charge de l'expéditeur, retard possible",
"sanctionsTitle": "Sanctions par Région", "sanctionsTitle": "Sanctions par Région",
"sanctions": [ "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": "France",
{ "region": "Chine", "sanction": "Refus d'embarquement, pénalités portuaires" }, "sanction": "Amende jusqu'à 7 500€ et refus d'embarquement"
{ "region": "Union Européenne", "sanction": "Application variable selon pays membre" } },
{
"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", "tipsTitle": "Bonnes Pratiques",
"tips": [ "tips": [
@ -2781,13 +2917,41 @@
"colTime": "Transit Time", "colTime": "Transit Time",
"colVia": "Via", "colVia": "Via",
"transitTimes": [ "transitTimes": [
{ "route": "Shanghai → Rotterdam", "time": "2832 jours", "via": "Suez" }, {
{ "route": "Shanghai → Le Havre", "time": "3035 jours", "via": "Suez" }, "route": "Shanghai → Rotterdam",
{ "route": "Shanghai → Los Angeles", "time": "1215 jours", "via": "Pacifique direct" }, "time": "2832 jours",
{ "route": "Shanghai → New York", "time": "3540 jours", "via": "Suez ou Panama" }, "via": "Suez"
{ "route": "Rotterdam → New York", "time": "1014 jours", "via": "Atlantique direct" }, },
{ "route": "Mumbai → Rotterdam", "time": "1822 jours", "via": "Suez" }, {
{ "route": "Santos → Rotterdam", "time": "1822 jours", "via": "Atlantique direct" } "route": "Shanghai → Le Havre",
"time": "3035 jours",
"via": "Suez"
},
{
"route": "Shanghai → Los Angeles",
"time": "1215 jours",
"via": "Pacifique direct"
},
{
"route": "Shanghai → New York",
"time": "3540 jours",
"via": "Suez ou Panama"
},
{
"route": "Rotterdam → New York",
"time": "1014 jours",
"via": "Atlantique direct"
},
{
"route": "Mumbai → Rotterdam",
"time": "1822 jours",
"via": "Suez"
},
{
"route": "Santos → Rotterdam",
"time": "1822 jours",
"via": "Atlantique direct"
}
], ],
"transitNote": "Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.", "transitNote": "Note : Ces temps sont indicatifs et varient selon les rotations, transbordements et conditions.",
"freeTimeTitle": "Free Time (Jours Gratuits)", "freeTimeTitle": "Free Time (Jours Gratuits)",

View File

@ -29,6 +29,7 @@ const prefixPublicPaths = [
'/press', '/press',
'/contact', '/contact',
'/carrier', '/carrier',
'/admin/login',
'/pricing', '/pricing',
'/docs', '/docs',
'/privacy', '/privacy',
@ -71,7 +72,11 @@ export default function middleware(request: NextRequest) {
const token = request.cookies.get('accessToken')?.value; const token = request.cookies.get('accessToken')?.value;
if (!isPublicPath && !token) { 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); loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl); return NextResponse.redirect(loginUrl);
} }

View File

@ -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<HTMLDivElement>(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 (
<div className="relative" ref={dropdownRef}>
{/* Trigger Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center w-full px-4 py-3 text-sm font-medium rounded-lg transition-colors ${
isActiveRoute ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-100'
}`}
>
<Settings className="mr-3 h-5 w-5" />
<span className="flex-1 text-left">{t('trigger')}</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div className="absolute left-0 right-0 mt-2 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
<div className="py-2">
{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 (
<Link
key={item.key}
href={item.href}
className={`flex items-start px-4 py-3 hover:bg-gray-50 transition-colors ${
isActive ? 'bg-blue-50' : ''
}`}
>
<IconComponent className="h-5 w-5 mr-3 mt-0.5 text-gray-500" />
<div className="flex-1">
<div
className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}
>
{name}
</div>
<div className="text-xs text-gray-500 mt-0.5">{description}</div>
</div>
</Link>
);
})}
</div>
</div>
)}
</div>
);
}