diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..74baffc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["denoland.vscode-deno"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..af62c23 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,24 @@ +{ + "deno.enablePaths": [ + "supabase/functions" + ], + "deno.lint": true, + "deno.unstable": [ + "bare-node-builtins", + "byonm", + "sloppy-imports", + "unsafe-proto", + "webgpu", + "broadcast-channel", + "worker-options", + "cron", + "kv", + "ffi", + "fs", + "http", + "net" + ], + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } +} diff --git a/app/(authenticated)/dashboard/page.tsx b/app/(authenticated)/dashboard/page.tsx new file mode 100644 index 0000000..519e97b --- /dev/null +++ b/app/(authenticated)/dashboard/page.tsx @@ -0,0 +1,59 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export const revalidate = 300; // Revalidate data every 5 minutes + +export default async function DashboardPage() { + const baseUrl = + process.env.NEXT_PUBLIC_APP_URL || + (process.env.NODE_ENV === 'production' ? 'https://jitendra.tech' : 'http://localhost:3000'); + const res = await fetch(`${baseUrl}/api/analytics/overview`, { cache: 'no-store' }); + if (!res.ok) { + throw new Error('Failed to load analytics overview'); + } + const data = (await res.json()) as { + uniqueVisitors: number; + pageViews: number; + topPages: Array<{ pathname: string; count: number }>; + }; + + return ( + + Analytics Dashboard + + + + Unique Visitors (30d) + + + {data.uniqueVisitors} + + + + + Page Views (30d) + + + {data.pageViews} + + + + + + Top Pages + + + Pathname + Views + + {data.topPages.map((p) => ( + + {p.pathname} + {p.count} + + ))} + {data.topPages.length === 0 && No data} + + + + ); +} diff --git a/app/api/analytics/overview/route.ts b/app/api/analytics/overview/route.ts new file mode 100644 index 0000000..9410cd3 --- /dev/null +++ b/app/api/analytics/overview/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; + +function getSinceIso(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString(); +} + +export async function GET() { + try { + let supabase; + try { + supabase = await createClient(); + } catch (e) { + console.error('Supabase not configured. Returning empty analytics.', e); + const empty = { uniqueVisitors: 0, pageViews: 0, topPages: [] as Array<{ pathname: string; count: number }>, }; + return NextResponse.json(empty, { status: 200 }); + } + const siteId = process.env.ANALYTICS_SITE_ID || 'jitendra-tech'; + const sinceIso = getSinceIso(30); + + // Page views + const [{ count, error: pvError }, visitorsResult, pagesResult] = await Promise.all([ + supabase.from('pageviews').select('*', { count: 'exact', head: true }).eq('website_id', siteId).gte('created_at', sinceIso), + supabase.from('pageviews').select('user_agent,country').eq('website_id', siteId).gte('created_at', sinceIso), + supabase.from('pageviews').select('pathname').eq('website_id', siteId).gte('created_at', sinceIso), + ]); + if (pvError) throw pvError; + const pageViews = count ?? 0; + + // Unique visitors + if (visitorsResult.error) throw visitorsResult.error; + const uniqueSet = new Set(); + for (const row of visitorsResult.data ?? []) { + uniqueSet.add(`${row.user_agent ?? ''}|${row.country ?? ''}`); + } + const uniqueVisitors = uniqueSet.size; + + // Top pages + if (pagesResult.error) throw pagesResult.error; + const counts = new Map(); + for (const row of pagesResult.data ?? []) { + const key = row.pathname ?? ''; + if (!key) continue; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + const topPages = Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([pathname, count]) => ({ pathname, count })); + + const data = { uniqueVisitors, pageViews, topPages }; + return NextResponse.json(data, { status: 200 }); + } catch (error) { + console.error('Failed to fetch analytics overview:', error); + // On failure, return safe defaults so the dashboard still renders + const fallback = { uniqueVisitors: 0, pageViews: 0, topPages: [] as Array<{ pathname: string; count: number }>, }; + return NextResponse.json(fallback, { status: 200 }); + } +} + + diff --git a/app/api/collect/route.ts b/app/api/collect/route.ts new file mode 100644 index 0000000..c597bed --- /dev/null +++ b/app/api/collect/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; + +export async function POST(request: NextRequest) { + const { websiteId, hostname, pathname, referrer, userAgent } = await request.json(); + + // Vercel provides geo-ip information in headers + const country = request.headers.get('x-vercel-ip-country') || 'unknown'; + + if (!websiteId || !hostname || !pathname) { + return NextResponse.json({ message: 'Missing required fields' }, { status: 400 }); + } + + try { + const supabase = await createClient(); + const { error } = await supabase + .from('pageviews') + .insert({ + website_id: websiteId, + hostname, + pathname, + referrer, + user_agent: userAgent, + country, + }); + + if (error) { + console.error('Failed to insert pageview:', error); + return NextResponse.json({ message: 'Database insert failed' }, { status: 500 }); + } + + return NextResponse.json( + { message: 'Success' }, + { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', // Be more specific in production! + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }, + ); + } catch (error) { + console.error('Failed to insert pageview:', error); + return NextResponse.json({ message: 'Internal Server Error' }, { status: 500 }); + } +} + +// Add an OPTIONS method for CORS preflight requests +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': process.env.NODE_ENV === 'production' ? 'https://jitendra.tech' : '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); +} + + diff --git a/public/tracker.js b/public/tracker.js new file mode 100644 index 0000000..0654378 --- /dev/null +++ b/public/tracker.js @@ -0,0 +1,38 @@ +(function () { + const scriptElement = document.currentScript; + const websiteId = scriptElement.getAttribute('data-website-id'); + const providedEndpoint = scriptElement.getAttribute('data-endpoint'); + // Prefer explicitly provided endpoint; otherwise default to same-origin API + const apiEndpoint = providedEndpoint || `${window.location.origin}/api/collect`; + + function trackPageView() { + fetch(apiEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + websiteId: websiteId, + hostname: window.location.hostname, + pathname: window.location.pathname, + referrer: document.referrer, + userAgent: navigator.userAgent, + }), + // Use 'keepalive' to ensure the request is sent even if the page is being unloaded + keepalive: true, + }).catch((err) => console.error(err)); + } + + // Track the initial page view + trackPageView(); + + // Overwrite pushState to track SPA navigation + const originalPushState = history.pushState; + history.pushState = function (...args) { + originalPushState.apply(this, args); + trackPageView(); + }; + + // Listen for popstate events (browser back/forward buttons) + window.addEventListener('popstate', trackPageView); +})(); diff --git a/supabase/.gitignore b/supabase/.gitignore index ad9264f..a4a4a68 100644 --- a/supabase/.gitignore +++ b/supabase/.gitignore @@ -6,3 +6,12 @@ .env.keys .env.local .env.*.local + +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml index 36ebc9d..6e800a0 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -2,7 +2,7 @@ # https://supabase.com/docs/guides/local-development/cli/config # A string used to distinguish different Supabase projects on the same host. Defaults to the # working directory name when running `supabase init`. -project_id = "starterkit" +project_id = "web-analytics" [api] enabled = true @@ -303,13 +303,14 @@ enabled = false [edge_runtime] enabled = true -# Configure one of the supported request policies: `oneshot`, `per_worker`. -# Use `oneshot` for hot reload, or `per_worker` for load testing. -policy = "oneshot" +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" # Port to attach the Chrome inspector for debugging edge functions. inspector_port = 8083 # The Deno major version to use. -deno_version = 1 +deno_version = 2 # [edge_runtime.secrets] # secret_key = "env(SECRET_VALUE)"