PageSourceSearch

https://neona.ai/contact.html?source=index

html neona.ai collected 2026-10-05 14:10:51 UTC 15,297 bytes, 267 lines download raw bytes

1<!DOCTYPE html>
2<html lang="ko">
3<head>
4<meta charset="utf-8" />
5<meta name="viewport" content="width=device-width, initial-scale=1" />
6<title>Neona · 도입 문의</title>
7<meta name="robots" content="noindex" />
8<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
9<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
10<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/[email protected]/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
11<style>
12  * { margin: 0; padding: 0; box-sizing: border-box; }
13  body { font-family: "Pretendard Variable", Pretendard, -apple-system, sans-serif; background: #fafafb; color: #0b0b0c; }
14  .topbar { background: #050506; padding: 14px 24px; }
15  .topbar a { color: #b7b7bd; text-decoration: none; font-size: 15px; font-weight: 600; }
16  .wrap { max-width: 560px; margin: 48px auto 80px; padding: 0 20px; }
17  h1 { font-size: 28px; letter-spacing: -0.02em; margin-bottom: 10px; }
18  .desc { color: #6f6f78; font-size: 15px; line-height: 1.6; margin-bottom: 32px; }
19  .card { background: #fff; border: 1px solid #eeeef1; border-radius: 16px; padding: 28px; }
20  label { display: block; font-size: 13.5px; font-weight: 600; margin: 18px 0 6px; }
21  label:first-child { margin-top: 0; }
22  label .req { color: #16a34a; }
23  input, textarea, select { width: 100%; font: inherit; font-size: 15px; padding: 11px 14px; border: 1px solid #d9d9df; border-radius: 10px; background: #fff; color: inherit; }
24  input:focus, textarea:focus, select:focus { outline: none; border-color: #16a34a; }
25  textarea { min-height: 120px; resize: vertical; }
26  .privacy { display: flex; align-items: flex-start; gap: 8px; margin: 20px 0 0; font-size: 13.5px; color: #4a4a52; }
27  .privacy input { width: auto; margin-top: 2px; }
28  .privacy a { color: #16a34a; }
29  button { width: 100%; margin-top: 24px; font: inherit; font-size: 15.5px; font-weight: 700; color: #fff; background: #16a34a; border: 0; border-radius: 999px; padding: 13px; cursor: pointer; }
30  button:disabled { opacity: 0.55; cursor: default; }
31  .msg { margin-top: 16px; font-size: 14px; display: none; }
32  .msg.error { color: #dc2626; display: block; }
33  .success { text-align: center; padding: 40px 12px; display: none; }
34  .success h2 { font-size: 22px; margin-bottom: 10px; }
35  .success p { color: #6f6f78; font-size: 15px; line-height: 1.6; }
36</style>
37</head>
38<body>
39<div class="topbar"><a href="/">← Neona 홈으로</a></div>
40<div class="wrap">
41  <h1>도입 문의</h1>
42  <p class="desc">
42네오나 에이전트 도입·데모 요청을 남겨주세요. 영업일 기준 1~2일 내에 회신드립니다.</p>
43  <!-- 데모 요청(intent=demo_request)으로 들어온 경우 — 누른 카드가 자동 표시된다 (CTA-LINKS §4.5) -->
44  <div id="demoBadge" style="display:none; margin:-16px 0 24px; padding:12px 16px; background:#f4fbf7; border:1px solid #d3ecd9; border-radius:12px; font-size:14px; color:#0f5132;">
45    요청 데모: <strong data-demo-title></strong>
46  </div>
47  <div class="card">
48    <form id="inquiryForm" novalidate>
49      <label for="f-name">이름 <span class="req">*</span></label>
50      <input id="f-name" name="name" required maxlength="150" autocomplete="name" />
51      <label for="f-email">회사 이메일 <span class="req">*</span></label>
52      <input id="f-email" name="email" type="email" required maxlength="254" autocomplete="email" />
53      <label for="f-company">회사명 <span class="req">*</span></label>
54      <input id="f-company" name="company" required maxlength="200" autocomplete="organization" />
55      <label for="f-phone">연락처 <span class="req">*</span></label>
56      <input id="f-phone" name="phone" required maxlength="13" inputmode="numeric" autocomplete="tel" placeholder="010-0000-0000" />
57      <!-- salesmap 리드의 `문의 유형(네오나에이전트)`. 옵션 텍스트가 그쪽 선택지와
58           문자 그대로 같아야 값이 실린다 — src/lib/api/inquiries.ts 의 INQUIRY_TYPES 와 동일. -->
59      <label for="f-inquiry-type">어떤 것이 필요하신가요?</label>
60      <select id="f-inquiry-type" name="inquiry_type">
61        <option value="">선택해 주세요 (선택)</option>
62        <option>고객 상담·문의 응대</option>
63        <option>아웃바운드 영업 콜</option>
64        <option>AI 튜터·학습 대화</option>
65        <option>음성 캐릭터·IP 탑재</option>
66        <option>기타 / 아직 미정</option>
67      </select>
68      <label for="f-message">문의 내용</label>
69      <textarea id="f-message" name="message" maxlength="2000" placeholder="도입하려는 시나리오, 예상 규모, 궁금한 점을 알려주세요."></textarea>
70      <label class="privacy"><input id="f-privacy" type="checkbox" required />
71        <span><a href="/privacy" target="_blank" rel="noopener">개인정보 처리방침</a>에 따라 문의 응대 목적으로 개인정보를 수집·이용하는 데 동의합니다. <span class="req">*</span></span>
72      </label>
73      <button id="submitBtn" type="submit">문의 보내기</button>
74      <p class="msg" id="formMsg"></p>
75    </form>
76    <div class="success" id="successPanel">
77      <h2>문의가 접수되었습니다</h2>
78      <p>남겨주신 이메일로 곧 연락드리겠습니다.<br /><a href="/" style="color:#16a34a;">홈으로 돌아가기</a></p>
79    </div>
80  </div>
81</div>
82<script src="/env-config.js"></script>
vendor: 1 bytes, line 82
82
83<script src="/assets/attribution.js"></script>
vendor: 1 bytes, line 83
83
84<script defer src="/assets/analytics.js"></script>
vendor: 1 bytes, line 84
84
85<script>
86(function () {
87  "use strict";
88  var API_ROOT = ((window.ENV && window.ENV.VITE_API_URL) || "http://localhost:8000/api").replace(/\/api\/?$/, "");
89  var form = document.getElementById("inquiryForm");
90
91  /* ── CTA 파라미터 수신 (CTA-LINKS.md §4.2) ─────────────────────────────
92   * 랜딩의 cta.js 가 어느 버튼에서 왔는지를 쿼리로 넘긴다. 새로고침하면 쿼리가
93   * 사라지므로 첫 진입에 sessionStorage 로 한 번 보관한다. intent 가 리드 성격
94   * (contact | self_serve | demo_request)을 가른다 — 세일즈맵까지 그대로 간다. */
95  /* 키: 백엔드 스키마의 max_length. 넘겨 보내면 422 로 폼 전체가 죽으므로 여기서 맞춰 자른다
96   * (정상 흐름에선 안 넘지만, 변조·이상 URL 이 제출 실패로 이어지면 안 된다) */
97  var CTA_KEYS = { intent: 20, cta_id: 50, cta_position: 30, cta_text: 100, demo_id: 100, demo_name: 100, page_path: 100 };
98  // 이전 빌드의 persistent PII handoff는 더 이상 사용하지 않고 어떤 페이지에서든 발견 즉시 폐기한다.
99  try {
100    Object.keys(localStorage).forEach(function (key) {
101      if (key.indexOf("neona_demo_form:") === 0 || key === "neona_demo_form") localStorage.removeItem(key);
102    });
103  } catch (e) {}
104  var demoToken = "";
105  var cta = (function () {
106    var saved = null;
107    try { saved = JSON.parse(sessionStorage.getItem("neona_cta") || "null"); } catch (e) {}
108    var q = new URLSearchParams(location.search), out = {}, has = false;
109    // URL에는 허용 키 cta_id의 비식별 token만 둔다. PII는 source tab 메모리에서
110    // BroadcastChannel 응답으로 한 번만 전달되며 persistent storage에 기록하지 않는다.
111    var tokenMatch = (q.get("cta_id") || "").match(/^demo_request\.([a-z0-9]{8,32})$/);
112    if (tokenMatch) demoToken = tokenMatch[1];
113    Object.keys(CTA_KEYS).forEach(function (k) {
114      var v = q.get(k);
115      if (!v) return;
116      if (k === "cta_id" && tokenMatch) v = "demo_request";
117      out[k] = v.slice(0, CTA_KEYS[k]); has = true;
118    });
119    if (!has) return saved || {};
120    /* page_path 는 슬러그로 정규화한다 (§4.3) — cta.js 는 파일 경로를 보낸다 */
121    if (out.page_path) {
122      out.page_path = out.page_path
123        .replace(/^\/(index\.html)?$/, "/")
124        .replace(/^\/usecase-([a-z-]+)\.html$/, "/usecases/$1")
125        .replace(/\.html$/, "");
126    }
127    // storage가 막혀도 허용 URL 키 cta_text에 담긴 선택 데모명으로 식별자를 복원한다.
128    if (out.intent === "demo_request" && !out.demo_name && out.cta_text) {
129      out.demo_name = out.cta_text.slice(0, CTA_KEYS.demo_name);
130      out.demo_id = out.cta_text.replace(/^AI\s*/, "").trim().replace(/[·\s]+/g, "-")
131        .replace(/[^0-9a-zA-Z가-힣-]/g, "").toLowerCase().slice(0, CTA_KEYS.demo_id);
132    }
133    try { sessionStorage.setItem("neona_cta", JSON.stringify(out)); } catch (e) {}
134    return out;
135  })();
136
137  var q0 = new URLSearchParams(location.search);
138  // 데모 요청(모달·무음 카드) — 어떤 데모인지 보여주고 문의 내용에도 남긴다
139  // demo 는 CTA_KEYS 를 안 거치므로 여기서 자른다 — 안 자르면 message 프리필이 백엔드 2000자 제한을 넘겨 422 가 난다
140  var demoName = cta.demo_name || (q0.get("demo") || "").slice(0, 100) || (cta.intent === "demo_request" ? cta.demo_id : "");
141  if (demoName) {
142    var badge = document.getElementById("demoBadge");
143    if (badge) {
144      badge.style.display = "block";
145      badge.querySelector("[data-demo-title]").textContent = demoName;
146    }
147    document.getElementById("f-message").value = "요청 데모: " + demoName + "\n";
148  }
149  /* ── 데모 요청 모달에서 넘어온 입력값 프리필 (usecases.js 가 sessionStorage 로 이월) ──
150   * 개인정보는 쿼리스트링에 싣지 않는다 — 같은 ì˜
150¤ë¦¬ì§„이라 sessionStorage 로 전달된다.
151   * 동의(privacy)는 이월하지 않는다: 이 페이지에서 다시 받아야 한다. */
152  function applyDemoHandoff(pre) {
153    if (!pre) return;
154    [["name","f-name"],["company","f-company"],["email","f-email"],["phone","f-phone"]].forEach(function (m) {
155      var el = document.getElementById(m[1]);
156      if (pre[m[0]] && el && !el.value) el.value = String(pre[m[0]]).slice(0, 200);
157    });
158    if (pre.message) {
159      var msgEl = document.getElementById("f-message");
160      msgEl.value = (msgEl.value ? msgEl.value : "") + String(pre.message).slice(0, 1500);
161    }
162  }
163  if (demoToken && window.BroadcastChannel) {
164    try {
165      var handoffChannel = new BroadcastChannel("neona_demo_handoff:" + demoToken);
166      handoffChannel.addEventListener("message", function (event) {
167        var message = event && event.data;
168        if (!message || message.type !== "response") return;
169        applyDemoHandoff(message.data);
170        handoffChannel.close();
171      });
172      handoffChannel.postMessage({ type: "request" });
173      setTimeout(function () { try { handoffChannel.close(); } catch (e) {} }, 5000);
174    } catch (e) { /* channel unavailable — CTA demo context remains, only PII prefill is skipped */ }
175  }
176
177  var btn = document.getElementById("submitBtn");
178  var msg = document.getElementById("formMsg");
179
180  /* ── 연락처: 숫자만 받고 하이픈은 자동으로 ───────────────────────────────
181   * 서울(02-)·휴대폰/지역(0XX-)·대표번호(15XX~19XX-XXXX)를 자릿수로 구분한다. */
182  function formatPhone(v) {
183    var d = v.replace(/\D/g, "").slice(0, 11);
184    if (d.indexOf("02") === 0) {
185      if (d.length <= 2) return d;
186      if (d.length <= 6) return d.slice(0, 2) + "-" + d.slice(2);
187      if (d.length <= 9) return d.slice(0, 2) + "-" + d.slice(2, d.length - 4) + "-" + d.slice(-4);
188      return d.slice(0, 2) + "-" + d.slice(2, 6) + "-" + d.slice(6, 10);
189    }
190    if (/^1[5-9]/.test(d)) { /* 1588 같은 대표번호 — 4-4 */
191      return d.length <= 4 ? d : d.slice(0, 4) + "-" + d.slice(4, 8);
192    }
193    if (d.length <= 3) return d;
194    if (d.length <= 7) return d.slice(0, 3) + "-" + d.slice(3);
195    if (d.length <= 10) return d.slice(0, 3) + "-" + d.slice(3, d.length - 4) + "-" + d.slice(-4);
196    return d.slice(0, 3) + "-" + d.slice(3, 7) + "-" + d.slice(7, 11);
197  }
198  var phoneEl = document.getElementById("f-phone");
199  phoneEl.addEventListener("input", function () {
200    var digitsBeforeCaret = phoneEl.value.slice(0, phoneEl.selectionStart).replace(/\D/g, "").length;
201    phoneEl.value = formatPhone(phoneEl.value);
202    /* 커서를 "몇 번째 숫자 뒤"였는지 기준으로 복원 — 중간 수정 시 끝으로 튀지 않게 */
203    var pos = 0, seen = 0;
204    while (pos < phoneEl.value.length && seen < digitsBeforeCaret) {
205      if (/\d/.test(phoneEl.value[pos])) seen++;
206      pos++;
207    }
208    phoneEl.setSelectionRange(pos, pos);
209  });
210
211  function fail(text) {
212    msg.textContent = text;
213    msg.className = "msg error";
214    btn.disabled = false;
215  }
216
217  form.addEventListener("submit", function (e) {
218    e.preventDefault();
219    msg.className = "msg";
220    var name = document.getElementById("f-name").value.trim();
221    var email = document.getElementById("f-email").value.trim();
222    // 연락처는 필수다 — 영업이 첫 통화를 걸어야 한다. 백엔드도 빈 값을 거부하므로
223    // 여기서 막지 않으면 422 가 그대로 사용자에게 보인다.
224    var phone = document.getElementById("f-phone").value.trim();
225    // 회사명도 필수다 — 영업 알림이 회사명으로 만들어진다. 백엔드도 빈 값을 거부한다.
226    var company = document.getElementById("f-company").value.trim();
227    if (!name || !email || !company || !phone) return fail("이름·이메일·회사명·연락처를 입력해주세요.");
228    // 대표번호(8자리)~휴대폰(11자리) — 그보다 짧으면 연락 불가능한 값이다
229    if (phone.replace(/\D/g, "").length < 8) return fail("연락처를 정확히 입력해주세요.");
230    if (!document.getElementById("f-privacy").checked) return fail("개인정보 수집·이용에 동의해주세요.");
231    btn.disabled = true;
232
233    var source = "landing:" + (new URLSearchParams(location.search).get("source") || "direct");
234    fetch(API_ROOT + "/api/v1/inquiries/", {
235      method: "POST",
236      headers: { "Content-Type": "application/json" },
237      body: JSON.stringify({
238        name: name,
239        email: email,
240        company: company,
241        phone: phone,
242        inquiry_type: document.getElementById("f-inquiry-type").value,
243        message: document.getElementById("f-message").value.trim(),
244        privacy_agreed: true,
245        source: source.slice(0, 50),
246        // CTA 맥락 (CTA-LINKS §4.1·4.5) — 값이 있는 키만 붙는다
247        ...cta,
248        // §3.3 캠페인 귀속 — 현재 URL과 합치지 않고 consent-gated first-touch
249        // 저장분만 보낸다. 그래야 후속 contact URL의 광고 ID가 기존 캠페인에 섞이지 않는다.
250        ...(window.neonaAttribution ? window.neonaAttribution() : {})
251      })
252    }).then(function (res) {
253      if (res.status === 201) {
254        form.style.display = "none";
255        document.getElementById("successPanel").style.display = "block";
256        return;
257      }
258      if (res.status === 429) return fail("요청이 많아 잠시 후 다시 시도해주세요.");
259      return fail("전송에 실패했습니다. 입력 내용을 확인하거나 잠시 후 다시 시도해주세요.");
260    }).catch(function () {
261      fail("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.");
262    });
263  });
264})();
265</script>
265
266</body>
267</html>

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.