amélrioration

This commit is contained in:
2026-06-18 21:45:05 +02:00
parent fea1d787ca
commit b2e73b4fe7
3 changed files with 249 additions and 31 deletions
+9
View File
@@ -2003,4 +2003,13 @@ console.log('[DB] Migrations 2FA OK');
} }
} }
{
// Migration : assigned_to sur tickets
const cols = db.prepare("PRAGMA table_info(tickets)").all().map(c => c.name);
if (!cols.includes('assigned_to')) {
db.exec("ALTER TABLE tickets ADD COLUMN assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL");
console.log('[DB] tickets: colonne assigned_to ajoutée');
}
}
export default db; export default db;
+27 -4
View File
@@ -144,8 +144,11 @@ router.get('/:id', (req, res) => {
const isAdmin = role === 'admin'; const isAdmin = role === 'admin';
const ticket = db.prepare(` const ticket = db.prepare(`
SELECT t.*, u.display_name as user_name, u.email as user_email SELECT t.*, u.display_name as user_name, u.email as user_email,
FROM tickets t JOIN users u ON u.id = t.user_id a.display_name as assigned_name
FROM tickets t
JOIN users u ON u.id = t.user_id
LEFT JOIN users a ON a.id = t.assigned_to
WHERE t.id = ? WHERE t.id = ?
`).get(req.params.id); `).get(req.params.id);
@@ -194,7 +197,12 @@ router.post('/:id/messages', upload.array('attachments', 10), (req, res) => {
} }
} }
db.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(ticket.id); // Réouverture automatique si l'émetteur répond sur un ticket en attente
if (!isAdmin && ticket.status === 'pending') {
db.prepare("UPDATE tickets SET status = 'open', updated_at = datetime('now') WHERE id = ?").run(ticket.id);
} else {
db.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(ticket.id);
}
return messageId; return messageId;
}); });
@@ -224,7 +232,7 @@ router.post('/:id/messages', upload.array('attachments', 10), (req, res) => {
// ── PATCH /api/tickets/:id/status — résoudre / rouvrir (admin) ──────────── // ── PATCH /api/tickets/:id/status — résoudre / rouvrir (admin) ────────────
router.patch('/:id/status', requireAdmin, (req, res) => { router.patch('/:id/status', requireAdmin, (req, res) => {
const { status } = req.body; const { status } = req.body;
if (!['open', 'resolved'].includes(status)) return res.status(400).json({ error: 'Statut invalide' }); if (!['open', 'resolved', 'pending'].includes(status)) return res.status(400).json({ error: 'Statut invalide' });
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id); const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id);
if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' }); if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' });
@@ -234,6 +242,21 @@ router.patch('/:id/status', requireAdmin, (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ── PATCH /api/tickets/:id/assign — assigner à un admin ──────────────────────
router.patch('/:id/assign', requireAdmin, (req, res) => {
const { assigned_to } = req.body; // null pour désassigner
if (assigned_to !== null && assigned_to !== undefined) {
const admin = db.prepare("SELECT id FROM users WHERE id = ? AND role = 'admin'").get(assigned_to);
if (!admin) return res.status(400).json({ error: 'Utilisateur invalide ou non admin' });
}
const ticket = db.prepare('SELECT id FROM tickets WHERE id = ?').get(req.params.id);
if (!ticket) return res.status(404).json({ error: 'Ticket introuvable' });
db.prepare("UPDATE tickets SET assigned_to = ?, updated_at = datetime('now') WHERE id = ?")
.run(assigned_to ?? null, req.params.id);
res.json({ ok: true });
});
// ── PUT /api/tickets/:id/messages/:msgId — éditer un message (5 min) ──────── // ── PUT /api/tickets/:id/messages/:msgId — éditer un message (5 min) ────────
router.put('/:id/messages/:msgId', (req, res) => { router.put('/:id/messages/:msgId', (req, res) => {
const { id: userId } = req.user; const { id: userId } = req.user;
+213 -27
View File
@@ -49,6 +49,7 @@ function StatusBadge({ status }) {
const styles = { const styles = {
open: { bg: '#dcfce7', color: '#16a34a', label: 'Ouvert' }, open: { bg: '#dcfce7', color: '#16a34a', label: 'Ouvert' },
resolved: { bg: '#f1f5f9', color: '#64748b', label: 'Résolu' }, resolved: { bg: '#f1f5f9', color: '#64748b', label: 'Résolu' },
pending: { bg: '#fef9c3', color: '#a16207', label: 'En attente' },
}; };
const s = styles[status] ?? styles.open; const s = styles[status] ?? styles.open;
return ( return (
@@ -510,7 +511,10 @@ export default function Communication() {
const { user, isAdmin } = useAuth(); const { user, isAdmin } = useAuth();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [notifMenuPos, setNotifMenuPos] = useState(null); // { x, y } const [notifMenuPos, setNotifMenuPos] = useState(null); // { x, y }
const [supportMenuPos, setSupportMenuPos] = useState(null); // { x, y }
const [showAssignModal, setShowAssignModal] = useState(false);
const [assignSearch, setAssignSearch] = useState('');
const [tab, setTab] = useState('support'); // 'support' | 'notifications' const [tab, setTab] = useState('support'); // 'support' | 'notifications'
const [showBroadcastForm, setShowBroadcastForm] = useState(false); const [showBroadcastForm, setShowBroadcastForm] = useState(false);
@@ -525,7 +529,7 @@ export default function Communication() {
}, []); }, []);
const [tickets, setTickets] = useState([]); const [tickets, setTickets] = useState([]);
const [ticketSearch, setTicketSearch] = useState(''); const [ticketSearch, setTicketSearch] = useState('');
const [ticketFilter, setTicketFilter] = useState('all'); // 'all'|'open'|'resolved' const [ticketFilter, setTicketFilter] = useState(new Set(['open', 'pending'])); // multi-sélection
const [notifFilter, setNotifFilter] = useState('all'); // 'all'|'unread' const [notifFilter, setNotifFilter] = useState('all'); // 'all'|'unread'
const [selectedTicket, setSelectedTicket] = useState(null); const [selectedTicket, setSelectedTicket] = useState(null);
const [thread, setThread] = useState(null); // { ticket, messages } const [thread, setThread] = useState(null); // { ticket, messages }
@@ -596,7 +600,7 @@ export default function Communication() {
if (!isAdmin) return; if (!isAdmin) return;
try { try {
const data = await api.get('/admin/users'); const data = await api.get('/admin/users');
setUsers(data.users ?? data ?? []); setUsers((data.users ?? data ?? []).filter(u => u.role === 'admin'));
} catch { /* silencieux */ } } catch { /* silencieux */ }
}, [isAdmin]); }, [isAdmin]);
@@ -696,6 +700,25 @@ export default function Communication() {
} catch { /* silencieux */ } } catch { /* silencieux */ }
}; };
// ── Assigner un ticket ───────────────────────────────────────────────
const assignTicket = async (adminId) => {
if (!thread) return;
try {
await api.patch(`/tickets/${thread.ticket.id}/assign`, { assigned_to: adminId });
await fetchThread(thread.ticket.id);
} catch { /* silencieux */ }
};
// ── Mettre en attente ────────────────────────────────────────────────
const setTicketPending = async () => {
if (!thread) return;
try {
await api.patch(`/tickets/${thread.ticket.id}/status`, { status: 'pending' });
await fetchThread(thread.ticket.id);
await fetchTickets();
} catch { /* silencieux */ }
};
// ── Broadcast ──────────────────────────────────────────────────────── // ── Broadcast ────────────────────────────────────────────────────────
const submitBroadcast = async (e) => { const submitBroadcast = async (e) => {
e.preventDefault(); e.preventDefault();
@@ -799,13 +822,6 @@ export default function Communication() {
{/* Colonne 2 — Dossier + filtres */} {/* Colonne 2 — Dossier + filtres */}
<div className="comm-topbar-2"> <div className="comm-topbar-2">
<span className="comm-topbar-folder" style={{ fontSize: 15, fontWeight: 700 }}>{tab === 'support' ? 'Support' : 'Notifications'}</span> <span className="comm-topbar-folder" style={{ fontSize: 15, fontWeight: 700 }}>{tab === 'support' ? 'Support' : 'Notifications'}</span>
{tab === 'support' && (
<div className="comm-topbar-filters">
{[['all','Tous'],['open','Ouverts'],['resolved','Résolus']].map(([v,l]) => (
<button key={v} className={`comm-topbar-filter-btn${ticketFilter === v ? ' active' : ''}`} onClick={() => setTicketFilter(v)}>{l}</button>
))}
</div>
)}
{tab === 'notifications' && ( {tab === 'notifications' && (
<button <button
className="btn-icon-sm" className="btn-icon-sm"
@@ -824,12 +840,16 @@ export default function Communication() {
{tab === 'support' && thread && ( {tab === 'support' && thread && (
<> <>
<span className="comm-topbar-ticket-ref">{thread.ticket.ticket_number} {thread.ticket.subject}</span> <span className="comm-topbar-ticket-ref">{thread.ticket.ticket_number} {thread.ticket.subject}</span>
<div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0 }}> <div style={{ display: 'flex', gap: 6, marginLeft: 'auto', flexShrink: 0, alignItems: 'center' }}>
{isAdmin && ( <button
<button className="btn btn-sm" onClick={toggleStatus}> className="btn-icon-sm"
{thread.ticket.status === 'open' ? 'Résoudre' : 'Rouvrir'} onClick={e => {
</button> const r = e.currentTarget.getBoundingClientRect();
)} setSupportMenuPos({ x: r.right, y: r.bottom + 4 });
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
</div> </div>
</> </>
)} )}
@@ -909,11 +929,15 @@ export default function Communication() {
<div className="comm-list"> <div className="comm-list">
{tab === 'support' && ( {tab === 'support' && (
<> <>
<div className="comm-list-header"> <div className="comm-list-header" style={{ flexDirection: 'column', alignItems: 'stretch', gap: 8 }}>
<div className="comm-topbar-filters">
{[['all','Tous'],['open','Ouverts'],['pending','En attente'],['resolved','Résolus']].map(([v,l]) => (
<button key={v} className={`comm-topbar-filter-btn${ticketFilter === v ? ' active' : ''}`} onClick={() => setTicketFilter(v)}>{l}</button>
))}
</div>
<input <input
className="comm-search" className="comm-search"
placeholder="Rechercher un ticket…" placeholder="Rechercher un ticket…"
style={{ flex: 1 }}
value={ticketSearch} value={ticketSearch}
onChange={e => setTicketSearch(e.target.value)} onChange={e => setTicketSearch(e.target.value)}
/> />
@@ -928,6 +952,7 @@ export default function Communication() {
</div> </div>
)} )}
{tickets {tickets
.filter(t => ticketFilter === 'all' || t.status === ticketFilter)
.filter(t => { .filter(t => {
if (!ticketSearch.trim()) return true; if (!ticketSearch.trim()) return true;
const q = ticketSearch.toLowerCase(); const q = ticketSearch.toLowerCase();
@@ -988,8 +1013,8 @@ export default function Communication() {
<div className="comm-list-scroll"> <div className="comm-list-scroll">
{notifs.length === 0 && ( {notifs.length === 0 && (
<div className="comm-empty"> <div className="comm-empty">
<div style={{ fontSize: 32, marginBottom: 8 }}>🔔</div> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<div style={{ fontWeight: 600 }}>Aucune notification</div> <div style={{ marginTop: 12, color: 'var(--text-muted)', fontSize: 14 }}>Aucune notification</div>
</div> </div>
)} )}
{pagedNotifs.map(n => ( {pagedNotifs.map(n => (
@@ -1317,13 +1342,33 @@ export default function Communication() {
</div> </div>
)} )}
{/* Placeholder vide */} {/* KPIs support */}
{tab === 'support' && !thread && ( {tab === 'support' && !thread && (() => {
<div className="comm-detail-empty"> const kpis = [
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><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> { label: 'Total Tickets', count: tickets.length, bg: '#f3f4f6', color: '#111827' },
<div style={{ marginTop: 12, color: 'var(--text-muted)', fontSize: 14 }}>Sélectionnez un ticket</div> { label: 'En attente', count: tickets.filter(t => t.status === 'pending').length, bg: '#fefce8', color: '#a16207' },
</div> { label: 'Ouverts', count: tickets.filter(t => t.status === 'open').length, bg: '#ecfdf5', color: '#0d9488' },
)} { label: 'Clôturés', count: tickets.filter(t => t.status === 'resolved').length, bg: '#fff1f2', color: '#e11d48' },
];
return (
<div style={{ background: '#fff', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '28px 28px 20px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
{kpis.map(k => (
<div key={k.label} style={{ background: k.bg, borderRadius: 12, padding: '24px 16px', textAlign: 'center' }}>
<div style={{ fontSize: 32, fontWeight: 700, color: k.color, lineHeight: 1 }}>{k.count}</div>
<div style={{ marginTop: 8, fontSize: 13, fontWeight: 600, color: k.color }}>{k.label}</div>
</div>
))}
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><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>
<div style={{ color: 'var(--text-muted)', fontSize: 14 }}>Sélectionnez un ticket</div>
</div>
</div>
);
})()}
{tab === 'notifications' && !selectedNotif && !showBroadcastForm && ( {tab === 'notifications' && !selectedNotif && !showBroadcastForm && (
<div className="comm-detail-empty"> <div className="comm-detail-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--border)" strokeWidth="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
@@ -1371,6 +1416,147 @@ export default function Communication() {
</> </>
)} )}
{/* ── Menu ⋮ support ── */}
{supportMenuPos && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setSupportMenuPos(null)} />
<div style={{
position: 'fixed', left: supportMenuPos.x, top: supportMenuPos.y,
transform: 'translateX(-100%) translateY(4px)',
zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
padding: '4px 0', minWidth: 180,
}}>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setShowCompose(true); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
Nouveau ticket
</button>
{thread && isAdmin && (
<>
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setAssignSearch(''); setShowAssignModal(true); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Assigner à
</button>
{thread.ticket.status !== 'pending' ? (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setTicketPending(); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Mettre en attente
</button>
) : (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { toggleStatus(); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>
Réouvrir le ticket
</button>
)}
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { toggleStatus(); setSupportMenuPos(null); }}
>
{thread.ticket.status === 'resolved'
? <><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 .49-3.5"/></svg>Rouvrir</>
: <><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12"/></svg>Clôturer le ticket</>
}
</button>
<div style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={() => { setThread(null); setSelectedTicket(null); setSearchParams({}, { replace: true }); setSupportMenuPos(null); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Fermer
</button>
</>
)}
</div>
</>
)}
{/* ── Modale Assigner à ── */}
{showAssignModal && (
<div className="comm-modal-backdrop" onClick={() => setShowAssignModal(false)}>
<div className="comm-modal" style={{ maxWidth: 420 }} onClick={e => e.stopPropagation()}>
<div className="comm-modal-header">
<span style={{ fontWeight: 700, fontSize: 15 }}>Assigner le ticket</span>
<button className="comm-modal-close" onClick={() => setShowAssignModal(false)}>×</button>
</div>
<div style={{ padding: '12px 20px 16px' }}>
<input
className="comm-search"
placeholder="Rechercher un administrateur…"
value={assignSearch}
onChange={e => setAssignSearch(e.target.value)}
autoFocus
style={{ width: '100%', marginBottom: 10 }}
/>
{/* Désassigner */}
{thread?.ticket.assigned_to && (
<button
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--danger)', borderRadius: 6, marginBottom: 4 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={async () => { await assignTicket(null); setShowAssignModal(false); }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
Retirer l'assignation
</button>
)}
{/* Liste admins */}
<div style={{ maxHeight: 260, overflowY: 'auto' }}>
{users
.filter(u => !assignSearch.trim() || (u.display_name ?? u.email ?? '').toLowerCase().includes(assignSearch.toLowerCase()))
.map(u => {
const isAssigned = thread?.ticket.assigned_to === u.id;
return (
<button
key={u.id}
style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '8px 12px', background: isAssigned ? 'var(--surface-2)' : 'none', border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text)', borderRadius: 6, textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = isAssigned ? 'var(--surface-2)' : 'none'}
onClick={async () => { await assignTicket(u.id); setShowAssignModal(false); }}
>
<UserAvatar name={u.display_name ?? u.email} isAdmin size={30} />
<div>
<div style={{ fontWeight: 600 }}>{u.display_name ?? u.email}</div>
{u.display_name && <div style={{ fontSize: 11, color: 'var(--text-muted)' }}>{u.email}</div>}
</div>
{isAssigned && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2.5" style={{ marginLeft: 'auto' }}><polyline points="20 6 9 17 4 12"/></svg>
)}
</button>
);
})
}
</div>
</div>
</div>
</div>
)}
{/* ── Lightbox image ── */} {/* ── Lightbox image ── */}
{lightbox && ( {lightbox && (
<div className="comm-lightbox" onClick={() => setLightbox(null)}> <div className="comm-lightbox" onClick={() => setLightbox(null)}>