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