PageSourceSearch

https://emooove.co.jp/form-stepper.js

js emooove.co.jp collected 2026-10-04 11:24:20 UTC 16,165 bytes, 417 lines download raw bytes

1/* form-stepper.js — HubSpot v2埋め込み(hbspt.forms.create)フォーム約134ページを
2 * 3ステップUIへ一括置換した際に使う汎用stepper。
3 *
4 * ctform-stepper.js(お問い合わせ用パイロット, /services/keymanappointer/contact 等)と
5 * 同じ「.step送り+Forms Submission API v3直POST」方式をベースに、資料請求ページ側の
6 * 競合ドメイン判定(/screen にemailをPOST→競合なら data-alt、それ以外は data-thanks へ
7 * 遷移)を data-screen 属性の有無で切り替えられるよう汎用化したもの。1ページに複数
8 * フォームがあることは想定していないが、boot()はquerySelectorAllで全件初期化する。
9 * /screen の fetch は HubSpotへの送信POSTと同時に開始して並列化しており(PR #41の
10 * 意図を移植・2026-08-04)、POST成功後は2500msタイムアウトとのraceで遷移する。
11 *
12 * 使い方:
13 * <form class="hsform-stepper" novalidate
14 *       data-portal="<portalId>" data-form="<formId>" data-thanks="<サンクスURLパス>"
15 *       data-ga-form-id="<generate_leadのform_id、サンクス側の従来値と同一にする>"
16 *       data-ga-lead-type="<generate_leadのlead_type>" data-ga-form-ref="<旧hashID、参考値>"
17 *       data-screen="<screen判定APIのURL>"(資料請求ページのみ) data-alt="<競合時の遷移先>">
18 *   <div class="hsform-step is-active" data-step="1"> ... </div>
19 *   <div class="hsform-step" data-step="2"> ... </div>
20 *   <div class="hsform-step" data-step="3"> ... <button type="submit">...</button></div>
21 * </form>
22 * ドット進捗は .hsform-dot[data-dot] を同じ .hsform-shell 内に置く(任意)。
23 * data-thanks を省略すると、送信成功時にページ遷移せずフォームをその場で
24 * 完了メッセージに差し替える(同じ .hsform-shell 内の .hsform-done[hidden] を表示。
25 * 無ければ既定の文言を生成)。ステップは1つだけでもよい(/contact/proposal 用・2026-08-11)。
26 *
27 * 既存の ctform-stepper.js / seminar-stepper.js はこのファイルの追加に伴い変更しない。
28 *
29 * 同意チェックボックスは name="consent" とし、data-subscription-id / data-consent-text /
30 * data-checkbox-text を持たせると(seminar-stepper.jsと同様に)legalConsentOptionsとして
31 * 送信される(2026-09-24追加。アーカイブ視聴フォームの共催企業向け同意チェックが、
32 * HubSpot側に存在しないプロパティ名で直POSTされサイレントに破棄されていたための修正)。
33 */
34(function () {
35  function qs(sel, root) {
36    return (root || document).querySelector(sel);
37  }
38  function qsa(sel, root) {
39    return Array.prototype.slice.call((root || document).querySelectorAll(sel));
40  }
41
42  function getCookie(name) {
43    var m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
44    return m ? decodeURIComponent(m[1]) : "";
45  }
46
47  function getUtmParams() {
48    var params = new URLSearchParams(location.search);
49    var out = {};
50    [
51      "utm_source",
52      "utm_medium",
53      "utm_campaign",
54      "utm_term",
55      "utm_content",
56    ].forEach(function (k) {
57      var v = params.get(k);
58      if (v) out[k] = v;
59    });
60    return out;
61  }
62
63  function showStep(form, n) {
64    qsa(".hsform-step", form).forEach(function (el) {
65      el.classList.toggle(
66        "is-active",
67        Number(el.getAttribute("data-step")) === n,
68      );
69    });
70    qsa(".hsform-dot", form.closest(".hsform-shell") || document).forEach(
71      function (dot) {
72        var d = Number(dot.getAttribute("data-dot"));
73        dot.classList.toggle("is-active", d === n);
74        dot.classList.toggle("is-done", d < n);
75      },
76    );
77  }
78
79  function validateStep(stepEl) {
80    var invalid = qsa("input,select,textarea", stepEl).filter(function (el) {
81      if (!el.required) return false;
82      if (el.type === "checkbox") return !el.checked;
83      return !el.value || !el.value.trim();
84    });
85    if (invalid.length) {
86      invalid[0].reportValidity
87        ? invalid[0].reportValidity()
88        : invalid[0].focus();
89      return false;
90    }
91    return true;
92  }
93
94  function setError(form, msg) {
95    var box = qs(".hsform-error", form);
96    if (!box) {
97      box = document.createElement("p");
98      box.className = "hsform-error";
99      box.style.cssText =
100        "color:#e0245e;font-size:13px;margin:0 0 14px;font-weight:700";
101      form.insertBefore(box, form.firstChild);
102    }
103    box.textContent = msg || "";
104    box.style.display = msg ? "block" : "none";
105  }
106
107  // HubSpot側(blockedEmailDomains)がフリーメールを拒否した場合、汎用の送信失敗
108  // メッセージだと原因が伝わらないため、専用の案内文を返す。
109  function errorMessageFor(err) {
110    var blocked =
111      err &&
112      Array.isArray(err.errors) &&
113      err.errors.some(function (e) {
114        return e.errorType === "BLOCKED_EMAIL";
115      });
116    if (blocked) {
117      return "フリーメール(Gmail・Yahoo!メール等)ではお申し込みいただけません。会社のメールアドレスをご入力ください。";
118    }
119    return "送信に失敗しました。時間をおいて再度お試しいただくか、内容をご確認のうえ再送信してください。";
120  }
121
122  function collectFields(form) {
123    var fields = [];
124    qsa("input,select,textarea", form).forEach(function (el) {
125      if (!el.name || el.name === "consent") return;
126      if (el.type === "checkbox") {
127        fields.push({ name: el.name, value: el.checked ? "true" : "false" });
128      } else {
129        if (el.value) fields.push({ name: el.name, value: el.value });
130      }
131    });
132    var utm = getUtmParams();
133    Object.keys(utm).forEach(function (k) {
134      if (
135        !fields.some(function (f) {
136          return f.name === k;
137        })
138      ) {
139        fields.push({ name: k, value: utm[k] });
140      }
141    });
142    var emailField = fields.filter(function (f) {
143      return f.name === "email";
144    })[0];
145    if (emailField && emailField.value) {
146      fields.push({
147        name: "clarity_recording_url",
148        value: clarityRecordingsUrl(emailField.value),
149      });
150    }
151    return fields;
152  }
153
154  function consentOptions(form) {
155    var cb = qs('input[name="consent"]', form);
156    if (!cb) return null;
157    return {
158      consent: {
159        consentToProcess: true,
160        text: cb.getAttribute("data-consent-text") || "",
161        communications: [
162          {
163            value: cb.checked,
164            subscriptionTypeId: Number(cb.getAttribute("data-subscription-id")),
165            text: cb.getAttribute("data-checkbox-text") || "",
166          },
167        ],
168      },
169    };
170  }
171
172  function trackLead(form) {
173    if (typeof gtag !== "function") return;
174    gtag("event", "generate_lead", {
175      form_id:
176        form.getAttribute("data-ga-form-id") || form.getAttribute("data-form"),
177      lead_type: form.getAttribute("data-ga-lead-type") || "",
178      form_ref:
179        form.getAttribute("data-ga-form-ref") ||
180        form.getAttribute("data-ga-form-id") ||
181        form.getAttribute("data-form"),
182    });
183  }
184
185  // CV(HubSpotフォーム送信)発生時にClarityセッションへメールアドレスを紐付ける。
186  // identify()はCustom User IDとして値をハッシュ化・マスクして送信するため即時には
187  // 検索できないが、2026-08-09実測でおよそ2日後にはCustom ID検索がヒットすることを
188  // 確認済み(単なる反映遅延で、ハッシュ自体が検索を妨げていたわけではなかった)。
189  // set()のカスタムタグは平文送信だがURLでの絞り込みができない(公式ドキュメントで
190  // URLパラメータ方式が存在しないことを確認済み)ため、identify()をメインの手段とし、
191  // カスタムタグは手動検索用のフォールバックとして両方送っておく。
192  var CLARITY_PROJECT_ID = "xxdpepkv6h";
193
194  function clarityRecordingsUrl(email) {
195    // Custom User ID(identify()の第1引数)はURLクエリでの絞り込みに対応している。
196    // 2026-08-09実機確認: Clarity側のフィルターUIが生成するURLは値を二重URL
197    // エンコードしており(+や@も2段エンコード)、この形式でないと正しく絞り込まれない。
198    var doubleEncoded = encodeURIComponent(encodeURIComponent(email));
199    return (
200      "https://clarity.microsoft.com/projects/view/" +
201      CLARITY_PROJECT_ID +
202      "/impressions?date=Last%2030%20days&CustomUserId=is%3B" +
203      doubleEncoded
204    );
205  }
206
207  function tagClarity(form) {
208    if (typeof window.clarity !== "function") return;
209    var emailEl = qs('input[name="email"]', form);
210    var email = emailEl ? emailEl.value.trim() : "";
211    if (!email) return;
212    window.clarity("identify", email);
213    window.clarity("set", "hubspot_email", email);
214  }
215
216  // 資料請求ページ専用: 送信メールのドメインを/screenで判定し、競合ドメインなら
217  // data-alt へ、それ以外(判定失敗・タイムアウト含め常にフェイルオープン)は
218  // data-thanks へ遷移する。data-screen が無いフォームは即 data-thanks へ。
219  //
220  // /screen の fetch は startScreenCheck() で HubSpot への送信POSTと並列に(送信開始と
221  // 同時に)開始しておき、競合ドメインか否かのbooleanに正規化したPromiseとして保持する
222  // (PR #41「資料請求フォーム送信後の空白カードを解消」の意図をform-stepper.js側にも
223  // 移植・2026-08-04)。送信POST成功後にredirectAfterSubmitでこのPromiseを待つため、
224  // 実質的な追加待ち時間はPOST完了時点でほぼ解決済みのケースが多く短縮される。
225  // 2500msのフェイルセーフタイムアウトはPOST成功後(=redirectAfterSubmit呼び出し時)に
226  // 改めて起動する(screen判定が異常に遅延した場合の保険)。送信中はボタンが既に
227  // 「送信中…」表示のまま無効化されているため、この待ち時間中も空白状態にはならない。
228  function startScreenCheck(form) {
229    var screenUrl = form.getAttribute("data-screen");
230    if (!screenUrl) return null;
231    var emailEl = qs('input[name="email"]', form);
232    var email = emailEl ? emailEl.value : "";
233    if (!email) return Promise.resolve(false);
234    return fetch(screenUrl, {
235      method: "POST",
236      headers: { "Content-Type": "application/json" },
237      body: JSON.stringify({ email: email }),
238    })
239      .then(function (r) {
240        return r.ok ? r.json() : Promise.reject();
241      })
242      .then(function (d) {
243        return !!(d && d.reason === "competitor");
244      })
245      .catch(function () {
246        return false;
247      });
248  }
249
250  // data-thanks が無いフォームの完了表示: ステップ表示と本体を隠し、
251  // 同じシェル内の .hsform-done[hidden] を表示する(無ければ既定文言を生成)。
252  function showDone(form) {
253    var shell = form.closest(".hsform-shell");
254    var steps = shell ? qs(".hsform-steps", shell) : null;
255    if (steps) steps.style.display = "none";
256    form.style.display = "none";
257    var done = shell ? qs(".hsform-done", shell) : null;
258    if (!done) {
259      done = document.createElement("div");
260      done.className = "hsform-done";
261      done.innerHTML =
262        "<p class='hsform-done__t'>送信が完了しました</p>" +
263        "<p class='hsform-done__d'>お問い合わせありがとうございました。</p>";
264      (shell || form.parentElement).appendChild(done);
265    }
266    done.hidden = false;
267    (shell || done).scrollIntoView({ block: "start", behavior: "smooth" });
268  }
269
270  function redirectAfterSubmit(form, screenPromise) {
271    var thanks = form.getAttribute("data-thanks");
272    if (!thanks) {
273      showDone(form);
274      return;
275    }
276    if (!screenPromise) {
277      location.href = thanks;
278      return;
279    }
280    var alt = form.getAttribute("data-alt") || thanks;
281    var done = false;
282    var t = setTimeout(function () {
283      if (done) return;
284      done = true;
285      location.href = thanks;
286    }, 2500);
287    screenPromise
288      .then(function (isCompetitor) {
289        if (done) return;
290        done = true;
291        clearTimeout(t);
292        location.href = isCompetitor ? alt : thanks;
293      })
294      .catch(function () {
295        if (done) return;
296        done = true;
297        clearTimeout(t);
298        location.href = thanks;
299      });
300  }
301
302  // フォーム営業botが message 欄に数字のみを入れて送ってくるため、
303  // その場合はHubSpotに送らずサイレントに破棄する(botに弾かれたと学習させない)。
304  function isNumericOnlyMessage(form) {
305    var messageEl = qs('textarea[name="message"]', form);
306    if (!messageEl) return false;
307    var stripped = messageEl.value.replace(/[\s ]/g, "");
308    return stripped.length > 0 && /^[0-90-9]+$/.test(stripped);
309  }
310
311  function submitForm(form) {
312    var portal = form.getAttribute("data-portal");
313    var formId = form.getAttribute("data-form");
314    var submitBtn = qs('button[type="submit"]', form);
315    var submitLabel = submitBtn ? submitBtn.textContent : "";
316    if (submitBtn) {
317      submitBtn.disabled = true;
318      submitBtn.textContent = "送信中…";
319    }
320    setError(form, "");
321
322    if (isNumericOnlyMessage(form)) {
323      redirectAfterSubmit(form, null);
324      return;
325    }
326
327    var payload = {
328      fields: collectFields(form),
329      context: {
330        pageUri: location.href,
331        pageName: document.title,
332      },
333    };
334    var legal = consentOptions(form);
335    if (legal) payload.legalConsentOptions = legal;
336    var hutk = getCookie("hubspotutk");
337    if (hutk) payload.context.hutk = hutk;
338
339    // /screen判定はHubSpotへの送信POSTと同時に(このPOSTを投げる直前に)開始し、
340    // POST完了を待たず並列で走らせる。
341    var screenPromise = startScreenCheck(form);
342
343    fetch(
344      "https://api.hsforms.com/submissions/v3/integration/submit/" +
345        portal +
346        "/" +
347        formId,
348      {
349        method: "POST",
350        headers: { "Content-Type": "application/json" },
351        body: JSON.stringify(payload),
352      },
353    )
354      .then(function (r) {
355        if (!r.ok)
356          return r.json().then(function (j) {
357            throw j;
358          });
359        return r.json();
360      })
361      .then(function () {
362        trackLead(form);
363        tagClarity(form);
364        redirectAfterSubmit(form, screenPromise);
365      })
366      .catch(function (err) {
367        console.error("hsform-stepper submit failed", err);
368        setError(form, errorMessageFor(err));
369        if (submitBtn) {
370          submitBtn.disabled = false;
371          submitBtn.textContent = submitLabel || "送信する";
372        }
373      });
374  }
375
376  function init(form) {
377    var steps = qsa(".hsform-step", form).map(function (el) {
378      return Number(el.getAttribute("data-step"));
379    });
380    var max = Math.max.apply(null, steps);
381    showStep(form, 1);
382
383    form.addEventListener("click", function (e) {
384      var nextBtn = e.target.closest("[data-next]");
385      var backBtn = e.target.closest("[data-back]");
386      if (!nextBtn && !backBtn) return;
387      e.preventDefault();
388      var current = qs(".hsform-step.is-active", form);
389      var n = Number(current.getAttribute("data-step"));
390      if (nextBtn) {
391        if (!validateStep(current)) return;
392        showStep(form, Math.min(n + 1, max));
393      } else {
394        showStep(form, Math.max(n - 1, 1));
395      }
396      var shell = current.closest(".hsform-shell");
397      (shell || form).scrollIntoView({ block: "start", behavior: "smooth" });
398    });
399
400    form.addEventListener("submit", function (e) {
401      e.preventDefault();
402      var current = qs(".hsform-step.is-active", form);
403      if (!validateStep(current)) return;
404      submitForm(form);
405    });
406  }
407
408  function boot() {
409    qsa("form.hsform-stepper[data-form]").forEach(init);
410  }
411
412  if (document.readyState === "loading") {
413    document.addEventListener("DOMContentLoaded", boot);
414  } else {
415    boot();
416  }
417})();

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.