Files
crowdlending-app/frontend/src/components/UserMenu.jsx
T

391 lines
17 KiB
React

import { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import { memberInitials, memberLabel } from '../utils/format.js';
/* ── Icons ───────────────────────────────────────────────────── */
function IconUser() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>;
}
function IconLogout() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>;
}
function IconAdmin() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="9" cy="6" r="3"/><path d="M2 16c0-3.3 3.1-6 7-6"/><path d="M14 13l-1.5 1.5L14 16"/><circle cx="15.5" cy="14.5" r="2.5"/></svg>;
}
function IconSettings() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><circle cx="8" cy="6" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="12" x2="20" y2="12"/><circle cx="16" cy="12" r="2" fill="var(--user-menu-bg, #1e2d4a)"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="10" cy="18" r="2" fill="var(--user-menu-bg, #1e2d4a)"/></svg>;
}
function IconAide() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>;
}
function IconComm() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>;
}
function IconRefresh() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>;
}
function IconChevronRight() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>;
}
function IconChevronUp({ open }) {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"
style={{ transition: 'transform .2s', transform: open ? 'rotate(0deg)' : 'rotate(180deg)', flexShrink: 0 }}
aria-hidden="true">
<path d="M18 15l-6-6-6 6"/>
</svg>
);
}
function IconCheck() {
return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>;
}
function IconTeam() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>;
}
/* ── Avatars ─────────────────────────────────────────────────── */
function UserAvatar({ user, size = 36 }) {
const initials = user?.display_name
? user.display_name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
: (user?.email || '?')[0].toUpperCase();
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: 'linear-gradient(135deg, #1e40af 0%, #1e3a8a 100%)',
border: '2px solid rgba(74,222,128,.5)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
letterSpacing: '.02em', userSelect: 'none',
}}>
{initials}
</div>
);
}
function MemberAvatar({ member, size = 28 }) {
const isEntreprise = member?.type === 'entreprise';
const bg = isEntreprise
? 'linear-gradient(135deg, #3730a3, #4338ca)'
: 'linear-gradient(135deg, #1e3a8a, #1e40af)';
return (
<div style={{
width: size, height: size, borderRadius: '50%',
background: bg, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontWeight: 700, fontSize: Math.round(size * 0.38), flexShrink: 0,
letterSpacing: '.02em', userSelect: 'none',
}}>
{memberInitials(member)}
</div>
);
}
function TeamBadge({ size = 28 }) {
return (
<div className="team-badge" style={{
width: size, height: size, borderRadius: '50%',
background: '#1e3a8a',
border: '1.5px solid rgba(255,255,255,.25)',
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<IconTeam />
</div>
);
}
/* ── Composant principal ─────────────────────────────────────── */
export default function UserMenu() {
const { user, logout, isAdmin } = useAuth();
const { sidebarCollapsed } = useUi();
const { investisseurs, activeView, activeViewMember, setActiveView } = useInvestisseur();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [popupStyle, setPopupStyle] = useState({});
const [subStyle, setSubStyle] = useState({});
const triggerRef = useRef(null);
const popupRef = useRef(null);
const subRef = useRef(null);
const closeTimer = useRef(null);
const famille = investisseurs.filter(i => i.type !== 'entreprise');
const entreprises = investisseurs.filter(i => i.type === 'entreprise');
const POPUP_W = 250;
const SUB_W = 230;
/* ── Calcul position (fixed = échappe overflow:hidden) ────── */
const computePosition = useCallback(() => {
if (!triggerRef.current) return;
const r = triggerRef.current.getBoundingClientRect();
// visualViewport est fiable sur Safari mobile (window.innerHeight ne l'est pas)
const vh = window.visualViewport ? window.visualViewport.height : window.innerHeight;
let mainLeft, mainBottom, mainWidth;
if (sidebarCollapsed) {
mainLeft = r.right + 8;
mainBottom = vh - r.bottom;
mainWidth = POPUP_W;
} else {
mainLeft = r.left;
mainBottom = vh - r.top + 6;
mainWidth = r.width;
}
setPopupStyle({
position: 'fixed', left: mainLeft, bottom: mainBottom,
width: mainWidth, top: 'auto', right: 'auto',
});
setSubStyle({
position: 'fixed',
left: mainLeft + (sidebarCollapsed ? POPUP_W : mainWidth) + 6,
bottom: mainBottom,
width: SUB_W, top: 'auto', right: 'auto',
});
}, [sidebarCollapsed]);
const openMenu = useCallback(() => { computePosition(); setOpen(true); }, [computePosition]);
const closeMenu = useCallback(() => { setOpen(false); setSubOpen(false); }, []);
const clearClose = () => clearTimeout(closeTimer.current);
const scheduleClose = () => { closeTimer.current = setTimeout(closeMenu, 200); };
/* Fermeture clic extérieur */
useEffect(() => {
if (!open) return;
const onDown = (e) => {
const inTrigger = triggerRef.current?.contains(e.target);
const inPopup = popupRef.current?.contains(e.target);
const inSub = subRef.current?.contains(e.target);
if (!inTrigger && !inPopup && !inSub) closeMenu();
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [open, closeMenu]);
/* Recalcul si sidebar change ou si visualViewport change (Safari mobile) */
useEffect(() => { if (open) computePosition(); }, [sidebarCollapsed, open, computePosition]);
useEffect(() => {
if (!open) return;
const vp = window.visualViewport;
if (!vp) return;
vp.addEventListener('resize', computePosition);
vp.addEventListener('scroll', computePosition);
return () => {
vp.removeEventListener('resize', computePosition);
vp.removeEventListener('scroll', computePosition);
};
}, [open, computePosition]);
/* Handlers ouverture */
const onWrapEnter = () => { if (sidebarCollapsed) { clearClose(); openMenu(); } };
const onWrapLeave = () => { if (sidebarCollapsed) scheduleClose(); };
const onTriggerClick = () => { if (!sidebarCollapsed) { open ? closeMenu() : openMenu(); } };
const go = (path) => { closeMenu(); navigate(path); };
const handleLogout = () => { closeMenu(); logout(); navigate('/login'); };
// En mode standalone (webapp ajoutée à l'écran d'accueil iPad), il n'y a
// ni bouton reload ni pull-to-refresh natif. On force donc une vraie
// navigation réseau (pas juste reload()) avec un paramètre anti-cache,
// pour être certain de récupérer l'index.html et les données à jour.
const handleForceRefresh = () => {
closeMenu();
const url = new URL(window.location.href);
url.searchParams.set('_r', Date.now().toString());
window.location.replace(url.toString());
};
const selectView = (v) => {
setActiveView(v);
closeMenu();
};
/* ── Libellés ────────────────────────────────────────────── */
const viewLabel = activeView === 'all'
? 'Famille et entreprises'
: (activeViewMember ? memberLabel(activeViewMember) : 'Famille et entreprises');
const TriggerBadge = activeView === 'all'
? <TeamBadge size={30} />
: <MemberAvatar member={activeViewMember} size={30} />;
return (
<div
className="user-menu-wrap"
onMouseEnter={onWrapEnter}
onMouseLeave={onWrapLeave}
>
{/* ── Main Popup (portail → échappe tout stacking context) ── */}
{open && createPortal(
<div
ref={popupRef}
id="user-menu-popup"
className="user-menu-popup"
style={popupStyle}
role="menu"
onMouseEnter={clearClose}
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
>
{/* En-tête compte utilisateur */}
<div className="user-menu-header">
<UserAvatar user={user} size={40} />
<div style={{ overflow: 'hidden', flex: 1 }}>
{user?.display_name && <div className="user-menu-name">{user.display_name}</div>}
<div className="user-menu-email">{user?.email}</div>
</div>
</div>
<div className="user-menu-sep" />
{/* Section Vue du profil */}
<div className="user-menu-section-header">Vue du profil</div>
<button
className={`user-menu-item user-menu-vue-row${subOpen ? ' active' : ''}`}
role="menuitem"
onClick={() => setSubOpen(s => !s)}
>
{activeView === 'all'
? <TeamBadge size={24} />
: <MemberAvatar member={activeViewMember} size={24} />
}
<span className="user-menu-vue-name">{viewLabel}</span>
<span style={{ color: '#4a6490', display: 'flex' }}><IconChevronRight /></span>
</button>
<div className="user-menu-sep" />
<button className="user-menu-item" role="menuitem" onClick={() => go('/compte')}>
<IconUser /> Mon compte
</button>
{isAdmin && (
<button className="user-menu-item" role="menuitem" onClick={() => go('/admin')}>
<IconAdmin /> Administration
</button>
)}
<button className="user-menu-item" role="menuitem" onClick={() => go('/settings')}>
<IconSettings /> Paramètres
</button>
<button className="user-menu-item" role="menuitem" onClick={() => go('/communication')}>
<IconComm /> Support &amp; Notifications
</button>
<button className="user-menu-item" role="menuitem" onClick={() => go('/aide')}>
<IconAide /> Aide
</button>
<button className="user-menu-item" role="menuitem" onClick={handleForceRefresh}>
<IconRefresh /> Recharger l'application
</button>
<div className="user-menu-sep" />
<button className="user-menu-item danger" role="menuitem" onClick={handleLogout}>
<IconLogout /> Se déconnecter
</button>
</div>,
document.body
)}
{/* ── Sub-panel Vue du profil (portail) ───────────────────── */}
{open && subOpen && createPortal(
<div
ref={subRef}
className="user-menu-subpanel"
style={subStyle}
onMouseEnter={clearClose}
onMouseLeave={() => { if (sidebarCollapsed) scheduleClose(); }}
>
<div className="user-menu-subpanel-title">Vue du profil</div>
{/* Famille et entreprises (vue agrégée) */}
<button
className={`user-menu-profile-item${activeView === 'all' ? ' selected' : ''}`}
onClick={() => selectView('all')}
>
<TeamBadge size={26} />
<span className="user-menu-profile-name">Famille et entreprises</span>
{activeView === 'all' && <IconCheck />}
</button>
{/* Membres famille */}
{famille.length > 0 && (
<>
<div className="user-menu-subpanel-section">
{famille.length > 1 ? 'Profils' : 'Profil'}
</div>
{famille.map(m => (
<button
key={m.id}
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
onClick={() => selectView(String(m.id))}
>
<MemberAvatar member={m} size={26} />
<span className="user-menu-profile-name">{memberLabel(m)}</span>
{String(activeView) === String(m.id) && <IconCheck />}
</button>
))}
</>
)}
{/* Entreprises */}
{entreprises.length > 0 && (
<>
<div className="user-menu-subpanel-section">
{entreprises.length > 1 ? 'Entreprises' : 'Entreprise'}
</div>
{entreprises.map(m => (
<button
key={m.id}
className={`user-menu-profile-item${String(activeView) === String(m.id) ? ' selected' : ''}`}
onClick={() => selectView(String(m.id))}
>
<MemberAvatar member={m} size={26} />
<span className="user-menu-profile-name">{memberLabel(m)}</span>
{String(activeView) === String(m.id) && <IconCheck />}
</button>
))}
</>
)}
<div className="user-menu-sep" style={{ margin: '6px 0' }} />
<button
className="user-menu-profile-manage"
onClick={() => go('/settings?section=membres')}
>
Gérer les profils
</button>
</div>,
document.body
)}
{/* ── Déclencheur (bas de sidebar) ─────────────────────── */}
<button
ref={triggerRef}
className="user-menu-trigger"
onClick={onTriggerClick}
aria-haspopup="menu"
aria-expanded={open}
title={viewLabel}
>
{TriggerBadge}
{!sidebarCollapsed && (
<div className="user-menu-trigger-info">
<span className="user-menu-trigger-name">{viewLabel}</span>
</div>
)}
{!sidebarCollapsed && <IconChevronUp open={open} />}
</button>
</div>
);
}