// ============================================================================
// 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 (
);
};
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 */}
{/* 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 (
);
};
// ============================================================================
// 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 && (
)}
{/* 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 && (
)}
);
};
// ============================================================================
// 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 && (

setLoading(false)}
onError={() => {
setLoading(false);
setError('Erreur de génération du screenshot via Cloudflare API');
}}
/>
)}
{/* Préchargement de la nouvelle image (invisible) */}
{nextScreenshotUrl && (

{
// 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...