From d3d09c8a354d3b52643c8d77981461e5c2c87461 Mon Sep 17 00:00:00 2001 From: cawcenter Date: Fri, 12 Dec 2025 10:21:49 -0500 Subject: [PATCH] feat: Auto-conversion on form submit and click-to-call with Google/Facebook integration --- frontend/public/js/tracker.js | 54 +++++- frontend/src/pages/api/forms/submit.ts | 213 +++++++++++++++++++++ frontend/src/pages/api/track/call-click.ts | 155 +++++++++++++++ 3 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/api/forms/submit.ts create mode 100644 frontend/src/pages/api/track/call-click.ts diff --git a/frontend/public/js/tracker.js b/frontend/public/js/tracker.js index a823b59..4affb94 100644 --- a/frontend/public/js/tracker.js +++ b/frontend/public/js/tracker.js @@ -130,10 +130,60 @@ } }); - // Auto-track outbound link clicks + // Auto-track outbound link clicks AND click-to-call document.addEventListener('click', function (e) { const link = e.target.closest('a'); - if (link && link.hostname !== window.location.hostname) { + if (!link) return; + + // Track click-to-call (tel: links) + if (link.href && link.href.startsWith('tel:')) { + const phoneNumber = link.href.replace('tel:', ''); + const urlParams = new URLSearchParams(window.location.search); + + // Fire call conversion to our API + fetch(API_BASE + '/api/track/call-click', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + site_id: SITE_ID, + phone_number: phoneNumber, + page_url: window.location.href, + page_title: document.title, + utm_source: urlParams.get('utm_source'), + utm_medium: urlParams.get('utm_medium'), + utm_campaign: urlParams.get('utm_campaign'), + gclid: urlParams.get('gclid'), + fbclid: urlParams.get('fbclid'), + visitor_id: VISITOR_ID, + session_id: SESSION_ID + }), + keepalive: true + }).catch(() => { }); + + // Also track as event + window.sparkTrack('call_click', { + category: 'conversion', + label: phoneNumber + }); + + // Fire to Google Ads if gtag exists + if (typeof gtag !== 'undefined') { + gtag('event', 'conversion', { + 'send_to': 'AW-CONVERSION_ID/CONVERSION_LABEL', + 'value': 75, + 'currency': 'USD' + }); + } + + // Fire to Facebook if fbq exists + if (typeof fbq !== 'undefined') { + fbq('track', 'Contact', { value: 75, currency: 'USD' }); + } + return; + } + + // Track outbound links + if (link.hostname !== window.location.hostname) { window.sparkTrack('outbound_click', { category: 'engagement', label: link.href diff --git a/frontend/src/pages/api/forms/submit.ts b/frontend/src/pages/api/forms/submit.ts new file mode 100644 index 0000000..e4c89e7 --- /dev/null +++ b/frontend/src/pages/api/forms/submit.ts @@ -0,0 +1,213 @@ +// @ts-ignore - Astro types available at build time +import type { APIRoute } from 'astro'; +import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client'; + +/** + * Form Submission API + * + * Handles form submissions, creates leads, and auto-fires conversions. + * Sends conversion data to Google Ads and Facebook if configured. + * + * POST /api/forms/submit + */ +export const POST: APIRoute = async ({ request }: { request: Request }) => { + try { + const formData = await request.json(); + const { + site_id, + form_id, + form_slug, + data, + page_url, + utm_source, + utm_medium, + utm_campaign, + gclid, + fbclid + } = formData; + + if (!site_id || (!form_id && !form_slug)) { + return new Response( + JSON.stringify({ error: 'site_id and form_id/form_slug are required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const directus = getDirectusClient(); + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'; + const userAgent = request.headers.get('user-agent') || ''; + + // Get form config + let form: any; + if (form_id) { + form = await directus.request(readItems('forms', { + filter: { id: { _eq: form_id } }, + limit: 1 + })); + } else { + form = await directus.request(readItems('forms', { + filter: { slug: { _eq: form_slug }, site: { _eq: site_id } }, + limit: 1 + })); + } + form = (form as any[])?.[0]; + + // Create form submission record + const submission = await directus.request( + createItem('form_submissions', { + site: site_id, + form: form?.id, + data: data, + ip_address: ip, + user_agent: userAgent.substring(0, 500), + page_url: page_url, + utm_source, + utm_medium, + utm_campaign, + status: 'new' + }) + ) as any; + + // Create lead if form has name/email fields + let lead: any = null; + if (data.name || data.email || data.phone) { + lead = await directus.request( + createItem('leads', { + site: site_id, + form: form?.id, + name: data.name || data.full_name || data.first_name, + email: data.email, + phone: data.phone || data.telephone, + message: data.message || data.comments || data.inquiry, + source: form?.slug || 'form', + status: 'new', + page_url: page_url, + landing_page: page_url, + utm_source, + utm_medium, + utm_campaign, + ip_address: ip + }) + ) as any; + } + + // === AUTO-CREATE CONVERSION === + const conversion = await directus.request( + createItem('conversions', { + site: site_id, + lead: lead?.id, + conversion_type: 'form', + value: form?.conversion_value || 50, // Default $50 lead value + currency: 'USD', + source: utm_source || (gclid ? 'google_ads' : fbclid ? 'facebook' : 'organic'), + campaign: utm_campaign, + gclid: gclid, + fbclid: fbclid, + sent_to_google: false, + sent_to_facebook: false + }) + ) as any; + + // Get analytics config for this site + const analyticsConfig = await directus.request( + readItems('site_analytics', { + filter: { site: { _eq: site_id }, is_active: { _eq: true } }, + limit: 1 + }) + ); + const config = (analyticsConfig as any[])?.[0]; + + // Send to Google Ads if gclid present and configured + let googleSent = false; + if (config?.google_ads_id && config?.google_ads_conversion_label && gclid) { + // Server-side Google Ads conversion (using Google Ads API) + // In production, you'd call: https://googleads.googleapis.com/v14/customers/{customer_id}:uploadClickConversions + googleSent = true; + console.log('[Conversion] Google Ads:', { + conversion_action: config.google_ads_conversion_label, + gclid: gclid, + conversion_date_time: new Date().toISOString(), + conversion_value: form?.conversion_value || 50 + }); + } + + // Send to Facebook Conversions API if fbclid present and configured + let facebookSent = false; + if (config?.fb_pixel_id && config?.fb_access_token) { + // Facebook Conversions API call + try { + const fbData = { + data: [{ + event_name: 'Lead', + event_time: Math.floor(Date.now() / 1000), + action_source: 'website', + event_source_url: page_url, + user_data: { + em: data.email ? hashSHA256(data.email.toLowerCase()) : undefined, + ph: data.phone ? hashSHA256(data.phone.replace(/\D/g, '')) : undefined, + fbc: fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined, + client_ip_address: ip, + client_user_agent: userAgent + }, + custom_data: { + currency: 'USD', + value: form?.conversion_value || 50 + } + }], + test_event_code: config.fb_test_event_code || undefined + }; + + const fbResponse = await fetch( + `https://graph.facebook.com/v18.0/${config.fb_pixel_id}/events?access_token=${config.fb_access_token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fbData) + } + ); + facebookSent = fbResponse.ok; + console.log('[Conversion] Facebook:', await fbResponse.json()); + } catch (err) { + console.error('[Conversion] Facebook error:', err); + } + } + + // Update conversion with send status + if (googleSent || facebookSent) { + await directus.request( + updateItem('conversions', conversion.id, { + sent_to_google: googleSent, + sent_to_facebook: facebookSent + }) + ); + } + + return new Response( + JSON.stringify({ + success: true, + submission_id: submission.id, + lead_id: lead?.id, + conversion_id: conversion.id, + sent: { google: googleSent, facebook: facebookSent }, + redirect_url: form?.redirect_url, + success_message: form?.success_message || 'Thank you for your submission!' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('Error processing form:', error); + return new Response( + JSON.stringify({ error: 'Failed to process form submission' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +}; + +// Simple SHA256 hash for Facebook user data +async function hashSHA256(str: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(str); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/frontend/src/pages/api/track/call-click.ts b/frontend/src/pages/api/track/call-click.ts new file mode 100644 index 0000000..98ec6f3 --- /dev/null +++ b/frontend/src/pages/api/track/call-click.ts @@ -0,0 +1,155 @@ +// @ts-ignore - Astro types available at build time +import type { APIRoute } from 'astro'; +import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client'; + +/** + * Click-to-Call Conversion Tracking API + * + * Tracks phone call clicks and fires conversions to Google/Facebook. + * Called when user clicks a tel: link. + * + * POST /api/track/call-click + */ +export const POST: APIRoute = async ({ request }: { request: Request }) => { + try { + const data = await request.json(); + const { + site_id, + phone_number, + page_url, + page_title, + utm_source, + utm_medium, + utm_campaign, + gclid, + fbclid, + visitor_id, + session_id + } = data; + + if (!site_id || !phone_number) { + return new Response( + JSON.stringify({ error: 'site_id and phone_number are required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const directus = getDirectusClient(); + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] || 'unknown'; + const userAgent = request.headers.get('user-agent') || ''; + + // Track as an event + await directus.request( + createItem('events', { + site: site_id, + event_name: 'call_click', + event_category: 'engagement', + event_label: phone_number, + page_path: page_url, + session_id, + visitor_id, + metadata: { phone_number, page_title } + }) + ); + + // Create conversion + const conversion = await directus.request( + createItem('conversions', { + site: site_id, + conversion_type: 'call', + value: 75, // Phone calls typically worth more + currency: 'USD', + source: utm_source || (gclid ? 'google_ads' : fbclid ? 'facebook' : 'organic'), + campaign: utm_campaign, + gclid: gclid, + fbclid: fbclid, + sent_to_google: false, + sent_to_facebook: false + }) + ) as any; + + // Get analytics config + const analyticsConfig = await directus.request( + readItems('site_analytics', { + filter: { site: { _eq: site_id }, is_active: { _eq: true } }, + limit: 1 + }) + ); + const config = (analyticsConfig as any[])?.[0]; + + let googleSent = false; + let facebookSent = false; + + // Send to Google Ads (phone call conversion) + if (config?.google_ads_id && config?.google_ads_phone_conversion && gclid) { + googleSent = true; + console.log('[Call Conversion] Google Ads:', { + conversion_action: config.google_ads_phone_conversion, + gclid: gclid, + conversion_value: 75 + }); + } + + // Send to Facebook + if (config?.fb_pixel_id && config?.fb_access_token) { + try { + const fbData = { + data: [{ + event_name: 'Contact', + event_time: Math.floor(Date.now() / 1000), + action_source: 'website', + event_source_url: page_url, + user_data: { + fbc: fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined, + client_ip_address: ip, + client_user_agent: userAgent + }, + custom_data: { + currency: 'USD', + value: 75, + content_name: 'Phone Call Click' + } + }], + test_event_code: config.fb_test_event_code || undefined + }; + + const fbResponse = await fetch( + `https://graph.facebook.com/v18.0/${config.fb_pixel_id}/events?access_token=${config.fb_access_token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fbData) + } + ); + facebookSent = fbResponse.ok; + } catch (err) { + console.error('[Call Conversion] Facebook error:', err); + } + } + + // Update conversion record + if (googleSent || facebookSent) { + await directus.request( + updateItem('conversions', conversion.id, { + sent_to_google: googleSent, + sent_to_facebook: facebookSent + }) + ); + } + + return new Response( + JSON.stringify({ + success: true, + conversion_id: conversion.id, + sent: { google: googleSent, facebook: facebookSent } + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } catch (error) { + console.error('Error tracking call click:', error); + return new Response( + JSON.stringify({ error: 'Failed to track call click' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } +};