feat: 카카오 로그인으로 마일리지 적립 기능 추가
- 카카오 OAuth 2.0 클라이언트 모듈 추가 (services/kakao_client.py) - 적립 페이지에 "카카오로 적립하기" 버튼 추가 - OAuth 콜백 처리: 전화번호 자동 적립 / 미제공 시 폰 입력 폴백 - state 파라미터로 claim 컨텍스트 보존 + CSRF 보호 - customer_identities 테이블 활용한 카카오 계정 연결 - 마이페이지 헤더 sticky 고정 - 카카오 OAuth 설정 가이드 문서 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
82220a4a44
commit
31cf6e3816
256
backend/app.py
256
backend/app.py
@ -3,8 +3,10 @@ Flask 웹 서버 - QR 마일리지 적립
|
|||||||
간편 적립: 전화번호 + 이름만 입력
|
간편 적립: 전화번호 + 이름만 입력
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from flask import Flask, request, render_template, jsonify, redirect, url_for
|
from flask import Flask, request, render_template, jsonify, redirect, url_for, session
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import secrets
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
@ -452,6 +454,67 @@ def claim_mileage(user_id, token_info):
|
|||||||
return (False, f"적립 처리 실패: {str(e)}", 0)
|
return (False, f"적립 처리 실패: {str(e)}", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_kakao_phone(kakao_phone):
|
||||||
|
"""
|
||||||
|
카카오 전화번호 형식 변환
|
||||||
|
"+82 10-1234-5678" → "01012345678"
|
||||||
|
"""
|
||||||
|
if not kakao_phone:
|
||||||
|
return None
|
||||||
|
digits = kakao_phone.replace('+', '').replace('-', '').replace(' ', '')
|
||||||
|
if digits.startswith('82'):
|
||||||
|
digits = '0' + digits[2:]
|
||||||
|
if len(digits) >= 10:
|
||||||
|
return digits
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def link_kakao_identity(user_id, kakao_id, kakao_data):
|
||||||
|
"""카카오 계정을 customer_identities에 연결"""
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id FROM customer_identities
|
||||||
|
WHERE provider = 'kakao' AND provider_user_id = ?
|
||||||
|
""", (kakao_id,))
|
||||||
|
|
||||||
|
if not cursor.fetchone():
|
||||||
|
# raw_data 제외 (세션 크기 절약)
|
||||||
|
store_data = {k: v for k, v in kakao_data.items() if k != 'raw_data'}
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO customer_identities (user_id, provider, provider_user_id, provider_data)
|
||||||
|
VALUES (?, 'kakao', ?, ?)
|
||||||
|
""", (user_id, kakao_id, json.dumps(store_data, ensure_ascii=False)))
|
||||||
|
|
||||||
|
# 프로필 이미지, 이메일 업데이트
|
||||||
|
updates = []
|
||||||
|
params = []
|
||||||
|
if kakao_data.get('profile_image'):
|
||||||
|
updates.append("profile_image_url = ?")
|
||||||
|
params.append(kakao_data['profile_image'])
|
||||||
|
if kakao_data.get('email'):
|
||||||
|
updates.append("email = ?")
|
||||||
|
params.append(kakao_data['email'])
|
||||||
|
if updates:
|
||||||
|
params.append(user_id)
|
||||||
|
cursor.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", params)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def find_user_by_kakao_id(kakao_id):
|
||||||
|
"""카카오 ID로 기존 연결된 사용자 조회"""
|
||||||
|
conn = db_manager.get_sqlite_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT user_id FROM customer_identities
|
||||||
|
WHERE provider = 'kakao' AND provider_user_id = ?
|
||||||
|
""", (kakao_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row['user_id'] if row else None
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 라우트
|
# 라우트
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@ -640,6 +703,197 @@ def api_claim():
|
|||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 카카오 적립 라우트
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.route('/claim/kakao/start')
|
||||||
|
def claim_kakao_start():
|
||||||
|
"""카카오 OAuth 시작 - claim 컨텍스트를 state에 담아 카카오로 리다이렉트"""
|
||||||
|
from services.kakao_client import get_kakao_client
|
||||||
|
|
||||||
|
token_param = request.args.get('t', '')
|
||||||
|
if ':' not in token_param:
|
||||||
|
return render_template('error.html', message="잘못된 QR 코드입니다.")
|
||||||
|
|
||||||
|
parts = token_param.split(':')
|
||||||
|
if len(parts) != 2:
|
||||||
|
return render_template('error.html', message="잘못된 QR 코드입니다.")
|
||||||
|
|
||||||
|
transaction_id, nonce = parts
|
||||||
|
|
||||||
|
# 토큰 사전 검증
|
||||||
|
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||||||
|
if not success:
|
||||||
|
return render_template('error.html', message=message)
|
||||||
|
|
||||||
|
# state: claim 컨텍스트 + CSRF 토큰
|
||||||
|
csrf_token = secrets.token_hex(16)
|
||||||
|
state_data = {
|
||||||
|
't': token_param,
|
||||||
|
'csrf': csrf_token
|
||||||
|
}
|
||||||
|
state_encoded = base64.urlsafe_b64encode(
|
||||||
|
json.dumps(state_data).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
session['kakao_csrf'] = csrf_token
|
||||||
|
|
||||||
|
kakao_client = get_kakao_client()
|
||||||
|
auth_url = kakao_client.get_authorization_url(state=state_encoded)
|
||||||
|
|
||||||
|
return redirect(auth_url)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/claim/kakao/callback')
|
||||||
|
def claim_kakao_callback():
|
||||||
|
"""카카오 OAuth 콜백 - 토큰 교환 → 사용자 정보 → 적립 처리"""
|
||||||
|
from services.kakao_client import get_kakao_client
|
||||||
|
|
||||||
|
code = request.args.get('code')
|
||||||
|
state_encoded = request.args.get('state')
|
||||||
|
error = request.args.get('error')
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return render_template('error.html', message="카카오 로그인이 취소되었습니다.")
|
||||||
|
|
||||||
|
if not code or not state_encoded:
|
||||||
|
return render_template('error.html', message="카카오 인증 정보가 올바르지 않습니다.")
|
||||||
|
|
||||||
|
# 1. state 디코딩
|
||||||
|
try:
|
||||||
|
state_json = base64.urlsafe_b64decode(state_encoded).decode()
|
||||||
|
state_data = json.loads(state_json)
|
||||||
|
except Exception:
|
||||||
|
return render_template('error.html', message="인증 상태 정보가 올바르지 않습니다.")
|
||||||
|
|
||||||
|
# 2. CSRF 검증
|
||||||
|
csrf_token = state_data.get('csrf')
|
||||||
|
if csrf_token != session.pop('kakao_csrf', None):
|
||||||
|
return render_template('error.html', message="보안 검증에 실패했습니다. 다시 시도해주세요.")
|
||||||
|
|
||||||
|
# 3. claim 컨텍스트 복원
|
||||||
|
token_param = state_data.get('t', '')
|
||||||
|
parts = token_param.split(':')
|
||||||
|
if len(parts) != 2:
|
||||||
|
return render_template('error.html', message="적립 정보가 올바르지 않습니다.")
|
||||||
|
|
||||||
|
transaction_id, nonce = parts
|
||||||
|
|
||||||
|
# 4. 토큰 재검증 (OAuth 중 다른 사람이 적립했을 수 있음)
|
||||||
|
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||||||
|
if not success:
|
||||||
|
return render_template('error.html', message=message)
|
||||||
|
|
||||||
|
# 5. 카카오 access_token 교환
|
||||||
|
kakao_client = get_kakao_client()
|
||||||
|
success, token_data = kakao_client.get_access_token(code)
|
||||||
|
if not success:
|
||||||
|
logging.error(f"카카오 토큰 교환 실패: {token_data}")
|
||||||
|
return render_template('error.html', message="카카오 인증에 실패했습니다. 다시 시도해주세요.")
|
||||||
|
|
||||||
|
# 6. 사용자 정보 조회
|
||||||
|
access_token = token_data.get('access_token')
|
||||||
|
success, user_info = kakao_client.get_user_info(access_token)
|
||||||
|
if not success:
|
||||||
|
logging.error(f"카카오 사용자 정보 조회 실패: {user_info}")
|
||||||
|
return render_template('error.html', message="카카오 사용자 정보를 가져올 수 없습니다.")
|
||||||
|
|
||||||
|
kakao_id = user_info.get('kakao_id')
|
||||||
|
kakao_name = user_info.get('name') or user_info.get('nickname', '')
|
||||||
|
kakao_phone_raw = user_info.get('phone_number')
|
||||||
|
kakao_phone = normalize_kakao_phone(kakao_phone_raw)
|
||||||
|
|
||||||
|
# 7. 분기: 전화번호가 있으면 자동 적립, 없으면 폰 입력 폼
|
||||||
|
if kakao_phone:
|
||||||
|
# 자동 적립
|
||||||
|
existing_user_id = find_user_by_kakao_id(kakao_id)
|
||||||
|
if existing_user_id:
|
||||||
|
user_id = existing_user_id
|
||||||
|
is_new = False
|
||||||
|
else:
|
||||||
|
user_id, is_new = get_or_create_user(kakao_phone, kakao_name)
|
||||||
|
|
||||||
|
link_kakao_identity(user_id, kakao_id, user_info)
|
||||||
|
|
||||||
|
success, msg, new_balance = claim_mileage(user_id, token_info)
|
||||||
|
if not success:
|
||||||
|
return render_template('error.html', message=msg)
|
||||||
|
|
||||||
|
return render_template('claim_kakao_success.html',
|
||||||
|
points=token_info['claimable_points'],
|
||||||
|
balance=new_balance,
|
||||||
|
phone=kakao_phone,
|
||||||
|
name=kakao_name)
|
||||||
|
else:
|
||||||
|
# 전화번호 없음 → 세션에 카카오 정보 저장 + 폰 입력 폼
|
||||||
|
session['kakao_claim'] = {
|
||||||
|
'kakao_id': kakao_id,
|
||||||
|
'name': kakao_name,
|
||||||
|
'profile_image': user_info.get('profile_image'),
|
||||||
|
'email': user_info.get('email'),
|
||||||
|
'token_param': token_param
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template('claim_kakao_phone.html',
|
||||||
|
token_info=token_info,
|
||||||
|
kakao_name=kakao_name,
|
||||||
|
kakao_profile_image=user_info.get('profile_image'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/claim/kakao', methods=['POST'])
|
||||||
|
def api_claim_kakao():
|
||||||
|
"""카카오 적립 (전화번호 폴백) - 세션의 카카오 정보 + 폼의 전화번호로 적립"""
|
||||||
|
kakao_data = session.pop('kakao_claim', None)
|
||||||
|
if not kakao_data:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '카카오 인증 정보가 만료되었습니다. 다시 시도해주세요.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
phone = data.get('phone', '').strip().replace('-', '').replace(' ', '')
|
||||||
|
|
||||||
|
if len(phone) < 10:
|
||||||
|
session['kakao_claim'] = kakao_data
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'message': '올바른 전화번호를 입력해주세요.'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
token_param = kakao_data['token_param']
|
||||||
|
parts = token_param.split(':')
|
||||||
|
transaction_id, nonce = parts
|
||||||
|
|
||||||
|
success, message, token_info = verify_claim_token(transaction_id, nonce)
|
||||||
|
if not success:
|
||||||
|
return jsonify({'success': False, 'message': message}), 400
|
||||||
|
|
||||||
|
kakao_id = kakao_data['kakao_id']
|
||||||
|
name = kakao_data['name']
|
||||||
|
|
||||||
|
existing_user_id = find_user_by_kakao_id(kakao_id)
|
||||||
|
if existing_user_id:
|
||||||
|
user_id = existing_user_id
|
||||||
|
is_new = False
|
||||||
|
else:
|
||||||
|
user_id, is_new = get_or_create_user(phone, name)
|
||||||
|
|
||||||
|
link_kakao_identity(user_id, kakao_id, kakao_data)
|
||||||
|
|
||||||
|
success, message, new_balance = claim_mileage(user_id, token_info)
|
||||||
|
if not success:
|
||||||
|
return jsonify({'success': False, 'message': message}), 500
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': message,
|
||||||
|
'points': token_info['claimable_points'],
|
||||||
|
'balance': new_balance,
|
||||||
|
'is_new_user': is_new
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route('/my-page')
|
@app.route('/my-page')
|
||||||
def my_page():
|
def my_page():
|
||||||
"""마이페이지 (전화번호로 조회)"""
|
"""마이페이지 (전화번호로 조회)"""
|
||||||
|
|||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
157
backend/services/kakao_client.py
Normal file
157
backend/services/kakao_client.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
Kakao API Client for OAuth 2.0 Authentication
|
||||||
|
pharmacy-pos-qr-system용 경량 버전
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, Tuple
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KakaoAPIClient:
|
||||||
|
"""카카오 API 클라이언트 (마일리지 적립용)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = os.getenv('KAKAO_CLIENT_ID', '')
|
||||||
|
self.client_secret = os.getenv('KAKAO_CLIENT_SECRET', '')
|
||||||
|
self.redirect_uri = os.getenv(
|
||||||
|
'KAKAO_REDIRECT_URI',
|
||||||
|
'https://mile.0bin.in/claim/kakao/callback'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.auth_base_url = 'https://kauth.kakao.com'
|
||||||
|
self.api_base_url = 'https://kapi.kakao.com'
|
||||||
|
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'User-Agent': 'YouthPharmacy-Mileage/1.0'
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_authorization_url(self, state: str = None) -> str:
|
||||||
|
"""카카오 OAuth 인증 URL 생성"""
|
||||||
|
params = {
|
||||||
|
'client_id': self.client_id,
|
||||||
|
'redirect_uri': self.redirect_uri,
|
||||||
|
'response_type': 'code',
|
||||||
|
'scope': 'profile_nickname,profile_image,account_email,name,phone_number'
|
||||||
|
}
|
||||||
|
|
||||||
|
if state:
|
||||||
|
params['state'] = state
|
||||||
|
|
||||||
|
return f"{self.auth_base_url}/oauth/authorize?{urlencode(params)}"
|
||||||
|
|
||||||
|
def get_access_token(self, authorization_code: str) -> Tuple[bool, Dict[str, Any]]:
|
||||||
|
"""Authorization Code로 Access Token 요청"""
|
||||||
|
url = f"{self.auth_base_url}/oauth/token"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'client_id': self.client_id,
|
||||||
|
'client_secret': self.client_secret,
|
||||||
|
'redirect_uri': self.redirect_uri,
|
||||||
|
'code': authorization_code
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
|
||||||
|
response = self.session.post(url, data=data, headers=headers)
|
||||||
|
|
||||||
|
logger.info(f"카카오 토큰 응답 상태: {response.status_code}")
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
token_data = response.json()
|
||||||
|
|
||||||
|
if 'expires_in' in token_data:
|
||||||
|
expires_at = datetime.now() + timedelta(seconds=token_data['expires_in'])
|
||||||
|
token_data['expires_at'] = expires_at.isoformat()
|
||||||
|
|
||||||
|
return True, token_data
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"카카오 토큰 요청 실패: {e}")
|
||||||
|
|
||||||
|
error_details = {
|
||||||
|
'error': 'token_request_failed',
|
||||||
|
'error_description': f'Failed to get access token: {e}'
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
if hasattr(e, 'response') and e.response is not None:
|
||||||
|
kakao_error = e.response.json()
|
||||||
|
logger.error(f"카카오 API 오류: {kakao_error}")
|
||||||
|
error_details.update(kakao_error)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False, error_details
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"카카오 응답 JSON 파싱 실패: {e}")
|
||||||
|
return False, {
|
||||||
|
'error': 'invalid_response',
|
||||||
|
'error_description': f'Invalid JSON response: {e}'
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_user_info(self, access_token: str) -> Tuple[bool, Dict[str, Any]]:
|
||||||
|
"""Access Token으로 사용자 정보 조회"""
|
||||||
|
url = f"{self.api_base_url}/v2/user/me"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {access_token}',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
user_data = response.json()
|
||||||
|
normalized_user = self._normalize_user_data(user_data)
|
||||||
|
|
||||||
|
return True, normalized_user
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"카카오 사용자 정보 조회 실패: {e}")
|
||||||
|
return False, {
|
||||||
|
'error': 'user_info_failed',
|
||||||
|
'error_description': f'Failed to get user info: {e}'
|
||||||
|
}
|
||||||
|
|
||||||
|
def _normalize_user_data(self, kakao_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""카카오 사용자 데이터를 정규화"""
|
||||||
|
kakao_account = kakao_user.get('kakao_account', {})
|
||||||
|
profile = kakao_account.get('profile', {})
|
||||||
|
|
||||||
|
normalized = {
|
||||||
|
'kakao_id': str(kakao_user.get('id')),
|
||||||
|
'nickname': profile.get('nickname'),
|
||||||
|
'profile_image': profile.get('profile_image_url'),
|
||||||
|
'thumbnail_image': profile.get('thumbnail_image_url'),
|
||||||
|
'email': kakao_account.get('email'),
|
||||||
|
'is_email_verified': kakao_account.get('is_email_verified', False),
|
||||||
|
'name': kakao_account.get('name'),
|
||||||
|
'phone_number': kakao_account.get('phone_number'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# None 값 제거
|
||||||
|
normalized = {k: v for k, v in normalized.items() if v is not None}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤
|
||||||
|
_kakao_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_kakao_client() -> KakaoAPIClient:
|
||||||
|
"""카카오 클라이언트 인스턴스 반환"""
|
||||||
|
global _kakao_client
|
||||||
|
if _kakao_client is None:
|
||||||
|
_kakao_client = KakaoAPIClient()
|
||||||
|
return _kakao_client
|
||||||
@ -557,6 +557,23 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 20px 0 16px 0; position: relative;">
|
||||||
|
<span style="background: #fff; padding: 0 16px; color: #adb5bd; font-size: 13px; font-weight: 500; position: relative; z-index: 1;">또는</span>
|
||||||
|
<div style="position: absolute; top: 50%; left: 0; right: 0; height: 1px; background: #e9ecef; z-index: 0;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/claim/kakao/start?t={{ request.args.get('t') }}"
|
||||||
|
style="display: flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
width: 100%; padding: 16px; background: #FEE500; color: #191919;
|
||||||
|
border: none; border-radius: 14px; font-size: 16px; font-weight: 600;
|
||||||
|
text-decoration: none; letter-spacing: -0.3px; transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||||
|
</svg>
|
||||||
|
카카오로 적립하기
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="alert error" id="alertMsg"></div>
|
<div class="alert error" id="alertMsg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
442
backend/templates/claim_kakao_phone.html
Normal file
442
backend/templates/claim_kakao_phone.html
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
<!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&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
padding: 32px 24px 140px 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 120px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 32px 32px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pharmacy-name {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: -100px 24px 24px 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kakao-profile {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-bottom: 1px solid #f1f3f5;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kakao-profile img {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 3px solid #FEE500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kakao-profile-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #212529;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kakao-profile-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #191919;
|
||||||
|
background: #FEE500;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-info {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-label {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-value {
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section { margin-top: 0; }
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group label {
|
||||||
|
display: block;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: #f8f9fa;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper input::placeholder {
|
||||||
|
color: #adb5bd;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 18px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit:active { transform: scale(0.98); }
|
||||||
|
.btn-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: none;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 16px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert.error {
|
||||||
|
background: #fff5f5;
|
||||||
|
color: #e03131;
|
||||||
|
border: 1px solid #ffc9c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 성공 화면 */
|
||||||
|
.success-screen {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon-wrap {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 24px auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon-wrap svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
fill: none;
|
||||||
|
stroke: #ffffff;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #212529;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-points {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6366f1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-balance {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-balance strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary, .btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f1f3f5;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body { padding: 0; }
|
||||||
|
.app-container { border-radius: 0; min-height: 100vh; }
|
||||||
|
.header { padding-top: 48px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="pharmacy-name">청춘약국</div>
|
||||||
|
<div class="header-title">포인트 적립</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="claimForm">
|
||||||
|
<div class="card">
|
||||||
|
<div class="kakao-profile">
|
||||||
|
{% if kakao_profile_image %}
|
||||||
|
<img src="{{ kakao_profile_image }}" alt="프로필">
|
||||||
|
{% else %}
|
||||||
|
<div style="width: 64px; height: 64px; border-radius: 50%; background: #FEE500; margin: 0 auto 12px auto; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="kakao-profile-name">{{ kakao_name }}님</div>
|
||||||
|
<span class="kakao-profile-badge">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||||
|
</svg>
|
||||||
|
카카오 인증됨
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="points-info">
|
||||||
|
<div class="points-label">적립 포인트</div>
|
||||||
|
<div class="points-value">+{{ "{:,}".format(token_info.claimable_points) }}P</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="formKakaoClaim" class="form-section">
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="phone">전화번호를 입력해주세요</label>
|
||||||
|
<div class="input-wrapper" style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span style="font-size: 18px; font-weight: 600; color: #495057; white-space: nowrap; padding: 16px 0 16px 4px;">010 -</span>
|
||||||
|
<input type="tel" id="phone"
|
||||||
|
placeholder="0000-0000"
|
||||||
|
inputmode="numeric"
|
||||||
|
maxlength="9"
|
||||||
|
autocomplete="tel"
|
||||||
|
required
|
||||||
|
style="flex: 1;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-submit" id="btnSubmit">포인트 적립하기</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="alert error" id="alertMsg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="successScreen" class="success-screen">
|
||||||
|
<div class="success-icon-wrap">
|
||||||
|
<svg viewBox="0 0 52 52">
|
||||||
|
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||||
|
</svg>
|
||||||
|
</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="button-group">
|
||||||
|
<a href="/" class="btn-secondary">홈으로</a>
|
||||||
|
<a href="#" class="btn-primary" id="btnMyPage">내역 보기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const phoneInput = document.getElementById('phone');
|
||||||
|
const form = document.getElementById('formKakaoClaim');
|
||||||
|
const btnSubmit = document.getElementById('btnSubmit');
|
||||||
|
const alertMsg = document.getElementById('alertMsg');
|
||||||
|
|
||||||
|
// 자동 하이픈
|
||||||
|
phoneInput.addEventListener('input', function(e) {
|
||||||
|
let value = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
if (value.length <= 4) {
|
||||||
|
e.target.value = value;
|
||||||
|
} else {
|
||||||
|
e.target.value = value.slice(0, 4) + '-' + value.slice(4, 8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
phoneInput.focus();
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const phoneRaw = phoneInput.value.replace(/[^0-9]/g, '');
|
||||||
|
if (phoneRaw.length < 7) {
|
||||||
|
showAlert('전화번호를 정확히 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phone = '010' + phoneRaw;
|
||||||
|
|
||||||
|
btnSubmit.disabled = true;
|
||||||
|
btnSubmit.textContent = '처리 중...';
|
||||||
|
alertMsg.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/claim/kakao', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ phone: phone })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showSuccess(data.points, data.balance, phone);
|
||||||
|
} else {
|
||||||
|
showAlert(data.message);
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
btnSubmit.textContent = '포인트 적립하기';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showAlert('네트워크 오류가 발생했습니다.');
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
btnSubmit.textContent = '포인트 적립하기';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showAlert(msg) {
|
||||||
|
alertMsg.textContent = msg;
|
||||||
|
alertMsg.style.display = 'block';
|
||||||
|
setTimeout(() => { alertMsg.style.display = 'none'; }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(points, balance, phone) {
|
||||||
|
document.getElementById('claimForm').style.display = 'none';
|
||||||
|
document.getElementById('successPoints').textContent = points.toLocaleString() + 'P';
|
||||||
|
document.getElementById('successBalance').textContent = balance.toLocaleString() + 'P';
|
||||||
|
document.getElementById('btnMyPage').href = '/my-page?phone=' + encodeURIComponent(phone);
|
||||||
|
document.getElementById('successScreen').style.display = 'block';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
187
backend/templates/claim_kakao_success.html
Normal file
187
backend/templates/claim_kakao_success.html
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
<!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&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: #f5f7fa;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24px;
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon-wrap {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto 24px auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: scaleIn 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
0% { transform: scale(0); opacity: 0; }
|
||||||
|
60% { transform: scale(1.1); }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon-wrap svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
fill: none;
|
||||||
|
stroke: #ffffff;
|
||||||
|
stroke-width: 3;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-dasharray: 100;
|
||||||
|
stroke-dashoffset: 100;
|
||||||
|
animation: drawCheck 0.6s 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drawCheck {
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #212529;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-name {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #868e96;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-name strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-points {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6366f1;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: -1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-balance {
|
||||||
|
color: #868e96;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-balance strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kakao-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #191919;
|
||||||
|
background: #FEE500;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary, .btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f1f3f5;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:active, .btn-primary:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body { padding: 0; }
|
||||||
|
.app-container { border-radius: 0; min-height: 100vh; display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="success-icon-wrap">
|
||||||
|
<svg viewBox="0 0 52 52">
|
||||||
|
<path d="M14.1 27.2l7.1 7.2 16.7-16.8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="success-title">적립 완료!</div>
|
||||||
|
<div class="success-name"><strong>{{ name }}</strong>님</div>
|
||||||
|
<div class="success-points">+{{ "{:,}".format(points) }}P</div>
|
||||||
|
<div class="success-balance">
|
||||||
|
총 포인트 <strong>{{ "{:,}".format(balance) }}P</strong>
|
||||||
|
</div>
|
||||||
|
<div class="kakao-badge">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 1C4.477 1 0 4.477 0 8.5c0 2.58 1.693 4.847 4.243 6.134l-1.084 3.97a.3.3 0 00.457.338L7.7 16.392c.75.112 1.52.17 2.3.17 5.523 0 10-3.477 10-7.562C20 4.477 15.523 1 10 1z" fill="#191919"/>
|
||||||
|
</svg>
|
||||||
|
카카오로 적립됨
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<a href="/" class="btn-secondary">홈으로</a>
|
||||||
|
<a href="/my-page?phone={{ phone }}" class="btn-primary">내역 보기</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -28,20 +28,20 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
|
||||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
||||||
padding: 48px 24px 32px 24px;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-top {
|
.header-top {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 56px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
|
color: #ffffff;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
@ -55,6 +55,12 @@
|
|||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-profile {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
padding: 28px 24px 32px 24px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
@ -254,20 +260,21 @@
|
|||||||
|
|
||||||
/* 모바일 최적화 */
|
/* 모바일 최적화 */
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.header {
|
.header-top {
|
||||||
padding-top: 60px;
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
height: calc(56px + env(safe-area-inset-top, 0px));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
<div class="header">
|
<div class="header-top">
|
||||||
<div class="header-top">
|
<div class="header-title">마이페이지</div>
|
||||||
<div class="header-title">마이페이지</div>
|
<a href="/my-page" class="btn-logout">다른 번호로 조회</a>
|
||||||
<a href="/my-page" class="btn-logout">다른 번호로 조회</a>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="header-profile">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<div class="user-name">{{ user.nickname }}님</div>
|
<div class="user-name">{{ user.nickname }}님</div>
|
||||||
<div class="user-phone">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</div>
|
<div class="user-phone">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</div>
|
||||||
|
|||||||
109
docs/kakao-oauth-setup.md
Normal file
109
docs/kakao-oauth-setup.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# 카카오 OAuth 설정 가이드
|
||||||
|
|
||||||
|
## 카카오 앱 정보
|
||||||
|
|
||||||
|
- **앱 ID**: 1165131
|
||||||
|
- **앱 이름**: 청춘약국
|
||||||
|
- **앱 유형**: 비즈 앱
|
||||||
|
- **개발자 콘솔**: https://developers.kakao.com/console/app/1165131
|
||||||
|
|
||||||
|
## Redirect URI 등록 (2025년 12월 개편 후)
|
||||||
|
|
||||||
|
> **주의**: 2025년 12월 카카오 콘솔 UI가 개편되면서 Redirect URI 위치가 변경됨.
|
||||||
|
> 기존에는 `카카오 로그인 > 일반`에 있었지만, 현재는 `앱 > 플랫폼 키` 하위로 이동됨.
|
||||||
|
|
||||||
|
### 현재 경로 (2025.12~ )
|
||||||
|
|
||||||
|
```
|
||||||
|
앱 > 플랫폼 키 > REST API 키 클릭 > 리다이렉트 URI
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이전 경로 (~ 2025.11, 더 이상 사용 안 함)
|
||||||
|
|
||||||
|
```
|
||||||
|
카카오 로그인 > 일반 > Redirect URI ← 여기 더 이상 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 등록된 Redirect URI 목록
|
||||||
|
|
||||||
|
| 서비스 | Redirect URI |
|
||||||
|
|--------|-------------|
|
||||||
|
| 게시판 (board-system) | `https://bbs.0bin.in/auth/kakao/callback` |
|
||||||
|
| 마일리지 적립 (pharmacy-pos-qr) | `https://mile.0bin.in/claim/kakao/callback` |
|
||||||
|
|
||||||
|
## 로그아웃 Redirect URI (별도)
|
||||||
|
|
||||||
|
로그아웃용 Redirect URI는 **다른 위치**에서 설정:
|
||||||
|
|
||||||
|
```
|
||||||
|
카카오 로그인 > 고급 > 로그아웃 리다이렉트 URI
|
||||||
|
```
|
||||||
|
|
||||||
|
로그인용 Redirect URI와 혼동하지 않도록 주의.
|
||||||
|
|
||||||
|
## 웹 도메인 등록
|
||||||
|
|
||||||
|
```
|
||||||
|
앱 > 제품 링크 관리 > 웹 도메인
|
||||||
|
```
|
||||||
|
|
||||||
|
등록된 도메인 (최대 10개):
|
||||||
|
- `https://img.0bin.in` (기본)
|
||||||
|
- `https://api.0bin.in`
|
||||||
|
- `https://0bin.in`
|
||||||
|
- `https://bbs.0bin.in`
|
||||||
|
- `https://drug.0bin.in`
|
||||||
|
- `https://ani.0bin.in`
|
||||||
|
- `https://figma.0bin.in`
|
||||||
|
- `https://am.0bin.in`
|
||||||
|
- `https://ka.0bin.in`
|
||||||
|
- `https://mile.0bin.in`
|
||||||
|
|
||||||
|
## 동의항목 설정
|
||||||
|
|
||||||
|
```
|
||||||
|
카카오 로그인 > 동의항목
|
||||||
|
```
|
||||||
|
|
||||||
|
| 항목 | ID | 용도 | 비즈앱 필요 |
|
||||||
|
|------|-----|------|------------|
|
||||||
|
| 닉네임 | profile_nickname | 사용자 이름 | X |
|
||||||
|
| 프로필 사진 | profile_image | 아바타 | X |
|
||||||
|
| 이메일 | account_email | 계정 연동 | X |
|
||||||
|
| 이름 (실명) | name | 마일리지 적립자명 | O |
|
||||||
|
| 전화번호 | phone_number | 마일리지 유저 매칭 | O |
|
||||||
|
|
||||||
|
## 환경변수
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KAKAO_CLIENT_ID=<REST API 키>
|
||||||
|
KAKAO_CLIENT_SECRET=<카카오 개발자 콘솔 > 앱 > 보안에서 확인>
|
||||||
|
KAKAO_REDIRECT_URI=https://mile.0bin.in/claim/kakao/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client ID 확인 위치
|
||||||
|
|
||||||
|
```
|
||||||
|
앱 > 플랫폼 키 > REST API 키 > 키 값
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Secret 확인 위치
|
||||||
|
|
||||||
|
```
|
||||||
|
앱 > 보안 > Client Secret 코드
|
||||||
|
```
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
|
||||||
|
| 프로젝트 | 파일 | 설명 |
|
||||||
|
|---------|------|------|
|
||||||
|
| pharmacy-pos-qr-system | `backend/services/kakao_client.py` | 카카오 API 클라이언트 |
|
||||||
|
| pharmacy-pos-qr-system | `backend/app.py` | OAuth 라우트 (`/claim/kakao/*`) |
|
||||||
|
| board-system-project | `backend/services/kakao_client.py` | 카카오 API 클라이언트 (원본) |
|
||||||
|
| board-system-project | `backend/routes/auth.py` | OAuth 라우트 (`/auth/kakao/*`) |
|
||||||
|
|
||||||
|
## 참고 링크
|
||||||
|
|
||||||
|
- [카카오 로그인 REST API 문서](https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api)
|
||||||
|
- [카카오 로그인 설정하기](https://developers.kakao.com/docs/latest/ko/kakaologin/prerequisite)
|
||||||
|
- [카카오 앱 키 구조 개편 공지 (2025.12)](https://devtalk.kakao.com/t/upcoming-kakao-developers-app-key-update/147295)
|
||||||
Loading…
Reference in New Issue
Block a user