/** * Oveyo PWA — Composants partagés réutilisables * Style Vivinter : cards blanches + barre dégradée signature en bas */ // ============================================================ // GRADIENT BAR (signature Vivinter sous chaque card) // ============================================================ const GradientBar = () => (
); // ============================================================ // CARD avec gradient bar — pattern principal Vivinter // ============================================================ const Card = ({ children, className = '', noBar = false, ...rest }) => (
{children} {!noBar && }
); // ============================================================ // ICON BUBBLE — bonbonnière gris-bleu derrière les icônes (Vivinter style) // ============================================================ const IconBubble = ({ children, color = 'navy', size = 'md', className = '' }) => { const sizes = { sm: 'w-8 h-8 rounded-lg', md: 'w-10 h-10 rounded-xl', lg: 'w-12 h-12 rounded-xl', xl: 'w-14 h-14 rounded-2xl', }; const colors = { navy: { bg: '#E8EBF0', icon: '#102A43' }, orange: { bg: '#FEEEE3', icon: '#F4632B' }, success: { bg: '#E8F5E9', icon: '#15803D' }, slate: { bg: '#F1F4F9', icon: '#6B7891' }, }; const c = colors[color] || colors.navy; return (
{React.cloneElement(children, { style: { color: c.icon, ...children.props.style } })}
); }; // ============================================================ // FLASH TOAST (notification temporaire) // ============================================================ const FlashContext = React.createContext(null); const FlashProvider = ({ children }) => { const [flash, setFlash] = React.useState(null); const show = (type, message, duration = 3500) => { setFlash({ type, message }); setTimeout(() => setFlash(null), duration); }; return ( {children} {flash && (
{flash.message}
)}
); }; const useFlash = () => React.useContext(FlashContext); // ============================================================ // SHEET / BOTTOM MODAL // ============================================================ // V3.6 — corrections rigoureuses du scroll des sheets sur iOS Safari mobile : // // PROBLÈME : `max-height: 92vh` se calcule contre le viewport **complet** // (y compris la zone occupée par la toolbar Safari), alors que // `position: fixed; bottom: 0` se positionne au-dessus de la toolbar. // Conséquence : le bas du sheet est masqué par la toolbar Safari, le // scroll natif n'a pas conscience de ces 80-100px cachés, et les // boutons "Envoyer" se retrouvent inaccessibles. // // SOLUTION : on calcule la hauteur réellement visible via // `window.visualViewport.height` (la seule source de vérité quand la // toolbar Safari change d'état) et on l'applique en pixels. Recalculé // à chaque resize visualViewport (rotation, apparition/disparition // de la toolbar, clavier qui s'ouvre, etc.). // // Compteur global pour ne pas réinitialiser body.overflow quand un // sheet se ferme alors qu'un autre est encore ouvert. let _sheetOpenCount = 0; const Sheet = ({ open, onClose, title, children, maxHeight = '92vh' }) => { // Hauteur dynamique en pixels, basée sur la vraie zone visible. const [computedMaxH, setComputedMaxH] = React.useState(maxHeight); React.useEffect(() => { if (!open) return; _sheetOpenCount += 1; document.body.style.overflow = 'hidden'; // V4.1 — classe sur body pour permettre du CSS conditionnel // (notamment cacher la carte Leaflet derrière le sheet) document.body.classList.add('has-sheet-open'); const recompute = () => { const vv = window.visualViewport; const h = vv ? vv.height : window.innerHeight; // V4.0 — On retire ~92px (offset bottom : tabbar 80px + ~12px de safe-area) // pour que la maxHeight reflète l'espace VRAIMENT disponible pour le sheet. // Avant : Math.floor(h * 0.92) → le sheet pouvait théoriquement dépasser // l'écran par le haut (titre coupé sur iPhone X+ avec encoche). const usableH = Math.max(280, h - 92); setComputedMaxH(`${Math.floor(usableH)}px`); }; recompute(); const vv = window.visualViewport; if (vv) { vv.addEventListener('resize', recompute); vv.addEventListener('scroll', recompute); } window.addEventListener('resize', recompute); return () => { _sheetOpenCount = Math.max(0, _sheetOpenCount - 1); if (_sheetOpenCount === 0) { document.body.style.overflow = ''; document.body.classList.remove('has-sheet-open'); } if (vv) { vv.removeEventListener('resize', recompute); vv.removeEventListener('scroll', recompute); } window.removeEventListener('resize', recompute); }; }, [open, maxHeight]); if (!open) return null; return ( <>
{/* V3.8 P2 — refonte scroll : 1) Le sheet s'arrête AU-DESSUS de la tabbar (bottom = hauteur tabbar) au lieu de bottom:0 (ce qui mettait le bas du contenu derrière la tabbar opaque). 2) Layout flexbox interne : header sticky en haut + zone scrollable qui prend tout le reste avec `flex: 1; min-height: 0; overflow-y: auto`. C'est la SEULE manière fiable d'avoir un vrai scroll qui descend jusqu'en bas sur iOS Safari. */}

{title}

{/* Zone scrollable : flex:1 + min-height:0 sont CRUCIAUX pour iOS */}
{children}
); }; // ============================================================ // EMPTY STATE // ============================================================ const EmptyState = ({ icon: Icon, title, subtitle, action }) => (
{Icon && (
)}

{title}

{subtitle &&

{subtitle}

} {action &&
{action}
}
); // ============================================================ // LOADING SPINNER // ============================================================ const Spinner = ({ size = 32 }) => (
); // Export globaux window.OveyoUI = { GradientBar, Card, IconBubble, FlashProvider, useFlash, Sheet, EmptyState, Spinner, };