feat: Auto-conversion on form submit and click-to-call with Google/Facebook integration

This commit is contained in:
cawcenter
2025-12-12 10:21:49 -05:00
parent de44be4e16
commit d3d09c8a35
3 changed files with 420 additions and 2 deletions

View File

@@ -130,10 +130,60 @@
} }
}); });
// Auto-track outbound link clicks // Auto-track outbound link clicks AND click-to-call
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
const link = e.target.closest('a'); 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', { window.sparkTrack('outbound_click', {
category: 'engagement', category: 'engagement',
label: link.href label: link.href

View File

@@ -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<string> {
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('');
}

View File

@@ -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' } }
);
}
};