diff --git a/frontend/public/js/tracker.js b/frontend/public/js/tracker.js
new file mode 100644
index 0000000..a823b59
--- /dev/null
+++ b/frontend/public/js/tracker.js
@@ -0,0 +1,161 @@
+/**
+ * Spark Platform - Client-Side Analytics Tracker
+ *
+ * Lightweight tracking script that:
+ * - Tracks pageviews with UTM params
+ * - Tracks custom events
+ * - Generates session/visitor IDs
+ * - Integrates with Google Tag Manager, GA4, Clarity, FB Pixel
+ *
+ * Usage:
+ *
+ */
+
+(function () {
+ 'use strict';
+
+ // Get site ID from script tag
+ const scriptTag = document.currentScript || document.querySelector('script[data-site-id]');
+ const SITE_ID = scriptTag?.getAttribute('data-site-id');
+ const API_BASE = scriptTag?.getAttribute('data-api-base') || '';
+
+ if (!SITE_ID) {
+ console.warn('[Spark Tracker] Missing data-site-id attribute');
+ return;
+ }
+
+ // Generate or get visitor ID (persisted in localStorage)
+ function getVisitorId() {
+ let id = localStorage.getItem('spark_visitor_id');
+ if (!id) {
+ id = 'v_' + Math.random().toString(36).substring(2, 15);
+ localStorage.setItem('spark_visitor_id', id);
+ }
+ return id;
+ }
+
+ // Generate session ID (persisted in sessionStorage)
+ function getSessionId() {
+ let id = sessionStorage.getItem('spark_session_id');
+ if (!id) {
+ id = 's_' + Math.random().toString(36).substring(2, 15);
+ sessionStorage.setItem('spark_session_id', id);
+ }
+ return id;
+ }
+
+ const VISITOR_ID = getVisitorId();
+ const SESSION_ID = getSessionId();
+
+ // Track pageview
+ function trackPageview() {
+ const data = {
+ site_id: SITE_ID,
+ page_path: window.location.pathname + window.location.search,
+ page_title: document.title,
+ referrer: document.referrer,
+ session_id: SESSION_ID,
+ visitor_id: VISITOR_ID
+ };
+
+ fetch(API_BASE + '/api/track/pageview', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ keepalive: true
+ }).catch(() => { });
+ }
+
+ // Track custom event
+ window.sparkTrack = function (eventName, eventData = {}) {
+ const data = {
+ site_id: SITE_ID,
+ event_name: eventName,
+ event_category: eventData.category,
+ event_label: eventData.label,
+ event_value: eventData.value,
+ page_path: window.location.pathname,
+ session_id: SESSION_ID,
+ visitor_id: VISITOR_ID,
+ metadata: eventData.metadata || eventData
+ };
+
+ fetch(API_BASE + '/api/track/event', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ keepalive: true
+ }).catch(() => { });
+
+ // Also send to Google Analytics if available
+ if (typeof gtag !== 'undefined') {
+ gtag('event', eventName, eventData);
+ }
+
+ // Send to Facebook Pixel if available
+ if (typeof fbq !== 'undefined') {
+ fbq('trackCustom', eventName, eventData);
+ }
+ };
+
+ // Track conversion (lead form, etc.)
+ window.sparkConversion = function (conversionType, value, options = {}) {
+ const data = {
+ site_id: SITE_ID,
+ conversion_type: conversionType,
+ value: value,
+ source: options.source,
+ campaign: options.campaign,
+ gclid: new URLSearchParams(window.location.search).get('gclid'),
+ fbclid: new URLSearchParams(window.location.search).get('fbclid'),
+ send_to_google: options.sendToGoogle !== false,
+ send_to_facebook: options.sendToFacebook !== false
+ };
+
+ fetch(API_BASE + '/api/track/conversion', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+ }).catch(() => { });
+ };
+
+ // Auto-track form submissions
+ document.addEventListener('submit', function (e) {
+ const form = e.target;
+ if (form.tagName === 'FORM') {
+ window.sparkTrack('form_submit', {
+ category: 'engagement',
+ label: form.id || form.name || 'unknown_form'
+ });
+ }
+ });
+
+ // Auto-track outbound link clicks
+ document.addEventListener('click', function (e) {
+ const link = e.target.closest('a');
+ if (link && link.hostname !== window.location.hostname) {
+ window.sparkTrack('outbound_click', {
+ category: 'engagement',
+ label: link.href
+ });
+ }
+ });
+
+ // Track initial pageview
+ if (document.readyState === 'complete') {
+ trackPageview();
+ } else {
+ window.addEventListener('load', trackPageview);
+ }
+
+ // Track SPA navigation (for frameworks that use history API)
+ let lastPath = window.location.pathname;
+ const observer = new MutationObserver(function () {
+ if (window.location.pathname !== lastPath) {
+ lastPath = window.location.pathname;
+ trackPageview();
+ }
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+
+})();
diff --git a/frontend/src/pages/api/analytics/dashboard.ts b/frontend/src/pages/api/analytics/dashboard.ts
new file mode 100644
index 0000000..e5ab2d0
--- /dev/null
+++ b/frontend/src/pages/api/analytics/dashboard.ts
@@ -0,0 +1,150 @@
+// @ts-ignore - Astro types available at build time
+import type { APIRoute } from 'astro';
+import { getDirectusClient, readItems } from '@/lib/directus/client';
+
+/**
+ * Analytics Dashboard API
+ *
+ * Returns analytics data for a site.
+ *
+ * GET /api/analytics/dashboard?site_id={id}&period=7d
+ */
+export const GET: APIRoute = async ({ url }: { url: URL }) => {
+ try {
+ const siteId = url.searchParams.get('site_id');
+ const period = url.searchParams.get('period') || '7d'; // 7d, 30d, 90d
+
+ if (!siteId) {
+ return new Response(
+ JSON.stringify({ error: 'site_id is required' }),
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Calculate date range
+ const periodDays = parseInt(period) || 7;
+ const startDate = new Date(Date.now() - periodDays * 24 * 60 * 60 * 1000).toISOString();
+
+ const directus = getDirectusClient();
+
+ // Parallel data fetches
+ const [
+ totalPageviews,
+ uniqueVisitors,
+ botPageviews,
+ topPages,
+ topReferrers,
+ deviceBreakdown,
+ browserBreakdown,
+ utmSources,
+ recentEvents,
+ conversions
+ ] = await Promise.all([
+ // Total pageviews
+ directus.request(readItems('pageviews', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
+ aggregate: { count: '*' }
+ })),
+ // Unique visitors
+ directus.request(readItems('pageviews', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
+ groupBy: ['visitor_id'],
+ aggregate: { count: '*' }
+ })),
+ // Bot pageviews
+ directus.request(readItems('pageviews', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: true } },
+ aggregate: { count: '*' }
+ })),
+ // Top pages
+ directus.request(readItems('pageviews', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
+ groupBy: ['page_path'],
+ aggregate: { count: '*' },
+ sort: ['-count'],
+ limit: 10
+ })),
+ // Top referrers
+ directus.request(readItems('pageviews', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false }, referrer: { _nnull: true } },
+ groupBy: ['referrer'],
+ aggregate: { count: '*' },
+ sort: ['-count'],
+ limit: 10
+ })),
+ // Device breakdown
+ directus.request(readItems('pageviews', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
+ groupBy: ['device_type'],
+ aggregate: { count: '*' }
+ })),
+ // Browser breakdown
+ directus.request(readItems('pageviews', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, is_bot: { _eq: false } },
+ groupBy: ['browser'],
+ aggregate: { count: '*' }
+ })),
+ // UTM sources
+ directus.request(readItems('pageviews', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate }, utm_source: { _nnull: true } },
+ groupBy: ['utm_source', 'utm_medium', 'utm_campaign'],
+ aggregate: { count: '*' },
+ sort: ['-count'],
+ limit: 10
+ })),
+ // Recent events
+ directus.request(readItems('events', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate } },
+ groupBy: ['event_name'],
+ aggregate: { count: '*' },
+ sort: ['-count'],
+ limit: 10
+ })),
+ // Conversions
+ directus.request(readItems('conversions', {
+ filter: { site: { _eq: siteId }, timestamp: { _gte: startDate } },
+ aggregate: { count: '*', sum: 'value' }
+ }))
+ ]);
+
+ const dashboard = {
+ period,
+ period_days: periodDays,
+ overview: {
+ total_pageviews: parseInt((totalPageviews as any)?.[0]?.count || '0', 10),
+ unique_visitors: (uniqueVisitors as any[])?.length || 0,
+ bot_pageviews: parseInt((botPageviews as any)?.[0]?.count || '0', 10),
+ total_conversions: parseInt((conversions as any)?.[0]?.count || '0', 10),
+ total_revenue: parseFloat((conversions as any)?.[0]?.sum?.value || '0')
+ },
+ top_pages: (topPages as any[]).map(p => ({ path: p.page_path, views: parseInt(p.count, 10) })),
+ top_referrers: (topReferrers as any[]).map(r => ({ referrer: r.referrer, views: parseInt(r.count, 10) })),
+ devices: (deviceBreakdown as any[]).reduce((acc, d) => {
+ acc[d.device_type] = parseInt(d.count, 10);
+ return acc;
+ }, {} as Record),
+ browsers: (browserBreakdown as any[]).reduce((acc, b) => {
+ acc[b.browser] = parseInt(b.count, 10);
+ return acc;
+ }, {} as Record),
+ utm_sources: (utmSources as any[]).map(u => ({
+ source: u.utm_source,
+ medium: u.utm_medium,
+ campaign: u.utm_campaign,
+ visits: parseInt(u.count, 10)
+ })),
+ top_events: (recentEvents as any[]).map(e => ({ name: e.event_name, count: parseInt(e.count, 10) }))
+ };
+
+ return new Response(
+ JSON.stringify(dashboard),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ );
+ } catch (error) {
+ console.error('Error fetching analytics:', error);
+ return new Response(
+ JSON.stringify({ error: 'Failed to fetch analytics' }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+};
diff --git a/frontend/src/pages/api/client/dashboard.ts b/frontend/src/pages/api/client/dashboard.ts
index a4e5551..8cca54b 100644
--- a/frontend/src/pages/api/client/dashboard.ts
+++ b/frontend/src/pages/api/client/dashboard.ts
@@ -10,7 +10,7 @@ import { getDirectusClient, readItems, aggregate } from '@/lib/directus/client';
*
* GET /api/client/dashboard?site_id={id}
*/
-export const GET: APIRoute = async ({ url }) => {
+export const GET: APIRoute = async ({ url }: { url: URL }) => {
try {
const siteId = url.searchParams.get('site_id');
diff --git a/frontend/src/pages/api/track/conversion.ts b/frontend/src/pages/api/track/conversion.ts
new file mode 100644
index 0000000..cd47046
--- /dev/null
+++ b/frontend/src/pages/api/track/conversion.ts
@@ -0,0 +1,105 @@
+// @ts-ignore - Astro types available at build time
+import type { APIRoute } from 'astro';
+import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
+
+/**
+ * Conversion Tracking API
+ *
+ * Records and optionally sends conversions to Google Ads and Facebook.
+ *
+ * POST /api/track/conversion
+ */
+export const POST: APIRoute = async ({ request }: { request: Request }) => {
+ try {
+ const data = await request.json();
+ const {
+ site_id,
+ lead_id,
+ conversion_type,
+ value,
+ currency = 'USD',
+ source,
+ campaign,
+ gclid,
+ fbclid,
+ send_to_google = false,
+ send_to_facebook = false
+ } = data;
+
+ if (!site_id || !conversion_type) {
+ return new Response(
+ JSON.stringify({ error: 'site_id and conversion_type are required' }),
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ const directus = getDirectusClient();
+
+ // Create conversion record
+ const conversion = await directus.request(
+ createItem('conversions', {
+ site: site_id,
+ lead: lead_id,
+ conversion_type,
+ value,
+ currency,
+ source,
+ campaign,
+ gclid,
+ fbclid,
+ sent_to_google: false,
+ sent_to_facebook: false
+ })
+ );
+
+ // Get site analytics config for sending to ad platforms
+ const analyticsConfig = await directus.request(
+ readItems('site_analytics', {
+ filter: { site: { _eq: site_id } },
+ limit: 1
+ })
+ );
+
+ const config = (analyticsConfig as any[])?.[0];
+ const results = { google: false, facebook: false };
+
+ // Send to Google Ads if configured and requested
+ if (send_to_google && config?.google_ads_id && config?.google_ads_conversion_label) {
+ // In production, you'd make a server-side call to Google Ads API
+ // or return the data for client-side gtag() call
+ results.google = true;
+ await directus.request(
+ updateItem('conversions', (conversion as any).id, {
+ sent_to_google: true
+ })
+ );
+ }
+
+ // Send to Facebook if configured and requested
+ if (send_to_facebook && config?.fb_pixel_id && config?.fb_access_token) {
+ // In production, you'd make a Conversions API call to Facebook
+ // https://developers.facebook.com/docs/marketing-api/conversions-api
+ results.facebook = true;
+ await directus.request(
+ updateItem('conversions', (conversion as any).id, {
+ sent_to_facebook: true
+ })
+ );
+ }
+
+ return new Response(
+ JSON.stringify({
+ success: true,
+ conversion_id: (conversion as any).id,
+ sent: results
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ );
+ } catch (error) {
+ console.error('Error tracking conversion:', error);
+ return new Response(
+ JSON.stringify({ error: 'Failed to track conversion' }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+};
diff --git a/frontend/src/pages/api/track/event.ts b/frontend/src/pages/api/track/event.ts
new file mode 100644
index 0000000..812e94c
--- /dev/null
+++ b/frontend/src/pages/api/track/event.ts
@@ -0,0 +1,61 @@
+// @ts-ignore - Astro types available at build time
+import type { APIRoute } from 'astro';
+import { getDirectusClient, createItem } from '@/lib/directus/client';
+
+/**
+ * Event Tracking API
+ *
+ * Records custom events (form submits, button clicks, scroll depth, etc.)
+ *
+ * POST /api/track/event
+ */
+export const POST: APIRoute = async ({ request }: { request: Request }) => {
+ try {
+ const data = await request.json();
+ const {
+ site_id,
+ event_name,
+ event_category,
+ event_label,
+ event_value,
+ page_path,
+ session_id,
+ visitor_id,
+ metadata
+ } = data;
+
+ if (!site_id || !event_name) {
+ return new Response(
+ JSON.stringify({ error: 'site_id and event_name are required' }),
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ const directus = getDirectusClient();
+
+ await directus.request(
+ createItem('events', {
+ site: site_id,
+ event_name,
+ event_category,
+ event_label,
+ event_value,
+ page_path,
+ session_id,
+ visitor_id,
+ metadata
+ })
+ );
+
+ return new Response(
+ JSON.stringify({ success: true }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ );
+ } catch (error) {
+ console.error('Error tracking event:', error);
+ return new Response(
+ JSON.stringify({ error: 'Failed to track event' }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+};
diff --git a/frontend/src/pages/api/track/pageview.ts b/frontend/src/pages/api/track/pageview.ts
new file mode 100644
index 0000000..a28df86
--- /dev/null
+++ b/frontend/src/pages/api/track/pageview.ts
@@ -0,0 +1,133 @@
+// @ts-ignore - Astro types available at build time
+import type { APIRoute } from 'astro';
+import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
+
+/**
+ * Pageview Tracking API
+ *
+ * Records pageviews for internal analytics.
+ * Detects bots and extracts UTM parameters.
+ *
+ * POST /api/track/pageview
+ */
+
+// Common bot user agents
+const BOT_PATTERNS = [
+ 'googlebot', 'bingbot', 'slurp', 'duckduckbot', 'baiduspider',
+ 'yandexbot', 'facebookexternalhit', 'twitterbot', 'linkedinbot',
+ 'whatsapp', 'telegrambot', 'applebot', 'semrushbot', 'ahrefsbot',
+ 'mj12bot', 'dotbot', 'petalbot', 'bytespider', 'gptbot', 'claudebot',
+ 'crawler', 'spider', 'bot/', 'bot;', 'headless', 'phantomjs', 'selenium'
+];
+
+function detectBot(userAgent: string): { isBot: boolean; botName: string | null } {
+ const ua = userAgent.toLowerCase();
+ for (const pattern of BOT_PATTERNS) {
+ if (ua.includes(pattern)) {
+ return { isBot: true, botName: pattern };
+ }
+ }
+ return { isBot: false, botName: null };
+}
+
+function detectDevice(userAgent: string): string {
+ const ua = userAgent.toLowerCase();
+ if (/mobile|android|iphone|ipad|ipod|blackberry|windows phone/i.test(ua)) {
+ if (/ipad|tablet/i.test(ua)) return 'tablet';
+ return 'mobile';
+ }
+ return 'desktop';
+}
+
+function detectBrowser(userAgent: string): string {
+ if (userAgent.includes('Firefox')) return 'Firefox';
+ if (userAgent.includes('Edg')) return 'Edge';
+ if (userAgent.includes('Chrome')) return 'Chrome';
+ if (userAgent.includes('Safari')) return 'Safari';
+ if (userAgent.includes('Opera')) return 'Opera';
+ return 'Unknown';
+}
+
+function detectOS(userAgent: string): string {
+ if (userAgent.includes('Windows')) return 'Windows';
+ if (userAgent.includes('Mac OS')) return 'macOS';
+ if (userAgent.includes('Linux')) return 'Linux';
+ if (userAgent.includes('Android')) return 'Android';
+ if (userAgent.includes('iOS') || userAgent.includes('iPhone') || userAgent.includes('iPad')) return 'iOS';
+ return 'Unknown';
+}
+
+export const POST: APIRoute = async ({ request }: { request: Request }) => {
+ try {
+ const data = await request.json();
+ const {
+ site_id,
+ page_path,
+ page_title,
+ referrer,
+ session_id,
+ visitor_id
+ } = data;
+
+ if (!site_id || !page_path) {
+ return new Response(
+ JSON.stringify({ error: 'site_id and page_path are required' }),
+ { status: 400, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+
+ // Extract user agent and IP
+ const userAgent = request.headers.get('user-agent') || '';
+ const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
+ request.headers.get('x-real-ip') ||
+ 'unknown';
+
+ // Detect bot
+ const { isBot, botName } = detectBot(userAgent);
+
+ // Parse URL for UTM params
+ const url = new URL(page_path, 'http://localhost');
+ const utmSource = url.searchParams.get('utm_source');
+ const utmMedium = url.searchParams.get('utm_medium');
+ const utmCampaign = url.searchParams.get('utm_campaign');
+ const utmContent = url.searchParams.get('utm_content');
+ const utmTerm = url.searchParams.get('utm_term');
+
+ const directus = getDirectusClient();
+
+ // Create pageview record
+ await directus.request(
+ createItem('pageviews', {
+ site: site_id,
+ page_path: url.pathname,
+ page_title,
+ referrer,
+ utm_source: utmSource,
+ utm_medium: utmMedium,
+ utm_campaign: utmCampaign,
+ utm_content: utmContent,
+ utm_term: utmTerm,
+ user_agent: userAgent.substring(0, 500),
+ ip_address: ip,
+ device_type: detectDevice(userAgent),
+ browser: detectBrowser(userAgent),
+ os: detectOS(userAgent),
+ session_id,
+ visitor_id,
+ is_bot: isBot,
+ bot_name: botName
+ })
+ );
+
+ return new Response(
+ JSON.stringify({ success: true, is_bot: isBot }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ );
+ } catch (error) {
+ console.error('Error tracking pageview:', error);
+ return new Response(
+ JSON.stringify({ error: 'Failed to track pageview' }),
+ { status: 500, headers: { 'Content-Type': 'application/json' } }
+ );
+ }
+};