xpeditis2.0/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx
David 19b96517d2 fix(bookings,blog): Phase 1 correctifs test utilisateur
- Telechargement docs transporteur: proxy de streaming API (fin des URLs
  presignees MinIO injoignables depuis l'exterieur)
- Documents: ajout de l'action "Supprimer" (endpoint DELETE deja existant)
- Blog: suppression reelle de l'image de couverture (null explicite bout en bout)
- Blog: le bouton Retour navigateur ferme l'editeur au lieu de quitter vers Logs
- Paiement: bouton Retour revient au contexte precedent (router.back)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 13:20:33 +02:00

599 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Commission Payment Page
*
* 2-column layout:
* - Left: payment method selector + action
* - Right: booking summary
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
CreditCard,
Building2,
ArrowLeft,
Loader2,
AlertTriangle,
CheckCircle,
Copy,
Clock,
ShieldCheck,
Send,
} from 'lucide-react';
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
import { getOrganization, requestSiretApproval } from '@/lib/api/organizations';
import { useAuth } from '@/lib/context/auth-context';
interface BookingData {
id: string;
bookingNumber?: string;
carrierName: string;
carrierEmail: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
priceEUR: number;
priceUSD: number;
primaryCurrency: string;
transitDays: number;
containerType: string;
status: string;
commissionRate?: number;
commissionAmountEur?: number;
freightTotal?: number;
freightCurrency?: string;
fobTotal?: number;
fobCurrency?: string;
}
interface OrgData {
siren?: string | null;
siret?: string | null;
siretVerified?: boolean;
name: string;
}
type PaymentMethod = 'card' | 'transfer' | null;
const BANK_DETAILS = {
beneficiary: 'XPEDITIS SAS',
iban: 'FR76 XXXX XXXX XXXX XXXX XXXX XXX',
bic: 'XXXXXXXX',
};
export default function PayCommissionPage() {
const router = useRouter();
const params = useParams();
const bookingId = params.id as string;
const { user } = useAuth();
const [booking, setBooking] = useState<BookingData | null>(null);
const [org, setOrg] = useState<OrgData | null>(null);
const [loading, setLoading] = useState(true);
const [paying, setPaying] = useState(false);
const [declaring, setDeclaring] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
const [copied, setCopied] = useState<string | null>(null);
const [requestingApproval, setRequestingApproval] = useState(false);
const [approvalSent, setApprovalSent] = useState(false);
const [approvalError, setApprovalError] = useState<string | null>(null);
useEffect(() => {
async function fetchData() {
try {
const [bookingData, orgData] = await Promise.all([
getCsvBooking(bookingId),
user?.organizationId ? getOrganization(user.organizationId) : Promise.resolve(null),
]);
setBooking(bookingData as any);
if (orgData) setOrg(orgData);
if ((bookingData as any).status !== 'PENDING_PAYMENT') {
router.replace('/dashboard/bookings');
}
} catch (err) {
setError('Impossible de charger les détails du booking');
} finally {
setLoading(false);
}
}
if (bookingId) fetchData();
}, [bookingId, router, user?.organizationId]);
const handlePayByCard = async () => {
setPaying(true);
setError(null);
try {
const result = await payBookingCommission(bookingId);
window.location.href = result.sessionUrl;
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors de la création du paiement');
setPaying(false);
}
};
const handleDeclareTransfer = async () => {
setDeclaring(true);
setError(null);
try {
await declareBankTransfer(bookingId);
router.push('/dashboard/bookings?transfer=declared');
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors de la déclaration du virement');
setDeclaring(false);
}
};
const handleRequestApproval = async () => {
setRequestingApproval(true);
setApprovalError(null);
try {
await requestSiretApproval();
setApprovalSent(true);
} catch (err) {
setApprovalError(
err instanceof Error ? err.message : 'Erreur lors de l\'envoi de la demande'
);
} finally {
setRequestingApproval(false);
}
};
const copyToClipboard = (value: string, key: string) => {
navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const formatPrice = (price: number, currency: string) =>
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price);
const hasSiretOrSiren = org && (org.siret || org.siren);
const siretNotVerified = hasSiretOrSiren && !org.siretVerified;
const identifier = org?.siret
? `SIRET ${org.siret}`
: org?.siren
? `SIREN ${org.siren}`
: null;
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-gray-600">Chargement...</span>
</div>
</div>
);
}
if (error && !booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-center text-gray-700">{error}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour aux bookings
</button>
</div>
</div>
);
}
if (!booking) return null;
const bookingFeeAmount = booking.commissionAmountEur || 0;
const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase();
// Transport cost breakdown (informational — paid to the carrier, not collected here).
// Freight and FOB may be in different currencies, so they are shown separately.
const freightTotal = booking.freightTotal ?? null;
const fobTotal = booking.fobTotal ?? null;
const freightCurrency = booking.freightCurrency || booking.primaryCurrency || 'USD';
const fobCurrency = booking.fobCurrency || 'EUR';
const hasBreakdown = freightTotal != null || fobTotal != null;
// Single transport total, always shown in USD = freight + FOB summed.
// New bookings store the freight/FOB breakdown; legacy ones only keep the
// per-currency price sums. In both cases the charges are added together.
const transportTotalUSD = hasBreakdown
? (freightTotal ?? 0) + (fobTotal ?? 0)
: (booking.priceUSD || 0) + (booking.priceEUR || 0);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
<div className="max-w-5xl mx-auto">
{/* Back button — returns to the previous context (list or booking detail)
instead of always jumping to the global list. */}
<button
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
</button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
<p className="text-gray-500 mb-8">
Finalisez votre booking en réglant le forfait par booking
</p>
{/* SIRET/SIREN non vérifié — bannière de demande */}
{siretNotVerified && (
<div className="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5">
<div className="flex items-start space-x-4">
<div className="flex-shrink-0 w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<ShieldCheck className="h-5 w-5 text-amber-600" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-amber-900 mb-1">
Votre {identifier} n&apos;est pas encore validé
</h3>
<p className="text-sm text-amber-700 mb-4">
Un administrateur doit vérifier votre numéro d&apos;identification avant de
finaliser certaines opérations. Vous pouvez lui envoyer une demande de validation
directement depuis cette page.
</p>
{approvalSent ? (
<div className="flex items-center space-x-2 text-green-700 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm">
<CheckCircle className="h-4 w-4 flex-shrink-0" />
<span>
Demande envoyée aux administrateurs. Vous serez notifié dès que votre{' '}
{identifier} sera validé.
</span>
</div>
) : (
<div className="space-y-2">
{approvalError && (
<p className="text-sm text-red-600">{approvalError}</p>
)}
<button
onClick={handleRequestApproval}
disabled={requestingApproval}
className="inline-flex items-center space-x-2 px-4 py-2.5 bg-amber-600 hover:bg-amber-700 disabled:bg-amber-400 text-white text-sm font-medium rounded-lg transition-colors disabled:cursor-not-allowed"
>
{requestingApproval ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Envoi en cours...</span>
</>
) : (
<>
<Send className="h-4 w-4" />
<span>Demander la validation à un administrateur</span>
</>
)}
</button>
</div>
)}
</div>
</div>
</div>
)}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* LEFT — Payment method selector */}
<div className="lg:col-span-3 space-y-4">
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
Choisir le mode de paiement
</h2>
{/* Card option */}
<button
onClick={() => setSelectedMethod('card')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'card'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'card' ? 'bg-blue-100' : 'bg-gray-100'
}`}
>
<CreditCard
className={`h-5 w-5 ${
selectedMethod === 'card' ? 'text-blue-600' : 'text-gray-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-gray-900">Carte bancaire</p>
<p className="text-sm text-gray-500">Paiement immédiat via Stripe</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
}`}
>
{selectedMethod === 'card' && <div className="w-2 h-2 rounded-full bg-white" />}
</div>
</div>
</button>
{/* Transfer option */}
<button
onClick={() => setSelectedMethod('transfer')}
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
selectedMethod === 'transfer'
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
selectedMethod === 'transfer' ? 'bg-blue-100' : 'bg-gray-100'
}`}
>
<Building2
className={`h-5 w-5 ${
selectedMethod === 'transfer' ? 'text-blue-600' : 'text-gray-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-gray-900">Virement bancaire</p>
<p className="text-sm text-gray-500">Validation sous 13 jours ouvrables</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'transfer'
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'
}`}
>
{selectedMethod === 'transfer' && (
<div className="w-2 h-2 rounded-full bg-white" />
)}
</div>
</div>
</button>
{/* Card action */}
{selectedMethod === 'card' && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<p className="text-sm text-gray-600 mb-4">
Vous serez redirigé vers Stripe pour finaliser votre paiement en toute sécurité.
</p>
<button
onClick={handlePayByCard}
disabled={paying}
className="w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{paying ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Redirection vers Stripe...</span>
</>
) : (
<>
<CreditCard className="h-5 w-5" />
<span>Payer {formatPrice(bookingFeeAmount, 'EUR')} par carte</span>
</>
)}
</button>
</div>
)}
{/* Transfer action */}
{selectedMethod === 'transfer' && (
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<p className="text-sm text-gray-600">
Effectuez le virement avec les coordonnées ci-dessous, puis cliquez sur
&ldquo;J&apos;ai effectué le virement&rdquo;.
</p>
{/* Bank details */}
<div className="bg-gray-50 rounded-lg divide-y divide-gray-200 text-sm">
{[
{ label: 'Bénéficiaire', value: BANK_DETAILS.beneficiary, key: 'beneficiary' },
{ label: 'IBAN', value: BANK_DETAILS.iban, key: 'iban', mono: true },
{ label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true },
{
label: 'Montant',
value: formatPrice(bookingFeeAmount, 'EUR'),
key: 'amount',
bold: true,
},
{ label: 'Référence', value: reference, key: 'ref', mono: true },
].map(({ label, value, key, mono, bold }) => (
<div key={key} className="flex items-center justify-between px-4 py-3">
<span className="text-gray-500">{label}</span>
<div className="flex items-center space-x-2">
<span
className={`${mono ? 'font-mono' : ''} ${bold ? 'font-bold text-gray-900' : 'text-gray-800'}`}
>
{value}
</span>
{key !== 'amount' && (
<button
onClick={() => copyToClipboard(value, key)}
className="text-gray-400 hover:text-blue-600 transition-colors"
title="Copier"
>
{copied === key ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-start space-x-2 text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
<Clock className="h-4 w-4 flex-shrink-0 mt-0.5" />
<span>
Mentionnez impérativement la référence <strong>{reference}</strong> dans le
libellé du virement.
</span>
</div>
<button
onClick={handleDeclareTransfer}
disabled={declaring}
className="w-full py-3 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
>
{declaring ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Enregistrement...</span>
</>
) : (
<>
<CheckCircle className="h-5 w-5" />
<span>J&apos;ai effectué le virement</span>
</>
)}
</button>
</div>
)}
{/* Placeholder when no method selected */}
{selectedMethod === null && (
<div className="bg-white rounded-xl border-2 border-dashed border-gray-200 p-6 text-center text-gray-400 text-sm">
Sélectionnez un mode de paiement ci-dessus
</div>
)}
</div>
{/* RIGHT — Booking summary */}
<div className="lg:col-span-2 space-y-4">
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
Récapitulatif
</h2>
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
{booking.bookingNumber && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Numéro</span>
<span className="font-semibold text-gray-900">{booking.bookingNumber}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Transporteur</span>
<span className="font-semibold text-gray-900 text-right max-w-[55%]">
{booking.carrierName}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Trajet</span>
<span className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Volume / Poids</span>
<span className="font-semibold text-gray-900">
{booking.volumeCBM} CBM · {booking.weightKG} kg
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Transit</span>
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
</div>
{/* Transport cost breakdown — Fret + FOB (paid to the carrier) */}
{hasBreakdown && (
<>
{freightTotal != null && (
<div className="border-t pt-3 flex justify-between text-sm">
<span className="text-gray-500">Fret</span>
<span className="font-semibold text-gray-900">
{formatPrice(freightTotal, freightCurrency)}
</span>
</div>
)}
{fobTotal != null && fobTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-500">Frais FOB</span>
<span className="font-semibold text-gray-900">
{formatPrice(fobTotal, fobCurrency)}
</span>
</div>
)}
</>
)}
{/* Single transport total — always in USD (fret + FOB) */}
<div className="flex justify-between text-sm border-t pt-3">
<span className="text-gray-700 font-medium">Total</span>
<span className="font-bold text-gray-900">
{formatPrice(transportTotalUSD, 'USD')}
</span>
</div>
<p className="text-xs text-gray-400 pt-1">
Fret et frais FOB sont réglés directement au transporteur.
</p>
</div>
{/* Booking fee box */}
<div className="bg-blue-600 rounded-xl p-5 text-white">
<p className="text-sm text-blue-100 mb-1">Forfait par booking à régler maintenant</p>
<p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
<p className="text-xs text-blue-200 mt-1">
Montant fixe par booking (hors fret et frais FOB)
</p>
</div>
{/* Organisation info */}
{org && (
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-2 text-sm">
<p className="font-medium text-gray-700">{org.name}</p>
{identifier && (
<div className="flex items-center justify-between">
<span className="text-gray-500">{identifier}</span>
{org.siretVerified ? (
<span className="flex items-center space-x-1 text-green-600 text-xs font-medium">
<CheckCircle className="h-3.5 w-3.5" />
<span>Vérifié</span>
</span>
) : (
<span className="flex items-center space-x-1 text-amber-600 text-xs font-medium">
<AlertTriangle className="h-3.5 w-3.5" />
<span>Non vérifié</span>
</span>
)}
</div>
)}
</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-gray-500">
Après validation du paiement, votre demande est envoyée au transporteur (
{booking.carrierEmail}). Vous serez notifié de sa réponse.
</p>
</div>
</div>
</div>
</div>
</div>
);
}