// Pagina schedina PayTourist — accessibile via magic link senza login // URL: /schedina/ function ScedinaPage({ token }) { const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [saving, setSaving] = React.useState(false); const [submitting, setSubmitting] = React.useState(false); const [submitDone, setSubmitDone] = React.useState(false); const [toast, setToast] = React.useState(null); // Lookup tables const [nations, setNations] = React.useState([]); const [docTypes, setDocTypes] = React.useState([]); const [guestTypes, setGuestTypes] = React.useState([]); const [reductions, setReductions] = React.useState([]); // Stato locale per le modifiche ai campi const [sendEmail, setSendEmail] = React.useState(false); const [guestEmail, setGuestEmail] = React.useState(""); const [guests, setGuests] = React.useState([]); const showToast = (msg, type = "success") => { setToast({ msg, type }); setTimeout(() => setToast(null), 3500); }; // --------------------------------------------------------------------------- // Load schedina // --------------------------------------------------------------------------- const [tokenExpired, setTokenExpired] = React.useState(false); React.useEffect(() => { setLoading(true); window.API.getSchedina(token) .then(d => { setData(d); setSendEmail(d.send_receipt_email || false); setGuestEmail(d.guest_email || ""); // Difesa contro payload anomalo: una schedina senza ospiti non dovrebbe // arrivare ma se succede meglio una pagina vuota che un crash. setGuests((d.guests || []).map(g => ({ ...g }))); if (d.status === "submitted") setSubmitDone(true); }) .catch(err => { if (err.status === 401 || err.status === 410 || err.status === 404) { setTokenExpired(true); } else { setError(err.message || "Errore nel caricamento della schedina"); } }) .finally(() => setLoading(false)); }, [token]); // Carica lookup dopo aver ottenuto la schedina React.useEffect(() => { if (!data) return; const load = (tipo, setter) => window.API.getSchedinaLookup(token, tipo).then(setter).catch(() => {}); load("nations", setNations); load("documents-types", setDocTypes); load("guests-types", setGuestTypes); load("reductions", setReductions); }, [data]); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const updateGuest = (idx, field, value) => { setGuests(prev => prev.map((g, i) => i === idx ? { ...g, [field]: value } : g)); }; const isSubmittable = () => { if (!data || data.status === "submitted") return false; return guests.every(g => g.pt_type_id && g.pt_nationality); }; const fmtDate = (iso) => { if (!iso) return "n.d."; return new Date(iso + "T00:00:00").toLocaleDateString("it-IT", { day: "2-digit", month: "short", year: "numeric" }); }; // --------------------------------------------------------------------------- // Save & Submit // --------------------------------------------------------------------------- const _emailValid = (e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); // Esegue il salvataggio senza toast: usato sia dal bottone "Salva bozza" (che mostra // il toast) sia da "Invia" (che vuole un comportamento atomico: se save fallisce, // submit non parte). Se "Invia ricevuta" è attivo serve un'email valida. const _persist = async () => { const email = guestEmail.trim(); if (sendEmail && !_emailValid(email)) { throw new Error("Inserisci un'email valida per inviare la ricevuta all'ospite."); } await window.API.updateSchedina(token, { send_receipt_email: sendEmail, guest_email: email || null, guests: guests.map(g => ({ checkin_record_id: g.id, pt_type_id: g.pt_type_id || null, pt_sex: g.pt_sex || null, pt_nationality: g.pt_nationality || null, pt_residence_country: g.pt_residence_country || null, pt_residence_city: g.pt_residence_city || null, pt_birth_country: g.pt_birth_country || null, pt_birth_city: g.pt_birth_city || null, pt_document_type_id: g.pt_document_type_id || null, pt_document_released_by_country: g.pt_document_released_by_country || null, pt_document_released_by_city: g.pt_document_released_by_city || null, pt_tax_refused: g.pt_tax_refused != null ? g.pt_tax_refused : null, pt_reduction_id: g.pt_reduction_id || null, })), }); }; const handleSave = async () => { setSaving(true); try { await _persist(); showToast("Modifiche salvate"); } catch (err) { showToast(err.message || "Errore nel salvataggio", "error"); } finally { setSaving(false); } }; const handleSubmit = async () => { if (!window.confirm("Inviare la schedina a PayTourist? I documenti verranno eliminati dopo l'invio.")) return; setSubmitting(true); try { await _persist(); await window.API.submitSchedina(token); setSubmitDone(true); setData(d => ({ ...d, status: "submitted" })); showToast("Schedina inviata con successo!"); } catch (err) { showToast(err.message || "Errore nell'invio", "error"); } finally { setSubmitting(false); } }; // --------------------------------------------------------------------------- // Render states // --------------------------------------------------------------------------- if (loading) { return (
Argo
Caricamento schedina…
); } if (tokenExpired) { return (
⏱
Link scaduto
Il link per questa schedina è valido 72 ore dall'invio dell'email.
Per aprire la schedina, accedi al tuo dashboard Argo, vai nella scheda della struttura, apri il tab Sessioni ospiti e clicca il bottone Schedina accanto alla sessione.
Vai al dashboard
); } if (error) { return (
🔒
Link non valido
{error}
); } const isReadonly = submitDone || data?.status === "submitted"; // --------------------------------------------------------------------------- // Main render // --------------------------------------------------------------------------- return (
{/* Toast */} {toast && (
{toast.msg}
)} {/* Header */}
Argo
Schedina PayTourist
{data?.property_name || "n.d."}
{data?.guest_name} · {fmtDate(data?.check_in_date)} → {fmtDate(data?.check_out_date)}
{/* Status banner */} {isReadonly && (
✅
Schedina inviata con successo
La schedina è stata trasmessa a PayTourist. I documenti degli ospiti sono stati eliminati.
)} {data?.status === "failed" && !isReadonly && (
⚠ Invio fallito ({data.retry_count}/3 tentativi)
{data.error_message &&
{data.error_message}
}
)} {/* Info riepilogo */}
{[ { label: "Ospite principale", value: data?.guest_name }, { label: "Check-in", value: fmtDate(data?.check_in_date) }, { label: "Check-out", value: fmtDate(data?.check_out_date) }, { label: "Ospiti", value: `${data?.adults} adult${data?.adults === 1 ? "o" : "i"}${data?.children ? `, ${data?.children} bambin${data?.children === 1 ? "o" : "i"}` : ""}` }, ].map(k => (
{k.label}
{k.value || "n.d."}
))}
{/* Campi schedina globali */}

