PageSourceSearch

https://founderfunnel.com/wire.js

js founderfunnel.com collected 2026-10-02 00:49:21 UTC 12,233 bytes, 259 lines download raw bytes

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.