// Property detail page with 3 tabs function PropertyDetailPage({ propertyId, host, state, setState, onBack, showToast, reloadData }) { const [tab, setTab] = React.useState("info"); const [qrOpen, setQrOpen] = React.useState(false); const [dragOver, setDragOver] = React.useState(false); const [saving, setSaving] = React.useState(false); const [addSectionOpen, setAddSectionOpen] = React.useState(false); const [newSectionLabel, setNewSectionLabel] = React.useState(""); const [renamingKey, setRenamingKey] = React.useState(null); const [renameValue, setRenameValue] = React.useState(""); const fileInputRef = React.useRef(null); const [editOpen, setEditOpen] = React.useState(false); const [editName, setEditName] = React.useState(""); const [editAddress, setEditAddress] = React.useState(""); const [editComune, setEditComune] = React.useState(""); const [editRooms, setEditRooms] = React.useState(""); const [editPhotoFile, setEditPhotoFile] = React.useState(null); const [editPhotoPreview, setEditPhotoPreview] = React.useState(null); // Tracciamo gli object URL creati da File per revocarli quando vengono // sostituiti o quando il componente viene smontato. Senza questo i blob // restano in memoria fino al refresh della pagina. const editPhotoBlobUrl = React.useRef(null); React.useEffect(() => () => { if (editPhotoBlobUrl.current) URL.revokeObjectURL(editPhotoBlobUrl.current); }, []); const _setPhotoFromFile = (f) => { if (editPhotoBlobUrl.current) URL.revokeObjectURL(editPhotoBlobUrl.current); if (f) { const url = URL.createObjectURL(f); editPhotoBlobUrl.current = url; setEditPhotoFile(f); setEditPhotoPreview(url); } else { editPhotoBlobUrl.current = null; setEditPhotoFile(null); setEditPhotoPreview(null); } }; const [editSaving, setEditSaving] = React.useState(false); const editPhotoRef = React.useRef(null); const [chatMessages, setChatMessages] = React.useState([]); const [chatInput, setChatInput] = React.useState(""); const [chatLoading, setChatLoading] = React.useState(false); const [deleteStep, setDeleteStep] = React.useState(null); // null | "confirm" | "code" const [deleteCode, setDeleteCode] = React.useState(""); const [deleteLoading, setDeleteLoading] = React.useState(false); const [sessionTab, setSessionTab] = React.useState("all"); const [openingSchedina, setOpeningSchedina] = React.useState(null); const [drawerSessionId, setDrawerSessionId] = React.useState(null); const [regeneratingCheckinLink, setRegeneratingCheckinLink] = React.useState(null); // Mappa sessionId -> url del link generato (resettata alla chiusura del drawer) const [generatedCheckinLinks, setGeneratedCheckinLinks] = React.useState({}); // Lock per azioni-bottone: previene doppio click che genera doppia API call // (specialmente approva/rifiuta/revoca dove un secondo invio rompe lo stato). const sessionActionInflight = React.useRef(new Set()); const [toggleCheckinPending, setToggleCheckinPending] = React.useState(false); // PayTourist config const [ptSystems, setPtSystems] = React.useState([]); const [ptMatchedSystem, setPtMatchedSystem] = React.useState(null); const [ptStructures, setPtStructures] = React.useState([]); const [ptSystemId, setPtSystemId] = React.useState(null); const [ptStructureId, setPtStructureId] = React.useState(null); const [ptSaving, setPtSaving] = React.useState(false); const [ptLoadingStructures, setPtLoadingStructures] = React.useState(false); const [ptError, setPtError] = React.useState(null); const [ptValidating, setPtValidating] = React.useState(false); // Token per-property (inserito inline) const [ptTokenInput, setPtTokenInput] = React.useState(""); const [ptSavingToken, setPtSavingToken] = React.useState(false); const [ptShowToken, setPtShowToken] = React.useState(false); // Modalità sostituzione token (quando token già configurato) const [ptReplaceMode, setPtReplaceMode] = React.useState(false); const [ptReplaceInput, setPtReplaceInput] = React.useState(""); const [ptReplaceShow, setPtReplaceShow] = React.useState(false); const openEdit = () => { setEditName(property.name); setEditAddress(property.address || ""); setEditComune(property.comune || ""); setEditRooms(property.rooms != null ? String(property.rooms) : ""); // Reset di eventuale blob URL della sessione precedente, poi mostra foto attuale _setPhotoFromFile(null); setEditPhotoPreview(property.photo_url || null); setEditOpen(true); }; const saveEdit = async () => { if (!editName.trim()) return; setEditSaving(true); try { await window.API.patchProperty(propertyId, { name: editName.trim(), address: editAddress.trim() || null, comune: editComune.trim() || null, rooms: editRooms ? parseInt(editRooms, 10) : null, }); if (editPhotoFile) await window.API.uploadPhoto(propertyId, editPhotoFile); showToast("Struttura aggiornata"); setEditOpen(false); await reloadData(); } catch (err) { showToast(err.message || "Errore", "error"); } finally { setEditSaving(false); } }; const property = state.properties.find(p => p.id === propertyId); if (!property) return null; // Carica sistemi PayTourist per host pro/business (sempre, non solo se checkin abilitato) React.useEffect(() => { if (host?.plan === "pro" || host?.plan === "business") { setPtSystemId(property.paytourist_system_id || null); setPtStructureId(property.paytourist_structure_id || null); window.API.listPaytouristSystems().then(setPtSystems).catch(() => {}); } }, [propertyId, host?.plan]); // Auto-detect sistema dal comune dell'indirizzo React.useEffect(() => { if (!ptSystems.length || !property?.address) return; const matched = _detectPtSystem(property.address, ptSystems); setPtMatchedSystem(matched || null); // Pre-seleziona il sistema rilevato solo se non già configurato if (matched && !property.paytourist_system_id) { setPtSystemId(matched.id); } }, [ptSystems, property?.address]); React.useEffect(() => { if (!ptSystemId || !property?.paytourist_token_configured) { setPtStructures([]); setPtError(null); return; } setPtLoadingStructures(true); setPtError(null); window.API.listPaytouristStructures(propertyId, ptSystemId) .then(structs => { setPtStructures(structs); setPtError(null); }) .catch(err => { setPtStructures([]); setPtError(err.message || "Errore nel recupero strutture PayTourist"); }) .finally(() => setPtLoadingStructures(false)); }, [ptSystemId, property?.paytourist_token_configured]); const updateProperty = (updater) => { setState(s => ({ ...s, properties: s.properties.map(p => p.id === propertyId ? updater(p) : p), })); }; const infoFields = [ { key: "descrizione", label: "Descrizione", placeholder: "Appartamento luminoso al secondo piano…" }, { key: "checkin", label: "Istruzioni check-in", placeholder: "Check-in dalle 15:00. Le chiavi si trovano nella cassetta…" }, { key: "checkout", label: "Istruzioni check-out", placeholder: "Check-out entro le 10:00. Riponi le chiavi…" }, { key: "regole", label: "Regole della casa", placeholder: "Vietato fumare. No feste. Silenzio dopo le 22:00…" }, { key: "wifi", label: "Informazioni Wi-Fi", placeholder: "Rete: CasaXYZ_Guest\nPassword: benvenuto2026" }, { key: "servizi", label: "Servizi e dotazioni", placeholder: "Aria condizionata, lavatrice, asciugamani e lenzuola incluse…" }, { key: "faq", label: "Domande frequenti (FAQ)", placeholder: "Q: C'è un parcheggio?\nA: Consigliamo il garage in Via …" }, { key: "note", label: "Note aggiuntive", placeholder: "Consigli, suggerimenti locali, contatti per emergenze…" }, ]; const setInfoField = (key, val) => { updateProperty(p => ({ ...p, info: { ...p.info, [key]: val } })); }; const customSections = property.customSections || []; const addCustomSection = () => { const label = newSectionLabel.trim(); if (!label) return; const key = "custom_" + Date.now(); updateProperty(p => ({ ...p, customSections: [...(p.customSections || []), { key, label }], info: { ...p.info, [key]: "" }, })); setNewSectionLabel(""); setAddSectionOpen(false); showToast(`Sezione "${label}" aggiunta`); }; const renameCustomSection = (key, newLabel) => { const label = newLabel.trim(); if (!label) return; updateProperty(p => ({ ...p, customSections: (p.customSections || []).map(s => s.key === key ? { ...s, label } : s), })); setRenamingKey(null); }; const deleteCustomSection = (key) => { updateProperty(p => { const { [key]: _, ...restInfo } = p.info; return { ...p, customSections: (p.customSections || []).filter(s => s.key !== key), info: restInfo }; }); showToast("Sezione eliminata"); }; // --- Salva informazioni --- const saveInfo = async () => { setSaving(true); try { await window.API.updateProperty(propertyId, property.info, property.customSections); showToast("Informazioni salvate · chatbot aggiornato"); } catch (err) { showToast(err.message || "Errore nel salvataggio", "error"); } finally { setSaving(false); } }; // --- Check-in digitale --- const toggleCheckin = async () => { if (toggleCheckinPending) return; setToggleCheckinPending(true); const newVal = !property.checkin_enabled; try { await window.API.patchProperty(propertyId, { checkin_enabled: newVal }); updateProperty(p => ({ ...p, checkin_enabled: newVal })); showToast(newVal ? "Check-in digitale abilitato" : "Check-in digitale disabilitato"); } catch (err) { showToast(err.message || "Errore", "error"); } finally { setToggleCheckinPending(false); } }; const downloadCheckinForm = async (sid, guestName) => { try { await window.API.downloadCheckinForm(propertyId, sid, guestName); } catch (err) { showToast(err.message || "Errore download form", "error"); } }; const openSchedina = async (sid) => { setOpeningSchedina(sid); try { const { url } = await window.API.getSchedinaLink(sid); window.open(url, "_blank"); } catch (err) { showToast(err.message || "Schedina non disponibile", "error"); } finally { setOpeningSchedina(null); } }; const regenerateCheckinLink = async (sid) => { setRegeneratingCheckinLink(sid); try { const { url } = await window.API.regenerateCheckinLink(sid); await navigator.clipboard.writeText(url).catch(() => {}); setGeneratedCheckinLinks(prev => ({ ...prev, [sid]: url })); showToast("Link check-in copiato negli appunti!"); } catch (err) { showToast(err.message || "Errore generazione link", "error"); } finally { setRegeneratingCheckinLink(null); } }; // --- Sessioni --- // Wrapper inflight: la chiave è "verbo:sid" per non bloccare azioni diverse // sulla stessa sessione (improbabile, ma evita stallo). Garantisce idempotenza // lato UI quando l'host clicca due volte "Approva" prima del reload. const _runSessionAction = async (key, fn) => { if (sessionActionInflight.current.has(key)) return; sessionActionInflight.current.add(key); try { await fn(); } catch (err) { showToast(err.message || "Errore", "error"); } finally { sessionActionInflight.current.delete(key); } }; const approveSession = (sid) => _runSessionAction(`approve:${sid}`, async () => { const session = property.sessions.find(s => s.id === sid); await window.API.approveSession(propertyId, sid, session?._checkoutIso); showToast("Accesso approvato · ospite notificato"); await reloadData(); }); const rejectSession = (sid) => _runSessionAction(`reject:${sid}`, async () => { await window.API.rejectSession(propertyId, sid); showToast("Accesso rifiutato"); await reloadData(); }); const revokeSession = (sid) => _runSessionAction(`revoke:${sid}`, async () => { await window.API.revokeSession(propertyId, sid); showToast("Accesso revocato"); await reloadData(); }); // --- Documenti --- const handleFiles = async (files) => { const file = files[0]; if (!file) return; try { const doc = await window.API.uploadDocument(propertyId, file); showToast(`"${file.name}" caricato · indicizzazione in corso`); updateProperty(p => ({ ...p, documents: [doc, ...p.documents] })); } catch (err) { showToast(err.message || "Errore upload", "error"); } }; const deleteDoc = async (docName) => { try { await window.API.deleteDocument(propertyId, docName); updateProperty(p => ({ ...p, documents: p.documents.filter(d => d.name !== docName) })); showToast("Documento eliminato"); } catch (err) { showToast(err.message || "Errore", "error"); } }; // --- Chat test --- const sendTestMessage = async () => { const text = chatInput.trim(); if (!text || chatLoading) return; setChatInput(""); const userMsg = { role: "user", content: text }; setChatMessages(prev => [...prev, userMsg]); setChatLoading(true); try { const history = chatMessages.map(m => ({ role: m.role === "user" ? "user" : "model", parts: [m.content], })); const { reply } = await window.API.testChat(propertyId, text, history); setChatMessages(prev => [...prev, { role: "assistant", content: reply }]); } catch (err) { setChatMessages(prev => [...prev, { role: "assistant", content: `Errore: ${err.message}` }]); } finally { setChatLoading(false); } }; // --- Elimina struttura --- const requestDelete = async () => { setDeleteLoading(true); try { await window.API.requestDeleteProperty(propertyId); setDeleteStep("code"); setDeleteCode(""); } catch (err) { showToast(err.message || "Errore nell'invio del codice", "error"); } finally { setDeleteLoading(false); } }; const confirmDelete = async () => { if (deleteCode.length !== 6) return; setDeleteLoading(true); try { await window.API.confirmDeleteProperty(propertyId, deleteCode); showToast("Struttura eliminata"); onBack(); await reloadData(); } catch (err) { showToast(err.message || "Codice non valido", "error"); } finally { setDeleteLoading(false); } }; // --- QR Code --- const downloadQr = async () => { try { await window.API.downloadQr(propertyId, property.name); showToast("QR Code scaricato"); setQrOpen(false); } catch (err) { showToast(err.message || "Errore download QR", "error"); } }; return (
{/* Header */}
{property.photo_url && ( {property.name} )}

{property.name}

{property.address || "Nessun indirizzo"} {property.comune && · {property.comune}}

{/* Tabs */}
setTab("info")}> Informazioni
setTab("docs")}> Documenti{" "} {property.documents.length}
setTab("sessions")}> Sessioni ospiti{" "} {property.sessions.length}
setTab("chat")}> Test chatbot
{/* INFO TAB — 3 card siblings + card adempimenti + footer */} {tab === "info" && (
{/* Card 1: Identità & accoglienza */}

Identità & accoglienza

Come si presenta la casa e cosa l'ospite trova all'arrivo.

{/* Descrizione — intera riga */}