xpeditis2.0/apps/frontend/app/[locale]/carrier/documents/[token]/page.tsx
2026-05-12 21:01:52 +02:00

567 lines
19 KiB
TypeScript

'use client';
import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import {
FileText,
Download,
Loader2,
XCircle,
Package,
Ship,
Clock,
AlertCircle,
ArrowRight,
Lock,
Eye,
EyeOff,
} from 'lucide-react';
interface Document {
id: string;
type: string;
fileName: string;
mimeType: string;
size: number;
downloadUrl: string;
}
interface BookingSummary {
id: string;
bookingNumber?: string;
carrierName: string;
origin: string;
destination: string;
routeDescription: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
price: number;
currency: string;
transitDays: number;
containerType: string;
acceptedAt: string;
}
interface CarrierDocumentsData {
booking: BookingSummary;
documents: Document[];
}
interface AccessRequirements {
requiresPassword: boolean;
bookingNumber?: string;
status: string;
}
const DOCUMENT_TYPE_KEYS = [
'BILL_OF_LADING',
'PACKING_LIST',
'COMMERCIAL_INVOICE',
'CERTIFICATE_OF_ORIGIN',
'OTHER',
] as const;
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const getFileIcon = (mimeType: string) => {
if (mimeType.includes('pdf')) return '📄';
if (mimeType.includes('image')) return '🖼️';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
if (mimeType.includes('word') || mimeType.includes('document')) return '📝';
return '📎';
};
export default function CarrierDocumentsPage() {
const params = useParams();
const token = params.token as string;
const t = useTranslations('carrierPortal.documents');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<CarrierDocumentsData | null>(null);
const [downloading, setDownloading] = useState<string | null>(null);
// Password protection state
const [requirements, setRequirements] = useState<AccessRequirements | null>(null);
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [verifying, setVerifying] = useState(false);
const hasCalledApi = useRef(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
const getDocumentTypeLabel = (type: string): string => {
if ((DOCUMENT_TYPE_KEYS as readonly string[]).includes(type)) {
return t(`documentTypes.${type}` as any);
}
return type;
};
// Check access requirements first
const checkRequirements = async () => {
if (!token) {
setError(t('linkInvalid'));
setLoading(false);
return;
}
try {
const response = await fetch(
`${apiUrl}/api/v1/csv-booking-actions/documents/${token}/requirements`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || t('loadError');
if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error(t('bookingNotFound'));
}
throw new Error(errorMessage);
}
const reqData: AccessRequirements = await response.json();
setRequirements(reqData);
if (reqData.status !== 'ACCEPTED') {
setError(t('notAcceptedYet'));
setLoading(false);
return;
}
if (!reqData.requiresPassword) {
await fetchDocumentsWithoutPassword();
} else {
setLoading(false);
}
} catch (err) {
console.error('Error checking requirements:', err);
setError(err instanceof Error ? err.message : t('loadError'));
setLoading(false);
}
};
// Fetch documents without password (legacy bookings)
const fetchDocumentsWithoutPassword = async () => {
try {
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || t('loadDocsError');
if (
errorMessage.includes('pas encore été acceptée') ||
errorMessage.includes('not accepted')
) {
throw new Error(t('notAcceptedYet'));
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
throw new Error(t('bookingNotFound'));
} else if (
errorMessage.includes('Mot de passe requis') ||
errorMessage.includes('required')
) {
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
setLoading(false);
return;
}
throw new Error(errorMessage);
}
const responseData = await response.json();
setData(responseData);
setLoading(false);
} catch (err) {
console.error('Error fetching documents:', err);
setError(err instanceof Error ? err.message : t('loadError'));
setLoading(false);
}
};
// Fetch documents with password
const fetchDocumentsWithPassword = async (pwd: string) => {
setVerifying(true);
setPasswordError(null);
try {
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ password: pwd }),
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch {
errorData = { message: `HTTP ${response.status}` };
}
const errorMessage = errorData.message || t('verifyError');
if (
response.status === 401 ||
errorMessage.includes('incorrect') ||
errorMessage.includes('invalid')
) {
setPasswordError(t('passwordIncorrect'));
setVerifying(false);
return;
}
throw new Error(errorMessage);
}
const responseData = await response.json();
setData(responseData);
setVerifying(false);
} catch (err) {
console.error('Error verifying password:', err);
setPasswordError(err instanceof Error ? err.message : t('verifyError'));
setVerifying(false);
}
};
useEffect(() => {
if (hasCalledApi.current) return;
hasCalledApi.current = true;
checkRequirements();
}, [token]);
const handlePasswordSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!password.trim()) {
setPasswordError(t('passwordMissing'));
return;
}
fetchDocumentsWithPassword(password.trim());
};
const handleDownload = async (doc: Document) => {
setDownloading(doc.id);
try {
window.open(doc.downloadUrl, '_blank');
} catch (err) {
console.error('Error downloading document:', err);
alert(t('downloadError'));
} finally {
setTimeout(() => setDownloading(null), 500);
}
};
const handleRefresh = () => {
setLoading(true);
setError(null);
setData(null);
setRequirements(null);
setPassword('');
setPasswordError(null);
hasCalledApi.current = false;
checkRequirements();
};
// Loading state
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('loading')}</h1>
<p className="text-gray-600">{t('loadingHint')}</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">{t('errorTitle')}</h1>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={handleRefresh}
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
>
{t('retry')}
</button>
</div>
</div>
);
}
// Password form state
if (requirements?.requiresPassword && !data) {
return (
<div className="min-h-screen flex items-center justify-center bg-brand-gray p-4">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
<div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-brand-turquoise" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">{t('password.title')}</h1>
<p className="text-gray-600">{t('password.intro')}</p>
{requirements.bookingNumber && (
<p className="mt-2 text-sm text-gray-500">
{t('password.bookingLabel')}{' '}
<span className="font-mono font-bold">{requirements.bookingNumber}</span>
</p>
)}
</div>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
{t('password.passwordLabel')}
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
value={password}
onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder={t('password.passwordPlaceholder')}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
autoComplete="off"
autoFocus
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{passwordError && (
<p className="mt-2 text-sm text-red-600 flex items-center gap-1">
<AlertCircle className="w-4 h-4" />
{passwordError}
</p>
)}
</div>
<button
type="submit"
disabled={verifying}
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors flex items-center justify-center gap-2"
>
{verifying ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{t('password.verifying')}
</>
) : (
<>
<Lock className="w-5 h-5" />
{t('password.submit')}
</>
)}
</button>
</form>
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-sm text-amber-800">
<strong>{t('password.helpTitle')}</strong>
<br />
{t('password.helpBody')}
</p>
</div>
</div>
</div>
);
}
if (!data) return null;
const { booking, documents } = data;
return (
<div className="min-h-screen bg-brand-gray">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Image
src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={40}
height={48}
className="h-auto"
/>
</div>
<button
onClick={handleRefresh}
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
>
{t('header.refresh')}
</button>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Booking Summary Card */}
<div className="bg-white rounded-xl shadow-md overflow-hidden mb-6">
<div className="bg-gradient-to-r from-brand-navy to-brand-navy/80 px-6 py-4">
<div className="flex items-center justify-center gap-4 text-white">
<span className="text-2xl font-bold">{booking.origin}</span>
<ArrowRight className="w-6 h-6" />
<span className="text-2xl font-bold">{booking.destination}</span>
</div>
{booking.bookingNumber && (
<p className="text-center text-white/70 text-sm mt-1">
{t('summary.bookingNumberPrefix')} {booking.bookingNumber}
</p>
)}
</div>
<div className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.volume')}</p>
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.weight')}</p>
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.transit')}</p>
<p className="font-semibold text-gray-900">
{t('summary.transitDays', { count: booking.transitDays })}
</p>
</div>
<div className="text-center p-3 bg-gray-50 rounded-lg">
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
<p className="text-xs text-gray-500">{t('summary.type')}</p>
<p className="font-semibold text-gray-900">{booking.containerType}</p>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<span className="text-gray-500">
{t('summary.carrierLabel')}{' '}
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
</span>
<span className="text-gray-500">
{t('summary.refLabel')}{' '}
<span className="font-mono text-gray-900">
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
</span>
</span>
</div>
</div>
</div>
{/* Documents Section */}
<div className="bg-white rounded-xl shadow-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<FileText className="w-5 h-5 text-brand-turquoise" />
{t('list.heading', { count: documents.length })}
</h2>
</div>
{documents.length === 0 ? (
<div className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">{t('list.empty')}</p>
<p className="text-gray-500 text-sm mt-1">{t('list.emptyHint')}</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{documents.map(doc => (
<div
key={doc.id}
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-4">
<span className="text-2xl">{getFileIcon(doc.mimeType)}</span>
<div>
<p className="font-medium text-gray-900">{doc.fileName}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
{getDocumentTypeLabel(doc.type)}
</span>
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
</div>
</div>
</div>
<button
onClick={() => handleDownload(doc)}
disabled={downloading === doc.id}
className="flex items-center gap-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
>
{downloading === doc.id ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>...</span>
</>
) : (
<>
<Download className="w-4 h-4" />
<span>{t('list.download')}</span>
</>
)}
</button>
</div>
))}
</div>
)}
</div>
{/* Info */}
<p className="mt-6 text-center text-sm text-gray-500">{t('footerNote')}</p>
</main>
{/* Footer */}
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
<p>{t('footer', { year: new Date().getFullYear() })}</p>
</footer>
</div>
);
}