1// Founder Funnel main site -> Close CRM wiring, plus Calendly prefill. 2// 3// Ported from the VSL funnel site. Same contract, one difference: that page 4// holds its form state in Alpine, this one has no Alpine and exposes a plain 5// object on window.__ffApply instead. The field names are identical, which is 6// the whole reason this file moves between the two sites unchanged otherwise. 7// 8// Do NOT repoint this at leon-abboud-close-relay.netlify.app. That external 9// relay is a third-party deployment we do not control, and its CORS allowlist 10// did not include go.founderfunnel.com, so every lead was silently dropped for 11// weeks. It will not include founderfunnel.com either. This file ships with the 12// site and posts to a function on the same origin, so neither can happen again. 13(function () { 14 // Same-origin. Deliberately relative, an absolute URL is what let the old 15 // setup break when the serving domain changed. 16 var RELAY = '/.netlify/functions/close-lead'; 17 18 // Readable labels for the two qualifier tiles. These must match the page copy 19 // in the apply modal; if the tiles are reworded, update these too. 20 var STAGE = { 21 a: 'Under $10k/mo, still finding traction', 22 b: '$10k to $50k/mo in MRR and growing', 23 c: '$50k+/mo in MRR and scaling', 24 }; 25 var BOTTLENECK = { 26 a: 'Not enough of the right people know we exist', 27 b: "Prospects don't trust us enough to buy", 28 c: "Growth depends on me and doesn't compound", 29 }; 30 31 // Held in memory only, the project rules forbid localStorage/sessionStorage. 32 // Passing this back on the second post is what stops Close creating a 33 // duplicate lead when both posts land within a second of each other. 34 var leadId = null; 35 36 // ââ Paid-traffic attribution ââââââââââââââââââââââââââââââââââââââââââââââââ 37 // Meta appends these to the landing URL. Captured once, at load, before any 38 // interaction, so a later history change or a stripped query string cannot 39 // lose the attribution for someone already midway through the form. 40 // 41 // Held in memory only, same storage rule as leadId. A visitor who reloads 42 // without the query string starts unattributed, which is correct â inventing 43 // attribution is worse than leaving it blank. 44 // 45 // This site previously captured none of this, so every lead booked here 46 // reached Close with the four attribution fields empty while the VSL funnel 47 // filled them. Same logic as go.founderfunnel.com/wire.js â keep the two in 48 // step if either changes. 49 // 50 // NOTE: Meta currently sends IDs rather than names (utm_campaign arrives 51 // identical to utm_id, i.e. the template uses {{campaign.id}}). The name 52 // fields will hold numbers until Ads Manager is switched to {{campaign.name}}, 53 // {{adset.name}} and {{ad.name}}. No change needed here when that happens. 54 var PLATFORM = { 55 ig: 'Instagram', 56 fb: 'Facebook', 57 an: 'Audience Network', 58 msg: 'Messenger', 59 }; 60 61 var attribution = (function () { 62 var out = {}; 63 try { 64 var qs = new URLSearchParams(window.location.search); 65 var src = qs.get('utm_source') || ''; 66 // Known Meta platform codes get a readable label; anything else passes 67 // straight through so Google, LinkedIn and manual links are not mangled. 68 if (src) out.trafficSource = PLATFORM[src.toLowerCase()] || src; 69 if (qs.get('utm_campaign')) out.campaignName = qs.get('utm_campaign'); 70 if (qs.get('utm_term')) out.adSetName = qs.get('utm_term'); 71 if (qs.get('utm_content')) out.adName = qs.get('utm_content'); 72 } catch (e) {} 73 return out; 74 })(); 75 76 // The funnel site reads Alpine's state off the [x-data] element. This page has 77 // no Alpine, so the apply flow publishes the same shape on window.__ffApply. 78 // Both are checked, so this file stays portable between the two sites. 79 function data() { 80 try { 81 if (window.__ffApply) return window.__ffApply; 82 var el = document.querySelector('[x-data]'); 83 return window.Alpine && el ? window.Alpine.$data(el) : null; 84 } catch (e) { return null; } 85 } 86 87 // Two intl-tel-input instances exist, one on the popup, one on the inline 88 // embed, and both bind to the same phone value. Take the first that yields a 89 // full +country number instead of assuming the popup was used, or leads from 90 // the inline form reach Close with an unnormalised number. 91 function phoneOf(d) { 92 try { 93 var itis = [window.__iti, window.__itiInline]; 94 for (var i = 0; i < itis.length; i++) { 95 if (itis[i] && itis[i].getNumber) { 96 var n = itis[i].getNumber(); 97 if (n) return n; 98 } 99 } 100 } catch (e) {} 101 return d.phone; 102 } 103 104 function post(d, booked) { 105 var payload = { 106 firstName: d.firstName, 107 lastName: d.lastName, 108 email: d.email, 109 phone: phoneOf(d), 110 // true only once Calendly confirms the event was scheduled. The relay 111 // uses this to file the opportunity in Leon's pipeline, so it must never 112 // be set merely because someone reached the calendar step. 113 booked: !!booked, 114 // Distinguishes these leads from the VSL funnel's inside Close. Both sites 115 // book the same Calendly event, so this field is the only place the 116 // booking's origin is recorded. 117 source: 'Founder Funnel main site', 118 }; 119 // Attribution rides on every post so a lead who abandons before booking 120 // still carries its source. Each is guarded â absent for organic and direct 121 // visitors, and writing an empty string would blank a real value on a later 122 // update rather than leaving the field genuinely empty. 123 if (attribution.trafficSource) payload.trafficSource = attribution.trafficSource; 124 if (attribution.campaignName) payload.campaignName = attribution.campaignName; 125 if (attribution.adSetName) payload.adSetName = attribution.adSetName; 126 if (attribution.adName) payload.adName = attribution.adName; 127 if (leadId) payload.leadId = leadId; 128 if (d.q1) payload.stageAnswer = STAGE[d.q1] || d.q1; 129 if (d.q2) payload.bottleneckAnswer = BOTTLENECK[d.q2] || d.q2; 130 131 // Fire-and-forget: a CRM problem must never block someone booking a call. 132 try { 133 fetch(RELAY, { 134 method: 'POST', 135 headers: { 'Content-Type': 'application/json' }, 136 body: JSON.stringify(payload), 137 keepalive: true, 138 }) 139 .then(function (r) { return r.ok ? r.json() : null; }) 140 .then(function (j) { 141 if (j && j.leadId) { 142 leadId = j.leadId; 143 // Handed to go.founderfunnel.com/ty (the Calendly redirect) so the 144 // booking updates THIS lead by id instead of searching Close by 145 // email, which is eventual. Parent domain so the sibling host can 146 // read it. A cookie, not localStorage - project rule. 147 try { 148 document.cookie = 'ff_lead=' + j.leadId + ';path=/;domain=.founderfunnel.com;max-age=7200;samesite=lax'; 149 if (document.cookie.indexOf('ff_lead=') === -1) { 150 document.cookie = 'ff_lead=' + j.leadId + ';path=/;max-age=7200;samesite=lax'; 151 } 152 } catch (e) {} 153 } 154 }) 155 .catch(function () {}); 156 } catch (e) {} 157 } 158
159 // These thresholds track the modal's step numbering and MUST be rechecked 160 // whenever the form is reordered, a redesign silently renumbers the steps 161 // and the posts either fire on the wrong screen or never fire at all. 162 // 163 // Current order: 1 bottleneck, 2 revenue stage, 3 contact details, 4 calendar. 164 // Contact is now the last thing before the calendar, so the single post fires 165 // on reaching step 4, the moment name/email/phone have been validated by 166 // canNext1(). Waiting for the step change rather than watching the fields as 167 // they are typed is deliberate: mid-typing values would push half-finished 168 // email addresses into Close. 169 // 170 // BOTH paths must be watched. formStep is the popup, inlineStep is the embed 171 // on the page; they are separate so one does not reset the other. Watching 172 // only formStep would silently drop every lead who applied through the inline 173 // form, which is the same invisible failure that left Close empty for weeks. 174 var sent = false; 175 setInterval(function () { 176 var d = data(); 177 if (!d) return; 178 // step is this page's single flow; formStep/inlineStep are the funnel 179 // site's popup and inline embed. Any of them reaching 4 means the calendar. 180 var reachedCalendar = d.step >= 4 || d.formStep >= 4 || d.inlineStep >= 4; 181 if (!sent && reachedCalendar && d.email && d.firstName) { 182 sent = true; 183 post(d); 184 } 185 }, 400); 186 187 // Calendly announces a completed booking by postMessage. This is the only 188 // reliable signal that someone actually scheduled a call, as opposed to 189 // reaching the calendar step and leaving. The relay turns this into the 190 // opportunity in Leon's pipeline, so nothing else should ever set booked. 191 // 192 // Guarded because Calendly can emit the event more than once, and a second 193 // post would risk a duplicate opportunity. 194 var bookedSent = false; 195 window.addEventListener('message', function (e) { 196 if (typeof e.origin !== 'string' || e.origin.indexOf('calendly.com') === -1) return; 197 if (!e.data || e.data.event !== 'calendly.event_scheduled') return; 198 if (bookedSent) return; 199 var d = data(); 200 if (!d || !d.email || !d.firstName) return; 201 bookedSent = true; 202 post(d, true); 203 }); 204 205 // The Calendly event type redirects every booking to go.founderfunnel.com/ty, 206 // so that page cannot tell a main-site booking from a funnel one. This cookie 207 // says so. Set on .founderfunnel.com, which go.founderfunnel.com can read. 208 // Two hours; a cookie, not localStorage - project rule. 209 try { 210 document.cookie = 'ff_funnel=main-site;path=/;domain=.founderfunnel.com;max-age=7200;samesite=lax'; 211 if (document.cookie.indexOf('ff_funnel=') === -1) { 212 document.cookie = 'ff_funnel=main-site;path=/;max-age=7200;samesite=lax'; 213 } 214 } catch (e) {} 215 216 // Prefill the Calendly widget by wrapping initInlineWidget before step 4 runs it. 217 var t = setInterval(function () { 218 if (!window.Calendly || window.Calendly.__ffWired) return; 219 var orig = window.Calendly.initInlineWidget.bind(window.Calendly); 220 window.Calendly.initInlineWidget = function (opts) { 221 var d = data() || {}; 222 opts = opts || {}; 223 // Name and email only, deliberately. 224 // 225 // This used to also send customAnswers { a1: phone }. Calendly's a1 is 226 // positional â "the first custom question", whatever that currently is â 227 // not a named field. When the first custom question on the shared event 228 // was changed to "Link to your company website", the phone number started 229 // being prefilled into it, and the form looked broken to everyone who 230 // reached the calendar. Both sites book the same event, so both were 231 // affected. 232 // 233 // Do not reintroduce a positional prefill. It silently breaks whenever 234 // the Calendly questions are reordered or edited, and nothing errors. The 235 // phone is already captured on our own form and written to Close. 236 opts.prefill = { 237 name: ((d.firstName || '') + ' ' + (d.lastName || '')).trim(), 238 email: d.email || '', 239 }; 240 // Forward this page's UTMs into the scheduling URL. Calendly stores them 241 // on the meeting and appends them to the /ty redirect.
242 try { 243 var qs = new URLSearchParams(window.location.search); 244 var pass = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']; 245 var extra = []; 246 for (var i = 0; i < pass.length; i++) { 247 var v = qs.get(pass[i]); 248 if (v) extra.push(pass[i] + '=' + encodeURIComponent(v.slice(0, 255))); 249 } 250 if (extra.length && opts.url && opts.url.indexOf('utm_') === -1) { 251 opts.url += (opts.url.indexOf('?') === -1 ? '?' : '&') + extra.join('&'); 252 } 253 } catch (e) {} 254 return orig(opts); 255 }; 256 window.Calendly.__ffWired = true; 257 clearInterval(t); 258 }, 200); 259})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.