- Suppression de documents: cause = deleteDocument restreint aux statuts PENDING/PENDING_PAYMENT + blocage du dernier document. Alignement sur replaceDocument (aucune restriction de statut) et autorisation de 0 document (validation domaine assouplie ; creation exige toujours >=1 doc cote service). - Edition avant paiement: recalcul auto du prix. La page /booking/:id/edit relance la recherche de tarif (meme transporteur/conteneur/transit) pour le nouveau volume/poids et met a jour fret/FOB/total ; nouveaux champs de prix dans UpdateCsvBookingDetailsDto + editDetails. - Liste reservations: actions regroupees dans un menu 3 points (kebab) en positionnement fixed (anti-clipping), desktop + mobile. - Responsive (<=1280px): tables documents & blog admin en overflow-x-auto, menus documents en fixed, grilles de la page edition en sm:. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
586 lines
21 KiB
TypeScript
586 lines
21 KiB
TypeScript
/**
|
||
* Edit Booking Page (before payment)
|
||
*
|
||
* Lets the owner adjust cargo details (volume, weight, pallets, notes) and manage
|
||
* documents of a booking that is still awaiting payment (PENDING_PAYMENT). When the
|
||
* cargo changes, the price is recomputed by re-quoting the same rate (carrier +
|
||
* container + transit) for the new volume/weight. Then they can continue to payment.
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { useRouter, useParams } from 'next/navigation';
|
||
import {
|
||
ArrowLeft,
|
||
Loader2,
|
||
AlertTriangle,
|
||
Save,
|
||
CreditCard,
|
||
FileText,
|
||
Trash2,
|
||
Upload,
|
||
Package,
|
||
RefreshCw,
|
||
} from 'lucide-react';
|
||
import { getCsvBooking, updateCsvBookingDetails } from '@/lib/api/bookings';
|
||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
||
import { upload, del } from '@/lib/api/client';
|
||
|
||
interface BookingDoc {
|
||
id?: string;
|
||
type: string;
|
||
fileName: string;
|
||
url: string;
|
||
}
|
||
|
||
interface BookingData {
|
||
id: string;
|
||
bookingNumber?: string;
|
||
carrierName: string;
|
||
origin: string;
|
||
destination: string;
|
||
volumeCBM: number;
|
||
weightKG: number;
|
||
palletCount: number;
|
||
containerType: string;
|
||
transitDays: number;
|
||
status: string;
|
||
notes?: string;
|
||
primaryCurrency?: string;
|
||
priceUSD?: number;
|
||
priceEUR?: number;
|
||
freightTotal?: number;
|
||
freightCurrency?: string;
|
||
fobTotal?: number;
|
||
fobCurrency?: string;
|
||
documents: BookingDoc[];
|
||
}
|
||
|
||
interface PricingState {
|
||
freightTotal: number;
|
||
freightCurrency: string;
|
||
fobTotal: number;
|
||
fobCurrency: string;
|
||
primaryCurrency: string;
|
||
priceUSD: number;
|
||
priceEUR: number;
|
||
}
|
||
|
||
function formatPrice(price: number, currency: string) {
|
||
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price || 0);
|
||
}
|
||
|
||
export default function EditBookingPage() {
|
||
const router = useRouter();
|
||
const params = useParams();
|
||
const bookingId = params.id as string;
|
||
|
||
const [booking, setBooking] = useState<BookingData | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [success, setSuccess] = useState<string | null>(null);
|
||
|
||
const [volumeCBM, setVolumeCBM] = useState('');
|
||
const [weightKG, setWeightKG] = useState('');
|
||
const [palletCount, setPalletCount] = useState('');
|
||
const [notes, setNotes] = useState('');
|
||
|
||
const [pricing, setPricing] = useState<PricingState | null>(null);
|
||
const [repricing, setRepricing] = useState(false);
|
||
const [priceWarning, setPriceWarning] = useState<string | null>(null);
|
||
|
||
const [uploadingDocs, setUploadingDocs] = useState(false);
|
||
const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const applyBooking = (data: BookingData) => {
|
||
setBooking(data);
|
||
setVolumeCBM(String(data.volumeCBM ?? ''));
|
||
setWeightKG(String(data.weightKG ?? ''));
|
||
setPalletCount(String(data.palletCount ?? ''));
|
||
setNotes(data.notes ?? '');
|
||
setPricing({
|
||
freightTotal: data.freightTotal ?? 0,
|
||
freightCurrency: data.freightCurrency ?? data.primaryCurrency ?? 'USD',
|
||
fobTotal: data.fobTotal ?? 0,
|
||
fobCurrency: data.fobCurrency ?? 'EUR',
|
||
primaryCurrency: data.primaryCurrency ?? 'USD',
|
||
priceUSD: data.priceUSD ?? 0,
|
||
priceEUR: data.priceEUR ?? 0,
|
||
});
|
||
};
|
||
|
||
const loadBooking = async () => {
|
||
try {
|
||
const data = (await getCsvBooking(bookingId)) as any as BookingData;
|
||
if (data.status !== 'PENDING_PAYMENT') {
|
||
// Only bookings awaiting payment can be edited.
|
||
router.replace('/dashboard/bookings');
|
||
return;
|
||
}
|
||
applyBooking(data);
|
||
} catch (err) {
|
||
setError('Impossible de charger la réservation');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (bookingId) loadBooking();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [bookingId]);
|
||
|
||
// Recompute the price by re-quoting the same rate for the new volume/weight.
|
||
// Returns the new pricing, or null if no matching rate could be found.
|
||
const recomputePricing = async (vol: number, wgt: number): Promise<PricingState | null> => {
|
||
if (!booking) return null;
|
||
const response = await searchCsvRatesWithOffers({
|
||
origin: booking.origin,
|
||
destination: booking.destination,
|
||
volumeCBM: vol,
|
||
weightKG: wgt,
|
||
hasDangerousGoods: false,
|
||
});
|
||
const results = response.results || [];
|
||
const match =
|
||
results.find(
|
||
(r: any) =>
|
||
r.companyName === booking.carrierName &&
|
||
r.containerType === booking.containerType &&
|
||
(r.adjustedTransitDays ?? r.transitDays) === booking.transitDays
|
||
) ||
|
||
results.find(
|
||
(r: any) =>
|
||
r.companyName === booking.carrierName && r.containerType === booking.containerType
|
||
);
|
||
|
||
if (!match) return null;
|
||
|
||
const pb = match.priceBreakdown;
|
||
const freightTotal = pb.totalFreight;
|
||
const freightCurrency = pb.freightCurrency;
|
||
const fobTotal = pb.totalFob;
|
||
const fobCurrency = pb.fobCurrency;
|
||
return {
|
||
freightTotal,
|
||
freightCurrency,
|
||
fobTotal,
|
||
fobCurrency,
|
||
primaryCurrency: pb.primaryCurrency || 'USD',
|
||
priceUSD:
|
||
(freightCurrency === 'USD' ? freightTotal : 0) + (fobCurrency === 'USD' ? fobTotal : 0),
|
||
priceEUR:
|
||
(freightCurrency === 'EUR' ? freightTotal : 0) + (fobCurrency === 'EUR' ? fobTotal : 0),
|
||
};
|
||
};
|
||
|
||
// Debounced auto-recompute when volume/weight change (price depends on both).
|
||
useEffect(() => {
|
||
if (!booking) return;
|
||
const vol = parseFloat(volumeCBM);
|
||
const wgt = parseFloat(weightKG);
|
||
if (!Number.isFinite(vol) || vol <= 0 || !Number.isFinite(wgt) || wgt <= 0) return;
|
||
|
||
const handle = setTimeout(async () => {
|
||
setRepricing(true);
|
||
setPriceWarning(null);
|
||
try {
|
||
const next = await recomputePricing(vol, wgt);
|
||
if (next) {
|
||
setPricing(next);
|
||
} else {
|
||
setPriceWarning(
|
||
'Tarif introuvable pour cette route — le prix affiché reste celui d’origine.'
|
||
);
|
||
}
|
||
} catch {
|
||
setPriceWarning('Impossible de recalculer le prix pour le moment.');
|
||
} finally {
|
||
setRepricing(false);
|
||
}
|
||
}, 600);
|
||
|
||
return () => clearTimeout(handle);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [volumeCBM, weightKG, booking]);
|
||
|
||
const persistDetails = async (): Promise<boolean> => {
|
||
setError(null);
|
||
const vol = parseFloat(volumeCBM);
|
||
const wgt = parseFloat(weightKG);
|
||
const plt = parseInt(palletCount, 10);
|
||
|
||
if (!Number.isFinite(vol) || vol <= 0) {
|
||
setError('Le volume doit être un nombre positif.');
|
||
return false;
|
||
}
|
||
if (!Number.isFinite(wgt) || wgt <= 0) {
|
||
setError('Le poids doit être un nombre positif.');
|
||
return false;
|
||
}
|
||
if (!Number.isFinite(plt) || plt < 0) {
|
||
setError('Le nombre de palettes est invalide.');
|
||
return false;
|
||
}
|
||
|
||
const updated = (await updateCsvBookingDetails(bookingId, {
|
||
volumeCBM: vol,
|
||
weightKG: wgt,
|
||
palletCount: plt,
|
||
notes: notes.trim() || undefined,
|
||
...(pricing
|
||
? {
|
||
priceUSD: pricing.priceUSD,
|
||
priceEUR: pricing.priceEUR,
|
||
primaryCurrency: pricing.primaryCurrency,
|
||
freightTotal: pricing.freightTotal,
|
||
freightCurrency: pricing.freightCurrency,
|
||
fobTotal: pricing.fobTotal,
|
||
fobCurrency: pricing.fobCurrency,
|
||
}
|
||
: {}),
|
||
})) as any as BookingData;
|
||
applyBooking(updated);
|
||
return true;
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
setSuccess(null);
|
||
try {
|
||
const ok = await persistDetails();
|
||
if (ok) setSuccess('Modifications enregistrées.');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleContinueToPayment = async () => {
|
||
setSaving(true);
|
||
setSuccess(null);
|
||
try {
|
||
const ok = await persistDetails();
|
||
if (ok) router.push(`/dashboard/booking/${bookingId}/pay`);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleAddDocuments = async (files: FileList | null) => {
|
||
if (!files || files.length === 0) return;
|
||
setUploadingDocs(true);
|
||
setError(null);
|
||
try {
|
||
const formData = new FormData();
|
||
Array.from(files).forEach(file => formData.append('documents', file));
|
||
await upload(`/api/v1/csv-bookings/${bookingId}/documents`, formData);
|
||
await loadBooking();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Erreur lors de l'ajout des documents");
|
||
} finally {
|
||
setUploadingDocs(false);
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const handleDeleteDocument = async (docId?: string) => {
|
||
if (!docId) return;
|
||
if (!confirm('Supprimer ce document ?')) return;
|
||
setDeletingDocId(docId);
|
||
setError(null);
|
||
try {
|
||
await del(`/api/v1/csv-bookings/${bookingId}/documents/${docId}`);
|
||
await loadBooking();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Erreur lors de la suppression du document');
|
||
} finally {
|
||
setDeletingDocId(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 (!booking) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 px-4">
|
||
<div className="bg-white rounded-xl shadow-md p-8 max-w-md w-full">
|
||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||
<p className="text-center text-gray-700">{error || 'Réservation introuvable'}</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 réservations
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const transportTotalUSD = (pricing?.priceUSD ?? 0) + (pricing?.priceEUR ?? 0);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-6 sm:py-10 px-4">
|
||
<div className="max-w-4xl mx-auto">
|
||
<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">Modifier la réservation</h1>
|
||
<p className="text-gray-500 mb-8">
|
||
Corrigez les informations de votre réservation avant de procéder au paiement. Le prix est
|
||
recalculé automatiquement.
|
||
</p>
|
||
|
||
{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>
|
||
)}
|
||
{success && (
|
||
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
|
||
<p className="text-green-700 text-sm">{success}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Read-only rate summary */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
|
||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||
Réservation
|
||
</h2>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||
<div>
|
||
<p className="text-gray-500">Transporteur</p>
|
||
<p className="font-semibold text-gray-900 break-words">{booking.carrierName}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">Trajet</p>
|
||
<p className="font-semibold text-gray-900">
|
||
{booking.origin} → {booking.destination}
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">Type</p>
|
||
<p className="font-semibold text-gray-900">{booking.containerType}</p>
|
||
</div>
|
||
{booking.bookingNumber && (
|
||
<div>
|
||
<p className="text-gray-500">Numéro</p>
|
||
<p className="font-semibold text-gray-900">{booking.bookingNumber}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-3">
|
||
Le transporteur et le trajet proviennent du tarif sélectionné et ne sont pas modifiables
|
||
ici.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Editable cargo details */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6 space-y-4">
|
||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
|
||
<Package className="h-4 w-4" />
|
||
Caractéristiques de la marchandise
|
||
</h2>
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Volume (CBM)</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={volumeCBM}
|
||
onChange={e => setVolumeCBM(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Poids (kg)</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
value={weightKG}
|
||
onChange={e => setWeightKG(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Palettes</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
step="1"
|
||
value={palletCount}
|
||
onChange={e => setPalletCount(e.target.value)}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||
<textarea
|
||
rows={3}
|
||
value={notes}
|
||
onChange={e => setNotes(e.target.value)}
|
||
placeholder="Instructions particulières..."
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Price summary (recomputed) */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide">
|
||
Prix estimé
|
||
</h2>
|
||
{repricing && (
|
||
<span className="inline-flex items-center gap-1.5 text-xs text-blue-600">
|
||
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||
Recalcul...
|
||
</span>
|
||
)}
|
||
</div>
|
||
{priceWarning && (
|
||
<div className="mb-3 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||
{priceWarning}
|
||
</div>
|
||
)}
|
||
{pricing && (
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<p className="text-gray-500 text-xs mb-0.5">Fret</p>
|
||
<p className="font-semibold text-gray-900">
|
||
{formatPrice(pricing.freightTotal, pricing.freightCurrency)}
|
||
</p>
|
||
</div>
|
||
<div className="bg-gray-50 rounded-lg p-3">
|
||
<p className="text-gray-500 text-xs mb-0.5">Frais FOB</p>
|
||
<p className="font-semibold text-gray-900">
|
||
{formatPrice(pricing.fobTotal, pricing.fobCurrency)}
|
||
</p>
|
||
</div>
|
||
<div className="bg-blue-50 rounded-lg p-3">
|
||
<p className="text-blue-500 text-xs mb-0.5">Total transport (USD)</p>
|
||
<p className="font-bold text-blue-900">{formatPrice(transportTotalUSD, 'USD')}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-gray-400 mt-3">
|
||
Fret et frais FOB sont réglés directement au transporteur. Le forfait Xpeditis reste
|
||
inchangé.
|
||
</p>
|
||
</div>
|
||
|
||
{/* Documents */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
|
||
<div className="flex items-center justify-between mb-3 gap-2 flex-wrap">
|
||
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
|
||
<FileText className="h-4 w-4" />
|
||
Documents
|
||
</h2>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={uploadingDocs}
|
||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors disabled:opacity-50"
|
||
>
|
||
{uploadingDocs ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Upload className="h-4 w-4" />
|
||
)}
|
||
Ajouter
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
className="hidden"
|
||
onChange={e => handleAddDocuments(e.target.files)}
|
||
/>
|
||
</div>
|
||
{booking.documents.length === 0 ? (
|
||
<p className="text-sm text-gray-400">Aucun document.</p>
|
||
) : (
|
||
<ul className="divide-y divide-gray-100">
|
||
{booking.documents.map((doc, idx) => (
|
||
<li
|
||
key={doc.id || `${doc.fileName}-${idx}`}
|
||
className="flex items-center justify-between py-2.5 gap-3"
|
||
>
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<FileText className="h-4 w-4 text-gray-400 flex-shrink-0" />
|
||
<span className="text-sm text-gray-800 truncate">{doc.fileName}</span>
|
||
</div>
|
||
{doc.id && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleDeleteDocument(doc.id)}
|
||
disabled={deletingDocId === doc.id}
|
||
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors disabled:opacity-50 flex-shrink-0"
|
||
title="Supprimer"
|
||
>
|
||
{deletingDocId === doc.id ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Trash2 className="h-4 w-4" />
|
||
)}
|
||
</button>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex flex-col sm:flex-row gap-3">
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
className="flex-1 py-3 bg-white border border-gray-300 text-gray-800 rounded-lg font-semibold hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
|
||
>
|
||
{saving ? <Loader2 className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
|
||
Enregistrer les modifications
|
||
</button>
|
||
<button
|
||
onClick={handleContinueToPayment}
|
||
disabled={saving}
|
||
className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
|
||
>
|
||
{saving ? (
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
) : (
|
||
<CreditCard className="h-5 w-5" />
|
||
)}
|
||
Continuer vers le paiement
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|