// ============================================================================ // COMPOSANTS UI RÉUTILISABLES // ============================================================================ const Button = ({ children, onClick, variant = 'primary', className = '', disabled = false, icon = null, id = '' }) => { const variants = { primary: 'btn-primary text-white', secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300', danger: 'bg-red-500 text-white hover:bg-red-600', success: 'bg-green-500 text-white hover:bg-green-600' }; return ( ); }; const Input = ({ label, type = 'text', value, onChange, placeholder = '', required = false, icon = null, id = '' }) => { const inputId = id || `sm-input-${label?.toLowerCase().replace(/\s+/g, '-')}`; return (
{label && ( )}
{icon && ( )} onChange(e.target.value)} placeholder={placeholder} required={required} className={`sm-input w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent ${icon ? 'pl-10' : ''}`} />
); }; const Select = ({ label, value, onChange, options, children, required = false, id = '', icon = '' }) => { const selectId = id || `sm-select-${label?.toLowerCase().replace(/\s+/g, '-')}`; return (
{label && ( )}
); }; const Card = ({ children, className = '', title = null, id = '' }) => { return (
{title &&

{title}

}
{children}
); }; const Modal = ({ isOpen, onClose, title, children, size = 'md', id = '' }) => { // Fermer avec la touche ESC useEffect(() => { if (!isOpen) return; const handleEscape = (e) => { if (e.key === 'Escape') { console.log('🔑 [Modal] Touche ESC détectée, fermeture du modal'); onClose(); } }; document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [isOpen, onClose]); if (!isOpen) return null; const sizes = { sm: 'max-w-md', md: 'max-w-2xl', lg: 'max-w-4xl', xl: 'max-w-6xl' }; return (

{title}

{children}
); }; const Loader = ({ id = 'sm-loader' }) => (
); const EmptyState = ({ icon, title, message, action = null, id = 'sm-empty-state' }) => (

{title}

{message}

{action &&
{action}
}
); // ============================================================================ // PAGE DE LOGIN // ============================================================================ const LoginPage = () => { const { login, register } = useAuth(); const toast = useToast(); const [isLogin, setIsLogin] = useState(true); const [loading, setLoading] = useState(false); // Formulaire de login const [loginEmail, setLoginEmail] = useState(''); const [loginPassword, setLoginPassword] = useState(''); // Formulaire de register const [regCompany, setRegCompany] = useState(''); const [regContact, setRegContact] = useState(''); const [regEmail, setRegEmail] = useState(''); const [regPassword, setRegPassword] = useState(''); const [regPhone, setRegPhone] = useState(''); const [regAddress, setRegAddress] = useState(''); const handleLogin = async (e) => { e.preventDefault(); setLoading(true); const result = await login(loginEmail, loginPassword); if (result.success) { toast.success('Connexion réussie !'); } else { toast.error(result.error || 'Erreur de connexion'); } setLoading(false); }; const handleRegister = async (e) => { e.preventDefault(); setLoading(true); const result = await register({ company_name: regCompany, contact_name: regContact, email: regEmail, password: regPassword, phone: regPhone, address: regAddress }); if (result.success) { toast.success('Compte créé avec succès ! Vous pouvez maintenant vous connecter.'); setIsLogin(true); // Pré-remplir l'email setLoginEmail(regEmail); } else { toast.error(result.error || 'Erreur lors de la création du compte'); } setLoading(false); }; return (
{/* Logo */}

SmartScreen Manager

Gérez vos écrans d'affichage dynamique

{/* Tabs */}
{/* Formulaire de connexion */} {isLogin ? (
) : ( // Formulaire d'inscription
)} {/* Compte de test */}

Compte de test

Email : admin@livit.ch
Password : password

); }; // ============================================================================ // COMPOSANT SIDEBAR // ============================================================================ const Sidebar = ({ currentView, onViewChange }) => { const { customer, logout } = useAuth(); const menuItems = [ { id: 'dashboard', icon: 'fa-home', label: 'Dashboard' }, { id: 'configurations', icon: 'fa-cog', label: 'Configurations' }, { id: 'screens', icon: 'fa-tv', label: 'Écrans' }, { id: 'advertisings', icon: 'fa-image', label: 'Publicités' }, { id: 'news', icon: 'fa-exclamation-triangle', label: 'Alertes' }, { id: 'sliders', icon: 'fa-align-left', label: 'Messages' }, { id: 'numbers', icon: 'fa-phone', label: 'Contacts' }, { id: 'real_estate', icon: 'fa-building', label: 'Immobilier' }, { id: 'concierges', icon: 'fa-key', label: 'Concierges' }, { id: 'map', icon: 'fa-map-marked-alt', label: 'Carte' }, { id: 'settings', icon: 'fa-user-cog', label: 'Paramètres' }, ]; return (
{/* Logo */}

