roomescape-refactored/frontend/src/pages/ReservationStep1Page.tsx
pricelees 5658f6c31f [#34] 회원 / 인증 도메인 재정의 (#43)
<!-- 제목 양식 -->
<!-- [이슈번호] 작업 요약 (예시: [#10] Gitea 템플릿 생성) -->

## 📝 관련 이슈 및 PR

**PR과 관련된 이슈 번호**
- #34

##  작업 내용
<!-- 어떤 작업을 했는지 알려주세요! -->
- 회원 테이블과 관리자 테이블 분리 및 관리자 계정의 예약 기능 제거
- API 인증을 모두(Public) / 회원 전용(UserOnly) / 관리자 전용(AdminOnly) / 회원 + 관리자(Authenticated) 로 세분화해서 구분
- 관리자의 경우 API 접근 권한 세분화 등 인증 로직 개선
- 전체 리팩터링이 완료되어 레거시 코드 제거

## 🧪 테스트
<!-- 어떤 테스트를 생각했고 진행했는지 알려주세요! -->
<img width="750" alt="스크린샷 2025-09-13 19.11.44.png" src="attachments/11e1a79c-9723-4843-839d-be6158d94130">

- 추가 & 변경된 모든 API에 대한 통합 테스트 진행

## 📚 참고 자료 및 기타
<!-- 참고한 자료, 또는 논의할 사항이 있다면 알려주세요! -->

Reviewed-on: #43
Co-authored-by: pricelees <priceelees@gmail.com>
Co-committed-by: pricelees <priceelees@gmail.com>
2025-09-13 10:13:45 +00:00

303 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {isLoginRequiredError} from '@_api/apiClient';
import {findAvailableThemesByDate, findSchedules, holdSchedule} from '@_api/schedule/scheduleAPI';
import {type ScheduleRetrieveResponse, ScheduleStatus} from '@_api/schedule/scheduleTypes';
import {findThemesByIds} from '@_api/theme/themeAPI';
import {mapThemeResponse, 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 {formatDate, formatTime} from 'src/util/DateTimeFormatter';
const ReservationStep1Page: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [viewDate, setViewDate] = useState<Date>(new Date()); // For carousel
const [themes, setThemes] = useState<ThemeInfoResponse[]>([]);
const [selectedTheme, setSelectedTheme] = useState<ThemeInfoResponse | null>(null);
const [schedules, setSchedules] = useState<ScheduleRetrieveResponse[]>([]);
const [selectedSchedule, setSelectedSchedule] = useState<ScheduleRetrieveResponse | null>(null);
const [isThemeModalOpen, setIsThemeModalOpen] = useState(false);
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(() => {
if (selectedDate) {
const dateStr = selectedDate.toLocaleDateString('en-CA'); // yyyy-mm-dd
findAvailableThemesByDate(dateStr)
.then(res => {
console.log('Available themes response:', res);
const themeIds: string[] = res.themeIds;
console.log('Available theme IDs:', themeIds);
if (themeIds.length > 0) {
return findThemesByIds({ themeIds });
} else {
return Promise.resolve({ themes: [] });
}
})
.then(themeResponse => {
setThemes(themeResponse.themes.map(mapThemeResponse));
})
.catch((err) => {
if (isLoginRequiredError(err)) {
setThemes([]);
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
})
.finally(() => {
setSelectedTheme(null);
setSchedules([]);
setSelectedSchedule(null);
});
}
}, [selectedDate]);
useEffect(() => {
if (selectedDate && selectedTheme) {
const dateStr = selectedDate.toLocaleDateString('en-CA');
findSchedules(dateStr, selectedTheme.id)
.then(res => {
setSchedules(res.schedules);
setSelectedSchedule(null);
})
.catch((err) => {
if (isLoginRequiredError(err)) {
setSchedules([]);
} else {
const message = err.response?.data?.message || '알 수 없는 오류가 발생했습니다.';
alert(message);
console.error(err);
}
setSelectedSchedule(null);
});
}
}, [selectedDate, selectedTheme]);
const handleNextStep = () => {
if (!selectedDate || !selectedTheme || !selectedSchedule) {
alert('날짜, 테마, 시간을 모두 선택해주세요.');
return;
}
if (selectedSchedule.status !== ScheduleStatus.AVAILABLE) {
alert('예약할 수 없는 시간입니다.');
return;
}
setIsConfirmModalOpen(true);
};
const handleConfirmReservation = () => {
if (!selectedSchedule) return;
holdSchedule(selectedSchedule.id)
.then(() => {
navigate('/reservation/form', {
state: {
scheduleId: selectedSchedule.id,
theme: selectedTheme,
date: selectedDate.toLocaleDateString('en-CA'),
time: selectedSchedule.time,
}
});
})
.catch(handleError)
.finally(() => setIsConfirmModalOpen(false));
};
const handleDateSelect = (date: Date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (date < today) {
alert("지난 날짜는 선택할 수 없습니다.");
return;
}
setSelectedDate(date);
}
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 openThemeModal = (theme: ThemeInfoResponse) => {
setSelectedTheme(theme);
setIsThemeModalOpen(true);
};
const getStatusText = (status: ScheduleStatus) => {
switch (status) {
case ScheduleStatus.AVAILABLE:
return '예약가능';
case ScheduleStatus.HOLD:
return '예약 진행중';
default:
return '예약불가';
}
};
const isButtonDisabled = !selectedDate || !selectedTheme || !selectedSchedule || selectedSchedule.status !== ScheduleStatus.AVAILABLE;
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="theme-list">
{themes.map(theme => (
<div
key={theme.id}
className={`theme-card ${selectedTheme?.id === theme.id ? 'active' : ''}`}
onClick={() => setSelectedTheme(theme)}
>
<div className="theme-info">
<h4>{theme.name}</h4>
<div className="theme-meta">
<p><strong>1 :</strong> {theme.price.toLocaleString()}</p>
<p><strong>:</strong> {theme.difficulty}</p>
<p><strong> :</strong> {theme.minParticipants} ~ {theme.maxParticipants}</p>
<p><strong> :</strong> {theme.expectedMinutesFrom} ~ {theme.expectedMinutesTo}</p>
<p><strong> :</strong> {theme.availableMinutes}</p>
</div>
<button className="theme-detail-button" onClick={(e) => { e.stopPropagation(); openThemeModal(theme); }}></button>
</div>
</div>
))}
</div>
</div>
<div className={`step-section ${!selectedTheme ? 'disabled' : ''}`}>
<h3>3. </h3>
<div className="time-slots">
{schedules.length > 0 ? 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.time}
<span className="time-availability">{getStatusText(schedule.status)}</span>
</div>
)) : <div className="no-times"> .</div>}
</div>
</div>
<div className="next-step-button-container">
<button className="next-step-button" disabled={isButtonDisabled} onClick={handleNextStep}>
</button>
</div>
{isThemeModalOpen && selectedTheme && (
<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={selectedTheme.thumbnailUrl} alt={selectedTheme.name} className="modal-theme-thumbnail" />
<h2>{selectedTheme.name}</h2>
<div className="modal-section">
<h3> </h3>
<p><strong>:</strong> {selectedTheme.difficulty}</p>
<p><strong> :</strong> {selectedTheme.minParticipants} ~ {selectedTheme.maxParticipants}</p>
<p><strong> :</strong> {selectedTheme.expectedMinutesFrom} ~ {selectedTheme.expectedMinutesTo}</p>
<p><strong>1 :</strong> {selectedTheme.price.toLocaleString()}</p>
</div>
<div className="modal-section">
<h3></h3>
<p>{selectedTheme.description}</p>
</div>
</div>
</div>
)}
{isConfirmModalOpen && (
<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">
<p><strong>:</strong> {formatDate(selectedDate!!.toLocaleDateString('ko-KR'))}</p>
<p><strong>:</strong> {selectedTheme!!.name}</p>
<p><strong>:</strong> {formatTime(selectedSchedule!!.time)}</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;