Compare commits
No commits in common. "cb927d2207c219282ca9decbd1348890d7fc1185" and "a30374cd4a3cd026883bee4e313329b123426d99" have entirely different histories.
cb927d2207
...
a30374cd4a
231
backend/app.py
231
backend/app.py
@ -45,9 +45,6 @@ db_manager = DatabaseManager()
|
|||||||
# KST 타임존 (UTC+9)
|
# KST 타임존 (UTC+9)
|
||||||
KST = timezone(timedelta(hours=9))
|
KST = timezone(timedelta(hours=9))
|
||||||
|
|
||||||
# 키오스크 현재 세션 (메모리 변수, 서버 재시작 시 초기화)
|
|
||||||
kiosk_current_session = None
|
|
||||||
|
|
||||||
|
|
||||||
def utc_to_kst_str(utc_time_str):
|
def utc_to_kst_str(utc_time_str):
|
||||||
"""
|
"""
|
||||||
@ -1791,8 +1788,7 @@ def admin():
|
|||||||
ml.balance_after,
|
ml.balance_after,
|
||||||
ml.reason,
|
ml.reason,
|
||||||
ml.description,
|
ml.description,
|
||||||
ml.created_at,
|
ml.created_at
|
||||||
ml.transaction_id
|
|
||||||
FROM mileage_ledger ml
|
FROM mileage_ledger ml
|
||||||
JOIN users u ON ml.user_id = u.id
|
JOIN users u ON ml.user_id = u.id
|
||||||
ORDER BY ml.created_at DESC
|
ORDER BY ml.created_at DESC
|
||||||
@ -1850,231 +1846,6 @@ def admin():
|
|||||||
recent_tokens=recent_tokens)
|
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:
|
|
||||||
# 기존 토큰 사용 — QR URL은 새 nonce로 생성
|
|
||||||
# (verify_claim_token은 transaction_id로만 조회하므로 nonce 불일치 무관)
|
|
||||||
claimable_points = token_row['claimable_points']
|
|
||||||
nonce = secrets.token_hex(6)
|
|
||||||
from utils.qr_token_generator import QR_BASE_URL
|
|
||||||
qr_url = f"{QR_BASE_URL}?t={transaction_id}:{nonce}"
|
|
||||||
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']
|
|
||||||
|
|
||||||
# MSSQL에서 구매 품목 조회
|
|
||||||
sale_items = []
|
|
||||||
try:
|
|
||||||
mssql_session = db_manager.get_session('PM_PRES')
|
|
||||||
sale_sub_query = text("""
|
|
||||||
SELECT
|
|
||||||
ISNULL(G.GoodsName, '(약품명 없음)') AS goods_name,
|
|
||||||
S.SL_NM_item AS quantity,
|
|
||||||
S.SL_TOTAL_PRICE AS total
|
|
||||||
FROM SALE_SUB S
|
|
||||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
|
||||||
WHERE S.SL_NO_order = :transaction_id
|
|
||||||
ORDER BY S.DrugCode
|
|
||||||
""")
|
|
||||||
rows = mssql_session.execute(sale_sub_query, {'transaction_id': transaction_id}).fetchall()
|
|
||||||
sale_items = [
|
|
||||||
{'name': r.goods_name, 'qty': int(r.quantity or 0), 'total': int(r.total or 0)}
|
|
||||||
for r in rows
|
|
||||||
]
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"키오스크 품목 조회 실패 (transaction_id={transaction_id}): {e}")
|
|
||||||
|
|
||||||
# 키오스크 세션 저장
|
|
||||||
kiosk_current_session = {
|
|
||||||
'transaction_id': transaction_id,
|
|
||||||
'amount': int(amount),
|
|
||||||
'points': claimable_points,
|
|
||||||
'qr_url': qr_url,
|
|
||||||
'items': sale_items,
|
|
||||||
'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'),
|
|
||||||
'items': kiosk_current_session.get('items', [])
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@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__':
|
if __name__ == '__main__':
|
||||||
# 개발 모드로 실행
|
# 개발 모드로 실행
|
||||||
app.run(host='0.0.0.0', port=7001, debug=True)
|
app.run(host='0.0.0.0', port=7001, debug=True)
|
||||||
|
|||||||
@ -7,18 +7,6 @@ import sys
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
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 (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
|
QPushButton, QLabel, QGroupBox, QTableWidget, QTableWidgetItem,
|
||||||
@ -636,14 +624,6 @@ class POSSalesGUI(QMainWindow):
|
|||||||
self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결
|
self.qr_btn.clicked.connect(self.generate_qr_label) # 이벤트 연결
|
||||||
settings_layout.addWidget(self.qr_btn)
|
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 = QCheckBox('미리보기 모드')
|
||||||
self.preview_checkbox.setChecked(True) # 기본값: 미리보기
|
self.preview_checkbox.setChecked(True) # 기본값: 미리보기
|
||||||
@ -948,35 +928,6 @@ class POSSalesGUI(QMainWindow):
|
|||||||
except:
|
except:
|
||||||
return False
|
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):
|
def generate_qr_label(self):
|
||||||
"""선택된 판매 건에 대해 QR 라벨 생성"""
|
"""선택된 판매 건에 대해 QR 라벨 생성"""
|
||||||
# 선택된 행 확인
|
# 선택된 행 확인
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@ -202,11 +202,6 @@
|
|||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section table tbody tr[onclick]:hover {
|
|
||||||
background: #eef2ff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 사이드바 레이아웃 */
|
/* 사이드바 레이아웃 */
|
||||||
.layout-wrapper {
|
.layout-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -500,12 +495,12 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for tx in recent_transactions %}
|
{% for tx in recent_transactions %}
|
||||||
<tr{% if tx.transaction_id %} onclick="showTransactionDetail('{{ tx.transaction_id }}')" style="cursor: pointer;" title="클릭하여 품목 상세 보기"{% endif %}>
|
<tr>
|
||||||
<td>{{ tx.nickname }}</td>
|
<td>{{ tx.nickname }}</td>
|
||||||
<td class="phone-masked">{{ tx.phone[:3] }}-{{ tx.phone[3:7] }}-{{ tx.phone[7:] if tx.phone|length > 7 else '' }}</td>
|
<td class="phone-masked">{{ tx.phone[:3] }}-{{ tx.phone[3:7] }}-{{ tx.phone[7:] if tx.phone|length > 7 else '' }}</td>
|
||||||
<td class="points-positive">{{ "{:,}".format(tx.points) }}P</td>
|
<td class="points-positive">{{ "{:,}".format(tx.points) }}P</td>
|
||||||
<td>{{ "{:,}".format(tx.balance_after) }}P</td>
|
<td>{{ "{:,}".format(tx.balance_after) }}P</td>
|
||||||
<td>{{ tx.description or tx.reason }}{% if tx.transaction_id %} <span style="color: #6366f1; font-size: 12px;">🔍</span>{% endif %}</td>
|
<td>{{ tx.description or tx.reason }}</td>
|
||||||
<td>{{ tx.created_at[:16].replace('T', ' ') }}</td>
|
<td>{{ tx.created_at[:16].replace('T', ' ') }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -1,798 +0,0 @@
|
|||||||
<!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: #0f0b2e;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 헤더 ── */
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
||||||
padding: 16px 32px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.header-logo { color: #fff; font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
|
|
||||||
.header-time { color: rgba(255,255,255,0.7); font-size: 15px; }
|
|
||||||
|
|
||||||
/* ── 메인 ── */
|
|
||||||
.main {
|
|
||||||
height: calc(100vh - 56px);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 24px;
|
|
||||||
position: relative;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.screen { display: none; width: 100%; }
|
|
||||||
.screen.active { display: flex; }
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════
|
|
||||||
대기 화면 - 슬라이드쇼 + 브랜딩
|
|
||||||
══════════════════════════════════════ */
|
|
||||||
.idle-screen {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 슬라이드 컨테이너 */
|
|
||||||
.slides-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 780px;
|
|
||||||
position: relative;
|
|
||||||
height: 380px;
|
|
||||||
}
|
|
||||||
.slide {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 20px;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(60px);
|
|
||||||
transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.slide.active {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
.slide.exit {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-60px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.slide-icon {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
border-radius: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 52px;
|
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
.slide-tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 6px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.slide-title {
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #fff;
|
|
||||||
letter-spacing: -0.8px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.slide-desc {
|
|
||||||
font-size: 17px;
|
|
||||||
color: rgba(255,255,255,0.65);
|
|
||||||
line-height: 1.6;
|
|
||||||
max-width: 500px;
|
|
||||||
}
|
|
||||||
.slide-highlight {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 28px;
|
|
||||||
background: rgba(255,255,255,0.08);
|
|
||||||
border: 1px solid rgba(255,255,255,0.15);
|
|
||||||
border-radius: 14px;
|
|
||||||
color: rgba(255,255,255,0.9);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 슬라이드별 색상 */
|
|
||||||
.slide-1 .slide-icon { background: linear-gradient(135deg, #fbbf24, #f59e0b); }
|
|
||||||
.slide-1 .slide-tag { background: #fef3c7; color: #92400e; }
|
|
||||||
.slide-2 .slide-icon { background: linear-gradient(135deg, #34d399, #10b981); }
|
|
||||||
.slide-2 .slide-tag { background: #d1fae5; color: #065f46; }
|
|
||||||
.slide-3 .slide-icon { background: linear-gradient(135deg, #60a5fa, #3b82f6); }
|
|
||||||
.slide-3 .slide-tag { background: #dbeafe; color: #1e40af; }
|
|
||||||
|
|
||||||
/* 인디케이터 */
|
|
||||||
.slide-dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 24px;
|
|
||||||
}
|
|
||||||
.slide-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255,255,255,0.2);
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
.slide-dot.active {
|
|
||||||
width: 32px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: #8b5cf6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 브랜딩 영역 */
|
|
||||||
.branding {
|
|
||||||
margin-top: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 32px;
|
|
||||||
}
|
|
||||||
.branding-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 48px;
|
|
||||||
background: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
.branding-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: rgba(255,255,255,0.4);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.branding-item span.icon {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════
|
|
||||||
적립 화면
|
|
||||||
══════════════════════════════════════ */
|
|
||||||
.claim-screen {
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 48px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.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; }
|
|
||||||
/* 품목 카드 */
|
|
||||||
.items-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
|
||||||
width: 100%;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.items-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #6b7280;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.items-list { display: flex; flex-direction: column; gap: 4px; }
|
|
||||||
.item-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #374151;
|
|
||||||
padding: 4px 0;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
|
||||||
}
|
|
||||||
.item-row:last-child { border-bottom: none; }
|
|
||||||
.item-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
|
|
||||||
.item-qty { color: #9ca3af; font-size: 13px; flex-shrink: 0; }
|
|
||||||
.item-total { font-weight: 600; color: #6366f1; flex-shrink: 0; min-width: 60px; text-align: right; }
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
.claim-right {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.phone-section-title { font-size: 20px; font-weight: 700; color: #1e1b4b; }
|
|
||||||
|
|
||||||
/* 전화번호 디스플레이 */
|
|
||||||
.phone-display-wrap {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
background: #fff;
|
|
||||||
border: 3px solid #e5e7eb;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 14px 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
min-height: 64px;
|
|
||||||
}
|
|
||||||
.phone-display-wrap.focus { border-color: #6366f1; }
|
|
||||||
.phone-display-wrap.error { border-color: #ef4444; animation: shake 0.3s; }
|
|
||||||
.phone-prefix {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #9ca3af;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.phone-number {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e1b4b;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
.phone-number.placeholder { color: #d1d5db; }
|
|
||||||
|
|
||||||
@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: 8px; width: 100%; max-width: 360px; }
|
|
||||||
.numpad-btn {
|
|
||||||
background: #fff;
|
|
||||||
border: 2px solid #e5e7eb;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 16px;
|
|
||||||
font-size: 26px;
|
|
||||||
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: 18px; }
|
|
||||||
.numpad-btn.delete:active { background: #ef4444; color: #fff; }
|
|
||||||
.numpad-btn.clear { background: #f5f5f5; border-color: #d4d4d4; color: #737373; font-size: 14px; }
|
|
||||||
.numpad-btn.clear:active { background: #737373; color: #fff; }
|
|
||||||
|
|
||||||
.submit-btn {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 360px;
|
|
||||||
padding: 16px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
.submit-btn:active { transform: scale(0.97); }
|
|
||||||
.submit-btn:disabled { background: #d1d5db; cursor: not-allowed; }
|
|
||||||
|
|
||||||
.error-msg { color: #ef4444; font-size: 14px; font-weight: 500; min-height: 20px; }
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════
|
|
||||||
성공 화면
|
|
||||||
══════════════════════════════════════ */
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
/* ── 로딩 ── */
|
|
||||||
.loading-overlay { position: fixed; inset: 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); } }
|
|
||||||
|
|
||||||
/* ── 적립/성공 화면 배경 밝게 ── */
|
|
||||||
.claim-screen, .success-screen { background: #f5f7fa; border-radius: 24px; padding: 32px; }
|
|
||||||
|
|
||||||
/* ── 반응형: 세로 모니터 (portrait, 폭 700px 이상) ── */
|
|
||||||
@media (orientation: portrait) and (min-width: 700px) {
|
|
||||||
.main { padding: 32px; }
|
|
||||||
|
|
||||||
/* 적립 화면: 세로 스택, 공간 활용 */
|
|
||||||
.claim-screen {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 32px 48px;
|
|
||||||
max-width: 640px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.claim-left {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
width: 100%;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.claim-info-card { flex: 1; min-width: 200px; }
|
|
||||||
.qr-container { flex-shrink: 0; }
|
|
||||||
.items-card { width: 100%; max-height: 160px; }
|
|
||||||
.qr-container img { width: 140px; height: 140px; }
|
|
||||||
|
|
||||||
.divider { flex-direction: row; }
|
|
||||||
.divider-line { width: 60px; height: 2px; }
|
|
||||||
|
|
||||||
.claim-right { width: 100%; align-items: center; }
|
|
||||||
.phone-display-wrap { max-width: 440px; }
|
|
||||||
.phone-prefix { font-size: 30px; }
|
|
||||||
.phone-number { font-size: 30px; white-space: nowrap; }
|
|
||||||
.numpad { max-width: 440px; }
|
|
||||||
.submit-btn { max-width: 440px; }
|
|
||||||
|
|
||||||
/* 슬라이드 더 크게 */
|
|
||||||
.slides-wrapper { height: 420px; }
|
|
||||||
.slide-icon { width: 110px; height: 110px; font-size: 56px; }
|
|
||||||
.slide-title { font-size: 34px; }
|
|
||||||
.slide-desc { font-size: 19px; }
|
|
||||||
.slide-highlight { font-size: 16px; padding: 12px 32px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 반응형: 좁은 화면 (모바일) ── */
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
.claim-screen { flex-direction: column; gap: 24px; padding: 20px; }
|
|
||||||
.divider { flex-direction: row; }
|
|
||||||
.divider-line { width: 60px; height: 2px; }
|
|
||||||
.claim-amount { font-size: 28px; }
|
|
||||||
.qr-container img { width: 150px; height: 150px; }
|
|
||||||
.branding { flex-direction: column; gap: 12px; }
|
|
||||||
.branding-divider { display: none; }
|
|
||||||
.slide-title { font-size: 24px; }
|
|
||||||
.slides-wrapper { height: 320px; }
|
|
||||||
}
|
|
||||||
</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="slides-wrapper">
|
|
||||||
<!-- 슬라이드 1: 동물의약품 -->
|
|
||||||
<div class="slide slide-1 active" data-slide="0">
|
|
||||||
<div class="slide-icon">🐾</div>
|
|
||||||
<div class="slide-tag">반려동물 케어</div>
|
|
||||||
<div class="slide-title">우리 아이 약도<br>마일리지로 구매!</div>
|
|
||||||
<div class="slide-desc">
|
|
||||||
청춘약국 마일리지로<br>
|
|
||||||
동물의약품을 구매할 수 있어요
|
|
||||||
</div>
|
|
||||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 슬라이드 2: 건기식 -->
|
|
||||||
<div class="slide slide-2" data-slide="1">
|
|
||||||
<div class="slide-icon">🌿</div>
|
|
||||||
<div class="slide-tag">건강기능식품</div>
|
|
||||||
<div class="slide-title">팜큐 건강기능식품<br>마일리지로 챙기세요</div>
|
|
||||||
<div class="slide-desc">
|
|
||||||
비타민, 유산균, 오메가3 등<br>
|
|
||||||
엄선된 건기식을 포인트로 구매!
|
|
||||||
</div>
|
|
||||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 슬라이드 3: 부외품 -->
|
|
||||||
<div class="slide slide-3" data-slide="2">
|
|
||||||
<div class="slide-icon">💧</div>
|
|
||||||
<div class="slide-tag">약국 용품</div>
|
|
||||||
<div class="slide-title">투약병, 부외품도<br>마일리지로 OK</div>
|
|
||||||
<div class="slide-desc">
|
|
||||||
물약병, 연고통, 밴드 등<br>
|
|
||||||
필요한 약국 용품을 포인트로!
|
|
||||||
</div>
|
|
||||||
<div class="slide-highlight">총 결제금액의 30% 한도 내 사용 가능</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 인디케이터 -->
|
|
||||||
<div class="slide-dots">
|
|
||||||
<div class="slide-dot active" data-dot="0"></div>
|
|
||||||
<div class="slide-dot" data-dot="1"></div>
|
|
||||||
<div class="slide-dot" data-dot="2"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 브랜딩 -->
|
|
||||||
<div class="branding">
|
|
||||||
<div class="branding-item"><span class="icon">🤖</span> AI 에이전트 개발 약국</div>
|
|
||||||
<div class="branding-divider"></div>
|
|
||||||
<div class="branding-item"><span class="icon">💊</span> 복약안내에 진심인 약사</div>
|
|
||||||
<div class="branding-divider"></div>
|
|
||||||
<div class="branding-item"><span class="icon">📱</span> 모바일 약료 시스템 도입</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 2. 적립 화면 -->
|
|
||||||
<div class="screen claim-screen" id="claimScreen">
|
|
||||||
<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="items-card" id="itemsCard" style="display:none;">
|
|
||||||
<div class="items-title">구매 품목</div>
|
|
||||||
<div class="items-list" id="itemsList"></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-wrap" id="phoneWrap">
|
|
||||||
<span class="phone-prefix">010-</span>
|
|
||||||
<span class="phone-number placeholder" id="phoneDisplay">0000-0000</span>
|
|
||||||
</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 = ''; // 010 이후 8자리만 관리
|
|
||||||
let currentSession = null;
|
|
||||||
let pollingInterval = null;
|
|
||||||
let successTimeout = null;
|
|
||||||
|
|
||||||
// ── 슬라이드쇼 ──
|
|
||||||
let currentSlide = 0;
|
|
||||||
const TOTAL_SLIDES = 3;
|
|
||||||
const SLIDE_INTERVAL = 4000; // 4초
|
|
||||||
let slideTimer = null;
|
|
||||||
|
|
||||||
function nextSlide() {
|
|
||||||
const slides = document.querySelectorAll('.slide');
|
|
||||||
const dots = document.querySelectorAll('.slide-dot');
|
|
||||||
|
|
||||||
// 현재 슬라이드 exit
|
|
||||||
slides[currentSlide].classList.remove('active');
|
|
||||||
slides[currentSlide].classList.add('exit');
|
|
||||||
dots[currentSlide].classList.remove('active');
|
|
||||||
|
|
||||||
// 다음 슬라이드
|
|
||||||
currentSlide = (currentSlide + 1) % TOTAL_SLIDES;
|
|
||||||
|
|
||||||
// exit 클래스 제거 (transition 후)
|
|
||||||
setTimeout(() => {
|
|
||||||
document.querySelectorAll('.slide.exit').forEach(s => s.classList.remove('exit'));
|
|
||||||
}, 600);
|
|
||||||
|
|
||||||
slides[currentSlide].classList.add('active');
|
|
||||||
dots[currentSlide].classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSlideshow() {
|
|
||||||
if (slideTimer) clearInterval(slideTimer);
|
|
||||||
slideTimer = setInterval(nextSlide, SLIDE_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopSlideshow() {
|
|
||||||
if (slideTimer) { clearInterval(slideTimer); slideTimer = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
startSlideshow();
|
|
||||||
|
|
||||||
// ── 화면 전환 ──
|
|
||||||
function showScreen(name) {
|
|
||||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
|
||||||
document.getElementById(name + 'Screen').classList.add('active');
|
|
||||||
|
|
||||||
if (name === 'idle') {
|
|
||||||
document.body.style.background = '#0f0b2e';
|
|
||||||
startSlideshow();
|
|
||||||
} else {
|
|
||||||
document.body.style.background = '#f5f7fa';
|
|
||||||
stopSlideshow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 시계 ──
|
|
||||||
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 고정, 나머지 8자리) ──
|
|
||||||
function formatSuffix(num) {
|
|
||||||
if (num.length <= 4) return num + '●'.repeat(Math.max(0, 4 - num.length)) + '-' + '●●●●';
|
|
||||||
return num.slice(0, 4) + '-' + num.slice(4) + '●'.repeat(Math.max(0, 8 - num.length));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePhoneDisplay() {
|
|
||||||
const display = document.getElementById('phoneDisplay');
|
|
||||||
const wrap = document.getElementById('phoneWrap');
|
|
||||||
const btn = document.getElementById('submitBtn');
|
|
||||||
|
|
||||||
if (phoneNumber.length === 0) {
|
|
||||||
display.textContent = '0000-0000';
|
|
||||||
display.className = 'phone-number placeholder';
|
|
||||||
wrap.classList.remove('focus');
|
|
||||||
btn.disabled = true;
|
|
||||||
} else {
|
|
||||||
display.textContent = formatSuffix(phoneNumber);
|
|
||||||
display.className = 'phone-number';
|
|
||||||
wrap.classList.add('focus');
|
|
||||||
btn.disabled = phoneNumber.length < 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
wrap.classList.remove('error');
|
|
||||||
document.getElementById('errorMsg').textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function numPress(digit) {
|
|
||||||
if (phoneNumber.length >= 8) return;
|
|
||||||
phoneNumber += digit;
|
|
||||||
updatePhoneDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function numDelete() {
|
|
||||||
phoneNumber = phoneNumber.slice(0, -1);
|
|
||||||
updatePhoneDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function numClear() {
|
|
||||||
phoneNumber = '';
|
|
||||||
updatePhoneDisplay();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 적립 ──
|
|
||||||
async function submitClaim() {
|
|
||||||
const fullPhone = '010' + phoneNumber;
|
|
||||||
if (fullPhone.length < 11) {
|
|
||||||
document.getElementById('phoneWrap').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: fullPhone })
|
|
||||||
});
|
|
||||||
const data = await resp.json();
|
|
||||||
document.getElementById('loadingOverlay').classList.remove('active');
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
showSuccess(data.points, data.balance);
|
|
||||||
} else {
|
|
||||||
document.getElementById('phoneWrap').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');
|
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 폴링 ──
|
|
||||||
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();
|
|
||||||
|
|
||||||
// 품목 목록 표시
|
|
||||||
const itemsCard = document.getElementById('itemsCard');
|
|
||||||
const itemsList = document.getElementById('itemsList');
|
|
||||||
if (data.items && data.items.length > 0) {
|
|
||||||
itemsList.innerHTML = data.items.map(item =>
|
|
||||||
`<div class="item-row">
|
|
||||||
<span class="item-name">${item.name}</span>
|
|
||||||
<span class="item-qty">${item.qty}개</span>
|
|
||||||
<span class="item-total">${item.total.toLocaleString()}원</span>
|
|
||||||
</div>`
|
|
||||||
).join('');
|
|
||||||
itemsCard.style.display = '';
|
|
||||||
} else {
|
|
||||||
itemsCard.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Loading…
Reference in New Issue
Block a user