From 8384d1a1d9ae7863517553432e0689344178fa6a Mon Sep 17 00:00:00 2001 From: "68893236+KINDNICK@users.noreply.github.com" <68893236+KINDNICK@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:12:43 +0900 Subject: [PATCH] =?UTF-8?q?Add:=20Contact=20=EB=AC=B8=EC=9D=98=20=ED=8F=BC?= =?UTF-8?q?=20+=20Discord=20=EB=B4=87=20=EC=97=B0=EB=8F=99=20+=20PIPA=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4=EC=B2=98=EB=A6=AC=EB=B0=A9?= =?UTF-8?q?=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문의 폼 프론트엔드 (이름, 이메일, 전화, 문의유형, 메시지, 개인정보동의) - api-server.py에 /api/contact 엔드포인트 추가 (Discord Bot API 2채널) - bot/discord_cleanup.py: 7일 후 자동 삭제 봇 - 개인정보처리방침 모달 (PIPA 10개 항목, 국외이전 명시) - 민감정보 입력 경고 문구 - 개인정보 동의 체크 시에만 제출 버튼 활성화 - API 실패 시 대체 연락 안내 오버레이 (이메일/전화/Discord) - 4개 언어 i18n 지원 (ko/en/zh/ja) Co-Authored-By: Claude Opus 4.6 --- .env.example | 20 ++++ .gitignore | Bin 32 -> 38 bytes api-server.py | 145 +++++++++++++++++++++++++++++ bot/discord_cleanup.py | 195 +++++++++++++++++++++++++++++++++++++++ contact.html | 179 ++++++++++++++++++++++++++++++++++++ css/contact.css | 202 +++++++++++++++++++++++++++++++++++++++++ en/contact.html | 178 ++++++++++++++++++++++++++++++++++++ i18n/en.json | 46 +++++++++- i18n/ja.json | 46 +++++++++- i18n/ko.json | 46 +++++++++- i18n/zh.json | 46 +++++++++- ja/contact.html | 178 ++++++++++++++++++++++++++++++++++++ js/contact.js | 184 +++++++++++++++++++++++++------------ zh/contact.html | 178 ++++++++++++++++++++++++++++++++++++ 14 files changed, 1579 insertions(+), 64 deletions(-) create mode 100644 .env.example create mode 100644 bot/discord_cleanup.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ef33d75 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# ─── Discord 봇 설정 ───────────────────────────── +# 봇 토큰: https://discord.com/developers/applications 에서 봇 생성 후 토큰 복사 +DISCORD_BOT_TOKEN=YOUR_BOT_TOKEN_HERE + +# 문의 상세 내용이 전달될 채널 ID (본계정만 열람 가능한 비공개 채널) +DISCORD_INQUIRY_CHANNEL_ID=YOUR_INQUIRY_CHANNEL_ID + +# 관리자 알림 채널 ID (멘션으로 알림만 보냄) +DISCORD_NOTIFY_CHANNEL_ID=YOUR_NOTIFY_CHANNEL_ID + +# 관리자 Discord User ID (쉼표 구분) +# Discord 개발자 모드 켜기 > 유저 우클릭 > ID 복사 +DISCORD_ADMIN_IDS=123456789,987654321,111222333 + +# ─── 자동 삭제 설정 ────────────────────────────── +# 문의 채널 메시지 삭제까지 보관 일수 (기본: 7일) +DELETE_AFTER_DAYS=7 + +# 삭제 체크 주기 (시간 단위, 기본: 6시간) +CHECK_INTERVAL_HOURS=6 diff --git a/.gitignore b/.gitignore index 27592c817d6e4658c948c5c0dbeba8fcfa56be6e..d339555c41f4eb8f50307c1d93ecd3a7a8309d9f 100644 GIT binary patch literal 38 mcmc~}$Y)4lNM(p;$OW@X8FGNUVg`K%E(SdyE03WJ$N~V8_y)NE delta 12 TcmY#WVEX@Wf)ooc0~Z4T85aW! diff --git a/api-server.py b/api-server.py index f1b845a..b84d686 100644 --- a/api-server.py +++ b/api-server.py @@ -10,6 +10,8 @@ import socketserver import os import sys import json +import urllib.request +import ssl from datetime import datetime from urllib.parse import urlparse @@ -31,6 +33,54 @@ HOST = '127.0.0.1' # 로컬만 허용 (Caddy가 프록시) WORKING_DIR = os.path.dirname(os.path.abspath(__file__)) os.chdir(WORKING_DIR) +# Discord 설정 (.env 파일에서 로드) +DISCORD_BOT_TOKEN = '' +DISCORD_INQUIRY_CHANNEL_ID = '' # 문의 상세 (본계정만 열람) +DISCORD_NOTIFY_CHANNEL_ID = '' # 관리자 알림 (멘션) +DISCORD_ADMIN_IDS = [] # 멘션할 관리자 User ID 목록 + +def load_env(): + """Load .env file""" + global DISCORD_BOT_TOKEN, DISCORD_INQUIRY_CHANNEL_ID, DISCORD_NOTIFY_CHANNEL_ID, DISCORD_ADMIN_IDS + env_path = os.path.join(WORKING_DIR, '.env') + if os.path.exists(env_path): + with open(env_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key == 'DISCORD_BOT_TOKEN': + DISCORD_BOT_TOKEN = value + elif key == 'DISCORD_INQUIRY_CHANNEL_ID': + DISCORD_INQUIRY_CHANNEL_ID = value + elif key == 'DISCORD_NOTIFY_CHANNEL_ID': + DISCORD_NOTIFY_CHANNEL_ID = value + elif key == 'DISCORD_ADMIN_IDS': + DISCORD_ADMIN_IDS = [id.strip() for id in value.split(',') if id.strip()] + +load_env() + +def discord_bot_send(channel_id, payload): + """Discord Bot API로 메시지 전송""" + url = f'https://discord.com/api/v10/channels/{channel_id}/messages' + body = json.dumps(payload).encode('utf-8') + req = urllib.request.Request( + url, + data=body, + headers={ + 'Content-Type': 'application/json', + 'Authorization': f'Bot {DISCORD_BOT_TOKEN}' + }, + method='POST' + ) + ctx = ssl.create_default_context() + with urllib.request.urlopen(req, context=ctx, timeout=10) as resp: + if resp.status not in (200, 201): + raise Exception(f'Discord API failed: {resp.status}') + return json.loads(resp.read().decode('utf-8')) + class APIRequestHandler(http.server.BaseHTTPRequestHandler): """API 전용 HTTP 요청 핸들러""" @@ -67,6 +117,8 @@ class APIRequestHandler(http.server.BaseHTTPRequestHandler): self.handle_post_backgrounds() elif parsed_path.path == '/api/props': self.handle_post_props() + elif parsed_path.path == '/api/contact': + self.handle_post_contact() else: self.send_error_json(404, 'API endpoint not found') @@ -200,6 +252,91 @@ class APIRequestHandler(http.server.BaseHTTPRequestHandler): except Exception as e: self.send_error_json(500, f'데이터 저장 실패: {str(e)}') + def handle_post_contact(self): + """문의 폼 처리 → Discord Bot API (2채널: 문의상세 + 관리자알림)""" + try: + content_length = int(self.headers.get('Content-Length', 0)) + if content_length == 0: + self.send_error_json(400, '요청 본문이 비어있습니다') + return + + post_data = self.rfile.read(content_length) + try: + data = json.loads(post_data.decode('utf-8')) + except json.JSONDecodeError as e: + self.send_error_json(400, f'잘못된 JSON 형식: {str(e)}') + return + + # 필수 필드 검증 + required = ['name', 'email', 'service', 'message'] + for field in required: + if not data.get(field, '').strip(): + self.send_error_json(400, f'{field} 필드가 필요합니다') + return + + # 문의 유형 한글 매핑 + service_map = { + 'studio_rental': '스튜디오 대관', + 'vtuber': 'VTuber 제작', + 'mocap': '모션캡쳐 촬영', + 'music_video': '뮤직비디오 제작', + 'partnership': '제휴/협력', + 'other': '기타' + } + service_label = service_map.get(data['service'], data['service']) + + # Discord 봇 설정 확인 + if not DISCORD_BOT_TOKEN or not DISCORD_INQUIRY_CHANNEL_ID: + print("[API] Warning: Discord bot not configured") + self.send_error_json(500, '서버 설정 오류') + return + + now = datetime.now() + + # 1) 문의 상세 채널에 전체 내용 전송 + embed = { + 'title': '📩 새로운 문의가 접수되었습니다', + 'color': 0xff8800, + 'fields': [ + {'name': '👤 이름', 'value': data['name'], 'inline': True}, + {'name': '📧 이메일', 'value': data['email'], 'inline': True}, + {'name': '📱 전화번호', 'value': data.get('phone', '-') or '-', 'inline': True}, + {'name': '📋 문의 유형', 'value': service_label, 'inline': True}, + {'name': '💬 문의 내용', 'value': data['message'][:1024], 'inline': False}, + ], + 'footer': {'text': f'⏰ {now.strftime("%Y-%m-%d %H:%M")} | 7일 후 자동 삭제'}, + 'timestamp': now.isoformat() + } + discord_bot_send(DISCORD_INQUIRY_CHANNEL_ID, {'embeds': [embed]}) + + # 2) 관리자 알림 채널에 멘션 알림 전송 + if DISCORD_NOTIFY_CHANNEL_ID and DISCORD_ADMIN_IDS: + mentions = ' '.join(f'<@{uid}>' for uid in DISCORD_ADMIN_IDS) + notify_msg = f'📩 **고객 문의가 접수되었습니다!**\n\n> **{data["name"]}**님 | {service_label}\n\n{mentions}' + discord_bot_send(DISCORD_NOTIFY_CHANNEL_ID, { + 'content': notify_msg, + 'allowed_mentions': {'users': DISCORD_ADMIN_IDS} + }) + + # 성공 응답 + response = { + 'success': True, + 'message': '문의가 성공적으로 전송되었습니다.' + } + self.send_response(200) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.end_headers() + self.wfile.write(json.dumps(response, ensure_ascii=False).encode('utf-8')) + + print(f"[API] 문의 접수: {data['name']} ({service_label})") + + except urllib.error.URLError as e: + print(f"[API] Discord API error: {e}") + self.send_error_json(500, '문의 전송에 실패했습니다. 잠시 후 다시 시도해 주세요.') + except Exception as e: + print(f"[API] Contact error: {e}") + self.send_error_json(500, f'문의 처리 실패: {str(e)}') + def send_error_json(self, code, message): """JSON 형식 에러 응답""" self.send_response(code) @@ -228,6 +365,14 @@ def main(): print(f" POST /api/backgrounds") print(f" GET /api/props") print(f" POST /api/props") + print(f" POST /api/contact → Discord Bot API") + if DISCORD_BOT_TOKEN: + print(f" Discord Bot: configured") + print(f" Inquiry channel: {DISCORD_INQUIRY_CHANNEL_ID or 'NOT SET'}") + print(f" Notify channel: {DISCORD_NOTIFY_CHANNEL_ID or 'NOT SET'}") + print(f" Admin mentions: {len(DISCORD_ADMIN_IDS)} user(s)") + else: + print(f" ⚠ Discord Bot: NOT configured (set DISCORD_BOT_TOKEN in .env)") print("=" * 40) try: diff --git a/bot/discord_cleanup.py b/bot/discord_cleanup.py new file mode 100644 index 0000000..c7f1de7 --- /dev/null +++ b/bot/discord_cleanup.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +밍글 스튜디오 Discord 문의 자동 삭제 봇 +- 지정된 채널의 메시지를 7일 후 자동 삭제 +- 개인정보보호법(PIPA) 준수를 위한 데이터 보관기간 관리 +""" + +import os +import sys +import json +import urllib.request +import ssl +import time +from datetime import datetime, timedelta, timezone + +# ─── 설정 ─────────────────────────────────────── +BOT_TOKEN = '' +CHANNEL_ID = '' +DELETE_AFTER_DAYS = 7 +CHECK_INTERVAL_HOURS = 6 # 삭제 체크 주기 (시간) + +def load_env(): + """Load .env file from parent directory""" + global BOT_TOKEN, CHANNEL_ID, DELETE_AFTER_DAYS, CHECK_INTERVAL_HOURS + + env_paths = [ + os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '.env'), + os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env'), + ] + + for env_path in env_paths: + if os.path.exists(env_path): + with open(env_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key == 'DISCORD_BOT_TOKEN': + BOT_TOKEN = value + elif key == 'DISCORD_INQUIRY_CHANNEL_ID': + CHANNEL_ID = value + elif key == 'DELETE_AFTER_DAYS': + DELETE_AFTER_DAYS = int(value) + elif key == 'CHECK_INTERVAL_HOURS': + CHECK_INTERVAL_HOURS = int(value) + break + +def discord_api(method, endpoint, data=None): + """Discord API 호출""" + url = f'https://discord.com/api/v10{endpoint}' + headers = { + 'Authorization': f'Bot {BOT_TOKEN}', + 'Content-Type': 'application/json', + } + + body = json.dumps(data).encode('utf-8') if data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + + ctx = ssl.create_default_context() + try: + with urllib.request.urlopen(req, context=ctx, timeout=15) as resp: + if resp.status == 204: + return None + return json.loads(resp.read().decode('utf-8')) + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8') if e.fp else '' + print(f"[ERROR] Discord API {method} {endpoint}: {e.code} {error_body}") + + # Rate limit 처리 + if e.code == 429: + retry_data = json.loads(error_body) if error_body else {} + retry_after = retry_data.get('retry_after', 5) + print(f"[RATE LIMIT] Waiting {retry_after}s...") + time.sleep(retry_after + 0.5) + return discord_api(method, endpoint, data) + + raise + +def get_old_messages(): + """지정 채널에서 만료된 메시지 조회""" + cutoff = datetime.now(timezone.utc) - timedelta(days=DELETE_AFTER_DAYS) + old_messages = [] + last_id = None + + while True: + params = f'?limit=100' + if last_id: + params += f'&before={last_id}' + + messages = discord_api('GET', f'/channels/{CHANNEL_ID}/messages{params}') + + if not messages: + break + + for msg in messages: + # Discord 타임스탬프 파싱 + msg_time = datetime.fromisoformat(msg['timestamp'].replace('+00:00', '+00:00')) + if msg_time.tzinfo is None: + msg_time = msg_time.replace(tzinfo=timezone.utc) + + if msg_time < cutoff: + old_messages.append({ + 'id': msg['id'], + 'timestamp': msg['timestamp'], + 'content': msg.get('content', '')[:50] + }) + + last_id = messages[-1]['id'] + + # 가장 오래된 메시지도 만료 기간 내라면 더 조회할 필요 없음 + oldest_time = datetime.fromisoformat(messages[-1]['timestamp'].replace('+00:00', '+00:00')) + if oldest_time.tzinfo is None: + oldest_time = oldest_time.replace(tzinfo=timezone.utc) + if oldest_time >= cutoff: + continue + else: + break + + time.sleep(0.5) # Rate limit 방지 + + return old_messages + +def delete_messages(messages): + """메시지 삭제""" + deleted = 0 + for msg in messages: + try: + discord_api('DELETE', f'/channels/{CHANNEL_ID}/messages/{msg["id"]}') + print(f" [DELETE] {msg['id']} ({msg['timestamp'][:10]})") + deleted += 1 + time.sleep(0.5) # Rate limit 방지 + except Exception as e: + print(f" [ERROR] Failed to delete {msg['id']}: {e}") + return deleted + +def run_cleanup(): + """한 번의 정리 사이클 실행""" + now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + print(f"\n[{now}] Cleanup check started (delete after {DELETE_AFTER_DAYS} days)") + + try: + messages = get_old_messages() + + if messages: + print(f" Found {len(messages)} expired message(s)") + deleted = delete_messages(messages) + print(f" Deleted {deleted}/{len(messages)} message(s)") + else: + print(f" No expired messages found") + + except Exception as e: + print(f" [ERROR] Cleanup failed: {e}") + +def main(): + """메인 실행""" + load_env() + + if not BOT_TOKEN: + print("Error: DISCORD_BOT_TOKEN not set in .env") + sys.exit(1) + if not CHANNEL_ID: + print("Error: DISCORD_CHANNEL_ID not set in .env") + sys.exit(1) + + print("Mingle Studio Discord Cleanup Bot") + print("=" * 40) + print(f"Channel ID: {CHANNEL_ID}") + print(f"Delete after: {DELETE_AFTER_DAYS} days") + print(f"Check interval: {CHECK_INTERVAL_HOURS} hours") + print("=" * 40) + + # 봇 정보 확인 + try: + bot_info = discord_api('GET', '/users/@me') + print(f"Bot: {bot_info['username']}#{bot_info.get('discriminator', '0')}") + except Exception as e: + print(f"Error: Failed to connect to Discord: {e}") + sys.exit(1) + + print("Bot started. Press Ctrl+C to stop.\n") + + try: + while True: + run_cleanup() + print(f" Next check in {CHECK_INTERVAL_HOURS} hours...") + time.sleep(CHECK_INTERVAL_HOURS * 3600) + except KeyboardInterrupt: + print("\nBot stopped.") + + +if __name__ == '__main__': + main() diff --git a/contact.html b/contact.html index 9109426..c4f320b 100644 --- a/contact.html +++ b/contact.html @@ -190,6 +190,185 @@ + +
+
+
+

