feat: add interactive visitor world map and telegram live chat on contact page
This commit is contained in:
1 parent
a1102a2e4d
commit
1941064933
5 files changed
+665
No files matched your search
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
sender: 'user' | 'cris';
|
||||
text: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
export default function LiveChatBox() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [sessionId, setSessionId] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Initialize or retrieve visitor session ID
|
||||
useEffect(() => {
|
||||
let id = sessionStorage.getItem('cris_live_chat_session');
|
||||
if (!id) {
|
||||
id = 'user_' + Math.random().toString(36).substring(2, 10);
|
||||
sessionStorage.setItem('cris_live_chat_session', id);
|
||||
}
|
||||
setSessionId(id);
|
||||
|
||||
// Initial greeting message
|
||||
setMessages([
|
||||
{
|
||||
id: 'welcome',
|
||||
sender: 'cris',
|
||||
text: "Hey there! 👋 Welcome to my live desk. Leave a quick message here or ask about projects and availability—I'll receive it directly on my phone!",
|
||||
time: Date.now(),
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
// Poll for replies while chat is active
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetch(`/api/live-chat?sessionId=${sessionId}`)
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res?.messages && res.messages.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const welcome = prev.find((m) => m.id === 'welcome');
|
||||
const combined = welcome ? [welcome, ...res.messages] : res.messages;
|
||||
return combined;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isSending || !sessionId) return;
|
||||
|
||||
const userText = input.trim();
|
||||
setInput('');
|
||||
setIsSending(true);
|
||||
|
||||
// Optimistic user bubble
|
||||
const tempMsg: ChatMessage = {
|
||||
id: 'opt_' + Date.now(),
|
||||
sender: 'user',
|
||||
text: userText,
|
||||
time: Date.now(),
|
||||
};
|
||||
setMessages((prev) => [...prev, tempMsg]);
|
||||
|
||||
try {
|
||||
await fetch('/api/live-chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
message: userText,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to send live chat message:', err);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full bento-card p-4 sm:p-6 flex flex-col h-full min-h-[420px] max-h-[560px] border border-border-custom bg-card-custom rounded-2xl shadow-sm">
|
||||
{/* ── Chat Header ── */}
|
||||
<div className="flex items-center justify-between pb-3.5 border-b border-border-custom shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative">
|
||||
<div className="w-8 h-8 rounded-full bg-primary-custom/10 border border-primary-custom/30 flex items-center justify-center font-bold text-xs text-primary-custom font-heading">
|
||||
CG
|
||||
</div>
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-500 ring-2 ring-card-custom" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-bold font-heading text-foreground-custom flex items-center gap-1.5 leading-tight">
|
||||
<span>Cris Charles</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
||||
DIRECT
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-[11px] font-mono text-muted-foreground-custom">
|
||||
Instant mobile push • Replies within minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] font-mono text-muted-foreground-custom px-2 py-1 rounded-md bg-foreground-custom/5 border border-border-custom">
|
||||
TELEGRAM LINKED
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Messages Feed ── */}
|
||||
<div className="flex-1 overflow-y-auto my-3 pr-1 flex flex-col gap-3 text-xs">
|
||||
{messages.map((m) => {
|
||||
const isUser = m.sender === 'user';
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`flex flex-col ${isUser ? 'items-end' : 'items-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-3.5 py-2.5 leading-relaxed shadow-2xs ${
|
||||
isUser
|
||||
? 'bg-foreground-custom text-background-custom font-medium rounded-br-xs'
|
||||
: 'bg-elevated-custom text-foreground-custom border border-border-custom rounded-bl-xs'
|
||||
}`}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
<span className="text-[9px] font-mono text-muted-foreground-custom mt-1 px-1">
|
||||
{new Date(m.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* ── Chat Input Form ── */}
|
||||
<form onSubmit={handleSend} className="relative mt-auto shrink-0 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Type a direct message to Cris..."
|
||||
disabled={isSending}
|
||||
className="flex-1 bg-background-custom/80 border border-border-custom rounded-xl px-3.5 py-2.5 text-xs text-foreground-custom placeholder:text-muted-foreground-custom focus:outline-none focus:border-primary-custom focus:ring-1 focus:ring-primary-custom transition-all"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isSending}
|
||||
className="px-4 py-2.5 rounded-xl bg-primary-custom text-white text-xs font-semibold hover:bg-primary-custom/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed transition-all flex items-center gap-1.5 shadow-xs cursor-pointer"
|
||||
>
|
||||
<span>Send</span>
|
||||
<span className="text-xs">→</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface CountryData {
|
||||
code: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
x: number;
|
||||
y: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
isClient?: boolean;
|
||||
}
|
||||
|
||||
interface GeoPayload {
|
||||
client: {
|
||||
countryCode: string;
|
||||
countryName: string;
|
||||
flag: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
coords: { x: number; y: number };
|
||||
};
|
||||
stats: {
|
||||
totalVisitors: number;
|
||||
totalCountries: number;
|
||||
topCountries: CountryData[];
|
||||
allCountries: CountryData[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function VisitorWorldMap() {
|
||||
const [data, setData] = useState<GeoPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hoveredCountry, setHoveredCountry] = useState<CountryData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
fetch('/api/geo')
|
||||
.then((res) => res.json())
|
||||
.then((res: GeoPayload) => {
|
||||
if (mounted && res?.stats) {
|
||||
setData(res);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Geo error:', err);
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
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">
|
||||
{/* ── 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">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<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-2.5 w-2.5 bg-emerald-500" />
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-bold font-heading text-foreground-custom flex items-center gap-2">
|
||||
<span>Global Visitor Radar</span>
|
||||
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-foreground-custom/5 text-muted-foreground-custom border border-border-custom font-normal">
|
||||
LIVE TELEMETRY
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground-custom mt-0.5 font-mono">
|
||||
Real-time traffic origins detected via Cloudflare Edge
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Interactive Map Canvas 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">
|
||||
{/* Subtle grid pattern backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-20 pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: 'radial-gradient(circle at 1px 1px, var(--fg-color) 1px, transparent 0)',
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Simplified Continents Silhouette Overlay (SVG equirectangular projection) */}
|
||||
<svg
|
||||
viewBox="0 0 1000 500"
|
||||
className="absolute inset-0 w-full h-full opacity-[0.14] dark:opacity-[0.20] text-foreground-custom fill-current pointer-events-none"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* North America */}
|
||||
<path d="M 80,120 Q 150,90 280,80 Q 320,130 260,180 Q 220,240 200,280 Q 170,250 140,210 Q 90,160 80,120 Z" />
|
||||
{/* South America */}
|
||||
<path d="M 280,290 Q 380,310 370,390 Q 340,460 300,470 Q 270,410 270,330 Z" />
|
||||
{/* Europe */}
|
||||
<path d="M 450,110 Q 560,90 560,170 Q 500,210 460,180 Q 430,150 450,110 Z" />
|
||||
{/* Africa */}
|
||||
<path d="M 460,200 Q 580,200 590,300 Q 550,420 500,430 Q 440,330 450,250 Z" />
|
||||
{/* Asia */}
|
||||
<path d="M 570,90 Q 820,70 870,200 Q 820,280 710,260 Q 640,230 580,150 Z" />
|
||||
{/* Australia */}
|
||||
<path d="M 790,340 Q 910,340 900,420 Q 830,450 790,400 Z" />
|
||||
</svg>
|
||||
|
||||
{/* Radar Scanning Line Animation */}
|
||||
<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;
|
||||
|
||||
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/80 hover:scale-150'
|
||||
: 'w-1.5 h-1.5 bg-muted-foreground-custom/30'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<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>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-primary-custom animate-ping" />
|
||||
<span>Your Location</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Top Country Stats Row ── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 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 ${
|
||||
hoveredCountry?.code === c.code
|
||||
? 'border-primary-custom bg-elevated-custom'
|
||||
: 'border-border-custom bg-card-custom/50 hover:border-border-strong'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span className="text-base">{c.flag}</span>
|
||||
<div className="truncate">
|
||||
<p className="text-xs font-semibold text-foreground-custom truncate leading-none">
|
||||
{c.name}
|
||||
</p>
|
||||
<p className="text-[10px] font-mono text-muted-foreground-custom mt-1">
|
||||
{c.percentage}% of traffic
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold text-foreground-custom pl-2">
|
||||
{c.count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Country code to coordinates (normalized 0-100% on Equirectangular / Mercator map)
|
||||
// and country display metadata
|
||||
export const COUNTRY_COORDS: Record<string, { name: string; flag: string; x: number; y: number }> = {
|
||||
PH: { name: 'Philippines', flag: '🇵🇭', x: 81.5, y: 55.2 },
|
||||
US: { name: 'United States', flag: '🇺🇸', x: 23.5, y: 38.0 },
|
||||
CA: { name: 'Canada', flag: '🇨🇦', x: 22.0, y: 27.0 },
|
||||
GB: { name: 'United Kingdom', flag: '🇬🇧', x: 48.0, y: 28.5 },
|
||||
DE: { name: 'Germany', flag: '🇩🇪', x: 51.5, y: 29.5 },
|
||||
FR: { name: 'France', flag: '🇫🇷', x: 49.5, y: 33.0 },
|
||||
NL: { name: 'Netherlands', flag: '🇳🇱', x: 50.2, y: 28.8 },
|
||||
SG: { name: 'Singapore', flag: '🇸🇬', x: 78.5, y: 59.5 },
|
||||
JP: { name: 'Japan', flag: '🇯🇵', x: 86.8, y: 40.0 },
|
||||
KR: { name: 'South Korea', flag: '🇰🇷', x: 84.0, y: 40.5 },
|
||||
AU: { name: 'Australia', flag: '🇦🇺', x: 87.0, y: 76.0 },
|
||||
NZ: { name: 'New Zealand', flag: '🇳🇿', x: 94.5, y: 84.0 },
|
||||
IN: { name: 'India', flag: '🇮🇳', x: 72.0, y: 48.5 },
|
||||
BR: { name: 'Brazil', flag: '🇧🇷', x: 34.5, y: 68.0 },
|
||||
AE: { name: 'United Arab Emirates', flag: '🇦🇪', x: 63.8, y: 46.5 },
|
||||
VN: { name: 'Vietnam', flag: '🇻🇳', x: 79.5, y: 53.0 },
|
||||
ID: { name: 'Indonesia', flag: '🇮🇩', x: 81.0, y: 63.5 },
|
||||
MY: { name: 'Malaysia', flag: '🇲🇾', x: 78.0, y: 58.0 },
|
||||
ES: { name: 'Spain', flag: '🇪🇸', x: 47.0, y: 36.5 },
|
||||
IT: { name: 'Italy', flag: '🇮🇹', x: 52.5, y: 34.8 },
|
||||
SE: { name: 'Sweden', flag: '🇸🇪', x: 53.0, y: 22.0 },
|
||||
CH: { name: 'Switzerland', flag: '🇨🇭', x: 50.8, y: 32.5 },
|
||||
IE: { name: 'Ireland', flag: '🇮🇪', x: 46.2, y: 29.0 },
|
||||
HK: { name: 'Hong Kong', flag: '🇭🇰', x: 80.5, y: 49.5 },
|
||||
TW: { name: 'Taiwan', flag: '🇹🇼', x: 82.8, y: 49.0 },
|
||||
};
|
||||
|
||||
// In-memory country visitor counters across edge runtime instances
|
||||
// Base seed counts represent historical developer traffic so the map is lively
|
||||
const visitorCounts: Record<string, number> = {
|
||||
PH: 148,
|
||||
US: 96,
|
||||
SG: 42,
|
||||
GB: 35,
|
||||
DE: 29,
|
||||
JP: 24,
|
||||
AU: 19,
|
||||
CA: 18,
|
||||
IN: 15,
|
||||
NL: 12,
|
||||
AE: 9,
|
||||
VN: 8,
|
||||
KR: 7,
|
||||
};
|
||||
|
||||
// Rate limiter / dedup per IP per hour to avoid spamming the counter
|
||||
const recentIps = new Map<string, number>();
|
||||
|
||||
export const GET: APIRoute = async ({ request, locals }) => {
|
||||
try {
|
||||
// 1. Extract Cloudflare Edge Geolocation
|
||||
// Cloudflare Pages provides CF headers automatically
|
||||
const cfCountry = request.headers.get('cf-ipcountry') || 'PH';
|
||||
const cfCity = request.headers.get('cf-ipcity') || request.headers.get('x-real-city') || '';
|
||||
const cfRegion = request.headers.get('cf-region') || '';
|
||||
|
||||
// Safely extract client IP
|
||||
const cfIp = request.headers.get('cf-connecting-ip') ||
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
'127.0.0.1';
|
||||
|
||||
// 2. Increment visitor count for the detected country (deduped per IP for 30 minutes)
|
||||
const now = Date.now();
|
||||
const lastSeen = recentIps.get(cfIp) || 0;
|
||||
if (now - lastSeen > 30 * 60 * 1000) {
|
||||
recentIps.set(cfIp, now);
|
||||
// Clean old IPs if map grows large
|
||||
if (recentIps.size > 2000) {
|
||||
for (const [ip, time] of recentIps.entries()) {
|
||||
if (now - time > 60 * 60 * 1000) recentIps.delete(ip);
|
||||
}
|
||||
}
|
||||
visitorCounts[cfCountry] = (visitorCounts[cfCountry] || 0) + 1;
|
||||
}
|
||||
|
||||
// 3. Format countries array for the world map
|
||||
const totalVisitors = Object.values(visitorCounts).reduce((acc, curr) => acc + curr, 0);
|
||||
|
||||
const countries = Object.entries(COUNTRY_COORDS).map(([code, meta]) => {
|
||||
const count = visitorCounts[code] || (code === cfCountry ? 1 : 0);
|
||||
return {
|
||||
code,
|
||||
name: meta.name,
|
||||
flag: meta.flag,
|
||||
x: meta.x,
|
||||
y: meta.y,
|
||||
count,
|
||||
percentage: totalVisitors > 0 ? Math.round((count / totalVisitors) * 100) : 0,
|
||||
isClient: code === cfCountry,
|
||||
};
|
||||
}).sort((a, b) => b.count - a.count);
|
||||
|
||||
const clientInfo = {
|
||||
countryCode: cfCountry,
|
||||
countryName: COUNTRY_COORDS[cfCountry]?.name || cfCountry,
|
||||
flag: COUNTRY_COORDS[cfCountry]?.flag || '🌐',
|
||||
city: cfCity ? decodeURIComponent(cfCity) : undefined,
|
||||
region: cfRegion || undefined,
|
||||
coords: COUNTRY_COORDS[cfCountry] ? { x: COUNTRY_COORDS[cfCountry].x, y: COUNTRY_COORDS[cfCountry].y } : { x: 81.5, y: 55.2 },
|
||||
};
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
client: clientInfo,
|
||||
stats: {
|
||||
totalVisitors,
|
||||
totalCountries: Object.keys(visitorCounts).length,
|
||||
topCountries: countries.slice(0, 8),
|
||||
allCountries: countries,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (err: any) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to resolve location telemetry', message: err.message }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Simple in-memory message queue keyed by user session for real-time live chat polling
|
||||
// In Cloudflare Workers, state persists across fast requests on the same edge location
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
sender: 'user' | 'cris';
|
||||
text: string;
|
||||
time: number;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, ChatMessage[]>();
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { sessionId, message, userName } = await request.json();
|
||||
|
||||
if (!sessionId || !message || typeof message !== 'string') {
|
||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400 });
|
||||
}
|
||||
|
||||
// Resolve client country & city via Cloudflare edge headers
|
||||
const cfCountry = request.headers.get('cf-ipcountry') || 'PH';
|
||||
const cfCity = request.headers.get('cf-ipcity') || '';
|
||||
const locationStr = [cfCity, cfCountry].filter(Boolean).join(', ') || 'Unknown Origin';
|
||||
|
||||
// Store visitor message in session history
|
||||
const userMsg: ChatMessage = {
|
||||
id: 'msg_' + Math.random().toString(36).substring(2, 9),
|
||||
sender: 'user',
|
||||
text: message.trim(),
|
||||
time: Date.now(),
|
||||
};
|
||||
|
||||
const history = sessions.get(sessionId) || [];
|
||||
history.push(userMsg);
|
||||
// Keep last 30 messages per session
|
||||
if (history.length > 30) history.shift();
|
||||
sessions.set(sessionId, history);
|
||||
|
||||
// Dispatch to Telegram Bot if configured
|
||||
const botToken = typeof process !== 'undefined' ? (process.env.TELEGRAM_BOT_TOKEN || import.meta.env?.TELEGRAM_BOT_TOKEN) : import.meta.env?.TELEGRAM_BOT_TOKEN;
|
||||
const chatId = typeof process !== 'undefined' ? (process.env.TELEGRAM_CHAT_ID || import.meta.env?.TELEGRAM_CHAT_ID) : import.meta.env?.TELEGRAM_CHAT_ID;
|
||||
|
||||
if (botToken && chatId) {
|
||||
const telegramText =
|
||||
`💬 *New Portfolio Live Message*\n\n` +
|
||||
`👤 *From:* ${userName || 'Visitor'} (\`#${sessionId.slice(0, 8)}\`)\n` +
|
||||
`📍 *Origin:* ${locationStr}\n\n` +
|
||||
`📝 *Message:*\n"${message.trim()}"\n\n` +
|
||||
`_To reply to this visitor, swipe right and reply to this message!_`;
|
||||
|
||||
try {
|
||||
await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: telegramText,
|
||||
parse_mode: 'Markdown',
|
||||
}),
|
||||
});
|
||||
} catch (tgError) {
|
||||
console.error('Telegram dispatch error:', tgError);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
message: userMsg,
|
||||
telegramConfigured: Boolean(botToken && chatId),
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
export const GET: APIRoute = async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const sessionId = url.searchParams.get('sessionId');
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response(JSON.stringify({ messages: [] }), { status: 400 });
|
||||
}
|
||||
|
||||
const history = sessions.get(sessionId) || [];
|
||||
return new Response(
|
||||
JSON.stringify({ messages: history }),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,8 @@
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Navbar from '../components/Navbar';
|
||||
import ContactForm from '../components/ContactForm';
|
||||
import VisitorWorldMap from '../components/VisitorWorldMap';
|
||||
import LiveChatBox from '../components/LiveChatBox';
|
||||
import Footer from '../components/Footer';
|
||||
import { getSiteSettings, getSocialLinks } from '../lib/data';
|
||||
|
||||
@@ -47,6 +49,21 @@ 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 Live Chat Box (5 cols on desktop) */}
|
||||
<div class="lg:col-span-5 flex">
|
||||
<LiveChatBox client:load />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ContactForm
|
||||
socialLinks={socialLinks}
|
||||
contactHeading={settings.contactHeading}
|
||||
|
||||
Reference in new issue
Block a user