1114 lines
40 KiB
TypeScript
1114 lines
40 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, Suspense } from 'react';
|
|
import { useSearchParams, useRouter } from 'next/navigation';
|
|
import {
|
|
Search,
|
|
ChevronRight,
|
|
ArrowRight,
|
|
ExternalLink,
|
|
Zap,
|
|
Key,
|
|
Package,
|
|
TrendingUp,
|
|
Building2,
|
|
ShieldCheck,
|
|
AlertTriangle,
|
|
List,
|
|
Home,
|
|
Menu,
|
|
X,
|
|
CheckCircle2,
|
|
Clock,
|
|
Info,
|
|
} from 'lucide-react';
|
|
import { CodeBlock } from '@/components/docs/CodeBlock';
|
|
import { DOC_SECTIONS, ALL_NAV_ITEMS } from '@/components/docs/docsNav';
|
|
|
|
// ─── Shared sub-components ────────────────────────────────────────────────────
|
|
|
|
function H2({ children }: { children: React.ReactNode }) {
|
|
return <h2 className="text-xl font-semibold text-gray-900 mt-10 mb-3">{children}</h2>;
|
|
}
|
|
function H3({ children }: { children: React.ReactNode }) {
|
|
return <h3 className="text-base font-semibold text-gray-800 mt-6 mb-2">{children}</h3>;
|
|
}
|
|
function P({ children }: { children: React.ReactNode }) {
|
|
return <p className="text-gray-600 leading-relaxed mb-4">{children}</p>;
|
|
}
|
|
function Divider() {
|
|
return <hr className="border-gray-100 my-8" />;
|
|
}
|
|
function Callout({ type = 'info', children }: { type?: 'info' | 'warning' | 'success'; children: React.ReactNode }) {
|
|
const styles = {
|
|
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
|
warning: 'bg-amber-50 border-amber-200 text-amber-800',
|
|
success: 'bg-green-50 border-green-200 text-green-800',
|
|
};
|
|
const icons = { info: Info, warning: AlertTriangle, success: CheckCircle2 };
|
|
const Icon = icons[type];
|
|
return (
|
|
<div className={`flex gap-3 p-4 rounded-lg border my-4 ${styles[type]}`}>
|
|
<Icon className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
|
<div className="text-sm leading-relaxed">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
function InlineCode({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<code className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-800 text-sm font-mono">
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
function Table({ headers, rows }: { headers: string[]; rows: (string | React.ReactNode)[][] }) {
|
|
return (
|
|
<div className="overflow-x-auto my-4 rounded-lg border border-gray-200">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-b border-gray-200">
|
|
{headers.map(h => (
|
|
<th key={h} className="text-left px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row, i) => (
|
|
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}>
|
|
{row.map((cell, j) => (
|
|
<td key={j} className="px-4 py-3 text-gray-700 border-b border-gray-100 last:border-0">
|
|
{cell}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
function SectionHeader({ title, description, badge }: { title: string; description: string; badge?: string }) {
|
|
return (
|
|
<div className="mb-8 pb-6 border-b border-gray-100">
|
|
{badge && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-[#34CCCD]/10 text-[#0e9999] border border-[#34CCCD]/30 mb-4">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">{title}</h1>
|
|
<p className="text-lg text-gray-500">{description}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Home ────────────────────────────────────────────────────────────
|
|
|
|
function HomeSection({ onNavigate }: { onNavigate: (id: string) => void }) {
|
|
const cards = [
|
|
{
|
|
id: 'quickstart',
|
|
icon: Zap,
|
|
title: 'Guide de démarrage',
|
|
description: 'Faites votre première requête API en moins de 5 minutes.',
|
|
color: 'text-amber-500',
|
|
bg: 'bg-amber-50',
|
|
},
|
|
{
|
|
id: 'authentication',
|
|
icon: Key,
|
|
title: 'Authentification',
|
|
description: 'Créez et gérez vos clés API pour accéder à la plateforme.',
|
|
color: 'text-blue-500',
|
|
bg: 'bg-blue-50',
|
|
},
|
|
{
|
|
id: 'bookings',
|
|
icon: Package,
|
|
title: 'Bookings',
|
|
description: 'Créez, consultez et gérez des réservations de fret maritime.',
|
|
color: 'text-violet-500',
|
|
bg: 'bg-violet-50',
|
|
},
|
|
{
|
|
id: 'rates',
|
|
icon: TrendingUp,
|
|
title: 'Tarifs & Recherche',
|
|
description: 'Recherchez et comparez des tarifs en temps réel.',
|
|
color: 'text-emerald-500',
|
|
bg: 'bg-emerald-50',
|
|
},
|
|
{
|
|
id: 'organizations',
|
|
icon: Building2,
|
|
title: 'Organisations',
|
|
description: 'Accédez aux données de votre organisation.',
|
|
color: 'text-orange-500',
|
|
bg: 'bg-orange-50',
|
|
},
|
|
{
|
|
id: 'endpoints',
|
|
icon: List,
|
|
title: 'Référence complète',
|
|
description: 'Tous les endpoints, paramètres et réponses.',
|
|
color: 'text-gray-500',
|
|
bg: 'bg-gray-50',
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
{/* Hero */}
|
|
<div className="relative rounded-2xl overflow-hidden mb-10 bg-gradient-to-br from-[#10183A] via-[#1a2a5e] to-[#0e3a3b]">
|
|
<div className="absolute inset-0 opacity-20"
|
|
style={{
|
|
backgroundImage: 'radial-gradient(circle at 70% 50%, #34CCCD 0%, transparent 60%)',
|
|
}}
|
|
/>
|
|
<div className="relative px-8 py-12">
|
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 text-[#34CCCD] text-xs font-medium mb-4 border border-white/10">
|
|
<ShieldCheck className="w-3.5 h-3.5" />
|
|
Plans Gold & Platinium uniquement
|
|
</div>
|
|
<h1 className="text-4xl font-bold text-white mb-3">
|
|
API Xpeditis
|
|
</h1>
|
|
<p className="text-gray-300 text-lg max-w-xl mb-6">
|
|
Intégrez la puissance de la freight logistics maritime dans vos applications.
|
|
Tarifs en temps réel, gestion de bookings, suivi d'expéditions.
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => onNavigate('quickstart')}
|
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-[#34CCCD] text-[#10183A] font-semibold text-sm hover:bg-[#2bb8b9] transition-colors"
|
|
>
|
|
<Zap className="w-4 h-4" />
|
|
Commencer
|
|
</button>
|
|
<button
|
|
onClick={() => onNavigate('endpoints')}
|
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-white/10 text-white font-medium text-sm hover:bg-white/15 transition-colors border border-white/10"
|
|
>
|
|
Référence API
|
|
<ArrowRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cards grid */}
|
|
<div className="mb-8">
|
|
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-4">Explorer la documentation</h2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{cards.map(card => (
|
|
<button
|
|
key={card.id}
|
|
onClick={() => onNavigate(card.id)}
|
|
className="group text-left p-5 bg-white rounded-xl border border-gray-200 hover:border-gray-300 hover:shadow-md transition-all duration-200"
|
|
>
|
|
<div className={`w-9 h-9 rounded-lg ${card.bg} flex items-center justify-center mb-3`}>
|
|
<card.icon className={`w-4.5 h-4.5 ${card.color}`} style={{ width: 18, height: 18 }} />
|
|
</div>
|
|
<h3 className="font-semibold text-gray-900 mb-1 group-hover:text-[#10183A]">{card.title}</h3>
|
|
<p className="text-sm text-gray-500 leading-relaxed">{card.description}</p>
|
|
<div className="flex items-center gap-1 mt-3 text-xs font-medium text-gray-400 group-hover:text-[#34CCCD] transition-colors">
|
|
En savoir plus <ChevronRight className="w-3.5 h-3.5" />
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Base URL */}
|
|
<Divider />
|
|
<H2>URL de base</H2>
|
|
<P>Toutes les requêtes API doivent être envoyées à l'URL de base suivante :</P>
|
|
<CodeBlock language="bash" code="https://api.xpeditis.com" />
|
|
<P>En environnement de développement local :</P>
|
|
<CodeBlock language="bash" code="http://localhost:4000" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Quick Start ─────────────────────────────────────────────────────
|
|
|
|
function QuickStartSection({ onNavigate }: { onNavigate: (id: string) => void }) {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
title="Guide de démarrage"
|
|
description="Faites votre première requête API en 3 étapes."
|
|
badge="5 minutes"
|
|
/>
|
|
|
|
<Callout type="info">
|
|
L'accès API est disponible uniquement sur les plans <strong>Gold</strong> et <strong>Platinium</strong>.
|
|
Rendez-vous dans <em>Paramètres → Abonnement</em> pour upgrader.
|
|
</Callout>
|
|
|
|
{/* Step 1 */}
|
|
<div className="flex gap-4 mb-8">
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[#10183A] text-white flex items-center justify-center text-sm font-bold">1</div>
|
|
<div className="flex-1 pt-0.5">
|
|
<H3>Obtenir votre clé API</H3>
|
|
<P>
|
|
Dans le dashboard, rendez-vous dans <strong>Paramètres → Clés API</strong>, puis cliquez sur <em>Créer une clé</em>.
|
|
La clé complète vous sera montrée <strong>une seule fois</strong> — conservez-la immédiatement.
|
|
</P>
|
|
<P>Format de la clé :</P>
|
|
<CodeBlock language="bash" code="xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step 2 */}
|
|
<div className="flex gap-4 mb-8">
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[#10183A] text-white flex items-center justify-center text-sm font-bold">2</div>
|
|
<div className="flex-1 pt-0.5">
|
|
<H3>Faire votre première requête</H3>
|
|
<P>Passez la clé dans l'en-tête <InlineCode>X-API-Key</InlineCode> :</P>
|
|
<CodeBlock
|
|
language="bash"
|
|
code={`curl https://api.xpeditis.com/bookings \\
|
|
-H "X-API-Key: xped_live_votre_cle" \\
|
|
-H "Content-Type: application/json"`}
|
|
/>
|
|
<CodeBlock
|
|
language="typescript"
|
|
filename="example.ts"
|
|
code={`const response = await fetch('https://api.xpeditis.com/bookings', {
|
|
headers: {
|
|
'X-API-Key': process.env.XPEDITIS_API_KEY!,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
const data = await response.json();
|
|
console.log(data);`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step 3 */}
|
|
<div className="flex gap-4 mb-8">
|
|
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-[#10183A] text-white flex items-center justify-center text-sm font-bold">3</div>
|
|
<div className="flex-1 pt-0.5">
|
|
<H3>Lire la réponse</H3>
|
|
<P>Toutes les réponses sont en JSON. En cas de succès :</P>
|
|
<CodeBlock
|
|
language="json"
|
|
code={`{
|
|
"data": [ ... ],
|
|
"meta": {
|
|
"total": 42,
|
|
"page": 1,
|
|
"perPage": 20
|
|
}
|
|
}`}
|
|
/>
|
|
<P>En cas d'erreur :</P>
|
|
<CodeBlock
|
|
language="json"
|
|
code={`{
|
|
"statusCode": 401,
|
|
"message": "Clé API invalide, expirée ou votre abonnement ne permet plus l'accès API.",
|
|
"error": "Unauthorized"
|
|
}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Divider />
|
|
<H2>Étapes suivantes</H2>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{[
|
|
{ id: 'authentication', label: 'Gérer vos clés API', desc: 'Créer, lister et révoquer des clés' },
|
|
{ id: 'bookings', label: 'Créer un booking', desc: 'Réservez du fret maritime via l\'API' },
|
|
{ id: 'rates', label: 'Rechercher des tarifs', desc: 'Comparez les tarifs en temps réel' },
|
|
{ id: 'errors', label: 'Gestion des erreurs', desc: 'Tous les codes d\'erreur expliqués' },
|
|
].map(item => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => onNavigate(item.id)}
|
|
className="flex items-center justify-between p-4 text-left bg-white rounded-xl border border-gray-200 hover:border-[#34CCCD] hover:shadow-sm transition-all group"
|
|
>
|
|
<div>
|
|
<p className="font-medium text-gray-900 text-sm">{item.label}</p>
|
|
<p className="text-xs text-gray-500 mt-0.5">{item.desc}</p>
|
|
</div>
|
|
<ArrowRight className="w-4 h-4 text-gray-300 group-hover:text-[#34CCCD] transition-colors flex-shrink-0 ml-3" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Authentication ──────────────────────────────────────────────────
|
|
|
|
function AuthenticationSection() {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
title="Clés API"
|
|
description="Authentifiez vos requêtes avec des clés API sécurisées."
|
|
/>
|
|
|
|
<H2>Vue d'ensemble</H2>
|
|
<P>
|
|
L'API Xpeditis utilise des clés API pour authentifier les requêtes.
|
|
Transmettez votre clé dans l'en-tête <InlineCode>X-API-Key</InlineCode> de chaque requête.
|
|
</P>
|
|
<Callout type="warning">
|
|
Vos clés API sont confidentielles. Ne les partagez jamais dans du code public, des dépôts Git ou des forums.
|
|
En cas de compromission, révoquez immédiatement la clé depuis le dashboard.
|
|
</Callout>
|
|
|
|
<Divider />
|
|
<H2>Format de la clé</H2>
|
|
<P>Toutes les clés Xpeditis commencent par <InlineCode>xped_live_</InlineCode> suivi de 64 caractères hexadécimaux :</P>
|
|
<CodeBlock language="bash" code="xped_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" />
|
|
|
|
<Divider />
|
|
<H2>Utilisation</H2>
|
|
<H3>Header HTTP</H3>
|
|
<P>Passez votre clé dans l'en-tête <InlineCode>X-API-Key</InlineCode> :</P>
|
|
<CodeBlock
|
|
language="bash"
|
|
code={`curl https://api.xpeditis.com/bookings \\
|
|
-H "X-API-Key: xped_live_votre_cle" \\
|
|
-H "Content-Type: application/json"`}
|
|
/>
|
|
|
|
<H3>Exemples par langage</H3>
|
|
<CodeBlock
|
|
language="typescript"
|
|
filename="xpeditis.ts"
|
|
code={`const XPEDITIS_API_KEY = process.env.XPEDITIS_API_KEY;
|
|
|
|
const response = await fetch('https://api.xpeditis.com/bookings', {
|
|
headers: {
|
|
'X-API-Key': XPEDITIS_API_KEY!,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});`}
|
|
/>
|
|
<CodeBlock
|
|
language="python"
|
|
filename="xpeditis.py"
|
|
code={`import os, requests
|
|
|
|
API_KEY = os.environ['XPEDITIS_API_KEY']
|
|
|
|
response = requests.get(
|
|
'https://api.xpeditis.com/bookings',
|
|
headers={
|
|
'X-API-Key': API_KEY,
|
|
'Content-Type': 'application/json',
|
|
}
|
|
)
|
|
data = response.json()`}
|
|
/>
|
|
<CodeBlock
|
|
language="php"
|
|
filename="xpeditis.php"
|
|
code={`$apiKey = getenv('XPEDITIS_API_KEY');
|
|
|
|
$ch = curl_init('https://api.xpeditis.com/bookings');
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
|
"X-API-Key: $apiKey",
|
|
'Content-Type: application/json',
|
|
]);
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
$response = json_decode(curl_exec($ch), true);`}
|
|
/>
|
|
|
|
<Divider />
|
|
<H2>Endpoints de gestion</H2>
|
|
<P>Ces endpoints nécessitent une authentification par <strong>token JWT</strong> (connexion via le dashboard) et non une clé API.</P>
|
|
|
|
<Table
|
|
headers={['Méthode', 'Endpoint', 'Description']}
|
|
rows={[
|
|
['POST', '/api-keys', 'Créer une nouvelle clé API'],
|
|
['GET', '/api-keys', 'Lister toutes les clés de l\'organisation'],
|
|
['DELETE', '/api-keys/:id', 'Révoquer une clé API'],
|
|
]}
|
|
/>
|
|
|
|
<H3>Créer une clé</H3>
|
|
<CodeBlock
|
|
language="bash"
|
|
code={`curl -X POST https://api.xpeditis.com/api-keys \\
|
|
-H "Authorization: Bearer <access_token>" \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{
|
|
"name": "Intégration ERP Production",
|
|
"expiresAt": "2027-01-01T00:00:00.000Z"
|
|
}'`}
|
|
/>
|
|
<CodeBlock
|
|
language="json"
|
|
filename="response.json"
|
|
code={`{
|
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
"name": "Intégration ERP Production",
|
|
"keyPrefix": "xped_live_a1b2c3d4",
|
|
"isActive": true,
|
|
"lastUsedAt": null,
|
|
"expiresAt": "2027-01-01T00:00:00.000Z",
|
|
"createdAt": "2025-03-26T10:00:00.000Z",
|
|
"fullKey": "xped_live_a1b2c3d4e5f6..."
|
|
}`}
|
|
/>
|
|
<Callout type="warning">
|
|
Le champ <InlineCode>fullKey</InlineCode> est retourné <strong>une seule fois</strong>.
|
|
Stockez-le immédiatement dans un gestionnaire de secrets.
|
|
</Callout>
|
|
|
|
<Divider />
|
|
<H2>Sécurité</H2>
|
|
<H3>Stockage recommandé</H3>
|
|
<Table
|
|
headers={['Environnement', 'Méthode recommandée']}
|
|
rows={[
|
|
['Développement local', 'Fichier .env (jamais commité)'],
|
|
['CI/CD', 'Variables d\'environnement secrètes'],
|
|
['Production cloud', 'AWS Secrets Manager / HashiCorp Vault'],
|
|
['Docker / K8s', 'Kubernetes Secrets chiffrés'],
|
|
]}
|
|
/>
|
|
<H3>Rotation des clés</H3>
|
|
<P>
|
|
Effectuez une rotation régulière (tous les 90 jours recommandé) : créez une nouvelle clé, migrez votre système,
|
|
puis révoquez l'ancienne.
|
|
</P>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Bookings ────────────────────────────────────────────────────────
|
|
|
|
function BookingsSection() {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
title="Bookings"
|
|
description="Créez et gérez des réservations de fret maritime via l'API."
|
|
/>
|
|
|
|
<H2>Lister les bookings</H2>
|
|
<CodeBlock
|
|
language="bash"
|
|
code={`GET /bookings
|
|
|
|
# Avec filtres
|
|
GET /bookings?status=confirmed&page=1&limit=20`}
|
|
/>
|
|
<Table
|
|
headers={['Paramètre', 'Type', 'Description']}
|
|
rows={[
|
|
[<InlineCode key="s">status</InlineCode>, 'string', 'Filtrer par statut (draft, confirmed, in_transit, delivered, cancelled)'],
|
|
[<InlineCode key="p">page</InlineCode>, 'number', 'Numéro de page (défaut: 1)'],
|
|
[<InlineCode key="l">limit</InlineCode>, 'number', 'Résultats par page (défaut: 20, max: 100)'],
|
|
[<InlineCode key="o">origin</InlineCode>, 'string', 'Code port d\'origine (ex: FRLEH)'],
|
|
[<InlineCode key="d">destination</InlineCode>, 'string', 'Code port de destination (ex: CNSHA)'],
|
|
]}
|
|
/>
|
|
<CodeBlock
|
|
language="json"
|
|
filename="response.json"
|
|
code={`{
|
|
"data": [
|
|
{
|
|
"id": "uuid",
|
|
"bookingNumber": "WCM-2025-000123",
|
|
"status": "confirmed",
|
|
"origin": "FRLEH",
|
|
"destination": "CNSHA",
|
|
"carrier": "Maersk",
|
|
"departureDate": "2025-04-15",
|
|
"arrivalDate": "2025-05-20",
|
|
"totalAmount": { "amount": 2400.00, "currency": "USD" },
|
|
"createdAt": "2025-03-26T10:00:00.000Z"
|
|
}
|
|
],
|
|
"meta": { "total": 42, "page": 1, "perPage": 20 }
|
|
}`}
|
|
/>
|
|
|
|
<Divider />
|
|
<H2>Créer un booking</H2>
|
|
<CodeBlock
|
|
language="bash"
|
|
code={`POST /bookings
|
|
Content-Type: application/json
|
|
X-API-Key: xped_live_...`}
|
|
/>
|
|
<CodeBlock
|
|
language="json"
|
|
filename="request.json"
|
|
code={`{
|
|
"rateQuoteId": "uuid-du-rate-quote",
|
|
"shipper": {
|
|
"name": "Société Exportatrice SAS",
|
|
"contactName": "Jean Dupont",
|
|
"contactEmail": "j.dupont@exemple.fr",
|
|
"contactPhone": "+33612345678",
|
|
"address": {
|
|
"street": "15 rue du Commerce",
|
|
"city": "Le Havre",
|
|
"postalCode": "76600",
|
|
"country": "France"
|
|
}
|
|
},
|
|
"consignee": {
|
|
"name": "Shanghai Imports Co.",
|
|
"contactName": "Li Wei",
|
|
"contactEmail": "li@imports.cn",
|
|
"contactPhone": "+8613912345678",
|
|
"address": {
|
|
"street": "88 Pudong Ave",
|
|
"city": "Shanghai",
|
|
"postalCode": "200120",
|
|
"country": "China"
|
|
}
|
|
}
|
|
}`}
|
|
/>
|
|
|
|
<Divider />
|
|
<H2>Statuts d'un booking</H2>
|
|
<Table
|
|
headers={['Statut', 'Description']}
|
|
rows={[
|
|
[<InlineCode key="1">draft</InlineCode>, 'Réservation créée, non confirmée'],
|
|
[<InlineCode key="2">pending_confirmation</InlineCode>, 'En attente de confirmation du transporteur'],
|
|
[<InlineCode key="3">confirmed</InlineCode>, 'Confirmée par le transporteur'],
|
|
[<InlineCode key="4">in_transit</InlineCode>, 'Expédition en cours'],
|
|
[<InlineCode key="5">delivered</InlineCode>, 'Livraison confirmée'],
|
|
[<InlineCode key="6">cancelled</InlineCode>, 'Annulée'],
|
|
]}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Rates ───────────────────────────────────────────────────────────
|
|
|
|
function RatesSection() {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
title="Tarifs & Recherche"
|
|
description="Recherchez et comparez des tarifs de fret maritime en temps réel."
|
|
/>
|
|
|
|
<H2>Rechercher des tarifs</H2>
|
|
<CodeBlock
|
|
language="bash"
|
|
code={`GET /rates/search?origin=FRLEH&destination=CNSHA&containerType=20GP&departureDate=2025-04-15`}
|
|
/>
|
|
<Table
|
|
headers={['Paramètre', 'Requis', 'Description']}
|
|
rows={[
|
|
[<InlineCode key="o">origin</InlineCode>, '✅', 'Code port d\'origine (UN/LOCODE, ex: FRLEH)'],
|
|
[<InlineCode key="d">destination</InlineCode>, '✅', 'Code port de destination (ex: CNSHA)'],
|
|
[<InlineCode key="c">containerType</InlineCode>, '✅', 'Type de conteneur: 20GP, 40GP, 40HC, 45HC, 20FR, 40FR'],
|
|
[<InlineCode key="dd">departureDate</InlineCode>, '❌', 'Date souhaitée de départ (YYYY-MM-DD)'],
|
|
[<InlineCode key="s">sortBy</InlineCode>, '❌', 'Tri: price_asc | price_desc | transit_time'],
|
|
]}
|
|
/>
|
|
<CodeBlock
|
|
language="json"
|
|
filename="response.json"
|
|
code={`{
|
|
"data": [
|
|
{
|
|
"id": "uuid",
|
|
"carrier": {
|
|
"name": "Maersk",
|
|
"scac": "MAEU",
|
|
"logoUrl": "..."
|
|
},
|
|
"origin": { "code": "FRLEH", "name": "Le Havre", "country": "France" },
|
|
"destination": { "code": "CNSHA", "name": "Shanghai", "country": "China" },
|
|
"containerType": "20GP",
|
|
"departureDate": "2025-04-15",
|
|
"arrivalDate": "2025-05-20",
|
|
"transitDays": 35,
|
|
"price": { "amount": 1850.00, "currency": "USD" },
|
|
"surcharges": [
|
|
{ "code": "BAF", "name": "Bunker Adjustment Factor", "amount": 150.00, "currency": "USD" }
|
|
],
|
|
"totalPrice": { "amount": 2000.00, "currency": "USD" },
|
|
"validUntil": "2025-04-01T00:00:00.000Z",
|
|
"directService": true
|
|
}
|
|
],
|
|
"meta": { "total": 8, "searchedAt": "2025-03-26T10:00:00.000Z" }
|
|
}`}
|
|
/>
|
|
|
|
<Callout type="info">
|
|
Les tarifs sont mis en cache pendant <strong>15 minutes</strong>. Au-delà, une nouvelle recherche est effectuée
|
|
auprès des transporteurs en temps réel.
|
|
</Callout>
|
|
|
|
<Divider />
|
|
<H2>Codes de ports (UN/LOCODE)</H2>
|
|
<P>Les ports sont identifiés par le code standard UN/LOCODE (5 caractères).</P>
|
|
<Table
|
|
headers={['Code', 'Port', 'Pays']}
|
|
rows={[
|
|
['FRLEH', 'Le Havre', 'France'],
|
|
['FRFOS', 'Fos-sur-Mer', 'France'],
|
|
['CNSHA', 'Shanghai', 'Chine'],
|
|
['CNNGB', 'Ningbo', 'Chine'],
|
|
['SGSIN', 'Singapore', 'Singapour'],
|
|
['USLAX', 'Los Angeles', 'États-Unis'],
|
|
['NLRTM', 'Rotterdam', 'Pays-Bas'],
|
|
['DEHAM', 'Hambourg', 'Allemagne'],
|
|
]}
|
|
/>
|
|
<CodeBlock
|
|
language="bash"
|
|
code="GET /ports?q=havre&limit=5"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Organizations ───────────────────────────────────────────────────
|
|
|
|
function OrganizationsSection() {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
title="Organisations"
|
|
description="Accédez aux informations de votre organisation."
|
|
/>
|
|
|
|
<H2>Profil de l'organisation</H2>
|
|
<CodeBlock language="bash" code="GET /organizations/me" />
|
|
<CodeBlock
|
|
language="json"
|
|
filename="response.json"
|
|
code={`{
|
|
"id": "uuid",
|
|
"name": "Freight Forwarder SAS",
|
|
"type": "FREIGHT_FORWARDER",
|
|
"siret": "12345678901234",
|
|
"siren": "123456789",
|
|
"statusBadge": "gold",
|
|
"subscription": {
|
|
"plan": "GOLD",
|
|
"status": "ACTIVE",
|
|
"currentPeriodEnd": "2025-04-26T00:00:00.000Z",
|
|
"features": ["dashboard", "wiki", "user_management", "csv_export", "api_access"]
|
|
},
|
|
"address": {
|
|
"street": "15 rue du Commerce",
|
|
"city": "Le Havre",
|
|
"postalCode": "76600",
|
|
"country": "France"
|
|
}
|
|
}`}
|
|
/>
|
|
|
|
<Divider />
|
|
<H2>Membres de l'organisation</H2>
|
|
<CodeBlock language="bash" code="GET /users?organizationId=uuid&role=MANAGER" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Endpoints ───────────────────────────────────────────────────────
|
|
|
|
function EndpointsSection() {
|
|
const endpoints = [
|
|
{ method: 'GET', path: '/bookings', desc: 'Lister les bookings' },
|
|
{ method: 'POST', path: '/bookings', desc: 'Créer un booking' },
|
|
{ method: 'GET', path: '/bookings/:id', desc: 'Détail d\'un booking' },
|
|
{ method: 'PATCH', path: '/bookings/:id/status', desc: 'Mettre à jour le statut' },
|
|
{ method: 'GET', path: '/rates/search', desc: 'Rechercher des tarifs' },
|
|
{ method: 'GET', path: '/rates/:id', desc: 'Détail d\'un tarif' },
|
|
{ method: 'GET', path: '/ports', desc: 'Lister les ports' },
|
|
{ method: 'GET', path: '/organizations/me', desc: 'Profil de l\'organisation' },
|
|
{ method: 'GET', path: '/users', desc: 'Membres de l\'organisation' },
|
|
{ method: 'POST', path: '/api-keys', desc: 'Créer une clé API (JWT requis)' },
|
|
{ method: 'GET', path: '/api-keys', desc: 'Lister les clés API (JWT requis)' },
|
|
{ method: 'DELETE', path: '/api-keys/:id', desc: 'Révoquer une clé (JWT requis)' },
|
|
];
|
|
|
|
const methodColor: Record<string, string> = {
|
|
GET: 'text-emerald-700 bg-emerald-50 border-emerald-200',
|
|
POST: 'text-blue-700 bg-blue-50 border-blue-200',
|
|
PATCH: 'text-amber-700 bg-amber-50 border-amber-200',
|
|
DELETE: 'text-red-700 bg-red-50 border-red-200',
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
title="Tous les endpoints"
|
|
description="Référence complète de l'API Xpeditis."
|
|
/>
|
|
|
|
<H2>Endpoints disponibles</H2>
|
|
<div className="space-y-2">
|
|
{endpoints.map((ep, i) => (
|
|
<div key={i} className="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors">
|
|
<span className={`text-xs font-bold px-2 py-0.5 rounded border font-mono w-14 text-center flex-shrink-0 ${methodColor[ep.method]}`}>
|
|
{ep.method}
|
|
</span>
|
|
<code className="text-sm font-mono text-gray-700 flex-1">{ep.path}</code>
|
|
<span className="text-sm text-gray-500">{ep.desc}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<Divider />
|
|
<H2>Format de réponse standard</H2>
|
|
<CodeBlock
|
|
language="json"
|
|
filename="success.json"
|
|
code={`{
|
|
"data": { ... }, // ou "data": [ ... ] pour les listes
|
|
"meta": { // uniquement pour les listes paginées
|
|
"total": 100,
|
|
"page": 1,
|
|
"perPage": 20,
|
|
"totalPages": 5
|
|
}
|
|
}`}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Errors ─────────────────────────────────────────────────────────
|
|
|
|
function ErrorsSection() {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
title="Codes d'erreur"
|
|
description="Comprendre et gérer les erreurs de l'API."
|
|
/>
|
|
|
|
<H2>Format d'erreur</H2>
|
|
<P>Toutes les erreurs retournent un JSON standardisé :</P>
|
|
<CodeBlock
|
|
language="json"
|
|
code={`{
|
|
"statusCode": 403,
|
|
"message": "L'accès API nécessite un abonnement Gold ou Platinium.",
|
|
"error": "Forbidden"
|
|
}`}
|
|
/>
|
|
|
|
<Divider />
|
|
<H2>Codes HTTP</H2>
|
|
<Table
|
|
headers={['Code', 'Signification', 'Cause probable']}
|
|
rows={[
|
|
['200', 'Succès', 'Requête traitée avec succès'],
|
|
['201', 'Créé', 'Ressource créée avec succès'],
|
|
['204', 'Pas de contenu', 'Suppression réussie'],
|
|
['400', 'Requête invalide', 'Corps de requête malformé ou paramètres invalides'],
|
|
['401', 'Non authentifié', 'Clé API manquante, invalide ou expirée'],
|
|
['403', 'Accès refusé', 'Plan insuffisant ou permissions manquantes'],
|
|
['404', 'Introuvable', 'Ressource inexistante ou appartenant à une autre organisation'],
|
|
['429', 'Trop de requêtes', 'Rate limit dépassé'],
|
|
['500', 'Erreur serveur', 'Erreur interne — contactez le support'],
|
|
]}
|
|
/>
|
|
|
|
<Divider />
|
|
<H2>Erreurs courantes</H2>
|
|
<H3>401 — Clé API invalide</H3>
|
|
<CodeBlock
|
|
language="json"
|
|
code={`{
|
|
"statusCode": 401,
|
|
"message": "Clé API invalide, expirée ou votre abonnement ne permet plus l'accès API.",
|
|
"error": "Unauthorized"
|
|
}`}
|
|
/>
|
|
<P><strong>Solutions :</strong> Vérifiez que la clé commence par <InlineCode>xped_live_</InlineCode>, qu'elle n'est pas révoquée et que l'abonnement est toujours Gold ou Platinium.</P>
|
|
|
|
<H3>403 — Plan insuffisant</H3>
|
|
<CodeBlock
|
|
language="json"
|
|
code={`{
|
|
"statusCode": 403,
|
|
"message": "L'accès API nécessite un abonnement Gold ou Platinium. Mettez à niveau votre abonnement pour générer des clés API.",
|
|
"error": "Forbidden"
|
|
}`}
|
|
/>
|
|
|
|
<H3>429 — Rate limit</H3>
|
|
<CodeBlock
|
|
language="json"
|
|
code={`{
|
|
"statusCode": 429,
|
|
"message": "Too many requests. Please wait before retrying.",
|
|
"error": "Too Many Requests",
|
|
"retryAfter": 60
|
|
}`}
|
|
/>
|
|
<P>En cas de 429, attendez la durée indiquée dans <InlineCode>retryAfter</InlineCode> avant de réessayer.</P>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section: Rate Limiting ───────────────────────────────────────────────────
|
|
|
|
function RateLimitingSection() {
|
|
return (
|
|
<div>
|
|
<SectionHeader
|
|
title="Rate Limiting"
|
|
description="Limites de requêtes et bonnes pratiques."
|
|
/>
|
|
|
|
<H2>Limites par plan</H2>
|
|
<Table
|
|
headers={['Plan', 'Requêtes / minute', 'Requêtes / jour']}
|
|
rows={[
|
|
['Gold', '60', '10 000'],
|
|
['Platinium', '120', 'Illimité'],
|
|
]}
|
|
/>
|
|
<Callout type="info">
|
|
Le rate limiting est appliqué <strong>par utilisateur</strong> (ID de l'utilisateur associé à la clé API).
|
|
</Callout>
|
|
|
|
<Divider />
|
|
<H2>En-têtes de réponse</H2>
|
|
<P>Chaque réponse inclut des en-têtes pour suivre votre consommation :</P>
|
|
<Table
|
|
headers={['En-tête', 'Description']}
|
|
rows={[
|
|
['X-RateLimit-Limit', 'Nombre maximum de requêtes par fenêtre'],
|
|
['X-RateLimit-Remaining', 'Requêtes restantes dans la fenêtre actuelle'],
|
|
['X-RateLimit-Reset', 'Timestamp UNIX de réinitialisation du compteur'],
|
|
]}
|
|
/>
|
|
|
|
<Divider />
|
|
<H2>Bonnes pratiques</H2>
|
|
<div className="space-y-3">
|
|
{[
|
|
{ icon: Clock, title: 'Exponential backoff', desc: 'En cas de 429, attendez avant de réessayer (1s, 2s, 4s, 8s…).' },
|
|
{ icon: CheckCircle2, title: 'Mise en cache', desc: 'Cachez les résultats côté client pour éviter les appels redondants. Les tarifs restent valides 15 minutes.' },
|
|
{ icon: ShieldCheck, title: 'Une clé par service', desc: 'Utilisez des clés séparées par service ou environnement pour un meilleur suivi et une révocation ciblée.' },
|
|
].map((item, i) => (
|
|
<div key={i} className="flex gap-3 p-4 rounded-lg bg-gray-50 border border-gray-100">
|
|
<item.icon className="w-5 h-5 text-[#34CCCD] flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="font-medium text-gray-900 text-sm">{item.title}</p>
|
|
<p className="text-sm text-gray-500 mt-0.5">{item.desc}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section map ──────────────────────────────────────────────────────────────
|
|
|
|
function SectionContent({
|
|
activeSection,
|
|
onNavigate,
|
|
}: {
|
|
activeSection: string;
|
|
onNavigate: (id: string) => void;
|
|
}) {
|
|
switch (activeSection) {
|
|
case 'home': return <HomeSection onNavigate={onNavigate} />;
|
|
case 'quickstart': return <QuickStartSection onNavigate={onNavigate} />;
|
|
case 'authentication': return <AuthenticationSection />;
|
|
case 'bookings': return <BookingsSection />;
|
|
case 'rates': return <RatesSection />;
|
|
case 'organizations': return <OrganizationsSection />;
|
|
case 'endpoints': return <EndpointsSection />;
|
|
case 'errors': return <ErrorsSection />;
|
|
case 'rate-limiting': return <RateLimitingSection />;
|
|
default: return <HomeSection onNavigate={onNavigate} />;
|
|
}
|
|
}
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
function DocsPageContent() {
|
|
const searchParams = useSearchParams();
|
|
const router = useRouter();
|
|
const [activeSection, setActiveSection] = useState(searchParams.get('section') ?? 'home');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
|
|
const navigate = (id: string) => {
|
|
setActiveSection(id);
|
|
router.replace(`/dashboard/docs?section=${id}`, { scroll: false });
|
|
setSidebarOpen(false);
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
// Filter nav based on search
|
|
const filteredSections = DOC_SECTIONS.map(section => ({
|
|
...section,
|
|
items: section.items.filter(item =>
|
|
item.label.toLowerCase().includes(searchQuery.toLowerCase())
|
|
),
|
|
})).filter(s => s.items.length > 0);
|
|
|
|
const Sidebar = () => (
|
|
<div className="flex flex-col h-full bg-white">
|
|
{/* Logo + title */}
|
|
<div className="px-4 pt-5 pb-4 border-b border-gray-100">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<div className="w-7 h-7 rounded-lg bg-[#10183A] flex items-center justify-center">
|
|
<span className="text-[#34CCCD] text-xs font-bold">X</span>
|
|
</div>
|
|
<span className="text-sm font-bold text-gray-900">Xpeditis</span>
|
|
<span className="text-xs text-gray-400 font-medium">/ API</span>
|
|
</div>
|
|
{/* Search */}
|
|
<div className="relative mt-3">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Rechercher..."
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
className="w-full pl-8 pr-3 py-1.5 text-sm bg-gray-50 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#34CCCD]/30 focus:border-[#34CCCD] placeholder-gray-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 overflow-y-auto py-3 px-3">
|
|
{filteredSections.map(section => (
|
|
<div key={section.title} className="mb-4">
|
|
<p className="px-3 mb-1 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
{section.title}
|
|
</p>
|
|
{section.items.map(item => (
|
|
<button
|
|
key={item.id}
|
|
onClick={() => navigate(item.id)}
|
|
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition-all mb-0.5 ${
|
|
activeSection === item.id
|
|
? 'bg-[#10183A] text-white font-medium'
|
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
<item.icon className="w-4 h-4 flex-shrink-0" />
|
|
<span>{item.label}</span>
|
|
{activeSection === item.id && (
|
|
<ChevronRight className="w-3.5 h-3.5 ml-auto opacity-70" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Footer */}
|
|
<div className="border-t border-gray-100 p-4">
|
|
<a
|
|
href="/dashboard/settings/api-keys"
|
|
className="flex items-center gap-2 text-xs text-gray-500 hover:text-[#10183A] transition-colors"
|
|
>
|
|
<Key className="w-3.5 h-3.5" />
|
|
Gérer mes clés API
|
|
<ExternalLink className="w-3 h-3 ml-auto" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
/* Break out of dashboard's p-6 padding */
|
|
<div className="-m-6 flex" style={{ height: 'calc(100vh - 64px)' }}>
|
|
{/* Mobile sidebar overlay */}
|
|
{sidebarOpen && (
|
|
<div
|
|
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Docs sidebar — desktop */}
|
|
<div className="hidden lg:flex flex-col w-64 xl:w-72 flex-shrink-0 border-r border-gray-100 overflow-hidden">
|
|
<Sidebar />
|
|
</div>
|
|
|
|
{/* Docs sidebar — mobile drawer */}
|
|
<div className={`fixed inset-y-0 left-0 z-50 w-72 transform transition-transform lg:hidden ${
|
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
|
}`}>
|
|
<Sidebar />
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<div className="flex-1 overflow-y-auto min-w-0">
|
|
{/* Mobile top bar */}
|
|
<div className="lg:hidden flex items-center gap-3 px-4 py-3 border-b border-gray-100 bg-white sticky top-0 z-10">
|
|
<button
|
|
onClick={() => setSidebarOpen(true)}
|
|
className="p-1.5 rounded-lg hover:bg-gray-100"
|
|
>
|
|
<Menu className="w-5 h-5 text-gray-600" />
|
|
</button>
|
|
<span className="text-sm font-medium text-gray-700">
|
|
{ALL_NAV_ITEMS.find(i => i.id === activeSection)?.label ?? 'Documentation'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="max-w-3xl mx-auto px-6 xl:px-12 py-8 pb-16">
|
|
{/* Breadcrumb */}
|
|
<div className="flex items-center gap-1.5 text-xs text-gray-400 mb-6">
|
|
<button onClick={() => navigate('home')} className="hover:text-gray-600 transition-colors">
|
|
Documentation
|
|
</button>
|
|
{activeSection !== 'home' && (
|
|
<>
|
|
<ChevronRight className="w-3 h-3" />
|
|
<span className="text-gray-600">
|
|
{ALL_NAV_ITEMS.find(i => i.id === activeSection)?.label}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<SectionContent activeSection={activeSection} onNavigate={navigate} />
|
|
|
|
{/* Bottom nav */}
|
|
{activeSection !== 'home' && (
|
|
<div className="mt-12 pt-6 border-t border-gray-100">
|
|
<button
|
|
onClick={() => navigate('home')}
|
|
className="flex items-center gap-2 text-sm text-gray-400 hover:text-gray-700 transition-colors"
|
|
>
|
|
← Retour à la vue d'ensemble
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DocsPage() {
|
|
return (
|
|
<Suspense fallback={<div className="-m-6 flex items-center justify-center" style={{ height: 'calc(100vh - 64px)' }}><div className="w-8 h-8 border-4 border-[#34CCCD] border-t-transparent rounded-full animate-spin" /></div>}>
|
|
<DocsPageContent />
|
|
</Suspense>
|
|
);
|
|
}
|