Smart-Hall

{customer?.company_name}

{/* Menu */} {/* User info */}
); }; // ============================================================================ // COMPOSANT TPG STOP SEARCH - Recherche d'arrêt TPG avec auto-complétion // ============================================================================ const TPGStopSearch = ({ label = 'Arrêt de bus le plus proche', value, onStopSelected, placeholder = 'Rechercher un arrêt TPG...', required = false }) => { const [searchTerm, setSearchTerm] = useState(value || ''); const [filteredStops, setFilteredStops] = useState([]); const [isLoading, setIsLoading] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false); const [shouldSearch, setShouldSearch] = useState(false); // Seulement rechercher si l'utilisateur tape const searchTimeout = React.useRef(null); // Mettre à jour le searchTerm quand value change (lors de l'édition) useEffect(() => { if (value !== undefined && value !== searchTerm) { setSearchTerm(value || ''); setShowSuggestions(false); setFilteredStops([]); setShouldSearch(false); // NE PAS rechercher lors du chargement initial } }, [value]); // Rechercher les arrêts via l'API proxy avec debounce useEffect(() => { // Clear previous timeout if (searchTimeout.current) { clearTimeout(searchTimeout.current); } // Ne rechercher que si l'utilisateur a commencé à taper if (!shouldSearch) { return; } if (searchTerm.length < 2) { setFilteredStops([]); setShowSuggestions(false); return; } // Debounce la recherche de 400ms pour éviter trop de requêtes searchTimeout.current = setTimeout(async () => { setIsLoading(true); try { const data = await api.searchTPGStops(searchTerm); if (data.stops && data.stops.length > 0) { // Transformer les données au format attendu const transformedStops = data.stops.map(stop => ({ nom: stop.stopName || '', code: stop.stopCode || '', commune: stop.commune || 'Genève', latitude: stop.coordinates ? stop.coordinates[1] : null, longitude: stop.coordinates ? stop.coordinates[0] : null, codesCount: stop.codes ? stop.codes.length : 1 })); setFilteredStops(transformedStops); setShowSuggestions(true); } else { setFilteredStops([]); setShowSuggestions(false); } } catch (error) { console.error('❌ [TPGStopSearch] Erreur recherche:', error); setFilteredStops([]); } finally { setIsLoading(false); } }, 400); return () => { if (searchTimeout.current) { clearTimeout(searchTimeout.current); } }; }, [searchTerm, shouldSearch]); const handleSelectStop = (stop) => { console.log('👆 [TPGStopSearch] handleSelectStop appelé avec:', stop); // Sécuriser les valeurs avant de les utiliser const safStop = { nom: stop.nom || '', code: stop.code || '', commune: stop.commune || 'Genève', latitude: stop.latitude || null, longitude: stop.longitude || null }; console.log('👆 [TPGStopSearch] safStop créé:', safStop); console.log('👆 [TPGStopSearch] Mise à jour searchTerm avec:', safStop.nom); // Désactiver la recherche et fermer les suggestions setShouldSearch(false); setSearchTerm(safStop.nom); setShowSuggestions(false); setFilteredStops([]); console.log('👆 [TPGStopSearch] Appel onStopSelected avec:', safStop); onStopSelected(safStop); }; const handleInputChange = (value) => { console.log('⌨️ [TPGStopSearch] handleInputChange:', value); setShouldSearch(true); // Activer la recherche car l'utilisateur tape setSearchTerm(value); // Si l'utilisateur efface le champ, notifier le parent if (!value) { console.log('⌨️ [TPGStopSearch] Champ effacé, notification parent'); onStopSelected({ nom: '', code: '' }); } }; // Fermer les suggestions quand on clique en dehors useEffect(() => { const handleClickOutside = (e) => { if (!e.target.closest('.tpg-stop-search-container')) { setShowSuggestions(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); return (
handleInputChange(e.target.value)} disabled={isLoading} required={required} /> {isLoading && (
)}
{/* Suggestions dropdown */} {showSuggestions && filteredStops.length > 0 && (
{filteredStops.map((stop, index) => (
handleSelectStop(stop)} >
{stop.nom || 'Sans nom'}
{stop.code && ( {stop.code} )} {stop.commune || 'Genève'} {stop.codesCount > 1 && ( {stop.codesCount} quais )}
))}
)} {/* Message si aucun résultat */} {showSuggestions && searchTerm.length >= 2 && filteredStops.length === 0 && !isLoading && (

Aucun arrêt trouvé pour "{searchTerm}"

)}
); }; // ============================================================================ // COMPOSANT ADDRESS AUTOCOMPLETE - API Maps SwissApp // ============================================================================ /** * Composant d'autocomplétion d'adresses utilisant api.maps.swissapp.net * * Features: * - Recherche avec debounce (400ms) * - Navigation au clavier (flèches, Enter, Escape) * - Géocodage automatique pour obtenir les coordonnées GPS * - États de chargement visuels * - Gestion des erreurs * - Responsive et accessible * * @param {string} label - Label du champ (default: "Adresse complète") * @param {string} value - Valeur actuelle de l'adresse * @param {function} onAddressSelected - Callback appelé lors de la sélection * Reçoit: { address, latitude, longitude, place_id } * @param {string} placeholder - Texte du placeholder * @param {boolean} required - Si le champ est obligatoire * * @example * { * setFormAddress(data.address); * setFormLatitude(data.latitude); * setFormLongitude(data.longitude); * }} * placeholder="Commencez à taper une adresse..." * required * /> */ const AddressAutocomplete = ({ id = 'sm-address-autocomplete-input', label = 'Adresse complète', value, onAddressSelected, placeholder = 'Commencez à taper une adresse en Suisse...', required = false }) => { // ======================================================================== // STATES // ======================================================================== const [searchTerm, setSearchTerm] = useState(value || ''); const [suggestions, setSuggestions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false); const [isGeocoding, setIsGeocoding] = useState(false); const [isUserTyping, setIsUserTyping] = useState(false); console.log('🗺️ [AddressAutocomplete] Render:', { value, searchTerm, suggestionsCount: suggestions.length, isUserTyping }); // Fonction de recherche d'adresses const searchAddresses = async (input) => { console.log('🔍 [AddressAutocomplete] Recherche:', input); setIsLoading(true); try { const result = await api.searchAddress(input); console.log('📥 [AddressAutocomplete] Résultat:', result); if (result.success) { console.log('✅ [AddressAutocomplete] Suggestions:', result.suggestions.length); setSuggestions(result.suggestions); setShowSuggestions(true); } else { console.error('❌ [AddressAutocomplete] Échec:', result.error); setSuggestions([]); } } catch (error) { console.error('❌ [AddressAutocomplete] Erreur chargement:', error); setSuggestions([]); } finally { setIsLoading(false); } }; // Mettre à jour le searchTerm quand value change (lors de l'édition) useEffect(() => { console.log('🔄 [AddressAutocomplete] value changed:', value); if (value !== undefined && value !== searchTerm) { setSearchTerm(value || ''); // Ne pas déclencher de recherche, c'est juste un chargement initial setIsUserTyping(false); } }, [value]); // Rechercher les adresses avec debounce SEULEMENT si l'utilisateur tape useEffect(() => { // Ne rien faire si ce n'est pas une saisie utilisateur if (!isUserTyping) { return; } if (searchTerm.length < 3) { setSuggestions([]); setShowSuggestions(false); return; } // Debounce de 500ms const timeoutId = setTimeout(() => { searchAddresses(searchTerm); }, 500); return () => clearTimeout(timeoutId); }, [searchTerm, isUserTyping]); const handleSelectAddress = async (suggestion) => { console.log('👆 [AddressAutocomplete] Adresse sélectionnée:', suggestion); const address = suggestion.description; setSearchTerm(address); setShowSuggestions(false); setIsGeocoding(true); setIsUserTyping(false); // Désactiver le flag pour éviter une nouvelle recherche try { // Utiliser place_geometry avec le place_id pour obtenir les coordonnées GPS exactes console.log('📍 [AddressAutocomplete] Récupération des détails via place_id:', suggestion.place_id); const result = await api.getPlaceGeometry(suggestion.place_id); if (result.success && result.location) { console.log('✅ [AddressAutocomplete] Détails récupérés:', result.location); // Mettre à jour le champ avec l'adresse formatée const formattedAddress = result.location.formatted || address; setSearchTerm(formattedAddress); // Notifier le parent avec l'adresse et les coordonnées onAddressSelected({ address: formattedAddress, latitude: result.location.lat, longitude: result.location.lng, place_id: suggestion.place_id }); } else { console.error('❌ [AddressAutocomplete] Échec récupération détails:', result.error); // Notifier quand même avec juste l'adresse onAddressSelected({ address: address, latitude: null, longitude: null, place_id: suggestion.place_id }); } } catch (error) { console.error('❌ [AddressAutocomplete] Erreur récupération détails:', error); // Notifier quand même avec juste l'adresse onAddressSelected({ address: address, latitude: null, longitude: null, place_id: suggestion.place_id }); } finally { setIsGeocoding(false); } }; const handleInputChange = (value) => { console.log('⌨️ [AddressAutocomplete] Input change (saisie utilisateur):', value); setSearchTerm(value); setIsUserTyping(true); // Marquer comme saisie utilisateur // Si l'utilisateur efface le champ, notifier le parent if (!value) { console.log('⌨️ [AddressAutocomplete] Champ effacé, notification parent'); onAddressSelected({ address: '', latitude: null, longitude: null }); } }; // Fermer les suggestions quand on clique en dehors useEffect(() => { const handleClickOutside = (e) => { if (!e.target.closest('.address-autocomplete-container')) { setShowSuggestions(false); } }; if (showSuggestions) { document.addEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside); } }, [showSuggestions]); return (
handleInputChange(e.target.value)} placeholder={placeholder} required={required} disabled={isGeocoding} className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed" />
{/* Indicateur de chargement */} {(isLoading || isGeocoding) && (
)} {/* Liste des suggestions */} {showSuggestions && suggestions.length > 0 && !isGeocoding && (
{suggestions.map((suggestion, index) => ( ))}
)} {/* Message si aucune suggestion */} {showSuggestions && suggestions.length === 0 && !isLoading && searchTerm.length >= 3 && (

Aucune adresse trouvée

)}
{/* Message de géocodage */} {isGeocoding && (

Récupération des coordonnées GPS...

)}
); }; // ============================================================================ // COMPOSANT D'AUTOCOMPLÉTION DE VILLE POUR MÉTÉO // ============================================================================ const CityAutocomplete = ({ id = 'sm-city-autocomplete-input', label = 'Ville pour la météo', value, onChange, placeholder = 'Recherchez une ville en Suisse...', required = false }) => { const [searchTerm, setSearchTerm] = useState(value || ''); const [suggestions, setSuggestions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false); const [isUserTyping, setIsUserTyping] = useState(false); // Fonction de recherche de villes const searchCities = async (input) => { console.log('🏙️ [CityAutocomplete] Recherche ville:', input); setIsLoading(true); try { const result = await api.searchAddress(input); console.log('📥 [CityAutocomplete] Résultat:', result); if (result.success && result.suggestions) { // Filtrer pour ne garder que les villes (locality) const cities = result.suggestions.filter(s => s.types && (s.types.includes('locality') || s.types.includes('administrative_area_level_3')) ); console.log('✅ [CityAutocomplete] Villes filtrées:', cities.length); setSuggestions(cities); setShowSuggestions(cities.length > 0); } else { setSuggestions([]); } } catch (error) { console.error('❌ [CityAutocomplete] Erreur:', error); setSuggestions([]); } finally { setIsLoading(false); } }; // Mettre à jour le searchTerm quand value change useEffect(() => { if (value !== undefined && value !== searchTerm) { setSearchTerm(value || ''); setIsUserTyping(false); } }, [value]); // Rechercher avec debounce SEULEMENT si l'utilisateur tape useEffect(() => { if (!isUserTyping) return; if (searchTerm.length < 2) { setSuggestions([]); setShowSuggestions(false); return; } const timeoutId = setTimeout(() => { searchCities(searchTerm); }, 500); return () => clearTimeout(timeoutId); }, [searchTerm, isUserTyping]); const handleSelectCity = (suggestion) => { console.log('👆 [CityAutocomplete] Ville sélectionnée:', suggestion); const cityName = suggestion.structured_formatting?.main_text || suggestion.description; setSearchTerm(cityName); setShowSuggestions(false); setIsUserTyping(false); // Notifier le parent if (onChange) { onChange(cityName); } }; const handleInputChange = (val) => { console.log('⌨️ [CityAutocomplete] Saisie:', val); setSearchTerm(val); setIsUserTyping(true); if (!val && onChange) { onChange(''); } }; // Fermer les suggestions au clic extérieur useEffect(() => { const handleClickOutside = (e) => { if (!e.target.closest('.city-autocomplete-container')) { setShowSuggestions(false); } }; if (showSuggestions) { document.addEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside); } }, [showSuggestions]); return (
handleInputChange(e.target.value)} placeholder={placeholder} required={required} className="w-full px-4 py-3 pl-10 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent" />
{/* Indicateur de chargement */} {isLoading && (
)} {/* Liste des suggestions */} {showSuggestions && suggestions.length > 0 && (
{suggestions.map((suggestion, index) => ( ))}
)} {/* Pas de résultat */} {showSuggestions && suggestions.length === 0 && !isLoading && searchTerm.length >= 2 && (

Aucune ville trouvée

)}
); }; // ============================================================================ // COMPOSANT CONTENT CARD - Affichage d'un élément de contenu (pub, alerte, etc.) // ============================================================================ const ContentCard = ({ type, title, description, source, readOnly = false, onEdit, onDelete, onViewConfig, data = {} }) => { const icons = { advertising: 'fa-image', alert: 'fa-exclamation-triangle', message: 'fa-comment', contact: 'fa-phone', slider: 'fa-align-left', number: 'fa-phone-square' }; const typeLabels = { advertising: 'Publicité', alert: 'Alerte', message: 'Message', contact: 'Contact', slider: 'Message défilant', number: 'Numéro d\'urgence' }; const colors = { config: 'bg-gray-50 border-gray-300 text-gray-700', screen: 'bg-orange-50 border-orange-300 text-orange-900' }; return (
{/* IcĂ´ne du type */}
{/* Contenu */}
{title}
{typeLabels[type] || type}
{description && (

{description}

)}

