diff --git a/src/app/admin/webhook/page.tsx b/src/app/admin/webhook/page.tsx
index 352a40d..b59b78c 100644
--- a/src/app/admin/webhook/page.tsx
+++ b/src/app/admin/webhook/page.tsx
@@ -56,8 +56,19 @@ const DEFAULTS: Settings = {
custom_title: '👨🏻💻 New Visitor',
}
-// Live sessions from /api/visitors
-interface Session { id: string; page: string; since: number; country: string; city: string }
+// Live sessions from the admin-protected /api/visitors endpoint
+interface Session {
+ id: string
+ page: string
+ since: number
+ country: string
+ city: string
+ name: string | null
+ phone: string | null
+ latitude: number | null
+ longitude: number | null
+ accuracy: number | null
+}
export default function WebhookPage() {
const router = useRouter()
@@ -268,8 +279,22 @@ CREATE POLICY "admin_rw" ON public.webhook_settings
{sv.id}
-
{sv.city && sv.city !== '—' ? `${sv.city}, ${sv.country}` : sv.country || 'Unknown'}
-
{sv.page}
+
{sv.name || 'Anonymous visitor'}
+
+ {sv.city && sv.city !== '—' ? `${sv.city}, ${sv.country}` : sv.country || 'Unknown location'}
+
+
{sv.phone ? `${sv.phone} · ` : ''}{sv.page}
+ {sv.latitude !== null && sv.longitude !== null && (
+
+ Precise map
+ {sv.accuracy !== null ? ` (±${Math.round(sv.accuracy)}m)` : ''}
+
+ )}
@@ -339,9 +364,9 @@ CREATE POLICY "admin_rw" ON public.webhook_settings
Visitor Details in Embed
-
-
-
+
+
+
diff --git a/src/app/api/visitors/route.ts b/src/app/api/visitors/route.ts
index bf5342c..56d77a6 100644
--- a/src/app/api/visitors/route.ts
+++ b/src/app/api/visitors/route.ts
@@ -1,286 +1,496 @@
import { NextRequest, NextResponse } from 'next/server'
-import { createClient } from '@supabase/supabase-js'
+import { createClient as createServiceClient } from '@supabase/supabase-js'
+import { createClient as createServerClient } from '@/utils/supabase/server'
+import { isAdminUser } from '@/lib/adminAccess'
export const dynamic = 'force-dynamic'
-// ── In-memory stores (reset on cold start — fine for Vercel) ─────────
-const viewers = new Map
()
-const spamGuard = new Map() // ip → last notified timestamp
+type ViewerSession = {
+ ts: number
+ since: number
+ page: string
+ ua: string
+ country: string
+ city: string
+ name: string | null
+ phone: string | null
+ latitude: number | null
+ longitude: number | null
+ accuracy: number | null
+}
+
+type PreciseLocation = {
+ latitude: number
+ longitude: number
+ accuracy: number | null
+}
+
+type WebhookSettings = {
+ webhook_url?: string | null
+ notifications_enabled?: boolean | null
+ notify_on_visit?: boolean | null
+ show_location?: boolean | null
+ show_map_link?: boolean | null
+ show_isp?: boolean | null
+ show_device?: boolean | null
+ show_browser?: boolean | null
+ show_referrer?: boolean | null
+ show_search_query?: boolean | null
+ show_screen?: boolean | null
+ show_language?: boolean | null
+ show_timezone?: boolean | null
+ show_visitor_name?: boolean | null
+ show_live_count?: boolean | null
+ spam_block_hours?: number | null
+ custom_footer?: string | null
+ custom_title?: string | null
+}
+
+const viewers = new Map()
+const spamGuard = new Map()
-const IDLE_MS = 3 * 60 * 1000 // 3 min without heartbeat = gone
-const SPAM_BLOCK_MS = 6 * 60 * 60 * 1000 // same IP only notified once per 6 hours
-const BOT_PATTERNS = /bot|crawler|spider|headless|lighthouse|pingdom|uptimerobot|gtmetrix|facebook|twitter|slack|discord|whatsapp|telegram|preview|prefetch|curl|wget|python|java|ruby|go-http|okhttp|axios|node-fetch/i
+const IDLE_MS = 3 * 60 * 1000
+const DEFAULT_SPAM_BLOCK_HOURS = 6
+const BOT_PATTERNS = /bot|crawler|spider|headless|lighthouse|pingdom|uptimerobot|gtmetrix|facebook|twitter|slack|discord|whatsapp|telegram|preview|prefetch|curl|wget|python|java|ruby|go-http|okhttp|axios|node-fetch/i
+
+function cleanText(value: unknown, maxLength: number) {
+ if (typeof value !== 'string') return ''
+ return value
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, maxLength)
+}
+
+function discordText(value: string) {
+ return value
+ .replace(/@/g, '@\u200b')
+ .replace(/([\\`*_{}\[\]()#+.!|>~-])/g, '\\$1')
+}
function purgeIdle() {
const cutoff = Date.now() - IDLE_MS
- for (const [id, v] of viewers) if (v.ts < cutoff) viewers.delete(id)
+ for (const [id, viewer] of viewers) {
+ if (viewer.ts < cutoff) viewers.delete(id)
+ }
+}
+
+function getClientIp(request: NextRequest) {
+ return (
+ request.headers.get('x-real-ip') ||
+ request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
+ 'Unknown'
+ )
+}
+
+function getPreciseLocation(body: Record): PreciseLocation | null {
+ if (body.locationConsent !== true) return null
+
+ const latitude = Number(body.latitude)
+ const longitude = Number(body.longitude)
+ const accuracyValue = Number(body.locationAccuracy)
+ if (
+ !Number.isFinite(latitude) ||
+ !Number.isFinite(longitude) ||
+ latitude < -90 ||
+ latitude > 90 ||
+ longitude < -180 ||
+ longitude > 180
+ ) return null
+
+ return {
+ latitude,
+ longitude,
+ accuracy: Number.isFinite(accuracyValue) && accuracyValue >= 0
+ ? Math.min(accuracyValue, 100_000)
+ : null,
+ }
}
-// ── Free geo lookup ──────────────────────────────────────────────────
async function getGeo(ip: string) {
- const blank = { country: '—', countryCode: '', city: '—', region: '—', org: '—', lat: 0, lon: 0, timezone: '—', isp: '—' }
- if (!ip || ip === 'Unknown' || ip.startsWith('127') || ip.startsWith('::1') || ip === '::ffff:127.0.0.1') {
+ const blank = {
+ country: '—', countryCode: '', city: '—', region: '—', org: '—',
+ lat: 0, lon: 0, timezone: '—', isp: '—',
+ }
+ if (
+ !ip ||
+ ip === 'Unknown' ||
+ ip.startsWith('127') ||
+ ip.startsWith('10.') ||
+ ip.startsWith('192.168.') ||
+ ip.startsWith('::1') ||
+ ip === '::ffff:127.0.0.1'
+ ) {
return { ...blank, country: 'Local/Dev', city: 'Localhost' }
}
+
try {
- const res = await fetch(
- `http://ip-api.com/json/${ip}?fields=status,country,countryCode,regionName,city,lat,lon,isp,org,timezone`,
- { signal: AbortSignal.timeout(3000) }
+ const response = await fetch(
+ `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,countryCode,regionName,city,lat,lon,isp,org,timezone`,
+ { signal: AbortSignal.timeout(3000) },
)
- const d = await res.json()
- if (d.status === 'success') {
+ const data = await response.json()
+ if (data.status === 'success') {
return {
- country: d.country || '—', countryCode: d.countryCode || '',
- city: d.city || '—', region: d.regionName || '—',
- org: d.org || d.isp || '—', isp: d.isp || '—',
- lat: d.lat || 0, lon: d.lon || 0, timezone: d.timezone || '—',
+ country: cleanText(data.country, 80) || '—',
+ countryCode: cleanText(data.countryCode, 4),
+ city: cleanText(data.city, 80) || '—',
+ region: cleanText(data.regionName, 80) || '—',
+ org: cleanText(data.org || data.isp, 120) || '—',
+ isp: cleanText(data.isp, 120) || '—',
+ lat: Number(data.lat) || 0,
+ lon: Number(data.lon) || 0,
+ timezone: cleanText(data.timezone, 80) || '—',
}
}
} catch {}
+
return blank
}
-// ── Parse user agent → browser, OS, device, search engine ───────────
-function parseUA(ua: string) {
- if (!ua) return { browser: 'Unknown', os: 'Unknown', device: '🖥️ Desktop', engine: null, isBot: false }
-
- const isBot = BOT_PATTERNS.test(ua)
+function parseUserAgent(userAgent: string) {
+ if (!userAgent) {
+ return { browser: 'Unknown', os: 'Unknown', device: 'Desktop', isBot: false }
+ }
- const mobile = /mobile|android|iphone/i.test(ua) && !/ipad/i.test(ua)
- const tablet = /ipad|tablet/i.test(ua)
+ const isBot = BOT_PATTERNS.test(userAgent)
+ const mobile = /mobile|android|iphone/i.test(userAgent) && !/ipad/i.test(userAgent)
+ const tablet = /ipad|tablet/i.test(userAgent)
const browser =
- /Edg\//.test(ua) ? 'Edge' :
- /OPR\//.test(ua) ? 'Opera' :
- /Firefox\//.test(ua) ? 'Firefox' :
- /SamsungBrowser/.test(ua) ? 'Samsung Browser' :
- /Chrome\//.test(ua) ? 'Chrome' :
- /Safari\//.test(ua) ? 'Safari' : 'Unknown'
+ /Edg\//.test(userAgent) ? 'Edge' :
+ /OPR\//.test(userAgent) ? 'Opera' :
+ /Firefox\//.test(userAgent) ? 'Firefox' :
+ /SamsungBrowser/.test(userAgent) ? 'Samsung Browser' :
+ /Chrome\//.test(userAgent) ? 'Chrome' :
+ /Safari\//.test(userAgent) ? 'Safari' : 'Unknown'
const os =
- /Windows NT 10/.test(ua) ? 'Windows 10/11' :
- /Windows NT 6/.test(ua) ? 'Windows 7/8' :
- /Windows/.test(ua) ? 'Windows' :
- /Mac OS X/.test(ua) ? 'macOS' :
- /Android/.test(ua) ? 'Android' :
- /iPhone|iPad/.test(ua) ? 'iOS' :
- /Linux/.test(ua) ? 'Linux' : 'Unknown'
-
- const device = tablet ? '📟 Tablet' : mobile ? '📱 Mobile' : '🖥️ Desktop'
-
- return { browser, os, device, engine: null, isBot }
+ /Windows NT 10/.test(userAgent) ? 'Windows 10/11' :
+ /Windows NT 6/.test(userAgent) ? 'Windows 7/8' :
+ /Windows/.test(userAgent) ? 'Windows' :
+ /Mac OS X/.test(userAgent) ? 'macOS' :
+ /Android/.test(userAgent) ? 'Android' :
+ /iPhone|iPad/.test(userAgent) ? 'iOS' :
+ /Linux/.test(userAgent) ? 'Linux' : 'Unknown'
+
+ return {
+ browser,
+ os,
+ device: tablet ? 'Tablet' : mobile ? 'Mobile' : 'Desktop',
+ isBot,
+ }
}
-// ── Detect referrer search engine / social ───────────────────────────
-function parseReferrer(ref: string) {
- if (!ref || ref === 'Direct') return { source: 'Direct / None', searchQuery: null, emoji: '🔗' }
+function parseReferrer(referrer: string) {
+ if (!referrer || referrer === 'Direct') {
+ return { source: 'Direct / None', searchQuery: null as string | null }
+ }
+
try {
- const url = new URL(ref)
+ const url = new URL(referrer)
const host = url.hostname.toLowerCase()
- const q = url.searchParams.get('q') || url.searchParams.get('query') || url.searchParams.get('p') || null
-
- if (host.includes('google')) return { source: `Google`, searchQuery: q, emoji: '🔍' }
- if (host.includes('bing')) return { source: `Bing`, searchQuery: q, emoji: '🔍' }
- if (host.includes('yahoo')) return { source: `Yahoo`, searchQuery: q, emoji: '🔍' }
- if (host.includes('duckduckgo')) return { source: `DuckDuckGo`, searchQuery: q, emoji: '🦆' }
- if (host.includes('linkedin')) return { source: `LinkedIn`, searchQuery: null, emoji: '💼' }
- if (host.includes('github')) return { source: `GitHub`, searchQuery: null, emoji: '🐙' }
- if (host.includes('twitter') || host.includes('t.co')) return { source: `Twitter/X`, searchQuery: null, emoji: '🐦' }
- if (host.includes('instagram')) return { source: `Instagram`, searchQuery: null, emoji: '📸' }
- if (host.includes('facebook')) return { source: `Facebook`, searchQuery: null, emoji: '👥' }
- if (host.includes('reddit')) return { source: `Reddit`, searchQuery: null, emoji: '🤖' }
- if (host.includes('youtube')) return { source: `YouTube`, searchQuery: null, emoji: '▶️' }
- if (host.includes('whatsapp')) return { source: `WhatsApp`, searchQuery: null, emoji: '💬' }
- if (host.includes('telegram')) return { source: `Telegram`, searchQuery: null, emoji: '✈️' }
-
- return { source: url.hostname, searchQuery: null, emoji: '🌐' }
+ const query = url.searchParams.get('q') || url.searchParams.get('query') || url.searchParams.get('p')
+
+ if (host.includes('google')) return { source: 'Google', searchQuery: query }
+ if (host.includes('bing')) return { source: 'Bing', searchQuery: query }
+ if (host.includes('yahoo')) return { source: 'Yahoo', searchQuery: query }
+ if (host.includes('duckduckgo')) return { source: 'DuckDuckGo', searchQuery: query }
+ if (host.includes('linkedin')) return { source: 'LinkedIn', searchQuery: null }
+ if (host.includes('github')) return { source: 'GitHub', searchQuery: null }
+ if (host.includes('twitter') || host.includes('t.co')) return { source: 'Twitter/X', searchQuery: null }
+ if (host.includes('instagram')) return { source: 'Instagram', searchQuery: null }
+ if (host.includes('facebook')) return { source: 'Facebook', searchQuery: null }
+ if (host.includes('reddit')) return { source: 'Reddit', searchQuery: null }
+ if (host.includes('youtube')) return { source: 'YouTube', searchQuery: null }
+ if (host.includes('whatsapp')) return { source: 'WhatsApp', searchQuery: null }
+ if (host.includes('telegram')) return { source: 'Telegram', searchQuery: null }
+ return { source: url.hostname, searchQuery: null }
} catch {
- return { source: ref.slice(0, 60), searchQuery: null, emoji: '🔗' }
+ return { source: cleanText(referrer, 80) || 'Direct / None', searchQuery: null }
}
}
-// ── Get webhook settings from Supabase ──────────────────────────────
-async function getWebhookSettings() {
+async function getWebhookSettings(): Promise {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
if (!url || !key) return null
try {
- const db = createClient(url, key, { auth: { persistSession: false } })
- const { data } = await db.from('webhook_settings').select('*').eq('id', 1).single()
+ const database = createServiceClient(url, key, { auth: { persistSession: false } })
+ const { data } = await database.from('webhook_settings').select('*').eq('id', 1).single()
return data
- } catch { return null }
+ } catch {
+ return null
+ }
}
-// ── Send to Discord ──────────────────────────────────────────────────
async function sendDiscord(webhookUrl: string, payload: object) {
try {
- const res = await fetch(webhookUrl, {
+ const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
- return res.ok
- } catch { return false }
+ return response.ok
+ } catch {
+ return false
+ }
+}
+
+function isEnabled(value: boolean | null | undefined) {
+ return value !== false
}
-// ────────────────────────────────────────────────────────────────────
-export async function POST(req: NextRequest) {
+export async function POST(request: NextRequest) {
try {
- const body = await req.json().catch(() => ({}))
- const { type, sessionId, page, referrer, timezone, screenWidth, screenHeight, language, visitorName } = body
+ const body = await request.json().catch(() => ({})) as Record
+ const type = cleanText(body.type, 20)
+ const sessionId = cleanText(body.sessionId, 128)
+ const page = cleanText(body.page, 200) || '/'
- // ── HEARTBEAT ──────────────────────────────────────────────────
if (type === 'heartbeat' && sessionId) {
const existing = viewers.get(sessionId)
+ const now = Date.now()
viewers.set(sessionId, {
- ts: Date.now(),
- page: page || '/',
+ ts: now,
+ since: existing?.since || now,
+ page,
ua: existing?.ua || '',
country: existing?.country || '—',
city: existing?.city || '—',
+ name: existing?.name || null,
+ phone: existing?.phone || null,
+ latitude: existing?.latitude || null,
+ longitude: existing?.longitude || null,
+ accuracy: existing?.accuracy || null,
})
purgeIdle()
return NextResponse.json({ viewers: viewers.size })
}
- // ── LEAVE ──────────────────────────────────────────────────────
if (type === 'leave' && sessionId) {
viewers.delete(sessionId)
return NextResponse.json({ ok: true })
}
- // ── VISIT ──────────────────────────────────────────────────────
- if (type === 'visit') {
- const ip =
- req.headers.get('x-real-ip') ||
- req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
- 'Unknown'
+ if (type !== 'visit' && type !== 'identify') {
+ return NextResponse.json({ error: 'Unsupported visitor event' }, { status: 400 })
+ }
- const ua = req.headers.get('user-agent') || ''
- const { browser, os, device, isBot } = parseUA(ua)
+ const visitorName = cleanText(body.visitorName, 80)
+ const phone = cleanText(body.phone, 32)
+ const preciseLocation = getPreciseLocation(body)
+ if (type === 'identify' && !visitorName && !phone && !preciseLocation) {
+ return NextResponse.json({ error: 'No visitor details supplied' }, { status: 400 })
+ }
- // ── Anti-spam: skip bots ───────────────────────────────────
- if (isBot) return NextResponse.json({ ok: true, skipped: 'bot' })
+ const ip = getClientIp(request)
+ const userAgent = request.headers.get('user-agent') || ''
+ const { browser, os, device, isBot } = parseUserAgent(userAgent)
+ if (isBot) return NextResponse.json({ ok: true, skipped: 'bot' })
+
+ const settings = await getWebhookSettings()
+ const spamBlockHours = Math.min(
+ 24,
+ Math.max(1, Number(settings?.spam_block_hours) || DEFAULT_SPAM_BLOCK_HOURS),
+ )
- // ── Anti-spam: rate limit same IP ─────────────────────────
+ if (type === 'visit') {
const lastSeen = spamGuard.get(ip) || 0
- const isSpam = Date.now() - lastSeen < SPAM_BLOCK_MS
- if (isSpam) {
- // Still track heartbeat but don't notify Discord
- viewers.set(sessionId || ip, { ts: Date.now(), page: page || '/', ua, country: '—', city: '—' })
+ if (Date.now() - lastSeen < spamBlockHours * 60 * 60 * 1000) {
+ const now = Date.now()
+ const existing = viewers.get(sessionId || ip)
+ viewers.set(sessionId || ip, {
+ ts: now,
+ since: existing?.since || now,
+ page,
+ ua: userAgent,
+ country: existing?.country || '—',
+ city: existing?.city || '—',
+ name: visitorName || existing?.name || null,
+ phone: phone || existing?.phone || null,
+ latitude: preciseLocation?.latitude ?? existing?.latitude ?? null,
+ longitude: preciseLocation?.longitude ?? existing?.longitude ?? null,
+ accuracy: preciseLocation?.accuracy ?? existing?.accuracy ?? null,
+ })
return NextResponse.json({ ok: true, skipped: 'rate_limited' })
}
spamGuard.set(ip, Date.now())
+ }
- // ── Geo lookup ────────────────────────────────────────────
- const geo = await getGeo(ip)
-
- // ── Track viewer ─────────────────────────────────────────
- if (sessionId) {
- viewers.set(sessionId, { ts: Date.now(), page: page || '/', ua, country: geo.country, city: geo.city })
- purgeIdle()
- }
-
- const ref = parseReferrer(referrer)
- const settings = await getWebhookSettings()
- const webhookUrl = settings?.webhook_url || process.env.DISCORD_WEBHOOK_URL
-
- // Skip if notifications are disabled
- if (settings?.notifications_enabled === false) {
- return NextResponse.json({ ok: true, skipped: 'notifications_disabled' })
- }
-
- const mapLink = geo.lat && geo.lon
- ? `[📍 Open Map](https://www.google.com/maps?q=${geo.lat},${geo.lon})`
- : null
-
- const now = new Date()
- const timeStr = now.toLocaleString('en-GB', {
- hour: '2-digit', minute: '2-digit', second: '2-digit',
- day: '2-digit', month: 'short', year: 'numeric',
- timeZone: 'Asia/Dubai',
- }) + ' (Dubai)'
+ const geo = await getGeo(ip)
+ const now = Date.now()
+ const viewerKey = sessionId || ip
+ const existing = viewers.get(viewerKey)
+ viewers.set(viewerKey, {
+ ts: now,
+ since: existing?.since || now,
+ page,
+ ua: userAgent,
+ country: geo.country,
+ city: geo.city,
+ name: visitorName || existing?.name || null,
+ phone: phone || existing?.phone || null,
+ latitude: preciseLocation?.latitude ?? existing?.latitude ?? null,
+ longitude: preciseLocation?.longitude ?? existing?.longitude ?? null,
+ accuracy: preciseLocation?.accuracy ?? existing?.accuracy ?? null,
+ })
+ purgeIdle()
- const currentViewers = viewers.size
+ const webhookUrl = settings?.webhook_url || process.env.DISCORD_WEBHOOK_URL
+ if (!webhookUrl || settings?.notifications_enabled === false) {
+ return NextResponse.json({ ok: true, skipped: 'notifications_disabled' })
+ }
+ if (type === 'visit' && settings?.notify_on_visit === false) {
+ return NextResponse.json({ ok: true, skipped: 'visit_notifications_disabled' })
+ }
- const embed: any = {
- author: {
- name: '👁️ New Portfolio Visitor',
- url: 'https://portfolio-v1-eta-nine.vercel.app',
+ const referrer = parseReferrer(cleanText(body.referrer, 500))
+ const timezone = cleanText(body.timezone, 80)
+ const language = cleanText(body.language, 40)
+ const screenWidth = Number(body.screenWidth)
+ const screenHeight = Number(body.screenHeight)
+ const approximateLocation = [geo.city, geo.region, geo.country]
+ .filter((part) => part && part !== '—')
+ .join(', ') || 'Unknown'
+ const preciseCoordinates = preciseLocation
+ ? `${preciseLocation.latitude.toFixed(6)}, ${preciseLocation.longitude.toFixed(6)}`
+ : null
+ const mapLatitude = preciseLocation?.latitude || geo.lat
+ const mapLongitude = preciseLocation?.longitude || geo.lon
+ const mapLink = mapLatitude && mapLongitude
+ ? `https://www.google.com/maps?q=${mapLatitude},${mapLongitude}`
+ : null
+ const time = new Date().toLocaleString('en-GB', {
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
+ day: '2-digit', month: 'short', year: 'numeric',
+ timeZone: 'Asia/Dubai',
+ }) + ' (Dubai)'
+
+ const fields: Array<{ name: string; value: string; inline: boolean }> = []
+ if (isEnabled(settings?.show_visitor_name)) {
+ fields.push({
+ name: '👤 Visitor Name',
+ value: visitorName ? `**${discordText(visitorName)}**` : 'Anonymous / not shared',
+ inline: true,
+ })
+ }
+ fields.push({
+ name: '📞 Phone (optional)',
+ value: phone ? discordText(phone) : 'Not provided',
+ inline: true,
+ })
+ if (isEnabled(settings?.show_browser)) {
+ fields.push({ name: '🌐 Browser & OS', value: `${browser} on ${os}`, inline: true })
+ }
+ if (isEnabled(settings?.show_device)) {
+ fields.push({ name: '📱 Device', value: device, inline: true })
+ }
+ fields.push({ name: '🔐 IP Address', value: discordText(ip), inline: true })
+ if (isEnabled(settings?.show_location)) {
+ fields.push(
+ { name: '📍 Approx. Location', value: discordText(approximateLocation), inline: true },
+ {
+ name: '🎯 Precise Coordinates',
+ value: preciseCoordinates || 'Not shared by visitor',
+ inline: false,
},
- color: 0x00e5ff,
- fields: [],
- footer: { text: `portfolio-v1 · ${ip} · ${timeStr}` },
- timestamp: new Date().toISOString(),
- }
-
- // ── Visitor identity ─────────────────────────────────────
- embed.fields.push(
- { name: '👤 Visitor Name', value: visitorName ? `**${visitorName}**` : '*(anonymous)*', inline: true },
- { name: '🌐 Browser', value: `${browser} on ${os}`, inline: true },
- { name: device === '📱 Mobile' ? '📱 Device' : '🖥️ Device', value: device, inline: true },
- )
-
- // ── Location ─────────────────────────────────────────────
- const locationVal = [geo.city, geo.region, geo.country].filter(s => s && s !== '—').join(', ') || '—'
- embed.fields.push(
- { name: '📍 Location', value: locationVal, inline: true },
- { name: '🏢 ISP / Org', value: geo.org || '—', inline: true },
- { name: '🕐 Time', value: timeStr, inline: true },
)
-
- // ── Map link ─────────────────────────────────────────────
- if (mapLink) embed.fields.push({ name: '🗺️ Map', value: mapLink, inline: true })
-
- // ── Referrer / source ─────────────────────────────────────
- embed.fields.push(
- { name: `${ref.emoji} Source`, value: ref.source, inline: true },
- { name: '📄 Page', value: page || '/', inline: true },
- )
-
- // ── Search query if came from search engine ───────────────
- if (ref.searchQuery) {
- embed.fields.push({ name: '🔍 Search Query', value: `"${ref.searchQuery}"`, inline: false })
+ if (preciseLocation?.accuracy !== null && preciseLocation?.accuracy !== undefined) {
+ fields.push({
+ name: '📏 Location Accuracy',
+ value: `Within approximately ${Math.round(preciseLocation.accuracy)} metres`,
+ inline: true,
+ })
}
-
- // ── Screen & language ─────────────────────────────────────
- if (screenWidth && screenHeight) {
- embed.fields.push({ name: '📺 Screen', value: `${screenWidth}×${screenHeight}`, inline: true })
- }
- if (language) embed.fields.push({ name: '🗣️ Language', value: language, inline: true })
- if (timezone) embed.fields.push({ name: '🌏 Timezone', value: timezone, inline: true })
-
- // ── Live viewers ──────────────────────────────────────────
- embed.fields.push({
+ }
+ if (isEnabled(settings?.show_map_link) && mapLink) {
+ fields.push({
+ name: preciseLocation ? '🗺️ Precise Map' : '🗺️ Approximate Map',
+ value: `[Open in Google Maps](${mapLink})`,
+ inline: true,
+ })
+ }
+ if (isEnabled(settings?.show_isp)) {
+ fields.push({ name: '🏢 ISP / Organization', value: discordText(geo.org || 'Unknown'), inline: true })
+ }
+ fields.push({ name: '🕐 Time', value: time, inline: true })
+ if (isEnabled(settings?.show_referrer)) {
+ fields.push(
+ { name: '🔗 Source', value: discordText(referrer.source), inline: true },
+ { name: '📄 Page', value: discordText(page), inline: true },
+ )
+ }
+ if (isEnabled(settings?.show_search_query) && referrer.searchQuery) {
+ fields.push({ name: '🔍 Search Query', value: discordText(referrer.searchQuery), inline: false })
+ }
+ if (
+ isEnabled(settings?.show_screen) &&
+ Number.isFinite(screenWidth) &&
+ Number.isFinite(screenHeight) &&
+ screenWidth > 0 &&
+ screenHeight > 0
+ ) {
+ fields.push({ name: '📺 Screen', value: `${screenWidth}×${screenHeight}`, inline: true })
+ }
+ if (isEnabled(settings?.show_language) && language) {
+ fields.push({ name: '🗣️ Language', value: discordText(language), inline: true })
+ }
+ if (isEnabled(settings?.show_timezone) && timezone) {
+ fields.push({ name: '🌏 Timezone', value: discordText(timezone), inline: true })
+ }
+ if (isEnabled(settings?.show_live_count)) {
+ fields.push({
name: '👥 Live Now',
- value: `**${currentViewers}** viewer${currentViewers !== 1 ? 's' : ''} on your portfolio`,
+ value: `**${viewers.size}** viewer${viewers.size !== 1 ? 's' : ''} on the portfolio`,
inline: false,
})
-
- if (webhookUrl) {
- await sendDiscord(webhookUrl, { embeds: [embed] })
- }
- return NextResponse.json({ ok: true })
}
- return NextResponse.json({ ok: false }, { status: 400 })
- } catch (err) {
- return NextResponse.json({ error: String(err) }, { status: 500 })
+ const title = type === 'identify'
+ ? '👤 Visitor Shared Details'
+ : cleanText(settings?.custom_title, 200) || '👁️ New Portfolio Visitor'
+ const footer = cleanText(settings?.custom_footer, 200) || 'sahad.is-a.dev · Visitor Analytics'
+ await sendDiscord(webhookUrl, {
+ embeds: [{
+ author: { name: title, url: 'https://sahad.is-a.dev/' },
+ color: type === 'identify' ? 0x7c3aed : 0x00e5ff,
+ fields,
+ footer: { text: footer },
+ timestamp: new Date().toISOString(),
+ }],
+ })
+
+ return NextResponse.json({ ok: true })
+ } catch (error) {
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Unexpected visitor event error' },
+ { status: 500 },
+ )
}
}
-// ── GET — live dashboard data ────────────────────────────────────────
export async function GET() {
+ const supabase = await createServerClient()
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ if (!isAdminUser(user)) return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
+
purgeIdle()
return NextResponse.json({
viewers: viewers.size,
- sessions: Array.from(viewers.entries()).map(([id, v]) => ({
- id: id.slice(0, 8),
- page: v.page,
- since: v.ts,
- country: v.country,
- city: v.city,
+ sessions: Array.from(viewers.entries()).map(([id, viewer]) => ({
+ id: id.slice(0, 8),
+ page: viewer.page,
+ since: viewer.since,
+ country: viewer.country,
+ city: viewer.city,
+ name: viewer.name,
+ phone: viewer.phone,
+ latitude: viewer.latitude,
+ longitude: viewer.longitude,
+ accuracy: viewer.accuracy,
})),
})
}
diff --git a/src/components/VisitorDetailsPrompt.tsx b/src/components/VisitorDetailsPrompt.tsx
new file mode 100644
index 0000000..3aa9986
--- /dev/null
+++ b/src/components/VisitorDetailsPrompt.tsx
@@ -0,0 +1,210 @@
+'use client'
+
+import { FormEvent, useEffect, useState } from 'react'
+import { AnimatePresence, motion } from 'framer-motion'
+import { Loader2, MapPin, Phone, ShieldCheck, User, X } from 'lucide-react'
+import { VISITOR_PROFILE_EVENT, type VisitorProfile } from '@/lib/visitorProfile'
+
+type VisitorDetailsPromptProps = {
+ enabled: boolean
+}
+
+type PreciseLocation = {
+ latitude: number
+ longitude: number
+ accuracy: number
+}
+
+function getPreciseLocation() {
+ return new Promise((resolve, reject) => {
+ if (!navigator.geolocation) {
+ reject(new Error('Location sharing is not supported by this browser.'))
+ return
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ ({ coords }) => resolve({
+ latitude: coords.latitude,
+ longitude: coords.longitude,
+ accuracy: coords.accuracy,
+ }),
+ () => reject(new Error('Location permission was not granted. Uncheck precise location to continue.')),
+ { enableHighAccuracy: true, timeout: 10_000, maximumAge: 0 },
+ )
+ })
+}
+
+export default function VisitorDetailsPrompt({ enabled }: VisitorDetailsPromptProps) {
+ const [open, setOpen] = useState(false)
+ const [name, setName] = useState('')
+ const [phone, setPhone] = useState('')
+ const [shareLocation, setShareLocation] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ if (!enabled || sessionStorage.getItem('_visitor_details_prompted')) return
+
+ setName(localStorage.getItem('_visitor_name') || '')
+ setPhone(localStorage.getItem('_visitor_phone') || '')
+ const timer = window.setTimeout(() => setOpen(true), 900)
+ return () => window.clearTimeout(timer)
+ }, [enabled])
+
+ const finish = (profile: VisitorProfile | null) => {
+ sessionStorage.setItem('_visitor_details_prompted', '1')
+ window.dispatchEvent(new CustomEvent(VISITOR_PROFILE_EVENT, {
+ detail: profile,
+ }))
+ setOpen(false)
+ }
+
+ const handleSkip = () => finish(null)
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault()
+ const visitorName = name.trim()
+ const visitorPhone = phone.trim()
+ if (!visitorName) return
+
+ setSubmitting(true)
+ setError('')
+
+ let preciseLocation: PreciseLocation | null = null
+ if (shareLocation) {
+ try {
+ preciseLocation = await getPreciseLocation()
+ } catch (locationError) {
+ setError(locationError instanceof Error ? locationError.message : 'Unable to share location.')
+ setSubmitting(false)
+ return
+ }
+ }
+
+ localStorage.setItem('_visitor_name', visitorName)
+ if (visitorPhone) localStorage.setItem('_visitor_phone', visitorPhone)
+ else localStorage.removeItem('_visitor_phone')
+
+ finish({
+ visitorName,
+ phone: visitorPhone || null,
+ locationConsent: Boolean(preciseLocation),
+ latitude: preciseLocation?.latitude ?? null,
+ longitude: preciseLocation?.longitude ?? null,
+ locationAccuracy: preciseLocation?.accuracy ?? null,
+ })
+ }
+
+ return (
+
+ {open && (
+
+
+
+
+
+ Optional visitor details
+
+
Say hello
+
+ Share your name with the portfolio owner. Phone and precise location are optional.
+
+
+
+
+
+
+ Details are sent privately through the portfolio notification webhook and are never requested without your action.
+
+
+ )}
+
+ )
+}
diff --git a/src/hooks/useVisitor.ts b/src/hooks/useVisitor.ts
index 33fa801..51df389 100644
--- a/src/hooks/useVisitor.ts
+++ b/src/hooks/useVisitor.ts
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
+import { VISITOR_PROFILE_EVENT, type VisitorProfile } from '@/lib/visitorProfile'
function getSessionId() {
let id = sessionStorage.getItem('_sid')
@@ -13,7 +14,6 @@ function getSessionId() {
export function useVisitor() {
const [liveViewers, setLiveViewers] = useState(1)
- const sessionId = useRef(null)
const fired = useRef(false)
useEffect(() => {
@@ -21,30 +21,50 @@ export function useVisitor() {
fired.current = true
const sid = getSessionId()
- sessionId.current = sid
+ let visitSent = sessionStorage.getItem('_visited') === '1'
- const alreadyNotified = sessionStorage.getItem('_visited')
- if (!alreadyNotified) {
+ const visitorContext = () => ({
+ sessionId: sid,
+ page: window.location.pathname + window.location.hash,
+ referrer: document.referrer || 'Direct',
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ language: navigator.language || navigator.languages?.[0] || '',
+ screenWidth: window.screen.width,
+ screenHeight: window.screen.height,
+ })
+
+ const notifyVisit = (profile: VisitorProfile | null) => {
+ if (visitSent) return
+ visitSent = true
sessionStorage.setItem('_visited', '1')
- fetch('/api/visitors', {
+ void fetch('/api/visitors', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ type: 'visit', ...visitorContext(), ...(profile || {}) }),
+ }).catch(() => {})
+ }
+
+ const identifyVisitor = (profile: VisitorProfile) => {
+ void fetch('/api/visitors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- type: 'visit',
- sessionId: sid,
- page: window.location.pathname + window.location.hash,
- referrer: document.referrer || 'Direct',
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- language: navigator.language || navigator.languages?.[0] || '',
- screenWidth: window.screen.width,
- screenHeight: window.screen.height,
- // visitorName filled in only if they've left a comment before
- visitorName: localStorage.getItem('_visitor_name') || null,
- }),
+ body: JSON.stringify({ type: 'identify', ...visitorContext(), ...profile }),
}).catch(() => {})
}
+ const handleProfile = (event: Event) => {
+ const profile = (event as CustomEvent).detail
+ if (!visitSent) notifyVisit(null)
+ if (profile) identifyVisitor(profile)
+ }
+
+ window.addEventListener(VISITOR_PROFILE_EVENT, handleProfile)
+
+ const anonymousTimer = visitSent
+ ? null
+ : window.setTimeout(() => notifyVisit(null), 15_000)
+
// Heartbeat every 28s
const heartbeat = async () => {
try {
@@ -69,7 +89,12 @@ export function useVisitor() {
navigator.sendBeacon('/api/visitors', JSON.stringify({ type: 'leave', sessionId: sid }))
window.addEventListener('beforeunload', leave)
- return () => { clearInterval(iv); window.removeEventListener('beforeunload', leave) }
+ return () => {
+ clearInterval(iv)
+ if (anonymousTimer) window.clearTimeout(anonymousTimer)
+ window.removeEventListener(VISITOR_PROFILE_EVENT, handleProfile)
+ window.removeEventListener('beforeunload', leave)
+ }
}, [])
return { liveViewers }
diff --git a/src/lib/adminAccess.ts b/src/lib/adminAccess.ts
new file mode 100644
index 0000000..198bb3f
--- /dev/null
+++ b/src/lib/adminAccess.ts
@@ -0,0 +1,25 @@
+type AdminIdentity = {
+ id?: string
+ email?: string | null
+ app_metadata?: Record | null
+}
+
+const OWNER_EMAIL = 'dev.sxhd@gmail.com'
+
+export function isAdminUser(user?: AdminIdentity | null) {
+ if (!user) return false
+
+ const configuredEmails = (process.env.ADMIN_EMAIL || '')
+ .split(',')
+ .map((email) => email.trim().toLowerCase())
+ .filter(Boolean)
+
+ const allowedEmails = new Set([OWNER_EMAIL, ...configuredEmails])
+ const configuredUserId = process.env.ADMIN_USER_ID?.trim()
+
+ return (
+ user.app_metadata?.role === 'admin' ||
+ Boolean(user.email && allowedEmails.has(user.email.toLowerCase())) ||
+ Boolean(configuredUserId && user.id === configuredUserId)
+ )
+}
diff --git a/src/lib/visitorProfile.ts b/src/lib/visitorProfile.ts
new file mode 100644
index 0000000..7891fd9
--- /dev/null
+++ b/src/lib/visitorProfile.ts
@@ -0,0 +1,10 @@
+export const VISITOR_PROFILE_EVENT = 'portfolio:visitor-profile'
+
+export type VisitorProfile = {
+ visitorName: string
+ phone: string | null
+ locationConsent: boolean
+ latitude: number | null
+ longitude: number | null
+ locationAccuracy: number | null
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index db345df..81ad343 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -13,5 +13,6 @@ export const config = {
'/api/import-github-projects',
'/api/seed-certificates',
'/api/setup-db',
+ '/api/visitors',
],
}
diff --git a/src/utils/supabase/middleware.ts b/src/utils/supabase/middleware.ts
index ca42624..6c75412 100644
--- a/src/utils/supabase/middleware.ts
+++ b/src/utils/supabase/middleware.ts
@@ -1,5 +1,6 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
+import { isAdminUser } from '@/lib/adminAccess'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
@@ -8,16 +9,21 @@ export async function updateSession(request: NextRequest) {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
+ const isAdminLoginRoute = request.nextUrl.pathname === '/admin/login'
const isProtectedAdminRoute =
request.nextUrl.pathname.startsWith('/admin') &&
- request.nextUrl.pathname !== '/admin/login'
+ !isAdminLoginRoute
const protectedAdminApiRoutes = new Set([
'/api/add-sample-certificates',
'/api/import-github-projects',
'/api/seed-certificates',
'/api/setup-db',
])
- const isProtectedAdminApiRoute = protectedAdminApiRoutes.has(request.nextUrl.pathname)
+ const isProtectedAdminApiRoute =
+ protectedAdminApiRoutes.has(request.nextUrl.pathname) ||
+ (request.nextUrl.pathname === '/api/visitors' && request.method === 'GET')
+ const isPublicVisitorRequest =
+ request.nextUrl.pathname === '/api/visitors' && request.method !== 'GET'
const redirectToLogin = () => {
const loginUrl = request.nextUrl.clone()
@@ -28,6 +34,15 @@ export async function updateSession(request: NextRequest) {
return redirect
}
+ const redirectToDashboard = () => {
+ const dashboardUrl = request.nextUrl.clone()
+ dashboardUrl.pathname = '/admin/dashboard'
+ dashboardUrl.search = ''
+ const redirect = NextResponse.redirect(dashboardUrl)
+ supabaseResponse.cookies.getAll().forEach((cookie) => redirect.cookies.set(cookie))
+ return redirect
+ }
+
const denyApiRequest = (status: 401 | 403) => NextResponse.json(
{ error: status === 401 ? 'Authentication required' : 'Admin access required' },
{ status },
@@ -38,6 +53,8 @@ export async function updateSession(request: NextRequest) {
return isProtectedAdminRoute ? redirectToLogin() : supabaseResponse
}
+ if (isPublicVisitorRequest) return supabaseResponse
+
const supabase = createServerClient(
url,
key,
@@ -59,13 +76,7 @@ export async function updateSession(request: NextRequest) {
// Verify the identity with Supabase Auth instead of trusting cookie data.
const { data: { user } } = await supabase.auth.getUser()
- const configuredAdminEmail = (process.env.ADMIN_EMAIL || 'dev.sxhd@gmail.com').toLowerCase()
- const isAdmin = Boolean(
- user && (
- user.app_metadata?.role === 'admin' ||
- user.email?.toLowerCase() === configuredAdminEmail
- )
- )
+ const isAdmin = isAdminUser(user)
if (isProtectedAdminApiRoute && !user) return denyApiRequest(401)
if (isProtectedAdminApiRoute && !isAdmin) return denyApiRequest(403)
@@ -74,5 +85,9 @@ export async function updateSession(request: NextRequest) {
return redirectToLogin()
}
+ if (isAdminLoginRoute && isAdmin) {
+ return redirectToDashboard()
+ }
+
return supabaseResponse
}
diff --git a/src/utils/supabase/server.ts b/src/utils/supabase/server.ts
index 7595477..1b3f0b4 100644
--- a/src/utils/supabase/server.ts
+++ b/src/utils/supabase/server.ts
@@ -31,6 +31,10 @@ const createNoopServerClient = () => {
data: null,
error: new Error('Missing Supabase environment variables'),
}),
+ getUser: async () => ({
+ data: { user: null },
+ error: new Error('Missing Supabase environment variables'),
+ }),
},
} as unknown as ReturnType
}