feat: rebalance contact layout with full-width world map and war-room fullscreen modal
This commit is contained in:
1 parent
234c52739a
commit
b0c10dc389
2 files changed
+546
-103
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import haptic from '../lib/haptics';
|
||||
|
||||
interface CountryData {
|
||||
code: string;
|
||||
@@ -81,6 +82,17 @@ export default function VisitorWorldMap() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hoveredCountry, setHoveredCountry] = useState<CountryData | null>(null);
|
||||
|
||||
// Fullscreen War-Room Lightbox State
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLeaderboardOpen, setIsLeaderboardOpen] = useState(false);
|
||||
|
||||
const dragStartRef = useRef({ x: 0, y: 0 });
|
||||
const panStartRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Pre-calculate the SVG coordinates for the landmass dot matrix
|
||||
const landDots = useMemo(() => {
|
||||
const dots: { cx: number; cy: number; key: string }[] = [];
|
||||
@@ -124,8 +136,213 @@ export default function VisitorWorldMap() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation for fullscreen modal
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsFullscreen(false);
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
setSearchQuery('');
|
||||
} else if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault();
|
||||
setZoom((z) => Math.min(Number((z + 0.5).toFixed(2)), 4));
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
e.preventDefault();
|
||||
setZoom((z) => {
|
||||
const next = Math.max(Number((z - 0.5).toFixed(2)), 1);
|
||||
if (next === 1) setPan({ x: 0, y: 0 });
|
||||
return next;
|
||||
});
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isFullscreen]);
|
||||
|
||||
// Zoom controls
|
||||
const handleZoomIn = () => {
|
||||
haptic.tick();
|
||||
setZoom((z) => Math.min(Number((z + 0.5).toFixed(2)), 4));
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
haptic.tick();
|
||||
setZoom((z) => {
|
||||
const next = Math.max(Number((z - 0.5).toFixed(2)), 1);
|
||||
if (next === 1) setPan({ x: 0, y: 0 });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetZoom = () => {
|
||||
haptic.tick();
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
// Drag & pan handlers when zoomed in
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom <= 1) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
panStartRef.current = { ...pan };
|
||||
};
|
||||
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || zoom <= 1) return;
|
||||
e.preventDefault();
|
||||
const dx = e.clientX - dragStartRef.current.x;
|
||||
const dy = e.clientY - dragStartRef.current.y;
|
||||
setPan({
|
||||
x: panStartRef.current.x + dx,
|
||||
y: panStartRef.current.y + dy,
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
if (zoom <= 1 || e.touches.length !== 1) return;
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
||||
panStartRef.current = { ...pan };
|
||||
};
|
||||
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
if (!isDragging || zoom <= 1) return;
|
||||
const dx = e.touches[0].clientX - dragStartRef.current.x;
|
||||
const dy = e.touches[0].clientY - dragStartRef.current.y;
|
||||
setPan({
|
||||
x: panStartRef.current.x + dx,
|
||||
y: panStartRef.current.y + dy,
|
||||
});
|
||||
};
|
||||
|
||||
const onTouchEnd = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY < 0 ? 0.25 : -0.25;
|
||||
setZoom((prev) => {
|
||||
const next = Math.min(Math.max(Number((prev + delta).toFixed(2)), 1), 4);
|
||||
if (next === 1) setPan({ x: 0, y: 0 });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to render map blips
|
||||
const renderCountryBlips = (isModal = false) => {
|
||||
if (!data?.stats?.allCountries) return null;
|
||||
|
||||
return data.stats.allCountries.map((country) => {
|
||||
const isClient = country.code === data.client.countryCode;
|
||||
const isHovered = hoveredCountry?.code === country.code;
|
||||
const hasTraffic = country.count > 0;
|
||||
const matchesSearch =
|
||||
searchQuery.trim() !== '' &&
|
||||
(country.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
country.code.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={country.code}
|
||||
style={{
|
||||
left: `${country.x}%`,
|
||||
top: `${country.y}%`,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCountry(country)}
|
||||
onMouseLeave={() => setHoveredCountry(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
haptic.tap();
|
||||
setHoveredCountry(country);
|
||||
}}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 z-20 cursor-pointer p-2 group/dot touch-manipulation"
|
||||
>
|
||||
{/* Pulsing ring for client or top countries or matching search */}
|
||||
{(isClient || country.count > 25 || matchesSearch) && (
|
||||
<span
|
||||
className={`absolute inset-0 rounded-full animate-ping opacity-75 ${
|
||||
matchesSearch ? 'bg-amber-400 ring-4 ring-amber-400/50' : 'bg-primary-custom'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Blip Center Dot */}
|
||||
<div
|
||||
className={`relative rounded-full transition-all duration-300 ${
|
||||
matchesSearch
|
||||
? 'w-4 h-4 bg-amber-400 ring-4 ring-amber-400/40 shadow-[0_0_16px_rgba(251,191,36,0.9)] animate-pulse'
|
||||
: isClient
|
||||
? 'w-3.5 h-3.5 bg-primary-custom ring-4 ring-primary-custom/30 shadow-[0_0_12px_var(--primary-color)]'
|
||||
: isHovered
|
||||
? 'w-3 h-3 bg-foreground-custom ring-2 ring-primary-custom'
|
||||
: hasTraffic
|
||||
? 'w-2.5 h-2.5 bg-primary-custom/90 shadow-[0_0_6px_var(--primary-color)] hover:scale-150'
|
||||
: 'w-1.5 h-1.5 bg-muted-foreground-custom/40'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Hover / Highlight Tooltip Card */}
|
||||
{(isHovered || (isModal && matchesSearch)) && (
|
||||
<div
|
||||
className={`absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2.5 rounded-xl border shadow-xl text-left pointer-events-none z-30 animate-in fade-in zoom-in-95 duration-150 ${
|
||||
isModal
|
||||
? 'bg-zinc-900/95 backdrop-blur-md border-zinc-700 text-white'
|
||||
: 'bg-card-custom/95 backdrop-blur-md border-border-custom text-foreground-custom'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1 mb-1.5">
|
||||
<span className="flex items-center gap-1.5 font-bold font-heading text-xs">
|
||||
<span className="text-sm">{country.flag}</span>
|
||||
<span className="truncate">{country.name}</span>
|
||||
</span>
|
||||
{isClient && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.2 rounded bg-primary-custom text-white font-medium">
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] font-mono opacity-80">
|
||||
<span>Recorded Visitors:</span>
|
||||
<strong className="font-semibold">{country.count}</strong>
|
||||
</div>
|
||||
|
||||
{/* Share of Traffic Bar */}
|
||||
<div className="w-full h-1.5 rounded-full bg-white/10 mt-1.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-custom rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.max(country.percentage, 5)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full bento-card p-4 sm:p-6 flex flex-col gap-4 border border-border-custom bg-card-custom rounded-2xl shadow-sm">
|
||||
<div className="w-full bento-card p-4 sm:p-6 md:p-7 flex flex-col gap-4 sm:gap-5 border border-border-custom bg-card-custom rounded-2xl shadow-sm">
|
||||
{/* ── Header Telemetry Bar ── */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3.5 border-b border-border-custom">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -146,23 +363,44 @@ export default function VisitorWorldMap() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Client Detected Location Badge */}
|
||||
{data?.client && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-elevated-custom border border-border-custom text-xs font-mono text-foreground-custom self-start sm:self-auto">
|
||||
<span className="text-sm">{data.client.flag}</span>
|
||||
<span>
|
||||
Connected from{' '}
|
||||
<strong className="text-primary-custom font-semibold">
|
||||
{data.client.city ? `${data.client.city}, ` : ''}
|
||||
{data.client.countryName}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Right Action Bar: Client Location Badge + Fullscreen Expand Button */}
|
||||
<div className="flex flex-wrap items-center gap-2 self-start sm:self-auto">
|
||||
{data?.client && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-elevated-custom border border-border-custom text-xs font-mono text-foreground-custom">
|
||||
<span className="text-sm">{data.client.flag}</span>
|
||||
<span>
|
||||
Connected from{' '}
|
||||
<strong className="text-primary-custom font-semibold">
|
||||
{data.client.city ? `${data.client.city}, ` : ''}
|
||||
{data.client.countryName}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expand to Fullscreen War-Room Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
haptic.openModal();
|
||||
setIsFullscreen(true);
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-card-custom hover:bg-elevated-custom border border-border-custom text-xs font-mono font-medium text-foreground-custom shadow-xs hover:border-primary-custom/40 transition-all cursor-pointer active:scale-95"
|
||||
title="Expand to interactive fullscreen war-room view"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-primary-custom" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Expand Radar</span>
|
||||
<span className="sm:hidden">Expand</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Interactive Dot-Matrix Map Container ── */}
|
||||
<div className="relative w-full aspect-[16/8.5] min-h-[220px] sm:min-h-[300px] rounded-xl overflow-hidden bg-background-custom/60 border border-border-custom flex items-center justify-center group/map select-none">
|
||||
{/* ── Spacious Interactive Dot-Matrix Map Container ── */}
|
||||
<div className="relative w-full aspect-[16/8] min-h-[280px] sm:min-h-[380px] md:min-h-[440px] rounded-xl overflow-hidden bg-background-custom/60 border border-border-custom flex items-center justify-center group/map select-none">
|
||||
{/* Dot-Matrix World SVG */}
|
||||
<svg
|
||||
viewBox="0 0 1000 500"
|
||||
@@ -208,78 +446,10 @@ export default function VisitorWorldMap() {
|
||||
<div className="absolute inset-y-0 w-32 bg-gradient-to-r from-transparent via-primary-custom/10 to-transparent pointer-events-none animate-[pulse_4s_ease-in-out_infinite]" />
|
||||
|
||||
{/* ── Interactive Country Blips ── */}
|
||||
{data?.stats?.allCountries?.map((country) => {
|
||||
const isClient = country.code === data.client.countryCode;
|
||||
const isHovered = hoveredCountry?.code === country.code;
|
||||
const hasTraffic = country.count > 0;
|
||||
{renderCountryBlips(false)}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={country.code}
|
||||
style={{
|
||||
left: `${country.x}%`,
|
||||
top: `${country.y}%`,
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCountry(country)}
|
||||
onMouseLeave={() => setHoveredCountry(null)}
|
||||
onClick={() => setHoveredCountry(country)}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2 z-20 cursor-pointer p-2 group/dot touch-manipulation"
|
||||
>
|
||||
{/* Pulsing ring for client or top countries */}
|
||||
{(isClient || country.count > 25) && (
|
||||
<span className="absolute inset-0 rounded-full animate-ping opacity-60 bg-primary-custom" />
|
||||
)}
|
||||
|
||||
{/* Blip Center Dot */}
|
||||
<div
|
||||
className={`relative rounded-full transition-all duration-300 ${
|
||||
isClient
|
||||
? 'w-3.5 h-3.5 bg-primary-custom ring-4 ring-primary-custom/30 shadow-[0_0_12px_var(--primary-color)]'
|
||||
: isHovered
|
||||
? 'w-3 h-3 bg-foreground-custom ring-2 ring-primary-custom'
|
||||
: hasTraffic
|
||||
? 'w-2 h-2 bg-primary-custom/90 shadow-[0_0_6px_var(--primary-color)] hover:scale-150'
|
||||
: 'w-1.5 h-1.5 bg-muted-foreground-custom/40'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Hover Tooltip Card */}
|
||||
{isHovered && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2.5 rounded-xl bg-card-custom/95 backdrop-blur-md border border-border-custom shadow-xl text-left pointer-events-none z-30 animate-in fade-in zoom-in-95 duration-150">
|
||||
<div className="flex items-center justify-between gap-1 mb-1.5">
|
||||
<span className="flex items-center gap-1.5 font-bold font-heading text-xs text-foreground-custom">
|
||||
<span className="text-sm">{country.flag}</span>
|
||||
<span className="truncate">{country.name}</span>
|
||||
</span>
|
||||
{isClient && (
|
||||
<span className="text-[9px] font-mono px-1.5 py-0.2 rounded bg-primary-custom text-white font-medium">
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] font-mono text-muted-foreground-custom">
|
||||
<span>Recorded Visitors:</span>
|
||||
<strong className="text-foreground-custom font-semibold">
|
||||
{country.count}
|
||||
</strong>
|
||||
</div>
|
||||
|
||||
{/* Share of Traffic Bar */}
|
||||
<div className="w-full h-1.5 rounded-full bg-foreground-custom/10 mt-1.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary-custom rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.max(country.percentage, 5)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Legend / Overlay Note */}
|
||||
<div className="absolute bottom-2.5 left-3 flex items-center gap-3 text-[10px] font-mono text-muted-foreground-custom bg-card-custom/80 backdrop-blur-xs px-2.5 py-1 rounded-md border border-border-custom">
|
||||
{/* Legend / Overlay Note with Expand Callout */}
|
||||
<div className="absolute bottom-2.5 left-3 flex flex-wrap items-center gap-3 text-[10px] font-mono text-muted-foreground-custom bg-card-custom/85 backdrop-blur-xs px-2.5 py-1 rounded-md border border-border-custom shadow-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-primary-custom shadow-[0_0_6px_var(--primary-color)]" />
|
||||
<span>Active / Recorded</span>
|
||||
@@ -288,24 +458,34 @@ export default function VisitorWorldMap() {
|
||||
<span className="w-2 h-2 rounded-full bg-primary-custom animate-ping" />
|
||||
<span>Your Location</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
haptic.tap();
|
||||
setIsFullscreen(true);
|
||||
}}
|
||||
className="text-primary-custom hover:underline font-medium cursor-pointer hidden md:inline"
|
||||
>
|
||||
Click to expand war-room ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Top Country Stats Row ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5 pt-1">
|
||||
{data?.stats?.topCountries?.slice(0, 4).map((c) => (
|
||||
<div
|
||||
key={c.code}
|
||||
onMouseEnter={() => setHoveredCountry(c)}
|
||||
onMouseLeave={() => setHoveredCountry(null)}
|
||||
className={`p-2.5 rounded-xl border transition-all cursor-pointer flex items-center justify-between ${
|
||||
onClick={() => setHoveredCountry(c)}
|
||||
className={`p-3 rounded-xl border transition-all cursor-pointer flex items-center justify-between ${
|
||||
hoveredCountry?.code === c.code
|
||||
? 'border-primary-custom bg-elevated-custom'
|
||||
: 'border-border-custom bg-card-custom/50 hover:border-border-strong'
|
||||
: 'border-border-custom bg-card-custom/50 hover:border-primary-custom/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span className="text-base">{c.flag}</span>
|
||||
<span className="text-base shrink-0">{c.flag}</span>
|
||||
<div className="truncate">
|
||||
<p className="text-xs font-semibold text-foreground-custom truncate leading-none">
|
||||
{c.name}
|
||||
@@ -315,12 +495,269 @@ export default function VisitorWorldMap() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold text-foreground-custom pl-2">
|
||||
<span className="text-xs font-mono font-bold text-foreground-custom pl-2 shrink-0">
|
||||
{c.count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ══════════════════════════════════════════════════════════════════════════════
|
||||
CINEMA FULLSCREEN WAR-ROOM MODAL (Interactive Zoom, Pan, Search & Leaderboard)
|
||||
══════════════════════════════════════════════════════════════════════════════ */}
|
||||
{isFullscreen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] bg-black/92 backdrop-blur-xl flex flex-col overflow-hidden select-none animate-modal-fade text-white p-3 sm:p-5"
|
||||
onClick={() => {
|
||||
setIsFullscreen(false);
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
setSearchQuery('');
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* ── Top Controls Bar ── */}
|
||||
<div
|
||||
className="w-full flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 pb-3 border-b border-white/10 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Left: Title + Telemetry stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="relative flex h-3 w-3 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-emerald-500" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-xs sm:text-sm font-bold font-mono uppercase tracking-wider text-white flex items-center gap-2">
|
||||
<span>GLOBAL TELEMETRY RADAR // WAR-ROOM</span>
|
||||
</h3>
|
||||
<p className="text-[10px] font-mono text-zinc-400">
|
||||
{data?.stats?.totalVisitors || 0} total sessions recorded across {data?.stats?.totalCountries || 0} sovereign territories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Search country input */}
|
||||
<div className="relative w-full md:w-80">
|
||||
<svg className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Find country (e.g. Philippines, USA, Japan)..."
|
||||
className="w-full bg-white/10 border border-white/20 rounded-lg pl-8 pr-8 py-1.5 text-xs text-white placeholder:text-zinc-400 font-mono focus:outline-none focus:border-primary-custom shadow-xs"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-white text-xs cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Zoom Toolbar, Leaderboard Drawer Toggle, Close */}
|
||||
<div className="flex items-center gap-2 self-end md:self-auto">
|
||||
{/* Zoom Toolbar */}
|
||||
<div className="flex items-center bg-white/10 border border-white/20 rounded-lg p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
className="p-1.5 rounded text-white hover:bg-white/10 disabled:opacity-30 cursor-pointer transition-all"
|
||||
title="Zoom Out (-)"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetZoom}
|
||||
className="px-2 py-0.5 text-[11px] font-mono text-zinc-300 hover:text-white cursor-pointer"
|
||||
title="Reset Zoom (0)"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 4}
|
||||
className="p-1.5 rounded text-white hover:bg-white/10 disabled:opacity-30 cursor-pointer transition-all"
|
||||
title="Zoom In (+)"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 stroke-[2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Drawer Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
haptic.tap();
|
||||
setIsLeaderboardOpen(!isLeaderboardOpen);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-mono border transition-all cursor-pointer flex items-center gap-1.5 ${
|
||||
isLeaderboardOpen
|
||||
? 'bg-primary-custom text-white border-primary-custom font-medium'
|
||||
: 'bg-white/10 text-zinc-300 border-white/20 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>📊 Leaderboard</span>
|
||||
</button>
|
||||
|
||||
{/* Close Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
haptic.tap();
|
||||
setIsFullscreen(false);
|
||||
setZoom(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="p-1.5 rounded-lg bg-white/10 hover:bg-rose-500/20 hover:text-rose-400 border border-white/20 text-zinc-300 transition-all cursor-pointer"
|
||||
aria-label="Close fullscreen radar"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main Viewport Container ── */}
|
||||
<div
|
||||
className="relative flex-1 min-h-0 flex items-center justify-center overflow-hidden my-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* Draggable & Scalable Map Frame */}
|
||||
<div
|
||||
className="relative w-full max-w-6xl aspect-[16/8] max-h-[80vh] flex items-center justify-center select-none"
|
||||
style={{
|
||||
transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
|
||||
}}
|
||||
>
|
||||
{/* Dot-Matrix World SVG */}
|
||||
<svg
|
||||
viewBox="0 0 1000 500"
|
||||
className="w-full h-full text-white pointer-events-none"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="fullscreen-ocean-matrix"
|
||||
x="0"
|
||||
y="0"
|
||||
width="12.5"
|
||||
height="12.5"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<circle cx="6.25" cy="6.25" r="1" className="fill-current opacity-10" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="1000" height="500" fill="url(#fullscreen-ocean-matrix)" />
|
||||
|
||||
<g className="fill-current opacity-40">
|
||||
{landDots.map((dot) => (
|
||||
<circle key={dot.key} cx={dot.cx} cy={dot.cy} r="2.6" />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Radar sweep line */}
|
||||
<div className="absolute inset-y-0 w-48 bg-gradient-to-r from-transparent via-primary-custom/20 to-transparent pointer-events-none animate-[pulse_4s_ease-in-out_infinite]" />
|
||||
|
||||
{/* Render country blips */}
|
||||
{renderCountryBlips(true)}
|
||||
</div>
|
||||
|
||||
{/* Hint overlay */}
|
||||
<div className="absolute bottom-3 left-4 text-[11px] font-mono text-zinc-400 bg-black/60 backdrop-blur-md px-3 py-1.5 rounded-lg border border-white/10 pointer-events-none">
|
||||
<span>Scroll / Pinch to zoom • Drag to pan • Press [ESC] to exit</span>
|
||||
</div>
|
||||
|
||||
{/* Collapsible Leaderboard Side Drawer */}
|
||||
{isLeaderboardOpen && (
|
||||
<div
|
||||
className="absolute top-0 right-0 bottom-0 w-80 max-w-full bg-zinc-900/95 backdrop-blur-xl border-l border-white/15 p-4 flex flex-col z-40 animate-in slide-in-from-right duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-white/10 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono font-bold uppercase tracking-wider text-white">
|
||||
Country Leaderboard
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded bg-white/10 text-zinc-300">
|
||||
{data?.stats?.allCountries?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsLeaderboardOpen(false)}
|
||||
className="text-zinc-400 hover:text-white text-xs cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto flex flex-col gap-1.5 pr-1 no-scrollbar">
|
||||
{data?.stats?.allCountries
|
||||
?.slice()
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((country, rank) => (
|
||||
<div
|
||||
key={country.code}
|
||||
onClick={() => {
|
||||
haptic.tap();
|
||||
setHoveredCountry(country);
|
||||
setSearchQuery(country.name);
|
||||
}}
|
||||
className={`p-2 rounded-lg border transition-all cursor-pointer flex items-center justify-between text-xs ${
|
||||
hoveredCountry?.code === country.code
|
||||
? 'border-primary-custom bg-primary-custom/20 text-white'
|
||||
: 'border-white/10 bg-white/5 hover:border-white/30 text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span className="text-[10px] font-mono text-zinc-500 w-4">
|
||||
#{rank + 1}
|
||||
</span>
|
||||
<span className="text-base">{country.flag}</span>
|
||||
<span className="font-medium truncate">{country.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 font-mono">
|
||||
<span className="font-bold text-white">{country.count}</span>
|
||||
<span className="text-[10px] text-zinc-500">
|
||||
({country.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+16
-10
@@ -49,19 +49,25 @@ const socialLinks = await getSocialLinks();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Real-Time Presence & Live Operations Grid ── */}
|
||||
<div class="container mx-auto px-4 sm:px-6 max-w-6xl mb-12">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
{/* Global Visitor Radar Map (7 cols on desktop) */}
|
||||
<div class="lg:col-span-7 flex">
|
||||
<VisitorWorldMap client:load />
|
||||
</div>
|
||||
{/* ── Real-Time Presence: Full-Width Global Visitor Radar ── */}
|
||||
<div class="container mx-auto px-4 sm:px-6 max-w-6xl mb-10 sm:mb-14">
|
||||
<VisitorWorldMap client:load />
|
||||
</div>
|
||||
|
||||
{/* Real-time Live Chat Box (5 cols on desktop) */}
|
||||
<div class="lg:col-span-5 flex">
|
||||
<LiveChatBox client:load />
|
||||
{/* ── Direct Interaction: Live Telegram Desk ── */}
|
||||
<div class="container mx-auto px-4 sm:px-6 max-w-4xl mb-12 sm:mb-16">
|
||||
<div class="flex items-center justify-between mb-3 px-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span class="text-xs font-mono uppercase tracking-wider text-muted-foreground-custom font-semibold">
|
||||
DIRECT LIVE DESK // INSTANT MOBILE PUSH
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-muted-foreground-custom hidden sm:inline">
|
||||
Instant notifications to phone • Replies within minutes
|
||||
</span>
|
||||
</div>
|
||||
<LiveChatBox client:load />
|
||||
</div>
|
||||
|
||||
<ContactForm
|
||||
|
||||
Reference in new issue
Block a user