온라인 문의

+

아래 양식을 작성하시면 빠르게 답변 드리겠습니다

+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

※ 주민등록번호, 계좌번호 등 민감한 개인정보를 입력하지 마세요.

+
+ +
+ + 개인정보 처리방침 보기 +
+ +
+
    +
  • 수집 목적: 문의 접수 및 답변
  • +
  • 수집 항목: 이름, 이메일, 전화번호, 문의 내용
  • +
  • 보유 기간: 7일 후 자동 파기
  • +
+
+ +
+ + +
+
+
+
+
+ + + + +
diff --git a/css/contact.css b/css/contact.css index dd2cb43..d75af9d 100644 --- a/css/contact.css +++ b/css/contact.css @@ -203,6 +203,186 @@ color: var(--secondary-color); } +/* 필드 힌트 */ +.field-hint { + color: #ef4444; + font-size: var(--font-xs); + margin-top: 0.375rem; + margin-bottom: 0; +} + +/* 비활성 제출 버튼 */ +.btn-submit-disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* API 에러 폴백 오버레이 */ +.api-error-fallback { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-lg); +} + +.api-error-content { + background: var(--bg-white); + border-radius: var(--border-radius); + max-width: 480px; + width: 100%; + padding: var(--spacing-2xl); + text-align: center; + position: relative; +} + +.api-error-close { + position: absolute; + top: 12px; + right: 16px; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-secondary); + line-height: 1; +} + +.api-error-close:hover { + color: var(--primary-color); +} + +.api-error-icon { + font-size: 3rem; + color: #ef4444; + margin-bottom: var(--spacing-md); +} + +.api-error-content h3 { + margin: 0 0 var(--spacing-sm); + color: var(--text-primary); +} + +.api-error-content > p { + color: var(--text-secondary); + font-size: var(--font-sm); + margin-bottom: var(--spacing-xl); +} + +.api-error-contacts { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.api-error-method { + display: flex; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-md) var(--spacing-lg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + text-decoration: none; + color: var(--text-primary); + transition: var(--transition); +} + +.api-error-method:hover { + border-color: var(--primary-color); + background: rgba(255, 136, 0, 0.05); +} + +.api-error-method i { + font-size: 1.25rem; + width: 24px; + text-align: center; + color: var(--primary-color); +} + +.api-error-method div { + text-align: left; +} + +.api-error-method strong { + display: block; + font-size: var(--font-sm); +} + +.api-error-method span { + font-size: var(--font-xs); + color: var(--text-secondary); +} + +/* 다크모드 API 에러 */ +[data-theme="dark"] .api-error-content { + background: var(--dark-bg-secondary); +} + +[data-theme="dark"] .api-error-content h3 { + color: var(--dark-text-primary); +} + +[data-theme="dark"] .api-error-content > p { + color: var(--dark-text-secondary); +} + +[data-theme="dark"] .api-error-method { + border-color: var(--dark-border); + color: var(--dark-text-primary); +} + +[data-theme="dark"] .api-error-method:hover { + background: rgba(255, 136, 0, 0.1); +} + +[data-theme="dark"] .api-error-method span { + color: var(--dark-text-secondary); +} + +/* 개인정보 요약 */ +.privacy-summary { + background: var(--bg-light, #f8f9fa); + border-radius: var(--border-radius-sm); + padding: var(--spacing-md) var(--spacing-lg); + margin-bottom: var(--spacing-lg); + font-size: var(--font-xs); + color: var(--text-secondary); + border-left: 3px solid var(--primary-color); +} + +.privacy-summary ul { + list-style: none; + padding: 0; + margin: 0; +} + +.privacy-summary li { + margin-bottom: var(--spacing-xs); + line-height: 1.5; +} + +.privacy-summary li:last-child { + margin-bottom: 0; +} + +/* 필수 표시 */ +.required { + color: #ef4444; +} + +[data-theme="dark"] .privacy-summary { + background: rgba(255, 255, 255, 0.04); + border-left-color: var(--primary-color); + color: var(--dark-text-tertiary); +} + /* 폼 제출 버튼 */ .form-submit { display: flex; @@ -353,6 +533,25 @@ max-height: 80vh; width: 90%; overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--primary-color) transparent; +} + +.modal-content::-webkit-scrollbar { + width: 6px; +} + +.modal-content::-webkit-scrollbar-track { + background: transparent; +} + +.modal-content::-webkit-scrollbar-thumb { + background: var(--primary-color); + border-radius: 3px; +} + +.modal-content::-webkit-scrollbar-thumb:hover { + background: var(--secondary-color); } .modal-header { @@ -372,6 +571,9 @@ font-size: var(--font-2xl); cursor: pointer; color: var(--text-secondary); + background: none; + border: none; + padding: 0; width: 30px; height: 30px; display: flex; diff --git a/en/contact.html b/en/contact.html index 776b135..d1ea4ae 100644 --- a/en/contact.html +++ b/en/contact.html @@ -197,6 +197,184 @@
+ +
+
+
+

온라인 문의

+

아래 양식을 작성하시면 빠르게 답변 드리겠습니다

+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

※ 주민등록번호, 계좌번호 등 민감한 개인정보를 입력하지 마세요.

+
+ +
+ + 개인정보 처리방침 보기 +
+ +
+
    +
  • 수집 목적: 문의 접수 및 답변
  • +
  • 수집 항목: 이름, 이메일, 전화번호, 문의 내용
  • +
  • 보유 기간: 7일 후 자동 파기
  • +
+
+ +
+ + +
+
+
+
+
+ + + + +
diff --git a/i18n/en.json b/i18n/en.json index faa5d4c..46f5bf2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -615,6 +615,44 @@ "naverMap": "Naver Map", "googleMap": "Google Maps" }, + "form": { + "title": "Online Inquiry", + "desc": "Fill out the form below and we'll get back to you promptly", + "name": "Name *", + "namePlaceholder": "John Doe", + "email": "Email *", + "phone": "Phone Number", + "service": "Inquiry Type *", + "serviceDefault": "Please select", + "serviceRental": "Studio Rental", + "serviceVtuber": "VTuber Production", + "serviceMocap": "Motion Capture", + "serviceMV": "Music Video Production", + "servicePartner": "Partnership", + "serviceOther": "Other", + "message": "Message *", + "messagePlaceholder": "Please describe your project, preferred schedule, etc.", + "sensitiveWarning": "※ Please do not include sensitive information such as national ID or bank account numbers.", + "privacyAgree": "I agree to the collection and use of personal information. (Required)", + "privacyView": "View Privacy Policy", + "privacyPurpose": "Purpose: Inquiry processing and response", + "privacyItems": "Items collected: Name, email, phone number, message", + "privacyPeriod": "Retention: Automatically deleted after 7 days", + "submit": "Send Inquiry", + "reset": "Reset", + "privacyModalTitle": "Personal Information Collection & Use Notice", + "privacyM1Title": "1. Purpose of Collection", + "privacyM1Desc": "Processing customer inquiries and providing responses", + "privacyM2Title": "2. Items Collected", + "privacyM2Required": "Required: Name, email, inquiry type, message", + "privacyM2Optional": "Optional: Phone number", + "privacyM3Title": "3. Retention Period", + "privacyM3Desc": "Data is automatically deleted 7 days after the inquiry is received.", + "privacyM4Title": "4. Right to Refuse", + "privacyM4Desc": "You have the right to refuse the collection and use of personal information. However, refusal will prevent inquiry submission.", + "privacyM5Title": "5. Third-Party Provision", + "privacyM5Desc": "Inquiry content is transmitted through a secure channel and may pass through overseas servers. It will not be used for purposes other than inquiry processing." + }, "cta": { "title": "Reservations & Inquiries", "desc": "Make an easy online reservation or check our frequently asked questions", @@ -626,10 +664,16 @@ "sending": "Sending...", "sendSuccess": "Your inquiry has been sent successfully. We will contact you shortly.", "sendError": "An error occurred while sending. Please try again.", + "errorTitle": "Failed to send", + "errorDesc": "Your inquiry could not be sent due to a temporary server error. Please contact us directly using the methods below.", + "errorEmail": "Email", + "errorPhone": "Phone", + "errorDiscord": "Contact us on Discord", "resetConfirm": "All entered data will be deleted. Continue?", "invalidEmail": "Please enter a valid email address.", "invalidPhone": "Please enter a valid phone number.", - "required": "This field is required." + "required": "This field is required.", + "privacyRequired": "Please agree to the collection and use of personal information." } }, "qna": { diff --git a/i18n/ja.json b/i18n/ja.json index 9605b67..4e1a377 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -615,6 +615,44 @@ "naverMap": "Naver地図", "googleMap": "Google マップ" }, + "form": { + "title": "オンラインお問い合わせ", + "desc": "以下のフォームにご記入いただければ、迅速にご返答いたします", + "name": "お名前 *", + "namePlaceholder": "山田太郎", + "email": "メールアドレス *", + "phone": "電話番号", + "service": "お問い合わせ種類 *", + "serviceDefault": "選択してください", + "serviceRental": "スタジオレンタル", + "serviceVtuber": "VTuber制作", + "serviceMocap": "モーションキャプチャー撮影", + "serviceMV": "ミュージックビデオ制作", + "servicePartner": "提携・協力", + "serviceOther": "その他", + "message": "お問い合わせ内容 *", + "messagePlaceholder": "プロジェクトの内容、ご希望の日程などをご自由にお書きください", + "sensitiveWarning": "※ お問い合わせ内容にマイナンバー、口座番号等の機密個人情報を含めないようご注意ください。", + "privacyAgree": "個人情報の収集及び利用に同意します。(必須)", + "privacyView": "プライバシーポリシーを見る", + "privacyPurpose": "収集目的:お問い合わせ受付及び回答", + "privacyItems": "収集項目:氏名、メール、電話番号、お問い合わせ内容", + "privacyPeriod": "保管期間:7日後に自動削除", + "submit": "お問い合わせを送信", + "reset": "リセット", + "privacyModalTitle": "個人情報の収集及び利用に関するご案内", + "privacyM1Title": "1. 収集目的", + "privacyM1Desc": "お客様のお問い合わせ受付及び回答の提供", + "privacyM2Title": "2. 収集項目", + "privacyM2Required": "必須:氏名、メールアドレス、お問い合わせ種類、お問い合わせ内容", + "privacyM2Optional": "任意:電話番号", + "privacyM3Title": "3. 保管及び利用期間", + "privacyM3Desc": "お問い合わせ受付日から7日間保管後、自動的に削除されます。", + "privacyM4Title": "4. 同意拒否の権利", + "privacyM4Desc": "個人情報の収集及び利用への同意を拒否する権利があります。ただし、同意を拒否された場合、お問い合わせの受付ができません。", + "privacyM5Title": "5. 第三者提供", + "privacyM5Desc": "お問い合わせ内容はセキュアチャネルを通じて送信され、海外サーバーを経由する場合があります。お問い合わせ処理目的以外には使用されません。" + }, "cta": { "title": "ご予約・お問い合わせ", "desc": "簡単なオンライン予約またはよくある質問をご確認ください", @@ -626,10 +664,16 @@ "sending": "送信中...", "sendSuccess": "お問い合わせが正常に送信されました。まもなくご連絡いたします。", "sendError": "送信中にエラーが発生しました。もう一度お試しください。", + "errorTitle": "送信に失敗しました", + "errorDesc": "一時的なサーバーエラーによりお問い合わせを送信できませんでした。以下の方法で直接ご連絡ください。", + "errorEmail": "メール", + "errorPhone": "電話", + "errorDiscord": "Discordサーバーでお問い合わせ", "resetConfirm": "入力内容がすべて削除されます。続行しますか?", "invalidEmail": "正しいメールアドレスを入力してください。", "invalidPhone": "正しい電話番号を入力してください。", - "required": "必須入力項目です。" + "required": "必須入力項目です。", + "privacyRequired": "個人情報の収集及び利用に同意してください。" } }, "qna": { diff --git a/i18n/ko.json b/i18n/ko.json index e61f11a..04d68df 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -615,6 +615,44 @@ "naverMap": "네이버 지도", "googleMap": "구글 맵" }, + "form": { + "title": "온라인 문의", + "desc": "아래 양식을 작성하시면 빠르게 답변 드리겠습니다", + "name": "이름 *", + "namePlaceholder": "홍길동", + "email": "이메일 *", + "phone": "전화번호", + "service": "문의 유형 *", + "serviceDefault": "선택해주세요", + "serviceRental": "스튜디오 대관", + "serviceVtuber": "VTuber 제작", + "serviceMocap": "모션캡쳐 촬영", + "serviceMV": "뮤직비디오 제작", + "servicePartner": "제휴/협력", + "serviceOther": "기타", + "message": "문의 내용 *", + "messagePlaceholder": "프로젝트 내용, 희망 일정 등을 자유롭게 작성해주세요", + "sensitiveWarning": "※ 주민등록번호, 계좌번호 등 민감한 개인정보를 입력하지 마세요.", + "privacyAgree": "개인정보 수집 및 이용에 동의합니다. (필수)", + "privacyView": "개인정보 처리방침 보기", + "privacyPurpose": "수집 목적: 문의 접수 및 답변", + "privacyItems": "수집 항목: 이름, 이메일, 전화번호, 문의 내용", + "privacyPeriod": "보유 기간: 7일 후 자동 파기", + "submit": "문의 보내기", + "reset": "초기화", + "privacyModalTitle": "개인정보 수집 및 이용 안내", + "privacyM1Title": "1. 수집 목적", + "privacyM1Desc": "고객 문의 접수 및 답변 제공", + "privacyM2Title": "2. 수집 항목", + "privacyM2Required": "필수: 이름, 이메일, 문의 유형, 문의 내용", + "privacyM2Optional": "선택: 전화번호", + "privacyM3Title": "3. 보유 및 이용 기간", + "privacyM3Desc": "문의 접수일로부터 7일간 보관 후 자동 파기됩니다.", + "privacyM4Title": "4. 동의 거부 권리", + "privacyM4Desc": "개인정보 수집 및 이용에 대한 동의를 거부할 권리가 있습니다. 다만, 동의를 거부하실 경우 문의 접수가 불가합니다.", + "privacyM5Title": "5. 제3자 제공", + "privacyM5Desc": "문의 내용은 보안 채널을 통해 전달되며, 해외 서버를 경유할 수 있습니다. 문의 처리 목적 외에는 사용되지 않습니다." + }, "cta": { "title": "예약 및 문의", "desc": "간편한 온라인 예약 또는 자주 묻는 질문을 확인해보세요", @@ -626,10 +664,16 @@ "sending": "전송 중...", "sendSuccess": "문의가 성공적으로 전송되었습니다. 빠른 시일 내에 연락드리겠습니다.", "sendError": "전송 중 오류가 발생했습니다. 다시 시도해 주세요.", + "errorTitle": "전송에 실패했습니다", + "errorDesc": "일시적인 서버 오류로 문의가 전송되지 않았습니다. 아래 방법으로 직접 연락해 주세요.", + "errorEmail": "이메일", + "errorPhone": "전화", + "errorDiscord": "디스코드 서버에서 문의", "resetConfirm": "입력한 내용이 모두 삭제됩니다. 계속하시겠습니까?", "invalidEmail": "올바른 이메일 형식을 입력해 주세요.", "invalidPhone": "올바른 전화번호 형식을 입력해 주세요.", - "required": "필수 입력 항목입니다." + "required": "필수 입력 항목입니다.", + "privacyRequired": "개인정보 수집 및 이용에 동의해 주세요." } }, "qna": { diff --git a/i18n/zh.json b/i18n/zh.json index 910f156..6181bf3 100644 --- a/i18n/zh.json +++ b/i18n/zh.json @@ -615,6 +615,44 @@ "naverMap": "Naver地图", "googleMap": "Google地图" }, + "form": { + "title": "在线咨询", + "desc": "请填写以下表单,我们将尽快回复", + "name": "姓名 *", + "namePlaceholder": "张三", + "email": "邮箱 *", + "phone": "电话号码", + "service": "咨询类型 *", + "serviceDefault": "请选择", + "serviceRental": "工作室租赁", + "serviceVtuber": "VTuber制作", + "serviceMocap": "动作捕捉拍摄", + "serviceMV": "MV制作", + "servicePartner": "合作/联盟", + "serviceOther": "其他", + "message": "咨询内容 *", + "messagePlaceholder": "请自由填写项目内容、希望的日程等", + "sensitiveWarning": "※ 请勿在咨询内容中包含身份证号、银行账号等敏感个人信息。", + "privacyAgree": "同意收集和使用个人信息。(必填)", + "privacyView": "查看隐私政策", + "privacyPurpose": "收集目的:咨询受理及回复", + "privacyItems": "收集项目:姓名、邮箱、电话号码、咨询内容", + "privacyPeriod": "保留期限:7天后自动删除", + "submit": "发送咨询", + "reset": "重置", + "privacyModalTitle": "个人信息收集及使用须知", + "privacyM1Title": "1. 收集目的", + "privacyM1Desc": "客户咨询受理及回复", + "privacyM2Title": "2. 收集项目", + "privacyM2Required": "必填:姓名、邮箱、咨询类型、咨询内容", + "privacyM2Optional": "选填:电话号码", + "privacyM3Title": "3. 保留及使用期限", + "privacyM3Desc": "自咨询受理之日起保留7天后自动删除。", + "privacyM4Title": "4. 拒绝同意的权利", + "privacyM4Desc": "您有权拒绝个人信息的收集和使用。但拒绝后将无法提交咨询。", + "privacyM5Title": "5. 第三方提供", + "privacyM5Desc": "咨询内容通过安全渠道传送,可能经由海外服务器。不会用于咨询处理以外的目的。" + }, "cta": { "title": "预约与咨询", "desc": "便捷的在线预约或查看常见问题", @@ -626,10 +664,16 @@ "sending": "发送中...", "sendSuccess": "咨询已成功发送。我们将尽快与您联系。", "sendError": "发送过程中出现错误,请重试。", + "errorTitle": "发送失败", + "errorDesc": "由于临时服务器错误,您的咨询未能发送。请通过以下方式直接联系我们。", + "errorEmail": "电子邮箱", + "errorPhone": "电话", + "errorDiscord": "通过Discord服务器咨询", "resetConfirm": "所有输入内容将被删除。是否继续?", "invalidEmail": "请输入正确的邮箱地址。", "invalidPhone": "请输入正确的电话号码。", - "required": "此为必填项。" + "required": "此为必填项。", + "privacyRequired": "请同意收集和使用个人信息。" } }, "qna": { diff --git a/ja/contact.html b/ja/contact.html index f0c4f01..0fa1ff3 100644 --- a/ja/contact.html +++ b/ja/contact.html @@ -197,6 +197,184 @@
+ +
+
+
+

온라인 문의

+

아래 양식을 작성하시면 빠르게 답변 드리겠습니다

+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

※ 주민등록번호, 계좌번호 등 민감한 개인정보를 입력하지 마세요.

+
+ +
+ + 개인정보 처리방침 보기 +
+ +
+
    +
  • 수집 목적: 문의 접수 및 답변
  • +
  • 수집 항목: 이름, 이메일, 전화번호, 문의 내용
  • +
  • 보유 기간: 7일 후 자동 파기
  • +
+
+ +
+ + +
+
+
+
+
+ + + + +
diff --git a/js/contact.js b/js/contact.js index 0107e31..9e7a44b 100644 --- a/js/contact.js +++ b/js/contact.js @@ -52,6 +52,13 @@ async function handleFormSubmit(e) { showNotification(_t('contact.js.checkInput', '입력 정보를 확인해 주세요.'), 'error'); return; } + + // 개인정보 동의 확인 + const privacyCheckbox = document.getElementById('privacyConsent'); + if (privacyCheckbox && !privacyCheckbox.checked) { + showNotification(_t('contact.js.privacyRequired', '개인정보 수집 및 이용에 동의해 주세요.'), 'error'); + return; + } // 제출 버튼 비활성화 const originalText = submitBtn.textContent; @@ -72,7 +79,7 @@ async function handleFormSubmit(e) { } catch (error) { console.error('Form submission error:', error); - showNotification(_t('contact.js.sendError', '전송 중 오류가 발생했습니다. 다시 시도해 주세요.'), 'error'); + showApiErrorFallback(); } finally { // 버튼 복원 submitBtn.textContent = originalText; @@ -80,19 +87,70 @@ async function handleFormSubmit(e) { } } -// 서버 전송 (mailto 기반 폴백) +// 서버 전송 (API) async function submitContactForm(data) { - // mailto 링크로 이메일 클라이언트 열기 - const subject = encodeURIComponent(`[밍글 스튜디오 문의] ${data.name || '웹사이트 문의'}`); - const body = encodeURIComponent( - `이름: ${data.name || ''}\n` + - `이메일: ${data.email || ''}\n` + - `전화번호: ${data.phone || ''}\n` + - `문의 유형: ${data.service || ''}\n` + - `\n문의 내용:\n${data.message || ''}` - ); - window.location.href = `mailto:mingle_studio@naver.com?subject=${subject}&body=${body}`; - return { success: true, message: '이메일 클라이언트가 열렸습니다.' }; + const response = await fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: data.name || '', + email: data.email || '', + phone: data.phone || '', + service: data.service || '', + message: data.message || '' + }) + }); + + const result = await response.json(); + + if (!response.ok || !result.success) { + throw new Error(result.error || '전송 실패'); + } + + return result; +} + +// API 실패 시 대체 연락 안내 +function showApiErrorFallback() { + // 기존 fallback 제거 + const existing = document.getElementById('apiErrorFallback'); + if (existing) existing.remove(); + + const fallback = document.createElement('div'); + fallback.id = 'apiErrorFallback'; + fallback.className = 'api-error-fallback'; + fallback.innerHTML = ` +
+ +
+

${_t('contact.js.errorTitle', '전송에 실패했습니다')}

+

${_t('contact.js.errorDesc', '일시적인 서버 오류로 문의가 전송되지 않았습니다. 아래 방법으로 직접 연락해 주세요.')}

+ +
+ `; + document.body.appendChild(fallback); } // 폼 리셋 처리 @@ -104,10 +162,17 @@ function handleFormReset(e) { // 에러 메시지 제거 const errorElements = document.querySelectorAll('.field-error'); errorElements.forEach(el => el.remove()); - + // 필드 에러 스타일 제거 const fields = document.querySelectorAll('.form-group input, .form-group select, .form-group textarea'); fields.forEach(field => field.classList.remove('error')); + + // 제출 버튼 재비활성화 + const btn = document.getElementById('submitBtn'); + if (btn) { + btn.disabled = true; + btn.classList.add('btn-submit-disabled'); + } } } @@ -227,58 +292,57 @@ function formatPhoneNumber(e) { // common.js의 showNotification 사용 const showNotification = window.commonUtils?.showNotification || function() {}; +// 모달 전역 함수 (onclick에서도 호출 가능) +let _modalLastFocus = null; + +function openPrivacyModal() { + const modal = document.getElementById('privacyModal'); + if (!modal) return; + _modalLastFocus = document.activeElement; + modal.classList.add('active'); + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + document.body.style.position = 'fixed'; + document.body.style.width = '100%'; + document.body.style.top = `-${window.scrollY}px`; + const closeBtn = modal.querySelector('.modal-close'); + if (closeBtn) closeBtn.focus(); +} + +function closePrivacyModal() { + const modal = document.getElementById('privacyModal'); + if (!modal) return; + modal.classList.remove('active'); + modal.style.display = ''; + const scrollY = document.body.style.top; + document.body.style.overflow = ''; + document.body.style.position = ''; + document.body.style.width = ''; + document.body.style.top = ''; + window.scrollTo(0, parseInt(scrollY || '0') * -1); + if (_modalLastFocus) _modalLastFocus.focus(); +} + // 모달 초기화 function initModal() { - const modal = document.getElementById('privacyModal'); - const privacyLink = document.querySelector('.privacy-link'); - const closeBtn = modal?.querySelector('.modal-close'); - - let lastFocusedElement = null; - - function openModal() { - lastFocusedElement = document.activeElement; - modal.classList.add('active'); - document.body.style.overflow = 'hidden'; - document.body.style.position = 'fixed'; - document.body.style.width = '100%'; - document.body.style.top = `-${window.scrollY}px`; - // 포커스를 모달 닫기 버튼으로 이동 - if (closeBtn) closeBtn.focus(); - } - - function closeModal() { - modal.classList.remove('active'); - const scrollY = document.body.style.top; - document.body.style.overflow = ''; - document.body.style.position = ''; - document.body.style.width = ''; - document.body.style.top = ''; - window.scrollTo(0, parseInt(scrollY || '0') * -1); - // 포커스 복원 - if (lastFocusedElement) lastFocusedElement.focus(); - } - - if (privacyLink && modal) { - privacyLink.addEventListener('click', function(e) { + // 이벤트 위임 (백업 — HTML onclick이 주요 트리거) + document.addEventListener('click', function(e) { + if (e.target.closest('.privacy-link')) { e.preventDefault(); - openModal(); - }); - } + openPrivacyModal(); + } + if (e.target.closest('.modal-close')) { + closePrivacyModal(); + } + if (e.target.id === 'privacyModal') { + closePrivacyModal(); + } + }); - if (closeBtn && modal) { - closeBtn.addEventListener('click', closeModal); - } - - if (modal) { - modal.addEventListener('click', function(e) { - if (e.target === modal) closeModal(); - }); - } - - // ESC 키로 모달 닫기 document.addEventListener('keydown', function(e) { + const modal = document.getElementById('privacyModal'); if (e.key === 'Escape' && modal?.classList.contains('active')) { - closeModal(); + closePrivacyModal(); } }); } diff --git a/zh/contact.html b/zh/contact.html index eea6acb..ad8f758 100644 --- a/zh/contact.html +++ b/zh/contact.html @@ -197,6 +197,184 @@
+ +
+
+
+

온라인 문의

+

아래 양식을 작성하시면 빠르게 답변 드리겠습니다

+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +

※ 주민등록번호, 계좌번호 등 민감한 개인정보를 입력하지 마세요.

+
+ +
+ + 개인정보 처리방침 보기 +
+ +
+
    +
  • 수집 목적: 문의 접수 및 답변
  • +
  • 수집 항목: 이름, 이메일, 전화번호, 문의 내용
  • +
  • 보유 기간: 7일 후 자동 파기
  • +
+
+ +
+ + +
+
+
+
+
+ + + + +