import React from 'react' import ReactDOM from 'react-dom' import { useState, useRef, useEffect } from 'react' import { Upload, Plane, Train, Hotel, UtensilsCrossed, Car, Anchor, Calendar, ArrowLeft, X } from 'lucide-react' import type { BookingImportPreviewItem } from '@trek/shared' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { reservationsApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' interface BookingImportModalProps { isOpen: boolean onClose: () => void tripId: number pushUndo?: (label: string, undoFn: () => Promise | void) => void } const ACCEPTED_EXTS = ['.eml', '.pdf', '.pkpass', '.html', '.htm', '.txt'] const MAX_FILE_BYTES = 10 * 1024 * 1024 const MAX_FILES = 5 const TYPE_ICONS: Record> = { flight: Plane, train: Train, hotel: Hotel, restaurant: UtensilsCrossed, car: Car, cruise: Anchor, event: Calendar, } function typeColor(type: string): string { const map: Record = { flight: '#3b82f6', train: '#10b981', hotel: '#8b5cf6', restaurant: '#f59e0b', car: '#6b7280', cruise: '#06b6d4', event: '#ec4899', } return map[type] ?? 'var(--text-faint)' } function formatDateTime(iso: unknown): string { if (!iso) return '' const str = typeof iso === 'string' ? iso : typeof iso === 'object' ? JSON.stringify(iso) : String(iso) const date = str.slice(0, 10) const time = str.length > 10 ? str.slice(11, 16) : '' return [date, time].filter(Boolean).join(' ') } export default function BookingImportModal({ isOpen, onClose, tripId, pushUndo }: BookingImportModalProps) { const { t } = useTranslation() const toast = useToast() const loadTrip = useTripStore((s) => s.loadTrip) const fileInputRef = useRef(null) const mouseDownTarget = useRef(null) type Phase = 'upload' | 'preview' | 'confirming' const [phase, setPhase] = useState('upload') const [files, setFiles] = useState([]) const [isDragOver, setIsDragOver] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [previewItems, setPreviewItems] = useState([]) const [warnings, setWarnings] = useState([]) const [excluded, setExcluded] = useState>(() => new Set()) const reset = () => { setPhase('upload') setFiles([]) setIsDragOver(false) setLoading(false) setError('') setPreviewItems([]) setWarnings([]) setExcluded(new Set()) } useEffect(() => { if (isOpen) reset() // reset is stable — intentional // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]) const handleClose = () => { reset(); onClose() } const validateFile = (f: File): string | null => { const ext = ('.' + f.name.toLowerCase().split('.').pop()) as string if (!ACCEPTED_EXTS.includes(ext)) return t('reservations.import.unsupportedFormat') if (f.size > MAX_FILE_BYTES) return t('reservations.import.fileTooLarge', { name: f.name }) return null } const selectFiles = (incoming: File[]) => { const valid: File[] = [] let firstErr: string | null = null for (const f of incoming.slice(0, MAX_FILES)) { const err = validateFile(f) if (err) { firstErr = firstErr ?? err; continue } valid.push(f) } if (valid.length === 0) { setError(firstErr ?? ''); return } setFiles(valid) setError(firstErr ?? '') } const handleInputChange = (e: React.ChangeEvent) => { const list = e.target.files ? Array.from(e.target.files) : [] e.target.value = '' if (list.length) selectFiles(list) } const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true) } const handleDragLeave = (e: React.DragEvent) => { if (e.target === e.currentTarget) setIsDragOver(false) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragOver(false) const list = Array.from(e.dataTransfer.files) if (list.length) selectFiles(list) } const handleParse = async () => { if (files.length === 0 || loading) return setLoading(true) setError('') try { const result = await reservationsApi.importBookingPreview(tripId, files) setPreviewItems(result.items ?? []) setWarnings(result.warnings ?? []) setExcluded(new Set()) setPhase('preview') } catch (err: any) { const msg = err?.response?.data?.error ?? t('reservations.import.error') setError(msg) } finally { setLoading(false) } } const handleConfirm = async () => { const toImport = previewItems.filter((_, i) => !excluded.has(i)) if (toImport.length === 0) return setPhase('confirming') setError('') try { const result = await reservationsApi.importBookingConfirm(tripId, toImport) const created = result.created ?? [] await loadTrip(tripId) if (created.length > 0) { pushUndo?.(t('undo.importBooking'), async () => { try { const { reservationsApi: rApi } = await import('../../api/client') await Promise.all(created.map((r) => rApi.delete(tripId, r.id).catch(() => {}))) } catch {} await loadTrip(tripId) }) toast.success(t('reservations.import.success', { count: created.length })) } else { toast.warning(t('reservations.import.previewEmpty')) } handleClose() } catch (err: any) { setError(err?.response?.data?.error ?? t('reservations.import.error')) setPhase('preview') } } const toggleExclude = (idx: number) => { setExcluded(prev => { const next = new Set(prev) if (next.has(idx)) next.delete(idx); else next.add(idx) return next }) } const activeCount = previewItems.filter((_, i) => !excluded.has(i)).length if (!isOpen) return null return ReactDOM.createPortal(
{ mouseDownTarget.current = e.target }} onClick={e => { if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose() mouseDownTarget.current = null }} >
e.stopPropagation()} className="bg-surface-card" style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "var(--font-system)", maxHeight: '90vh', display: 'flex', flexDirection: 'column' }} > {/* Header */}
{phase === 'preview' && ( )}
{t('reservations.import.title')}
{/* Upload phase */} {phase === 'upload' && ( <>
{t('reservations.import.acceptedFormats')}
fileInputRef.current?.click()} onDragOver={handleDragOver} onDragEnter={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} className={isDragOver ? 'bg-surface-tertiary' : 'bg-transparent'} style={{ width: '100%', minHeight: 100, borderRadius: 12, border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, fontWeight: 500, cursor: 'pointer', marginBottom: 12, padding: 16, boxSizing: 'border-box', transition: 'border-color 0.15s, background 0.15s', }} > {isDragOver ? ( {t('reservations.import.dropActive')} ) : files.length > 0 ? ( {files.map(f => f.name).join(', ')} ) : ( {t('reservations.import.dropHere')} )}
)} {/* Preview phase */} {(phase === 'preview' || phase === 'confirming') && ( <>
{t('reservations.import.previewHeading', { count: previewItems.length })}
{previewItems.length === 0 && (
{t('reservations.import.previewEmpty')}
)} {previewItems.map((item, idx) => { const Icon = TYPE_ICONS[item.type] ?? Calendar const isExcluded = excluded.has(idx) const fromEp = item.endpoints?.find(e => e.role === 'from') const toEp = item.endpoints?.find(e => e.role === 'to') return (
{item.title}
{fromEp && toEp && (
{fromEp.code ?? fromEp.name} → {toEp.code ?? toEp.name}
)} {item.reservation_time && (
{formatDateTime(item.reservation_time)} {item.reservation_end_time && ` – ${formatDateTime(item.reservation_end_time)}`}
)} {item._accommodation?.check_in && (
{formatDateTime(item._accommodation.check_in)} – {formatDateTime(item._accommodation.check_out)}
)} {item.confirmation_number && (
{item.confirmation_number}
)}
) })} )} {/* Warnings */} {warnings.length > 0 && (
{warnings.join('\n')}
)} {/* Error */} {error && (
{error}
)}
{/* Footer */}
{phase === 'upload' && ( )} {(phase === 'preview' || phase === 'confirming') && ( )}
, document.body ) }