// Pagina check-in digitale ospite — accessibile via magic link senza login // URL: /checkin/ // Conformità Alloggiati Web art. 109 TULPS: // - Capogruppo (guest_index=1): dati documento completi obbligatori // - Altri ospiti: solo dati anagrafici, campi documento omessi // --------------------------------------------------------------------------- // Helper: calcolo codice fiscale italiano // --------------------------------------------------------------------------- const _MESE = "ABCDEHLMPRST"; const _COMUNI = {}; // lookup belfiore opzionale (non incluso per dimensioni) function _consonanti(s) { return (s || "").toUpperCase().replace(/[^A-Z]/g, "").split("").filter(c => !"AEIOU".includes(c)); } function _vocali(s) { return (s || "").toUpperCase().replace(/[^A-Z]/g, "").split("").filter(c => "AEIOU".includes(c)); } function _codiceNome(s, isCognome) { const c = _consonanti(s); const v = _vocali(s); if (!isCognome && c.length >= 4) { return [c[0], c[2], c[3]].join(""); } const combined = c.concat(v).concat(["X", "X", "X"]); return combined.slice(0, 3).join(""); } function _checkChar(s15) { const ODD = { 0:1,1:0,2:5,3:7,4:9,5:13,6:15,7:17,8:19,9:21, A:1,B:0,C:5,D:7,E:9,F:13,G:15,H:17,I:19,J:21, K:2,L:4,M:18,N:20,O:11,P:3,Q:6,R:8,S:12,T:14, U:16,V:10,W:22,X:25,Y:24,Z:23 }; const EVEN = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; let sum = 0; s15.toUpperCase().split("").forEach((c, i) => { sum += (i % 2 === 0) ? (ODD[c] ?? 0) : (EVEN.indexOf(c) >= 0 ? EVEN.indexOf(c) : 0); }); return EVEN[sum % 26]; } function computeItalianTaxCode(firstName, lastName, birthDateStr, sex, belfioreCode) { // belfioreCode: codice catastale del comune (4 char), es. "H501" per Roma // Se non disponibile, ritorna stringa vuota if (!firstName || !lastName || !birthDateStr || !sex || !belfioreCode) return ""; try { const d = new Date(birthDateStr + "T00:00:00"); const yy = String(d.getFullYear()).slice(2); const mm = _MESE[d.getMonth()]; let dd = d.getDate(); if (sex.toUpperCase() === "F") dd += 40; const ddStr = String(dd).padStart(2, "0"); const s15 = ( _codiceNome(lastName, true) + _codiceNome(firstName, false) + yy + mm + ddStr + belfioreCode.toUpperCase() ).toUpperCase(); if (s15.length !== 15) return ""; return s15 + _checkChar(s15); } catch { return ""; } } // --------------------------------------------------------------------------- // API helpers // --------------------------------------------------------------------------- const _API_BASE = window.location.protocol === "file:" ? "http://localhost:8000" : ""; // Errore arricchito con lo status HTTP — necessario per distinguere 401/410 (link scaduto) // da 409 (già inviato) e 5xx (rete/server) lato chiamante, senza euristiche su `message`. class _ApiError extends Error { constructor(message, status) { super(message); this.status = status; } } async function _fetchJson(url, init) { let res; try { res = await fetch(url, init); } catch (e) { // Rete giù / offline / CORS preflight fallito → status 0 throw new _ApiError("Connessione non disponibile. Controlla la rete e riprova.", 0); } if (!res.ok) { const err = await res.json().catch(() => ({})); throw new _ApiError(err.detail || `Errore ${res.status}`, res.status); } if (res.status === 204) return null; return res.json(); } async function _apiGet(token, path, params = {}) { const qs = new URLSearchParams(params).toString(); const url = `${_API_BASE}/api/checkin/${token}${path}${qs ? "?" + qs : ""}`; return _fetchJson(url); } async function _apiPost(token, guests) { return _fetchJson(`${_API_BASE}/api/checkin/${token}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ guests }), }); } async function _lookupCities(token, q) { return _apiGet(token, `/lookup/cities`, { q }); } // --------------------------------------------------------------------------- // Costanti // --------------------------------------------------------------------------- const STORAGE_KEY = (token) => `checkin_${token}`; const DOC_TYPES = [ { value: "passport", label: "Passaporto" }, { value: "id_card", label: "Carta d'identità" }, { value: "driving_license", label: "Patente di guida" }, ]; // ISO 3166-1 alpha-3 — lista completa ~195 paesi, nomi in italiano, ordinati alfabeticamente const COUNTRIES_ISO3 = [ { code: "AFG", name: "Afghanistan" }, { code: "ZAF", name: "Africa del Sud" }, { code: "ALB", name: "Albania" }, { code: "DZA", name: "Algeria" }, { code: "AND", name: "Andorra" }, { code: "AGO", name: "Angola" }, { code: "ATG", name: "Antigua e Barbuda" }, { code: "SAU", name: "Arabia Saudita" }, { code: "ARG", name: "Argentina" }, { code: "ARM", name: "Armenia" }, { code: "AUS", name: "Australia" }, { code: "AUT", name: "Austria" }, { code: "AZE", name: "Azerbaigian" }, { code: "BHS", name: "Bahamas" }, { code: "BHR", name: "Bahrein" }, { code: "BGD", name: "Bangladesh" }, { code: "BRB", name: "Barbados" }, { code: "BEL", name: "Belgio" }, { code: "BLZ", name: "Belize" }, { code: "BEN", name: "Benin" }, { code: "BTN", name: "Bhutan" }, { code: "BLR", name: "Bielorussia" }, { code: "MMR", name: "Birmania (Myanmar)" }, { code: "BOL", name: "Bolivia" }, { code: "BIH", name: "Bosnia ed Erzegovina" }, { code: "BWA", name: "Botswana" }, { code: "BRA", name: "Brasile" }, { code: "BRN", name: "Brunei" }, { code: "BGR", name: "Bulgaria" }, { code: "BFA", name: "Burkina Faso" }, { code: "BDI", name: "Burundi" }, { code: "CPV", name: "Capo Verde" }, { code: "KHM", name: "Cambogia" }, { code: "CMR", name: "Camerun" }, { code: "CAN", name: "Canada" }, { code: "CAF", name: "Centrafrica (Rep.)" }, { code: "TCD", name: "Ciad" }, { code: "CHL", name: "Cile" }, { code: "CHN", name: "Cina" }, { code: "CYP", name: "Cipro" }, { code: "COL", name: "Colombia" }, { code: "COM", name: "Comore" }, { code: "COD", name: "Congo (Rep. Dem.)" }, { code: "COG", name: "Congo (Rep.)" }, { code: "PRK", name: "Corea del Nord" }, { code: "KOR", name: "Corea del Sud" }, { code: "CRI", name: "Costa Rica" }, { code: "CIV", name: "Costa d'Avorio" }, { code: "HRV", name: "Croazia" }, { code: "CUB", name: "Cuba" }, { code: "DNK", name: "Danimarca" }, { code: "DMA", name: "Dominica" }, { code: "DOM", name: "Repubblica Dominicana" }, { code: "ECU", name: "Ecuador" }, { code: "EGY", name: "Egitto" }, { code: "SLV", name: "El Salvador" }, { code: "ARE", name: "Emirati Arabi Uniti" }, { code: "ERI", name: "Eritrea" }, { code: "EST", name: "Estonia" }, { code: "SWZ", name: "Eswatini" }, { code: "ETH", name: "Etiopia" }, { code: "FJI", name: "Figi" }, { code: "PHL", name: "Filippine" }, { code: "FIN", name: "Finlandia" }, { code: "FRA", name: "Francia" }, { code: "GAB", name: "Gabon" }, { code: "GMB", name: "Gambia" }, { code: "GEO", name: "Georgia" }, { code: "DEU", name: "Germania" }, { code: "GHA", name: "Ghana" }, { code: "JAM", name: "Giamaica" }, { code: "JPN", name: "Giappone" }, { code: "DJI", name: "Gibuti" }, { code: "JOR", name: "Giordania" }, { code: "GRC", name: "Grecia" }, { code: "GRD", name: "Grenada" }, { code: "GTM", name: "Guatemala" }, { code: "GIN", name: "Guinea" }, { code: "GNB", name: "Guinea-Bissau" }, { code: "GNQ", name: "Guinea Equatoriale" }, { code: "GUY", name: "Guyana" }, { code: "HTI", name: "Haiti" }, { code: "HND", name: "Honduras" }, { code: "IND", name: "India" }, { code: "IDN", name: "Indonesia" }, { code: "IRN", name: "Iran" }, { code: "IRQ", name: "Iraq" }, { code: "IRL", name: "Irlanda" }, { code: "ISL", name: "Islanda" }, { code: "MHL", name: "Isole Marshall" }, { code: "SLB", name: "Isole Salomone" }, { code: "ISR", name: "Israele" }, { code: "ITA", name: "Italia" }, { code: "KAZ", name: "Kazakistan" }, { code: "KEN", name: "Kenya" }, { code: "KGZ", name: "Kirghizistan" }, { code: "KIR", name: "Kiribati" }, { code: "KWT", name: "Kuwait" }, { code: "LAO", name: "Laos" }, { code: "LSO", name: "Lesotho" }, { code: "LVA", name: "Lettonia" }, { code: "LBN", name: "Libano" }, { code: "LBR", name: "Liberia" }, { code: "LBY", name: "Libia" }, { code: "LIE", name: "Liechtenstein" }, { code: "LTU", name: "Lituania" }, { code: "LUX", name: "Lussemburgo" }, { code: "MKD", name: "Macedonia del Nord" }, { code: "MDG", name: "Madagascar" }, { code: "MWI", name: "Malawi" }, { code: "MDV", name: "Maldive" }, { code: "MYS", name: "Malaysia" }, { code: "MLI", name: "Mali" }, { code: "MLT", name: "Malta" }, { code: "MAR", name: "Marocco" }, { code: "MRT", name: "Mauritania" }, { code: "MUS", name: "Maurizio" }, { code: "MEX", name: "Messico" }, { code: "FSM", name: "Micronesia" }, { code: "MDA", name: "Moldavia" }, { code: "MCO", name: "Monaco" }, { code: "MNG", name: "Mongolia" }, { code: "MNE", name: "Montenegro" }, { code: "MOZ", name: "Mozambico" }, { code: "NAM", name: "Namibia" }, { code: "NRU", name: "Nauru" }, { code: "NPL", name: "Nepal" }, { code: "NIC", name: "Nicaragua" }, { code: "NER", name: "Niger" }, { code: "NGA", name: "Nigeria" }, { code: "NOR", name: "Norvegia" }, { code: "NZL", name: "Nuova Zelanda" }, { code: "OMN", name: "Oman" }, { code: "NLD", name: "Paesi Bassi" }, { code: "PAK", name: "Pakistan" }, { code: "PLW", name: "Palau" }, { code: "PSE", name: "Palestina" }, { code: "PAN", name: "Panama" }, { code: "PNG", name: "Papua Nuova Guinea" }, { code: "PRY", name: "Paraguay" }, { code: "PER", name: "Perù" }, { code: "POL", name: "Polonia" }, { code: "PRT", name: "Portogallo" }, { code: "QAT", name: "Qatar" }, { code: "GBR", name: "Regno Unito" }, { code: "CZE", name: "Repubblica Ceca" }, { code: "ROU", name: "Romania" }, { code: "RWA", name: "Ruanda" }, { code: "RUS", name: "Russia" }, { code: "KNA", name: "Saint Kitts e Nevis" }, { code: "LCA", name: "Saint Lucia" }, { code: "VCT", name: "Saint Vincent e Grenadine" }, { code: "WSM", name: "Samoa" }, { code: "SMR", name: "San Marino" }, { code: "STP", name: "São Tomé e Príncipe" }, { code: "SEN", name: "Senegal" }, { code: "SRB", name: "Serbia" }, { code: "SYC", name: "Seychelles" }, { code: "SLE", name: "Sierra Leone" }, { code: "SGP", name: "Singapore" }, { code: "SYR", name: "Siria" }, { code: "SVK", name: "Slovacchia" }, { code: "SVN", name: "Slovenia" }, { code: "SOM", name: "Somalia" }, { code: "ESP", name: "Spagna" }, { code: "LKA", name: "Sri Lanka" }, { code: "USA", name: "Stati Uniti" }, { code: "ZWE", name: "Zimbabwe" }, { code: "SDN", name: "Sudan" }, { code: "SSD", name: "Sudan del Sud" }, { code: "SUR", name: "Suriname" }, { code: "SWE", name: "Svezia" }, { code: "CHE", name: "Svizzera" }, { code: "TJK", name: "Tagikistan" }, { code: "TZA", name: "Tanzania" }, { code: "THA", name: "Thailandia" }, { code: "TLS", name: "Timor Est" }, { code: "TGO", name: "Togo" }, { code: "TON", name: "Tonga" }, { code: "TTO", name: "Trinidad e Tobago" }, { code: "TUN", name: "Tunisia" }, { code: "TUR", name: "Turchia" }, { code: "TKM", name: "Turkmenistan" }, { code: "TUV", name: "Tuvalu" }, { code: "UKR", name: "Ucraina" }, { code: "UGA", name: "Uganda" }, { code: "HUN", name: "Ungheria" }, { code: "URY", name: "Uruguay" }, { code: "UZB", name: "Uzbekistan" }, { code: "VUT", name: "Vanuatu" }, { code: "VEN", name: "Venezuela" }, { code: "VNM", name: "Vietnam" }, { code: "YEM", name: "Yemen" }, { code: "ZMB", name: "Zambia" }, ].sort((a, b) => a.name.localeCompare(b.name, "it")); // --------------------------------------------------------------------------- // Traduzioni i18n // --------------------------------------------------------------------------- const TRANSLATIONS = { it: { legal_notice: "Perché ti chiediamo questi dati. Vengono trasmessi direttamente al comune come previsto dall'art. 109 TULPS. Bastano i documenti del capogruppo; per gli altri ospiti servono solo i dati anagrafici.", label_guest: "Ospite", label_checkin: "Check-in", label_checkout: "Check-out", instructions: "Inserisci i dati di tutti i componenti del gruppo. Per il capogruppo sono richiesti anche i dati del documento d'identità.", first_name: "Nome", last_name: "Cognome", sex: "Sesso", sex_m: "Maschio", sex_f: "Femmina", birth_date: "Data di nascita", birth_place: "Luogo di nascita", nationality: "Nazionalità", residence_city: "Comune di residenza", residence_country: "Paese di residenza", document_type: "Tipo documento", document_number: "Numero documento", expiry_date: "Scadenza documento", tax_code: "Codice fiscale", tax_code_hint: "Opzionale, solo per cittadini italiani", doc_type_passport: "Passaporto", doc_type_id_card: "Carta d'identità", doc_type_driving_license: "Patente di guida", capogruppo: "Capogruppo", capogruppo_badge: "Dati documento richiesti", guest_n: "Ospite", save_continue: "Salva e prosegui →", save_submit: "Salva e invia →", add_guest: "+ Aggiungi ospite", remove_guest: "− Rimuovi ultimo", submit: "Invia check-in", submitting: "Invio in corso…", fill_required: "Compila tutti i campi obbligatori prima di inviare.", max_guests: "Massimo {n} ospiti per check-in", saved_locally: "Dati salvati nel tuo browser", loading: "Caricamento…", link_expired_title: "Link scaduto", link_expired_msg: "Il link per il check-in resta valido 72 ore. Scrivi alla casa o all'host per riceverne uno nuovo.", link_invalid_title: "Link non valido", done_title: "Check-in completato", done_msg: "Tutto pronto. La casa ti aspetta.\nL'host riceverà una notifica del tuo arrivo.", err_required: "Obbligatorio", err_birth_date: "Data di nascita non valida", err_nationality: "Codice ISO 3 lettere", err_doc_type: "Obbligatorio per il capogruppo", err_doc_number: "Obbligatorio per il capogruppo", err_doc_expired: "Documento scaduto", err_doc_number_format: "Formato non valido", select_placeholder: "Seleziona", search_placeholder: "Cerca…", ocr_title: "Carica foto del documento", ocr_hint: "Scatta o carica una foto del documento del capogruppo: compileremo i campi automaticamente. Potrai sempre correggerli. La foto non viene salvata.", ocr_button: "Carica documento", ocr_reading: "Lettura documento…", ocr_ok: "Campi precompilati dal documento. Controlla e correggi se serve.", ocr_fail: "Non siamo riusciti a leggere il documento, compila a mano.", ocr_face_warning: "Non siamo riusciti a coprire il volto sul documento. Verifica l'anteprima prima di procedere.", ocr_preview_label: "Anteprima documento (volto coperto + campi rilevati)", ocr_privacy_note: "Il volto viene coperto automaticamente prima di inviare il documento per la lettura. La foto non viene conservata.", }, en: { legal_notice: "Why we ask for this data. It is transmitted directly to the municipality as required by art. 109 TULPS. Only the group leader's documents are needed; for other guests, only personal data is required.", label_guest: "Guest", label_checkin: "Check-in", label_checkout: "Check-out", instructions: "Enter the details of all group members. For the group leader, identity document details are also required.", first_name: "First name", last_name: "Last name", sex: "Sex", sex_m: "Male", sex_f: "Female", birth_date: "Date of birth", birth_place: "Place of birth", nationality: "Nationality", residence_city: "City of residence", residence_country: "Country of residence", document_type: "Document type", document_number: "Document number", expiry_date: "Document expiry", tax_code: "Tax code", tax_code_hint: "Optional, Italian citizens only", doc_type_passport: "Passport", doc_type_id_card: "Identity card", doc_type_driving_license: "Driving licence", capogruppo: "Group leader", capogruppo_badge: "Document details required", guest_n: "Guest", save_continue: "Save and continue →", save_submit: "Save and submit →", add_guest: "+ Add guest", remove_guest: "− Remove last", submit: "Submit check-in", submitting: "Submitting…", fill_required: "Please fill in all required fields before submitting.", max_guests: "Maximum {n} guests per check-in", saved_locally: "Data saved in your browser", loading: "Loading…", link_expired_title: "Link expired", link_expired_msg: "The check-in link is valid for 72 hours. Contact the host to receive a new one.", link_invalid_title: "Invalid link", done_title: "Check-in complete", done_msg: "All set. The accommodation is waiting for you.\nThe host will be notified of your arrival.", err_required: "Required", err_birth_date: "Invalid date of birth", err_nationality: "3-letter ISO code", err_doc_type: "Required for the group leader", err_doc_number: "Required for the group leader", err_doc_expired: "Document expired", err_doc_number_format: "Invalid format", select_placeholder: "Select", search_placeholder: "Search…", ocr_title: "Upload a photo of the document", ocr_hint: "Take or upload a photo of the group leader's document: we'll fill in the fields automatically. You can always edit them. The photo is not stored.", ocr_button: "Upload document", ocr_reading: "Reading document…", ocr_ok: "Fields pre-filled from the document. Please review and correct if needed.", ocr_fail: "We couldn't read the document, please fill it in manually.", ocr_face_warning: "We couldn't cover the face on the document. Please check the preview before proceeding.", ocr_preview_label: "Document preview (face covered + detected fields)", ocr_privacy_note: "The face is automatically covered before the document is sent for reading. The photo is not stored.", }, fr: { legal_notice: "Pourquoi nous demandons ces données. Elles sont transmises directement à la commune conformément à l'art. 109 TULPS. Seuls les documents du chef de groupe sont nécessaires ; pour les autres invités, seules les données personnelles sont requises.", label_guest: "Invité", label_checkin: "Arrivée", label_checkout: "Départ", instructions: "Saisissez les informations de tous les membres du groupe. Pour le chef de groupe, les données du document d'identité sont également requises.", first_name: "Prénom", last_name: "Nom", sex: "Sexe", sex_m: "Homme", sex_f: "Femme", birth_date: "Date de naissance", birth_place: "Lieu de naissance", nationality: "Nationalité", residence_city: "Ville de résidence", residence_country: "Pays de résidence", document_type: "Type de document", document_number: "Numéro de document", expiry_date: "Expiration du document", tax_code: "Code fiscal", tax_code_hint: "Facultatif, citoyens italiens uniquement", doc_type_passport: "Passeport", doc_type_id_card: "Carte d'identité", doc_type_driving_license: "Permis de conduire", capogruppo: "Chef de groupe", capogruppo_badge: "Documents requis", guest_n: "Invité", save_continue: "Enregistrer et continuer →", save_submit: "Enregistrer et envoyer →", add_guest: "+ Ajouter un invité", remove_guest: "− Supprimer le dernier", submit: "Envoyer l'enregistrement", submitting: "Envoi en cours…", fill_required: "Veuillez remplir tous les champs obligatoires avant d'envoyer.", max_guests: "Maximum {n} invités par enregistrement", saved_locally: "Données enregistrées dans votre navigateur", loading: "Chargement…", link_expired_title: "Lien expiré", link_expired_msg: "Le lien de check-in est valable 72 heures. Contactez l'hôte pour en recevoir un nouveau.", link_invalid_title: "Lien invalide", done_title: "Enregistrement terminé", done_msg: "Tout est prêt. L'hébergement vous attend.\nL'hôte sera notifié de votre arrivée.", err_required: "Obligatoire", err_birth_date: "Date de naissance invalide", err_nationality: "Code ISO à 3 lettres", err_doc_type: "Obligatoire pour le chef de groupe", err_doc_number: "Obligatoire pour le chef de groupe", err_doc_expired: "Document expiré", err_doc_number_format: "Format invalide", select_placeholder: "Sélectionner", search_placeholder: "Rechercher…", ocr_title: "Téléchargez une photo du document", ocr_hint: "Prenez ou téléchargez une photo du document du chef de groupe : nous remplirons les champs automatiquement. Vous pourrez toujours les modifier. La photo n'est pas conservée.", ocr_button: "Télécharger le document", ocr_reading: "Lecture du document…", ocr_ok: "Champs préremplis à partir du document. Vérifiez et corrigez si nécessaire.", ocr_fail: "Nous n'avons pas pu lire le document, veuillez le remplir manuellement.", ocr_face_warning: "Nous n'avons pas pu masquer le visage sur le document. Vérifiez l'aperçu avant de continuer.", ocr_preview_label: "Aperçu du document (visage masqué + champs détectés)", ocr_privacy_note: "Le visage est automatiquement masqué avant l'envoi du document pour la lecture. La photo n'est pas conservée.", }, de: { legal_notice: "Warum wir diese Daten benötigen. Sie werden gemäß Art. 109 TULPS direkt an die Gemeinde übermittelt. Nur die Dokumente des Gruppenführers sind erforderlich; für die anderen Gäste werden nur persönliche Daten benötigt.", label_guest: "Gast", label_checkin: "Check-in", label_checkout: "Check-out", instructions: "Geben Sie die Daten aller Gruppenmitglieder ein. Für den Gruppenführer sind auch die Ausweisdaten erforderlich.", first_name: "Vorname", last_name: "Nachname", sex: "Geschlecht", sex_m: "Männlich", sex_f: "Weiblich", birth_date: "Geburtsdatum", birth_place: "Geburtsort", nationality: "Staatsangehörigkeit", residence_city: "Wohnort", residence_country: "Wohnsitzland", document_type: "Dokumententyp", document_number: "Dokumentennummer", expiry_date: "Dokumentenablauf", tax_code: "Steuercode", tax_code_hint: "Optional, nur für italienische Staatsbürger", doc_type_passport: "Reisepass", doc_type_id_card: "Personalausweis", doc_type_driving_license: "Führerschein", capogruppo: "Gruppenführer", capogruppo_badge: "Ausweisdaten erforderlich", guest_n: "Gast", save_continue: "Speichern und weiter →", save_submit: "Speichern und senden →", add_guest: "+ Gast hinzufügen", remove_guest: "− Letzten entfernen", submit: "Check-in einreichen", submitting: "Wird gesendet…", fill_required: "Bitte füllen Sie alle Pflichtfelder aus, bevor Sie absenden.", max_guests: "Maximal {n} Gäste pro Check-in", saved_locally: "Daten in Ihrem Browser gespeichert", loading: "Wird geladen…", link_expired_title: "Link abgelaufen", link_expired_msg: "Der Check-in-Link ist 72 Stunden gültig. Kontaktieren Sie den Gastgeber, um einen neuen zu erhalten.", link_invalid_title: "Ungültiger Link", done_title: "Check-in abgeschlossen", done_msg: "Alles bereit. Die Unterkunft erwartet Sie.\nDer Gastgeber wird über Ihre Ankunft benachrichtigt.", err_required: "Pflichtfeld", err_birth_date: "Ungültiges Geburtsdatum", err_nationality: "3-Buchstaben-ISO-Code", err_doc_type: "Erforderlich für den Gruppenführer", err_doc_number: "Erforderlich für den Gruppenführer", err_doc_expired: "Dokument abgelaufen", err_doc_number_format: "Ungültiges Format", select_placeholder: "Auswählen", search_placeholder: "Suchen…", ocr_title: "Foto des Dokuments hochladen", ocr_hint: "Machen oder laden Sie ein Foto des Dokuments des Gruppenführers hoch: Wir füllen die Felder automatisch aus. Sie können sie jederzeit korrigieren. Das Foto wird nicht gespeichert.", ocr_button: "Dokument hochladen", ocr_reading: "Dokument wird gelesen…", ocr_ok: "Felder aus dem Dokument vorausgefüllt. Bitte prüfen und ggf. korrigieren.", ocr_fail: "Wir konnten das Dokument nicht lesen, bitte manuell ausfüllen.", ocr_face_warning: "Wir konnten das Gesicht im Dokument nicht abdecken. Bitte prüfen Sie die Vorschau, bevor Sie fortfahren.", ocr_preview_label: "Dokumentvorschau (Gesicht abgedeckt + erkannte Felder)", ocr_privacy_note: "Das Gesicht wird vor dem Senden des Dokuments zur Texterkennung automatisch abgedeckt. Das Foto wird nicht gespeichert.", }, es: { legal_notice: "Por qué pedimos estos datos. Se transmiten directamente al municipio según el art. 109 TULPS. Solo se necesitan los documentos del jefe de grupo; para los demás huéspedes solo se requieren datos personales.", label_guest: "Huésped", label_checkin: "Check-in", label_checkout: "Check-out", instructions: "Introduce los datos de todos los miembros del grupo. Para el jefe de grupo también se requieren los datos del documento de identidad.", first_name: "Nombre", last_name: "Apellidos", sex: "Sexo", sex_m: "Hombre", sex_f: "Mujer", birth_date: "Fecha de nacimiento", birth_place: "Lugar de nacimiento", nationality: "Nacionalidad", residence_city: "Ciudad de residencia", residence_country: "País de residencia", document_type: "Tipo de documento", document_number: "Número de documento", expiry_date: "Vencimiento del documento", tax_code: "Código fiscal", tax_code_hint: "Opcional, solo para ciudadanos italianos", doc_type_passport: "Pasaporte", doc_type_id_card: "Documento de identidad", doc_type_driving_license: "Permiso de conducir", capogruppo: "Jefe de grupo", capogruppo_badge: "Datos del documento requeridos", guest_n: "Huésped", save_continue: "Guardar y continuar →", save_submit: "Guardar y enviar →", add_guest: "+ Añadir huésped", remove_guest: "− Eliminar último", submit: "Enviar check-in", submitting: "Enviando…", fill_required: "Completa todos los campos obligatorios antes de enviar.", max_guests: "Máximo {n} huéspedes por check-in", saved_locally: "Datos guardados en tu navegador", loading: "Cargando…", link_expired_title: "Enlace caducado", link_expired_msg: "El enlace de check-in es válido durante 72 horas. Contacta con el anfitrión para recibir uno nuevo.", link_invalid_title: "Enlace no válido", done_title: "Check-in completado", done_msg: "Todo listo. El alojamiento te espera.\nEl anfitrión recibirá una notificación de tu llegada.", err_required: "Obligatorio", err_birth_date: "Fecha de nacimiento no válida", err_nationality: "Código ISO de 3 letras", err_doc_type: "Obligatorio para el jefe de grupo", err_doc_number: "Obligatorio para el jefe de grupo", err_doc_expired: "Documento caducado", err_doc_number_format: "Formato no válido", select_placeholder: "Seleccionar", search_placeholder: "Buscar…", ocr_title: "Sube una foto del documento", ocr_hint: "Haz o sube una foto del documento del jefe de grupo: rellenaremos los campos automáticamente. Siempre podrás corregirlos. La foto no se guarda.", ocr_button: "Subir documento", ocr_reading: "Leyendo documento…", ocr_ok: "Campos rellenados a partir del documento. Revisa y corrige si es necesario.", ocr_fail: "No hemos podido leer el documento, rellénalo manualmente.", ocr_face_warning: "No hemos podido cubrir el rostro en el documento. Verifica la vista previa antes de continuar.", ocr_preview_label: "Vista previa del documento (rostro cubierto + campos detectados)", ocr_privacy_note: "El rostro se cubre automáticamente antes de enviar el documento para su lectura. La foto no se conserva.", }, pt: { legal_notice: "Por que pedimos estes dados. São transmitidos diretamente ao município conforme o art. 109 TULPS. Apenas os documentos do líder do grupo são necessários; para os outros hóspedes, apenas os dados pessoais são exigidos.", label_guest: "Hóspede", label_checkin: "Check-in", label_checkout: "Check-out", instructions: "Insira os dados de todos os membros do grupo. Para o líder do grupo, os dados do documento de identidade também são obrigatórios.", first_name: "Nome", last_name: "Apelido", sex: "Sexo", sex_m: "Masculino", sex_f: "Feminino", birth_date: "Data de nascimento", birth_place: "Local de nascimento", nationality: "Nacionalidade", residence_city: "Cidade de residência", residence_country: "País de residência", document_type: "Tipo de documento", document_number: "Número do documento", expiry_date: "Validade do documento", tax_code: "Código fiscal", tax_code_hint: "Opcional, apenas para cidadãos italianos", doc_type_passport: "Passaporte", doc_type_id_card: "Bilhete de identidade", doc_type_driving_license: "Carta de condução", capogruppo: "Líder do grupo", capogruppo_badge: "Dados do documento obrigatórios", guest_n: "Hóspede", save_continue: "Guardar e continuar →", save_submit: "Guardar e enviar →", add_guest: "+ Adicionar hóspede", remove_guest: "− Remover último", submit: "Enviar check-in", submitting: "A enviar…", fill_required: "Preencha todos os campos obrigatórios antes de enviar.", max_guests: "Máximo {n} hóspedes por check-in", saved_locally: "Dados guardados no seu navegador", loading: "A carregar…", link_expired_title: "Link expirado", link_expired_msg: "O link de check-in é válido por 72 horas. Contacte o anfitrião para receber um novo.", link_invalid_title: "Link inválido", done_title: "Check-in concluído", done_msg: "Tudo pronto. O alojamento está à sua espera.\nO anfitrião será notificado da sua chegada.", err_required: "Obrigatório", err_birth_date: "Data de nascimento inválida", err_nationality: "Código ISO de 3 letras", err_doc_type: "Obrigatório para o líder do grupo", err_doc_number: "Obrigatório para o líder do grupo", err_doc_expired: "Documento expirado", err_doc_number_format: "Formato inválido", select_placeholder: "Selecionar", search_placeholder: "Pesquisar…", ocr_title: "Carregue uma foto do documento", ocr_hint: "Tire ou carregue uma foto do documento do líder do grupo: preencheremos os campos automaticamente. Poderá sempre corrigi-los. A foto não é guardada.", ocr_button: "Carregar documento", ocr_reading: "A ler documento…", ocr_ok: "Campos preenchidos a partir do documento. Verifique e corrija se necessário.", ocr_fail: "Não conseguimos ler o documento, preencha manualmente.", ocr_face_warning: "Não conseguimos cobrir o rosto no documento. Verifique a pré-visualização antes de continuar.", ocr_preview_label: "Pré-visualização do documento (rosto coberto + campos detectados)", ocr_privacy_note: "O rosto é coberto automaticamente antes de enviar o documento para leitura. A foto não é guardada.", }, sv: { legal_notice: "Varför vi frågar efter dessa uppgifter. De skickas direkt till kommunen enligt art. 109 TULPS. Bara gruppledaren behöver visa dokumentation; för övriga gäster räcker personuppgifter.", label_guest: "Gäst", label_checkin: "Incheckning", label_checkout: "Utcheckning", instructions: "Ange uppgifter för alla gruppmedlemmar. För gruppledaren krävs också identitetshandlingens uppgifter.", first_name: "Förnamn", last_name: "Efternamn", sex: "Kön", sex_m: "Man", sex_f: "Kvinna", birth_date: "Födelsedatum", birth_place: "Födelseort", nationality: "Nationalitet", residence_city: "Hemort", residence_country: "Hemland", document_type: "Dokumenttyp", document_number: "Dokumentnummer", expiry_date: "Dokumentets giltighetstid", tax_code: "Personnummer", tax_code_hint: "Valfritt, endast för italienska medborgare", doc_type_passport: "Pass", doc_type_id_card: "Identitetskort", doc_type_driving_license: "Körkort", capogruppo: "Gruppledare", capogruppo_badge: "Dokumentuppgifter krävs", guest_n: "Gäst", save_continue: "Spara och fortsätt →", save_submit: "Spara och skicka →", add_guest: "+ Lägg till gäst", remove_guest: "− Ta bort sista", submit: "Skicka incheckning", submitting: "Skickar…", fill_required: "Fyll i alla obligatoriska fält innan du skickar.", max_guests: "Maximalt {n} gäster per incheckning", saved_locally: "Uppgifter sparade i din webbläsare", loading: "Laddar…", link_expired_title: "Länken har gått ut", link_expired_msg: "Incheckningslänken är giltig i 72 timmar. Kontakta värden för att få en ny.", link_invalid_title: "Ogiltig länk", done_title: "Incheckning klar", done_msg: "Allt klart. Boendet väntar på dig.\nVärden meddelas om din ankomst.", err_required: "Obligatoriskt", err_birth_date: "Ogiltigt födelsedatum", err_nationality: "ISO-kod med 3 bokstäver", err_doc_type: "Obligatoriskt för gruppledaren", err_doc_number: "Obligatoriskt för gruppledaren", err_doc_expired: "Dokumentet har gått ut", err_doc_number_format: "Ogiltigt format", select_placeholder: "Välj", search_placeholder: "Sök…", ocr_title: "Ladda upp ett foto av dokumentet", ocr_hint: "Ta eller ladda upp ett foto av gruppledaren dokument: vi fyller i fälten automatiskt. Du kan alltid korrigera dem. Fotot sparas inte.", ocr_button: "Ladda upp dokument", ocr_reading: "Läser dokument…", ocr_ok: "Fält förifyllda från dokumentet. Kontrollera och korrigera vid behov.", ocr_fail: "Vi kunde inte läsa dokumentet, fyll i manuellt.", ocr_face_warning: "Vi kunde inte täcka ansiktet i dokumentet. Kontrollera förhandsgranskningen innan du fortsätter.", ocr_preview_label: "Dokumentförhandsvisning (ansikte täckt + identifierade fält)", ocr_privacy_note: "Ansiktet täcks automatiskt innan dokumentet skickas för läsning. Fotot sparas inte.", }, pl: { legal_notice: "Dlaczego pytamy o te dane. Są one przekazywane bezpośrednio do gminy zgodnie z art. 109 TULPS. Wymagane są tylko dokumenty lidera grupy; od pozostałych gości wymagane są jedynie dane osobowe.", label_guest: "Gość", label_checkin: "Zameldowanie", label_checkout: "Wymeldowanie", instructions: "Wprowadź dane wszystkich członków grupy. Dla lidera grupy wymagane są również dane dokumentu tożsamości.", first_name: "Imię", last_name: "Nazwisko", sex: "Płeć", sex_m: "Mężczyzna", sex_f: "Kobieta", birth_date: "Data urodzenia", birth_place: "Miejsce urodzenia", nationality: "Narodowość", residence_city: "Miasto zamieszkania", residence_country: "Kraj zamieszkania", document_type: "Typ dokumentu", document_number: "Numer dokumentu", expiry_date: "Ważność dokumentu", tax_code: "PESEL / kod podatkowy", tax_code_hint: "Opcjonalne, tylko dla obywateli włoskich", doc_type_passport: "Paszport", doc_type_id_card: "Dowód osobisty", doc_type_driving_license: "Prawo jazdy", capogruppo: "Lider grupy", capogruppo_badge: "Wymagane dane dokumentu", guest_n: "Gość", save_continue: "Zapisz i kontynuuj →", save_submit: "Zapisz i wyślij →", add_guest: "+ Dodaj gościa", remove_guest: "− Usuń ostatniego", submit: "Wyślij zameldowanie", submitting: "Wysyłanie…", fill_required: "Wypełnij wszystkie wymagane pola przed wysłaniem.", max_guests: "Maksymalnie {n} gości na zameldowanie", saved_locally: "Dane zapisane w Twojej przeglądarce", loading: "Ładowanie…", link_expired_title: "Link wygasł", link_expired_msg: "Link do zameldowania jest ważny przez 72 godziny. Skontaktuj się z gospodarzem, aby otrzymać nowy.", link_invalid_title: "Nieprawidłowy link", done_title: "Zameldowanie zakończone", done_msg: "Wszystko gotowe. Zakwaterowanie czeka na Ciebie.\nGospodarz zostanie powiadomiony o Twoim przyjeździe.", err_required: "Wymagane", err_birth_date: "Nieprawidłowa data urodzenia", err_nationality: "3-literowy kod ISO", err_doc_type: "Wymagane dla lidera grupy", err_doc_number: "Wymagane dla lidera grupy", err_doc_expired: "Dokument wygasł", err_doc_number_format: "Nieprawidłowy format", select_placeholder: "Wybierz", search_placeholder: "Szukaj…", ocr_title: "Wgraj zdjęcie dokumentu", ocr_hint: "Zrób lub wgraj zdjęcie dokumentu lidera grupy: pola zostaną wypełnione automatycznie. Zawsze możesz je poprawić. Zdjęcie nie jest przechowywane.", ocr_button: "Wgraj dokument", ocr_reading: "Odczytywanie dokumentu…", ocr_ok: "Pola wypełnione na podstawie dokumentu. Sprawdź i popraw w razie potrzeby.", ocr_fail: "Nie udało się odczytać dokumentu, wypełnij ręcznie.", ocr_face_warning: "Nie udało się zakryć twarzy na dokumencie. Sprawdź podgląd przed kontynuowaniem.", ocr_preview_label: "Podgląd dokumentu (twarz zakryta + wykryte pola)", ocr_privacy_note: "Twarz jest automatycznie zakrywana przed wysłaniem dokumentu do odczytu. Zdjęcie nie jest przechowywane.", }, }; function makeTrans(lang) { const dict = TRANSLATIONS[lang] || TRANSLATIONS["it"]; return (key, vars = {}) => { let s = dict[key] ?? TRANSLATIONS["it"][key] ?? key; Object.entries(vars).forEach(([k, v]) => { s = s.replace(`{${k}}`, v); }); return s; }; } // --------------------------------------------------------------------------- // Illustrazioni SVG stilizzate (metafore di casa, non di tech) — stroke 1.5, monocrome // --------------------------------------------------------------------------- function IconHourglass({ size = 72, color = "var(--color-egeo)" }) { return ( ); } function IconKey({ size = 64, color = "var(--color-egeo)" }) { return ( ); } function IconDoor({ size = 80, color = "var(--color-terracotta)" }) { return ( ); } function IconCheckGlyph({ size = 14, color = "white" }) { return ( ); } function ArgoWordmark({ size = 24, withTagline = false }) { return (
Argo
{withTagline && (
L'ospitalità che parla
)}
); } function emptyGuest(index) { return { guest_index: index, first_name: "", last_name: "", sex: "", birth_date: "", birth_place: "", nationality: "", residence_city: "", residence_country: "", document_type: "", document_number: "", _valid: false, }; } // --------------------------------------------------------------------------- // Validazione singolo ospite // --------------------------------------------------------------------------- function validateGuest(g, isCapogruppo, t) { const _t = t || makeTrans("it"); const today = new Date().toISOString().slice(0, 10); const errors = {}; if (!g.first_name.trim()) errors.first_name = _t("err_required"); if (!g.last_name.trim()) errors.last_name = _t("err_required"); if (!g.sex) errors.sex = _t("err_required"); if (!g.birth_date) errors.birth_date = _t("err_required"); else if (g.birth_date >= today) errors.birth_date = _t("err_birth_date"); if (!g.nationality || g.nationality.length !== 3) errors.nationality = _t("err_nationality"); if (isCapogruppo) { if (!g.document_type) errors.document_type = _t("err_doc_type"); if (!g.document_number.trim()) errors.document_number = _t("err_doc_number"); // Validazione formato numero documento if (g.document_number && g.document_type) { const num = g.document_number.trim().toUpperCase(); let valid = true; if (g.document_type === "id_card") { // Vecchio formato: 2 lettere + 7 cifre (AA1234567) // Nuovo formato elettronico: 2 lettere + 5 cifre + 2 lettere (CA12345AB) — 9 char valid = /^[A-Z]{2}\d{7}$/.test(num) || /^[A-Z]{2}\d{5}[A-Z]{2}$/.test(num); } else if (g.document_type === "passport") { // Passaporto italiano: 2 lettere + 7 alfanumerici // Passaporti esteri: formati variabili — valida solo lunghezza 6-12 e caratteri alfanumerici valid = /^[A-Z0-9]{6,12}$/.test(num); } // driving_license: troppo variabile, nessuna validazione formato if (!valid) errors.document_number = _t("err_doc_number_format"); } } return errors; } // --------------------------------------------------------------------------- // CityAutocomplete (versione per checkin — usa endpoint /checkin/{token}/lookup/cities) // --------------------------------------------------------------------------- function CheckinCityAutocomplete({ label, value, onChange, token, disabled, t }) { const _t = t || makeTrans("it"); const [query, setQuery] = React.useState(value || ""); const [suggestions, setSuggestions] = React.useState([]); const [open, setOpen] = React.useState(false); const [hoveredIdx, setHoveredIdx] = React.useState(-1); const debounceRef = React.useRef(null); const wrapRef = React.useRef(null); const inputRef = React.useRef(null); const [dropRect, setDropRect] = React.useState({ top: 0, left: 0, width: 200 }); React.useEffect(() => { const handler = (e) => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); // Su mobile piccolo il dropdown diventa fixed per evitare clipping 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); }, []); const _updateDropRect = () => { if (inputRef.current) { const r = inputRef.current.getBoundingClientRect(); setDropRect({ top: r.bottom + 4, left: r.left, width: r.width }); } }; React.useEffect(() => { if (!open || !isMobileSmall) return; _updateDropRect(); window.addEventListener("scroll", _updateDropRect, { passive: true }); window.addEventListener("resize", _updateDropRect, { passive: true }); return () => { window.removeEventListener("scroll", _updateDropRect); window.removeEventListener("resize", _updateDropRect); }; }, [open, isMobileSmall]); const handleInput = (e) => { const q = e.target.value; setQuery(q); onChange(q); // testo libero finché non si seleziona setHoveredIdx(-1); clearTimeout(debounceRef.current); if (q.length >= 2) { debounceRef.current = setTimeout(() => { _lookupCities(token, q) .then(res => { setSuggestions(res || []); if ((res || []).length > 0) { _updateDropRect(); setOpen(true); } else { setOpen(false); } }) .catch(() => {}); }, 280); } else { setSuggestions([]); setOpen(false); } }; const select = (city) => { setQuery(city.name); onChange(city.name); setSuggestions([]); setOpen(false); setHoveredIdx(-1); }; const handleKeyDown = (e) => { if (!open || suggestions.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setHoveredIdx(i => Math.min(i + 1, suggestions.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHoveredIdx(i => Math.max(i - 1, 0)); } else if (e.key === "Enter" && hoveredIdx >= 0) { e.preventDefault(); select(suggestions[hoveredIdx]); } else if (e.key === "Escape") { setOpen(false); setHoveredIdx(-1); } }; const LABEL_STYLE = { display: "block", fontSize: 12, fontWeight: 600, color: "var(--text-muted)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.04em" }; const dropStyle = isMobileSmall ? { position: "fixed", top: dropRect.top, left: dropRect.left, width: dropRect.width, background: "white", border: "1px solid var(--border-default)", borderRadius: 8, zIndex: 200, maxHeight: 220, overflowY: "auto", boxShadow: "0 6px 20px rgba(0,0,0,.13)" } : { position: "absolute", top: "100%", left: 0, right: 0, background: "white", border: "1px solid var(--border-default)", borderRadius: 8, zIndex: 200, maxHeight: 220, overflowY: "auto", boxShadow: "0 6px 20px rgba(0,0,0,.13)" }; return (
{ if (suggestions.length > 0) { _updateDropRect(); setOpen(true); } }} onKeyDown={handleKeyDown} disabled={disabled} placeholder={_t("search_placeholder")} style={{ fontSize: 16, minHeight: 44 }} autoComplete="off" /> {open && suggestions.length > 0 && (
{suggestions.map((c, i) => (
select(c)} onMouseEnter={() => setHoveredIdx(i)} onMouseLeave={() => setHoveredIdx(-1)} style={{ padding: "10px 14px", fontSize: 14, cursor: "pointer", borderBottom: i < suggestions.length - 1 ? "1px solid var(--border-subtle)" : "none", background: hoveredIdx === i ? "var(--surface-info-soft)" : "white", color: hoveredIdx === i ? "var(--color-egeo)" : "inherit", fontWeight: hoveredIdx === i ? 600 : 400, transition: "background .1s" }} > {c.name}
))}
)}
); } // --------------------------------------------------------------------------- // CountryAutocomplete — ricerca client-side su COUNTRIES_ISO3 // --------------------------------------------------------------------------- function CountryAutocomplete({ label, value, onChange, disabled, required, hasError, errorMsg, t }) { const _t = t || makeTrans("it"); // Risolve il nome display dall'eventuale codice ISO3 salvato const _nameFromCode = (code) => { if (!code) return ""; const found = COUNTRIES_ISO3.find(c => c.code === code); return found ? found.name : code; }; const [query, setQuery] = React.useState(_nameFromCode(value)); const [suggestions, setSuggestions] = React.useState([]); const [open, setOpen] = React.useState(false); const [hoveredIdx, setHoveredIdx] = React.useState(-1); const wrapRef = React.useRef(null); const inputRef = React.useRef(null); const [dropRect, setDropRect] = React.useState({ top: 0, left: 0, width: 200 }); // Aggiorna il testo visualizzato quando il valore esterno cambia (es. reset form) React.useEffect(() => { setQuery(_nameFromCode(value)); }, [value]); 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 [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); }, []); const _updateDropRect = () => { if (inputRef.current) { const r = inputRef.current.getBoundingClientRect(); setDropRect({ top: r.bottom + 4, left: r.left, width: r.width }); } }; React.useEffect(() => { if (!open || !isMobileSmall) return; _updateDropRect(); window.addEventListener("scroll", _updateDropRect, { passive: true }); window.addEventListener("resize", _updateDropRect, { passive: true }); return () => { window.removeEventListener("scroll", _updateDropRect); window.removeEventListener("resize", _updateDropRect); }; }, [open, isMobileSmall]); const handleInput = (e) => { const q = e.target.value; setQuery(q); setHoveredIdx(-1); if (!q) { onChange(""); setSuggestions([]); setOpen(false); return; } const filtered = COUNTRIES_ISO3.filter(c => c.name.toLowerCase().includes(q.toLowerCase())); setSuggestions(filtered); if (filtered.length > 0) { _updateDropRect(); setOpen(true); } else { setOpen(false); } }; const select = (country) => { setQuery(country.name); onChange(country.code); setSuggestions([]); setOpen(false); setHoveredIdx(-1); }; const handleKeyDown = (e) => { if (!open || suggestions.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setHoveredIdx(i => Math.min(i + 1, suggestions.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHoveredIdx(i => Math.max(i - 1, 0)); } else if (e.key === "Enter" && hoveredIdx >= 0) { e.preventDefault(); select(suggestions[hoveredIdx]); } else if (e.key === "Escape") { setOpen(false); setHoveredIdx(-1); } }; const LABEL_STYLE = { display: "block", fontSize: 12, fontWeight: 600, color: "var(--text-muted)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.04em" }; const dropStyle = isMobileSmall ? { position: "fixed", top: dropRect.top, left: dropRect.left, width: dropRect.width, background: "white", border: "1px solid var(--border-default)", borderRadius: 8, zIndex: 200, maxHeight: 220, overflowY: "auto", boxShadow: "0 6px 20px rgba(0,0,0,.13)" } : { position: "absolute", top: "100%", left: 0, right: 0, background: "white", border: "1px solid var(--border-default)", borderRadius: 8, zIndex: 200, maxHeight: 220, overflowY: "auto", boxShadow: "0 6px 20px rgba(0,0,0,.13)" }; return (
{ if (suggestions.length > 0) { _updateDropRect(); setOpen(true); } }} onKeyDown={handleKeyDown} disabled={disabled} placeholder={_t("search_placeholder")} style={{ fontSize: 16, minHeight: 44, borderColor: hasError ? "var(--color-danger)" : undefined }} autoComplete="off" /> {open && suggestions.length > 0 && (
{suggestions.map((c, i) => (
select(c)} onMouseEnter={() => setHoveredIdx(i)} onMouseLeave={() => setHoveredIdx(-1)} style={{ padding: "10px 14px", fontSize: 14, cursor: "pointer", borderBottom: i < suggestions.length - 1 ? "1px solid var(--border-subtle)" : "none", background: hoveredIdx === i ? "var(--surface-info-soft)" : "white", color: hoveredIdx === i ? "var(--color-egeo)" : "inherit", fontWeight: hoveredIdx === i ? 600 : 400, transition: "background .1s" }} > {c.name}
))}
)} {hasError &&
{errorMsg}
}
); } // --------------------------------------------------------------------------- // GuestForm — form singolo ospite // // Nota implementativa: i componenti TextField/SelectField sono dichiarati a // livello di modulo e non dentro GuestForm. Se fossero inner-component React // li tratterebbe come un nuovo `type` a ogni render, smontando e rimontando // l' a ogni keystroke — su mobile si perde il focus a ogni lettera. // --------------------------------------------------------------------------- const _LABEL_STYLE = { display: "block", fontSize: 12, fontWeight: 600, color: "var(--text-muted)", marginBottom: 4, textTransform: "uppercase", letterSpacing: "0.04em" }; const _ERR_STYLE = { fontSize: 11, color: "var(--color-danger)", marginTop: 2 }; function TextField({ label, field, guest, errors, onChange, type = "text", required, placeholder, maxLength, pattern, min, max, transform, inputMode, autoComplete, disabled }) { const hasError = !!errors[field]; const handle = (e) => { const v = transform ? transform(e.target.value) : e.target.value; onChange(field, v); }; return (
{hasError &&
{errors[field]}
}
); } function SelectField({ label, field, options, required, guest, errors, onChange, placeholder }) { const hasError = !!errors[field]; return (
{hasError &&
{errors[field]}
}
); } // --------------------------------------------------------------------------- // DocumentOcrUploader — caricamento foto documento per il capogruppo (opzionale) // // L'ospite carica una foto/PDF del documento; il backend estrae i campi via OCR // e li restituisce. Precompiliamo i campi del capogruppo (sempre modificabili). // La foto NON viene salvata: serve solo all'estrazione. // --------------------------------------------------------------------------- function DocumentOcrUploader({ token, onFields, t }) { const _t = t || makeTrans("it"); const [loading, setLoading] = React.useState(false); const [message, setMessage] = React.useState(null); // { type: "ok" | "fail", text } const [previewB64, setPreviewB64] = React.useState(null); // stringa base64 PNG annotata const [faceRedacted, setFaceRedacted] = React.useState(null); // bool | null const fileRef = React.useRef(null); const handleFile = async (file) => { if (!file) return; setLoading(true); setMessage(null); setPreviewB64(null); setFaceRedacted(null); try { const res = await window.API.ocrCheckinDocument(token, file); if (res && res.ok && res.fields) { // Mappa solo i campi valorizzati (non sovrascrive con vuoti) const allowed = ["first_name", "last_name", "birth_date", "birth_place", "nationality", "sex", "document_type", "document_number"]; const fields = {}; allowed.forEach(k => { const v = res.fields[k]; if (v !== undefined && v !== null && String(v).trim() !== "") fields[k] = v; }); onFields(fields); setMessage({ type: "ok", text: _t("ocr_ok") }); // Anteprima documento redatto + annotato if (res.image_b64) { setPreviewB64(res.image_b64); } // face_redacted === false → avviso (volto non coperto) setFaceRedacted(res.face_redacted === true); } else { setMessage({ type: "fail", text: _t("ocr_fail") }); } } catch (err) { setMessage({ type: "fail", text: _t("ocr_fail") }); } finally { setLoading(false); } }; return (
{_t("ocr_title")}
{_t("ocr_hint")}
{_t("ocr_privacy_note")}
{ if (e.target.files.length) { handleFile(e.target.files[0]); e.target.value = ""; } }} /> {message && (
{message.text}
)} {/* Avviso: volto non coperto (face_redacted === false dopo un OCR ok) */} {faceRedacted === false && message && message.type === "ok" && (
{_t("ocr_face_warning")}
)} {/* Anteprima documento: volto coperto + riquadri campi */} {previewB64 && (
{_t("ocr_preview_label")}
Document preview
)}
); } // Bound oggi (per max date di nascita) e bound 1900 (per min): evita "data assurda" senza essere paternalisti. const _TODAY_ISO = () => new Date().toISOString().slice(0, 10); function GuestForm({ guest, index, isCapogruppo, onChange, paytouristEnabled, token, showErrors, t }) { const _t = t || makeTrans("it"); const errors = showErrors ? validateGuest(guest, isCapogruppo, _t) : {}; const fieldProps = { guest, errors, onChange }; const today = _TODAY_ISO(); const docTypeOptions = [ { value: "passport", label: _t("doc_type_passport") }, { value: "id_card", label: _t("doc_type_id_card") }, { value: "driving_license", label: _t("doc_type_driving_license") }, ]; return (
{/* OCR documento — solo capogruppo, opzionale. Precompila i campi sottostanti. */} {isCapogruppo && ( Object.entries(fields).forEach(([k, v]) => onChange(k, v))} /> )} {paytouristEnabled ? ( onChange("birth_place", v)} token={token} t={_t} /> ) : ( )} onChange("nationality", v)} required hasError={!!errors.nationality} errorMsg={errors.nationality} disabled={false} t={_t} /> { onChange("residence_country", v); // Se si seleziona un paese estero, pulisci il comune di residenza if (v && v !== "ITA") onChange("residence_city", ""); }} disabled={false} t={_t} /> {/* Comune di residenza — disabilitato se il paese è estero (non ITA) */} {paytouristEnabled ? ( onChange("residence_city", v)} token={token} disabled={!!(guest.residence_country && guest.residence_country !== "ITA")} t={_t} /> ) : ( )} {/* Campi documento — solo capogruppo */} {isCapogruppo && ( <> )}
); } // --------------------------------------------------------------------------- // GuestCard accordion // --------------------------------------------------------------------------- function GuestCard({ guest, index, total, isCapogruppo, onChange, paytouristEnabled, token, onSave, onSubmitAll, t }) { const _t = t || makeTrans("it"); const [open, setOpen] = React.useState(index === 0); // primo aperto di default const [showErrors, setShowErrors] = React.useState(false); const errors = validateGuest(guest, isCapogruppo, _t); const isValid = Object.keys(errors).length === 0 && guest.first_name && guest.last_name && guest.sex && guest.birth_date && guest.nationality; const isLast = index === total - 1; const handleSave = () => { setShowErrors(true); if (!isValid) return; setOpen(false); onSave(index); if (isLast && onSubmitAll) { // Piccolo timeout per permettere lo stato di "collapse" prima di inviare setTimeout(() => onSubmitAll(), 80); } }; return (
{/* Header accordion */}
setOpen(o => !o)} style={{ padding: "14px 20px", display: "flex", alignItems: "center", gap: 12, cursor: "pointer", background: isValid ? "var(--surface-success-soft)" : "var(--color-avorio)", userSelect: "none", borderRadius: "9px 9px 0 0" }} >
{isValid ? : index + 1}
{isCapogruppo ? {_t("capogruppo")} {_t("capogruppo_badge")} : `${_t("guest_n")} ${index + 1}`}
{(guest.first_name || guest.last_name) && (
{guest.first_name} {guest.last_name}
)}
▾
{/* Body */} {open && (
)}
); } // --------------------------------------------------------------------------- // CheckinPage — pagina principale // --------------------------------------------------------------------------- function CheckinPage({ token }) { const [info, setInfo] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [tokenExpired, setTokenExpired] = React.useState(false); const [submitDone, setSubmitDone] = React.useState(false); const [submitting, setSubmitting] = React.useState(false); const [toast, setToast] = React.useState(null); const [lang, setLang] = React.useState("it"); // Stato ospiti const [guests, setGuests] = React.useState([]); const [savedCards, setSavedCards] = React.useState(new Set()); const storageKey = STORAGE_KEY(token); const t = makeTrans(lang); const showToast = (msg, type = "success") => { setToast({ msg, type }); setTimeout(() => setToast(null), 4000); }; // --------------------------------------------------------------------------- // Load info // --------------------------------------------------------------------------- React.useEffect(() => { setLoading(true); _apiGet(token, "") .then(d => { setInfo(d); setLang(d.language?.slice(0, 2).toLowerCase() || "it"); if (d.already_submitted) setSubmitDone(true); // Ripristina dallo storage locale const saved = localStorage.getItem(storageKey); if (saved) { try { const parsed = JSON.parse(saved); if (parsed.length > 0) { setGuests(parsed); return; } } catch {} } // Numero ospiti da info (se già noto) altrimenti chiediamo 1 per default const n = Math.max(d.num_guests_expected || 1, 1); setGuests(Array.from({ length: n }, (_, i) => emptyGuest(i + 1))); }) .catch(err => { // 401 = token non valido, 410 = token scaduto/usato, 404 = sessione mancante if (err.status === 401 || err.status === 410 || err.status === 404) { setTokenExpired(true); } else { setError(err.message || "Errore nel caricamento"); } }) .finally(() => setLoading(false)); }, [token]); // Title + lang dinamici (l'ospite può essere non italiano) React.useEffect(() => { if (!info) return; document.title = `Check-in · ${info.property_name || "Argo"}`; document.documentElement.lang = info.language || "it"; }, [info?.property_name, info?.language]); // Autosave con debounce 2s. localStorage può lanciare in Safari privato // o quando lo storage è pieno: catturare evita di bloccare il form. const autosaveRef = React.useRef(null); React.useEffect(() => { clearTimeout(autosaveRef.current); autosaveRef.current = setTimeout(() => { if (guests.length > 0) { try { localStorage.setItem(storageKey, JSON.stringify(guests)); } catch {} } }, 2000); }, [guests]); // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- const updateGuest = (index, field, value) => { setGuests(prev => prev.map((g, i) => { if (i !== index) return g; const updated = { ...g, [field]: value }; // Ricalcola CF automaticamente per cittadini italiani (capogruppo) if (i === 0 && (field === "first_name" || field === "last_name" || field === "birth_date" || field === "sex" || field === "nationality")) { if (updated.nationality === "ITA") { // Richiede codice belfiore — non disponibile senza lookup esteso: lasciamo vuoto // L'utente può inserirlo manualmente } } return updated; })); }; const handleCardSave = (index) => { setSavedCards(prev => new Set([...prev, index])); // Apri la card successiva if (index < guests.length - 1) { setTimeout(() => { const nextEl = document.getElementById(`guest-card-${index + 1}`); if (nextEl) nextEl.scrollIntoView({ behavior: "smooth", block: "start" }); }, 100); } }; // Cap difensivo: nessun gruppo realistico supera questo numero; impedisce // che un dito incollato sul bottone faccia esplodere la pagina e il payload. const MAX_GUESTS = 20; const addGuest = () => { setGuests(prev => prev.length >= MAX_GUESTS ? (showToast(t("max_guests", { n: MAX_GUESTS }), "error"), prev) : [...prev, emptyGuest(prev.length + 1)]); }; const removeGuest = () => { if (guests.length <= 1) return; setGuests(prev => { const next = prev.slice(0, -1); return next; }); setSavedCards(prev => { const s = new Set(prev); s.delete(guests.length - 1); return s; }); }; const allValid = guests.length > 0 && guests.every((g, i) => { const errs = validateGuest(g, i === 0, t); return Object.keys(errs).length === 0 && g.first_name && g.last_name && g.sex && g.birth_date && g.nationality; }); const handleSubmit = async () => { if (!allValid) { showToast(t("fill_required"), "error"); return; } setSubmitting(true); try { const payload = guests.map((g, i) => ({ guest_index: i + 1, first_name: g.first_name.trim(), last_name: g.last_name.trim(), sex: g.sex, birth_date: g.birth_date, birth_place: g.birth_place?.trim() || null, nationality: g.nationality.toUpperCase(), residence_city: g.residence_city?.trim() || null, residence_country: g.residence_country?.toUpperCase() || null, // Campi documento: solo capogruppo document_type: i === 0 ? (g.document_type || null) : undefined, document_number: i === 0 ? (g.document_number?.trim() || null) : undefined, })); await _apiPost(token, payload); try { localStorage.removeItem(storageKey); } catch {} setSubmitDone(true); } catch (err) { // 409 = backend dice "già inviato": non è un errore lato ospite, è uno stato terminale. // 410/401 = token nel frattempo scaduto: portare alla schermata dedicata. if (err.status === 409) { try { localStorage.removeItem(storageKey); } catch {} setSubmitDone(true); } else if (err.status === 410 || err.status === 401) { setTokenExpired(true); } else { showToast(err.message || "Errore nell'invio. Riprova.", "error"); } } finally { setSubmitting(false); } }; // --------------------------------------------------------------------------- // Render states // --------------------------------------------------------------------------- if (loading) { return (
{t("loading")}
); } if (tokenExpired) { return (
{t("link_expired_title")}
{t("link_expired_msg")}
); } if (error) { return (
{t("link_invalid_title")}
{error}
); } if (submitDone) { const doneLines = t("done_msg").split("\n"); return (
{t("done_title")}
{doneLines.map((line, i) => ( {line}{i < doneLines.length - 1 &&
}
))}
); } // --------------------------------------------------------------------------- // Main render // --------------------------------------------------------------------------- return (
{/* Toast */} {toast && (
{toast.msg}
)} {/* Header */}
{["it","en","fr","de","es","pt"].map(l => ( ))}
{t("label_checkin")} digitale
{info?.property_name || ""}
{/* Nota legale */}
{t("legal_notice")}
{/* Info prenotazione */} {info && (
{[ { label: t("label_guest"), value: info.guest_name }, { label: t("label_checkin"), value: info.check_in || "·" }, { label: t("label_checkout"), value: info.check_out || "·" }, ].map(k => (
{k.label}
{k.value}
))}
)} {/* Istruzioni */}
{t("instructions")}
{/* Card ospiti */}
{guests.map((g, i) => (
updateGuest(i, field, value)} paytouristEnabled={info?.paytourist_enabled || false} token={token} onSave={handleCardSave} onSubmitAll={handleSubmit} t={t} />
))}
{/* Aggiungi / rimuovi ospiti */}
{guests.length > 1 && ( )}
{/* Footer submit sticky su mobile */}
{guests.length > 0 && (
{t("saved_locally")}
)} {!allValid && (
{t("fill_required")}
)}
); } window.CheckinPage = CheckinPage;