xpeditis2.0/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx
2026-09-01 17:19:01 +02:00

636 lines
26 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-brand-blue/5">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-brand-navy" />
<span className="text-neutral-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-brand-blue/5">
<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-neutral-700">{error}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90"
>
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;
// Le total de transport n'a de sens que si ses composantes partagent une
// devise. L'ancien calcul additionnait fret et frais FOB sans les comparer —
// et, dans le cas des anciennes reservations, `priceUSD + priceEUR`, soit des
// dollars ajoutes a des euros — puis etiquetait systematiquement le resultat
// en USD. Un fret de 353 EUR s'affichait donc « 353,00 $US ».
const primaryCurrency = booking.primaryCurrency || 'EUR';
const hasFob = fobTotal != null && fobTotal > 0;
const currenciesMatch = !hasFob || fobCurrency === freightCurrency;
const transportCurrency = hasBreakdown ? freightCurrency : primaryCurrency;
const transportTotal = hasBreakdown
? currenciesMatch
? (freightTotal ?? 0) + (fobTotal ?? 0)
: // Devises differentes : aucun total honnete n'est calculable ici, les
// deux lignes ci-dessus se suffisent.
null
: ((primaryCurrency === 'USD' ? booking.priceUSD : booking.priceEUR) ?? 0);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-brand-blue/5 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-brand-navy hover:text-brand-blue font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
</button>
<h1 className="text-2xl font-bold text-brand-navy mb-1">Paiement du forfait booking</h1>
<p className="text-neutral-500 mb-4">
Finalisez votre booking en réglant le forfait par booking
</p>
<button
onClick={() => {
const opts = (booking as any).options || {};
const enabledOptions = Object.keys(opts)
.filter(k => opts[k])
.join(',');
const params = new URLSearchParams({
editBookingId: bookingId,
origin: booking.origin || '',
destination: booking.destination || '',
volumeCBM: String(booking.volumeCBM ?? ''),
weightKG: String(booking.weightKG ?? ''),
palletCount: String(booking.palletCount ?? ''),
hasDangerousGoods: String(!!opts.dangerousGoods),
});
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced?${params.toString()}`);
}}
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-brand-navy bg-brand-blue/5 hover:bg-brand-blue/10 rounded-lg transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Modifier la réservation avant de payer
</button>
{/* 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-neutral-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-brand-blue bg-brand-blue/5'
: 'border-border bg-white hover:border-neutral-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-brand-blue/10' : 'bg-neutral-100'
}`}
>
<CreditCard
className={`h-5 w-5 ${
selectedMethod === 'card' ? 'text-brand-navy' : 'text-neutral-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-brand-navy">Carte bancaire</p>
<p className="text-sm text-neutral-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-brand-blue bg-brand-blue' : 'border-neutral-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-brand-blue bg-brand-blue/5'
: 'border-border bg-white hover:border-neutral-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-brand-blue/10' : 'bg-neutral-100'
}`}
>
<Building2
className={`h-5 w-5 ${
selectedMethod === 'transfer' ? 'text-brand-navy' : 'text-neutral-500'
}`}
/>
</div>
<div>
<p className="font-semibold text-brand-navy">Virement bancaire</p>
<p className="text-sm text-neutral-500">Validation sous 1–3 jours ouvrables</p>
</div>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
selectedMethod === 'transfer'
? 'border-brand-blue bg-brand-blue'
: 'border-neutral-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-border p-5">
<p className="text-sm text-neutral-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-brand-navy text-white rounded-lg font-semibold hover:bg-brand-navy/90 disabled:bg-neutral-300 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-border p-5 space-y-4">
<p className="text-sm text-neutral-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-neutral-50 rounded-lg divide-y divide-border 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-neutral-500">{label}</span>
<div className="flex items-center space-x-2">
<span
className={`${mono ? 'font-mono' : ''} ${bold ? 'font-bold text-brand-navy' : 'text-neutral-800'}`}
>
{value}
</span>
{key !== 'amount' && (
<button
onClick={() => copyToClipboard(value, key)}
className="text-neutral-400 hover:text-brand-navy 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-neutral-300 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-border p-6 text-center text-neutral-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-neutral-700 uppercase tracking-wide">
Récapitulatif
</h2>
<div className="bg-white rounded-xl border border-border p-5 space-y-4">
{booking.bookingNumber && (
<div className="flex justify-between text-sm">
<span className="text-neutral-500">Numéro</span>
<span className="font-semibold text-brand-navy">{booking.bookingNumber}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-neutral-500">Transporteur</span>
<span className="font-semibold text-brand-navy text-right max-w-[55%]">
{booking.carrierName}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-neutral-500">Trajet</span>
<span className="font-semibold text-brand-navy">
{booking.origin} → {booking.destination}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-neutral-500">Volume / Poids</span>
<span className="font-semibold text-brand-navy">
{booking.volumeCBM} CBM · {booking.weightKG} kg
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-neutral-500">Transit</span>
<span className="font-semibold text-brand-navy">{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-neutral-500">Fret</span>
<span className="font-semibold text-brand-navy">
{formatPrice(freightTotal, freightCurrency)}
</span>
</div>
)}
{fobTotal != null && fobTotal > 0 && (
<div className="flex justify-between text-sm">
<span className="text-neutral-500">Frais FOB</span>
<span className="font-semibold text-brand-navy">
{formatPrice(fobTotal, fobCurrency)}
</span>
</div>
)}
</>
)}
{/* Total de transport, dans la devise de ses composantes. */}
{transportTotal != null && (
<div className="flex justify-between text-sm border-t pt-3">
<span className="text-neutral-700 font-medium">Total</span>
<span className="font-bold text-brand-navy">
{formatPrice(transportTotal, transportCurrency)}
</span>
</div>
)}
<p className="text-xs text-neutral-400 pt-1">
Fret et frais FOB sont réglés directement au transporteur.
</p>
</div>
{/* Booking fee box */}
<div className="bg-brand-navy rounded-xl p-5 text-white">
<p className="text-sm text-white/70 mb-1">Forfait par booking — à régler maintenant</p>
<p className="text-3xl font-bold">{formatPrice(bookingFeeAmount, 'EUR')}</p>
<p className="text-xs text-white/70 mt-1">
Montant fixe par booking (hors fret et frais FOB)
</p>
</div>
{/* Organisation info */}
{org && (
<div className="bg-white rounded-xl border border-border p-4 space-y-2 text-sm">
<p className="font-medium text-neutral-700">{org.name}</p>
{identifier && (
<div className="flex items-center justify-between">
<span className="text-neutral-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-border 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-neutral-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>
);
}