pharmacy-pos-qr-system/backend/templates/kiosk.html
thug0bin f80c19567a feat: 키오스크 마일리지 적립 시스템 추가
- 키오스크 전체화면 웹 UI (/kiosk) - QR 표시 + 전화번호 숫자패드 입력
- 키오스크 API 4개 (trigger, current, claim, kiosk 페이지)
- POS GUI에 "키오스크 적립" 버튼 추가 (Flask 서버로 HTTP 트리거)
- NHN Cloud 알림톡 발송 모듈 (적립 완료 시 자동 발송)
- Qt 플랫폼 플러그인 경로 자동 설정 (no Qt platform plugin 에러 해결)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:08:02 +09:00

639 lines
20 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>키오스크 적립 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f7fa;
height: 100vh;
overflow: hidden;
-webkit-font-smoothing: antialiased;
user-select: none;
}
/* ── 헤더 ── */
.header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 20px 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-logo {
color: #fff;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
}
.header-time {
color: rgba(255,255,255,0.8);
font-size: 16px;
}
/* ── 메인 컨텐츠 ── */
.main {
height: calc(100vh - 70px);
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
}
/* ── 화면 상태 ── */
.screen { display: none; width: 100%; max-width: 900px; }
.screen.active { display: flex; }
/* ══════════════════════════════════════
대기 화면
══════════════════════════════════════ */
.idle-screen {
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 24px;
}
.idle-icon {
width: 120px;
height: 120px;
background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%);
border-radius: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 56px;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.9; }
50% { transform: scale(1.05); opacity: 1; }
}
.idle-title {
font-size: 32px;
font-weight: 700;
color: #1e1b4b;
letter-spacing: -0.5px;
}
.idle-subtitle {
font-size: 18px;
color: #6b7280;
line-height: 1.6;
}
/* ══════════════════════════════════════
적립 화면
══════════════════════════════════════ */
.claim-screen {
flex-direction: row;
gap: 48px;
align-items: stretch;
}
/* 왼쪽: QR + 안내 */
.claim-left {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
}
.claim-info-card {
background: #fff;
border-radius: 20px;
padding: 28px 36px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
text-align: center;
width: 100%;
}
.claim-amount-label {
font-size: 15px;
color: #6b7280;
margin-bottom: 4px;
}
.claim-amount {
font-size: 36px;
font-weight: 900;
color: #1e1b4b;
letter-spacing: -1px;
}
.claim-points {
font-size: 20px;
color: #6366f1;
font-weight: 700;
margin-top: 8px;
}
.qr-container {
background: #fff;
border-radius: 20px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
}
.qr-container img {
width: 200px;
height: 200px;
}
.qr-hint {
font-size: 15px;
color: #6b7280;
text-align: center;
margin-top: 12px;
}
/* 구분선 */
.divider {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.divider-line {
width: 2px;
height: 80px;
background: #e5e7eb;
}
.divider-text {
font-size: 16px;
color: #9ca3af;
font-weight: 500;
background: #f5f7fa;
padding: 8px 0;
}
/* 오른쪽: 전화번호 입력 */
.claim-right {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
}
.phone-section-title {
font-size: 20px;
font-weight: 700;
color: #1e1b4b;
}
.phone-display {
width: 100%;
max-width: 360px;
background: #fff;
border: 3px solid #e5e7eb;
border-radius: 16px;
padding: 16px 24px;
font-size: 32px;
font-weight: 700;
text-align: center;
color: #1e1b4b;
letter-spacing: 2px;
min-height: 68px;
transition: border-color 0.2s;
}
.phone-display.focus {
border-color: #6366f1;
}
.phone-display.error {
border-color: #ef4444;
animation: shake 0.3s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
75% { transform: translateX(8px); }
}
/* 숫자 패드 */
.numpad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
width: 100%;
max-width: 360px;
}
.numpad-btn {
background: #fff;
border: 2px solid #e5e7eb;
border-radius: 14px;
padding: 18px;
font-size: 28px;
font-weight: 700;
color: #1e1b4b;
cursor: pointer;
transition: all 0.1s;
font-family: inherit;
}
.numpad-btn:active {
background: #6366f1;
color: #fff;
border-color: #6366f1;
transform: scale(0.95);
}
.numpad-btn.delete {
background: #fef2f2;
border-color: #fecaca;
color: #ef4444;
font-size: 20px;
}
.numpad-btn.delete:active {
background: #ef4444;
color: #fff;
}
.numpad-btn.clear {
background: #f5f5f5;
border-color: #d4d4d4;
color: #737373;
font-size: 16px;
}
.numpad-btn.clear:active {
background: #737373;
color: #fff;
}
/* 적립 버튼 */
.submit-btn {
width: 100%;
max-width: 360px;
padding: 18px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
border: none;
border-radius: 14px;
font-size: 22px;
font-weight: 700;
cursor: pointer;
font-family: inherit;
transition: all 0.2s;
letter-spacing: -0.3px;
}
.submit-btn:active {
transform: scale(0.97);
}
.submit-btn:disabled {
background: #d1d5db;
cursor: not-allowed;
}
/* ══════════════════════════════════════
성공 화면
══════════════════════════════════════ */
.success-screen {
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 20px;
}
.success-icon {
width: 120px;
height: 120px;
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 60px;
animation: pop 0.4s ease-out;
}
@keyframes pop {
0% { transform: scale(0); }
80% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.success-title {
font-size: 36px;
font-weight: 900;
color: #16a34a;
}
.success-points {
font-size: 48px;
font-weight: 900;
color: #1e1b4b;
letter-spacing: -1px;
}
.success-balance {
font-size: 20px;
color: #6b7280;
}
.success-balance strong {
color: #6366f1;
font-weight: 700;
}
.success-countdown {
font-size: 15px;
color: #9ca3af;
margin-top: 8px;
}
/* ── 에러 메시지 ── */
.error-msg {
color: #ef4444;
font-size: 15px;
font-weight: 500;
min-height: 22px;
}
/* ── 로딩 ── */
.loading-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.loading-overlay.active { display: flex; }
.loading-spinner {
width: 60px;
height: 60px;
border: 6px solid #e5e7eb;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── 반응형 (세로 모드) ── */
@media (max-width: 700px) {
.claim-screen { flex-direction: column; gap: 24px; }
.divider { flex-direction: row; }
.divider-line { width: 80px; height: 2px; }
.claim-amount { font-size: 28px; }
.qr-container img { width: 150px; height: 150px; }
}
</style>
</head>
<body>
<!-- 헤더 -->
<div class="header">
<div class="header-logo">청춘약국 마일리지</div>
<div class="header-time" id="headerTime"></div>
</div>
<!-- 메인 -->
<div class="main">
<!-- 1. 대기 화면 -->
<div class="screen idle-screen active" id="idleScreen">
<div class="idle-icon">💊</div>
<div class="idle-title">마일리지 적립</div>
<div class="idle-subtitle">
약사님이 적립을 시작하면<br>
이 화면에서 바로 적립할 수 있습니다
</div>
</div>
<!-- 2. 적립 화면 -->
<div class="screen claim-screen" id="claimScreen">
<!-- 왼쪽: QR + 금액 -->
<div class="claim-left">
<div class="claim-info-card">
<div class="claim-amount-label">결제 금액</div>
<div class="claim-amount" id="claimAmount">0원</div>
<div class="claim-points">적립 <span id="claimPoints">0</span>P</div>
</div>
<div class="qr-container" id="qrContainer" style="display:none;">
<img id="qrImage" src="" alt="QR Code">
<div class="qr-hint">휴대폰으로 QR을 스캔하여<br>적립할 수도 있습니다</div>
</div>
</div>
<!-- 구분선 -->
<div class="divider" id="dividerEl" style="display:none;">
<div class="divider-line"></div>
<div class="divider-text">또는</div>
<div class="divider-line"></div>
</div>
<!-- 오른쪽: 전화번호 입력 -->
<div class="claim-right">
<div class="phone-section-title">전화번호로 적립하기</div>
<div class="phone-display" id="phoneDisplay">-</div>
<div class="error-msg" id="errorMsg"></div>
<div class="numpad">
<button class="numpad-btn" onclick="numPress('1')">1</button>
<button class="numpad-btn" onclick="numPress('2')">2</button>
<button class="numpad-btn" onclick="numPress('3')">3</button>
<button class="numpad-btn" onclick="numPress('4')">4</button>
<button class="numpad-btn" onclick="numPress('5')">5</button>
<button class="numpad-btn" onclick="numPress('6')">6</button>
<button class="numpad-btn" onclick="numPress('7')">7</button>
<button class="numpad-btn" onclick="numPress('8')">8</button>
<button class="numpad-btn" onclick="numPress('9')">9</button>
<button class="numpad-btn clear" onclick="numClear()">전체삭제</button>
<button class="numpad-btn" onclick="numPress('0')">0</button>
<button class="numpad-btn delete" onclick="numDelete()">← 삭제</button>
</div>
<button class="submit-btn" id="submitBtn" onclick="submitClaim()" disabled>적립하기</button>
</div>
</div>
<!-- 3. 성공 화면 -->
<div class="screen success-screen" id="successScreen">
<div class="success-icon"></div>
<div class="success-title">적립 완료!</div>
<div class="success-points" id="successPoints">0P</div>
<div class="success-balance">총 잔액: <strong id="successBalance">0P</strong></div>
<div class="success-countdown" id="successCountdown"></div>
</div>
</div>
<!-- 로딩 오버레이 -->
<div class="loading-overlay" id="loadingOverlay">
<div class="loading-spinner"></div>
</div>
<script>
// ── 상태 관리 ──
let phoneNumber = '';
let currentSession = null;
let pollingInterval = null;
let successTimeout = null;
// ── 화면 전환 ──
function showScreen(name) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById(name + 'Screen').classList.add('active');
}
// ── 시계 ──
function updateClock() {
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
document.getElementById('headerTime').textContent = h + ':' + m;
}
updateClock();
setInterval(updateClock, 30000);
// ── 전화번호 포맷 (010-1234-5678) ──
function formatPhone(num) {
if (num.length <= 3) return num;
if (num.length <= 7) return num.slice(0, 3) + '-' + num.slice(3);
return num.slice(0, 3) + '-' + num.slice(3, 7) + '-' + num.slice(7);
}
function updatePhoneDisplay() {
const display = document.getElementById('phoneDisplay');
const btn = document.getElementById('submitBtn');
if (phoneNumber.length === 0) {
display.textContent = '-';
display.classList.remove('focus');
btn.disabled = true;
} else {
display.textContent = formatPhone(phoneNumber);
display.classList.add('focus');
btn.disabled = phoneNumber.length < 10;
}
display.classList.remove('error');
document.getElementById('errorMsg').textContent = '';
}
// ── 숫자 패드 ──
function numPress(digit) {
if (phoneNumber.length >= 11) return;
phoneNumber += digit;
updatePhoneDisplay();
}
function numDelete() {
phoneNumber = phoneNumber.slice(0, -1);
updatePhoneDisplay();
}
function numClear() {
phoneNumber = '';
updatePhoneDisplay();
}
// ── 적립 제출 ──
async function submitClaim() {
if (phoneNumber.length < 10) {
document.getElementById('phoneDisplay').classList.add('error');
document.getElementById('errorMsg').textContent = '전화번호를 정확히 입력해주세요';
return;
}
document.getElementById('loadingOverlay').classList.add('active');
document.getElementById('submitBtn').disabled = true;
try {
const resp = await fetch('/api/kiosk/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: phoneNumber })
});
const data = await resp.json();
document.getElementById('loadingOverlay').classList.remove('active');
if (data.success) {
showSuccess(data.points, data.balance);
} else {
document.getElementById('phoneDisplay').classList.add('error');
document.getElementById('errorMsg').textContent = data.message || '적립 실패';
document.getElementById('submitBtn').disabled = false;
}
} catch (err) {
document.getElementById('loadingOverlay').classList.remove('active');
document.getElementById('errorMsg').textContent = '서버 연결 실패';
document.getElementById('submitBtn').disabled = false;
}
}
// ── 성공 화면 ──
function showSuccess(points, balance) {
document.getElementById('successPoints').textContent = points.toLocaleString() + 'P';
document.getElementById('successBalance').textContent = balance.toLocaleString() + 'P';
showScreen('success');
// 5초 카운트다운 후 대기 화면
let countdown = 5;
const el = document.getElementById('successCountdown');
el.textContent = countdown + '초 후 처음 화면으로 돌아갑니다';
if (successTimeout) clearInterval(successTimeout);
successTimeout = setInterval(() => {
countdown--;
if (countdown <= 0) {
clearInterval(successTimeout);
resetToIdle();
} else {
el.textContent = countdown + '초 후 처음 화면으로 돌아갑니다';
}
}, 1000);
}
// ── 대기 화면 복귀 ──
function resetToIdle() {
phoneNumber = '';
currentSession = null;
updatePhoneDisplay();
showScreen('idle');
}
// ── 폴링: 키오스크 세션 확인 (2초) ──
async function pollKioskSession() {
try {
const resp = await fetch('/api/kiosk/current');
const data = await resp.json();
if (data.active && !currentSession) {
// 새 세션 감지 → 적립 화면 전환
currentSession = data;
phoneNumber = '';
updatePhoneDisplay();
// 금액, 포인트 표시
document.getElementById('claimAmount').textContent =
data.amount.toLocaleString() + '원';
document.getElementById('claimPoints').textContent =
data.points.toLocaleString();
// QR 코드 (있으면 표시)
if (data.qr_url) {
document.getElementById('qrImage').src =
'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' +
encodeURIComponent(data.qr_url);
document.getElementById('qrContainer').style.display = '';
document.getElementById('dividerEl').style.display = '';
} else {
document.getElementById('qrContainer').style.display = 'none';
document.getElementById('dividerEl').style.display = 'none';
}
showScreen('claim');
} else if (!data.active && currentSession) {
// 세션 종료 (다른 곳에서 적립 완료 등)
resetToIdle();
}
} catch (err) {
// 네트워크 오류 시 무시 (다음 폴링에서 재시도)
}
}
// ── 폴링 시작 ──
pollingInterval = setInterval(pollKioskSession, 1000);
pollKioskSession(); // 즉시 1회 실행
</script>
</body>
</html>