Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["denoland.vscode-deno"]
}
24 changes: 24 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
59 changes: 59 additions & 0 deletions app/(authenticated)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Analytics Dashboard</h1>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader>
<CardTitle>Unique Visitors (30d)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.uniqueVisitors}</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Page Views (30d)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.pageViews}</div>
</CardContent>
</Card>
</div>

<div className="mt-8">
<h2 className="text-2xl font-bold mb-4">Top Pages</h2>
<div className="rounded-md border">
<div className="grid grid-cols-2 gap-2 p-3 font-medium">
<div>Pathname</div>
<div className="text-right">Views</div>
</div>
{data.topPages.map((p) => (
<div key={p.pathname} className="grid grid-cols-2 gap-2 p-3 border-t">
<div className="truncate">{p.pathname}</div>
<div className="text-right">{p.count}</div>
</div>
))}
{data.topPages.length === 0 && <div className="p-3 text-sm text-muted-foreground">No data</div>}
</div>
</div>
</main>
);
}
63 changes: 63 additions & 0 deletions app/api/analytics/overview/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string, number>();
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 });
}
}


61 changes: 61 additions & 0 deletions app/api/collect/route.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
}


38 changes: 38 additions & 0 deletions public/tracker.js
Original file line number Diff line number Diff line change
@@ -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);
})();
9 changes: 9 additions & 0 deletions supabase/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,12 @@
.env.keys
.env.local
.env.*.local

# Supabase
.branches
.temp

# dotenvx
.env.keys
.env.local
.env.*.local
11 changes: 6 additions & 5 deletions supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"
Expand Down