feat: 반려동물 등록 기능 및 확장 마이페이지 추가

- pets 테이블 추가 (이름, 종류, 품종, 사진 등)
- 반려동물 CRUD API (/api/pets)
- 확장 마이페이지 (/mypage) - 카카오 로그인 기반
- 기존 마이페이지에 퀵 메뉴 추가 (반려동물/쿠폰/구매내역/내정보)
- 카카오 로그인 시 세션에 user_id 저장
- 동물약 APC 매핑 가이드 문서 추가
This commit is contained in:
thug0bin
2026-03-02 13:56:22 +09:00
parent f102f6b42e
commit 1cebb02ec6
7 changed files with 1656 additions and 16 deletions

View File

@@ -119,6 +119,49 @@
letter-spacing: -0.2px;
}
/* 퀵 메뉴 */
.quick-menu {
display: flex;
justify-content: space-around;
padding: 20px 16px;
background: #fff;
margin: 0 16px 16px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.quick-menu-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-decoration: none;
padding: 8px 12px;
border-radius: 12px;
transition: background 0.2s;
}
.quick-menu-item:active {
background: #f5f5f5;
}
.quick-menu-icon {
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.quick-menu-item span {
font-size: 12px;
font-weight: 600;
color: #495057;
letter-spacing: -0.3px;
}
.section {
padding: 24px;
}
@@ -301,6 +344,26 @@
<div class="balance-desc">약국에서 1P = 1원으로 사용 가능</div>
</div>
<!-- 퀵 메뉴 -->
<div class="quick-menu">
<a href="/mypage" class="quick-menu-item">
<div class="quick-menu-icon" style="background: #fef3c7;">🐾</div>
<span>반려동물</span>
</a>
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
<div class="quick-menu-icon" style="background: #dbeafe;">🎟️</div>
<span>쿠폰함</span>
</a>
<a href="#" class="quick-menu-item" onclick="alert('준비중입니다'); return false;">
<div class="quick-menu-icon" style="background: #fce7f3;">📦</div>
<span>구매내역</span>
</a>
<a href="/mypage" class="quick-menu-item">
<div class="quick-menu-icon" style="background: #ede9fe;">⚙️</div>
<span>내정보</span>
</a>
</div>
<div class="section">
<div class="section-title">적립 내역</div>

View File

@@ -0,0 +1,891 @@
<!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">
<meta name="theme-color" content="#6366f1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="청춘약국">
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<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;600;700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f7fa;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
.app-container {
background: #ffffff;
min-height: 100vh;
max-width: 420px;
margin: 0 auto;
box-shadow: 0 0 20px rgba(0,0,0,0.05);
}
/* 헤더 */
.header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 20px 24px 100px;
position: relative;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.header-title {
color: white;
font-size: 20px;
font-weight: 700;
}
.btn-logout {
color: rgba(255,255,255,0.8);
font-size: 14px;
text-decoration: none;
padding: 8px 12px;
border-radius: 8px;
transition: background 0.2s;
}
.btn-logout:hover {
background: rgba(255,255,255,0.1);
}
/* 프로필 카드 */
.profile-card {
background: white;
border-radius: 20px;
margin: -80px 16px 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
position: relative;
z-index: 10;
}
.profile-info {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.profile-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
color: white;
overflow: hidden;
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.profile-details h2 {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin-bottom: 4px;
}
.profile-details p {
color: #6b7280;
font-size: 14px;
}
/* 통계 그리드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding-top: 20px;
border-top: 1px solid #f3f4f6;
}
.stat-item {
text-align: center;
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 8px;
font-size: 20px;
}
.stat-icon.purple { background: #ede9fe; }
.stat-icon.blue { background: #dbeafe; }
.stat-icon.pink { background: #fce7f3; }
.stat-value {
font-size: 18px;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
/* 섹션 */
.section {
background: white;
margin: 16px;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #1f2937;
display: flex;
align-items: center;
gap: 8px;
}
.section-action {
color: #6366f1;
font-size: 13px;
font-weight: 500;
text-decoration: none;
}
/* 반려동물 카드 */
.pet-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #f9fafb;
border-radius: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: background 0.2s;
}
.pet-card:hover {
background: #f3f4f6;
}
.pet-photo {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
overflow: hidden;
flex-shrink: 0;
}
.pet-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pet-info {
flex: 1;
}
.pet-name {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 4px;
}
.pet-details {
font-size: 13px;
color: #6b7280;
}
.pet-arrow {
color: #d1d5db;
font-size: 18px;
}
/* 반려동물 추가 버튼 */
.add-pet-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.add-pet-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
/* 메뉴 리스트 */
.menu-list {
list-style: none;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #f3f4f6;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:hover {
background: #f9fafb;
margin: 0 -20px;
padding: 16px 20px;
}
.menu-left {
display: flex;
align-items: center;
gap: 12px;
}
.menu-icon {
font-size: 20px;
}
.menu-text {
font-size: 15px;
color: #374151;
}
.menu-badge {
background: #fef3c7;
color: #92400e;
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
}
.menu-arrow {
color: #d1d5db;
}
/* 모달 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: none;
align-items: flex-end;
justify-content: center;
z-index: 1000;
}
.modal-overlay.active {
display: flex;
}
.modal-content {
background: white;
border-radius: 24px 24px 0 0;
width: 100%;
max-width: 420px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-title {
font-size: 20px;
font-weight: 700;
color: #1f2937;
}
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f3f4f6;
border: none;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* 폼 스타일 */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: #374151;
margin-bottom: 8px;
}
.form-input {
width: 100%;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 15px;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #6366f1;
}
/* 종류 선택 */
.species-options {
display: flex;
gap: 12px;
}
.species-option {
flex: 1;
padding: 20px;
border: 2px solid #e5e7eb;
border-radius: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.species-option:hover {
border-color: #c7d2fe;
}
.species-option.selected {
border-color: #6366f1;
background: #eef2ff;
}
.species-option .icon {
font-size: 40px;
margin-bottom: 8px;
}
.species-option .label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
/* 사진 업로드 */
.photo-upload {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.photo-preview {
width: 120px;
height: 120px;
border-radius: 50%;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
overflow: hidden;
cursor: pointer;
transition: background 0.2s;
}
.photo-preview:hover {
background: #e5e7eb;
}
.photo-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-hint {
font-size: 13px;
color: #9ca3af;
}
/* 제출 버튼 */
.submit-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 24px;
transition: transform 0.2s, box-shadow 0.2s;
}
.submit-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
/* 빈 상태 */
.empty-state {
text-align: center;
padding: 32px 16px;
color: #9ca3af;
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-state p {
font-size: 14px;
}
/* 로딩 */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="app-container">
<!-- 헤더 -->
<div class="header">
<div class="header-top">
<h1 class="header-title">마이페이지</h1>
<a href="/logout" class="btn-logout">로그아웃</a>
</div>
</div>
<!-- 프로필 카드 -->
<div class="profile-card">
<div class="profile-info">
<div class="profile-avatar">
{% if user.profile_image_url %}
<img src="{{ user.profile_image_url }}" alt="프로필">
{% else %}
😊
{% endif %}
</div>
<div class="profile-details">
<h2>{{ user.nickname or '회원' }}님</h2>
<p>{{ user.phone or '전화번호 미등록' }}</p>
</div>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-icon purple">🎁</div>
<div class="stat-value">{{ '{:,}'.format(user.mileage_balance or 0) }}</div>
<div class="stat-label">포인트</div>
</div>
<div class="stat-item">
<div class="stat-icon blue">📦</div>
<div class="stat-value">{{ purchase_count or 0 }}</div>
<div class="stat-label">구매</div>
</div>
<div class="stat-item">
<div class="stat-icon pink">🐾</div>
<div class="stat-value" id="pet-count">{{ pets|length }}</div>
<div class="stat-label">반려동물</div>
</div>
</div>
</div>
<!-- 반려동물 섹션 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">🐾 내 반려동물</h3>
</div>
<div id="pet-list">
{% if pets %}
{% for pet in pets %}
<div class="pet-card" onclick="editPet({{ pet.id }})">
<div class="pet-photo">
{% if pet.photo_url %}
<img src="{{ pet.photo_url }}" alt="{{ pet.name }}">
{% else %}
{{ '🐕' if pet.species == 'dog' else ('🐈' if pet.species == 'cat' else '🐾') }}
{% endif %}
</div>
<div class="pet-info">
<div class="pet-name">{{ pet.name }}</div>
<div class="pet-details">
{{ pet.species_label }}
{% if pet.breed %}· {{ pet.breed }}{% endif %}
{% if pet.gender %}· {{ '♂' if pet.gender == 'male' else ('♀' if pet.gender == 'female' else '') }}{% endif %}
</div>
</div>
<span class="pet-arrow"></span>
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<div class="icon">🐾</div>
<p>등록된 반려동물이 없습니다</p>
</div>
{% endif %}
</div>
<button class="add-pet-btn" onclick="openAddPetModal()">
<span>+</span> 반려동물 추가하기
</button>
</div>
<!-- 메뉴 섹션 -->
<div class="section">
<ul class="menu-list">
<li class="menu-item" onclick="location.href='/my-page?phone={{ user.phone }}'">
<div class="menu-left">
<span class="menu-icon">📋</span>
<span class="menu-text">적립 내역</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">📦</span>
<span class="menu-text">구매 내역</span>
<span class="menu-badge">준비중</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">🎟️</span>
<span class="menu-text">쿠폰함</span>
<span class="menu-badge">준비중</span>
</div>
<span class="menu-arrow"></span>
</li>
<li class="menu-item">
<div class="menu-left">
<span class="menu-icon">⚙️</span>
<span class="menu-text">내 정보 수정</span>
</div>
<span class="menu-arrow"></span>
</li>
</ul>
</div>
</div>
<!-- 반려동물 추가/수정 모달 -->
<div class="modal-overlay" id="petModal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">반려동물 등록</h2>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<form id="petForm" onsubmit="submitPet(event)">
<input type="hidden" id="petId" value="">
<!-- 종류 선택 -->
<div class="form-group">
<label class="form-label">종류 *</label>
<div class="species-options">
<div class="species-option" data-species="dog" onclick="selectSpecies('dog')">
<div class="icon">🐕</div>
<div class="label">강아지</div>
</div>
<div class="species-option" data-species="cat" onclick="selectSpecies('cat')">
<div class="icon">🐈</div>
<div class="label">고양이</div>
</div>
</div>
</div>
<!-- 이름 -->
<div class="form-group">
<label class="form-label">이름 *</label>
<input type="text" class="form-input" id="petName" placeholder="예: 뽀삐" required>
</div>
<!-- 품종 -->
<div class="form-group">
<label class="form-label">품종</label>
<select class="form-input" id="petBreed">
<option value="">선택해주세요</option>
</select>
</div>
<!-- 성별 -->
<div class="form-group">
<label class="form-label">성별</label>
<select class="form-input" id="petGender">
<option value="">선택해주세요</option>
<option value="male">남아 ♂</option>
<option value="female">여아 ♀</option>
<option value="unknown">모름</option>
</select>
</div>
<!-- 사진 -->
<div class="form-group">
<label class="form-label">사진</label>
<div class="photo-upload">
<div class="photo-preview" id="photoPreview" onclick="document.getElementById('photoInput').click()">
📷
</div>
<input type="file" id="photoInput" accept="image/*" style="display:none" onchange="previewPhoto(event)">
<span class="photo-hint">탭하여 사진 추가</span>
</div>
</div>
<button type="submit" class="submit-btn" id="submitBtn">등록하기</button>
<button type="button" class="submit-btn" style="background:#ef4444; margin-top:12px; display:none;" id="deleteBtn" onclick="deletePet()">삭제하기</button>
</form>
</div>
</div>
<script>
let selectedSpecies = '';
let currentPetId = null;
const DOG_BREEDS = ['말티즈', '푸들', '포메라니안', '치와와', '시츄', '요크셔테리어', '비숑프리제', '골든리트리버', '래브라도리트리버', '진돗개', '시바견', '웰시코기', '닥스훈트', '비글', '보더콜리', '프렌치불독', '불독', '슈나우저', '사모예드', '허스키', '믹스견', '기타'];
const CAT_BREEDS = ['코리안숏헤어', '페르시안', '러시안블루', '샴', '먼치킨', '랙돌', '브리티쉬숏헤어', '아메리칸숏헤어', '스코티쉬폴드', '노르웨이숲', '메인쿤', '뱅갈', '아비시니안', '터키쉬앙고라', '믹스묘', '기타'];
function selectSpecies(species) {
selectedSpecies = species;
document.querySelectorAll('.species-option').forEach(el => {
el.classList.toggle('selected', el.dataset.species === species);
});
// 품종 옵션 업데이트
const breedSelect = document.getElementById('petBreed');
const breeds = species === 'dog' ? DOG_BREEDS : CAT_BREEDS;
breedSelect.innerHTML = '<option value="">선택해주세요</option>' +
breeds.map(b => `<option value="${b}">${b}</option>`).join('');
}
function openAddPetModal() {
currentPetId = null;
document.getElementById('modalTitle').textContent = '반려동물 등록';
document.getElementById('petId').value = '';
document.getElementById('petForm').reset();
document.getElementById('photoPreview').innerHTML = '📷';
document.getElementById('submitBtn').textContent = '등록하기';
document.getElementById('deleteBtn').style.display = 'none';
selectedSpecies = '';
document.querySelectorAll('.species-option').forEach(el => el.classList.remove('selected'));
document.getElementById('petModal').classList.add('active');
}
function editPet(petId) {
// TODO: API에서 pet 정보 가져와서 폼에 채우기
currentPetId = petId;
document.getElementById('modalTitle').textContent = '반려동물 수정';
document.getElementById('submitBtn').textContent = '수정하기';
document.getElementById('deleteBtn').style.display = 'block';
document.getElementById('petModal').classList.add('active');
}
function closeModal() {
document.getElementById('petModal').classList.remove('active');
}
function previewPhoto(event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('photoPreview').innerHTML =
`<img src="${e.target.result}" alt="미리보기">`;
};
reader.readAsDataURL(file);
}
}
async function submitPet(event) {
event.preventDefault();
if (!selectedSpecies) {
alert('종류를 선택해주세요.');
return;
}
const name = document.getElementById('petName').value.trim();
if (!name) {
alert('이름을 입력해주세요.');
return;
}
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '처리중...';
try {
const data = {
name: name,
species: selectedSpecies,
breed: document.getElementById('petBreed').value,
gender: document.getElementById('petGender').value
};
const url = currentPetId ? `/api/pets/${currentPetId}` : '/api/pets';
const method = currentPetId ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
// 사진 업로드
const photoInput = document.getElementById('photoInput');
if (photoInput.files.length > 0) {
const petId = result.pet_id || currentPetId;
const formData = new FormData();
formData.append('photo', photoInput.files[0]);
await fetch(`/api/pets/${petId}/photo`, {
method: 'POST',
body: formData
});
}
alert(result.message || '저장되었습니다!');
location.reload();
} else {
alert(result.error || '오류가 발생했습니다.');
}
} catch (error) {
console.error(error);
alert('서버 오류가 발생했습니다.');
} finally {
btn.disabled = false;
btn.textContent = currentPetId ? '수정하기' : '등록하기';
}
}
async function deletePet() {
if (!currentPetId) return;
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await fetch(`/api/pets/${currentPetId}`, {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
alert('삭제되었습니다.');
location.reload();
} else {
alert(result.error || '삭제 실패');
}
} catch (error) {
alert('서버 오류가 발생했습니다.');
}
}
// 모달 외부 클릭 시 닫기
document.getElementById('petModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
</script>
</body>
</html>