PageSourceSearch

https://aichroma.shop/feedback.js

js aichroma.shop collected 2026-10-02 02:07:20 UTC 9,283 bytes, 170 lines download raw bytes

1/* Chroma AI — 체류 시간 기반 피드백 팝업
2   일정 시간 머문 방문자에게만 조용히 노출. 별점(필수) + 한 줄 의견(선택) →
3   Supabase site_feedback 테이블에 익명 insert.
4
5   노출 규칙(과하게 뜨지 않도록):
6   · DWELL_MS 동안 "실제로 보고 있을 때"만 카운트 (탭 숨김 시 일시정지)
7   · 제출하면 영구적으로 다시 안 뜸
8   · 닫으면 SNOOZE_DAYS 동안 안 뜸
9   · 결제/인증 등 전환 흐름 페이지에서는 아예 동작 안 함
10*/
11(function () {
12  'use strict';
13
14  var DWELL_MS    = 45000;   // 45초 체류 후 노출
15  var SNOOZE_DAYS = 30;      // 닫았을 때 재노출 유예
16  var KEY         = 'chroma-feedback';
17
18  // 전환 흐름 방해 금지 — 결제·로그인·콜백 페이지 제외.
19  // /community 와 /theme 은 업로드 유도 팝업(community.html)이 같은 우측 하단을
20  // 쓰므로 제외한다. 두 팝업이 겹치면 최악의 경험이 되고, 커뮤니티 방문자에겐
21  // 사이트 만족도보다 업로드 유도가 우선이다.
22  var BLOCKED = ['/upgrade', '/login', '/auth-callback', '/thank-you', '/reset-password', '/admin', '/uninstall',
23                 '/community', '/theme'];
24  var path = location.pathname.replace(/\.html$/, '');
25  for (var i = 0; i < BLOCKED.length; i++) {
26    if (path.indexOf(BLOCKED[i]) === 0) return;
27  }
28
29  // 이전 상태 확인 (제출 완료 / 스누즈 중이면 종료)
30  var state = null;
31  try { state = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch (e) { /* noop */ }
32  if (state && state.done) return;
33  if (state && state.snoozeUntil && Date.now() < state.snoozeUntil) return;
34
35  function save(obj) { try { localStorage.setItem(KEY, JSON.stringify(obj)); } catch (e) { /* noop */ } }
36
37  var SB_REST = 'https://vwvyjkmvpdppojsrpdwv.supabase.co/rest/v1';
38  var SB_ANON = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ3dnlqa212cGRwcG9qc3JwZHd2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNDY0NzgsImV4cCI6MjA5MzYyMjQ3OH0.U_PFEL1cz6JWwzk0cvZyEr79snHn56T0-waCvAZMjTI';
39
40  var isKo = function () {
41    if (document.body && document.body.classList.contains('ko')) return true;
42    try { if (localStorage.getItem('chroma-lang') === 'ko') return true; } catch (e) { /* noop */ }
43    return (navigator.language || '').toLowerCase().indexOf('ko') === 0;
44  };
45
46  // ── 체류 시간 측정 (탭이 보이는 동안만 누적) ────────────────────────────
47  var elapsed = 0, last = Date.now(), timer = null, shown = false;
48  function tick() {
49    if (document.visibilityState === 'visible') elapsed += Date.now() - last;
50    last = Date.now();
51    if (elapsed >= DWELL_MS && !shown) { shown = true; clearInterval(timer); show(); }
52  }
53  document.addEventListener('visibilitychange', function () { last = Date.now(); });
54  timer = setInterval(tick, 1000);
55
56  // ── UI ───────────────────────────────────────────────────────────────────
57  function show() {
58    var ko = isKo();
59    var wrap = document.createElement('div');
60    wrap.id = 'chromaFb';
61    wrap.setAttribute('role', 'dialog');
62    wrap.setAttribute('aria-label', ko ? '피드백' : 'Feedback');
63    wrap.innerHTML = ''
64      + '<style>'
65      + '#chromaFb{position:fixed;right:20px;bottom:20px;z-index:2147483000;width:320px;max-width:calc(100vw - 32px);'
66      + 'background:#fff;border:1px solid #e7eaf0;border-radius:16px;box-shadow:0 18px 50px rgba(12,19,34,.18);'
67      + "font-family:'Pretendard','Inter',system-ui,sans-serif;color:#0c1322;padding:18px 18px 16px;"
68      + 'opacity:0;transform:translateY(14px);transition:opacity .32s ease,transform .32s cubic-bezier(.16,1,.3,1)}'
69      + '#chromaFb.in{opacity:1;transform:none}'
70      + '#chromaFb h3{margin:0 0 4px;font-size:15px;font-weight:800;letter-spacing:-.01em}'
71      + '#chromaFb p{margin:0 0 12px;font-size:12.5px;line-height:1.5;color:#54607a}'
72      + '#chromaFb .fb-stars{display:flex;gap:4px;margin-bottom:12px}'
73      + '#chromaFb .fb-star{cursor:pointer;font-size:24px;line-height:1;color:#dfe3ea;background:none;border:0;padding:0 2px;transition:transform .12s ease,color .12s ease}'
74      + '#chromaFb .fb-star:hover{transform:scale(1.14)}'
75      + '#chromaFb .fb-star.on{color:#ffb020}'
76      + '#chromaFb textarea{width:100%;box-sizing:border-box;min-height:64px;resize:vertical;font-family:inherit;'
77      + 'font-size:12.5px;line-height:1.5;padding:9px 11px;border:1px solid #e7eaf0;border-radius:10px;color:#0c1322;outline:none}'
78      + '#chromaFb textarea:focus{border-color:#146ef5}'
79      + '#chromaFb .fb-row{display:flex;gap:8px;margin-top:11px}'
80      + '#chromaFb button.fb-act{flex:1;appearance:none;cursor:pointer;font-family:inherit;font-size:13px;font-weight:700;'
81      + 'padding:10px;border-radius:10px;border:1px solid #e7eaf0;background:#fff;color:#54607a}'
82      + '#chromaFb button.fb-act.primary{background:#146ef5;border-color:#146ef5;color:#fff}'
83      + '#chromaFb button.fb-act.primary:disabled{opacity:.45;cursor:not-allowed}'
84      + '#chromaFb .fb-x{position:absolute;top:10px;right:12px;border:0;background:none;cursor:pointer;'
85      + 'font-size:18px;line-height:1;color:#97a1b2;padding:2px 4px}'
86      + '#chromaFb .fb-done{text-align:center;padding:6px 0 2px}'
87      + '#chromaFb .fb-done .fb-ok{font-size:26px}'
88      + '@media (prefers-reduced-motion: reduce){#chromaFb{transition:none}}'
89      + '</style>'
90      + '<button class="fb-x" aria-label="' + (ko ? '닫기' : 'Close') + '">&times;</button>'
91      + '<h3>' + (ko ? '잠깐, 어떠셨나요?' : 'How is it going?') + '</h3>'
92      + '<p>' + (ko ? 'Chroma AI를 더 좋게 만드는 데 큰 힘이 됩니다. 10초면 충분해요.'
93                   : 'Your feedback helps us make Chroma AI better. Takes 10 seconds.') + '</p>'
94      + '<div class="fb-stars" role="radiogroup"></div>'
95      + '<textarea placeholder="' + (ko ? '남기고 싶은 말이 있다면… (선택)' : 'Anything you want to tell us? (optional)') + '"></textarea>'
96      + '<div class="fb-row">'
97      +   '<button class="fb-act fb-later">' + (ko ? '나중에' : 'Later') + '</button>'
98      +   '<button class="fb-act primary fb-send" disabled>' + (ko ? '보내기' : 'Send') + '</button>'
99      + '</div>';
100    wrap.style.position = 'fixed';
101    document.body.appendChild(wrap);
102    requestAnimationFrame(function () { wrap.classList.add('in'); });
103
104    // 별점
105    var rating = 0;
106    var starsEl = wrap.querySelector('.fb-stars');
107    var sendBtn = wrap.querySelector('.fb-send');
108    for (var s = 1; s <= 5; s++) {
109      var b = document.createElement('button');
110      b.className = 'fb-star';
111      b.type = 'button';
112      b.textContent = '★';
113      b.dataset.v = String(s);
114      b.setAttribute('aria-label', s + (ko ? '점' : ' stars'));
115      starsEl.appendChild(b);
116    }
117    starsEl.addEventListener('click', function (e) {
118      var t = e.target.closest('.fb-star');
119      if (!t) return;
120      rating = Number(t.dataset.v) || 0;
121      Array.prototype.forEach.call(starsEl.children, function (el, idx) {
122        el.classList.toggle('on', idx < rating);
123      });
124      sendBtn.disabled = rating === 0;
125    });
126
127    function close(snooze) {
128      if (snooze) save({ snoozeUntil: Date.now() + SNOOZE_DAYS * 864e5 });
129      wrap.classList.remove('in');
130      setTimeout(function () { wrap.remove(); }, 320);
131    }
132    wrap.querySelector('.fb-x').addEventListener('click', function () { close(true); });
133    wrap.querySelector('.fb-later').addEventListener('click', function () { close(true); });
134
135    sendBtn.addEventListener('click', function () {
136      if (!rating) return;
137      sendBtn.disabled = true;
138      sendBtn.textContent = ko ? '보내는 중…' : 'Sending…';
139      var payload = {
140        rating: rating,
141        message: (wrap.querySelector('textarea').value || '').trim().slice(0, 2000) || null,
142        page: location.pathname,
143        lang: ko ? 'ko' : 'en',
144        user_agent: (navigator.userAgent || '').slice(0, 300),
145      };
146      fetch(SB_REST + '/site_feedback', {
147        method: 'POST',
148        headers: {
149          apikey: SB_ANON,
150          Authorization: 'Bearer ' + SB_ANON,
151          'Content-Type': 'application/json',
152          Prefer: 'return=minimal',
153        },
154        body: JSON.stringify(payload),
155      }).catch(function () { /* 실패해도 사용자에겐 감사 표시 — 재촉하지 않음 */ })
156        .then(function () {
157          save({ done: true });
158          try {
159            if (window.gtag) window.gtag('event', 'feedback_submit', { rating: rating });
160          } catch (e) { /* noop */ }
161          wrap.innerHTML = wrap.querySelector('style').outerHTML
162            + '<div class="fb-done"><div class="fb-ok">🙏</div><h3 style="margin-top:6px">'
163            + (ko ? '감사합니다!' : 'Thank you!') + '</h3><p style="margin:4px 0 0">'
164            + (ko ? '보내주신 의견은 개선에 바로 반영하겠습니다.' : 'We read every note — thank you for helping.')
165            + '</p></div>';
166          setTimeout(function () { close(false); }, 2200);
167        });
168    });
169  }
170})();

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.