feat: complete dot-matrix landmass bitmask and fix telegram reply routing

This commit is contained in:
ssbg04 committed 2026-10-18 19:03:52 +08:00
1 parent 75617ae49b
commit f9a5f018c5
3 files changed
+136 -42

No files matched your search

+10 -2
View File
@@ -83,8 +83,9 @@ export default function LiveChatBox() {
setIsSending(true);
// Optimistic user bubble
const optId = 'opt_' + Date.now();
const tempMsg: ChatMessage = {
id: 'opt_' + Date.now(),
id: optId,
sender: 'user',
text: userText,
time: Date.now(),
@@ -92,7 +93,7 @@ export default function LiveChatBox() {
setMessages((prev) => [...prev, tempMsg]);
try {
await fetch('/api/live-chat', {
const res = await fetch('/api/live-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -100,6 +101,13 @@ export default function LiveChatBox() {
message: userText,
}),
});
const data = await res.json();
if (data?.message) {
// Swap optimistic message with official server message
setMessages((prev) =>
prev.map((m) => (m.id === optId ? data.message : m))
);
}
} catch (err) {
console.error('Failed to send live chat message:', err);
} finally {
+115 -29
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
interface CountryData {
code: string;
@@ -28,11 +28,82 @@ interface GeoPayload {
};
}
/**
* 80x40 Equirectangular World Map Dot-Matrix Bitmask
* '#' = Landmass dot | '.' = Ocean
* Matches 2:1 aspect ratio (1000x500 SVG viewBox)
*/
const WORLD_DOT_MATRIX: string[] = [
"................................................................................",
"..........................##########............................................",
".................#####...############......................#####................",
"........#####...######...############.........####.......##############.........",
".....#################....##########........#######...####################......",
"....###################....########........#########.#######################....",
"....###################......####.........###################################...",
"......#################...................####################################..",
"........###############..................######################################.",
".........##############.................#####.################################..",
".........#############..................####..###############################...",
"..........###########...................#####..##############################...",
"..........##########....................######.############################.....",
"...........########.......#.............##################################......",
"............#######.....####.............################################.......",
".............###..##......###...........#########..#####################........",
"................####....................#########....###################........",
".................###...................###########....#####...#########.........",
"..................###..................############....###.....#######..........",
"...................###................#############....##.......#####...........",
"....................#######...........#############.............#####.#.........",
"....................#########.........#############..............####.##........",
"....................##########.........###########................######........",
"....................###########.........#########..................#######......",
"....................############.........########.................####.###......",
".....................###########..........#######...............................",
"......................#########...........######......................#####.....",
"......................########............######....................#########...",
".......................######.............#####....................##########...",
".......................#####...............####....................##########...",
".......................####................###.....................#########....",
".......................###..................#.......................#######.....",
".......................##................................................#..##..",
".......................##...................................................#...",
".......................#........................................................",
"................................................................................",
"................................................................................",
"................................................................................",
"................................................................................",
"................................................................................",
];
export default function VisitorWorldMap() {
const [data, setData] = useState<GeoPayload | null>(null);
const [loading, setLoading] = useState(true);
const [hoveredCountry, setHoveredCountry] = useState<CountryData | null>(null);
// Pre-calculate the SVG coordinates for the landmass dot matrix
const landDots = useMemo(() => {
const dots: { cx: number; cy: number; key: string }[] = [];
const rows = WORLD_DOT_MATRIX.length; // 40
const cols = WORLD_DOT_MATRIX[0].length; // 80
const cellW = 1000 / cols; // 12.5
const cellH = 500 / rows; // 12.5
for (let r = 0; r < rows; r++) {
const rowStr = WORLD_DOT_MATRIX[r];
for (let c = 0; c < cols; c++) {
if (rowStr[c] === '#') {
dots.push({
cx: c * cellW + cellW / 2,
cy: r * cellH + cellH / 2,
key: `${r}-${c}`,
});
}
}
}
return dots;
}, []);
useEffect(() => {
let mounted = true;
fetch('/api/geo')
@@ -82,42 +153,55 @@ export default function VisitorWorldMap() {
<span>
Connected from{' '}
<strong className="text-primary-custom font-semibold">
{data.client.city ? `${data.client.city}, ` : ''}{data.client.countryName}
{data.client.city ? `${data.client.city}, ` : ''}
{data.client.countryName}
</strong>
</span>
</div>
)}
</div>
{/* ── Interactive Map Canvas Container ── */}
{/* ── 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">
{/* 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) */}
{/* Dot-Matrix World SVG */}
<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"
className="absolute inset-0 w-full h-full text-foreground-custom 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" />
<defs>
{/* Subtle background ocean matrix */}
<pattern
id="ocean-dot-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-[0.06] dark:opacity-[0.08]"
/>
</pattern>
</defs>
{/* Render Ocean Sub-Grid */}
<rect width="1000" height="500" fill="url(#ocean-dot-matrix)" />
{/* Render Continental Landmass Dot Matrix */}
<g className="fill-current opacity-[0.22] dark:opacity-[0.32]">
{landDots.map((dot) => (
<circle
key={dot.key}
cx={dot.cx}
cy={dot.cy}
r="2.6"
/>
))}
</g>
</svg>
{/* Radar Scanning Line Animation */}
@@ -154,8 +238,8 @@ export default function VisitorWorldMap() {
: 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'
? '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'
}`}
/>
@@ -176,7 +260,9 @@ export default function VisitorWorldMap() {
<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>
<strong className="text-foreground-custom font-semibold">
{country.count}
</strong>
</div>
{/* Share of Traffic Bar */}
+11 -11
View File
@@ -20,28 +20,28 @@ export const POST: APIRoute = async ({ request }) => {
const replyToMessage = message.reply_to_message;
const replyText = message.text.trim();
// Find the session ID from the replied message
// When visitor sends message, we tagged it with (#user_xxxxxxxx)
// Find the session ID from the replied message or the reply text itself
let targetSessionId = '';
if (replyToMessage?.text) {
const match = replyToMessage.text.match(/#([a-zA-Z0-9_-]+)/);
const textToScan = [
replyToMessage?.text,
replyToMessage?.caption,
message.text
].filter(Boolean).join('\n');
// Matches "#user_xxxx" or "Session: #xxxx"
const match = textToScan.match(/#([a-zA-Z0-9_-]+)/);
if (match && match[1]) {
targetSessionId = match[1];
}
}
// If you replied without swiping or session wasn't found, check caption or fallback
if (!targetSessionId && replyToMessage?.caption) {
const match = replyToMessage.caption.match(/#([a-zA-Z0-9_-]+)/);
if (match && match[1]) targetSessionId = match[1];
}
if (targetSessionId) {
addCrisReply(targetSessionId, replyText);
console.log(`[Telegram Webhook] Matched reply to session: ${targetSessionId} -> "${replyText}"`);
return new Response(JSON.stringify({ ok: true, matchedSession: targetSessionId }), { status: 200 });
}
console.warn('[Telegram Webhook] Could not match reply to any session. textToScan:', textToScan);
return new Response(JSON.stringify({ ok: true, note: 'No session ID found in reply' }), { status: 200 });
} catch (err: any) {
console.error('Telegram webhook error:', err);