/** * @file avokado-nudge.js * Remote-config banner replacement and Q&A keyword nudge (toast) for apartment ERP embeds. */ (function (global) { 'use strict'; var SDK_VERSION = '1.0.1'; var TOAST_ID = '__avn_toast__'; var STYLE_ID = '__avn_style__'; var BANNER_TARGET = '.bg_area2_left'; var VEHICLE_COUNT_ID = 'avokado-banner-vehicle-count'; var _vehicleCountObs = null; var DEFAULTS = { configUrl: null, configTtl: 300, bannerTarget: BANNER_TARGET, onBannerLoad: null, onDetect: null, onAppClick: null, }; var _cfg = {}; var _remoteCfg = null; var _debounceT = null; var _dismissed = false; var _lastKw = ''; var _toastEl = null; var _inputEl = null; var _qnaTriggerKeyword = null; var _qnaImpressionSent = false; var _moveActionBound = false; var _inputHandler = null; var _boundInputEl = null; var TOAST_CSS = [ '#' + TOAST_ID + '{', ' position:fixed;bottom:24px;right:24px;width:340px;', ' background:#fff;border:1.5px solid #5b9bd5;border-radius:12px;', ' box-shadow:0 6px 24px rgba(0,0,0,.15);padding:18px 20px 16px;', ' z-index:99999;font-family:"Noto Sans KR","Apple SD Gothic Neo",sans-serif;', ' font-size:15px;line-height:1.6;', ' opacity:0;transform:translateY(10px);pointer-events:none;', ' transition:opacity .25s,transform .25s;', '}', '#' + TOAST_ID + '.avn-show{opacity:1;transform:translateY(0);pointer-events:auto;}', '#' + TOAST_ID + ' .avn-hd{display:flex;align-items:center;gap:8px;', ' font-weight:700;color:#0d3d8c;margin-bottom:10px;font-size:16px;}', '#' + TOAST_ID + ' .avn-ico{font-size:20px;line-height:1;}', '#' + TOAST_ID + ' .avn-list{list-style:none;margin:0 0 12px;padding:0;}', '#' + TOAST_ID + ' .avn-list-item a{display:flex;align-items:center;justify-content:space-between;', ' padding:10px 6px;font-size:15px;color:#1D6ADD;text-decoration:none;', ' border-bottom:1px solid #f0f0f0;border-radius:4px;transition:background .1s;}', '#' + TOAST_ID + ' .avn-list-item a::after{content:"›";font-size:18px;color:#88bef5;}', '#' + TOAST_ID + ' .avn-list-item:last-child a{border-bottom:none;}', '#' + TOAST_ID + ' .avn-list-item a:hover{background:#e8f2ff;}', '#' + TOAST_ID + ' .avn-ch{width:100%;background:#f5f5f5;color:#444;', ' border:1px solid #ddd;border-radius:10px;padding:14px 0;font-size:15px;', ' cursor:pointer;font-family:inherit;transition:background .15s;}', '#' + TOAST_ID + ' .avn-ch:hover{background:#ebebeb;}', '#' + TOAST_ID + ' .avn-kw{display:inline-block;background:#e8f2ff;color:#1558b0;', ' border:1px solid #88bef5;border-radius:10px;font-size:13px;', ' padding:2px 10px;margin-left:2px;font-weight:500;}', '#' + TOAST_ID + ' .avn-body{color:#333;font-size:15px;margin-bottom:14px;line-height:1.7;}', '#' + TOAST_ID + ' .avn-btns{display:flex;gap:8px;}', '#' + TOAST_ID + ' .avn-cta{flex:1;background:#1D6ADD;color:#fff;border:none;', ' border-radius:10px;padding:14px 0;font-size:16px;font-weight:700;', ' cursor:pointer;font-family:inherit;transition:background .15s;}', '#' + TOAST_ID + ' .avn-cta:hover{background:#155fc4;}', '#' + TOAST_ID + ' .avn-sub{background:#f5f5f5;color:#555;border:1px solid #ddd;', ' border-radius:10px;padding:14px 14px;font-size:16px;cursor:pointer;', ' font-family:inherit;transition:background .15s;}', '#' + TOAST_ID + ' .avn-sub:hover{background:#ebebeb;}', '#' + TOAST_ID + ' .avn-x{position:absolute;top:10px;right:12px;font-size:18px;', ' color:#aaa;cursor:pointer;background:none;border:none;padding:4px;', ' font-family:inherit;line-height:1;}', '#' + TOAST_ID + ' .avn-x:hover{color:#555;}', ].join('\n'); function _loadRemoteConfig(url, callback) { var cacheKey = '__avn_cfg__'; var cacheTime = '__avn_cfg_t__'; var ttl = (_cfg.configTtl || 300) * 1000; try { var cached = sessionStorage.getItem(cacheKey); var ts = parseInt(sessionStorage.getItem(cacheTime) || '0', 10); if (cached && Date.now() - ts < ttl) { return callback(null, JSON.parse(cached)); } } catch (e) {} var xhr = new XMLHttpRequest(); xhr.open('GET', url + '?_=' + Date.now(), true); xhr.onreadystatechange = function () { if (xhr.readyState !== 4) return; if (xhr.status === 200) { try { var data = JSON.parse(xhr.responseText); try { sessionStorage.setItem(cacheKey, JSON.stringify(data)); sessionStorage.setItem(cacheTime, String(Date.now())); } catch (e) {} callback(null, data); } catch (e) { callback(new Error('Config JSON parse error: ' + e.message), null); } } else { callback(new Error('Config load failed: HTTP ' + xhr.status), null); } }; xhr.send(); } function _vehicleCountHostEmpty(text) { if (text == null) return true; var s = String(text) .replace(/\uFEFF/g, '') .replace(/\u00A0/g, '') .replace(/\u2007/g, '') .replace(/\u202F/g, '') .replace(/\s/g, ''); return s.length === 0; } function _vehicleCountRow(countEl) { var row = countEl.parentElement; while (row && row !== document.body) { if (_hasClass(row, 'avk-tb-info-count')) return row; row = row.parentElement; } return countEl.parentElement; } function _syncVehicleCountPlaceholder(countEl) { if (!countEl) return; var row = _vehicleCountRow(countEl); var raw = countEl.textContent || ''; if (_vehicleCountHostEmpty(raw)) { countEl.textContent = '미사용'; if (row) row.classList.add('avk-vehicle-placeholder'); return; } if (raw === '미사용') { if (row) row.classList.add('avk-vehicle-placeholder'); return; } if (row) row.classList.remove('avk-vehicle-placeholder'); } function _bindVehicleCountPlaceholderFix(bannerRoot) { if (!bannerRoot) return; if (_vehicleCountObs) { try { _vehicleCountObs.disconnect(); } catch (e) {} _vehicleCountObs = null; } function findEl() { if (bannerRoot.querySelector) { var q = bannerRoot.querySelector('#' + VEHICLE_COUNT_ID); if (q) return q; } return document.getElementById(VEHICLE_COUNT_ID); } var rafPending = false; function runSync() { rafPending = false; _syncVehicleCountPlaceholder(findEl()); } function schedule() { if (rafPending) return; rafPending = true; if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(runSync); } else { setTimeout(runSync, 0); } } runSync(); try { _vehicleCountObs = new MutationObserver(schedule); _vehicleCountObs.observe(bannerRoot, { childList: true, subtree: true, characterData: true, }); } catch (e) { _warn('방문차량 건수 placeholder 보정 observer 설정 실패'); } } function _renderBanner(bannerCfg) { var target = document.querySelector(_cfg.bannerTarget); if (!target) { _warn('배너 대상 요소를 찾을 수 없습니다: ' + _cfg.bannerTarget); return; } var type = (bannerCfg && bannerCfg.type) || 'default'; function fireBannerLoad() { if (typeof _cfg.onBannerLoad === 'function') { _cfg.onBannerLoad(type, target); } } if (type === 'hidden') { target.style.display = 'none'; fireBannerLoad(); return; } if (type === 'html') { if (bannerCfg.url) { var xhr = new XMLHttpRequest(); xhr.open('GET', bannerCfg.url + '?_=' + Date.now(), true); xhr.onreadystatechange = function () { if (xhr.readyState !== 4) return; if (xhr.status === 200) { target.innerHTML = xhr.responseText; _bindVehicleCountPlaceholderFix(target); fireBannerLoad(); } else { _warn('배너 HTML 로딩 실패: HTTP ' + xhr.status); } }; xhr.send(); return; } if (bannerCfg.html) { target.innerHTML = bannerCfg.html; _bindVehicleCountPlaceholderFix(target); fireBannerLoad(); return; } } fireBannerLoad(); } function _injectStyle() { if (document.getElementById(STYLE_ID)) return; var el = document.createElement('style'); el.id = STYLE_ID; el.textContent = TOAST_CSS; document.head.appendChild(el); } function _buildToast() { if (document.getElementById(TOAST_ID)) return; var div = document.createElement('div'); div.id = TOAST_ID; div.setAttribute('role', 'alert'); div.setAttribute('aria-live', 'polite'); div.innerHTML = [ '', '
', ' 🥑', ' ', '
', '
', ].join(''); document.body.appendChild(div); _toastEl = div; div.addEventListener('click', function (e) { if (e.isTrusted === false) return; var t = e.target || e.srcElement; while (t && t.nodeType !== 1) t = t.parentNode; while (t && t !== div) { if ( t.nodeType === 1 && (t.tagName || '').toUpperCase() === 'A' && t.href && t.parentNode && _hasClass(t.parentNode, 'avn-list-item') ) { if (_qnaTriggerKeyword) { trackEvent('apt_qna_help_tooltip_item_click', { trigger_keyword: _qnaTriggerKeyword }); } return; } if (t.id === '__avn_x__') { if (_qnaTriggerKeyword) { trackEvent('apt_qna_help_tooltip_close_btn_click', { trigger_keyword: _qnaTriggerKeyword, }); } _dismiss(); return; } if (_hasClass(t, 'avn-sub')) { _dismiss(); return; } if (_hasClass(t, 'avn-cta')) { if (typeof _cfg.onAppClick === 'function') _cfg.onAppClick(_lastKw); window.open((_remoteCfg && _remoteCfg.appUrl) || '#', '_blank'); _dismiss(); return; } if (_hasClass(t, 'avn-ch')) { _openChannelTalk(); return; } t = t.parentNode; } }); } function _renderBody(kwObj) { var word = typeof kwObj === 'string' ? kwObj : kwObj.word; var contents = typeof kwObj === 'object' && kwObj.contents && kwObj.contents.length ? kwObj.contents : null; var toastCfg = (_remoteCfg && _remoteCfg.nudge && _remoteCfg.nudge.toast) || {}; if (contents) { var items = ''; for (var i = 0; i < contents.length; i++) { items += '
  • ' + '' + _esc(contents[i].title) + '
  • '; } return ''; } return [ '
    ', ' "' + _esc(word) + '" ', ' ' + _esc(toastCfg.body || '아보카도 앱에서 더 빠르게 처리됩니다.'), '
    ', '
    ', ' ', ' ', '
    ', ].join(''); } function trackEvent(name, params) { if (typeof global.gtag === 'function') { global.gtag('event', name, params || {}); } } function resolveQnaTriggerKeyword(kwObj) { if (kwObj == null || kwObj === '') return null; if (typeof kwObj === 'object' && kwObj.trigger_keyword) { var fixed = String(kwObj.trigger_keyword).trim(); if (fixed === 'vote' || fixed === 'fire_check') return fixed; } var word = typeof kwObj === 'string' ? kwObj : kwObj.word != null ? String(kwObj.word) : ''; word = String(word); if (word.indexOf('소방') !== -1) return 'fire_check'; if (word.indexOf('투표') !== -1) return 'vote'; return null; } function getButtonType(el) { if (!el) return null; var text = (el.textContent || '').trim(); if (text.indexOf('전자투표') !== -1) return 'vote'; if (text.indexOf('소방') !== -1) return 'fire_check'; if (text.indexOf('사용 방법') !== -1) return 'guide'; if (text.indexOf('입주민 등록') !== -1) return 'resident_register'; if (text.indexOf('방문차량') !== -1) return 'visitor_parking'; return null; } function resolveBannerButtonType(el) { if (!el || !el.getAttribute) return null; var raw = el.getAttribute('data-analytics'); if (raw && String(raw).trim()) return String(raw).trim(); var tag = (el.tagName || '').toUpperCase(); if (tag !== 'BUTTON' && tag !== 'A') return null; return getButtonType(el); } function _bindMoveAction() { if (_moveActionBound) return; _moveActionBound = true; document.addEventListener('click', function (e) { if (e.isTrusted === false) return; var toastRoot = document.getElementById(TOAST_ID); if ( toastRoot && e.target && typeof toastRoot.contains === 'function' && toastRoot.contains(e.target) ) { return; } var el = e.target; var bannerTracked = false; while (el && el !== document) { if (!bannerTracked) { var buttonType = resolveBannerButtonType(el); if (buttonType) { trackEvent('apt_banner_btn_click', { button_type: buttonType }); bannerTracked = true; } } if (el.hasAttribute && el.getAttribute('data-params') === 'resident_list') { e.preventDefault(); var target = document.getElementById('VIN66100'); if (target) { target.click(); } else { console.warn('[AvokadoNudge] #VIN66100 요소를 찾을 수 없음'); } return; } if (el.hasAttribute && el.hasAttribute('data-move')) { e.preventDefault(); var type = el.getAttribute('data-move'); if (typeof window.MoveAvo === 'function') { window.MoveAvo(type); } else { console.warn('[AvokadoNudge] MoveAvo 함수 없음:', type); } return; } if (el.hasAttribute && el.hasAttribute('data-action')) { e.preventDefault(); var action = el.getAttribute('data-action'); var params = el.getAttribute('data-params'); if (typeof window[action] === 'function') { window[action](params); } else { try { new Function(action + '(' + JSON.stringify(params) + ')')(); } catch (e) { console.warn('[AvokadoNudge] 실행 실패:', action); } } return; } el = el.parentNode; } }); } function _showToast(kwObj) { if (!_toastEl) return; var word = typeof kwObj === 'string' ? kwObj : kwObj.word; var contents = typeof kwObj === 'object' && kwObj.contents && kwObj.contents.length ? kwObj.contents : null; _lastKw = word; var tk = resolveQnaTriggerKeyword(kwObj); if (tk) { if (!_qnaImpressionSent || tk !== _qnaTriggerKeyword) { trackEvent('apt_qna_help_tooltip_impression', { trigger_keyword: tk }); _qnaImpressionSent = true; } _qnaTriggerKeyword = tk; } else { _qnaTriggerKeyword = null; _qnaImpressionSent = false; } var hdEl = document.getElementById('__avn_hdtxt__'); var toastCfg = (_remoteCfg && _remoteCfg.nudge && _remoteCfg.nudge.toast) || {}; if (hdEl) { hdEl.textContent = contents ? '"' + word + '" 관련 도움말' : toastCfg.title || '아보카도 관련 문의인가요?'; } var bodyEl = document.getElementById('__avn_body__'); if (bodyEl) bodyEl.innerHTML = _renderBody(kwObj); requestAnimationFrame(function () { _toastEl.classList.add('avn-show'); }); if (typeof _cfg.onDetect === 'function') _cfg.onDetect(word); _slackNotify(word); } function _openChannelTalk() { var chCfg = _remoteCfg && _remoteCfg.channelTalk; if (!chCfg) return; if (typeof global.ChannelIO === 'function') { global.ChannelIO('openChat'); } else if (chCfg.fallbackUrl) { window.open(chCfg.fallbackUrl, '_blank'); } _dismiss(); } function _hideToast() { if (_toastEl) _toastEl.classList.remove('avn-show'); _qnaTriggerKeyword = null; _qnaImpressionSent = false; } function _dismiss() { _hideToast(); setTimeout(function () { _dismissed = false; }, 2000); } function _detectKeyword(text, keywords) { for (var i = 0; i < keywords.length; i++) { var kw = keywords[i]; var word = typeof kw === 'string' ? kw : kw.word; if (text.indexOf(word) !== -1) return kw; } return null; } function _onInput(keywords) { return function (e) { if (_dismissed) return; clearTimeout(_debounceT); var el = e.target || e.srcElement; _debounceT = setTimeout( function () { var found = _detectKeyword(el.value || '', keywords); if (found) { _showToast(found); } else { _hideToast(); _dismissed = false; } }, (_remoteCfg && _remoteCfg.nudge && _remoteCfg.nudge.debounce) || 400, ); }; } function _bindInput(nudgeCfg) { var targetId = (nudgeCfg && nudgeCfg.targetId) || 'txtCnts'; var keywords = (nudgeCfg && nudgeCfg.keywords) || []; var el = document.getElementById(targetId); if (!el) { _warn('Nudge 대상 요소를 찾을 수 없습니다: #' + targetId); return; } if (_inputHandler && _boundInputEl) { try { _boundInputEl.removeEventListener('input', _inputHandler); _boundInputEl.removeEventListener('keyup', _inputHandler); } catch (err) {} } _boundInputEl = el; _inputEl = el; _inputHandler = _onInput(keywords); _inputEl.addEventListener('input', _inputHandler); _inputEl.addEventListener('keyup', _inputHandler); } function _slackNotify(keyword) { var slackCfg = _remoteCfg && _remoteCfg.slack; if (!slackCfg || !slackCfg.enabled || !slackCfg.webhookUrl) return; var payload = JSON.stringify({ text: '[Avokado Nudge] 키워드 감지: *' + keyword + '* — ' + (document.title || location.href), }); var xhr = new XMLHttpRequest(); xhr.open('POST', slackCfg.webhookUrl, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(payload); } function _bootstrap(remoteCfg) { _remoteCfg = remoteCfg; if (remoteCfg.enabled === false) { _log('SDK disabled via remote config.'); return; } if (remoteCfg.banner) { _renderBanner(remoteCfg.banner); } var nudgeCfg = remoteCfg.nudge; if (nudgeCfg && nudgeCfg.enabled !== false) { _injectStyle(); _buildToast(); _bindInput(nudgeCfg); } _bindMoveAction(); } function _hasClass(el, cls) { return !!(el && el.className && el.className.indexOf(cls) !== -1); } function _merge(a, b) { var r = {}; for (var k in a) r[k] = a[k]; if (b) for (var k in b) r[k] = b[k]; return r; } function _esc(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function _log(msg) { if (global.console) console.log('[AvokadoNudge] ' + msg); } function _warn(msg) { if (global.console) console.warn('[AvokadoNudge] ' + msg); } function _ready(fn) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn); } else { fn(); } } /** * @typedef {object} AvokadoNudgeInitOptions * @property {string} [configUrl] Remote JSON config URL. * @property {number} [configTtl] SessionStorage cache TTL in seconds. * @property {string} [bannerTarget] Banner mount selector (default {@code .bg_area2_left}). * @property {function(string, HTMLElement)} [onBannerLoad] Banner type and mount element. * @property {function(string)} [onDetect] Keyword detected in the bound textarea. * @property {function(string)} [onAppClick] Fallback toast primary CTA clicked. */ /** * @namespace AvokadoNudge */ var AvokadoNudge = { version: SDK_VERSION, /** * 원격 JSON 설정을 읽어 배너와 키워드 넛지를 붙입니다. {@code configUrl}이 없으면 내장 기본값으로만 동작합니다. * DOM 준비 후 실행되므로, 스크립트는 {@code } 직전 등에서 로드하면 됩니다. * * @example * AvokadoNudge.init({ * configUrl: 'https://example.com/banner-config.json', * bannerTarget: '.bg_area2_left', * configTtl: 300, * onBannerLoad: function (type, mountEl) {}, * onDetect: function (keyword) {}, * onAppClick: function (keyword) {} * }); * * @param {AvokadoNudgeInitOptions} [opts] * @returns {AvokadoNudge} */ init: function (opts) { _cfg = _merge(DEFAULTS, opts); if (!_cfg.configUrl) { _warn('configUrl이 없습니다. 로컬 기본값으로 동작합니다.'); _ready(function () { _bootstrap({ enabled: true, banner: { type: 'default', }, nudge: { enabled: true, targetId: 'txtCnts', debounce: 400, keywords: [], toast: {}, }, appUrl: 'https://back-office.avo-kado.co.kr', channelTalk: { enabled: true, label: '원하는 답변이 없다면 채널톡으로 문의', fallbackUrl: '', }, slack: { enabled: false, }, }); }); return this; } _loadRemoteConfig(_cfg.configUrl, function (err, data) { if (err) { _warn('Config 로딩 실패: ' + err.message); return; } _ready(function () { _bootstrap(data); }); }); return this; }, /** @param {string} html */ setBannerHtml: function (html) { var target = document.querySelector(_cfg.bannerTarget || BANNER_TARGET); if (target) { target.innerHTML = html; _bindVehicleCountPlaceholderFix(target); } return this; }, /** @param {boolean} visible */ toggleBanner: function (visible) { var target = document.querySelector(_cfg.bannerTarget || BANNER_TARGET); if (target) target.style.display = visible ? '' : 'none'; return this; }, /** @param {string} [keyword] */ show: function (keyword) { _dismissed = false; _showToast(keyword || ''); return this; }, hide: function () { _hideToast(); return this; }, reset: function () { _dismissed = false; return this; }, /** @param {string|Object} kw */ addKeyword: function (kw) { if (_remoteCfg && _remoteCfg.nudge && _remoteCfg.nudge.keywords) { if (_remoteCfg.nudge.keywords.indexOf(kw) === -1) { _remoteCfg.nudge.keywords.push(kw); } } return this; }, /** @returns {Object|null} Deep clone of the loaded remote config. */ getConfig: function () { return _remoteCfg ? JSON.parse(JSON.stringify(_remoteCfg)) : null; }, }; global.AvokadoNudge = AvokadoNudge; })(typeof window !== 'undefined' ? window : this); (function initGA() { if (window.gtag) return; const script = document.createElement('script'); script.src = 'https://www.googletagmanager.com/gtag/js?id=G-QYJY9D06RY'; script.async = true; document.head.appendChild(script); window.dataLayer = window.dataLayer || []; window.gtag = function () { window.dataLayer.push(arguments); }; window.gtag('js', new Date()); window.gtag('config', 'G-QYJY9D06RY'); })();