import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { legacyUrl } from '../legacyUrls.js'; const copy = { it: { pageTitle: 'Face ID', pageHeadline: 'Trova le tue foto con un selfie', pageIntro: 'Carica una tua immagine recente e lascia che Face ID cerchi le corrispondenze solo nella gara corrente.', userFallback: 'Sessione FaceAI', raceFallback: 'Gara corrente', statusReady: 'Pronto', statusProcessing: 'In lavorazione', statusCompleted: 'Completata', statusFailed: 'Errore', statusLabel: 'Stato', userLabel: 'Utente', raceLabel: 'Gara', languageLabel: 'Lingua', uploaderTitle: 'Carica il tuo selfie', uploaderHint: 'Puoi trascinare un file immagine oppure selezionarlo dal dispositivo.', uploaderDragIdle: 'Trascina qui il selfie', uploaderDragActive: 'Rilascia l’immagine per caricarla', uploaderBrowse: 'Scegli immagine', uploaderFormats: 'Formati supportati: JPG, PNG, WEBP', uploaderSelected: 'File selezionato', uploaderReplace: 'Sostituisci', uploaderRemove: 'Rimuovi', backButton: 'Torna alla pagina gara', uploadButton: 'Avvia ricerca Face ID', openSimulator: 'Apri il simulatore legacy', handoffMissing: 'Apri prima il simulatore legacy per generare il token firmato di handoff.', sessionLoading: 'Caricamento della sessione FaceAI…', submitLoading: 'Invio del selfie e preparazione della ricerca…', redirectLoading: 'Reindirizzamento alla pagina legacy filtrata in corso…', processingLoading: 'Ricerca biometrica in corso su tutte le foto della gara…', unavailableDefault: 'FaceAI non è disponibile per questa gara.', noFacesFoundMessage: 'Nessun volto rilevato nella foto caricata. Puoi tornare alla gara oppure provare con un altro selfie.', readyMessage: 'Seleziona un selfie per avviare una ricerca limitata alla gara corrente.', completedMessage: 'Ricerca completata. Trovate {count} foto corrispondenti.', failedMessage: 'La ricerca non è stata completata. Verifica il messaggio di errore e riprova.', matchesLabel: 'Foto trovate', redirectMessage: 'Reindirizzamento alla pagina legacy filtrata in corso…', noFileCta: 'Seleziona un’immagine per sbloccare la ricerca.', invalidImage: 'Seleziona un file immagine valido.', pollError: 'Impossibile leggere lo stato della ricerca.', searchFailed: 'La ricerca non è andata a buon fine.', processorUnavailable: 'Il motore FaceAI non è disponibile in questo momento. Riprova tra poco.', redirectError: 'Impossibile generare il link di ritorno.', chooseSelfie: 'Seleziona un selfie prima di avviare la ricerca.', raceDataUnavailable: 'I dati FaceAI non sono disponibili per questa gara.', invalidRaceData: 'I dati della gara ricevuti non sono validi. Torna alla pagina gara e riapri Face ID dalla gara corretta.', searchCreateError: 'Impossibile avviare la ricerca.', activeSearchExists: 'C\'e gia una ricerca in corso per questo utente. Attendi il completamento oppure ricarica la pagina.', rateLimited: 'Hai avviato troppe ricerche in poco tempo. Attendi un momento e riprova.', faceAiAlt: 'FaceAI', dropzoneDisabled: 'Il caricamento non è disponibile per questa gara.' }, en: { pageTitle: 'Face ID', pageHeadline: 'Find your photos with a selfie', pageIntro: 'Upload a recent picture of yourself and Face ID will search only within the current race.', userFallback: 'FaceAI session', raceFallback: 'Current race', statusReady: 'Ready', statusProcessing: 'Processing', statusCompleted: 'Completed', statusFailed: 'Error', statusLabel: 'Status', userLabel: 'User', raceLabel: 'Race', languageLabel: 'Language', uploaderTitle: 'Upload your selfie', uploaderHint: 'Drag an image here or choose it from your device.', uploaderDragIdle: 'Drag your selfie here', uploaderDragActive: 'Drop the image to upload it', uploaderBrowse: 'Choose image', uploaderFormats: 'Supported formats: JPG, PNG, WEBP', uploaderSelected: 'Selected file', uploaderReplace: 'Replace', uploaderRemove: 'Remove', backButton: 'Back to the race page', uploadButton: 'Start Face ID search', openSimulator: 'Open the legacy simulator', handoffMissing: 'Open the legacy simulator first to generate the signed handoff token.', sessionLoading: 'Loading the FaceAI session…', submitLoading: 'Uploading the selfie and preparing the search…', redirectLoading: 'Redirecting to the filtered legacy page…', processingLoading: 'Biometric search in progress across all race photos…', unavailableDefault: 'FaceAI is not available for this race.', noFacesFoundMessage: 'No faces were detected in the uploaded image. You can return to the race page or try another selfie.', readyMessage: 'Select a selfie to start a search limited to the current race.', completedMessage: 'Search completed. Found {count} matching photos.', failedMessage: 'The search did not complete. Check the message and try again.', matchesLabel: 'Photos found', redirectMessage: 'Redirecting to the filtered legacy page…', noFileCta: 'Select an image to unlock the search action.', invalidImage: 'Select a valid image file.', pollError: 'Unable to read the search status.', searchFailed: 'The search failed.', processorUnavailable: 'The FaceAI processor is temporarily unavailable. Please try again shortly.', redirectError: 'Unable to build the return link.', chooseSelfie: 'Choose a selfie before starting the search.', raceDataUnavailable: 'FaceAI data is not available for this race.', invalidRaceData: 'The race data received for this session is invalid. Go back to the race page and reopen Face ID from the correct race.', searchCreateError: 'Unable to start the search.', activeSearchExists: 'There is already a search running for this user. Wait for it to finish or reload the page.', rateLimited: 'Too many searches were started in a short time. Please wait a moment and try again.', faceAiAlt: 'FaceAI', dropzoneDisabled: 'Upload is not available for this race.' } }; const knownServerCodes = { PROCESSOR_UNAVAILABLE: 'processorUnavailable', ACTIVE_SEARCH_EXISTS: 'activeSearchExists', RATE_LIMITED: 'rateLimited', MISSING_SELFIE: 'chooseSelfie', RACE_PKL_UNAVAILABLE: 'raceDataUnavailable', RACE_DIRECTORY_NOT_FOUND: 'invalidRaceData', MISSING_RACE_STORAGE: 'invalidRaceData' }; const knownServerMessages = { 'No training dataset available for this race.': 'raceDataUnavailable', 'FaceAI data is not available for this race.': 'raceDataUnavailable', 'FaceAI is not available for this race.': 'unavailableDefault', 'Unable to read search status.': 'pollError', 'The search failed.': 'searchFailed', 'FaceAI processor is temporarily unavailable. Please try again shortly.': 'processorUnavailable', 'Unable to build return URL.': 'redirectError', 'Unable to create the search.': 'searchCreateError', 'Choose a selfie before starting the search.': 'chooseSelfie', 'There is already an operation being processed.': 'activeSearchExists', 'Too many search attempts. Please try again later.': 'rateLimited' }; const simulatorUrl = legacyUrl('/faceai_simulator.php?raceId=101&lang=it'); const legacyHomeUrl = legacyUrl('/'); function isInvalidRaceAvailability(availability) { return availability?.reasonCode === 'RACE_DIRECTORY_NOT_FOUND' || availability?.reasonCode === 'MISSING_RACE_STORAGE'; } function buildLegacyReturnUrl(url) { if (!url) { return legacyHomeUrl; } try { return new URL(url, window.location.href).toString(); } catch { return legacyHomeUrl; } } export function useFaceAiHome() { const session = ref(null); const loading = ref(true); const errorMessage = ref(''); const selectedFile = ref(null); const activeSearch = ref(null); const redirectUrl = ref(''); const isSubmitting = ref(false); const isRedirecting = ref(false); const isDragging = ref(false); const fileInput = ref(null); let pollTimer = null; let dragDepth = 0; const currentLocale = computed(() => { const language = (session.value?.lang || document.documentElement.lang || 'it').toLowerCase(); return language.startsWith('en') ? 'en' : 'it'; }); function t(key, params = {}) { const message = copy[currentLocale.value][key] || copy.it[key] || key; return Object.keys(params).reduce((text, paramKey) => text.replace(`{${paramKey}}`, String(params[paramKey])), message); } function localizeServerMessage(message, fallbackKey) { if (!message) { return t(fallbackKey); } if (currentLocale.value === 'en') { return message; } const mappedKey = knownServerMessages[message]; if (mappedKey) { return t(mappedKey); } return t(fallbackKey); } function localizeServerError(payload, fallbackKey) { const mappedCodeKey = payload?.code ? knownServerCodes[payload.code] : null; if (mappedCodeKey) { return t(mappedCodeKey); } return localizeServerMessage(payload?.error, fallbackKey); } function getAvailabilityUserMessage(availability, fallbackKey = 'unavailableDefault') { if (isInvalidRaceAvailability(availability)) { return t('invalidRaceData'); } return localizeServerMessage(availability?.message, fallbackKey); } function shouldLogFaceAiDebug() { return import.meta.env.DEV || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; } function logFaceAiDebug(label, extra = null) { if (!shouldLogFaceAiDebug()) { return; } const payload = { pageUrl: window.location.href, session: session.value, availability: raceAvailability.value, activeSearch: activeSearch.value, redirectUrl: redirectUrl.value, extra }; console.groupCollapsed(`[FaceAI] ${label}`); console.log(payload); console.groupEnd(); } function reportInvalidRaceAvailability(availability) { if (!isInvalidRaceAvailability(availability)) { return; } const details = { raceId: session.value?.race?.id || null, raceName: session.value?.race?.name || null, lang: session.value?.lang || currentLocale.value, reasonCode: availability.reasonCode, message: availability.message, storage: availability.storage || null, raceDir: availability.raceDir || null }; console.error(`[FaceAI] Invalid race data: ${JSON.stringify(details)}`); } const isWorking = computed(() => loading.value || isSubmitting.value || isRedirecting.value || activeSearch.value?.status === 'processing'); const isProcessingSearch = computed(() => isSubmitting.value || activeSearch.value?.status === 'processing'); const raceAvailability = computed(() => session.value?.availability || null); const canPickFile = computed(() => Boolean(session.value) && raceAvailability.value?.available === true && !isWorking.value); const canStartSearch = computed(() => { if (!session.value || !selectedFile.value) { return false; } if (!session.value.access?.faceAiAllowed) { return false; } return raceAvailability.value?.available === true && !isWorking.value; }); const selectedFileSizeLabel = computed(() => { if (!selectedFile.value?.size) { return ''; } const sizeInMb = selectedFile.value.size / (1024 * 1024); if (sizeInMb >= 1) { return `${sizeInMb.toFixed(1)} MB`; } return `${Math.max(1, Math.round(selectedFile.value.size / 1024))} KB`; }); const activeSearchStatusLabel = computed(() => { const status = activeSearch.value?.status; if (status === 'processing') { return t('statusProcessing'); } if (status === 'completed') { return t('statusCompleted'); } if (status === 'failed') { return t('statusFailed'); } return t('statusReady'); }); const busyLabel = computed(() => { if (loading.value) { return t('sessionLoading'); } if (isSubmitting.value) { return t('submitLoading'); } if (isRedirecting.value) { return t('redirectLoading'); } if (activeSearch.value?.status === 'processing') { return t('processingLoading'); } return ''; }); const statusLabel = computed(() => { if (!activeSearch.value) { if (session.value && raceAvailability.value && !raceAvailability.value.available) { return getAvailabilityUserMessage(raceAvailability.value, 'unavailableDefault'); } return t('readyMessage'); } if (activeSearch.value.status === 'completed') { if (activeSearch.value.completionCode === 'NO_FACES_FOUND') { return t('noFacesFoundMessage'); } return t('completedMessage', { count: activeSearch.value.matchCount ?? 0 }); } if (activeSearch.value.status === 'failed') { return localizeServerMessage(activeSearch.value.errorMessage, 'failedMessage'); } return t('processingLoading'); }); function openFilePicker() { if (!canPickFile.value) { return; } fileInput.value?.click(); } function setSelectedFile(file) { if (!file) { selectedFile.value = null; return; } if (!file.type || !file.type.startsWith('image/')) { selectedFile.value = null; errorMessage.value = t('invalidImage'); if (fileInput.value) { fileInput.value.value = ''; } return; } selectedFile.value = file; errorMessage.value = ''; } function clearSelectedFile() { selectedFile.value = null; if (fileInput.value) { fileInput.value.value = ''; } } function onFileChange(event) { setSelectedFile(event.target.files?.[0] || null); } function onDragEnter(event) { if (!canPickFile.value) { return; } event.preventDefault(); dragDepth += 1; isDragging.value = true; } function onDragOver(event) { if (!canPickFile.value) { return; } event.preventDefault(); isDragging.value = true; } function onDragLeave(event) { if (!canPickFile.value) { return; } event.preventDefault(); dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) { isDragging.value = false; } } function onDrop(event) { if (!canPickFile.value) { return; } event.preventDefault(); dragDepth = 0; isDragging.value = false; setSelectedFile(event.dataTransfer?.files?.[0] || null); } async function loadSession() { loading.value = true; const response = await fetch('/api/session', { credentials: 'include' }); if (!response.ok) { const payload = await response.json().catch(() => ({})); loading.value = false; logFaceAiDebug('Session load failed', { status: response.status, payload }); if (response.status === 401 || response.status === 403) { window.location.replace(payload.redirectUrl || legacyHomeUrl); } return; } session.value = await response.json(); loading.value = false; if (session.value?.availability && !session.value.availability.available && isInvalidRaceAvailability(session.value.availability)) { errorMessage.value = getAvailabilityUserMessage(session.value.availability, 'invalidRaceData'); reportInvalidRaceAvailability(session.value.availability); } logFaceAiDebug('Session loaded'); } async function pollSearch(searchId) { const response = await fetch(`/api/searches/${searchId}`, { credentials: 'include' }); if (!response.ok) { const payload = await response.json().catch(() => ({})); errorMessage.value = localizeServerError(payload, 'pollError'); isSubmitting.value = false; logFaceAiDebug('Search polling failed', { searchId, status: response.status, payload }); return; } activeSearch.value = await response.json(); logFaceAiDebug('Search status updated', { searchId, status: activeSearch.value.status }); if (activeSearch.value.status === 'failed') { isSubmitting.value = false; errorMessage.value = localizeServerMessage(activeSearch.value.errorMessage, 'searchFailed'); return; } if (activeSearch.value.status === 'completed') { isSubmitting.value = false; if (activeSearch.value.completionCode === 'NO_FACES_FOUND') { isRedirecting.value = false; redirectUrl.value = ''; clearSelectedFile(); logFaceAiDebug('Search completed without detectable faces', { searchId }); return; } const redirectResponse = await fetch(`/api/searches/${searchId}/redirect`, { credentials: 'include' }); const payload = await redirectResponse.json(); if (!redirectResponse.ok) { errorMessage.value = localizeServerError(payload, 'redirectError'); logFaceAiDebug('Redirect build failed', { searchId, payload }); return; } redirectUrl.value = payload.url; isRedirecting.value = true; logFaceAiDebug('Redirect URL ready', { searchId, url: payload.url }); window.setTimeout(() => { window.location.href = payload.url; }, 1200); return; } pollTimer = window.setTimeout(() => { pollSearch(searchId); }, 1500); } async function submitSearch() { errorMessage.value = ''; redirectUrl.value = ''; isRedirecting.value = false; if (!selectedFile.value) { errorMessage.value = t('chooseSelfie'); return; } if (!session.value?.access?.faceAiAllowed || !raceAvailability.value?.available) { errorMessage.value = getAvailabilityUserMessage(raceAvailability.value, 'raceDataUnavailable'); return; } isSubmitting.value = true; const formData = new FormData(); formData.set('raceId', session.value.race.id); formData.set('selfie', selectedFile.value); const response = await fetch('/api/searches', { method: 'POST', credentials: 'include', body: formData }); const payload = await response.json(); if (!response.ok) { errorMessage.value = localizeServerError(payload, 'searchCreateError'); isSubmitting.value = false; logFaceAiDebug('Search creation failed', { status: response.status, payload }); return; } activeSearch.value = payload; logFaceAiDebug('Search created', { payload }); pollSearch(payload.id); } function returnToLegacy() { const returnUrl = buildLegacyReturnUrl(session.value?.returnUrl); logFaceAiDebug('Returning to legacy race page', { returnUrl }); window.location.replace(returnUrl); } onMounted(loadSession); onBeforeUnmount(() => { if (pollTimer) { window.clearTimeout(pollTimer); } }); return { activeSearch, activeSearchStatusLabel, busyLabel, canPickFile, canStartSearch, clearSelectedFile, currentLocale, errorMessage, fileInput, isDragging, isProcessingSearch, isRedirecting, isSubmitting, isWorking, loading, onDragEnter, onDragLeave, onDragOver, onDrop, onFileChange, openFilePicker, redirectUrl, returnToLegacy, selectedFile, selectedFileSizeLabel, session, simulatorUrl, statusLabel, submitSearch, t }; }