diff --git a/src/components/LiveChatBox.tsx b/src/components/LiveChatBox.tsx new file mode 100644 index 0000000..bc5a006 --- /dev/null +++ b/src/components/LiveChatBox.tsx @@ -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([]); + const [input, setInput] = useState(''); + const [isSending, setIsSending] = useState(false); + const [sessionId, setSessionId] = useState(''); + const messagesEndRef = useRef(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 ( +
+ {/* โ”€โ”€ Chat Header โ”€โ”€ */} +
+
+
+
+ CG +
+ +
+
+

+ Cris Charles + + DIRECT + +

+

+ Instant mobile push โ€ข Replies within minutes +

+
+
+ + + TELEGRAM LINKED + +
+ + {/* โ”€โ”€ Messages Feed โ”€โ”€ */} +
+ {messages.map((m) => { + const isUser = m.sender === 'user'; + return ( +
+
+ {m.text} +
+ + {new Date(m.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+ ); + })} +
+
+ + {/* โ”€โ”€ Chat Input Form โ”€โ”€ */} +
+ 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" + /> + +
+
+ ); +} diff --git a/src/components/VisitorWorldMap.tsx b/src/components/VisitorWorldMap.tsx new file mode 100644 index 0000000..3ea479d --- /dev/null +++ b/src/components/VisitorWorldMap.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [hoveredCountry, setHoveredCountry] = useState(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 ( +
+ {/* โ”€โ”€ Header Telemetry Bar โ”€โ”€ */} +
+
+ + + + +
+

+ Global Visitor Radar + + LIVE TELEMETRY + +

+

+ Real-time traffic origins detected via Cloudflare Edge +

+
+
+ + {/* Client Detected Location Badge */} + {data?.client && ( +
+ {data.client.flag} + + Connected from{' '} + + {data.client.city ? `${data.client.city}, ` : ''}{data.client.countryName} + + +
+ )} +
+ + {/* โ”€โ”€ Interactive Map Canvas Container โ”€โ”€ */} +
+ {/* Subtle grid pattern backdrop */} +
+ + {/* Simplified Continents Silhouette Overlay (SVG equirectangular projection) */} + + {/* North America */} + + {/* South America */} + + {/* Europe */} + + {/* Africa */} + + {/* Asia */} + + {/* Australia */} + + + + {/* Radar Scanning Line Animation */} +
+ + {/* โ”€โ”€ 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 ( +
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) && ( + + )} + + {/* Blip Center Dot */} +
+ + {/* Hover Tooltip Card */} + {isHovered && ( +
+
+ + {country.flag} + {country.name} + + {isClient && ( + + YOU + + )} +
+ +
+ Recorded Visitors: + {country.count} +
+ + {/* Share of Traffic Bar */} +
+
+
+
+ )} +
+ ); + })} + + {/* Legend / Overlay Note */} +
+ + + Active / Recorded + + + + Your Location + +
+
+ + {/* โ”€โ”€ Top Country Stats Row โ”€โ”€ */} +
+ {data?.stats?.topCountries?.slice(0, 4).map((c) => ( +
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' + }`} + > +
+ {c.flag} +
+

+ {c.name} +

+

+ {c.percentage}% of traffic +

+
+
+ + {c.count} + +
+ ))} +
+
+ ); +} diff --git a/src/pages/api/geo.ts b/src/pages/api/geo.ts new file mode 100644 index 0000000..00f27f9 --- /dev/null +++ b/src/pages/api/geo.ts @@ -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 = { + 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 = { + 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(); + +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' } } + ); + } +}; diff --git a/src/pages/api/live-chat.ts b/src/pages/api/live-chat.ts new file mode 100644 index 0000000..f0d64d5 --- /dev/null +++ b/src/pages/api/live-chat.ts @@ -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(); + +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', + }, + } + ); +}; diff --git a/src/pages/contact.astro b/src/pages/contact.astro index 87b6b6a..4e1da78 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -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();
+ {/* โ”€โ”€ Real-Time Presence & Live Operations Grid โ”€โ”€ */} +
+
+ {/* Global Visitor Radar Map (7 cols on desktop) */} +
+ +
+ + {/* Real-time Live Chat Box (5 cols on desktop) */} +
+ +
+
+
+