- Add main application files (main.py, models.py, schemas.py, etc.) - Add routers for all features (waiting, attendance, members, etc.) - Add HTML templates for admin and user interfaces - Add migration scripts and utility files - Add Docker configuration - Add documentation files - Add .gitignore to exclude database and cache files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1562 lines
55 KiB
HTML
1562 lines
55 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>대기접수 - 데스크</title>
|
|
<link rel="stylesheet" href="/static/css/common.css">
|
|
<link rel="stylesheet" href="/static/css/keypad-styles.css">
|
|
<script src="/static/js/screen-monitor.js"></script>
|
|
<style>
|
|
/* 전체 페이지 레이아웃 - 태블릿 최적화 */
|
|
body {
|
|
margin: 0;
|
|
padding: 0;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #f5f7fa;
|
|
}
|
|
|
|
.reception-container {
|
|
width: 95%;
|
|
max-width: 100%;
|
|
padding: 10px 15px;
|
|
box-sizing: border-box;
|
|
margin: 0 auto;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
}
|
|
|
|
.reception-header {
|
|
text-align: center;
|
|
margin-bottom: 10px;
|
|
padding-top: 10px;
|
|
}
|
|
|
|
.reception-header h1 {
|
|
font-size: 80px;
|
|
color: #2c3e50;
|
|
margin-bottom: 8px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.reception-header .date {
|
|
font-size: 38px;
|
|
color: #2c3e50;
|
|
font-weight: 800;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.phone-input-group {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.phone-input-group label {
|
|
display: block;
|
|
font-size: 32px;
|
|
font-weight: 800;
|
|
margin-bottom: 8px;
|
|
color: #2c3e50;
|
|
text-align: center;
|
|
}
|
|
|
|
.phone-display {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 10px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.phone-prefix {
|
|
font-size: 40px;
|
|
font-weight: 700;
|
|
color: #7f8c8d;
|
|
padding: 15px 0;
|
|
background: #ecf0f1;
|
|
border-radius: 12px;
|
|
text-align: center;
|
|
grid-column: 1;
|
|
text-align: center;
|
|
grid-column: 1;
|
|
/* display: flex; REMOVED to hide, or just remove content */
|
|
display: none;
|
|
/* Hide standard prefix, let JS handle it */
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.phone-number {
|
|
font-size: 72px;
|
|
font-weight: 900;
|
|
color: #2c3e50;
|
|
padding: 18px 0;
|
|
background: #fff;
|
|
border: 5px solid #3498db;
|
|
border-radius: 12px;
|
|
text-align: center;
|
|
letter-spacing: 6px;
|
|
grid-column: 1 / span 3;
|
|
/* Take full width */
|
|
min-width: 0;
|
|
}
|
|
|
|
.keypad {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 15px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.key {
|
|
padding: 25px;
|
|
font-size: 44px;
|
|
font-weight: 700;
|
|
background: linear-gradient(145deg, #ffffff, #f8f9fa);
|
|
border: 2px solid #e8ecef;
|
|
border-radius: 18px;
|
|
cursor: pointer;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
color: #2c3e50;
|
|
min-height: 85px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12),
|
|
0 3px 6px rgba(0, 0, 0, 0.08),
|
|
inset 0 -2px 4px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.key:hover {
|
|
background: linear-gradient(145deg, #3498db, #2980b9);
|
|
color: #fff;
|
|
border-color: #2980b9;
|
|
transform: translateY(-4px) scale(1.03);
|
|
box-shadow: 0 10px 24px rgba(52, 152, 219, 0.35),
|
|
0 6px 12px rgba(52, 152, 219, 0.25),
|
|
inset 0 -2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.key:active {
|
|
transform: translateY(-1px) scale(0.98);
|
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.15),
|
|
inset 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.key.zero {
|
|
grid-column: 2;
|
|
}
|
|
|
|
.key.backspace {
|
|
background: linear-gradient(145deg, #e74c3c, #c0392b);
|
|
color: #fff;
|
|
border: 2px solid #c0392b;
|
|
box-shadow: 0 6px 16px rgba(231, 76, 60, 0.35),
|
|
0 3px 6px rgba(231, 76, 60, 0.25),
|
|
inset 0 -2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.key.backspace:hover {
|
|
background: linear-gradient(145deg, #c0392b, #a93226);
|
|
border-color: #a93226;
|
|
transform: translateY(-4px) scale(1.03);
|
|
box-shadow: 0 10px 24px rgba(192, 57, 43, 0.45),
|
|
0 6px 12px rgba(192, 57, 43, 0.35),
|
|
inset 0 -2px 4px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.submit-btn {
|
|
width: 100%;
|
|
padding: 32px;
|
|
font-size: 84px;
|
|
font-weight: 900;
|
|
background: linear-gradient(145deg, #27ae60, #229954);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 16px;
|
|
cursor: pointer;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
min-height: 120px;
|
|
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.3),
|
|
0 2px 4px rgba(39, 174, 96, 0.2);
|
|
}
|
|
|
|
.submit-btn:hover:not(:disabled) {
|
|
background: linear-gradient(145deg, #229954, #1e8449);
|
|
transform: translateY(-3px) scale(1.01);
|
|
box-shadow: 0 8px 20px rgba(39, 174, 96, 0.4),
|
|
0 4px 8px rgba(39, 174, 96, 0.3);
|
|
}
|
|
|
|
.submit-btn:active:not(:disabled) {
|
|
transform: translateY(-1px) scale(0.99);
|
|
box-shadow: 0 2px 8px rgba(39, 174, 96, 0.2);
|
|
}
|
|
|
|
.submit-btn:disabled {
|
|
background: #95a5a6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.back-link {
|
|
display: none;
|
|
}
|
|
|
|
.waiting-status {
|
|
background: #ecf0f1;
|
|
padding: 10px 15px;
|
|
border-radius: 10px;
|
|
margin-bottom: 10px;
|
|
text-align: center;
|
|
}
|
|
|
|
.waiting-status .label {
|
|
font-size: 26px;
|
|
color: #2c3e50;
|
|
margin-bottom: 6px;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.waiting-status .count {
|
|
font-size: 48px;
|
|
font-weight: 900;
|
|
color: #2c3e50;
|
|
}
|
|
|
|
.waiting-status.full {
|
|
background: #e74c3c;
|
|
color: white;
|
|
}
|
|
|
|
.waiting-status.full .label,
|
|
.waiting-status.full .count {
|
|
color: white;
|
|
}
|
|
|
|
.waiting-status.warning {
|
|
background: #f39c12;
|
|
color: white;
|
|
}
|
|
|
|
.waiting-status.warning .label,
|
|
.waiting-status.warning .count {
|
|
color: white;
|
|
}
|
|
|
|
.keypad-container {
|
|
position: relative;
|
|
}
|
|
|
|
.closed-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(231, 76, 60, 0.95);
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: 10px;
|
|
z-index: 10;
|
|
}
|
|
|
|
.closed-overlay.active {
|
|
display: flex;
|
|
}
|
|
|
|
.closed-message {
|
|
text-align: center;
|
|
color: white;
|
|
}
|
|
|
|
.closed-message .icon {
|
|
font-size: 64px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.closed-message .text {
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* ========================================
|
|
태블릿 최적화 미디어 쿼리
|
|
======================================== */
|
|
|
|
/* 작은 태블릿 세로 모드 (iPad Mini 등) - 768px ~ 834px */
|
|
@media only screen and (min-width: 768px) and (max-width: 834px) and (orientation: portrait) {
|
|
.reception-container {
|
|
width: 98%;
|
|
max-width: 100%;
|
|
padding: 10px 5px;
|
|
}
|
|
|
|
.reception-header {
|
|
padding-top: 5px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.reception-header h1 {
|
|
font-size: 60px;
|
|
}
|
|
|
|
.reception-header .date {
|
|
font-size: 30px;
|
|
}
|
|
|
|
.phone-number {
|
|
font-size: 64px;
|
|
}
|
|
|
|
.key {
|
|
font-size: 44px;
|
|
min-height: 80px;
|
|
padding: 15px;
|
|
}
|
|
|
|
.submit-btn {
|
|
font-size: 60px;
|
|
min-height: 90px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.keypad {
|
|
gap: 10px;
|
|
}
|
|
}
|
|
|
|
/* 작은 태블릿 가로 모드 */
|
|
@media only screen and (min-width: 768px) and (max-width: 1024px) and (orientation: landscape) {
|
|
.reception-container {
|
|
width: 99%;
|
|
max-width: 100%;
|
|
padding: 5px 10px;
|
|
min-height: auto;
|
|
justify-content: flex-start;
|
|
padding-top: 5px;
|
|
}
|
|
|
|
.reception-header {
|
|
padding-top: 0;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.reception-header h1 {
|
|
font-size: 50px;
|
|
}
|
|
|
|
.reception-header .date {
|
|
font-size: 26px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.phone-number {
|
|
font-size: 60px;
|
|
padding: 10px 0;
|
|
}
|
|
|
|
.key {
|
|
font-size: 40px;
|
|
min-height: 70px;
|
|
padding: 10px;
|
|
}
|
|
|
|
.keypad {
|
|
gap: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.submit-btn {
|
|
font-size: 50px;
|
|
min-height: 80px;
|
|
padding: 15px;
|
|
}
|
|
}
|
|
|
|
/* 표준 태블릿 세로 모드 (iPad 10.2", iPad Air 등) - 810px ~ 1024px */
|
|
@media only screen and (min-width: 810px) and (max-width: 1024px) and (orientation: portrait) {
|
|
.reception-container {
|
|
width: 98%;
|
|
max-width: 100%;
|
|
padding: 10px 5px;
|
|
}
|
|
|
|
.reception-header {
|
|
padding-top: 5px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.reception-header h1 {
|
|
font-size: 65px;
|
|
}
|
|
|
|
.reception-header .date {
|
|
font-size: 32px;
|
|
}
|
|
|
|
.phone-number {
|
|
font-size: 68px;
|
|
}
|
|
|
|
.key {
|
|
font-size: 46px;
|
|
min-height: 85px;
|
|
}
|
|
|
|
.submit-btn {
|
|
font-size: 65px;
|
|
min-height: 100px;
|
|
}
|
|
|
|
.keypad {
|
|
gap: 12px;
|
|
}
|
|
}
|
|
|
|
/* 큰 태블릿 세로 모드 (iPad Pro 11", 12.9" 등) - 1024px ~ 1366px */
|
|
@media only screen and (min-width: 1024px) and (max-width: 1366px) and (orientation: portrait) {
|
|
.reception-container {
|
|
width: 98%;
|
|
max-width: 100%;
|
|
padding: 15px 10px;
|
|
}
|
|
|
|
.reception-header {
|
|
padding-top: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.reception-header h1 {
|
|
font-size: 75px;
|
|
}
|
|
|
|
.reception-header .date {
|
|
font-size: 36px;
|
|
}
|
|
|
|
.phone-number {
|
|
font-size: 72px;
|
|
}
|
|
|
|
.key {
|
|
font-size: 50px;
|
|
min-height: 95px;
|
|
}
|
|
|
|
.submit-btn {
|
|
font-size: 75px;
|
|
min-height: 110px;
|
|
}
|
|
|
|
.keypad {
|
|
gap: 14px;
|
|
}
|
|
}
|
|
|
|
/* 큰 태블릿 가로 모드 */
|
|
@media only screen and (min-width: 1024px) and (orientation: landscape) {
|
|
.reception-container {
|
|
width: 98%;
|
|
max-width: 100%;
|
|
padding: 10px 25px;
|
|
min-height: auto;
|
|
justify-content: center;
|
|
}
|
|
|
|
.reception-header {
|
|
padding-top: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.keypad {
|
|
gap: 15px;
|
|
}
|
|
}
|
|
|
|
/* 초소형 태블릿 (7인치 등) - 600px ~ 768px */
|
|
@media only screen and (min-width: 600px) and (max-width: 767px) {
|
|
.reception-container {
|
|
max-width: 550px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.reception-header {
|
|
padding-top: 15px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.reception-header h1 {
|
|
font-size: 32px;
|
|
}
|
|
|
|
.reception-header .date {
|
|
font-size: 24px;
|
|
}
|
|
|
|
.phone-number {
|
|
font-size: 52px;
|
|
padding: 15px 0;
|
|
}
|
|
|
|
.key {
|
|
font-size: 36px;
|
|
min-height: 65px;
|
|
padding: 18px;
|
|
}
|
|
|
|
.keypad {
|
|
gap: 12px;
|
|
}
|
|
|
|
.submit-btn {
|
|
font-size: 24px;
|
|
min-height: 65px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.waiting-status .label {
|
|
font-size: 18px;
|
|
}
|
|
|
|
.waiting-status .count {
|
|
font-size: 28px;
|
|
}
|
|
}
|
|
|
|
/* 모바일 (600px 이하) */
|
|
@media only screen and (max-width: 599px) {
|
|
.reception-container {
|
|
padding: 10px;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.reception-header {
|
|
padding-top: 10px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.reception-header h1 {
|
|
font-size: 28px;
|
|
}
|
|
|
|
.reception-header .date {
|
|
font-size: 20px;
|
|
}
|
|
|
|
.phone-number {
|
|
font-size: 44px;
|
|
padding: 12px 0;
|
|
letter-spacing: 3px;
|
|
}
|
|
|
|
.key {
|
|
font-size: 32px;
|
|
min-height: 60px;
|
|
padding: 15px;
|
|
}
|
|
|
|
.keypad {
|
|
gap: 10px;
|
|
}
|
|
|
|
.submit-btn {
|
|
font-size: 22px;
|
|
min-height: 60px;
|
|
padding: 14px;
|
|
}
|
|
|
|
.waiting-status {
|
|
padding: 10px 15px;
|
|
}
|
|
|
|
.waiting-status .label {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.waiting-status .count {
|
|
font-size: 24px;
|
|
}
|
|
}
|
|
|
|
/* 화면 높이가 낮은 경우 (가로 모드 최적화) */
|
|
@media only screen and (max-height: 700px) and (orientation: landscape) {
|
|
.reception-container {
|
|
min-height: auto;
|
|
padding-top: 10px;
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
.reception-header {
|
|
padding-top: 5px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.reception-header h1 {
|
|
font-size: 30px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.reception-header .date {
|
|
font-size: 22px;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.phone-number {
|
|
padding: 12px 0;
|
|
}
|
|
|
|
.key {
|
|
min-height: 60px;
|
|
padding: 15px;
|
|
}
|
|
|
|
.keypad {
|
|
gap: 12px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.submit-btn {
|
|
min-height: 60px;
|
|
padding: 15px;
|
|
}
|
|
|
|
.waiting-status {
|
|
padding: 8px 15px;
|
|
margin-bottom: 10px;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<div class="container reception-container">
|
|
<div class="reception-header">
|
|
<h1 id="storeName">대기 접수</h1>
|
|
<div class="date" id="currentDate"></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="waiting-status" id="waitingStatus">
|
|
<div class="count" id="waitingCount">로딩 중...</div>
|
|
</div>
|
|
|
|
<div class="phone-input-group">
|
|
<label>핸드폰번호 또는 바코드 입력</label>
|
|
<div class="phone-display">
|
|
<!-- Prefix removed/hidden via CSS, JS handles full display -->
|
|
<div class="phone-number" id="phoneDisplay">010-____-____</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="keypad-container">
|
|
<div class="keypad">
|
|
<button class="key" onclick="inputNumber('1')">1</button>
|
|
<button class="key" onclick="inputNumber('2')">2</button>
|
|
<button class="key" onclick="inputNumber('3')">3</button>
|
|
<button class="key" onclick="inputNumber('4')">4</button>
|
|
<button class="key" onclick="inputNumber('5')">5</button>
|
|
<button class="key" onclick="inputNumber('6')">6</button>
|
|
<button class="key" onclick="inputNumber('7')">7</button>
|
|
<button class="key" onclick="inputNumber('8')">8</button>
|
|
<button class="key" onclick="inputNumber('9')">9</button>
|
|
<button class="key backspace" onclick="backspace()">←</button>
|
|
<button class="key zero" onclick="inputNumber('0')">0</button>
|
|
<button class="key" onclick="clearInput()">C</button>
|
|
</div>
|
|
|
|
<!-- 접수 마감 오버레이 -->
|
|
<div class="closed-overlay" id="closedOverlay">
|
|
<div class="closed-message">
|
|
<div class="icon">🚫</div>
|
|
<div class="text">대기접수가<br>마감되었습니다</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="submit-btn" id="submitBtn" onclick="submitReception()">
|
|
대기 접수
|
|
</button>
|
|
</div>
|
|
|
|
<a href="/" class="back-link">← 메인으로 돌아가기</a>
|
|
</div>
|
|
|
|
<div id="resultModal" class="modal">
|
|
<div class="modal-content">
|
|
|
|
<div id="resultMessage" style="text-align: center; font-size: 18px; line-height: 1.8; padding: 20px 0;">
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-primary" onclick="closeModal()">확인</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="errorModal" class="modal">
|
|
<div class="modal-content" style="max-width: 600px; border-radius: 24px; padding: 0; overflow: hidden;">
|
|
<div style="text-align: center; padding: 60px 40px;">
|
|
<!-- Simple Icon -->
|
|
<div
|
|
style="width: 100px; height: 100px; margin: 0 auto 30px; background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 8px 24px rgba(238, 90, 111, 0.3);">
|
|
<svg width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3"
|
|
stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="10"></circle>
|
|
<line x1="12" y1="8" x2="12" y2="12"></line>
|
|
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Main Error Message (첫 번째 줄) -->
|
|
<div id="errorMessageMain"
|
|
style="font-size: 48px; line-height: 1.3; font-weight: 700; color: #2c3e50; margin-bottom: 20px;">
|
|
</div>
|
|
|
|
<!-- Secondary Message (두 번째 줄) -->
|
|
<div id="errorMessageSub"
|
|
style="font-size: 28px; line-height: 1.4; font-weight: 500; color: #7f8c8d; margin-bottom: 40px;">
|
|
</div>
|
|
|
|
<!-- Auto-close indicator -->
|
|
<div style="font-size: 18px; color: #95a5a6; font-weight: 500;">
|
|
잠시 후 자동으로 닫힙니다
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
console.log('=== RECEPTION DESK SCRIPT LOADED - Version: 2025-12-07-16:56 ===');
|
|
|
|
let phoneNumber = '';
|
|
let storeSettings = null;
|
|
|
|
// 요일 매핑 (JavaScript getDay(): 0=일요일, 1=월요일, ...)
|
|
const WEEKDAY_MAP = {
|
|
0: "sun", // Sunday
|
|
1: "mon", // Monday
|
|
2: "tue", // Tuesday
|
|
3: "wed", // Wednesday
|
|
4: "thu", // Thursday
|
|
5: "fri", // Friday
|
|
6: "sat" // Saturday
|
|
};
|
|
|
|
// 오늘 요일에 맞는 클래스만 필터링
|
|
function filterClassesByToday(classList) {
|
|
const today = new Date();
|
|
const weekday = WEEKDAY_MAP[today.getDay()];
|
|
|
|
return classList.filter(cls => {
|
|
// weekday_schedule이 없으면 모든 요일 운영으로 간주
|
|
if (!cls.weekday_schedule) {
|
|
return true;
|
|
}
|
|
|
|
const schedule = typeof cls.weekday_schedule === 'string'
|
|
? JSON.parse(cls.weekday_schedule)
|
|
: cls.weekday_schedule;
|
|
|
|
// 해당 요일이 활성화되어 있으면 포함
|
|
return schedule[weekday] === true;
|
|
});
|
|
}
|
|
|
|
// Helper function to get headers with store ID and Auth token
|
|
function getHeaders(additionalHeaders = {}) {
|
|
const headers = { ...additionalHeaders };
|
|
|
|
// 매장 ID 설정
|
|
const storeId = localStorage.getItem('selected_store_id');
|
|
if (storeId) {
|
|
headers['X-Store-Id'] = storeId;
|
|
}
|
|
|
|
// 인증 토큰 설정
|
|
const token = localStorage.getItem('access_token');
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
async function loadStoreInfo() {
|
|
try {
|
|
const response = await fetch('/api/store/', {
|
|
headers: getHeaders()
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
console.error('인증 실패, 로그인 페이지로 이동');
|
|
window.location.href = '/reception-login';
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Store API error: ${response.status}`);
|
|
}
|
|
|
|
storeSettings = await response.json();
|
|
document.getElementById('storeName').textContent = storeSettings.store_name;
|
|
|
|
// 키패드 스타일 적용
|
|
applyKeypadStyles();
|
|
|
|
await loadWaitingStatus();
|
|
} catch (error) {
|
|
console.error('매장 정보 조회 실패:', error);
|
|
document.getElementById('waitingCount').textContent = '매장 정보 오류';
|
|
// 인증 토큰이 없거나 만료된 경우 로그인 페이지로 리다이렉트
|
|
if (!localStorage.getItem('access_token')) {
|
|
window.location.href = '/reception-login';
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyKeypadStyles() {
|
|
const container = document.querySelector('.reception-container');
|
|
if (!container || !storeSettings) return;
|
|
|
|
// 기존 스타일 클래스 제거
|
|
container.classList.remove('keypad-style-modern', 'keypad-style-bold', 'keypad-style-dark', 'keypad-style-colorful');
|
|
container.classList.remove('keypad-font-small', 'keypad-font-medium', 'keypad-font-large', 'keypad-font-xlarge');
|
|
|
|
// 새 스타일 클래스 추가
|
|
const style = storeSettings.keypad_style || 'modern';
|
|
const fontSize = storeSettings.keypad_font_size || 'large';
|
|
|
|
container.classList.add(`keypad-style-${style}`);
|
|
container.classList.add(`keypad-font-${fontSize}`);
|
|
|
|
console.log(`✅ Keypad style applied: ${style}, Font size: ${fontSize}`);
|
|
}
|
|
|
|
async function loadWaitingStatus() {
|
|
try {
|
|
// Call the Single Source of Truth API
|
|
const response = await fetch('/api/waiting/next-slot', {
|
|
headers: getHeaders()
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
const statusDiv = document.getElementById('waitingStatus');
|
|
const countDiv = document.getElementById('waitingCount');
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
|
|
// Display Next Slot Info
|
|
if (data.is_full && data.class_id === -1) {
|
|
// Full or Closed
|
|
statusDiv.className = 'waiting-status full';
|
|
countDiv.textContent = data.class_name; // "접수 마감" or "운영 교시 없음"
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = data.class_name;
|
|
isRegistrationClosed = true;
|
|
document.getElementById('closedOverlay').classList.add('active');
|
|
} else {
|
|
// Available
|
|
statusDiv.className = 'waiting-status';
|
|
countDiv.textContent = `${data.class_name} ${data.class_order}번째 / ${data.max_capacity}명`;
|
|
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = '대기 접수';
|
|
isRegistrationClosed = false;
|
|
document.getElementById('closedOverlay').classList.remove('active');
|
|
|
|
// Optional: Warning logic if approaching limit (e.g. 90%)
|
|
const isNearFull = (data.class_order > data.max_capacity * 0.9);
|
|
if (isNearFull) {
|
|
statusDiv.className = 'waiting-status warning';
|
|
}
|
|
}
|
|
|
|
// Global Limit Check (if needed, but backend handles it mostly)
|
|
// If using storeSettings.max_waiting_limit, we can check data.total_waiting
|
|
const maxLimit = storeSettings?.max_waiting_limit || 0;
|
|
if (storeSettings?.use_max_waiting_limit && maxLimit > 0) {
|
|
if (data.total_waiting >= maxLimit) {
|
|
statusDiv.className = 'waiting-status full';
|
|
countDiv.textContent = '대기 인원 초과'; // Override
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = '대기 인원 초과';
|
|
isRegistrationClosed = true;
|
|
document.getElementById('closedOverlay').classList.add('active');
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('대기 현황 조회 실패:', error);
|
|
document.getElementById('waitingCount').textContent = '오류';
|
|
}
|
|
}
|
|
|
|
async function updateDate() {
|
|
try {
|
|
const response = await fetch('/api/daily/check-status', { headers: getHeaders() });
|
|
const status = await response.json();
|
|
|
|
if (status && status.business_date) {
|
|
const dateObj = new Date(status.business_date);
|
|
const year = dateObj.getFullYear();
|
|
const month = dateObj.getMonth() + 1;
|
|
const day = dateObj.getDate();
|
|
document.getElementById('currentDate').textContent = `${year}년 ${month}월 ${day}일`;
|
|
} else {
|
|
// Fallback to local date if not open or error
|
|
const now = new Date();
|
|
document.getElementById('currentDate').textContent = `${now.getFullYear()}년 ${now.getMonth() + 1}월 ${now.getDate()}일`;
|
|
}
|
|
} catch (error) {
|
|
console.error('영업일 조회 실패:', error);
|
|
const now = new Date();
|
|
document.getElementById('currentDate').textContent = `${now.getFullYear()}년 ${now.getMonth() + 1}월 ${now.getDate()}일`;
|
|
}
|
|
}
|
|
|
|
let isRegistrationClosed = false; // 접수 마감 상태 추적
|
|
|
|
// 오디오 컨텍스트 초기화 (Web Audio API)
|
|
let audioContext = null;
|
|
let speechSynthesisReady = false;
|
|
|
|
function initAudioContext() {
|
|
if (!audioContext) {
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
}
|
|
// AudioContext가 suspended 상태면 resume
|
|
if (audioContext.state === 'suspended') {
|
|
audioContext.resume();
|
|
}
|
|
}
|
|
|
|
// 음성 합성 초기화 (사용자 상호작용 후 활성화)
|
|
function initSpeechSynthesis() {
|
|
if (!speechSynthesisReady && 'speechSynthesis' in window) {
|
|
try {
|
|
// 빈 음성을 재생하여 초기화 (브라우저 보안 정책 회피)
|
|
const utterance = new SpeechSynthesisUtterance('');
|
|
utterance.volume = 0;
|
|
utterance.lang = 'ko-KR';
|
|
|
|
// 음성 로드 대기
|
|
const voices = window.speechSynthesis.getVoices();
|
|
if (voices.length > 0) {
|
|
const koreanVoice = voices.find(v => v.lang.startsWith('ko'));
|
|
if (koreanVoice) {
|
|
utterance.voice = koreanVoice;
|
|
}
|
|
}
|
|
|
|
window.speechSynthesis.speak(utterance);
|
|
speechSynthesisReady = true;
|
|
console.log('Speech synthesis initialized');
|
|
} catch (error) {
|
|
console.error('Speech synthesis init error:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 버튼 클릭 소리 (최적화된 버전)
|
|
function playClickSound() {
|
|
try {
|
|
initAudioContext();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.value = 800; // 800Hz 톤
|
|
oscillator.type = 'sine';
|
|
|
|
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.05);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.05);
|
|
} catch (error) {
|
|
console.error('Click sound error:', error);
|
|
}
|
|
}
|
|
|
|
// 제출 버튼 클릭 소리
|
|
function playSubmitSound() {
|
|
try {
|
|
initAudioContext();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.value = 1200; // 1200Hz 톤 (더 높은 음)
|
|
oscillator.type = 'sine';
|
|
|
|
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.1);
|
|
} catch (error) {
|
|
console.error('Submit sound error:', error);
|
|
}
|
|
}
|
|
|
|
// 음성 안내 (Web Speech API)
|
|
function speakMessage(message) {
|
|
try {
|
|
if ('speechSynthesis' in window) {
|
|
// 음성 합성이 초기화되지 않았으면 초기화 시도
|
|
if (!speechSynthesisReady) {
|
|
initSpeechSynthesis();
|
|
}
|
|
|
|
// 기존 음성 취소
|
|
window.speechSynthesis.cancel();
|
|
|
|
// 짧은 대기 후 재생 (취소 후 즉시 재생 방지)
|
|
setTimeout(() => {
|
|
const utterance = new SpeechSynthesisUtterance(message);
|
|
utterance.lang = 'ko-KR';
|
|
utterance.rate = 1.0;
|
|
utterance.pitch = 1.0;
|
|
utterance.volume = 1.0;
|
|
|
|
// 한국어 음성 선택
|
|
const voices = window.speechSynthesis.getVoices();
|
|
if (voices.length > 0) {
|
|
const koreanVoice = voices.find(v => v.lang.startsWith('ko'));
|
|
if (koreanVoice) {
|
|
utterance.voice = koreanVoice;
|
|
}
|
|
}
|
|
|
|
// 음성 로드 대기 후 재생
|
|
if (window.speechSynthesis.getVoices().length === 0) {
|
|
window.speechSynthesis.addEventListener('voiceschanged', function () {
|
|
window.speechSynthesis.speak(utterance);
|
|
}, { once: true });
|
|
} else {
|
|
window.speechSynthesis.speak(utterance);
|
|
}
|
|
|
|
console.log('Speech message queued:', message);
|
|
}, 100);
|
|
}
|
|
} catch (error) {
|
|
console.error('Speech synthesis error:', error);
|
|
}
|
|
}
|
|
|
|
function inputNumber(num) {
|
|
if (isRegistrationClosed) {
|
|
return; // 오버레이가 표시되므로 알림 없이 리턴
|
|
}
|
|
|
|
initSpeechSynthesis(); // 사용자 상호작용 시 음성 초기화
|
|
playClickSound(); // 클릭 소리 재생
|
|
|
|
// 입력 제한 로직 (스마트 감지)
|
|
if (phoneNumber.startsWith('010')) {
|
|
// 전체 핸드폰 번호 (010...) -> 11자리 제한
|
|
if (phoneNumber.length >= 11) {
|
|
// 11자리 입력 완료 시 안내 메시지
|
|
showErrorModal('핸드폰번호가 입력되었습니다.\n대기 접수를 눌러 주세요.');
|
|
return;
|
|
}
|
|
} else {
|
|
// Suffix 모드 (010 없이 입력)
|
|
if (phoneNumber.length >= 8) {
|
|
// 8자리 입력 완료 시 안내 메시지
|
|
showErrorModal('핸드폰번호가 입력되었습니다.\n대기 접수를 눌러 주세요.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
phoneNumber += num;
|
|
updateDisplay();
|
|
}
|
|
|
|
|
|
function backspace() {
|
|
if (isRegistrationClosed) {
|
|
return; // 오버레이가 표시되므로 알림 없이 리턴
|
|
}
|
|
playClickSound(); // 클릭 소리 재생
|
|
if (phoneNumber.length > 0) {
|
|
phoneNumber = phoneNumber.slice(0, -1);
|
|
updateDisplay();
|
|
}
|
|
}
|
|
|
|
function clearInput() {
|
|
if (isRegistrationClosed) {
|
|
return; // 오버레이가 표시되므로 알림 없이 리턴
|
|
}
|
|
playClickSound(); // 클릭 소리 재생
|
|
phoneNumber = '';
|
|
updateDisplay();
|
|
}
|
|
|
|
function updateDisplay() {
|
|
const display = document.getElementById('phoneDisplay');
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
|
|
if (phoneNumber.length === 0) {
|
|
display.textContent = '010-____-____'; // Default hint
|
|
submitBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
// Case 1: Full Phone Number (Starts with 010)
|
|
if (phoneNumber.startsWith('010')) {
|
|
let formatted = phoneNumber;
|
|
if (phoneNumber.length > 7) {
|
|
formatted = phoneNumber.replace(/(\d{3})(\d{4})(\d{1,4})/, '$1-$2-$3');
|
|
} else if (phoneNumber.length > 3) {
|
|
formatted = phoneNumber.replace(/(\d{3})(\d{1,4})/, '$1-$2');
|
|
}
|
|
display.textContent = formatted;
|
|
|
|
// Enable submit if valid length (11 for phone)
|
|
submitBtn.disabled = (phoneNumber.length !== 11);
|
|
return;
|
|
}
|
|
|
|
// Case 2: Barcode / Long Number (Length > 8 and NO 010 prefix)
|
|
if (phoneNumber.length > 8) {
|
|
// Show raw digits
|
|
display.textContent = phoneNumber;
|
|
submitBtn.disabled = false; // Allow submission of barcode
|
|
return;
|
|
}
|
|
|
|
// Case 3: Phone Suffix (Length <= 8, treated as 010 suffix)
|
|
// Suffix formatting: XXXX-XXXX
|
|
let part1 = '', part2 = '';
|
|
if (phoneNumber.length <= 4) {
|
|
part1 = phoneNumber.padEnd(4, '_');
|
|
part2 = '____';
|
|
} else {
|
|
part1 = phoneNumber.substring(0, 4);
|
|
part2 = phoneNumber.substring(4).padEnd(4, '_');
|
|
}
|
|
display.textContent = `010-${part1}-${part2}`;
|
|
submitBtn.disabled = (phoneNumber.length !== 8);
|
|
}
|
|
|
|
async function submitReception() {
|
|
let payload = {};
|
|
|
|
// Smart input classification for payload
|
|
if (phoneNumber.startsWith('010')) {
|
|
if (phoneNumber.length !== 11) {
|
|
showErrorModal('전체 핸드폰번호 11자리를 입력해주세요.');
|
|
return;
|
|
}
|
|
payload = { phone: phoneNumber };
|
|
} else if (phoneNumber.length > 8) {
|
|
// Barcode Lookup Logic
|
|
try {
|
|
const searchRes = await fetch(`/api/members/?search=${phoneNumber}`, { headers: getHeaders() });
|
|
if (searchRes.ok) {
|
|
const members = await searchRes.json();
|
|
if (members && members.length > 0) {
|
|
// Found member! Use their phone.
|
|
payload = { phone: members[0].phone };
|
|
} else {
|
|
showErrorModal('해당 바코드로 등록된 회원이 없습니다.');
|
|
return;
|
|
}
|
|
} else {
|
|
// Search failed
|
|
showErrorModal('회원 조회 중 오류가 발생했습니다.');
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.error("Member lookup failed", e);
|
|
showErrorModal("시스템 오류");
|
|
return;
|
|
}
|
|
|
|
} else {
|
|
// Suffix (8 digits)
|
|
if (phoneNumber.length !== 8) {
|
|
showErrorModal('핸드폰번호 뒷자리 8자를 입력해주세요.');
|
|
return;
|
|
}
|
|
payload = { phone: '010' + phoneNumber };
|
|
}
|
|
|
|
playSubmitSound(); // 제출 버튼 클릭 소리 재생
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = '접수 중...';
|
|
|
|
try {
|
|
const response = await fetch('/api/waiting/register', {
|
|
method: 'POST',
|
|
headers: getHeaders({
|
|
'Content-Type': 'application/json'
|
|
}),
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
showResult(result);
|
|
showResult(result);
|
|
|
|
// 음성 안내 재생 (설정이 켜져있는 경우)
|
|
if (storeSettings && storeSettings.enable_waiting_voice_alert) {
|
|
let voiceMessage = `${result.class_name} 대기 접수 되었습니다`;
|
|
// 커스텀 메시지가 있으면 그것을 사용
|
|
if (storeSettings.waiting_voice_message && storeSettings.waiting_voice_message.trim() !== '') {
|
|
voiceMessage = storeSettings.waiting_voice_message;
|
|
}
|
|
speakMessage(
|
|
voiceMessage,
|
|
storeSettings.waiting_voice_name,
|
|
storeSettings.waiting_voice_rate || 1.0,
|
|
storeSettings.waiting_voice_pitch || 1.0
|
|
);
|
|
}
|
|
|
|
clearInput();
|
|
// 대기 현황 업데이트
|
|
await loadWaitingStatus();
|
|
} else {
|
|
const error = await response.json();
|
|
// 교시 접수 마감 에러 처리
|
|
if (error.detail && error.detail.includes('교시 접수가 마감')) {
|
|
showErrorModal(error.detail);
|
|
await loadWaitingStatus(); // 상태 새로고침
|
|
} else {
|
|
showErrorModal(error.detail || '접수에 실패했습니다.');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('접수 실패:', error);
|
|
showErrorModal('접수 중 오류가 발생했습니다.');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = '대기 접수';
|
|
}
|
|
}
|
|
|
|
function showResult(result) {
|
|
const message = document.getElementById('resultMessage');
|
|
|
|
// Settings (Default fallback)
|
|
const timeout = (storeSettings && storeSettings.waiting_modal_timeout) ? storeSettings.waiting_modal_timeout * 1000 : 5000;
|
|
const showName = (storeSettings && storeSettings.show_member_name_in_waiting_modal !== undefined) ? storeSettings.show_member_name_in_waiting_modal : true;
|
|
const showNew = (storeSettings && storeSettings.show_new_member_text_in_waiting_modal !== undefined) ? storeSettings.show_new_member_text_in_waiting_modal : true;
|
|
|
|
// Display Logic
|
|
let greeting = '';
|
|
if (result.is_new_member) {
|
|
if (showNew) {
|
|
greeting = '<span style="color: #ff6b6b">신규회원님</span>';
|
|
}
|
|
} else {
|
|
if (showName) {
|
|
greeting = result.name ? `<span style="color: #2c3e50">${result.name}님</span>` : '회원님';
|
|
}
|
|
}
|
|
|
|
let greetingHtml = '';
|
|
if (greeting) {
|
|
greetingHtml = `<div style="font-size: 42px; font-weight: 700; margin-bottom: 10px;">${greeting}</div>`;
|
|
}
|
|
|
|
message.innerHTML = `
|
|
${greetingHtml}
|
|
<div style="font-size: 80px; font-weight: 700; color: #3498db; margin-bottom: 30px;">
|
|
${result.waiting_number}번
|
|
</div>
|
|
<div style="font-size: 56px; font-weight: 700; color: #2c3e50; margin-bottom: 20px;">
|
|
${result.class_name}
|
|
</div>
|
|
<div style="font-size: 36px; color: #7f8c8d;">
|
|
${result.class_order}번째 대기 <span style="color: #e74c3c; font-weight: 800; margin-left: 10px;">접수 되었습니다.</span>
|
|
</div>
|
|
`;
|
|
|
|
const modal = document.getElementById('resultModal');
|
|
modal.classList.add('active');
|
|
|
|
// Auto-close based on settings
|
|
if (window.resultModalTimer) clearTimeout(window.resultModalTimer);
|
|
window.resultModalTimer = setTimeout(() => {
|
|
closeModal();
|
|
}, timeout);
|
|
}
|
|
|
|
function closeModal() {
|
|
const modal = document.getElementById('resultModal');
|
|
modal.classList.remove('active');
|
|
if (window.resultModalTimer) {
|
|
clearTimeout(window.resultModalTimer);
|
|
window.resultModalTimer = null;
|
|
}
|
|
}
|
|
|
|
let errorModalTimer = null;
|
|
|
|
function showErrorModal(message) {
|
|
// 메시지를 줄바꿈(\n)으로 분리
|
|
const lines = message.split('\n');
|
|
const mainMessage = lines[0] || message; // 첫 번째 줄
|
|
const subMessage = lines.slice(1).join('\n'); // 나머지 줄들
|
|
|
|
const errorMessageMain = document.getElementById('errorMessageMain');
|
|
const errorMessageSub = document.getElementById('errorMessageSub');
|
|
|
|
errorMessageMain.textContent = mainMessage;
|
|
errorMessageSub.textContent = subMessage;
|
|
|
|
const modal = document.getElementById('errorModal');
|
|
modal.classList.add('active');
|
|
|
|
// Clear existing timer if any
|
|
if (errorModalTimer) {
|
|
clearTimeout(errorModalTimer);
|
|
}
|
|
|
|
// Auto-close after 3 seconds
|
|
errorModalTimer = setTimeout(() => {
|
|
closeErrorModal();
|
|
}, 3000);
|
|
}
|
|
|
|
function closeErrorModal() {
|
|
const modal = document.getElementById('errorModal');
|
|
modal.classList.remove('active');
|
|
|
|
// Clear timer when manually closed
|
|
if (errorModalTimer) {
|
|
clearTimeout(errorModalTimer);
|
|
errorModalTimer = null;
|
|
}
|
|
}
|
|
|
|
// 키보드 입력 지원
|
|
document.addEventListener('keydown', (e) => {
|
|
// ESC 키로 에러 모달 닫기
|
|
if (e.key === 'Escape') {
|
|
const errorModal = document.getElementById('errorModal');
|
|
const resultModal = document.getElementById('resultModal');
|
|
|
|
if (errorModal.classList.contains('active')) {
|
|
closeErrorModal();
|
|
return;
|
|
} else if (resultModal.classList.contains('active')) {
|
|
closeModal();
|
|
return;
|
|
} else {
|
|
clearInput();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key >= '0' && e.key <= '9') {
|
|
inputNumber(e.key);
|
|
} else if (e.key === 'Backspace') {
|
|
backspace();
|
|
} else if (e.key === 'Enter' && phoneNumber.length === 8) {
|
|
submitReception();
|
|
}
|
|
});
|
|
|
|
// 모달 외부 클릭 시 닫기
|
|
document.getElementById('errorModal').addEventListener('click', function (e) {
|
|
if (e.target === this) {
|
|
closeErrorModal();
|
|
}
|
|
});
|
|
|
|
document.getElementById('resultModal').addEventListener('click', function (e) {
|
|
if (e.target === this) {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
// URL 파라미터에서 매장 정보 가져오기
|
|
async function checkUrlStoreParam() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const storeParam = urlParams.get('store');
|
|
|
|
if (storeParam) {
|
|
try {
|
|
const response = await fetch(`/ api / stores / code / ${storeParam} `);
|
|
if (response.ok) {
|
|
const store = await response.json();
|
|
localStorage.setItem('selected_store_id', store.id);
|
|
localStorage.setItem('selected_store_name', store.name);
|
|
console.log(`URL 매장 파라미터 적용: ${store.name} (코드: ${storeParam})`);
|
|
} else {
|
|
console.error('매장 코드를 찾을 수 없습니다:', storeParam);
|
|
}
|
|
} catch (e) {
|
|
console.error('매장 정보 조회 실패:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 음성 안내 함수
|
|
function speakMessage(message, voiceName = null, rate = 1.0, pitch = 1.0) {
|
|
if (!window.speechSynthesis) return;
|
|
|
|
// 기존 음성 중지
|
|
window.speechSynthesis.cancel();
|
|
|
|
const utterance = new SpeechSynthesisUtterance(message);
|
|
utterance.lang = 'ko-KR';
|
|
utterance.rate = rate;
|
|
utterance.pitch = pitch;
|
|
|
|
if (voiceName) {
|
|
const voices = window.speechSynthesis.getVoices();
|
|
const selectedVoice = voices.find(voice => voice.name === voiceName);
|
|
if (selectedVoice) {
|
|
utterance.voice = selectedVoice;
|
|
}
|
|
}
|
|
|
|
window.speechSynthesis.speak(utterance);
|
|
}
|
|
|
|
// 초기 로드
|
|
async function init() {
|
|
console.log('[INIT] Starting reception desk initialization...');
|
|
await checkUrlStoreParam();
|
|
loadStoreInfo();
|
|
updateDate();
|
|
updateDisplay();
|
|
console.log('[INIT] Basic initialization complete, setting up SSE...');
|
|
|
|
// SSE 연결 설정 (실시간 업데이트 - 트래픽 효율적)
|
|
const storeId = localStorage.getItem('selected_store_id');
|
|
console.log('[SSE] Initializing SSE connection for store:', storeId);
|
|
|
|
if (storeId) {
|
|
const sseUrl = `/api/sse/stream?store_id=${storeId}`;
|
|
console.log('[SSE] Connecting to:', sseUrl);
|
|
|
|
let eventSource = null;
|
|
let reconnectAttempts = 0;
|
|
const maxReconnectAttempts = 5;
|
|
|
|
function connectSSE() {
|
|
// Debounce Utility
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Debounced loadWaitingStatus
|
|
const debouncedLoadWaitingStatus = debounce(() => loadWaitingStatus(), 300);
|
|
|
|
try {
|
|
eventSource = new EventSource(sseUrl);
|
|
|
|
eventSource.onopen = () => {
|
|
console.log('[SSE] ✅ Connection opened successfully');
|
|
reconnectAttempts = 0; // 연결 성공 시 재시도 카운터 리셋
|
|
};
|
|
|
|
// 백엔드(sse_manager.py)가 모든 이벤트를 generic 'message' 타입으로 보내고
|
|
// 페이로드 내부에 event 타입을 포함하는 방식이므로, onmessage 하나로 처리합니다.
|
|
eventSource.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
// console.log('[SSE] 📥 Message received:', data);
|
|
|
|
// data.event 필드로 이벤트 타입 확인
|
|
if (data.event === 'new_user') {
|
|
console.log('[SSE] 📥 new_user event detected');
|
|
debouncedLoadWaitingStatus();
|
|
}
|
|
else if (data.event === 'status_change' || data.event === 'status_changed') {
|
|
console.log('[SSE] 📥 status_change event detected');
|
|
debouncedLoadWaitingStatus();
|
|
}
|
|
else if (data.event === 'class_closed' || data.event === 'class_reopened') {
|
|
console.log('[SSE] 📥 class status changed');
|
|
debouncedLoadWaitingStatus();
|
|
}
|
|
else if (data.event === 'ping') {
|
|
// Heartbeat - ignore
|
|
}
|
|
else if (data.event === 'connected') {
|
|
console.log('[SSE] Connected confirmed');
|
|
}
|
|
} catch (e) {
|
|
console.error('[SSE] Error parsing event data:', e);
|
|
}
|
|
};
|
|
|
|
eventSource.onerror = (error) => {
|
|
console.error('[SSE] ❌ Connection error');
|
|
|
|
// EventSource는 자동으로 재연결을 시도하지만,
|
|
// 완전히 실패한 경우 수동 재연결
|
|
if (eventSource.readyState === EventSource.CLOSED) {
|
|
eventSource.close();
|
|
|
|
if (reconnectAttempts < maxReconnectAttempts) {
|
|
reconnectAttempts++;
|
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000); // 지수 백오프 (최대 30초)
|
|
console.log(`[SSE] 🔄 Reconnecting in ${delay / 1000} s(attempt ${reconnectAttempts} / ${maxReconnectAttempts})...`);
|
|
setTimeout(connectSSE, delay);
|
|
} else {
|
|
console.error('[SSE] ⛔ Max reconnection attempts reached. Please refresh the page.');
|
|
}
|
|
}
|
|
};
|
|
} catch (e) {
|
|
console.error('[SSE] Failed to create EventSource:', e);
|
|
}
|
|
}
|
|
|
|
// 초기 연결 시작
|
|
connectSSE();
|
|
|
|
} else {
|
|
console.warn('[SSE] ⚠️ No store ID found in localStorage');
|
|
console.warn('[SSE] Please select a store first');
|
|
}
|
|
|
|
// 첫 번째 사용자 상호작용 시 오디오 및 음성 합성 초기화
|
|
const initAudio = () => {
|
|
initAudioContext();
|
|
initSpeechSynthesis();
|
|
};
|
|
|
|
// 클릭 또는 터치 시 한 번만 실행
|
|
document.addEventListener('click', initAudio, { once: true });
|
|
document.addEventListener('touchstart', initAudio, { once: true });
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
|
|
</html> |