feat(dispatch): Phase 4 — Leaflet map panel with rep markers, lead pins, route polylines, and storm polygon
- Real Plano TX map using Leaflet with dark/light tile layer filter - Custom DivIcon rep markers (initials circles, pulsing ring on top AI pick) - Urgency-colored lead pins with radar rings on selected lead - Route polyline with stroke-dashoffset draw animation keyed to selected lead - Travel time chip at route midpoint — solid color background, white text, triple ring shadow - Storm polygon overlay (NE Plano) with Storm Zone Active badge - Route Active chip top-right, legend overlay bottom-left (rep status + lead urgency counts) - Hover tooltips on rep markers (name, status, slots, rating/close%) and lead pins (name, urgency, address, notes snippet) - invalidateSize() with 50ms defer + window resize listener to fix blank tile area after flex layout settles
This commit is contained in:
@@ -0,0 +1,427 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Polyline, Polygon, Tooltip, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
|
import { DISPATCH_REPS, DISPATCH_RECOMMENDATIONS } from '../../data/mockStore';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const PLANO_CENTER = [33.055, -96.752];
|
||||||
|
const DEFAULT_ZOOM = 12;
|
||||||
|
|
||||||
|
const REP_STATUS_COLORS = { available: '#10B981', en_route: '#3B82F6', busy: '#F59E0B' };
|
||||||
|
const REP_STATUS_LABELS = { available: 'Available', en_route: 'En Route', busy: 'Busy' };
|
||||||
|
const URGENCY_COLORS = { emergency: '#EF4444', high: '#F59E0B', standard: '#6B7280' };
|
||||||
|
|
||||||
|
// Storm polygon — northeast Plano, sweeping toward central
|
||||||
|
const STORM_POLYGON = [
|
||||||
|
[33.105, -96.745], [33.100, -96.695], [33.078, -96.682],
|
||||||
|
[33.051, -96.698], [33.042, -96.725], [33.058, -96.750],
|
||||||
|
[33.080, -96.758],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom marker factories
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const createRepIcon = (rep, isTopPick) => {
|
||||||
|
const color = REP_STATUS_COLORS[rep.status] || '#6B7280';
|
||||||
|
const size = isTopPick ? 42 : 36;
|
||||||
|
const ring = isTopPick
|
||||||
|
? `<div style="position:absolute;inset:-5px;border-radius:50%;border:2px solid ${color};opacity:0.5;animation:repRing 1.6s ease-out infinite;"></div>`
|
||||||
|
: '';
|
||||||
|
return L.divIcon({
|
||||||
|
html: `
|
||||||
|
<div style="position:relative;width:${size}px;height:${size}px;">
|
||||||
|
${ring}
|
||||||
|
<div style="
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
background:${color}DD;border:2.5px solid white;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
font-size:${isTopPick ? 12 : 11}px;font-weight:900;color:white;
|
||||||
|
font-family:system-ui,sans-serif;
|
||||||
|
box-shadow:0 3px 10px ${color}60,0 1px 3px rgba(0,0,0,0.25);
|
||||||
|
transition:transform 0.2s;
|
||||||
|
">${rep.initials}</div>
|
||||||
|
<div style="
|
||||||
|
position:absolute;bottom:-3px;right:-3px;
|
||||||
|
width:10px;height:10px;border-radius:50%;
|
||||||
|
background:${color};border:2px solid white;
|
||||||
|
"></div>
|
||||||
|
</div>`,
|
||||||
|
className: '',
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLeadIcon = (lead, isSelected) => {
|
||||||
|
const color = URGENCY_COLORS[lead.urgency] || '#6B7280';
|
||||||
|
const size = isSelected ? 22 : 14;
|
||||||
|
const pulse = lead.urgency !== 'standard';
|
||||||
|
const radar = isSelected
|
||||||
|
? `<div style="position:absolute;inset:-7px;border-radius:50%;border:2px solid ${color};animation:radarPing 1.4s ease-out infinite;"></div>
|
||||||
|
<div style="position:absolute;inset:-14px;border-radius:50%;border:1.5px solid ${color};animation:radarPing 1.4s ease-out 0.4s infinite;"></div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
html: `
|
||||||
|
<div style="position:relative;width:${size}px;height:${size}px;${pulse && !isSelected ? 'animation:dotPulse 1.6s ease-in-out infinite;' : ''}">
|
||||||
|
${radar}
|
||||||
|
<div style="
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
background:${color};border:2px solid white;
|
||||||
|
box-shadow:0 2px 6px ${color}80;
|
||||||
|
"></div>
|
||||||
|
</div>`,
|
||||||
|
className: '',
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTravelChipIcon = (text, color) => {
|
||||||
|
const label = `⏱ ${text}`;
|
||||||
|
return L.divIcon({
|
||||||
|
html: `
|
||||||
|
<div style="
|
||||||
|
background:${color};
|
||||||
|
border-radius:999px;
|
||||||
|
padding:7px 16px;
|
||||||
|
font-size:15px;
|
||||||
|
font-weight:900;
|
||||||
|
color:#ffffff;
|
||||||
|
box-shadow:0 4px 16px rgba(0,0,0,0.45), 0 0 0 3px white, 0 0 0 5px ${color};
|
||||||
|
white-space:nowrap;
|
||||||
|
font-family:system-ui,sans-serif;
|
||||||
|
letter-spacing:0.2px;
|
||||||
|
text-shadow:0 1px 3px rgba(0,0,0,0.35);
|
||||||
|
pointer-events:none;
|
||||||
|
">${label}</div>`,
|
||||||
|
className: '',
|
||||||
|
iconAnchor: [56, 20],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MapController — fits bounds when lead selection changes + fixes tile gaps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const MapController = ({ selectedLeadId, leadPos, repPos, routePath }) => {
|
||||||
|
const map = useMap();
|
||||||
|
const prevId = useRef(null);
|
||||||
|
|
||||||
|
// Invalidate size on mount and on every window resize so Leaflet fills
|
||||||
|
// the full flex container — prevents the blank tile area on the right.
|
||||||
|
useEffect(() => {
|
||||||
|
const invalidate = () => map.invalidateSize({ animate: false });
|
||||||
|
// Defer one frame so the flex layout has fully settled
|
||||||
|
const t = setTimeout(invalidate, 50);
|
||||||
|
window.addEventListener('resize', invalidate);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t);
|
||||||
|
window.removeEventListener('resize', invalidate);
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedLeadId === prevId.current) return;
|
||||||
|
prevId.current = selectedLeadId;
|
||||||
|
|
||||||
|
if (!leadPos || !repPos) return;
|
||||||
|
|
||||||
|
const positions = [leadPos, repPos, ...(routePath || [])].filter(Boolean);
|
||||||
|
if (positions.length < 2) return;
|
||||||
|
|
||||||
|
map.fitBounds(L.latLngBounds(positions), {
|
||||||
|
padding: [72, 72],
|
||||||
|
maxZoom: 14,
|
||||||
|
animate: true,
|
||||||
|
duration: 0.7,
|
||||||
|
});
|
||||||
|
}, [selectedLeadId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CSS animations — injected as a style block
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const MAP_STYLES = `
|
||||||
|
.dispatch-route {
|
||||||
|
stroke-dasharray: 5000;
|
||||||
|
stroke-dashoffset: 5000;
|
||||||
|
animation: drawRoute 1.1s cubic-bezier(0.4,0,0.2,1) forwards;
|
||||||
|
}
|
||||||
|
@keyframes drawRoute {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
@keyframes radarPing {
|
||||||
|
0% { transform: scale(1); opacity: 0.75; }
|
||||||
|
100% { transform: scale(1); opacity: 0; }
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
100% { transform: scale(2.4); opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes dotPulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.3); opacity: 0.7; }
|
||||||
|
}
|
||||||
|
@keyframes repRing {
|
||||||
|
0% { transform: scale(1); opacity: 0.6; }
|
||||||
|
100% { transform: scale(1.8); opacity: 0; }
|
||||||
|
}
|
||||||
|
.leaflet-container { font-family: system-ui, sans-serif; }
|
||||||
|
.dispatch-tooltip {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid rgba(0,0,0,0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||||
|
padding: 8px 10px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.dispatch-tooltip::before { display: none; }
|
||||||
|
.leaflet-tooltip.dispatch-tooltip { white-space: nowrap; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const DispatchMapPanel = ({ selectedLead, dispatchLeads, reps = DISPATCH_REPS, stormMode, accent, isDesktop }) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
// Top recommendation for the selected lead
|
||||||
|
const topRec = selectedLead ? (DISPATCH_RECOMMENDATIONS[selectedLead.id]?.[0] ?? null) : null;
|
||||||
|
const topRep = topRec ? reps.find(r => r.id === topRec.repId) : null;
|
||||||
|
|
||||||
|
// Positions for bounds fitting
|
||||||
|
const leadPos = selectedLead
|
||||||
|
? [selectedLead.property.lat, selectedLead.property.lng]
|
||||||
|
: null;
|
||||||
|
const repPos = topRep
|
||||||
|
? [topRep.currentLat, topRep.currentLng]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Travel time chip — midpoint of route
|
||||||
|
const routeMid = topRec?.routePath?.length
|
||||||
|
? topRec.routePath[Math.floor(topRec.routePath.length / 2)]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Derive estimated drive minutes from top rec factors (driveTime score → minutes)
|
||||||
|
const driveMinutes = topRec
|
||||||
|
? Math.round(8 + ((100 - topRec.factors.driveTime) / 100) * 22)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const routeColor = stormMode ? '#F59E0B' : accent;
|
||||||
|
|
||||||
|
const containerStyle = isDesktop ? { height: 'calc(72rem + 2.375rem)' } : undefined;
|
||||||
|
const containerClass = !isDesktop
|
||||||
|
? 'min-h-[280px] sm:min-h-[360px] md:min-h-[420px]'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative overflow-hidden ${containerClass}`} style={containerStyle}>
|
||||||
|
{/* Inject map animations */}
|
||||||
|
<style>{MAP_STYLES}</style>
|
||||||
|
|
||||||
|
<MapContainer
|
||||||
|
center={PLANO_CENTER}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
scrollWheelZoom={true}
|
||||||
|
zoomControl={false}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
>
|
||||||
|
{/* Tile layer — dark/light aware */}
|
||||||
|
<TileLayer
|
||||||
|
key={theme}
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
className={
|
||||||
|
theme === 'dark'
|
||||||
|
? 'map-tiles filter invert grayscale contrast-100'
|
||||||
|
: 'map-tiles'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fit bounds on lead selection */}
|
||||||
|
<MapController
|
||||||
|
selectedLeadId={selectedLead?.id}
|
||||||
|
leadPos={leadPos}
|
||||||
|
repPos={repPos}
|
||||||
|
routePath={topRec?.routePath}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Storm polygon */}
|
||||||
|
{stormMode && (
|
||||||
|
<Polygon
|
||||||
|
positions={STORM_POLYGON}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#F59E0B',
|
||||||
|
fillColor: '#F59E0B',
|
||||||
|
fillOpacity: 0.13,
|
||||||
|
weight: 2,
|
||||||
|
dashArray: '6 5',
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Route polyline — animates in with stroke-dashoffset draw */}
|
||||||
|
{topRec && (
|
||||||
|
<Polyline
|
||||||
|
key={selectedLead.id}
|
||||||
|
positions={topRec.routePath}
|
||||||
|
pathOptions={{
|
||||||
|
color: routeColor,
|
||||||
|
weight: 3.5,
|
||||||
|
opacity: 0.85,
|
||||||
|
className: 'dispatch-route',
|
||||||
|
lineCap: 'round',
|
||||||
|
lineJoin: 'round',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lead pins */}
|
||||||
|
{dispatchLeads.map(lead => {
|
||||||
|
const urgColor = URGENCY_COLORS[lead.urgency] || '#6B7280';
|
||||||
|
const notesSnip = lead.notes
|
||||||
|
? (lead.notes.length > 55 ? lead.notes.slice(0, 55) + '…' : lead.notes)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={lead.id}
|
||||||
|
position={[lead.property.lat, lead.property.lng]}
|
||||||
|
icon={createLeadIcon(lead, selectedLead?.id === lead.id)}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="dispatch-tooltip">
|
||||||
|
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 150, maxWidth: 220 }}>
|
||||||
|
<p style={{ fontSize: 11, fontWeight: 700, color: '#18181b', marginBottom: 2 }}>
|
||||||
|
{lead.customer.name}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 10, fontWeight: 700, color: urgColor, marginBottom: 2 }}>
|
||||||
|
{lead.urgency.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 10, color: '#71717a', marginBottom: notesSnip ? 4 : 0 }}>
|
||||||
|
{lead.property.address}
|
||||||
|
</p>
|
||||||
|
{notesSnip && (
|
||||||
|
<p style={{ fontSize: 9, color: '#a1a1aa', fontStyle: 'italic', lineHeight: 1.4, borderTop: '1px solid #f0f0f0', paddingTop: 4 }}>
|
||||||
|
{notesSnip}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Rep markers */}
|
||||||
|
{reps.map(rep => {
|
||||||
|
const repColor = REP_STATUS_COLORS[rep.status] || '#6B7280';
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={rep.id}
|
||||||
|
position={[rep.currentLat, rep.currentLng]}
|
||||||
|
icon={createRepIcon(rep, topRep?.id === rep.id)}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="dispatch-tooltip">
|
||||||
|
<div style={{ fontFamily: 'system-ui,sans-serif', minWidth: 140 }}>
|
||||||
|
<p style={{ fontSize: 11, fontWeight: 800, color: repColor, marginBottom: 3 }}>
|
||||||
|
{rep.name}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 1 }}>
|
||||||
|
{REP_STATUS_LABELS[rep.status]} · {rep.todayAppointments}/{rep.maxDaily} slots
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 10, color: '#71717a' }}>
|
||||||
|
★ {rep.rating} · {rep.closeRate}% close
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Travel time chip at route midpoint */}
|
||||||
|
{topRec && routeMid && driveMinutes && (
|
||||||
|
<Marker
|
||||||
|
key={`chip-${selectedLead.id}`}
|
||||||
|
position={routeMid}
|
||||||
|
icon={createTravelChipIcon(`~${driveMinutes} min`, routeColor)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</MapContainer>
|
||||||
|
|
||||||
|
{/* ── Legend overlay — bottom-left ── */}
|
||||||
|
<div className="absolute bottom-3 left-3 z-[1000] flex flex-col gap-1.5 pointer-events-none">
|
||||||
|
{/* Rep status legend */}
|
||||||
|
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Reps</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(REP_STATUS_COLORS).map(([status, color]) => (
|
||||||
|
<div key={status} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||||
|
<span className="text-[10px] font-medium text-zinc-600 dark:text-zinc-300 capitalize">
|
||||||
|
{REP_STATUS_LABELS[status]}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-zinc-400 ml-1">
|
||||||
|
{DISPATCH_REPS.filter(r => r.status === status).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lead urgency legend */}
|
||||||
|
<div className="rounded-xl border bg-white/90 dark:bg-zinc-900/85 backdrop-blur-md border-zinc-200/80 dark:border-white/[0.08] px-2.5 py-2 shadow-lg">
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest text-zinc-400 dark:text-zinc-500 mb-1.5">Leads</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{Object.entries(URGENCY_COLORS).map(([urgency, color]) => (
|
||||||
|
<div key={urgency} className="flex items-center gap-1.5">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||||
|
<span className="text-[10px] font-medium text-zinc-600 dark:text-zinc-300 capitalize">
|
||||||
|
{urgency}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-zinc-400 ml-1">
|
||||||
|
{dispatchLeads.filter(l => l.urgency === urgency).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storm mode overlay label */}
|
||||||
|
{stormMode && (
|
||||||
|
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1000] pointer-events-none">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-500/90 backdrop-blur-sm shadow-lg">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-white">
|
||||||
|
Storm Zone Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Selected lead info chip — top right */}
|
||||||
|
{selectedLead && topRep && (
|
||||||
|
<div className="absolute top-3 right-3 z-[1000] pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="rounded-xl border px-3 py-2 bg-white/92 dark:bg-zinc-900/85 backdrop-blur-md shadow-lg border-zinc-200/80 dark:border-white/[0.08]"
|
||||||
|
style={{ borderLeftWidth: 3, borderLeftColor: routeColor }}
|
||||||
|
>
|
||||||
|
<p className="text-[9px] font-black uppercase tracking-widest mb-0.5" style={{ color: routeColor }}>
|
||||||
|
Route Active
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] font-bold text-zinc-900 dark:text-white">{topRep.name}</p>
|
||||||
|
<p className="text-[10px] text-zinc-400 mt-0.5 truncate max-w-[140px]">
|
||||||
|
→ {selectedLead.property.address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DispatchMapPanel;
|
||||||
Reference in New Issue
Block a user