feat: Auto-conversion on form submit and click-to-call with Google/Facebook integration
This commit is contained in:
@@ -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
|
||||||
|
|||||||
213
frontend/src/pages/api/forms/submit.ts
Normal file
213
frontend/src/pages/api/forms/submit.ts
Normal 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('');
|
||||||
|
}
|
||||||
155
frontend/src/pages/api/track/call-click.ts
Normal file
155
frontend/src/pages/api/track/call-click.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user