PageSourceSearch

https://frankburger.co.kr/js/octomo_inquiry.js?vs=1790410175

js frankburger.co.kr collected 2026-09-26 08:09:41 UTC 46,530 bytes, 1,081 lines download raw bytes

1(function () {
2  'use strict';
3
4  if (window.FrankOctomoInquiryLoaded) return;
5  window.FrankOctomoInquiryLoaded = true;
6
7  // 운영은 /api를 사용하고, 저장소 하위 경로로 실행하는 로컬 환경만 /public_html 앞 경로를 덧붙인다.
8  var localRootMatch = window.location.pathname.match(/^(.*\/public_html)(?:\/|$)/);
9  var ASSET_ROOT = localRootMatch ? localRootMatch[1] : '';
10  var ENDPOINT = ASSET_ROOT + '/api/octomo_auth.php';
11  var AREA_NAMES = ['서울', '부산', '대구', '인천', '광주', '대전', '울산', '세종', '경기', '강원', '충북', '충남', '전북', '전남', '경북', '경남', '제주'];
12
13  // 휴대폰 인증은 모바일 전용 사이트에서만 사용한다. PC는 기존 문의 접수 흐름을 유지한다.
14  var PHONE_VERIFICATION_ENABLED = /(^|\.)m\./i.test(window.location.hostname)
15    || /(^|\/)m(?:\/|$)/i.test(window.location.pathname);
16
17  // 로컬에서 접수하더라도 외부 상담 서버에는 등록된 운영 도메인을 전달한다.
18  function inquiryDataHost() {
19    var hostname = String(window.location.hostname || '').toLowerCase();
20    var isLocal = hostname === 'localhost'
21      || hostname === '127.0.0.1'
22      || hostname === '::1'
23      || /^10\./.test(hostname)
24      || /^192\.168\./.test(hostname)
25      || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
26      || /\.local$/.test(hostname);
27    return isLocal ? 'frankburger.co.kr' : hostname;
28  }
29
30  // 기존 페이지 코드가 이 함수를 호출하는 경우를 위해 전역 호환 함수를 유지한다.
31  window.FrankInquiryDataHost = inquiryDataHost;
32
33  // 메인·하단 문의 폼의 희망지역 선택항목을 동일한 17개 시·도로 구성한다.
34  function populateAreaSelect(select, placeholder) {
35    if (!select) return;
36    select.textContent = '';
37    var emptyOption = document.createElement('option');
38    emptyOption.value = '';
39    emptyOption.textContent = placeholder || '희망지역';
40    select.appendChild(emptyOption);
41    for (var i = 0; i < AREA_NAMES.length; i++) {
42      var option = document.createElement('option');
43      option.value = AREA_NAMES[i];
44      option.textContent = AREA_NAMES[i];
45      select.appendChild(option);
46    }
47  }
48
49  // 기존 하단 고정바 호출 규격을 유지하면서 새 문의 폼을 동적으로 그린다.
50  window.FrankLoadInquiryFooter = function (array, target, site) {
51    if (!PHONE_VERIFICATION_ENABLED) {
52      if (typeof window.raonSimpleLoad === 'function') {
53        window.raonSimpleLoad(array, target, site);
54        populateAreaSelect(byId('selarea_combined'), '희망지역');
55      }
56      return;
57    }
58
59    var targetElement = byId(target);
60    if (!targetElement || byId('footer_inqs_form')) return;
61    targetElement.innerHTML = '' +
62      '<div class="inq_responsive_container">' +
63        '<div class="inq_pc_header">빠른 창업문의 1544-7733</div>' +
64        '<form id="footer_inqs_form" class="inq_responsive_form" novalidate>' +
65          '<div class="inq_mobile_row">' +
66            '<div class="inq_field f_area"><label for="selarea_combined">희망지역</label><select name="area" id="selarea_combined"></select></div>' +
67            '<div class="inq_field f_name"><label for="footer_inquiry_name">이름</label><input id="footer_inquiry_name" type="text" name="name" maxlength="30" placeholder="이름" autocomplete="name"></div>' +
68            '<div class="inq_field f_hp"><label for="footer_inquiry_phone">연락처</label><input id="footer_inquiry_phone" type="tel" name="contact" maxlength="13" inputmode="tel" placeholder="연락처" autocomplete="tel"></div>' +
69          '</div>' +
70          '<button type="button" class="inq_submit_btn" id="footer_submit_combined"><span class="wave-text">' +
71            '<span class="wave-char">빠</span><span class="wave-char">른</span><span class="wave-char space"></span>' +
72            '<span class="wave-char">창</span><span class="wave-char">업</span><span class="wave-char space"></span>' +
73            '<span class="wave-char">문</span><span class="wave-char">의</span>' +
74          '</span></button>' +
75        '</form>' +
76      '</div>';
77    populateAreaSelect(byId('selarea_combined'), '희망지역');
78  };
79
80  // 현재 브라우저 탭에서 진행 중인 인증·접수 상태만 메모리에 보관한다.
81  var state = {
82    inquiry: null,
83    csrfToken: '',
84    consentVersion: '',
85    challengeId: '',
86    verificationToken: '',
87    message: '',
88    recipient: '',
89    expiresAt: 0,
90    countdownTimer: 0,
91    expired: false,
92    mobileSmsOpened: false,
93    busy: false,
94    submitted: false,
95  };
96
97  // 반복되는 DOM 조회를 짧게 표현하기 위한 내부 도우미다.
98  function byId(id) {
99    return document.getElementById(id);
100  }
101
102  // 구형 브라우저에서도 클릭된 요소의 상위 버튼을 찾을 수 있게 보완한다.
103  function closest(element, selector) {
104    if (!element) return null;
105    if (element.closest) return element.closest(selector);
106    while (element && element.nodeType === 1) {
107      if (element.matches && element.matches(selector)) return element;
108      element = element.parentElement;
109    }
110    return null;
111  }
112
113  var policyData = null;
114  var policyRequest = null;
115
116  // 정책 API가 돌려준 HTML 엔티티를 실제 표시 문자열로 복원한다.
117  function decodePolicyContent(value) {
118    var textarea = document.createElement('textarea');
119    textarea.innerHTML = String(value || '');
120    return textarea.value;
121  }
122
123  // 개인정보 처리방침 모달을 최초 한 번만 생성하고 닫기 동작을 연결한다.
124  function ensurePolicyModal() {
125    var modal = byId('octomo-policy-modal');
126    if (modal) return modal;
127
128    var wrapper = document.createElement('div');
129    wrapper.innerHTML = '' +
130      '<div id="octomo-policy-modal" class="octomo-policy" role="dialog" aria-modal="true" aria-labelledby="octomo-policy-title">' +
131        '<button type="button" class="octomo-policy__backdrop" aria-label="닫기" data-octomo-policy-close></button>' +
132        '<section class="octomo-policy__card">' +
133          '<header class="octomo-policy__header">' +
134            '<h2 id="octomo-policy-title">개인정보 처리방침</h2>' +
135            '<button type="button" class="octomo-policy__close" aria-label="닫기" data-octomo-policy-close>&times;</button>' +
136          '</header>' +
137          '<div class="octomo-policy__body">' +
138            '<p class="octomo-policy__loading">개인정보 처리방침을 불러오는 중입니다.</p>' +
139            '<div class="octomo-policy__viewer" hidden></div>' +
140          '</div>' +
141        '</section>' +
142      '</div>';
143    modal = wrapper.firstChild;
144    document.body.appendChild(modal);
145
146    var closers = modal.querySelectorAll('[data-octomo-policy-close]');
147    for (var i = 0; i < closers.length; i++) {
148      closers[i].addEventListener('click', closePolicyModal);
149    }
150    return modal;
151  }
152
153  // 정책 본문용 Toast UI 뷰어를 필요할 때 한 번만 불러온다.
154  function ensureToastViewer() {
155    if (window.toastui && window.toastui.Editor) return Promise.resolve(window.toastui.Editor);
156    if (window.FrankPolicyToastRequest) return window.FrankPolicyToastRequest;
157
158    if (!document.querySelector('link[data-octomo-toast-style]')) {
159      var stylesheet = document.createElement('link');
160      stylesheet.rel = 'stylesheet';
161      stylesheet.href = 'https://uicdn.toast.com/editor/latest/toastui-editor.min.css';
162      stylesheet.setAttribute('data-octomo-toast-style', '1');
163      document.head.appendChild(stylesheet);
164    }
165
166    window.FrankPolicyToastRequest = new Promise(function (resolve, reject) {
167      var script = document.createElement('script');
168      script.src = 'https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js';
169      script.async = true;
170      script.onload = function () {
171        if (window.toastui && window.toastui.Editor) resolve(window.toastui.Editor);
172        else reject(new Error('renderer'));
173      };
174      script.onerror = function () { reject(new Error('renderer')); };
175      document.head.appendChild(script);
176    });
177    return window.FrankPolicyToastRequest;
178  }
179
180  // 개인정보 처리방침 데이터를 한 번 요청한 뒤 같은 페이지에서는 재사용한다.
181  function loadPolicyData() {
182    if (policyData) return Promise.resolve(policyData);
183    if (policyRequest) return policyRequest;
184
185    policyRequest = fetch(ASSET_ROOT + '/html/policy.html?format=json', {
186      method: 'GET',
187      credentials: 'same-origin',
188      headers: { 'Accept': 'application/json' },
189    }).then(function (response) {
190      return response.json().catch(function () { return {}; }).then(function (data) {
191        if (!response.ok || !data.ok) throw new Error(data.message || '개인정보 처리방침을 불러오지 못했습니다.');
192        policyData = data;
193        return data;
194      });
195    }).catch(function (error) {
196      policyRequest = null;
197      throw error;
198    });
199    return policyRequest;
200  }
201
202  // 정책 데이터를 전용 뷰어로 표시하고, 로딩 실패 시 일반 텍스트로 대체한다.
203  function renderPolicy(modal, data) {
204    var loading = modal.querySelector('.octomo-policy__loading');
205    var viewer = modal.querySelector('.octomo-policy__viewer');
206    var content = decodePolicyContent(data.content);
207    loading.hidden = true;
208    viewer.hidden = false;
209    viewer.textContent = '';
210
211    ensureToastViewer().then(function (Editor) {
212      if (!modal.classList.contains('is-open')) return;
213      viewer.textContent = '';
214      new Editor.factory({
215        el: viewer,
216        viewer: true,
217        initialValue: content,
218      });
219    }).catch(function () {
220      viewer.classList.add('is-plain');
221      viewer.textContent = content.replace(/<br\s*\/?\s*>/gi, '\n');
222    });
223  }
224
225  // 인증 모달 위에 개인정보 처리방침을 열고 배경 스크롤을 잠근다.
226  function openPolicyModal() {
227    var modal = ensurePolicyModal();
228    var loading = modal.querySelector('.octomo-policy__loading');
229    var viewer = modal.querySelector('.octomo-policy__viewer');
230    loading.hidden = false;
231    loading.textContent = '개인정보 처리방침을 불러오는 중입니다.';
232    viewer.hidden = true;
233    viewer.textContent = '';
234    viewer.classList.remove('is-plain');
235    modal.classList.add('is-open');
236    document.documentElement.classList.add('octomo-lock');
237    document.body.classList.add('octomo-lock');
238
239    loadPolicyData().then(function (data) {
240      renderPolicy(modal, data);
241    }).catch(function (error) {
242      loading.hidden = false;
243      loading.textContent = error.message;
244    });
245  }
246
247  // 정책 모달만 닫되 뒤의 인증 모달이 열려 있으면 스크롤 잠금을 유지한다.
248  function closePolicyModal() {
249    var modal = byId('octomo-policy-modal');
250    if (modal) modal.classList.remove('is-open');
251    var inquiryModal = byId('octomo-inquiry-modal');
252    if ((!inquiryModal || !inquiryModal.classList.contains('is-open')) && !byId('octomo-completion')) {
253      document.documentElement.classList.remove('octomo-lock');
254      document.body.classList.remove('octomo-lock');
255    }
256  }
257
258  // 사용자 에이전트와 화면 폭을 함께 사용하여 모바일 안내 화면을 선택한다.
259  function isMobileDevice() {
260    return /Android|iPhone|iPad|iPod|IEMobile|Mobile/i.test(navigator.userAgent) || window.innerWidth < 768;
261  }
262
263  // 입력값에서 숫자만 추출하고 010 휴대폰 번호만 통과시킨다.
264  function normalizePhone(value) {
265    var phone = String(value || '').replace(/\D/g, '');
266    return /^010\d{8}$/.test(phone) ? phone : '';
267  }
268
269  // 사용자가 입력하는 동안 연락처를 010-0000-0000 형태로 표시한다.
270  function formatPhone(value) {
271    var digits = String(value || '').replace(/\D/g, '').slice(0, 11);
272    if (digits.length <= 3) return digits;
273    if (digits.length <= 7) return digits.slice(0, 3) + '-' + digits.slice(3);
274    return digits.slice(0, 3) + '-' + digits.slice(3, 7) + '-' + digits.slice(7);
275  }
276
277  // 메인 폼과 동적으로 생성된 하단 폼의 연락처 입력칸인지 확인한다.
278  function isInquiryPhoneInput(element) {
279    return element
280      && element.matches
281      && element.matches('input[name="contact"]');
282  }
283
284  // 연락처 입력칸에만 자동 하이픈 형식을 적용한다.
285  function formatInquiryPhoneInput(element) {
286    if (!isInquiryPhoneInput(element)) return;
287    var formatted = formatPhone(element.value);
288    if (element.value !== formatted) element.value = formatted;
289  }
290
291  // 유입경로에서 검색어 등 쿼리정보를 제외하고 주소 경로까지만 수집한다.
292  function minimizedReferrer() {
293    if (!document.referrer) return '';
294    try {
295      var url = new URL(document.referrer);
296      if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
297      return url.protocol + '//' + url.host + (url.pathname || '/');
298    } catch (error) {
299      return '';
300    }
301  }
302
303  // 폼 필드가 없더라도 오류 없이 빈 문자열을 반환한다.
304  function getField(form, name) {
305    var field = form ? form.querySelector('[name="' + name + '"]') : null;
306    return field ? String(field.value || '').trim() : '';
307  }
308
309  // 클릭한 버튼이 속한 메인·하단 폼에서 인증에 필요한 입력값을 모은다.
310  function collectInquiry(button) {
311    var isBodyForm = button.id === 'str_submit' || button.id === 'str2_submit';
312    var source = isBodyForm ? 'main' : 'footer';
313    var form = closest(button, 'form');
314    if (!form) {
315      form = source === 'main' ? byId('str_form') : byId('footer_inqs_form');
316    }
317    if (!form) return null;
318
319    var inquiry = {
320      source: source,
321      form: form,
322      area: getField(form, 'area'),
323      name: getField(form, 'name'),
324      contact: normalizePhone(getField(form, 'contact')),
325      referer: minimizedReferrer(),
326      agreement: true,
327      agreementConfirmed: false,
328    };
329
330    var agreement = form.querySelector('[name="agreement"]');
331    if (agreement) {
332      inquiry.agreement = agreement.checked === true;
333      inquiry.agreementConfirmed = agreement.checked === true;
334    }
335    return inquiry;
336  }
337
338  // 서버 호출 전에 필수값과 개인정보 동의 여부를 빠르게 확인한다.
339  function validateInquiry(inquiry) {
340    if (!inquiry) return '문의 입력정보를 확인할 수 없습니다.';
341    if (!inquiry.area) return '희망지역을 선택해 주세요.';
342    if (!inquiry.name) return '이름을 입력해 주세요.';
343    if (!inquiry.contact) return '010으로 시작하는 휴대폰 번호를 정확히 입력해 주세요.';
344    if (!inquiry.agreement) return '개인정보 처리방침에 동의해 주세요.';
345    return '';
346  }
347
348  // PC 본문 상·하단 폼은 외부 스크립트의 이벤트 바인딩 상태와 무관하게 직접 접수한다.
349  function submitPcBodyInquiry(inquiry) {
350    var form = inquiry && inquiry.form;
351    if (!form || state.busy) return;
352
353    var hostField = form.querySelector('[name="a_host"]');
354    var ipsField = form.querySelector('[name="ips"]');
355    if (hostField) hostField.value = inquiryDataHost();
356    if (ipsField) {
357      ipsField.value = typeof window.public_ips !== 'undefined'
358        ? window.public_ips
359        : (typeof window.myip !== 'undefined' ? window.myip : '');
360    }
361
362    var send = function () {
363      state.busy = true;
364      var callbackName = 'frankPcInquiryCallback_' + Date.now();
365      var script = document.createElement('script');
366      var query = [];
367      var fields = form.elements || [];
368
369      for (var i = 0; i < fields.length; i++) {
370        var field = fields[i];
371        if (!field.name || field.disabled) continue;
372        if ((field.type === 'checkbox' || field.type === 'radio') && !field.checked) continue;
373        query.push(encodeURIComponent(field.name) + '=' + encodeURIComponent(field.value || ''));
374      }
375      query.push('device=responsive');
376      query.push('rsrc=' + encodeURIComponent(inquiry.referer || ''));
377      query.push('callback=' + encodeURIComponent(callbackName));
378
379      var cleanup = function () {
380        state.busy = false;
381        if (script.parentNode) script.parentNode.removeChild(script);
382        try { delete window[callbackName]; } catch (error) { window[callbackName] = undefined; }
383      };
384
385      window[callbackName] = function (response) {
386        var data = response;
387        if (typeof data === 'string') {
388          try { data = JSON.parse(data); } catch (error) { data = {}; }
389        }
390        cleanup();
391        if (!data || !data.confirm) {
392          alert((data && data.msg) || '문의 접수 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
393          return;
394        }
395        showCompletion();
396        runConversionTracking();
397      };
398
399      script.onerror = function () {
400        cleanup();
401        alert('문의 접수 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
402      };
403      script.src = 'https://franknmts.com/api/input_datas_store.php?' + query.join('&');
404      document.head.appendChild(script);
405      window.setTimeout(function () {
406        if (window[callbackName]) script.onerror();
407      }, 15000);
408    };
409
410    if (typeof window.showInq2Modal === 'function') {
411      window.showInq2Modal(formatPhone(inquiry.contact), send);
412      return;
413    }
414    if (window.confirm('입력하신 연락처 ' + formatPhone(inquiry.contact) + '로 문의를 접수할까요?')) send();
415  }
416
417  // 인증 API를 동일 출처·JSON·세션 쿠키 방식으로 호출하고 오류 형식을 통일한다.
418  function requestApi(action, payload) {
419    payload = payload || {};
420    payload.action = action;
421    var headers = {
422      'Content-Type': 'application/json',
423      'X-Requested-With': 'XMLHttpRequest',
424    };
425    if (state.csrfToken) headers['X-CSRF-Token'] = state.csrfToken;
426    return fetch(ENDPOINT, {
427      method: 'POST',
428      credentials: 'same-origin',
429      cache: 'no-store',
430      referrerPolicy: 'same-origin',
431      headers: headers,
432      body: JSON.stringify(payload),
433    }).then(function (response) {
434      return response.json().catch(function () {
435        return { ok: false, message: '서버 응답을 확인할 수 없습니다.' };
436      }).then(function (data) {
437        if (!response.ok || data.ok === false) {
438          var error = new Error(data.message || '요청을 처리하지 못했습니다.');
439          error.status = response.status;
440          throw error;
441        }
442        return data;
443      });
444    });
445  }
446
447  // 인증 전용 세션의 CSRF 토큰과 현재 개인정보 동의 버전을 먼저 발급받는다.
448  function bootstrapSecurity() {
449    return requestApi('bootstrap', {}).then(function (data) {
450      state.csrfToken = data.csrfToken || '';
451      state.consentVersion = data.consentVersion || '';
452      if (!/^[a-f0-9]{64}$/.test(state.csrfToken) || !state.consentVersion) {
453        throw new Error('보안 인증 준비 정보를 확인할 수 없습니다.');
454      }
455    });
456  }
457
458  // bootstrap 이후 요청에 CSRF 토큰과 필요한 동의 버전을 자동으로 포함한다.
459  function api(action, payload) {
460    if (action === 'bootstrap') return bootstrapSecurity();
461    var ready = state.csrfToken && state.consentVersion ? Promise.resolve() : bootstrapSecurity();
462    return ready.then(function () {
463      payload = payload || {};
464      if (action === 'issue' || action === 'submit') payload.consentVersion = state.consentVersion;
465      return requestApi(action, payload);
466    });
467  }
468
469  // 동의·문자 발송·인증 확인 단계를 담는 인증 모달을 최초 한 번만 생성한다.
470  function ensureModal() {
471    if (byId('octomo-inquiry-modal')) return;
472
473    var wrapper = document.createElement('div');
474    wrapper.innerHTML = '' +
475      '<div id="octomo-inquiry-modal" class="octomo-modal" aria-hidden="true">' +
476        '<div class="octomo-modal__backdrop" data-octomo-close></div>' +
477        '<section class="octomo-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="octomo-modal-title">' +
478          '<div class="octomo-modal__header">' +
479            '<h2 id="octomo-modal-title">휴대폰 번호 인증</h2>' +
480            '<button type="button" class="octomo-modal__close" aria-label="닫기" data-octomo-close>&times;</button>' +
481          '</div>' +
482          '<div class="octomo-modal__body">' +
483            '<p class="octomo-modal__intro">번호 도용 방지를 위해 본인인증을 진행해 주세요.</p>' +
484            '<div id="octomo-step-consent" class="octomo-step">' +
485              '<label class="octomo-consent"><input type="checkbox" id="octomo-consent-check"><span>개인정보 수집·이용에 동의합니다. <button type="button" class="octomo-consent__detail octomo-policy-open">[자세히]</button></span></label>' +
486              '<button type="button" id="octomo-start" class="octomo-primary">문자 인증 시작</button>' +
487            '</div>' +
488            '<div id="octomo-step-send" class="octomo-step" hidden>' +
489              '<div id="octomo-pc-guide" class="octomo-pc-guide" hidden>' +
490                '<p class="octomo-guide-title">휴대폰으로 QR코드를 촬영하고 문자를 보내주세요.</p>' +
491                '<div class="octomo-pc-guide__visuals">' +
492                  '<div class="octomo-guide-card octomo-qr-card">' +
493                    '<p class="octomo-guide-card__heading"><span>1</span> QR코드 촬영</p>' +
494                    '<div class="octomo-qr-wrap"><img id="octomo-qr" alt="인증 문자 발송용 QR코드"></div>' +
495                    '<p class="octomo-guide-card__caption">휴대폰 카메라로 QR코드를 찍어주세요.</p>' +
496                  '</div>' +
497                  '<figure class="octomo-guide-card octomo-send-example">' +
498                    '<p class="octomo-guide-card__heading"><span>2</span> 문자 전송</p>' +
499                    '<img src="' + ASSET_ROOT + '/img/common/octomo_sms_send_guide.png" alt="받는 번호와 인증 문구가 입력된 문자 전송 화면 예시">' +
500                    '<figcaption>문자 발송 화면이 나오면 전송 버튼을 눌러주세요.</figcaption>' +
501                  '</figure>' +
502                '</div>' +
503              '</div>' +
504              '<div id="octomo-mobile-guide" class="octomo-mobile-guide" hidden>' +
505                '<p class="octomo-guide-title octomo-mobile-guide__title">인증 문자를 보내주세요.</p>' +
506                '<a id="octomo-sms-link" class="octomo-sms-button octomo-sms-button--fallback" href="#">문자 앱이 안 열리면 다시 열기</a>' +
507                '<figure class="octomo-guide-card octomo-send-example octomo-send-example--mobile">' +
508                  '<img src="' + ASSET_ROOT + '/img/common/octomo_sms_send_guide.png" alt="받는 번호와 인증 문구가 입력된 문자 전송 화면 예시">' +
509                  '<figcaption>내용을 수정하지 말고 그대로 보내주세요.</figcaption>' +
510                '</figure>' +
511              '</div>' +
512              '<div id="octomo-expired-notice" class="octomo-expired-notice" role="alert" hidden>' +
513                '<strong>인증 유효시간 만료</strong>' +
514                '<span>인증 시간이 만료되었습니다. 다시 요청해 주세요.</span>' +
515              '</div>' +
516              '<p id="octomo-countdown" class="octomo-countdown"></p>' +
517              '<button type="button" id="octomo-verify" class="octomo-primary">문자를 보냈어요 · 인증하기</button>' +
518            '</div>' +
519            '<div id="octomo-status" class="octomo-status" role="status" aria-live="polite"></div>' +
520            '<p id="octomo-help-call" class="octomo-help-call" hidden><a id="octomo-help-call-link"> 어려움이 있으신가요?<strong>1544-7733</strong>으로 문의해 주세요.</a></p>' +
521          '</div>' +
522        '</section>' +
523      '</div>';
524    document.body.appendChild(wrapper.firstChild);
525
526    byId('octomo-start').addEventListener('click', issueChallenge);
527    byId('octomo-verify').addEventListener('click', function () {
528      if (state.expired) {
529        issueChallenge();
530        return;
531      }
532      if (isMobileDevice() && !state.mobileSmsOpened) {
533        openMobileSmsApp();
534        return;
535      }
536      verifyChallenge();
537    });
538    byId('octomo-sms-link').addEventListener('click', function () {
539      markMobileSmsOpened();
540    });
541
542    var closers = document.querySelectorAll('#octomo-inquiry-modal [data-octomo-close]');
543    for (var i = 0; i < closers.length; i++) {
544      closers[i].addEventListener('click', closeModal);
545    }
546  }
547
548  // 입력값을 인증 상태에 연결하고 동의 여부에 맞는 첫 화면을 연다.
549  function openModal(inquiry) {
550    ensureModal();
551    // PC에서 불필요한 앱 선택창이 뜨지 않도록 모바일에서만 전화 연결 주소를 활성화한다.
552    var helpCallLink = byId('octomo-help-call-link');
553    if (isMobileDevice()) {
554      helpCallLink.href = 'tel:15447733';
555      helpCallLink.setAttribute('aria-label', '1544-7733으로 전화 문의');
556    } else {
557      helpCallLink.removeAttribute('href');
558      helpCallLink.removeAttribute('aria-label');
559    }
560    state.inquiry = inquiry;
561    state.challengeId = '';
562    state.verificationToken = '';
563    state.message = '';
564    state.recipient = '';
565    state.expiresAt = 0;
566    state.expired = false;
567    state.mobileSmsOpened = false;
568    state.busy = false;
569    state.submitted = false;
570    clearCountdownTimer();
571
572    var agreementAlreadyConfirmed = inquiry.agreementConfirmed === true;
573    byId('octomo-consent-check').checked = agreementAlreadyConfirmed;
574    byId('octomo-step-consent').hidden = agreementAlreadyConfirmed;
575    byId('octomo-step-send').hidden = true;
576    resetSendStep();
577    setStatus('', '');
578
579    var modal = byId('octomo-inquiry-modal');
580    modal.classList.remove('is-send-step');
581    modal.setAttribute('aria-hidden', 'false');
582    modal.classList.add('is-open');
583    document.documentElement.classList.add('octomo-lock');
584    document.body.classList.add('octomo-lock');
585
586    if (agreementAlreadyConfirmed) {
587      setStatus('문자 인증 안내를 준비하고 있습니다.', 'loading');
588      issueChallenge();
589    } else {
590      setTimeout(function () { byId('octomo-start').focus(); }, 0);
591    }
592  }
593
594  // 처리 중이 아닐 때만 모달을 닫고 화면에 남은 민감정보를 지운다.
595  function closeModal() {
596    if (state.busy) return;
597    var modal = byId('octomo-inquiry-modal');
598    if (!modal) return;
599    modal.setAttribute('aria-hidden', 'true');
600    modal.classList.remove('is-open');
601    clearCountdownTimer();
602    document.documentElement.classList.remove('octomo-lock');
603    document.body.classList.remove('octomo-lock');
604    clearSensitiveState();
605  }
606
607  // 중복 클릭을 막기 위해 API 처리 중에는 모달 내 버튼을 일괄 비활성화한다.
608  function setBusy(isBusy) {
609    state.busy = isBusy;
610    var buttons = document.querySelectorAll('#octomo-inquiry-modal button, #octomo-inquiry-modal .octomo-sms-button');
611    for (var i = 0; i < buttons.length; i++) {
612      if (buttons[i].tagName === 'BUTTON') buttons[i].disabled = isBusy;
613      buttons[i].classList.toggle('is-disabled', isBusy);
614    }
615  }
616
617  // 성공·오류·로딩 안내를 접근성 라이브 영역에 표시한다.
618  function setStatus(message, type) {
619    var status = byId('octomo-status');
620    if (!status) return;
621    status.className = 'octomo-status' + (type ? ' is-' + type : '');
622    status.textContent = message || '';
623  }
624
625  // iOS와 Android의 문자 앱 링크 형식 차이를 반영해 수신번호와 본문을 만든다.
626  function smsHref(recipient, message) {
627    var number = String(recipient || '').replace(/\D/g, '');
628    var separator = /iPhone|iPad|iPod/i.test(navigator.userAgent) ? '&' : '?';
629    return 'sms:' + number + separator + 'body=' + encodeURIComponent(message);
630  }
631
632  // 문자 앱을 연 뒤 버튼을 인증 확인 동작으로 전환하고 재열기 링크를 표시한다.
633  function markMobileSmsOpened() {
634    state.mobileSmsOpened = true;
635    var verifyButton = byId('octomo-verify');
636    var smsLink = byId('octomo-sms-link');
637    if (verifyButton) verifyButton.textContent = '문자를 보냈어요 · 인증하기';
638    if (smsLink) smsLink.hidden = false;
639  }
640
641  // 모바일에서 사용자가 수정할 필요 없는 수신번호·문구가 채워진 문자 앱을 연다.
642  function openMobileSmsApp() {
643    if (state.busy || state.expired || !state.recipient || !state.message) return;
644    var smsUrl = smsHref(state.recipient, state.message);
645    var smsLink = byId('octomo-sms-link');
646    if (smsLink) smsLink.href = smsUrl;
647    markMobileSmsOpened();
648    window.location.href = smsUrl;
649  }
650
651  // 모달 종료·재발급 시 이전 만료 타이머가 중복 실행되지 않게 정리한다.
652  function clearCountdownTimer() {
653    if (!state.countdownTimer) return;
654    window.clearTimeout(state.countdownTimer);
655    state.countdownTimer = 0;
656  }
657
658  // 새 인증을 시작할 때 PC·모바일 발송 화면과 버튼 상태를 초기화한다.
659  function resetSendStep() {
660    var modal = byId('octomo-inquiry-modal');
661    var smsLink = byId('octomo-sms-link');
662    var pcGuide = byId('octomo-pc-guide');
663    var mobileGuide = byId('octomo-mobile-guide');
664    var notice = byId('octomo-expired-notice');
665    var countdown = byId('octomo-countdown');
666    var verifyButton = byId('octomo-verify');
667    var helpCall = byId('octomo-help-call');
668    if (modal) modal.classList.remove('is-expired');
669    if (helpCall) helpCall.hidden = true;
670    if (notice) notice.hidden = true;
671    if (countdown) countdown.hidden = false;
672    if (verifyButton) {
673      verifyButton.textContent = isMobileDevice() && !state.mobileSmsOpened
674        ? '인증하기'
675        : '문자를 보냈어요 · 인증하기';
676    }
677    if (pcGuide) pcGuide.removeAttribute('aria-disabled');
678    if (mobileGuide) mobileGuide.removeAttribute('aria-disabled');
679    if (smsLink) {
680      smsLink.hidden = isMobileDevice() && !state.mobileSmsOpened;
681      smsLink.classList.remove('is-expired');
682      smsLink.removeAttribute('aria-disabled');
683      smsLink.removeAttribute('tabindex');
684    }
685  }
686
687  // 5분 유효시간이 지나면 발송 안내를 숨기고 같은 버튼을 재발급 동작으로 전환한다.
688  function showExpiredState(message) {
689    clearCountdownTimer();
690    state.expired = true;
691    state.challengeId = '';
692    state.verificationToken = '';
693    state.message = '';
694    state.recipient = '';
695    state.expiresAt = 0;
696    state.mobileSmsOpened = false;
697
698    var modal = byId('octomo-inquiry-modal');
699    var smsLink = byId('octomo-sms-link');
700    var pcGuide = byId('octomo-pc-guide');
701    var mobileGuide = byId('octomo-mobile-guide');
702    var notice = byId('octomo-expired-notice');
703    var countdown = byId('octomo-countdown');
704    var verifyButton = byId('octomo-verify');
705    if (modal) modal.classList.add('is-expired');
706    if (notice) notice.hidden = false;
707    if (pcGuide) pcGuide.setAttribute('aria-disabled', 'true');
708    if (mobileGuide) mobileGuide.setAttribute('aria-disabled', 'true');
709    if (countdown) countdown.hidden = true;
710    if (verifyButton) {
711      verifyButton.textContent = isMobileDevice() ? '인증 문자 재발송' : '인증 QR코드 재발급';
712    }
713    if (smsLink) {
714      smsLink.removeAttribute('href');
715      smsLink.classList.add('is-expired');
716      smsLink.setAttribute('aria-disabled', 'true');
717      smsLink.setAttribute('tabindex', '-1');
718    }
719    setStatus('', '');
720    setBusy(false);
721    if (verifyButton) verifyButton.focus();
722  }
723
724  // 서버에서 일회성 인증문구를 발급받아 PC에는 QR, 모바일에는 문자 앱을 표시한다.
725  function issueChallenge() {
726    if (state.busy || !state.inquiry) return;
727    if (!byId('octomo-consent-check').checked) {
728      setStatus('개인정보 수집·이용 동의가 필요합니다.', 'error');
729      return;
730    }
731
732    var wasExpired = state.expired;
733    setBusy(true);
734    if (wasExpired) {
735      byId('octomo-verify').textContent = isMobileDevice() ? '인증 문자 재발송 중...' : '인증 QR코드 재발급 중...';
736    }
737    var mobile = isMobileDevice();
738    api('issue', {
739      phone: state.inquiry.contact,
740      needQr: !mobile,
741      privacyConsent: true,
742      consentVersion: state.consentVersion,
743    })
744      .then(function (data) {
745        clearCountdownTimer();
746        state.expired = false;
747        state.challengeId = data.challengeId;
748        state.message = data.message;
749        state.recipient = data.recipient;
750        state.expiresAt = Date.now() + (data.expiresIn * 1000);
751        state.mobileSmsOpened = false;
752
753        byId('octomo-step-consent').hidden = true;
754        byId('octomo-step-send').hidden = false;
755        byId('octomo-inquiry-modal').classList.add('is-send-step');
756        resetSendStep();
757        byId('octomo-pc-guide').hidden = mobile;
758        byId('octomo-mobile-guide').hidden = !mobile;
759        byId('octomo-help-call').hidden = false;
760        var mobileSmsUrl = smsHref(data.recipient, data.message);
761        byId('octomo-sms-link').href = mobileSmsUrl;
762
763        var qr = byId('octomo-qr');
764        if (!mobile && data.qrCode) {
765          qr.src = data.qrCode;
766          qr.parentElement.hidden = false;
767          setStatus('', 'info');
768        } else if (!mobile) {
769          qr.removeAttribute('src');
770          qr.parentElement.hidden = true;
771          setStatus('QR코드를 불러오지 못했습니다. 잠시 후 인증을 다시 시작해 주세요.', 'warning');
772        } else {
773          setStatus('', 'info');
774        }
775
776        updateCountdown();
777        setBusy(false);
778        byId('octomo-verify').focus();
779      })
780      .catch(function (error) {
781        if (wasExpired) {
782          showExpiredState(error.message);
783        } else {
784          setBusy(false);
785          setStatus(error.message, 'error');
786          if (byId('octomo-step-consent').hidden && byId('octomo-step-send').hidden) {
787            byId('octomo-step-consent').hidden = false;
788            byId('octomo-start').focus();
789          }
790        }
791      });
792  }
793
794  // 서버 만료시각을 기준으로 남은 시간을 1초마다 갱신한다.
795  function updateCountdown() {
796    if (!state.expiresAt || !byId('octomo-countdown')) return;
797    var seconds = Math.max(0, Math.ceil((state.expiresAt - Date.now()) / 1000));
798    var minutes = Math.floor(seconds / 60);
799    var remain = String(seconds % 60);
800    if (remain.length < 2) remain = '0' + remain;
801    byId('octomo-countdown').textContent = '인증 유효시간 ' + minutes + ':' + remain;
802    if (seconds === 0) {
803      showExpiredState();
804      return;
805    }
806    if (seconds > 0 && byId('octomo-inquiry-modal').classList.contains('is-open')) {
807      state.countdownTimer = window.setTimeout(updateCountdown, 1000);
808    }
809  }
810
811  // 옥토모 수신내역을 확인하고 성공하면 즉시 최종 상담 접수로 이어간다.
812  function verifyChallenge() {
813    if (state.busy || !state.challengeId) return;
814    if (state.verificationToken) {
815      submitInquiry();
816      return;
817    }
818
819    var verifyingChallengeId = state.challengeId;
820    setBusy(true);
821    setStatus('문자 수신 여부를 확인하고 있습니다.', 'loading');
822    api('verify', { challengeId: verifyingChallengeId })
823      .then(function (data) {
824        if (state.challengeId !== verifyingChallengeId || state.expired) return;
825        if (!data.verified) {
826          setBusy(false);
827          setStatus(data.message || '아직 인증 문자가 확인되지 않았습니다.', 'warning');
828          return;
829        }
830        state.verificationToken = data.verificationToken;
831        setStatus('휴대폰 인증이 완료되었습니다. 문의를 접수하고 있습니다.', 'success');
832        submitInquiry();
833      })
834      .catch(function (error) {
835        if (state.challengeId !== verifyingChallengeId && !state.expired) return;
836        if (error.status === 410 || error.status === 404 || (error.status === 429 && /횟수/.test(error.message))) {
837          showExpiredState(error.message);
838        } else {
839          setBusy(false);
840          setStatus(error.message, 'error');
841        }
842      });
843  }
844
845  // 인증 토큰과 최초 입력값을 함께 보내 상담 DB에 한 번만 접수한다.
846  function submitInquiry() {
847    if (!state.inquiry || !state.verificationToken || state.submitted) {
848      setBusy(false);
849      return;
850    }
851
852    setBusy(true);
853    byId('octomo-verify').textContent = '접수 중입니다...';
854    api('submit', {
855      challengeId: state.challengeId,
856      verificationToken: state.verificationToken,
857      source: state.inquiry.source,
858      area: state.inquiry.area,
859      name: state.inquiry.name,
860      contact: state.inquiry.contact,
861      referer: state.inquiry.referer,
862      privacyConsent: state.inquiry.agreement === true && byId('octomo-consent-check').checked === true,
863      consentVersion: state.consentVersion,
864    })
865      .then(function () {
866        state.submitted = true;
867        setBusy(false);
868        setStatus('창업문의가 정상적으로 접수되었습니다.', 'success');
869        window.setTimeout(function () {
870          closeModal();
871          showCompletion();
872          runConversionTracking();
873        }, 450);
874      })
875      .catch(function (error) {
876        setBusy(false);
877        byId('octomo-verify').textContent = '접수 다시 시도';
878        setStatus(error.message, 'error');
879      });
880  }
881
882  // 정상 접수 후 인증 모달을 닫고 별도의 완료 바텀시트를 표시한다.
883  function showCompletion() {
884    closeCompletion();
885
886    var wrapper = document.createElement('div');
887    wrapper.innerHTML = '' +
888      '<div id="octomo-completion" class="octomo-complete" role="dialog" aria-modal="true" aria-labelledby="octomo-complete-title">' +
889        '<button type="button" class="octomo-complete__backdrop" aria-label="완료창 닫기" data-octomo-complete-close></button>' +
890        '<section class="octomo-complete__card">' +
891          '<header class="octomo-complete__header">' +
892            '<h2 id="octomo-complete-title">상담 신청 완료</h2>' +
893            '<button type="button" class="octomo-complete__close" aria-label="닫기" data-octomo-complete-close>&times;</button>' +
894          '</header>' +
895          '<div class="octomo-complete__body">' +
896            '<span class="octomo-complete__check" aria-hidden="true"></span>' +
897            '<p class="octomo-complete__title"><strong>상담 신청</strong>이 완료되었습니다</p>' +
898            '<p class="octomo-complete__message">영업일 기준 <strong>1~2일</strong> 이내에<br>빠르게 연락드리겠습니다.<br>감사합니다.</p>' +
899          '</div>' +
900          '<button type="button" class="octomo-complete__confirm" data-octomo-complete-close>확인</button>' +
901        '</section>' +
902      '</div>';
903
904    document.body.appendChild(wrapper.firstChild);
905    document.documentElement.classList.add('octomo-lock');
906    document.body.classList.add('octomo-lock');
907
908    var closers = document.querySelectorAll('#octomo-completion [data-octomo-complete-close]');
909    for (var i = 0; i < closers.length; i++) {
910      closers[i].addEventListener('click', closeCompletion);
911    }
912    window.setTimeout(function () {
913      var confirm = document.querySelector('#octomo-completion .octomo-complete__confirm');
914      if (confirm) confirm.focus();
915    }, 0);
916  }
917
918  // 완료창을 닫을 때 메인·하단 문의 폼을 모두 초기 상태로 되돌린다.
919  function resetInquiryForms() {
920    var forms = document.querySelectorAll(
921      'form.str_form, form#str_form, form#str2_form, form#footer_inqs_form, form.inq_responsive_form'
922    );
923    for (var i = 0; i < forms.length; i++) {
924      forms[i].reset();
925    }
926  }
927
928  // 완료창을 제거하고 접수가 끝난 경우에만 입력 폼을 비운다.
929  function closeCompletion() {
930    var completion = byId('octomo-completion');
931    var completedSubmissionClosed = !!completion && state.submitted;
932    if (completion && completion.parentNode) completion.parentNode.removeChild(completion);
933
934    if (completedSubmissionClosed) {
935      resetInquiryForms();
936    }
937
938    var modal = byId('octomo-inquiry-modal');
939    if (!modal || !modal.classList.contains('is-open')) {
940      document.documentElement.classList.remove('octomo-lock');
941      document.body.classList.remove('octomo-lock');
942    }
943  }
944
945  // 전화번호와 연결된 인증 식별값·문구·토큰을 브라우저 메모리에서 제거한다.
946  function clearSensitiveState() {
947    state.inquiry = null;
948    state.challengeId = '';
949    state.verificationToken = '';
950    state.message = '';
951    state.recipient = '';
952    state.expiresAt = 0;
953    state.mobileSmsOpened = false;
954  }
955
956  // 상담 접수가 실제 완료된 경우에만 광고 매체별 전환 이벤트를 전송한다.
957  function runConversionTracking() {
958    try {
959      if (window.wcs) {
960        if (!window.wcs_add) window.wcs_add = {};
961        window.wcs_add.wa = 's_b1023afdb07';
962        window.wcs.trans({ type: 'lead' });
963      }
964      if (typeof window.kakaoPixel === 'function') {
965        window.kakaoPixel('4132825108520547695').participation('Consulting');
966        window.kakaoPixel('1191030118083245209').completeRegistration('전환완료');
967      }
968      if (typeof window.gtag === 'function') {
969        window.gtag('event', 'conversion', { send_to: 'AW-10830457268/VqBoCOLkudgDELTTrqwo' });
970      }
971      if (typeof window.fbq === 'function') {
972        window.fbq('trackSingle', '306586952158382', 'CompleteRegistration', { status: true }, {});
973      }
974      window.adn_param = window.adn_param || [];
975      window.adn_param.push([{ ui: '106130', ut: 'Complete', uo: 'types2' }]);
976    } catch (error) {
977      if (window.console && console.error) console.error('Conversion tracking error:', error);
978    }
979  }
980
981  // 동적으로 생성되는 하단 폼까지 처리하기 위해 문서 수준에서 클릭을 위임한다.
982  document.addEventListener('click', function (event) {
983    var policyButton = closest(event.target, '.octomo-policy-open');
984    if (policyButton) {
985      event.preventDefault();
986      event.stopPropagation();
987      openPolicyModal();
988      return;
989    }
990
991    var button = closest(event.target, '#footer_submit_combined, #str_submit, #str2_submit');
992    if (!button) return;
993
994    // PC 고정바는 기존 흐름을 유지하고, 본문 상·하단 폼은 로컬 코드가 직접 접수한다.
995    if (!PHONE_VERIFICATION_ENABLED) {
996      if (document.readyState !== 'loading') window.isPageLoaded = true;
997      if (button.id === 'footer_submit_combined') return;
998
999      event.preventDefault();
1000      event.stopPropagation();
1001      if (event.stopImmediatePropagation) event.stopImmediatePropagation();
1002
1003      var pcInquiry = collectInquiry(button);
1004      var pcError = validateInquiry(pcInquiry);
1005      if (pcError) {
1006        alert(pcError);
1007        return;
1008      }
1009      submitPcBodyInquiry(pcInquiry);
1010      return;
1011    }
1012
1013    event.preventDefault();
1014    event.stopPropagation();
1015    if (event.stopImmediatePropagation) event.stopImmediatePropagation();
1016
1017    if (state.busy) return;
1018    var inquiry = collectInquiry(button);
1019    var error = validateInquiry(inquiry);
1020    if (error) {
1021      alert(error);
1022      return;
1023    }
1024    openModal(inquiry);
1025  }, true);
1026
1027  // 동적으로 생성되는 하단 고정 폼을 포함해 모든 문의 연락처에 같은 형식을 적용한다.
1028  document.addEventListener('input', function (event) {
1029    formatInquiryPhoneInput(event.target);
1030  });
1031
1032  document.addEventListener('change', function (event) {
1033    formatInquiryPhoneInput(event.target);
1034  });
1035
1036  document.addEventListener('keydown', function (event) {
1037    if (event.key === 'Escape' && byId('octomo-policy-modal') && byId('octomo-policy-modal').classList.contains('is-open')) {
1038      closePolicyModal();
1039      return;
1040    }
1041    if (event.key === 'Escape' && byId('octomo-completion')) {
1042      closeCompletion();
1043      return;
1044    }
1045    if (event.key === 'Escape' && byId('octomo-inquiry-modal') && byId('octomo-inquiry-modal').classList.contains('is-open')) {
1046      closeModal();
1047    }
1048  });
1049
1050  // 외부 지역 API 상태와 관계없이 본문 상단·하단 폼의 희망지역 목록을 구성한다.
1051  function populateLocalInquiryAreas() {
1052    var mainArea = byId('str_area');
1053    if (mainArea) populateAreaSelect(mainArea, '희망지역을 선택하세요');
1054
1055    var secondaryArea = byId('str2_area');
1056    if (secondaryArea) populateAreaSelect(secondaryArea, '희망지역을 선택하세요');
1057
1058    var phoneInputs = document.querySelectorAll('input[name="contact"]');
1059    for (var i = 0; i < phoneInputs.length; i++) {
1060      phoneInputs[i].setAttribute('maxlength', '13');
1061      phoneInputs[i].setAttribute('inputmode', 'tel');
1062      formatInquiryPhoneInput(phoneInputs[i]);
1063    }
1064  }
1065
1066  if (document.readyState === 'loading') {
1067    document.addEventListener('DOMContentLoaded', populateLocalInquiryAreas);
1068  } else {
1069    populateLocalInquiryAreas();
1070  }
1071
1072  function markInquiryPageLoaded() {
1073    window.isPageLoaded = true;
1074  }
1075
1076  if (document.readyState === 'complete') {
1077    markInquiryPageLoaded();
1078  } else {
1079    window.addEventListener('load', markInquiryPageLoaded);
1080  }
1081})();

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.