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>
This commit is contained in:
thug0bin 2026-02-25 13:08:02 +09:00
parent a30374cd4a
commit f80c19567a
4 changed files with 1003 additions and 0 deletions

View File

@ -45,6 +45,9 @@ db_manager = DatabaseManager()
# KST 타임존 (UTC+9)
KST = timezone(timedelta(hours=9))
# 키오스크 현재 세션 (메모리 변수, 서버 재시작 시 초기화)
kiosk_current_session = None
def utc_to_kst_str(utc_time_str):
"""
@ -1846,6 +1849,208 @@ def admin():
recent_tokens=recent_tokens)
# ============================================================================
# 키오스크 적립
# ============================================================================
@app.route('/kiosk')
def kiosk():
"""키오스크 메인 페이지 (전체 화면 웹 UI)"""
return render_template('kiosk.html')
@app.route('/api/kiosk/trigger', methods=['POST'])
def api_kiosk_trigger():
"""
POS 키오스크 세션 생성
POST /api/kiosk/trigger
Body: {"transaction_id": "...", "amount": 50000}
"""
global kiosk_current_session
data = request.get_json()
transaction_id = data.get('transaction_id')
amount = data.get('amount', 0)
if not transaction_id:
return jsonify({'success': False, 'message': 'transaction_id가 필요합니다.'}), 400
try:
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# QR 토큰 존재 확인
cursor.execute("SELECT id, token_hash, claimable_points, claimed_at FROM claim_tokens WHERE transaction_id = ?",
(transaction_id,))
token_row = cursor.fetchone()
if token_row and token_row['claimed_at']:
return jsonify({'success': False, 'message': '이미 적립된 거래입니다.'}), 400
if token_row:
# 기존 토큰 사용
claimable_points = token_row['claimable_points']
# nonce 재생성 (기존 토큰의 hash에서 nonce를 알 수 없으므로 QR URL 재생성)
from utils.qr_token_generator import QR_BASE_URL
# 기존 토큰의 nonce를 DB에서 가져올 수 없으므로, 새 nonce로 QR 재생성은 불가
# → 키오스크에서는 QR 대신 transaction_id + nonce를 직접 제공
qr_url = None
else:
# 새 토큰 생성
from utils.qr_token_generator import generate_claim_token, save_token_to_db
token_info = generate_claim_token(transaction_id, float(amount))
success, error = save_token_to_db(
transaction_id,
token_info['token_hash'],
float(amount),
token_info['claimable_points'],
token_info['expires_at'],
token_info['pharmacy_id']
)
if not success:
return jsonify({'success': False, 'message': error}), 500
claimable_points = token_info['claimable_points']
qr_url = token_info['qr_url']
# 키오스크 세션 저장
kiosk_current_session = {
'transaction_id': transaction_id,
'amount': int(amount),
'points': claimable_points,
'qr_url': qr_url,
'created_at': datetime.now(KST).isoformat()
}
return jsonify({
'success': True,
'message': f'키오스크 적립 대기 ({claimable_points}P)',
'points': claimable_points
})
except Exception as e:
logging.error(f"키오스크 트리거 오류: {e}")
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500
@app.route('/api/kiosk/current')
def api_kiosk_current():
"""
키오스크 폴링 - 현재 세션 조회
GET /api/kiosk/current
"""
global kiosk_current_session
if kiosk_current_session is None:
return jsonify({'active': False})
# 5분 경과 시 자동 만료
created = datetime.fromisoformat(kiosk_current_session['created_at'])
if datetime.now(KST) - created > timedelta(minutes=5):
kiosk_current_session = None
return jsonify({'active': False})
return jsonify({
'active': True,
'transaction_id': kiosk_current_session['transaction_id'],
'amount': kiosk_current_session['amount'],
'points': kiosk_current_session['points'],
'qr_url': kiosk_current_session.get('qr_url')
})
@app.route('/api/kiosk/claim', methods=['POST'])
def api_kiosk_claim():
"""
키오스크 전화번호 적립
POST /api/kiosk/claim
Body: {"phone": "01012345678"}
"""
global kiosk_current_session
if kiosk_current_session is None:
return jsonify({'success': False, 'message': '적립 대기 중인 거래가 없습니다.'}), 400
data = request.get_json()
phone = data.get('phone', '').strip().replace('-', '').replace(' ', '')
if len(phone) < 10:
return jsonify({'success': False, 'message': '올바른 전화번호를 입력해주세요.'}), 400
transaction_id = kiosk_current_session['transaction_id']
try:
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# claim_tokens에서 nonce 조회를 위해 token_hash로 검증
cursor.execute("""
SELECT id, transaction_id, token_hash, total_amount, claimable_points,
pharmacy_id, expires_at, claimed_at, claimed_by_user_id
FROM claim_tokens WHERE transaction_id = ?
""", (transaction_id,))
token_row = cursor.fetchone()
if not token_row:
return jsonify({'success': False, 'message': '토큰을 찾을 수 없습니다.'}), 400
if token_row['claimed_at']:
kiosk_current_session = None
return jsonify({'success': False, 'message': '이미 적립된 거래입니다.'}), 400
# 만료 확인
expires_at = datetime.strptime(token_row['expires_at'], '%Y-%m-%d %H:%M:%S')
if datetime.now() > expires_at:
kiosk_current_session = None
return jsonify({'success': False, 'message': '만료된 거래입니다.'}), 400
# token_info 딕셔너리 구성 (claim_mileage 호환)
token_info = {
'id': token_row['id'],
'transaction_id': token_row['transaction_id'],
'total_amount': token_row['total_amount'],
'claimable_points': token_row['claimable_points']
}
# 사용자 조회/생성
user_id, is_new = get_or_create_user(phone, '고객')
# 마일리지 적립
claim_success, claim_msg, new_balance = claim_mileage(user_id, token_info)
if not claim_success:
return jsonify({'success': False, 'message': claim_msg}), 500
# 키오스크 세션 클리어
claimed_points = token_info['claimable_points']
kiosk_current_session = None
# 알림톡 발송 (fire-and-forget)
try:
from services.nhn_alimtalk import send_mileage_claim_alimtalk
# 유저 이름 조회 (기존 유저는 실명이 있을 수 있음)
cursor.execute("SELECT nickname FROM users WHERE id = ?", (user_id,))
user_row = cursor.fetchone()
user_name = user_row['nickname'] if user_row else '고객'
send_mileage_claim_alimtalk(phone, user_name, claimed_points, new_balance)
except Exception as alimtalk_err:
logging.warning(f"알림톡 발송 실패 (적립은 완료): {alimtalk_err}")
return jsonify({
'success': True,
'message': f'{claimed_points}P 적립 완료!',
'points': claimed_points,
'balance': new_balance,
'is_new_user': is_new
})
except Exception as e:
logging.error(f"키오스크 적립 오류: {e}")
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500
if __name__ == '__main__':
# 개발 모드로 실행
app.run(host='0.0.0.0', port=7001, debug=True)

View File

@ -7,6 +7,18 @@ import sys
import os
import json
from datetime import datetime
# Qt 플랫폼 플러그인 경로 자동 설정 (PyQt5 import 전에 반드시 설정)
if not os.environ.get('QT_QPA_PLATFORM_PLUGIN_PATH'):
import importlib.util
_spec = importlib.util.find_spec('PyQt5')
if _spec and _spec.origin:
_pyqt5_plugins = os.path.join(
os.path.dirname(_spec.origin), 'Qt5', 'plugins', 'platforms'
)
if os.path.isdir(_pyqt5_plugins):
os.environ['QT_QPA_PLATFORM_PLUGIN_PATH'] = _pyqt5_plugins
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
@ -624,6 +636,14 @@ class POSSalesGUI(QMainWindow):
self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결
settings_layout.addWidget(self.qr_btn)
# 키오스크 적립 버튼
self.kiosk_btn = QPushButton('키오스크 적립')
self.kiosk_btn.setStyleSheet(
'background-color: #6366f1; color: white; padding: 8px; font-weight: bold;')
self.kiosk_btn.setToolTip('선택된 거래를 키오스크 화면에 표시')
self.kiosk_btn.clicked.connect(self.trigger_kiosk_claim)
settings_layout.addWidget(self.kiosk_btn)
# 미리보기 모드 체크박스 추가
self.preview_checkbox = QCheckBox('미리보기 모드')
self.preview_checkbox.setChecked(True) # 기본값: 미리보기
@ -928,6 +948,35 @@ class POSSalesGUI(QMainWindow):
except:
return False
def trigger_kiosk_claim(self):
"""선택된 판매 건을 키오스크에 표시"""
current_row = self.sales_table.currentRow()
if current_row < 0:
QMessageBox.warning(self, '경고', '거래를 선택해주세요.')
return
order_no = self.sales_table.item(current_row, 0).text()
amount_text = self.sales_table.item(current_row, 2).text()
amount = float(amount_text.replace(',', '').replace('', ''))
try:
import requests as req
resp = req.post(
'http://localhost:7001/api/kiosk/trigger',
json={'transaction_id': order_no, 'amount': amount},
timeout=5
)
result = resp.json()
if result.get('success'):
self.status_label.setText(f'키오스크 적립 대기 중 ({result.get("points", 0)}P)')
self.status_label.setStyleSheet(
'color: #6366f1; font-size: 12px; padding: 5px; font-weight: bold;')
else:
QMessageBox.warning(self, '키오스크', result.get('message', '전송 실패'))
except Exception as e:
QMessageBox.critical(self, '오류', f'Flask 서버 연결 실패:\n{str(e)}')
def generate_qr_label(self):
"""선택된 판매 건에 대해 QR 라벨 생성"""
# 선택된 행 확인

View File

@ -0,0 +1,111 @@
"""
NHN Cloud 알림톡 발송 서비스
마일리지 적립 완료 알림톡 발송
"""
import os
import logging
from datetime import datetime, timezone, timedelta
import requests
logger = logging.getLogger(__name__)
# NHN Cloud 알림톡 설정
APPKEY = os.getenv('NHN_ALIMTALK_APPKEY', 'u0TLUaXXY9bfQFkY')
SECRET_KEY = os.getenv('NHN_ALIMTALK_SECRET', 'naraGEUJfpkRu1fgirKewJtwADqWQ5gY')
SENDER_KEY = os.getenv('NHN_ALIMTALK_SENDER', '341352077bce225195ccc2697fb449f723e70982')
API_BASE = f'https://api-alimtalk.cloud.toast.com/alimtalk/v2.3/appkeys/{APPKEY}'
# KST 타임존
KST = timezone(timedelta(hours=9))
def _send_alimtalk(template_code, recipient_no, template_params):
"""
알림톡 발송 공통 함수
Args:
template_code: 템플릿 코드
recipient_no: 수신 번호 (01012345678)
template_params: 템플릿 변수 딕셔너리
Returns:
tuple: (성공 여부, 메시지)
"""
url = f'{API_BASE}/messages'
headers = {
'Content-Type': 'application/json;charset=UTF-8',
'X-Secret-Key': SECRET_KEY
}
data = {
'senderKey': SENDER_KEY,
'templateCode': template_code,
'recipientList': [
{
'recipientNo': recipient_no,
'templateParameter': template_params
}
]
}
try:
resp = requests.post(url, headers=headers, json=data, timeout=10)
result = resp.json()
if resp.status_code == 200 and result.get('header', {}).get('isSuccessful'):
logger.info(f"알림톡 발송 성공: {template_code}{recipient_no}")
return (True, "발송 성공")
else:
error_msg = result.get('header', {}).get('resultMessage', str(result))
logger.warning(f"알림톡 발송 실패: {template_code}{recipient_no}: {error_msg}")
return (False, error_msg)
except requests.exceptions.Timeout:
logger.warning(f"알림톡 발송 타임아웃: {template_code}{recipient_no}")
return (False, "타임아웃")
except Exception as e:
logger.warning(f"알림톡 발송 오류: {template_code}{recipient_no}: {e}")
return (False, str(e))
def send_mileage_claim_alimtalk(phone, name, points, balance):
"""
마일리지 적립 완료 알림톡 발송
Args:
phone: 수신 전화번호 (01012345678)
name: 고객명
points: 적립 포인트
balance: 적립 잔액
Returns:
tuple: (성공 여부, 메시지)
"""
now_kst = datetime.now(KST).strftime('%Y-%m-%d %H:%M')
# MILEAGE_CLAIM_V2 (버튼 포함 버전) 우선 시도
template_code = 'MILEAGE_CLAIM_V2'
params = {
'고객명': name,
'적립포인트': f'{points:,}',
'총잔액': f'{balance:,}',
'적립일시': now_kst,
'전화번호': phone
}
success, msg = _send_alimtalk(template_code, phone, params)
if not success:
# V2 실패 시 V1 (버튼 없는 버전) 시도
template_code = 'MILEAGE_CLAIM'
params_v1 = {
'고객명': name,
'적립포인트': f'{points:,}',
'총잔액': f'{balance:,}',
'적립일시': now_kst
}
success, msg = _send_alimtalk(template_code, phone, params_v1)
return (success, msg)

View File

@ -0,0 +1,638 @@
<!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>