{source === 'config' ? ( Depuis la configuration ) : ( Spécifique à cet écran )}

{/* Actions */}
{readOnly ? ( ) : ( <> )}
); }; // ============================================================================ // COMPOSANT PREVIEW ÉCRAN - SCREENSHOT IMAGE // ============================================================================ const ScreenPreviewModal = ({ previewUrl, onClose }) => { const [screenshotUrl, setScreenshotUrl] = useState(''); const [nextScreenshotUrl, setNextScreenshotUrl] = useState(''); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [lastUpdate, setLastUpdate] = useState(new Date()); const intervalRef = useRef(null); // Extraire le MD5 de l'URL const getMd5FromUrl = (url) => { const match = url.match(/md5=([a-zA-Z0-9]+)/); return match ? match[1] : null; }; // Générer l'URL du screenshot depuis l'API const loadScreenshot = (isInitialLoad = false) => { const md5 = getMd5FromUrl(previewUrl); if (!md5) { setError('MD5 non trouvé dans l\'URL'); return; } // Détecter l'environnement const isLocal = window.location.hostname.includes('local'); const apiUrl = isLocal ? 'https://local.smarthall.api:8890' : 'https://api.smarthall.ch'; // Ajouter un timestamp pour éviter le cache navigateur const url = `${apiUrl}/screenshot.php?md5=${md5}&t=${Date.now()}`; if (isInitialLoad) { setLoading(true); setScreenshotUrl(url); } else { setRefreshing(true); setNextScreenshotUrl(url); } setLastUpdate(new Date()); }; // Effet au montage et rafraîchissement périodique useEffect(() => { loadScreenshot(true); // Rafraîchir toutes les 60 secondes (1 minute) intervalRef.current = setInterval(() => { loadScreenshot(false); }, 60000); return () => { if (intervalRef.current) { clearInterval(intervalRef.current); } }; }, [previewUrl]); // Fermer avec Échap useEffect(() => { const handleEscape = (e) => { if (e.key === 'Escape') { onClose(); } }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [onClose]); return (
{/* Bouton fermer */} {/* Indicateur de rafraîchissement */}
Capture automatique (1 min) • {lastUpdate.toLocaleTimeString('fr-CH')}
{/* Image du screenshot - Plein écran */}
{error ? (

{error}

Vérifiez que l'URL est accessible publiquement

L'API Cloudflare doit pouvoir accéder à screen.smarthall.ch

) : ( <> {/* Image principale visible */} {screenshotUrl && ( Aperçu de l'écran SmartScreen setLoading(false)} onError={() => { setLoading(false); setError('Erreur de génération du screenshot via Cloudflare API'); }} /> )} {/* Préchargement de la nouvelle image (invisible) */} {nextScreenshotUrl && ( Préchargement { // Remplacer l'ancienne image par la nouvelle setScreenshotUrl(nextScreenshotUrl); setNextScreenshotUrl(''); setRefreshing(false); }} onError={() => { setNextScreenshotUrl(''); setRefreshing(false); }} /> )} {/* Indicateur de chargement initial uniquement */} {loading && (

Génération du screenshot...

Cela peut prendre quelques secondes

)} {/* Petit indicateur de rafraîchissement en cours (discret) */} {refreshing && !loading && (
Rafraîchissement...
)} )}
{/* Info bas de page */}
Capture image sécurisée • Rafraîchit toutes les minutes | {getMd5FromUrl(previewUrl)}
); }; // Suite dans le fichier app.jsx...