Dati prenotazione

Invia ricevuta via email all'ospite
{sendEmail && (
setGuestEmail(e.target.value)} placeholder="ospite@email.com" disabled={isReadonly} />
Indirizzo a cui inviare la ricevuta della tassa di soggiorno.
)}
{/* Ospiti */}
{guests.map((g, idx) => ( updateGuest(idx, field, value)} /> ))}
{/* Footer azioni — sticky su mobile */} {!isReadonly && (
)} {!isReadonly && !isSubmittable() && (
Compila almeno Tipo ospite e Nazionalità per ogni ospite per poter inviare.
)}
); } // --------------------------------------------------------------------------- // GuestCard // --------------------------------------------------------------------------- function CityAutocomplete({ label, field, value, onChange, readonly, token }) { const [query, setQuery] = React.useState(""); const [suggestions, setSuggestions] = React.useState([]); const [open, setOpen] = React.useState(false); const debounceRef = React.useRef(null); const wrapRef = React.useRef(null); // Risolve il nome dal ID al mount (se il valore è già impostato) React.useEffect(() => { if (value != null && query === "") { window.API.getSchedinaLookup(token, "cities", { id: value }) .then(res => { if (res[0]) setQuery(res[0].name); }) .catch(() => {}); } }, [value]); // Click fuori → chiudi dropdown React.useEffect(() => { const handler = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const handleInput = (e) => { const q = e.target.value; setQuery(q); onChange(null); // deseleziona finché non si sceglie di nuovo clearTimeout(debounceRef.current); if (q.length >= 2) { debounceRef.current = setTimeout(() => { window.API.getSchedinaLookup(token, "cities", { q }) .then(res => { setSuggestions(res); setOpen(true); }) .catch(() => {}); }, 280); } else { setSuggestions([]); setOpen(false); } }; const select = (city) => { setQuery(city.name); onChange(city.id); setSuggestions([]); setOpen(false); }; const LABEL_STYLE = { display: "block", fontSize: 12, fontWeight: 600, color: "var(--gray-600)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.04em" }; const [isMobileSmall, setIsMobileSmall] = React.useState(window.innerWidth <= 640); React.useEffect(() => { const handler = () => setIsMobileSmall(window.innerWidth <= 640); window.addEventListener("resize", handler, { passive: true }); return () => window.removeEventListener("resize", handler); }, []); return (
suggestions.length > 0 && setOpen(true)} disabled={readonly} placeholder="Cerca comune…" style={{ fontSize: 16, minHeight: 44 }} autoComplete="off" /> {open && suggestions.length > 0 && (
{suggestions.map(c => (
select(c)} style={{ padding: "10px 14px", fontSize: 14, cursor: "pointer", borderBottom: "1px solid var(--border-subtle)" }} > {c.name}
))}
)}
); } // Hoisted da dentro GuestCard: definirli inline causava un nuovo `type` a ogni render, // remountando ogni onChange(field, e.target.value ? (isNaN(e.target.value) ? e.target.value : parseInt(e.target.value, 10)) : null)} disabled={readonly} style={{ fontSize: 16, minHeight: 44 }} > {options.map(o => ( ))} ); } function SchedCityField({ label, field, guest, onChange, readonly, token }) { return ( onChange(field, v)} readonly={readonly} token={token} /> ); } const _ITALY_IDS = new Set(["118", "IT", 118]); const _isItalian = (v) => v != null && (_ITALY_IDS.has(v) || _ITALY_IDS.has(String(v))); function GuestCard({ guest, index, nations, docTypes, guestTypes, reductions, readonly, token, onChange }) { const [photoOpen, setPhotoOpen] = React.useState(false); const sf = { guest, onChange, readonly }; const cf = { guest, onChange, readonly, token }; return (
{/* Card header */}
{guest.raw_image_url ? ( {`Documento setPhotoOpen(true)} style={{ width: 56, height: 40, objectFit: "cover", borderRadius: 6, cursor: "pointer", border: "1px solid var(--gray-200)", flexShrink: 0 }} title="Clicca per ingrandire" /> ) : (
No foto
)}
Ospite {index + 1} · {guest.first_name} {guest.last_name}
{guest.birth_date ? `Nato il ${guest.birth_date}` : ""}{guest.document_number ? ` · Doc. ${guest.document_number}` : ""}
{/* Fields */}
{_isItalian(guest.pt_residence_country) && ( )} {_isItalian(guest.pt_birth_country) && ( )} {_isItalian(guest.pt_document_released_by_country) && ( )}
{/* Photo modal */} {photoOpen && guest.raw_image_url && (
setPhotoOpen(false)} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.8)", zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center", cursor: "zoom-out" }} > Documento e.stopPropagation()} />
)}
); } window.ScedinaPage = ScedinaPage;