- 키오스크 전체화면 웹 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>
639 lines
20 KiB
HTML
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>
|