feat: AI CRM 어드민 대시보드 + 바텀시트 드래그 닫기 + UTF-8 인코딩 + 문서화
- /admin/ai-crm: AI 업셀링 추천 생성 현황 대시보드 (통계 카드 + 로그 테이블 + 아코디언 상세) - 마이페이지 바텀시트: 터치 드래그로 닫기 기능 추가 (80px 임계값) - Windows 콘솔 UTF-8 인코딩 강제 (app.py, clawdbot_client.py) - admin.html 헤더에 AI CRM 네비 링크 추가 - docs: ai-upselling-crm.md, windows-utf8-encoding.md 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -396,16 +396,21 @@
|
||||
<!-- AI 추천 바텀시트 -->
|
||||
<div id="rec-sheet" style="display:none;">
|
||||
<div id="rec-backdrop" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:999;animation:recFadeIn .3s ease;"></div>
|
||||
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:12px 24px 32px;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);">
|
||||
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
|
||||
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
|
||||
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:0 0 0;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);touch-action:none;">
|
||||
<!-- 드래그 핸들 영역 -->
|
||||
<div id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
|
||||
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
|
||||
<button onclick="dismissRec()" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
|
||||
<button onclick="dismissRec()" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
|
||||
<div style="padding:0 24px 32px;">
|
||||
<div style="text-align:center;padding:8px 0 20px;">
|
||||
<div style="font-size:48px;margin-bottom:16px;">💊</div>
|
||||
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
|
||||
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
|
||||
<button onclick="dismissRec()" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
|
||||
<button onclick="dismissRec()" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,6 +421,87 @@
|
||||
</style>
|
||||
<script>
|
||||
let _recId = null;
|
||||
|
||||
// ── 드래그 닫기 ──
|
||||
(function() {
|
||||
let startY = 0, currentY = 0, isDragging = false;
|
||||
const DISMISS_THRESHOLD = 80;
|
||||
|
||||
function getContent() { return document.getElementById('rec-content'); }
|
||||
function getBackdrop() { return document.getElementById('rec-backdrop'); }
|
||||
|
||||
function onStart(y) {
|
||||
const c = getContent();
|
||||
if (!c) return;
|
||||
isDragging = true;
|
||||
startY = y;
|
||||
currentY = 0;
|
||||
c.style.animation = 'none';
|
||||
c.style.transition = 'none';
|
||||
}
|
||||
function onMove(y) {
|
||||
if (!isDragging) return;
|
||||
const c = getContent();
|
||||
const b = getBackdrop();
|
||||
currentY = Math.max(0, y - startY); // 아래로만
|
||||
c.style.transform = 'translate(-50%, ' + currentY + 'px)';
|
||||
// 배경 투명도도 같이
|
||||
const opacity = Math.max(0, 0.3 * (1 - currentY / 300));
|
||||
b.style.background = 'rgba(0,0,0,' + opacity + ')';
|
||||
}
|
||||
function onEnd() {
|
||||
if (!isDragging) return;
|
||||
isDragging = false;
|
||||
const c = getContent();
|
||||
if (currentY > DISMISS_THRESHOLD) {
|
||||
// 충분히 내렸으면 닫기
|
||||
c.style.transition = 'transform .25s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
getBackdrop().style.transition = 'opacity .25s';
|
||||
getBackdrop().style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
document.getElementById('rec-sheet').style.display = 'none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 250);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
|
||||
} else {
|
||||
// 복귀
|
||||
c.style.transition = 'transform .25s cubic-bezier(.16,1,.3,1)';
|
||||
c.style.transform = 'translate(-50%, 0)';
|
||||
getBackdrop().style.transition = 'background .25s';
|
||||
getBackdrop().style.background = 'rgba(0,0,0,0.3)';
|
||||
setTimeout(function() { c.style.transition = ''; }, 250);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var el = document.getElementById('rec-content');
|
||||
if (!el) return;
|
||||
|
||||
// 터치 (모바일)
|
||||
el.addEventListener('touchstart', function(e) {
|
||||
onStart(e.touches[0].clientY);
|
||||
}, {passive: true});
|
||||
el.addEventListener('touchmove', function(e) {
|
||||
if (isDragging && currentY > 0) e.preventDefault();
|
||||
onMove(e.touches[0].clientY);
|
||||
}, {passive: false});
|
||||
el.addEventListener('touchend', onEnd);
|
||||
|
||||
// 마우스 (데스크톱 테스트용)
|
||||
el.addEventListener('mousedown', function(e) {
|
||||
if (e.target.tagName === 'BUTTON') return;
|
||||
onStart(e.clientY);
|
||||
});
|
||||
document.addEventListener('mousemove', function(e) {
|
||||
if (isDragging) onMove(e.clientY);
|
||||
});
|
||||
document.addEventListener('mouseup', onEnd);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 추천 로드 ──
|
||||
window.addEventListener('load', function() {
|
||||
{% if user_id %}
|
||||
setTimeout(async function() {
|
||||
@@ -429,17 +515,25 @@
|
||||
document.getElementById('rec-sheet').style.display = 'block';
|
||||
document.getElementById('rec-backdrop').onclick = dismissRec;
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch(e) {
|
||||
console.error('[AI추천] 에러:', e);
|
||||
}
|
||||
}, 1500);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function dismissRec() {
|
||||
const c = document.getElementById('rec-content');
|
||||
const b = document.getElementById('rec-backdrop');
|
||||
c.style.animation = 'recSlideDown .3s ease forwards';
|
||||
c.style.transition = 'transform .3s ease';
|
||||
c.style.transform = 'translate(-50%, 100%)';
|
||||
b.style.opacity = '0';
|
||||
b.style.transition = 'opacity .3s';
|
||||
setTimeout(function(){ document.getElementById('rec-sheet').style.display='none'; }, 300);
|
||||
setTimeout(function(){
|
||||
document.getElementById('rec-sheet').style.display='none';
|
||||
c.style.transition = '';
|
||||
c.style.transform = '';
|
||||
}, 300);
|
||||
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user