generated from pricelees/issue-pr-template
<!-- 제목 양식 --> <!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) --> ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #46 ## ✨ 작업 내용 <!-- 어떤 작업을 했는지 알려주세요! --> - 전체 더미 데이터 추가(관리자 약 2,400건 / 회원 100만건 / 예약, 일정 약 197만건 / 결제 및 결제 상세 196만건(대략 충전식 간편결제 29.3만건, 카드 147만건, 계좌이체 19.6만건) / 테마 500건 / 매장 263건 - 로컬 애플리케이션 실행 후, 가장 병목이 되는 메인 인기 테마 쿼리만 성능 개선(5회 측정시 API 응답 시간 평균 3300 -> 90ms) ## 🧪 테스트 <!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! --> 변경된 기능은 모두 테스트 반영 ## 📚 참고 자료 및 기타 <!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! --> 취소 데이터 등이 들어가있지 않아, 일부 컬럼에서의 Cardinality가 훨씬 낮게 나오는 상황이긴 함. 예약을 예로 들면, 현재는 확정 예약인 데이터만 추가하여 확정 예약이 100%지만, 실제 도메인의 특성상 예약 데이터는 8~90%는 확정 예약일 것으로 생각하여 큰 차이가 없다고 판단하였음. Reviewed-on: #47 Co-authored-by: pricelees <priceelees@gmail.com> Co-committed-by: pricelees <priceelees@gmail.com>
331 lines
15 KiB
TypeScript
331 lines
15 KiB
TypeScript
import {isLoginRequiredError} from '@_api/apiClient';
|
||
import {fetchSidoList, fetchSigunguList} from '@_api/region/regionAPI';
|
||
import {type SidoResponse, type SigunguResponse} from '@_api/region/regionTypes';
|
||
import {fetchSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
|
||
import {ScheduleStatus, type ScheduleWithThemeResponse} from '@_api/schedule/scheduleTypes';
|
||
import {getStores} from '@_api/store/storeAPI';
|
||
import {type SimpleStoreResponse} from '@_api/store/storeTypes';
|
||
import {fetchThemeById} from '@_api/theme/themeAPI';
|
||
import {DifficultyKoreanMap, type ThemeInfoResponse} from '@_api/theme/themeTypes';
|
||
import '@_css/reservation-v2-1.css';
|
||
import React, {useEffect, useState} from 'react';
|
||
import {useLocation, useNavigate} from 'react-router-dom';
|
||
import {type ReservationData} from '@_api/reservation/reservationTypes';
|
||
import {formatDate} from 'src/util/DateTimeFormatter';
|
||
|
||
const ReservationStep1Page: React.FC = () => {
|
||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||
const [viewDate, setViewDate] = useState<Date>(new Date());
|
||
|
||
const [sidoList, setSidoList] = useState<SidoResponse[]>([]);
|
||
const [sigunguList, setSigunguList] = useState<SigunguResponse[]>([]);
|
||
const [storeList, setStoreList] = useState<SimpleStoreResponse[]>([]);
|
||
|
||
const [selectedSido, setSelectedSido] = useState('');
|
||
const [selectedSigungu, setSelectedSigungu] = useState('');
|
||
const [selectedStore, setSelectedStore] = useState<SimpleStoreResponse | null>(null);
|
||
|
||
const [schedulesByTheme, setSchedulesByTheme] = useState<Record<string, ScheduleWithThemeResponse[]>>({});
|
||
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleWithThemeResponse | null>(null);
|
||
|
||
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
|
||
const [modalThemeDetails, setModalThemeDetails] = useState<ThemeInfoResponse | null>(null);
|
||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
|
||
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
|
||
const handleError = (err: any) => {
|
||
if (isLoginRequiredError(err)) {
|
||
alert('로그인이 필요해요.');
|
||
navigate('/login', {state: {from: location}});
|
||
} else {
|
||
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
|
||
alert(message);
|
||
console.error(err);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetchSidoList().then(res => setSidoList(res.sidoList)).catch(handleError);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedSido) {
|
||
fetchSigunguList(selectedSido).then(res => setSigunguList(res.sigunguList)).catch(handleError);
|
||
} else {
|
||
setSigunguList([]);
|
||
}
|
||
setSelectedSigungu('');
|
||
}, [selectedSido]);
|
||
|
||
useEffect(() => {
|
||
if (selectedSido) {
|
||
getStores(selectedSido, selectedSigungu)
|
||
.then(res => setStoreList(res.stores))
|
||
.catch(handleError);
|
||
} else {
|
||
setStoreList([]);
|
||
}
|
||
setSelectedStore(null);
|
||
}, [selectedSido, selectedSigungu]);
|
||
|
||
useEffect(() => {
|
||
if (selectedDate && selectedStore) {
|
||
const dateStr = selectedDate.toLocaleDateString('en-CA');
|
||
fetchSchedules(selectedStore.id, dateStr)
|
||
.then(res => {
|
||
const grouped = res.schedules.reduce((acc, schedule) => {
|
||
const key = schedule.themeName;
|
||
if (!acc[key]) acc[key] = [];
|
||
acc[key].push(schedule);
|
||
return acc;
|
||
}, {} as Record<string, ScheduleWithThemeResponse[]>);
|
||
setSchedulesByTheme(grouped);
|
||
})
|
||
.catch(handleError);
|
||
} else {
|
||
setSchedulesByTheme({});
|
||
}
|
||
setSelectedSchedule(null);
|
||
}, [selectedDate, selectedStore]);
|
||
|
||
const handleDateSelect = (date: Date) => {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
if (date < today) {
|
||
alert("지난 날짜는 선택할 수 없습니다.");
|
||
return;
|
||
}
|
||
setSelectedDate(date);
|
||
};
|
||
|
||
const handleNextStep = () => {
|
||
if (!selectedSchedule) {
|
||
alert('예약할 시간을 선택해주세요.');
|
||
return;
|
||
}
|
||
setIsConfirmModalOpen(true);
|
||
};
|
||
|
||
const handleConfirmReservation = () => {
|
||
if (!selectedSchedule) return;
|
||
|
||
holdSchedule(selectedSchedule.id)
|
||
.then(() => {
|
||
fetchThemeById(selectedSchedule.themeId).then(res => {
|
||
const reservationData: ReservationData = {
|
||
scheduleId: selectedSchedule.id,
|
||
store: {
|
||
id: selectedStore!.id,
|
||
name: selectedStore!.name,
|
||
},
|
||
theme: {
|
||
id: res.id,
|
||
name: res.name,
|
||
price: res.price,
|
||
minParticipants: res.minParticipants,
|
||
maxParticipants: res.maxParticipants,
|
||
},
|
||
date: selectedDate.toLocaleDateString('en-CA'),
|
||
startFrom: selectedSchedule.startFrom,
|
||
endAt: selectedSchedule.endAt,
|
||
};
|
||
navigate('/reservation/form', {state: reservationData});
|
||
}).catch(handleError);
|
||
})
|
||
.catch(handleError);
|
||
};
|
||
|
||
const openThemeModal = (themeId: string) => {
|
||
fetchThemeById(themeId)
|
||
.then(themeDetails => {
|
||
setModalThemeDetails(themeDetails);
|
||
setIsThemeModalOpen(true);
|
||
})
|
||
.catch(handleError);
|
||
};
|
||
|
||
const renderDateCarousel = () => {
|
||
const dates = [];
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
|
||
for (let i = 0; i < 7; i++) {
|
||
const date = new Date(viewDate);
|
||
date.setDate(viewDate.getDate() + i);
|
||
dates.push(date);
|
||
}
|
||
|
||
const handlePrev = () => {
|
||
const newViewDate = new Date(viewDate);
|
||
newViewDate.setDate(viewDate.getDate() - 1);
|
||
if (newViewDate < today) {
|
||
alert("지난 날짜는 조회할 수 없습니다.");
|
||
return;
|
||
}
|
||
setViewDate(newViewDate);
|
||
}
|
||
|
||
const handleNext = () => {
|
||
const newViewDate = new Date(viewDate);
|
||
newViewDate.setDate(viewDate.getDate() + 1);
|
||
setViewDate(newViewDate);
|
||
}
|
||
|
||
const goToToday = () => {
|
||
setViewDate(new Date());
|
||
setSelectedDate(new Date());
|
||
}
|
||
|
||
return (
|
||
<div className="date-carousel">
|
||
<button onClick={handlePrev} className="carousel-arrow">‹</button>
|
||
<div className="date-options-container">
|
||
{dates.map(date => {
|
||
const isSelected = selectedDate.toDateString() === date.toDateString();
|
||
const isPast = date < today;
|
||
return (
|
||
<div
|
||
key={date.toISOString()}
|
||
className={`date-option ${isSelected ? 'active' : ''} ${isPast ? 'disabled' : ''}`}
|
||
onClick={() => handleDateSelect(date)}
|
||
>
|
||
<div className="day-of-week">{['일', '월', '화', '수', '목', '금', '토'][date.getDay()]}</div>
|
||
<div className="day-circle">{date.getDate()}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<button onClick={handleNext} className="carousel-arrow">›</button>
|
||
<button onClick={goToToday} className="today-button">오늘</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const getStatusText = (status: ScheduleStatus) => {
|
||
switch (status) {
|
||
case ScheduleStatus.AVAILABLE:
|
||
return '예약가능';
|
||
case ScheduleStatus.HOLD:
|
||
return '예약 진행중';
|
||
default:
|
||
return '예약불가';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="reservation-v21-container">
|
||
<h2 className="page-title">예약하기</h2>
|
||
|
||
<div className="step-section">
|
||
<h3>1. 날짜 선택</h3>
|
||
{renderDateCarousel()}
|
||
</div>
|
||
|
||
<div className={`step-section ${!selectedDate ? 'disabled' : ''}`}>
|
||
<h3>2. 매장 선택</h3>
|
||
<div className="region-store-selectors">
|
||
<select value={selectedSido} onChange={e => setSelectedSido(e.target.value)}>
|
||
<option value="">시/도</option>
|
||
{sidoList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
|
||
</select>
|
||
<select value={selectedSigungu} onChange={e => setSelectedSigungu(e.target.value)}
|
||
disabled={!selectedSido}>
|
||
<option value="">시/군/구 (전체)</option>
|
||
{sigunguList.map(s => <option key={s.code} value={s.code}>{s.name}</option>)}
|
||
</select>
|
||
<select value={selectedStore?.id || ''}
|
||
onChange={e => setSelectedStore(storeList.find(s => s.id === e.target.value) || null)}
|
||
disabled={storeList.length === 0}>
|
||
<option value="">매장 선택</option>
|
||
{storeList.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={`step-section ${!selectedStore ? 'disabled' : ''}`}>
|
||
<h3>3. 시간 선택</h3>
|
||
<div className="schedule-list">
|
||
{Object.keys(schedulesByTheme).length > 0 ? (
|
||
Object.entries(schedulesByTheme).map(([themeName, schedules]) => (
|
||
<div key={themeName} className="theme-schedule-group">
|
||
<div className="theme-header">
|
||
<h4>{themeName}</h4>
|
||
<button onClick={() => openThemeModal(schedules[0].themeId)}
|
||
className="theme-detail-button">상세보기
|
||
</button>
|
||
</div>
|
||
<div className="time-slots">
|
||
{schedules.map(schedule => (
|
||
<div
|
||
key={schedule.id}
|
||
className={`time-slot ${selectedSchedule?.id === schedule.id ? 'active' : ''} ${schedule.status !== ScheduleStatus.AVAILABLE ? 'disabled' : ''}`}
|
||
onClick={() => schedule.status === ScheduleStatus.AVAILABLE && setSelectedSchedule(schedule)}
|
||
>
|
||
{`${schedule.startFrom} ~ ${schedule.endAt}`}
|
||
<span className="time-availability">{getStatusText(schedule.status)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))
|
||
) : (
|
||
<div className="no-times">선택한 조건으로 예약 가능한 시간이 없습니다.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="next-step-button-container">
|
||
<button className="next-step-button" disabled={!selectedSchedule} onClick={handleNextStep}>
|
||
예약하기
|
||
</button>
|
||
</div>
|
||
|
||
{isThemeModalOpen && modalThemeDetails && (
|
||
<div className="modal-overlay" onClick={() => setIsThemeModalOpen(false)}>
|
||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||
<button className="modal-close-button" onClick={() => setIsThemeModalOpen(false)}>×</button>
|
||
<img src={modalThemeDetails.thumbnailUrl} alt={modalThemeDetails.name}
|
||
className="modal-theme-thumbnail"/>
|
||
<h2>{modalThemeDetails.name}</h2>
|
||
<div className="modal-section modal-info-grid">
|
||
<h3>테마 정보</h3>
|
||
<p><strong>난이도:</strong><span>{DifficultyKoreanMap[modalThemeDetails.difficulty]}</span></p>
|
||
<p><strong>이용 가능 인원:</strong><span>{modalThemeDetails.minParticipants} ~ {modalThemeDetails.maxParticipants}명</span></p>
|
||
<p><strong>1인당 요금:</strong><span>{modalThemeDetails.price.toLocaleString()}원</span></p>
|
||
<p><strong>예상 시간:</strong><span>{modalThemeDetails.expectedMinutesFrom} ~ {modalThemeDetails.expectedMinutesTo}분</span></p>
|
||
<p><strong>이용 가능 시간:</strong><span>{modalThemeDetails.availableMinutes}분</span></p>
|
||
</div>
|
||
<div className="modal-section">
|
||
<h3>소개</h3>
|
||
<p>{modalThemeDetails.description}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isConfirmModalOpen && selectedSchedule && (
|
||
<div className="modal-overlay" onClick={() => setIsConfirmModalOpen(false)}>
|
||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||
<button className="modal-close-button" onClick={() => setIsConfirmModalOpen(false)}>×</button>
|
||
<h2>예약 정보를 확인해주세요</h2>
|
||
<div className="modal-section modal-info-grid">
|
||
<p><strong>날짜:</strong><span>{formatDate(selectedDate.toLocaleDateString('ko-KR'))}</span></p>
|
||
<p><strong>매장:</strong><span>{selectedStore?.name}</span></p>
|
||
<p><strong>테마:</strong><span>{selectedSchedule.themeName}</span></p>
|
||
<p><strong>시간:</strong><span>{`${selectedSchedule.startFrom} ~ ${selectedSchedule.endAt}`}</span></p>
|
||
</div>
|
||
<div className="modal-actions">
|
||
<button className="cancel-button" onClick={() => setIsConfirmModalOpen(false)}>취소</button>
|
||
<button className="confirm-button" onClick={handleConfirmReservation}>예약하기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ReservationStep1Page;
|