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:
parent
a30374cd4a
commit
f80c19567a
205
backend/app.py
205
backend/app.py
@ -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)
|
||||
|
||||
@ -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 라벨 생성"""
|
||||
# 선택된 행 확인
|
||||
|
||||
111
backend/services/nhn_alimtalk.py
Normal file
111
backend/services/nhn_alimtalk.py
Normal 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)
|
||||
638
backend/templates/kiosk.html
Normal file
638
backend/templates/kiosk.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user