/**
* GUARDiA ITSM 온보딩 가이드 챗봇
* 설치 완료 후 자동 실행 — 로그인부터 프로젝트 등록까지 단계별 안내
*/
(function GUARDiAOnboarding() {
'use strict';
// ── 상태 ──────────────────────────────────────────────────
let state = {
visible: false,
minimized: false,
currentStep: null,
totalSteps: 8,
messages: [],
isTyping: false,
spotlightEl: null,
};
let _token = null;
let _pollId = null;
// ── 초기화 ────────────────────────────────────────────────
function init() {
// 토큰 확인 (로그인 상태만)
_token = localStorage.getItem('access_token');
if (!_token) return;
// 온보딩 상태 조회
fetchStatus().then(status => {
if (!status) return;
if (status.show_bot) {
buildUI();
show();
loadStep(status.current);
// 화면 변화 감지
watchNavigation();
} else {
// 완료됐어도 우측하단 도움말 버튼만 남김
buildHelpButton();
}
});
}
// ── API ───────────────────────────────────────────────────
async function api(method, path, body) {
const opts = {
method,
headers: {
'Authorization': 'Bearer ' + _token,
'Content-Type': 'application/json',
},
};
if (body) opts.body = JSON.stringify(body);
try {
const r = await fetch(path, opts);
return r.ok ? r.json() : null;
} catch { return null; }
}
async function fetchStatus() {
return api('GET', '/api/onboarding/status');
}
async function postStep(stepId, action) {
return api('POST', '/api/onboarding/step', { step_id: stepId, action });
}
async function postMessage(userMessage) {
return api('POST', '/api/onboarding/message', {
current_view: location.pathname,
current_step: state.currentStep?.id,
user_message: userMessage,
});
}
async function completeOnboarding() {
await api('POST', '/api/onboarding/complete');
}
async function dismissOnboarding() {
await api('POST', '/api/onboarding/dismiss');
}
// ── UI 빌드 ───────────────────────────────────────────────
function buildUI() {
if (document.getElementById('grd-onboarding')) return;
const panel = document.createElement('div');
panel.id = 'grd-onboarding';
panel.innerHTML = `
1 / 8 단계
`;
// 스타일
const style = document.createElement('style');
style.textContent = `
#grd-onboarding {
position: fixed;
right: 0; top: 50%;
transform: translateY(-50%);
width: 360px;
max-height: 85vh;
background: #1e2333;
border-left: 3px solid #818cf8;
border-radius: 16px 0 0 16px;
box-shadow: -8px 0 40px rgba(0,0,0,.4);
display: flex; flex-direction: column;
z-index: 9999;
font-family: 'Noto Sans KR', Arial, sans-serif;
font-size: 13px; color: #e2e8f0;
transition: all .3s cubic-bezier(.4,0,.2,1);
overflow: hidden;
}
#grd-onboarding.minimized {
height: 52px; max-height: 52px;
border-radius: 16px 0 0 16px;
}
#grd-onboarding.minimized #grd-ob-messages,
#grd-onboarding.minimized #grd-ob-input-area,
#grd-onboarding.minimized #grd-ob-progress-bar,
#grd-onboarding.minimized #grd-ob-progress-label { display: none; }
#grd-ob-header {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px;
background: #252b3b;
cursor: pointer; flex-shrink: 0;
}
.grd-ob-avatar {
width: 36px; height: 36px; border-radius: 50%;
background: linear-gradient(135deg,#818cf8,#6366f1);
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex-shrink: 0;
animation: pulse 2s ease infinite;
}
@keyframes pulse {
0%,100%{box-shadow:0 0 0 0 rgba(129,140,248,.4)}
50%{box-shadow:0 0 0 8px rgba(129,140,248,0)}
}
.grd-ob-header-info { flex: 1; }
.grd-ob-title { font-weight: 700; font-size: 14px; color: #fff; }
.grd-ob-subtitle { font-size: 11px; color: #818cf8; margin-top: 1px; }
.grd-ob-header-actions { display: flex; gap: 4px; }
.grd-ob-btn-icon {
width: 26px; height: 26px; border-radius: 6px;
background: rgba(255,255,255,.08);
color: #94a3b8; border: none; cursor: pointer;
font-size: 13px; display: flex; align-items: center; justify-content: center;
transition: all .15s;
}
.grd-ob-btn-icon:hover { background: rgba(255,255,255,.15); color: #fff; }
#grd-ob-progress-bar {
height: 4px; background: rgba(255,255,255,.08);
flex-shrink: 0;
}
#grd-ob-progress-fill {
height: 100%; background: linear-gradient(90deg,#818cf8,#6ee7b7);
transition: width .5s ease;
}
#grd-ob-progress-label {
font-size: 10px; color: #64748b;
text-align: right; padding: 3px 12px 0;
flex-shrink: 0;
}
#grd-ob-messages {
flex: 1; overflow-y: auto; padding: 14px 12px;
display: flex; flex-direction: column; gap: 10px;
scrollbar-width: thin;
}
.grd-ob-msg {
display: flex; gap: 8px; align-items: flex-start;
}
.grd-ob-msg.user { flex-direction: row-reverse; }
.grd-ob-msg-avatar {
width: 28px; height: 28px; border-radius: 50%;
background: linear-gradient(135deg,#818cf8,#6366f1);
display: flex; align-items: center; justify-content: center;
font-size: 14px; flex-shrink: 0;
}
.grd-ob-msg.user .grd-ob-msg-avatar {
background: rgba(99,102,241,.25);
}
.grd-ob-msg-bubble {
background: #252b3b;
border-radius: 4px 14px 14px 14px;
padding: 10px 13px;
max-width: 270px;
line-height: 1.6;
white-space: pre-line;
}
.grd-ob-msg.user .grd-ob-msg-bubble {
background: #4f46e5;
border-radius: 14px 4px 14px 14px;
}
.grd-ob-msg-bubble strong { color: #a5b4fc; }
.grd-ob-msg-bubble code {
background: rgba(255,255,255,.1);
padding: 2px 5px; border-radius: 4px;
font-family: monospace; font-size: 12px;
}
.grd-ob-actions {
display: flex; flex-wrap: wrap; gap: 6px;
margin-top: 8px; padding-left: 36px;
}
.grd-ob-action-btn {
padding: 7px 14px; border-radius: 20px;
background: rgba(129,140,248,.18);
color: #818cf8; border: 1px solid rgba(129,140,248,.3);
font-size: 12px; font-weight: 600; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
.grd-ob-action-btn:hover {
background: rgba(129,140,248,.35);
color: #fff;
}
.grd-ob-action-btn.primary {
background: #4f46e5; color: #fff; border-color: transparent;
}
.grd-ob-action-btn.primary:hover { background: #4338ca; }
.grd-ob-typing {
display: flex; gap: 4px; padding: 10px 14px;
background: #252b3b; border-radius: 4px 14px 14px 14px;
width: fit-content;
}
.grd-ob-typing span {
width: 6px; height: 6px; border-radius: 50%;
background: #818cf8; animation: typing .8s ease infinite;
}
.grd-ob-typing span:nth-child(2) { animation-delay: .2s; }
.grd-ob-typing span:nth-child(3) { animation-delay: .4s; }
@keyframes typing { 0%,60%,100%{opacity:.3} 30%{opacity:1} }
#grd-ob-input-area {
display: flex; gap: 8px; padding: 10px 12px;
border-top: 1px solid rgba(255,255,255,.07);
flex-shrink: 0;
}
#grd-ob-input {
flex: 1; background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.1);
border-radius: 8px; color: #e2e8f0;
padding: 8px 12px; font-size: 13px; outline: none;
font-family: inherit;
}
#grd-ob-input:focus { border-color: #818cf8; }
#grd-ob-send {
width: 34px; height: 34px; border-radius: 8px;
background: #4f46e5; color: #fff; border: none;
cursor: pointer; font-size: 14px;
display: flex; align-items: center; justify-content: center;
transition: background .15s;
}
#grd-ob-send:hover { background: #4338ca; }
/* 스포트라이트 */
.grd-spotlight {
position: fixed; z-index: 9998;
border: 2px solid #818cf8;
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(0,0,0,.5), 0 0 24px rgba(129,140,248,.6);
pointer-events: none;
transition: all .3s ease;
animation: spotlight-pulse 2s ease infinite;
}
@keyframes spotlight-pulse {
0%,100%{box-shadow:0 0 0 9999px rgba(0,0,0,.5),0 0 24px rgba(129,140,248,.4)}
50%{box-shadow:0 0 0 9999px rgba(0,0,0,.5),0 0 40px rgba(129,140,248,.8)}
}
/* 도움말 버튼 (온보딩 완료 후) */
#grd-help-btn {
position: fixed; right: 20px; bottom: 20px;
width: 48px; height: 48px; border-radius: 50%;
background: linear-gradient(135deg,#818cf8,#6366f1);
color: #fff; border: none; cursor: pointer;
font-size: 22px; z-index: 9000;
box-shadow: 0 4px 16px rgba(129,140,248,.4);
display: flex; align-items: center; justify-content: center;
transition: transform .2s;
}
#grd-help-btn:hover { transform: scale(1.1); }
@media (max-width: 768px) {
#grd-onboarding {
width: 100%; right: 0; top: auto; bottom: 0;
transform: none; border-radius: 16px 16px 0 0;
border-left: none; border-top: 3px solid #818cf8;
max-height: 65vh;
}
}
`;
document.head.appendChild(style);
document.body.appendChild(panel);
// 이벤트 연결
document.getElementById('grd-ob-minimize').onclick = toggleMinimize;
document.getElementById('grd-ob-close').onclick = closeBotConfirm;
document.getElementById('grd-ob-header').ondblclick = toggleMinimize;
document.getElementById('grd-ob-send').onclick = sendUserMessage;
document.getElementById('grd-ob-input').onkeydown = e => {
if (e.key === 'Enter') sendUserMessage();
};
}
function buildHelpButton() {
if (document.getElementById('grd-help-btn')) return;
const btn = document.createElement('button');
btn.id = 'grd-help-btn';
btn.textContent = '?';
btn.title = 'GUARDiA 도움말';
btn.onclick = () => {
_onboarding_state_dismissed = false;
api('POST', '/api/onboarding/reset').then(() => location.reload());
};
document.body.appendChild(btn);
}
// ── 메시지 렌더링 ─────────────────────────────────────────
function renderMessage(text, isUser = false, actions = []) {
const msgArea = document.getElementById('grd-ob-messages');
if (!msgArea) return;
const msg = document.createElement('div');
msg.className = 'grd-ob-msg' + (isUser ? ' user' : '');
const avi = document.createElement('div');
avi.className = 'grd-ob-msg-avatar';
avi.textContent = isUser ? '👤' : '🤖';
const bubble = document.createElement('div');
bubble.className = 'grd-ob-msg-bubble';
// 마크다운 간단 렌더링
bubble.innerHTML = text
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/`(.+?)`/g, '$1')
.replace(/^```[\s\S]*?```$/gm, m => `${m.replace(/```\w*\n?/g,'').trim()}`)
.replace(/\n/g, '
');
msg.appendChild(avi);
msg.appendChild(bubble);
msgArea.appendChild(msg);
// 액션 버튼
if (actions && actions.length > 0) {
const actDiv = document.createElement('div');
actDiv.className = 'grd-ob-actions';
actions.forEach((act, i) => {
const btn = document.createElement('button');
btn.className = 'grd-ob-action-btn' + (i === 0 ? ' primary' : '');
btn.textContent = act.label;
btn.onclick = () => handleAction(act);
actDiv.appendChild(btn);
});
msgArea.appendChild(actDiv);
}
msgArea.scrollTop = msgArea.scrollHeight;
}
function showTyping() {
const msgArea = document.getElementById('grd-ob-messages');
if (!msgArea) return;
const el = document.createElement('div');
el.className = 'grd-ob-msg';
el.id = 'grd-ob-typing';
el.innerHTML = `
🤖
`;
msgArea.appendChild(el);
msgArea.scrollTop = msgArea.scrollHeight;
}
function hideTyping() {
document.getElementById('grd-ob-typing')?.remove();
}
// ── 단계 로드 ─────────────────────────────────────────────
function loadStep(step) {
if (!step) return;
state.currentStep = step;
// 헤더 업데이트
const sub = document.getElementById('grd-ob-subtitle');
if (sub) sub.textContent = `${step.icon} ${step.title}`;
// 진행 바 업데이트
const pct = Math.round(step.order / 7 * 100);
const fill = document.getElementById('grd-ob-progress-fill');
const label = document.getElementById('grd-ob-progress-label');
if (fill) fill.style.width = pct + '%';
if (label) label.textContent = `${step.order + 1} / 8 단계`;
// 타이핑 효과 후 메시지 표시
showTyping();
setTimeout(() => {
hideTyping();
renderMessage(step.message, false, step.actions);
// 스포트라이트
if (step.target) spotlightElement(step.target);
}, 800);
// 화면 이동 힌트
if (step.view && location.pathname !== step.view.split('?')[0]) {
setTimeout(() => {
renderMessage(`💡 현재 화면: **${location.pathname}**\n이 단계는 **${step.view}** 화면에서 진행됩니다.`, false, [
{ label: `${step.view} 이동`, action: 'navigate', path: step.view }
]);
}, 1500);
}
}
// ── 액션 처리 ─────────────────────────────────────────────
async function handleAction(act) {
const step = state.currentStep;
switch (act.action) {
case 'next':
case 'complete_step':
if (step) {
showTyping();
const result = await postStep(step.id, 'complete');
hideTyping();
if (result?.step) loadStep(result.step);
}
break;
case 'navigate':
if (act.path) {
if (act.path.startsWith('http')) {
window.open(act.path, '_blank');
} else if (act.path.includes('?view=')) {
const viewName = act.path.split('?view=')[1];
// GUARDiA SPA 뷰 전환
const navItem = document.querySelector(`[data-view="${viewName}"]`);
if (navItem) navItem.click();
} else {
location.href = act.path;
}
}
break;
case 'external':
window.open(act.url || act.path, '_blank');
break;
case 'complete':
await completeOnboarding();
renderMessage('🎉 모든 설정이 완료되었습니다!\n이제 GUARDiA의 모든 기능을 사용하세요.\n\n우측 하단 **?** 버튼으로 언제든 가이드를 다시 볼 수 있습니다.', false, []);
setTimeout(() => {
hide();
buildHelpButton();
}, 3000);
break;
case 'skip':
if (step) {
const result = await postStep(step.id, 'skip');
if (result?.step) loadStep(result.step);
}
break;
}
}
// ── 사용자 메시지 전송 ────────────────────────────────────
async function sendUserMessage() {
const input = document.getElementById('grd-ob-input');
if (!input) return;
const text = input.value.trim();
if (!text) return;
input.value = '';
renderMessage(text, true);
showTyping();
const resp = await postMessage(text);
hideTyping();
if (resp?.message) {
renderMessage(resp.message, false);
}
}
// ── 스포트라이트 ──────────────────────────────────────────
function spotlightElement(selector) {
// 기존 스포트라이트 제거
clearSpotlight();
const el = document.querySelector(selector);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(() => {
const rect = el.getBoundingClientRect();
const pad = 6;
const spot = document.createElement('div');
spot.className = 'grd-spotlight';
spot.id = 'grd-spotlight';
spot.style.cssText = `
top: ${rect.top - pad + window.scrollY}px;
left: ${rect.left - pad}px;
width: ${rect.width + pad * 2}px;
height: ${rect.height + pad * 2}px;
`;
document.body.appendChild(spot);
// 8초 후 자동 제거
setTimeout(clearSpotlight, 8000);
}, 400);
}
function clearSpotlight() {
document.getElementById('grd-spotlight')?.remove();
}
// ── 화면 변화 감지 ────────────────────────────────────────
function watchNavigation() {
// SPA 네비게이션 감지 (hash/pushState)
let lastPath = location.pathname;
const observer = new MutationObserver(() => {
if (location.pathname !== lastPath) {
lastPath = location.pathname;
onViewChange(location.pathname);
}
});
observer.observe(document.body, { childList: true, subtree: true });
// GUARDiA nav-item 클릭 감지
document.addEventListener('click', e => {
const navItem = e.target.closest('[data-view]');
if (navItem) {
setTimeout(() => onViewChange(location.pathname, navItem.dataset.view), 300);
}
});
}
function onViewChange(path, viewId) {
clearSpotlight();
const step = state.currentStep;
if (!step || !step.view) return;
const stepView = step.view.split('?')[0];
if (path === stepView || (viewId && step.target?.includes(viewId))) {
// 현재 단계의 화면으로 이동했으면 힌트 표시
setTimeout(() => {
if (step.target) spotlightElement(step.target);
renderMessage(`✅ 좋아요! 지금 **${step.title}** 단계를 진행 중입니다.\n\n${step.message.split('\n')[0]}`, false);
}, 500);
}
}
// ── 패널 제어 ─────────────────────────────────────────────
function show() {
const panel = document.getElementById('grd-onboarding');
if (panel) { panel.style.display = 'flex'; state.visible = true; }
}
function hide() {
const panel = document.getElementById('grd-onboarding');
if (panel) { panel.style.display = 'none'; state.visible = false; }
clearSpotlight();
}
function toggleMinimize() {
const panel = document.getElementById('grd-onboarding');
if (!panel) return;
state.minimized = !state.minimized;
panel.classList.toggle('minimized', state.minimized);
document.getElementById('grd-ob-minimize').textContent = state.minimized ? '□' : '─';
}
function closeBotConfirm() {
if (confirm('온보딩 가이드를 닫을까요?\n언제든 우측하단 ? 버튼으로 다시 열 수 있습니다.')) {
dismissOnboarding();
hide();
buildHelpButton();
}
}
// ── 진입점 ────────────────────────────────────────────────
// DOM 준비 후 실행
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// 로그인 완료 후 토큰이 설정되면 초기화
setTimeout(init, 1000);
}
// 로그인 이벤트 감지 (localStorage 변화)
window.addEventListener('storage', e => {
if (e.key === 'access_token' && e.newValue && !_token) {
_token = e.newValue;
setTimeout(init, 500);
}
});
// 전역 노출 (수동 재시작)
window.GUARDiAOnboarding = { restart: () => { api('POST','/api/onboarding/reset').then(()=>location.reload()); } };
})();