first commit
This commit is contained in:
commit
15d43ee2bd
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.idea
|
||||||
60
Dockerfile
Normal file
60
Dockerfile
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Multi-stage build pour le frontend React
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Installer pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
|
# Copier les fichiers de dépendances
|
||||||
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# Installer les dépendances
|
||||||
|
RUN pnpm install
|
||||||
|
|
||||||
|
# Stage de développement
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
|
# Copier le code source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Exposer le port de développement
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Commande de développement avec hot reload
|
||||||
|
CMD ["pnpm", "dev", "--host", "0.0.0.0"]
|
||||||
|
|
||||||
|
# Stage de build pour la production
|
||||||
|
FROM base AS build
|
||||||
|
|
||||||
|
# Copier le code source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Variables d'environnement pour le build
|
||||||
|
ARG VITE_API_URL
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
# Construire l'application
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage de production avec Nginx
|
||||||
|
FROM nginx:alpine AS production
|
||||||
|
|
||||||
|
# Copier la configuration Nginx personnalisée
|
||||||
|
COPY infrastructure/nginx/frontend.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copier les fichiers buildés
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copier le script d'entrée pour les variables d'environnement
|
||||||
|
COPY infrastructure/docker/frontend-entrypoint.sh /docker-entrypoint.d/40-frontend-config.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-frontend-config.sh
|
||||||
|
|
||||||
|
# Exposer le port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/ || exit 1
|
||||||
|
|
||||||
|
# Nginx démarre automatiquement
|
||||||
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
46
index.html
Normal file
46
index.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#0066CC" />
|
||||||
|
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Xpeditis - Plateforme SaaS pour le transport maritime international. Devis instantané, suivi en temps réel, gestion des expéditions. Simplifiez votre logistique maritime."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="transport maritime, logistique, SaaS, expédition, fret maritime, devis transport, suivi cargaison, commerce international, Xpeditis"
|
||||||
|
/>
|
||||||
|
<meta name="author" content="Xpeditis" />
|
||||||
|
|
||||||
|
<!-- Open Graph pour un meilleur affichage sur les réseaux sociaux -->
|
||||||
|
<meta property="og:title" content="Xpeditis - Plateforme Maritime SaaS" />
|
||||||
|
<meta property="og:description" content="Révolutionnez votre transport maritime avec notre plateforme SaaS. Devis instantané, suivi en temps réel et gestion simplifiée." />
|
||||||
|
<meta property="og:image" content="/vite.svg" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://www.xpeditis.com" />
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" href="/vite.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
|
<title>Xpeditis - Plateforme Maritime SaaS | Transport & Logistique</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>Vous devez activer JavaScript pour utiliser cette application.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
25
manifest.json
Normal file
25
manifest.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
50
package.json
Normal file
50
package.json
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "xpeditis-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-select": "^2.0.0",
|
||||||
|
"@radix-ui/react-slot": "^1.0.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@tanstack/react-query": "^5.8.4",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.48.2",
|
||||||
|
"react-router-dom": "^6.20.1",
|
||||||
|
"tailwind-merge": "^2.0.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
|
"@vitejs/plugin-react": "^4.1.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.53.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.4",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"tailwindcss": "^3.3.5",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3818
pnpm-lock.yaml
generated
Normal file
3818
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
3
robots.txt
Normal file
3
robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
41
src/App.tsx
Normal file
41
src/App.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
import ProtectedRoute from './components/auth/ProtectedRoute'
|
||||||
|
import Layout from './components/layout/Layout'
|
||||||
|
import LandingPage from './pages/LandingPage'
|
||||||
|
import HomePage from './pages/HomePage'
|
||||||
|
import QuotePage from './pages/QuotePage'
|
||||||
|
import BookingPage from './pages/BookingPage'
|
||||||
|
import TrackingPage from './pages/TrackingPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import ShipSchedulePage from './pages/ShipSchedulePage'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Routes>
|
||||||
|
{/* Landing page publique */}
|
||||||
|
<Route path="/" element={<LandingPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* Application avec layout (dashboard) - Routes protégées */}
|
||||||
|
<Route path="/app" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="home" element={<HomePage />} />
|
||||||
|
<Route path="quote" element={<QuotePage />} />
|
||||||
|
<Route path="bookings" element={<BookingPage />} />
|
||||||
|
<Route path="tracking" element={<TrackingPage />} />
|
||||||
|
<Route path="dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="schedule" element={<ShipSchedulePage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
38
src/components/auth/ProtectedRoute.tsx
Normal file
38
src/components/auth/ProtectedRoute.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { Ship } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Afficher un loader pendant la vérification de l'authentification
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-white flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="flex items-center justify-center mb-2">
|
||||||
|
<Ship className="h-6 w-6 text-blue-600 mr-2" />
|
||||||
|
<span className="text-lg font-semibold text-gray-900">Xpeditis</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600">Vérification de l'authentification...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si pas authentifié, rediriger vers la page de connexion
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si authentifié, afficher le contenu protégé
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
340
src/components/layout/Layout.tsx
Normal file
340
src/components/layout/Layout.tsx
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||||
|
import { Ship, Menu, X, Home, FileText, Package, MapPin, BarChart3, User, LogOut, Bell, Settings, Globe } from 'lucide-react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import {FC, ReactNode, useEffect, useState} from "react";
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout: FC<LayoutProps> = () => {
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
const [isNotificationOpen, setIsNotificationOpen] = useState(false);
|
||||||
|
const [isLanguageOpen, setIsLanguageOpen] = useState(false);
|
||||||
|
const [currentLanguage, setCurrentLanguage] = useState('fr');
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Accueil', href: '/app/home', icon: Home },
|
||||||
|
{ name: 'Tableau de bord', href: '/app', icon: BarChart3 },
|
||||||
|
{ name: 'Devis', href: '/app/quote', icon: FileText },
|
||||||
|
{ name: 'Réservations', href: '/app/bookings', icon: Package },
|
||||||
|
{ name: 'Suivi', href: '/app/tracking', icon: MapPin }
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
|
// Fermer le menu utilisateur quand on clique ailleurs
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (isUserMenuOpen) {
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isUserMenuOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Header fixe en haut */}
|
||||||
|
<header className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between h-16 px-6">
|
||||||
|
{/* Logo à gauche */}
|
||||||
|
<Link to="/" className="flex items-center">
|
||||||
|
<Ship className="h-8 w-8 text-blue-600" />
|
||||||
|
<span className="ml-2 text-xl font-bold text-gray-900">Xpeditis</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Actions et User Menu tout à droite */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* Centre de notifications */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsNotificationOpen(!isNotificationOpen)}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 transition-colors relative"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5 text-gray-600" />
|
||||||
|
{/* Badge de notification */}
|
||||||
|
<span className="absolute -top-1 -right-1 h-4 w-4 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Notifications */}
|
||||||
|
{isNotificationOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-80 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
|
||||||
|
<div className="px-4 py-2 text-sm font-medium text-gray-900 border-b border-gray-100">
|
||||||
|
Notifications
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
<div className="px-4 py-3 hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<div className="text-sm font-medium text-gray-900">Nouvelle expédition</div>
|
||||||
|
<div className="text-xs text-gray-500">Votre expédition XPD-2025-003 a été confirmée</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">Il y a 2 heures</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 hover:bg-gray-50 border-b border-gray-100">
|
||||||
|
<div className="text-sm font-medium text-gray-900">Devis approuvé</div>
|
||||||
|
<div className="text-xs text-gray-500">Le devis QT-2025-012 a été accepté</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">Il y a 4 heures</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 hover:bg-gray-50">
|
||||||
|
<div className="text-sm font-medium text-gray-900">Livraison terminée</div>
|
||||||
|
<div className="text-xs text-gray-500">L'expédition XPD-2025-001 a été livrée</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">Il y a 1 jour</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 border-t border-gray-100">
|
||||||
|
<button className="text-sm text-blue-600 hover:text-blue-800">Voir toutes les notifications</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paramètres */}
|
||||||
|
<button className="p-2 rounded-lg hover:bg-gray-100 transition-colors">
|
||||||
|
<Settings className="h-5 w-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sélecteur de langue */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsLanguageOpen(!isLanguageOpen)}
|
||||||
|
className="flex items-center space-x-1 px-2 py-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4 text-gray-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-700 uppercase">{currentLanguage}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Langue */}
|
||||||
|
{isLanguageOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-32 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentLanguage('fr');
|
||||||
|
setIsLanguageOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 transition-colors ${
|
||||||
|
currentLanguage === 'fr' ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🇫🇷 Français
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentLanguage('en');
|
||||||
|
setIsLanguageOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 transition-colors ${
|
||||||
|
currentLanguage === 'en' ? 'bg-blue-50 text-blue-700' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🇬🇧 English
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||||
|
className="flex items-center space-x-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<User className="h-5 w-5 text-gray-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">{user?.name || user?.email}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isUserMenuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
|
||||||
|
<div className="px-4 py-2 text-sm text-gray-700 border-b border-gray-100">
|
||||||
|
<div className="font-medium">{user?.name || 'Utilisateur'}</div>
|
||||||
|
<div className="text-gray-500">{user?.email}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Contenu principal avec sidebar */}
|
||||||
|
<div className="flex pt-16 min-h-screen"> {/* pt-16 pour compenser le header fixe */}
|
||||||
|
{/* Sidebar rétractable */}
|
||||||
|
<div className="hidden md:flex md:flex-col group justify-center" style={{height: 'calc(100vh - 4rem)'}}>
|
||||||
|
<div className="flex flex-col bg-white border border-gray-200 rounded-lg shadow-sm transition-all duration-300 ease-in-out w-16 group-hover:w-64 overflow-hidden">
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="py-4 px-2 space-y-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={`group/item flex items-center px-2 py-3 text-sm font-medium rounded-md transition-all duration-200 relative ${
|
||||||
|
isActive(item.href)
|
||||||
|
? 'bg-blue-100 text-blue-900'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
title={item.name}
|
||||||
|
>
|
||||||
|
<Icon className={`flex-shrink-0 h-6 w-6 transition-colors ${
|
||||||
|
isActive(item.href) ? 'text-blue-500' : 'text-gray-400 group-hover/item:text-gray-500'
|
||||||
|
}`} />
|
||||||
|
<span className={`ml-3 whitespace-nowrap transition-all duration-300 opacity-0 group-hover:opacity-100 group-hover:translate-x-0 translate-x-2 ${
|
||||||
|
isActive(item.href) ? 'text-blue-900' : 'text-gray-600 group-hover/item:text-gray-900'
|
||||||
|
}`}>
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone de contenu principal */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden transition-all duration-300">
|
||||||
|
<main className="flex-1 relative overflow-y-auto focus:outline-none">
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header mobile */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<div className="fixed top-0 left-0 right-0 z-40 bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div className="flex justify-between items-center h-16 px-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
className="p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
{isSidebarOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||||
|
</button>
|
||||||
|
<Link to="/" className="flex items-center">
|
||||||
|
<Ship className="h-8 w-8 text-blue-600" />
|
||||||
|
<span className="ml-2 text-xl font-bold text-gray-900">Xpeditis</span>
|
||||||
|
</Link>
|
||||||
|
{/* User menu mobile */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||||
|
className="flex items-center p-2 rounded-full hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<User className="h-5 w-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu Mobile */}
|
||||||
|
{isUserMenuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
|
||||||
|
<div className="px-4 py-2 text-sm text-gray-700 border-b border-gray-100">
|
||||||
|
<div className="font-medium">{user?.name || 'Utilisateur'}</div>
|
||||||
|
<div className="text-gray-500">{user?.email}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Sidebar */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div className="md:hidden">
|
||||||
|
<div className="fixed inset-0 z-40 flex">
|
||||||
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setIsSidebarOpen(false)} />
|
||||||
|
<div className="relative flex-1 flex flex-col max-w-xs w-full bg-white">
|
||||||
|
<div className="absolute top-0 right-0 -mr-12 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
|
||||||
|
<nav className="mt-5 px-2 space-y-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
className={`group flex items-center px-2 py-2 text-base font-medium rounded-md ${
|
||||||
|
isActive(item.href)
|
||||||
|
? 'text-blue-600 bg-blue-50'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="mr-4 h-6 w-6" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-white border-t border-gray-200 mt-auto">
|
||||||
|
<div className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Ship className="h-6 w-6 text-blue-600" />
|
||||||
|
<span className="ml-2 text-sm text-gray-600">
|
||||||
|
© 2025 Xpeditis. Plateforme maritime SaaS.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-6">
|
||||||
|
<a href="#" className="text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
Support
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-sm text-gray-600 hover:text-gray-900">
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
10
src/components/quote/QuoteForm.tsx
Normal file
10
src/components/quote/QuoteForm.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
function QuoteForm({ customerId }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
19
src/components/quote/QuoteResults.tsx
Normal file
19
src/components/quote/QuoteResults.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import {QuoteResponse} from "../../lib/api";
|
||||||
|
|
||||||
|
|
||||||
|
interface QuoteResultsProps {
|
||||||
|
quotes: QuoteResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuoteResults({ quotes }: QuoteResultsProps) {
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Offres disponibles</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
src/components/ui/toast.tsx
Normal file
127
src/components/ui/toast.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
import {cn} from "../../lib/utils";
|
||||||
|
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"destructive border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=""
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
))
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
28
src/components/ui/toaster.tsx
Normal file
28
src/components/ui/toaster.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
|
||||||
|
|
||||||
|
import {useToast} from "../../hooks/use-toast";
|
||||||
|
import {Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport} from "./toast";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && (
|
||||||
|
<ToastDescription>{description}</ToastDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/contexts/AuthContext.tsx
Normal file
78
src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Vérifier si l'utilisateur est déjà connecté au chargement
|
||||||
|
useEffect(() => {
|
||||||
|
const savedUser = localStorage.getItem('xpeditis_user');
|
||||||
|
if (savedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('xpeditis_user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (email: string, password: string): Promise<boolean> => {
|
||||||
|
// Simulation d'authentification
|
||||||
|
if (email === 'demo@xpeditis.com' && password === 'demo123') {
|
||||||
|
const userData: User = {
|
||||||
|
id: '1',
|
||||||
|
email: 'demo@xpeditis.com',
|
||||||
|
name: 'Utilisateur Demo'
|
||||||
|
};
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
localStorage.setItem('xpeditis_user', JSON.stringify(userData));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null);
|
||||||
|
localStorage.removeItem('xpeditis_user');
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading,
|
||||||
|
login,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
186
src/hooks/use-toast.ts
Normal file
186
src/hooks/use-toast.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import {ToastActionElement, ToastProps} from "../components/ui/toast";
|
||||||
|
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string
|
||||||
|
title?: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: ToastActionElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: "ADD_TOAST",
|
||||||
|
UPDATE_TOAST: "UPDATE_TOAST",
|
||||||
|
DISMISS_TOAST: "DISMISS_TOAST",
|
||||||
|
REMOVE_TOAST: "REMOVE_TOAST",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||||
|
return count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType["ADD_TOAST"]
|
||||||
|
toast: ToasterToast
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["UPDATE_TOAST"]
|
||||||
|
toast: Partial<ToasterToast>
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"]
|
||||||
|
toastId?: ToasterToast["id"]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId)
|
||||||
|
dispatch({
|
||||||
|
type: "REMOVE_TOAST",
|
||||||
|
toastId: toastId,
|
||||||
|
})
|
||||||
|
}, TOAST_REMOVE_DELAY)
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case "ADD_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "UPDATE_TOAST":
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
case "DISMISS_TOAST": {
|
||||||
|
const { toastId } = action
|
||||||
|
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId)
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "REMOVE_TOAST":
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = []
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] }
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action)
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, "id">
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId()
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: "UPDATE_TOAST",
|
||||||
|
toast: { ...props, id },
|
||||||
|
})
|
||||||
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: "ADD_TOAST",
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState)
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState)
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast }
|
||||||
86
src/index.css
Normal file
86
src/index.css
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 84% 4.9%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 84% 4.9%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 221.2 83.2% 53.3%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--primary-foreground: 222.2 84% 4.9%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 224.3 76.3% 94.1%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles spécifiques pour le thème maritime */
|
||||||
|
.maritime-gradient {
|
||||||
|
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 50%, #0369a1 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.maritime-card {
|
||||||
|
@apply bg-white/95 backdrop-blur-sm border border-blue-100 shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maritime-button {
|
||||||
|
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium px-6 py-2 rounded-lg transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maritime-input {
|
||||||
|
@apply border-blue-200 focus:border-blue-500 focus:ring-blue-500/20;
|
||||||
|
}
|
||||||
120
src/lib/api.ts
Normal file
120
src/lib/api.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.VITE_API_URL || '/api'
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Request interceptor pour ajouter le token JWT
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('auth_token')
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response interceptor pour gérer les erreurs
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Types pour les API calls
|
||||||
|
export interface QuoteRequest {
|
||||||
|
originPort: string
|
||||||
|
destinationPort: string
|
||||||
|
originCountry?: string
|
||||||
|
destinationCountry?: string
|
||||||
|
transportType: 'MARITIME' | 'ROAD' | 'MULTIMODAL'
|
||||||
|
containerType: 'FCL_20' | 'FCL_40' | 'FCL_40HC' | 'LCL'
|
||||||
|
quantity: number
|
||||||
|
totalWeight?: number
|
||||||
|
totalVolume?: number
|
||||||
|
customerId?: string
|
||||||
|
cargoItems?: CargoItem[]
|
||||||
|
additionalServiceCodes?: string[]
|
||||||
|
isHazardous?: boolean
|
||||||
|
requiresInsurance?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CargoItem {
|
||||||
|
description: string
|
||||||
|
quantity: number
|
||||||
|
weight: number
|
||||||
|
volume: number
|
||||||
|
hsCode?: string
|
||||||
|
value?: number
|
||||||
|
currency?: string
|
||||||
|
isHazardous?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteResponse {
|
||||||
|
id: string
|
||||||
|
originPort: string
|
||||||
|
destinationPort: string
|
||||||
|
originCountry?: string
|
||||||
|
destinationCountry?: string
|
||||||
|
transportType: string
|
||||||
|
serviceType: 'EXPRESS' | 'STANDARD' | 'ECO'
|
||||||
|
containerType: string
|
||||||
|
quantity: number
|
||||||
|
totalWeight?: number
|
||||||
|
totalVolume?: number
|
||||||
|
price: number
|
||||||
|
currency: string
|
||||||
|
transitDays: number
|
||||||
|
validUntil: string
|
||||||
|
createdAt: string
|
||||||
|
status: string
|
||||||
|
customerId?: string
|
||||||
|
additionalServices?: AdditionalService[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdditionalService {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
price: number
|
||||||
|
currency: string
|
||||||
|
mandatory: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// API functions
|
||||||
|
export const quoteApi = {
|
||||||
|
generateInstantQuote: (request: QuoteRequest): Promise<QuoteResponse[]> =>
|
||||||
|
api.post('/quotes/instant', request).then(res => res.data),
|
||||||
|
|
||||||
|
getQuoteById: (quoteId: string): Promise<QuoteResponse> =>
|
||||||
|
api.get(`/quotes/${quoteId}`).then(res => res.data),
|
||||||
|
|
||||||
|
getQuotesByCustomer: (customerId: string): Promise<QuoteResponse[]> =>
|
||||||
|
api.get(`/quotes/customer/${customerId}`).then(res => res.data),
|
||||||
|
|
||||||
|
acceptQuote: (quoteId: string, customerId: string): Promise<QuoteResponse> =>
|
||||||
|
api.post(`/quotes/${quoteId}/accept?customerId=${customerId}`).then(res => res.data),
|
||||||
|
|
||||||
|
rejectQuote: (quoteId: string, customerId: string): Promise<QuoteResponse> =>
|
||||||
|
api.post(`/quotes/${quoteId}/reject?customerId=${customerId}`).then(res => res.data),
|
||||||
|
|
||||||
|
isQuoteValid: (quoteId: string): Promise<boolean> =>
|
||||||
|
api.get(`/quotes/${quoteId}/valid`).then(res => res.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default api
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
25
src/main.tsx
Normal file
25
src/main.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
219
src/pages/BookingPage.tsx
Normal file
219
src/pages/BookingPage.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Package, MapPin, Calendar, Clock, Eye, Download } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
departureDate: string;
|
||||||
|
arrivalDate: string;
|
||||||
|
status: 'confirmed' | 'in-transit' | 'delivered' | 'pending';
|
||||||
|
service: string;
|
||||||
|
price: number;
|
||||||
|
cargoType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BookingPage: React.FC = () => {
|
||||||
|
const [bookings] = useState<Booking[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
reference: 'XPD-2025-001',
|
||||||
|
origin: 'Le Havre, France',
|
||||||
|
destination: 'Shanghai, Chine',
|
||||||
|
departureDate: '2025-01-15',
|
||||||
|
arrivalDate: '2025-02-05',
|
||||||
|
status: 'in-transit',
|
||||||
|
service: 'Standard',
|
||||||
|
price: 1950,
|
||||||
|
cargoType: 'Conteneur FCL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
reference: 'XPD-2025-002',
|
||||||
|
origin: 'Marseille, France',
|
||||||
|
destination: 'New York, États-Unis',
|
||||||
|
departureDate: '2025-01-20',
|
||||||
|
arrivalDate: '2025-02-12',
|
||||||
|
status: 'confirmed',
|
||||||
|
service: 'Express',
|
||||||
|
price: 2850,
|
||||||
|
cargoType: 'Marchandise générale',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
reference: 'XPD-2024-156',
|
||||||
|
origin: 'Rotterdam, Pays-Bas',
|
||||||
|
destination: 'Hamburg, Allemagne',
|
||||||
|
departureDate: '2024-12-10',
|
||||||
|
arrivalDate: '2024-12-15',
|
||||||
|
status: 'delivered',
|
||||||
|
service: 'Économique',
|
||||||
|
price: 1450,
|
||||||
|
cargoType: 'Groupage LCL',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'confirmed':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'in-transit':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'delivered':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'confirmed':
|
||||||
|
return 'Confirmé';
|
||||||
|
case 'in-transit':
|
||||||
|
return 'En transit';
|
||||||
|
case 'delivered':
|
||||||
|
return 'Livré';
|
||||||
|
case 'pending':
|
||||||
|
return 'En attente';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Mes réservations</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Gérez et suivez toutes vos expéditions maritimes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button className="maritime-button">
|
||||||
|
Nouvelle réservation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques rapides */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<div className="maritime-card p-6 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 mb-2">3</div>
|
||||||
|
<div className="text-sm text-gray-600">Réservations actives</div>
|
||||||
|
</div>
|
||||||
|
<div className="maritime-card p-6 text-center">
|
||||||
|
<div className="text-2xl font-bold text-yellow-600 mb-2">1</div>
|
||||||
|
<div className="text-sm text-gray-600">En transit</div>
|
||||||
|
</div>
|
||||||
|
<div className="maritime-card p-6 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600 mb-2">1</div>
|
||||||
|
<div className="text-sm text-gray-600">Livré ce mois</div>
|
||||||
|
</div>
|
||||||
|
<div className="maritime-card p-6 text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 mb-2">6,250€</div>
|
||||||
|
<div className="text-sm text-gray-600">Total expédié</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des réservations */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{bookings.map((booking) => (
|
||||||
|
<div key={booking.id} className="maritime-card p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
||||||
|
{/* Informations principales */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-4 mb-3">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{booking.reference}
|
||||||
|
</h3>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusColor(booking.status)}`}>
|
||||||
|
{getStatusText(booking.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<MapPin className="h-4 w-4 mr-2 text-blue-600" />
|
||||||
|
<span>{booking.origin} → {booking.destination}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Package className="h-4 w-4 mr-2 text-blue-600" />
|
||||||
|
<span>{booking.cargoType} - {booking.service}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Calendar className="h-4 w-4 mr-2 text-blue-600" />
|
||||||
|
<span>Départ: {new Date(booking.departureDate).toLocaleDateString('fr-FR')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Clock className="h-4 w-4 mr-2 text-blue-600" />
|
||||||
|
<span>Arrivée: {new Date(booking.arrivalDate).toLocaleDateString('fr-FR')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prix et actions */}
|
||||||
|
<div className="flex flex-col lg:flex-row items-start lg:items-center space-y-3 lg:space-y-0 lg:space-x-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{booking.price.toLocaleString()}€
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">Prix total</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
Détails
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
Documents
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre de progression pour les expéditions en transit */}
|
||||||
|
{booking.status === 'in-transit' && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||||
|
<span>Progression de l'expédition</span>
|
||||||
|
<span>65%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div className="bg-blue-600 h-2 rounded-full" style={{ width: '65%' }}></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||||
|
<span>Départ</span>
|
||||||
|
<span>En mer</span>
|
||||||
|
<span>Arrivée</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message si aucune réservation */}
|
||||||
|
{bookings.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Package className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Aucune réservation
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Vous n'avez pas encore de réservation. Commencez par demander un devis.
|
||||||
|
</p>
|
||||||
|
<button className="maritime-button">
|
||||||
|
Demander un devis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BookingPage;
|
||||||
260
src/pages/DashboardPage.tsx
Normal file
260
src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
import { BarChart3, TrendingUp, Ship, Package, DollarSign, Clock, MapPin, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
const DashboardPage: React.FC = () => {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
name: 'Expéditions ce mois',
|
||||||
|
value: '24',
|
||||||
|
change: '+12%',
|
||||||
|
changeType: 'positive',
|
||||||
|
icon: Ship,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Chiffre d\'affaires',
|
||||||
|
value: '45,230€',
|
||||||
|
change: '+8.2%',
|
||||||
|
changeType: 'positive',
|
||||||
|
icon: DollarSign,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Temps moyen de transit',
|
||||||
|
value: '18.5j',
|
||||||
|
change: '-2.1j',
|
||||||
|
changeType: 'positive',
|
||||||
|
icon: Clock,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Taux de livraison',
|
||||||
|
value: '98.2%',
|
||||||
|
change: '+0.8%',
|
||||||
|
changeType: 'positive',
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentShipments = [
|
||||||
|
{
|
||||||
|
id: 'XPD-2025-001',
|
||||||
|
origin: 'Le Havre',
|
||||||
|
destination: 'Shanghai',
|
||||||
|
status: 'En transit',
|
||||||
|
progress: 65,
|
||||||
|
eta: '2025-02-05',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'XPD-2025-002',
|
||||||
|
origin: 'Marseille',
|
||||||
|
destination: 'New York',
|
||||||
|
status: 'Confirmé',
|
||||||
|
progress: 0,
|
||||||
|
eta: '2025-02-12',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'XPD-2025-003',
|
||||||
|
origin: 'Rotterdam',
|
||||||
|
destination: 'Hamburg',
|
||||||
|
status: 'Livré',
|
||||||
|
progress: 100,
|
||||||
|
eta: '2024-12-15',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const alerts = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Retard possible sur XPD-2025-001 dû aux conditions météo',
|
||||||
|
time: 'Il y a 2h',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'info',
|
||||||
|
message: 'Nouveau devis disponible pour la route Le Havre-Singapore',
|
||||||
|
time: 'Il y a 4h',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'Confirmé':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'En transit':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'Livré':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Tableau de bord</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Vue d'ensemble de vos activités maritimes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques principales */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<div key={stat.name} className="maritime-card p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600">{stat.name}</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||||
|
{stat.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Icon className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<TrendingUp className={`h-4 w-4 mr-1 ${
|
||||||
|
stat.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`} />
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
stat.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{stat.change}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-600 ml-1">vs mois dernier</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Expéditions récentes */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<div className="maritime-card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
Expéditions récentes
|
||||||
|
</h2>
|
||||||
|
<button className="text-blue-600 hover:text-blue-700 text-sm font-medium">
|
||||||
|
Voir tout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentShipments.map((shipment) => (
|
||||||
|
<div key={shipment.id} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<h3 className="font-medium text-gray-900">{shipment.id}</h3>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(shipment.status)}`}>
|
||||||
|
{shipment.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
ETA: {new Date(shipment.eta).toLocaleDateString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-sm text-gray-600 mb-3">
|
||||||
|
<MapPin className="h-4 w-4 mr-1" />
|
||||||
|
<span>{shipment.origin} → {shipment.destination}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shipment.status === 'En transit' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||||
|
<span>Progression</span>
|
||||||
|
<span>{shipment.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full"
|
||||||
|
style={{ width: `${shipment.progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alertes et notifications */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="maritime-card p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Alertes récentes
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div key={alert.id} className="flex items-start space-x-3 p-3 bg-yellow-50 rounded-lg">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-gray-900">{alert.message}</p>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">{alert.time}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions rapides */}
|
||||||
|
<div className="maritime-card p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Actions rapides
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button className="w-full maritime-button text-left">
|
||||||
|
Nouvelle expédition
|
||||||
|
</button>
|
||||||
|
<button className="w-full border border-gray-300 text-gray-700 hover:bg-gray-50 p-3 rounded-lg font-medium transition-colors text-left">
|
||||||
|
Demander un devis
|
||||||
|
</button>
|
||||||
|
<button className="w-full border border-gray-300 text-gray-700 hover:bg-gray-50 p-3 rounded-lg font-medium transition-colors text-left">
|
||||||
|
Suivre une expédition
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graphique des performances */}
|
||||||
|
<div className="maritime-card p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Performances mensuelles
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600">Janvier</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||||
|
<div className="bg-blue-600 h-2 rounded-full" style={{ width: '85%' }}></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">85%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600">Décembre</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||||
|
<div className="bg-blue-600 h-2 rounded-full" style={{ width: '92%' }}></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">92%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600">Novembre</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-24 bg-gray-200 rounded-full h-2">
|
||||||
|
<div className="bg-blue-600 h-2 rounded-full" style={{ width: '78%' }}></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">78%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage;
|
||||||
157
src/pages/HomePage.tsx
Normal file
157
src/pages/HomePage.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Ship, FileText, MapPin, BarChart3, Clock, Globe, Shield, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
const HomePage: React.FC = () => {
|
||||||
|
const stats = [
|
||||||
|
{ name: 'Expéditions traitées', value: '12,543', icon: Ship },
|
||||||
|
{ name: 'Devis générés', value: '8,921', icon: FileText },
|
||||||
|
{ name: 'Ports desservis', value: '450+', icon: Globe },
|
||||||
|
{ name: 'Clients satisfaits', value: '2,100+', icon: BarChart3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
name: 'Devis instantané',
|
||||||
|
description: 'Obtenez un devis en quelques secondes pour vos expéditions maritimes.',
|
||||||
|
icon: Zap,
|
||||||
|
href: '/quote',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Suivi en temps réel',
|
||||||
|
description: 'Suivez vos expéditions en temps réel avec notre système de tracking avancé.',
|
||||||
|
icon: MapPin,
|
||||||
|
href: '/tracking',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Gestion simplifiée',
|
||||||
|
description: 'Gérez toutes vos expéditions depuis un tableau de bord unifié.',
|
||||||
|
icon: BarChart3,
|
||||||
|
href: '/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Sécurité garantie',
|
||||||
|
description: 'Vos données et expéditions sont protégées par nos systèmes sécurisés.',
|
||||||
|
icon: Shield,
|
||||||
|
href: '#',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-12">
|
||||||
|
{/* Hero Section */}
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
<div className="maritime-gradient rounded-2xl px-8 py-16 text-center text-white">
|
||||||
|
<h1 className="text-4xl md:text-6xl font-bold mb-6">
|
||||||
|
Votre plateforme maritime
|
||||||
|
<span className="block text-blue-200">nouvelle génération</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl md:text-2xl mb-8 text-blue-100 max-w-3xl mx-auto">
|
||||||
|
Simplifiez vos expéditions maritimes avec notre solution SaaS complète :
|
||||||
|
devis instantané, suivi en temps réel et gestion centralisée.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
to="/quote"
|
||||||
|
className="bg-white text-blue-600 hover:bg-blue-50 px-8 py-4 rounded-lg font-semibold text-lg transition-colors inline-flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<FileText className="h-5 w-5 mr-2" />
|
||||||
|
Demander un devis
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/tracking"
|
||||||
|
className="border-2 border-white text-white hover:bg-white hover:text-blue-600 px-8 py-4 rounded-lg font-semibold text-lg transition-colors inline-flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<MapPin className="h-5 w-5 mr-2" />
|
||||||
|
Suivre une expédition
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Section */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{stats.map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<div key={stat.name} className="maritime-card p-6 text-center">
|
||||||
|
<Icon className="h-8 w-8 text-blue-600 mx-auto mb-4" />
|
||||||
|
<div className="text-3xl font-bold text-gray-900 mb-2">{stat.value}</div>
|
||||||
|
<div className="text-gray-600">{stat.name}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Pourquoi choisir Xpeditis ?
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Notre plateforme vous offre tous les outils nécessaires pour optimiser
|
||||||
|
vos opérations de transport maritime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{features.map((feature) => {
|
||||||
|
const Icon = feature.icon;
|
||||||
|
return (
|
||||||
|
<div key={feature.name} className="maritime-card p-8 hover:shadow-xl transition-shadow">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Icon className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-6">
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">
|
||||||
|
{feature.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
{feature.description}
|
||||||
|
</p>
|
||||||
|
{feature.href !== '#' && (
|
||||||
|
<Link
|
||||||
|
to={feature.href}
|
||||||
|
className="text-blue-600 hover:text-blue-700 font-medium inline-flex items-center"
|
||||||
|
>
|
||||||
|
En savoir plus
|
||||||
|
<svg className="ml-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<div className="maritime-card p-8 text-center">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
|
Prêt à optimiser vos expéditions ?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-6 max-w-2xl mx-auto">
|
||||||
|
Rejoignez plus de 2,100 entreprises qui font confiance à Xpeditis
|
||||||
|
pour leurs opérations de transport maritime.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
to="/quote"
|
||||||
|
className="maritime-button"
|
||||||
|
>
|
||||||
|
Commencer maintenant
|
||||||
|
</Link>
|
||||||
|
<button className="border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-lg font-medium transition-colors">
|
||||||
|
Planifier une démo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
459
src/pages/LandingPage.tsx
Normal file
459
src/pages/LandingPage.tsx
Normal file
@ -0,0 +1,459 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Ship,
|
||||||
|
Globe,
|
||||||
|
Clock,
|
||||||
|
Shield,
|
||||||
|
BarChart3,
|
||||||
|
Users,
|
||||||
|
MapPin,
|
||||||
|
FileText,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle,
|
||||||
|
Star,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin as LocationIcon,
|
||||||
|
Search,
|
||||||
|
Calendar,
|
||||||
|
Package,
|
||||||
|
Anchor,
|
||||||
|
TrendingUp,
|
||||||
|
Award,
|
||||||
|
Truck
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const LandingPage: React.FC = () => {
|
||||||
|
const [searchData, setSearchData] = useState({
|
||||||
|
origin: '',
|
||||||
|
destination: '',
|
||||||
|
departureDate: '',
|
||||||
|
cargoType: 'FCL'
|
||||||
|
});
|
||||||
|
|
||||||
|
const popularRoutes = [
|
||||||
|
{ from: 'Le Havre', to: 'New York', price: '€2,450', duration: '12-15 jours' },
|
||||||
|
{ from: 'Marseille', to: 'Shanghai', price: '€1,890', duration: '18-22 jours' },
|
||||||
|
{ from: 'Rotterdam', to: 'Los Angeles', price: '€2,180', duration: '14-18 jours' },
|
||||||
|
{ from: 'Hamburg', to: 'Singapore', price: '€1,650', duration: '16-20 jours' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ports = [
|
||||||
|
'Le Havre, France', 'Marseille, France', 'Rotterdam, Pays-Bas', 'Hamburg, Allemagne',
|
||||||
|
'Shanghai, Chine', 'New York, États-Unis', 'Los Angeles, États-Unis', 'Singapore',
|
||||||
|
'Dubai, EAU', 'Hong Kong', 'Anvers, Belgique', 'Barcelone, Espagne'
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ value: '2M+', label: 'Conteneurs expédiés', icon: Ship },
|
||||||
|
{ value: '140', label: 'Pays desservis', icon: Globe },
|
||||||
|
{ value: '35%', label: 'Économies moyennes', icon: TrendingUp },
|
||||||
|
{ value: '450+', label: 'Ports connectés', icon: Anchor }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
// Rediriger vers la page de devis avec les paramètres de recherche
|
||||||
|
window.location.href = `/app/quote?origin=${searchData.origin}&destination=${searchData.destination}&date=${searchData.departureDate}&type=${searchData.cargoType}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white shadow-sm sticky top-0 z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center h-16">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Ship className="h-8 w-8 text-blue-600 mr-2" />
|
||||||
|
<span className="text-2xl font-bold text-gray-900">Xpeditis</span>
|
||||||
|
</div>
|
||||||
|
<nav className="hidden md:flex space-x-8">
|
||||||
|
<a href="#services" className="text-gray-700 hover:text-blue-600 font-medium">Expéditions</a>
|
||||||
|
<a href="#tracking" className="text-gray-700 hover:text-blue-600 font-medium">Suivi</a>
|
||||||
|
<a href="#about" className="text-gray-700 hover:text-blue-600 font-medium">Solutions</a>
|
||||||
|
<a href="#contact" className="text-gray-700 hover:text-blue-600 font-medium">Support</a>
|
||||||
|
</nav>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
Se connecter
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/app/quote"
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Obtenir un devis
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero Section avec moteur de recherche */}
|
||||||
|
<section className="relative bg-gradient-to-br from-blue-50 to-white py-16">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
|
||||||
|
Recherchez et comparez les tarifs de
|
||||||
|
<span className="text-blue-600"> transport maritime</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
|
Trouvez les meilleures offres parmi des centaines de transporteurs maritimes.
|
||||||
|
Comparez les prix, les délais et réservez en quelques clics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Moteur de recherche principal */}
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl p-6 border border-gray-100">
|
||||||
|
<div className="flex flex-wrap items-center gap-4 mb-4">
|
||||||
|
<button className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg font-medium">
|
||||||
|
<Ship className="h-4 w-4 mr-2" />
|
||||||
|
Expédition maritime
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cargoType"
|
||||||
|
value="FCL"
|
||||||
|
checked={searchData.cargoType === 'FCL'}
|
||||||
|
onChange={(e) => setSearchData({...searchData, cargoType: e.target.value})}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">FCL (Conteneur complet)</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="cargoType"
|
||||||
|
value="LCL"
|
||||||
|
checked={searchData.cargoType === 'LCL'}
|
||||||
|
onChange={(e) => setSearchData({...searchData, cargoType: e.target.value})}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">LCL (Groupage)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Port d'origine</label>
|
||||||
|
<select
|
||||||
|
value={searchData.origin}
|
||||||
|
onChange={(e) => setSearchData({...searchData, origin: e.target.value})}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un port</option>
|
||||||
|
{ports.map(port => (
|
||||||
|
<option key={port} value={port}>{port}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Port de destination</label>
|
||||||
|
<select
|
||||||
|
value={searchData.destination}
|
||||||
|
onChange={(e) => setSearchData({...searchData, destination: e.target.value})}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un port</option>
|
||||||
|
{ports.map(port => (
|
||||||
|
<option key={port} value={port}>{port}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date de départ</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={searchData.departureDate}
|
||||||
|
onChange={(e) => setSearchData({...searchData, departureDate: e.target.value})}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="w-full bg-blue-600 text-white p-3 rounded-lg hover:bg-blue-700 transition-colors font-medium flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Search className="h-5 w-5 mr-2" />
|
||||||
|
Rechercher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Routes populaires */}
|
||||||
|
<div className="mt-12">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-6 text-center">Routes populaires</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{popularRoutes.map((route, index) => (
|
||||||
|
<div key={index} className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{route.from}</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-gray-400 my-1" />
|
||||||
|
<div className="font-medium text-gray-900">{route.to}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-lg font-bold text-blue-600">{route.price}</div>
|
||||||
|
<div className="text-sm text-gray-500">{route.duration}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stats Section */}
|
||||||
|
<section className="py-16 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-8">
|
||||||
|
{stats.map((stat, index) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<div key={index} className="text-center">
|
||||||
|
<Icon className="h-8 w-8 text-blue-600 mx-auto mb-3" />
|
||||||
|
<div className="text-3xl font-bold text-gray-900 mb-1">{stat.value}</div>
|
||||||
|
<div className="text-sm text-gray-600">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<section id="services" className="py-16 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Nos solutions de transport maritime
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600 max-w-3xl mx-auto">
|
||||||
|
Des services complets pour tous vos besoins d'expédition internationale
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-8 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-lg mb-6">
|
||||||
|
<Ship className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-4">Transport FCL/LCL</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Solutions flexibles pour conteneurs complets ou groupage selon vos besoins.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600">
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Conteneurs 20' et 40'
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Groupage optimisé
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Suivi en temps réel
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-8 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-lg mb-6">
|
||||||
|
<Truck className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-4">Logistique Intégrée</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Gestion complète de votre chaîne logistique de bout en bout.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600">
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Transport terrestre
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Entreposage sécurisé
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Dédouanement
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-8 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="inline-flex items-center justify-center w-12 h-12 bg-blue-100 rounded-lg mb-6">
|
||||||
|
<FileText className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-4">Devis Instantané</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Obtenez vos tarifs en temps réel et comparez les meilleures offres.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-600">
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Prix transparents
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Comparaison automatique
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
Réservation en ligne
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* About Section */}
|
||||||
|
<section id="about" className="py-20 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-4xl font-bold text-gray-900 mb-6">
|
||||||
|
21 ans d'expertise maritime
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-gray-600 mb-6">
|
||||||
|
Depuis plus de deux décennies, Xpeditis accompagne les entreprises
|
||||||
|
dans leurs défis logistiques internationaux. Notre expertise et notre
|
||||||
|
réseau mondial nous permettent d'offrir des solutions sur mesure.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-blue-600 mb-2">99.8%</div>
|
||||||
|
<div className="text-gray-600">Taux de livraison</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-3xl font-bold text-blue-600 mb-2">24/7</div>
|
||||||
|
<div className="text-gray-600">Support client</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src="/api/placeholder/500/400"
|
||||||
|
alt="Port maritime"
|
||||||
|
className="rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<section className="py-20 bg-blue-600">
|
||||||
|
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
|
||||||
|
<h2 className="text-4xl font-bold text-white mb-6">
|
||||||
|
Prêt à expédier avec Xpeditis ?
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-blue-100 mb-8">
|
||||||
|
Rejoignez plus de 15,000 entreprises qui nous font confiance pour leurs expéditions maritimes
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Link
|
||||||
|
to="/app/quote"
|
||||||
|
className="bg-white text-blue-600 px-8 py-4 rounded-lg font-semibold text-lg hover:bg-blue-50 transition-colors inline-flex items-center justify-center"
|
||||||
|
>
|
||||||
|
Obtenir un devis gratuit
|
||||||
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/app"
|
||||||
|
className="border-2 border-white text-white px-8 py-4 rounded-lg font-semibold text-lg hover:bg-white hover:text-blue-600 transition-colors inline-flex items-center justify-center"
|
||||||
|
>
|
||||||
|
Accéder au dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer id="contact" className="bg-gray-900 text-white py-16">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<Ship className="h-8 w-8 text-blue-400 mr-2" />
|
||||||
|
<span className="text-2xl font-bold">Xpeditis</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
Votre partenaire de confiance pour le transport maritime international.
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-xs">f</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-xs">in</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-xs">tw</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Services</h3>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><a href="#" className="hover:text-white">Transport Maritime</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white">Logistique</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white">Douanes</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white">Assurance</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Entreprise</h3>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><a href="#" className="hover:text-white">À propos</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white">Carrières</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white">Actualités</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white">Partenaires</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Contact</h3>
|
||||||
|
<div className="space-y-3 text-gray-400">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Phone className="h-4 w-4 mr-2" />
|
||||||
|
<span>+33 1 23 45 67 89</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Mail className="h-4 w-4 mr-2" />
|
||||||
|
<span>contact@xpeditis.com</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<LocationIcon className="h-4 w-4 mr-2" />
|
||||||
|
<span>Paris, France</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400">
|
||||||
|
<p>© 2024 Xpeditis. Tous droits réservés.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LandingPage;
|
||||||
231
src/pages/LoginPage.tsx
Normal file
231
src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Eye, EyeOff, Ship, Mail, Lock, ArrowRight } from 'lucide-react';
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
const LoginPage: React.FC = () => {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { login, isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
// Rediriger si déjà connecté
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const from = (location.state as any)?.from?.pathname || '/app';
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate, location]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await login(email, password);
|
||||||
|
if (success) {
|
||||||
|
const from = (location.state as any)?.from?.pathname || '/app';
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} else {
|
||||||
|
setError('Email ou mot de passe incorrect');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError('Une erreur est survenue lors de la connexion');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-blue-50 flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
{/* Logo et titre */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center mb-6">
|
||||||
|
<div className="bg-blue-600 p-3 rounded-xl">
|
||||||
|
<Ship className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900">
|
||||||
|
Connexion à Xpeditis
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-gray-600">
|
||||||
|
Accédez à votre plateforme maritime
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire de connexion */}
|
||||||
|
<div className="maritime-card p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Adresse email
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="votre@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mot de passe */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full pl-10 pr-12 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-700">
|
||||||
|
Se souvenir de moi
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
Mot de passe oublié ?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message d'erreur */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bouton de connexion */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full maritime-button py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
|
Connexion en cours...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
Se connecter
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Informations de démonstration */}
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-blue-800 mb-2">
|
||||||
|
Compte de démonstration
|
||||||
|
</h4>
|
||||||
|
<div className="text-sm text-blue-700 space-y-1">
|
||||||
|
<p><strong>Email :</strong> demo@xpeditis.com</p>
|
||||||
|
<p><strong>Mot de passe :</strong> demo123</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lien d'inscription */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Pas encore de compte ?{' '}
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
Créer un compte
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fonctionnalités */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="bg-blue-100 w-12 h-12 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Ship className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Suivi en temps réel</h3>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Suivez vos expéditions 24h/24
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="bg-green-100 w-12 h-12 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Mail className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Devis instantané</h3>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Obtenez vos prix en quelques clics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="bg-purple-100 w-12 h-12 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Lock className="h-6 w-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">Sécurisé</h3>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Vos données sont protégées
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-center text-xs text-gray-500">
|
||||||
|
<p>
|
||||||
|
© 2025 Xpeditis. Tous droits réservés.{' '}
|
||||||
|
<Link to="/privacy" className="hover:text-gray-700">
|
||||||
|
Politique de confidentialité
|
||||||
|
</Link>
|
||||||
|
{' • '}
|
||||||
|
<Link to="/terms" className="hover:text-gray-700">
|
||||||
|
Conditions d'utilisation
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
320
src/pages/QuotePage.tsx
Normal file
320
src/pages/QuotePage.tsx
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ArrowRight, MapPin, Package, Truck, Ship, CheckCircle, DollarSign, Calendar } from 'lucide-react';
|
||||||
|
|
||||||
|
interface QuoteForm {
|
||||||
|
operation: 'import' | 'export' | '';
|
||||||
|
incoterm: 'dock-to-door' | 'door-to-dock' | 'door-to-door' | '';
|
||||||
|
originPort: string;
|
||||||
|
originWarehouse: string;
|
||||||
|
destinationPort: string;
|
||||||
|
destinationWarehouse: string;
|
||||||
|
cargoType: string;
|
||||||
|
departureDate: string;
|
||||||
|
weight: string;
|
||||||
|
volume: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Quote {
|
||||||
|
id: string;
|
||||||
|
serviceType: string;
|
||||||
|
price: number;
|
||||||
|
transitTime: string;
|
||||||
|
carrier: string;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuotePage: React.FC = () => {
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [formData, setFormData] = useState<QuoteForm>({
|
||||||
|
operation: '',
|
||||||
|
incoterm: '',
|
||||||
|
originPort: '',
|
||||||
|
originWarehouse: '',
|
||||||
|
destinationPort: '',
|
||||||
|
destinationWarehouse: '',
|
||||||
|
cargoType: '',
|
||||||
|
departureDate: '',
|
||||||
|
weight: '',
|
||||||
|
volume: ''
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [quotes, setQuotes] = useState<Quote[]>([]);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, name: 'General Info', completed: false },
|
||||||
|
{ id: 2, name: 'Conditionnement', completed: false },
|
||||||
|
{ id: 3, name: 'téléchargement', completed: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep < 3) {
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOperationSelect = (operation: 'import' | 'export') => {
|
||||||
|
setFormData({ ...formData, operation });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIncotermSelect = (incoterm: 'dock-to-door' | 'door-to-dock' | 'door-to-door') => {
|
||||||
|
setFormData({ ...formData, incoterm });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData({ ...formData, [name]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Simulation d'appel API
|
||||||
|
setTimeout(() => {
|
||||||
|
const mockQuotes = [
|
||||||
|
{
|
||||||
|
id: 'quote-express-001',
|
||||||
|
serviceType: 'Express',
|
||||||
|
price: 2850,
|
||||||
|
transitTime: '12-15 jours',
|
||||||
|
carrier: 'Maritime Express Line',
|
||||||
|
features: ['Priorité d\'embarquement', 'Suivi premium', 'Assurance incluse'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quote-standard-001',
|
||||||
|
serviceType: 'Standard',
|
||||||
|
price: 1950,
|
||||||
|
transitTime: '18-22 jours',
|
||||||
|
carrier: 'Global Shipping Co.',
|
||||||
|
features: ['Suivi standard', 'Service client 24/7', 'Documentation complète'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quote-economy-001',
|
||||||
|
serviceType: 'Économique',
|
||||||
|
price: 1450,
|
||||||
|
transitTime: '25-30 jours',
|
||||||
|
carrier: 'Budget Maritime',
|
||||||
|
features: ['Prix compétitif', 'Suivi de base', 'Flexibilité des dates'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setQuotes(mockQuotes);
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ports = [
|
||||||
|
'Le Havre, France',
|
||||||
|
'Marseille, France',
|
||||||
|
'Rotterdam, Pays-Bas',
|
||||||
|
'Hamburg, Allemagne',
|
||||||
|
'Shanghai, Chine',
|
||||||
|
'New York, États-Unis',
|
||||||
|
'Los Angeles, États-Unis',
|
||||||
|
'Singapore',
|
||||||
|
'Dubai, EAU',
|
||||||
|
'Hong Kong',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Demande de devis maritime
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Obtenez instantanément les meilleurs tarifs pour vos expéditions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire de devis */}
|
||||||
|
<div className="maritime-card p-8">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Port d'origine */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<MapPin className="inline h-4 w-4 mr-1" />
|
||||||
|
Port d'origine
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="originPort"
|
||||||
|
value={formData.originPort}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full maritime-input p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un port</option>
|
||||||
|
{ports.map(port => (
|
||||||
|
<option key={port} value={port}>{port}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Port de destination */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<MapPin className="inline h-4 w-4 mr-1" />
|
||||||
|
Port de destination
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="destinationPort"
|
||||||
|
value={formData.destinationPort}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full maritime-input p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner un port</option>
|
||||||
|
{ports.map(port => (
|
||||||
|
<option key={port} value={port}>{port}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type de marchandise */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Package className="inline h-4 w-4 mr-1" />
|
||||||
|
Type de marchandise
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="cargoType"
|
||||||
|
value={formData.cargoType}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full maritime-input p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner le type</option>
|
||||||
|
<option value="general">Marchandise générale</option>
|
||||||
|
<option value="container">Conteneur FCL</option>
|
||||||
|
<option value="lcl">Groupage LCL</option>
|
||||||
|
<option value="dangerous">Matières dangereuses</option>
|
||||||
|
<option value="refrigerated">Produits réfrigérés</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date de départ */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Calendar className="inline h-4 w-4 mr-1" />
|
||||||
|
Date de départ souhaitée
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
name="departureDate"
|
||||||
|
value={formData.departureDate}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
className="w-full maritime-input p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Poids */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Poids (kg)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="weight"
|
||||||
|
value={formData.weight}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
placeholder="Ex: 1500"
|
||||||
|
className="w-full maritime-input p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Volume (m³)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="volume"
|
||||||
|
value={formData.volume}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
required
|
||||||
|
placeholder="Ex: 2.5"
|
||||||
|
step="0.1"
|
||||||
|
className="w-full maritime-input p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full maritime-button py-4 text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
|
Recherche en cours...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DollarSign className="inline h-5 w-5 mr-2" />
|
||||||
|
Obtenir mes devis
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Résultats des devis */}
|
||||||
|
{quotes.length > 0 && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">
|
||||||
|
Vos devis personnalisés
|
||||||
|
</h2>
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{quotes.map((quote) => (
|
||||||
|
<div key={quote.id} className="maritime-card p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-1">
|
||||||
|
Service {quote.serviceType}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600">{quote.carrier}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-3xl font-bold text-blue-600">
|
||||||
|
{quote.price.toLocaleString()}€
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Transit: {quote.transitTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="font-medium text-gray-900 mb-2">Services inclus:</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{quote.features.map((feature, index) => (
|
||||||
|
<li key={index} className="text-sm text-gray-600 flex items-center">
|
||||||
|
<div className="w-2 h-2 bg-blue-600 rounded-full mr-2"></div>
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button className="maritime-button flex-1">
|
||||||
|
Réserver maintenant
|
||||||
|
</button>
|
||||||
|
<button className="border border-gray-300 text-gray-700 hover:bg-gray-50 px-6 py-2 rounded-lg font-medium transition-colors">
|
||||||
|
Détails
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuotePage;
|
||||||
326
src/pages/ShipSchedulePage.tsx
Normal file
326
src/pages/ShipSchedulePage.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Ship, Calendar, MapPin, Clock, Filter, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Schedule {
|
||||||
|
id: string;
|
||||||
|
vesselName: string;
|
||||||
|
route: string;
|
||||||
|
departure: {
|
||||||
|
port: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
};
|
||||||
|
arrival: {
|
||||||
|
port: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
};
|
||||||
|
transitTime: string;
|
||||||
|
capacity: {
|
||||||
|
available: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
price: number;
|
||||||
|
service: 'Express' | 'Standard' | 'Économique';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShipSchedulePage: React.FC = () => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [selectedService, setSelectedService] = useState<string>('all');
|
||||||
|
|
||||||
|
const schedules: Schedule[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
vesselName: 'MSC Bellissima',
|
||||||
|
route: 'Europe - Asie',
|
||||||
|
departure: {
|
||||||
|
port: 'Le Havre, France',
|
||||||
|
date: '2025-01-15',
|
||||||
|
time: '08:00',
|
||||||
|
},
|
||||||
|
arrival: {
|
||||||
|
port: 'Shanghai, Chine',
|
||||||
|
date: '2025-02-05',
|
||||||
|
time: '14:00',
|
||||||
|
},
|
||||||
|
transitTime: '21 jours',
|
||||||
|
capacity: {
|
||||||
|
available: 45,
|
||||||
|
total: 120,
|
||||||
|
},
|
||||||
|
price: 1950,
|
||||||
|
service: 'Standard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
vesselName: 'CMA CGM Antoine de Saint Exupéry',
|
||||||
|
route: 'Europe - Amérique du Nord',
|
||||||
|
departure: {
|
||||||
|
port: 'Marseille, France',
|
||||||
|
date: '2025-01-20',
|
||||||
|
time: '12:00',
|
||||||
|
},
|
||||||
|
arrival: {
|
||||||
|
port: 'New York, États-Unis',
|
||||||
|
date: '2025-02-12',
|
||||||
|
time: '09:00',
|
||||||
|
},
|
||||||
|
transitTime: '23 jours',
|
||||||
|
capacity: {
|
||||||
|
available: 28,
|
||||||
|
total: 80,
|
||||||
|
},
|
||||||
|
price: 2850,
|
||||||
|
service: 'Express',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
vesselName: 'Hapag-Lloyd Berlin Express',
|
||||||
|
route: 'Europe - Europe',
|
||||||
|
departure: {
|
||||||
|
port: 'Rotterdam, Pays-Bas',
|
||||||
|
date: '2025-01-18',
|
||||||
|
time: '16:00',
|
||||||
|
},
|
||||||
|
arrival: {
|
||||||
|
port: 'Hamburg, Allemagne',
|
||||||
|
date: '2025-01-20',
|
||||||
|
time: '10:00',
|
||||||
|
},
|
||||||
|
transitTime: '2 jours',
|
||||||
|
capacity: {
|
||||||
|
available: 67,
|
||||||
|
total: 100,
|
||||||
|
},
|
||||||
|
price: 1450,
|
||||||
|
service: 'Économique',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
vesselName: 'COSCO Shipping Universe',
|
||||||
|
route: 'Asie - Europe',
|
||||||
|
departure: {
|
||||||
|
port: 'Singapore',
|
||||||
|
date: '2025-01-25',
|
||||||
|
time: '20:00',
|
||||||
|
},
|
||||||
|
arrival: {
|
||||||
|
port: 'Rotterdam, Pays-Bas',
|
||||||
|
date: '2025-02-18',
|
||||||
|
time: '06:00',
|
||||||
|
},
|
||||||
|
transitTime: '24 jours',
|
||||||
|
capacity: {
|
||||||
|
available: 89,
|
||||||
|
total: 150,
|
||||||
|
},
|
||||||
|
price: 2200,
|
||||||
|
service: 'Standard',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filteredSchedules = schedules.filter(schedule => {
|
||||||
|
const matchesSearch = schedule.vesselName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
schedule.route.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
schedule.departure.port.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
schedule.arrival.port.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
const matchesService = selectedService === 'all' || schedule.service === selectedService;
|
||||||
|
|
||||||
|
return matchesSearch && matchesService;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getServiceColor = (service: string) => {
|
||||||
|
switch (service) {
|
||||||
|
case 'Express':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
case 'Standard':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'Économique':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCapacityColor = (available: number, total: number) => {
|
||||||
|
const percentage = (available / total) * 100;
|
||||||
|
if (percentage > 60) return 'text-green-600';
|
||||||
|
if (percentage > 30) return 'text-yellow-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Planning des navires</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Consultez les horaires et disponibilités des navires
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtres et recherche */}
|
||||||
|
<div className="maritime-card p-6">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Rechercher par navire, route ou port..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<select
|
||||||
|
value={selectedService}
|
||||||
|
onChange={(e) => setSelectedService(e.target.value)}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="all">Tous les services</option>
|
||||||
|
<option value="Express">Express</option>
|
||||||
|
<option value="Standard">Standard</option>
|
||||||
|
<option value="Économique">Économique</option>
|
||||||
|
</select>
|
||||||
|
<button className="flex items-center px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
|
Plus de filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques rapides */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
|
<div className="maritime-card p-6 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 mb-2">{schedules.length}</div>
|
||||||
|
<div className="text-sm text-gray-600">Navires programmés</div>
|
||||||
|
</div>
|
||||||
|
<div className="maritime-card p-6 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600 mb-2">
|
||||||
|
{schedules.reduce((acc, s) => acc + s.capacity.available, 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">Places disponibles</div>
|
||||||
|
</div>
|
||||||
|
<div className="maritime-card p-6 text-center">
|
||||||
|
<div className="text-2xl font-bold text-yellow-600 mb-2">12</div>
|
||||||
|
<div className="text-sm text-gray-600">Routes actives</div>
|
||||||
|
</div>
|
||||||
|
<div className="maritime-card p-6 text-center">
|
||||||
|
<div className="text-2xl font-bold text-gray-900 mb-2">18j</div>
|
||||||
|
<div className="text-sm text-gray-600">Transit moyen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des horaires */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredSchedules.map((schedule) => (
|
||||||
|
<div key={schedule.id} className="maritime-card p-6 hover:shadow-lg transition-shadow">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
|
||||||
|
{/* Informations du navire */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center space-x-4 mb-3">
|
||||||
|
<Ship className="h-6 w-6 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
{schedule.vesselName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600">{schedule.route}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getServiceColor(schedule.service)}`}>
|
||||||
|
{schedule.service}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Départ */}
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex-shrink-0 w-2 h-2 bg-green-500 rounded-full mt-2"></div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">Départ</div>
|
||||||
|
<div className="font-medium">{schedule.departure.port}</div>
|
||||||
|
<div className="text-sm text-gray-600 flex items-center mt-1">
|
||||||
|
<Calendar className="h-4 w-4 mr-1" />
|
||||||
|
{new Date(schedule.departure.date).toLocaleDateString('fr-FR')} à {schedule.departure.time}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrivée */}
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="flex-shrink-0 w-2 h-2 bg-red-500 rounded-full mt-2"></div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">Arrivée</div>
|
||||||
|
<div className="font-medium">{schedule.arrival.port}</div>
|
||||||
|
<div className="text-sm text-gray-600 flex items-center mt-1">
|
||||||
|
<Calendar className="h-4 w-4 mr-1" />
|
||||||
|
{new Date(schedule.arrival.date).toLocaleDateString('fr-FR')} à {schedule.arrival.time}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informations de capacité et prix */}
|
||||||
|
<div className="flex flex-col lg:flex-row items-start lg:items-center space-y-4 lg:space-y-0 lg:space-x-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-600">Durée de transit</div>
|
||||||
|
<div className="font-semibold text-gray-900 flex items-center">
|
||||||
|
<Clock className="h-4 w-4 mr-1" />
|
||||||
|
{schedule.transitTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-600">Disponibilité</div>
|
||||||
|
<div className={`font-semibold ${getCapacityColor(schedule.capacity.available, schedule.capacity.total)}`}>
|
||||||
|
{schedule.capacity.available}/{schedule.capacity.total}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">conteneurs</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-600">À partir de</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{schedule.price.toLocaleString()}€
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="maritime-button whitespace-nowrap">
|
||||||
|
Réserver
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message si aucun résultat */}
|
||||||
|
{filteredSchedules.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Ship className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
Aucun navire trouvé
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Aucun navire ne correspond à vos critères de recherche.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setSelectedService('all');
|
||||||
|
}}
|
||||||
|
className="maritime-button"
|
||||||
|
>
|
||||||
|
Réinitialiser les filtres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShipSchedulePage;
|
||||||
294
src/pages/TrackingPage.tsx
Normal file
294
src/pages/TrackingPage.tsx
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Search, MapPin, Ship, Package, Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TrackingEvent {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
location: string;
|
||||||
|
description: string;
|
||||||
|
status: 'completed' | 'current' | 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrackingInfo {
|
||||||
|
reference: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
currentLocation: string;
|
||||||
|
estimatedArrival: string;
|
||||||
|
progress: number;
|
||||||
|
events: TrackingEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrackingPage: React.FC = () => {
|
||||||
|
const [trackingNumber, setTrackingNumber] = useState('');
|
||||||
|
const [trackingInfo, setTrackingInfo] = useState<TrackingInfo | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!trackingNumber.trim()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Simulation d'appel API
|
||||||
|
setTimeout(() => {
|
||||||
|
if (trackingNumber === 'XPD-2025-001') {
|
||||||
|
setTrackingInfo({
|
||||||
|
reference: 'XPD-2025-001',
|
||||||
|
origin: 'Le Havre, France',
|
||||||
|
destination: 'Shanghai, Chine',
|
||||||
|
currentLocation: 'En mer - Océan Indien',
|
||||||
|
estimatedArrival: '2025-02-05',
|
||||||
|
progress: 65,
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
date: '2025-01-15',
|
||||||
|
time: '08:30',
|
||||||
|
location: 'Le Havre, France',
|
||||||
|
description: 'Conteneur chargé à bord du navire MSC Bellissima',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
date: '2025-01-16',
|
||||||
|
time: '14:00',
|
||||||
|
location: 'Manche',
|
||||||
|
description: 'Navire en route vers la Méditerranée',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
date: '2025-01-20',
|
||||||
|
time: '09:15',
|
||||||
|
location: 'Canal de Suez, Égypte',
|
||||||
|
description: 'Passage du canal de Suez',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
date: '2025-01-25',
|
||||||
|
time: '16:45',
|
||||||
|
location: 'Océan Indien',
|
||||||
|
description: 'Navigation en cours vers l\'Asie',
|
||||||
|
status: 'current',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
date: '2025-02-02',
|
||||||
|
time: '--:--',
|
||||||
|
location: 'Détroit de Malacca',
|
||||||
|
description: 'Passage prévu du détroit de Malacca',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
date: '2025-02-05',
|
||||||
|
time: '--:--',
|
||||||
|
location: 'Shanghai, Chine',
|
||||||
|
description: 'Arrivée prévue au port de Shanghai',
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError('Numéro de suivi non trouvé. Vérifiez votre référence.');
|
||||||
|
setTrackingInfo(null);
|
||||||
|
}
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEventIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||||
|
case 'current':
|
||||||
|
return <AlertCircle className="h-5 w-5 text-blue-600" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="h-5 w-5 text-gray-400" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-5 w-5 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Suivi d'expédition
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-gray-600">
|
||||||
|
Suivez votre expédition en temps réel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire de recherche */}
|
||||||
|
<div className="maritime-card p-8">
|
||||||
|
<form onSubmit={handleSearch} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Numéro de suivi
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={trackingNumber}
|
||||||
|
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||||
|
placeholder="Ex: XPD-2025-001"
|
||||||
|
className="flex-1 maritime-input p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="maritime-button px-8 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Recherche...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="h-4 w-4 mr-2" />
|
||||||
|
Suivre
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 text-sm mt-2">{error}</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Exemple de numéros */}
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800 mb-2">
|
||||||
|
<strong>Numéro d'exemple pour test :</strong>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setTrackingNumber('XPD-2025-001')}
|
||||||
|
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
XPD-2025-001
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Informations de suivi */}
|
||||||
|
{trackingInfo && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Résumé de l'expédition */}
|
||||||
|
<div className="maritime-card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
Expédition {trackingInfo.reference}
|
||||||
|
</h2>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm text-gray-600">Progression</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{trackingInfo.progress}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<MapPin className="h-5 w-5 text-blue-600 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">Origine</div>
|
||||||
|
<div className="font-medium">{trackingInfo.origin}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Ship className="h-5 w-5 text-blue-600 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">Position actuelle</div>
|
||||||
|
<div className="font-medium">{trackingInfo.currentLocation}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Package className="h-5 w-5 text-blue-600 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-600">Destination</div>
|
||||||
|
<div className="font-medium">{trackingInfo.destination}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Barre de progression */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||||
|
<span>Progression du voyage</span>
|
||||||
|
<span>Arrivée prévue: {new Date(trackingInfo.estimatedArrival).toLocaleDateString('fr-FR')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-3 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${trackingInfo.progress}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Historique des événements */}
|
||||||
|
<div className="maritime-card p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-6">
|
||||||
|
Historique de l'expédition
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{trackingInfo.events.map((event, index) => (
|
||||||
|
<div key={event.id} className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
{getEventIcon(event.status)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className={`text-sm font-medium ${
|
||||||
|
event.status === 'completed' ? 'text-gray-900' :
|
||||||
|
event.status === 'current' ? 'text-blue-600' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
<div className="text-right text-sm text-gray-500">
|
||||||
|
<div>{new Date(event.date).toLocaleDateString('fr-FR')}</div>
|
||||||
|
{event.time !== '--:--' && <div>{event.time}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{event.location}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="maritime-card p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Actions disponibles
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button className="maritime-button">
|
||||||
|
Recevoir des notifications
|
||||||
|
</button>
|
||||||
|
<button className="border border-gray-300 text-gray-700 hover:bg-gray-50 px-4 py-2 rounded-lg font-medium transition-colors">
|
||||||
|
Télécharger le rapport
|
||||||
|
</button>
|
||||||
|
<button className="border border-gray-300 text-gray-700 hover:bg-gray-50 px-4 py-2 rounded-lg font-medium transition-colors">
|
||||||
|
Partager le suivi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrackingPage;
|
||||||
78
tailwind.config.js
Normal file
78
tailwind.config.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{ts,tsx}',
|
||||||
|
'./components/**/*.{ts,tsx}',
|
||||||
|
'./app/**/*.{ts,tsx}',
|
||||||
|
'./src/**/*.{ts,tsx}',
|
||||||
|
'./index.html',
|
||||||
|
],
|
||||||
|
prefix: "",
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: "2rem",
|
||||||
|
screens: {
|
||||||
|
"2xl": "1400px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
"accordion-down": {
|
||||||
|
from: { height: "0" },
|
||||||
|
to: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
},
|
||||||
|
"accordion-up": {
|
||||||
|
from: { height: "var(--radix-accordion-content-height)" },
|
||||||
|
to: { height: "0" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
35
vite.config.ts
Normal file
35
vite.config.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
configure: (proxy, _options) => {
|
||||||
|
proxy.on('error', (err, _req, _res) => {
|
||||||
|
console.log('proxy error', err);
|
||||||
|
});
|
||||||
|
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||||
|
console.log('Sending Request to the Target:', req.method, req.url);
|
||||||
|
});
|
||||||
|
proxy.on('proxyRes', (proxyRes, req, _res) => {
|
||||||
|
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user