Skip to content

Commit 631ede5

Browse files
authored
Merge pull request #249 from boostcampwm-2024/feat/#238-separate-page-and-rendering
페이지 분리
2 parents ca579a1 + 13ca62f commit 631ede5

File tree

16 files changed

+285
-206
lines changed

16 files changed

+285
-206
lines changed

front/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export default tseslint.config(
2929
...reactHooks.configs.recommended.rules,
3030
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
3131
'prefer-const': ['off'],
32+
'react-refresh/only-export-components': ['off'],
3233
},
3334
},
3435
);

front/src/components/Toast/ToastContainer.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useEffect, useState, useTransition } from 'react';
22

33
import Toast from '@/components/Toast/Toast.tsx';
44

@@ -14,14 +14,14 @@ interface ToastData {
1414

1515
export default function ToastContainer() {
1616
const [toastList, setToastList] = useState<ToastData[]>([]);
17-
17+
const [, startTransition] = useTransition();
1818
const getId = () => Date.now();
1919
const setSuccessToast = (text: string) =>
20-
setToastList((prev) => [{ type: 'success', text, id: getId() }, ...prev]);
20+
startTransition(() => setToastList((prev) => [{ type: 'success', text, id: getId() }, ...prev]));
2121
const setWarningToast = (text: string) =>
22-
setToastList((prev) => [{ type: 'warning', text, id: getId() }, ...prev]);
22+
startTransition(() => setToastList((prev) => [{ type: 'warning', text, id: getId() }, ...prev]));
2323
const setErrorToast = (text: string) =>
24-
setToastList((prev) => [{ type: 'error', text, id: getId() }, ...prev]);
24+
startTransition(() => setToastList((prev) => [{ type: 'error', text, id: getId() }, ...prev]));
2525

2626
useEffect(() => {
2727
const toastEvent = ToastEvent.getInstance();

front/src/constants/reservation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const SEAT_COUNT_LIST = [1, 2, 3, 4] as const;

front/src/constants/user.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const LOGIN_FAILED_MESSAGE = '아이디 또는 비밀번호가 일치하지 않습니다.';

front/src/hooks/useSSE.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ export default function useSSE<T>({ sseURL }: useSSEProps) {
3030
};
3131
}, [sseURL]);
3232

33-
return { data } as { data: T | null };
33+
const isLoading = data === null ? true : false;
34+
return { data, isLoading } as { data: T | null; isLoading: boolean };
3435
}

front/src/pages/LoginPage/index.tsx

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,38 +4,37 @@ import { type CustomError } from '@/api/axios.ts';
44
import { type UserData, postLogin } from '@/api/user.ts';
55

66
import { useAuthContext } from '@/hooks/useAuthContext.tsx';
7-
import useForm, { type Validate } from '@/hooks/useForm';
7+
import useForm from '@/hooks/useForm';
88

99
import { toast } from '@/components/Toast/index.ts';
1010
import Button from '@/components/common/Button';
1111
import Field from '@/components/common/Field';
1212
import Icon from '@/components/common/Icon';
1313
import Input from '@/components/common/Input';
1414

15+
import { lengthValidate } from '@/pages/LoginPage/validate.ts';
16+
17+
import { LOGIN_FAILED_MESSAGE } from '@/constants/user.ts';
18+
import type { LoginForm } from '@/type/user.ts';
1519
import { useMutation } from '@tanstack/react-query';
1620
import { AxiosResponse } from 'axios';
1721

18-
type Form = {
19-
id: string;
20-
password: string;
21-
};
22-
type ResponseData = {
23-
loginId: string;
24-
};
25-
26-
const FAILED_MESSAGE = '아이디 또는 비밀번호가 일치하지 않습니다.';
2722
export default function LoginPage() {
23+
type ResponseData = {
24+
loginId: string;
25+
};
26+
2827
const {
2928
handleSubmit,
3029
register,
3130
formState: { errors },
32-
} = useForm<Form>();
31+
} = useForm<LoginForm>();
3332
const { login } = useAuthContext();
3433
const navigation = useNavigate();
3534
const { mutate, isPending, error } = useMutation<AxiosResponse<ResponseData>, CustomError, UserData>({
3635
mutationFn: postLogin,
3736
onError: () => {
38-
toast.error(`로그인 실패\n 사유 : ${FAILED_MESSAGE}`);
37+
toast.error(`로그인 실패\n 사유 : ${LOGIN_FAILED_MESSAGE}`);
3938
},
4039
onSuccess: (data) => {
4140
const { loginId } = data.data;
@@ -45,7 +44,7 @@ export default function LoginPage() {
4544
},
4645
});
4746

48-
const submit = async (data: Form) => {
47+
const submit = async (data: LoginForm) => {
4948
const { id, password } = data;
5049
mutate({ loginId: id, loginPassword: password });
5150
};
@@ -59,7 +58,7 @@ export default function LoginPage() {
5958
<Field
6059
label="Id"
6160
isValid={!errors.id && !error}
62-
errorMessage={errors.id ? errors.id : FAILED_MESSAGE}>
61+
errorMessage={errors.id ? errors.id : LOGIN_FAILED_MESSAGE}>
6362
<Input
6463
disabled={isPending}
6564
{...register('id', {
@@ -71,7 +70,7 @@ export default function LoginPage() {
7170
<Field
7271
label="Password"
7372
isValid={!errors.password && !error}
74-
errorMessage={errors.password ? errors.password : FAILED_MESSAGE}>
73+
errorMessage={errors.password ? errors.password : LOGIN_FAILED_MESSAGE}>
7574
<Input
7675
type="password"
7776
disabled={isPending}
@@ -96,9 +95,3 @@ export default function LoginPage() {
9695
</div>
9796
);
9897
}
99-
100-
const lengthValidate: Validate<Form> = ({ value }) => {
101-
const isRightLength = value.length >= 4 && value.length <= 12;
102-
if (!isRightLength) return '최소 4자리, 최대 12자리 입니다.';
103-
return null;
104-
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { Validate } from '@/hooks/useForm.tsx';
2+
3+
import type { LoginForm } from '@/type/user.ts';
4+
5+
export const lengthValidate: Validate<LoginForm> = ({ value }) => {
6+
const isRightLength = value.length >= 4 && value.length <= 12;
7+
if (!isRightLength) return '최소 4자리, 최대 12자리 입니다.';
8+
return null;
9+
};

front/src/pages/NotFoundPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
33
import Button from '@/components/common/Button.tsx';
44
import Icon from '@/components/common/Icon.tsx';
55

6-
export default function NotFondPage() {
6+
export default function NotFoundPage() {
77
return (
88
<div className="flex h-[100vh] w-full items-center justify-center">
99
<div className="flex w-[420px] flex-col items-center gap-8">

front/src/pages/ReservationPage/SeatCountContent.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { postSeatCount } from '@/api/booking.ts';
66
import Button from '@/components/common/Button';
77
import Separator from '@/components/common/Separator.tsx';
88

9+
import { SEAT_COUNT_LIST } from '@/constants/reservation.ts';
10+
import type { SeatCount } from '@/type/reservation.ts';
911
import { useMutation } from '@tanstack/react-query';
1012
import { cx } from 'class-variance-authority';
1113

@@ -15,7 +17,6 @@ interface ISeatCountContentProps {
1517
goNextStep: () => void;
1618
}
1719
//section 선택 페이지는 좌석 선택시에도 사용된다\
18-
export type SeatCount = (typeof SEAT_COUNT_LIST)[number];
1920

2021
export default function SeatCountContent({ selectCount, goNextStep, seatCount }: ISeatCountContentProps) {
2122
const { mutate: postSeatCountMutate, isPending } = useMutation({ mutationFn: postSeatCount });
@@ -39,7 +40,11 @@ export default function SeatCountContent({ selectCount, goNextStep, seatCount }:
3940
<Separator direction="row" />
4041
<label htmlFor="seatCount" className="flex flex-col gap-4">
4142
<span className="text-heading2">좌석 개수</span>
42-
<select id="seatCount" className="w-full rounded border px-4 py-2" onChange={selectSeatCount}>
43+
<select
44+
id="seatCount"
45+
className="w-full rounded border px-4 py-2"
46+
defaultValue={seatCount}
47+
onChange={selectSeatCount}>
4348
{SEAT_COUNT_LIST.map((count) => (
4449
<option key={count} className="" value={count}>{`${count} 개`}</option>
4550
))}
@@ -66,6 +71,4 @@ export default function SeatCountContent({ selectCount, goNextStep, seatCount }:
6671
</div>
6772
);
6873
}
69-
70-
const SEAT_COUNT_LIST = [1, 2, 3, 4] as const;
7174
const HELP_MESSAGE_LIST = ['최대 4매까지 선택 가능합니다.'];
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { useParams } from 'react-router-dom';
2+
3+
import { BASE_URL } from '@/api/axios.ts';
4+
import type { PostSeatData } from '@/api/booking.ts';
5+
import { postSeat } from '@/api/booking.ts';
6+
7+
import useSSE from '@/hooks/useSSE.tsx';
8+
9+
import { toast } from '@/components/Toast/index.ts';
10+
import Icon from '@/components/common/Icon.tsx';
11+
12+
import type { SelectedSeat } from '@/pages/ReservationPage/SectionAndSeat.tsx';
13+
14+
import { API } from '@/constants/index.ts';
15+
import type { Section } from '@/type/index.ts';
16+
import { useMutation, useMutationState } from '@tanstack/react-query';
17+
18+
interface SeatMapProps {
19+
selectedSection: Section;
20+
selectedSectionIndex: number;
21+
setSelectedSeats: (seats: SelectedSeat[]) => void;
22+
maxSelectCount: number;
23+
selectedSeats: SelectedSeat[];
24+
}
25+
26+
export default function SeatMap({
27+
selectedSection,
28+
selectedSectionIndex,
29+
setSelectedSeats,
30+
maxSelectCount,
31+
selectedSeats,
32+
}: SeatMapProps) {
33+
const { eventId } = useParams();
34+
const { mutate: pickSeat } = useMutation({
35+
mutationFn: postSeat,
36+
mutationKey: PICK_SEAT_MUTATION_KEY_LIST,
37+
onError: (_, data) => {
38+
const { seatIndex, sectionIndex } = data;
39+
const filtered = selectedSeats.filter(
40+
(seat) => seat.seatIndex !== seatIndex || seat.sectionIndex !== sectionIndex,
41+
);
42+
setSelectedSeats([...filtered]);
43+
toast.error('좌석 선택/취소에 실패했습니다');
44+
},
45+
throwOnError: false,
46+
});
47+
48+
const reservingList = useMutationState<PostSeatData>({
49+
filters: {
50+
mutationKey: PICK_SEAT_MUTATION_KEY_LIST,
51+
status: 'pending',
52+
predicate: (mutation) => {
53+
return mutation.state.variables.expectedStatus === 'reserved';
54+
},
55+
},
56+
select: (mutation) => mutation.state.variables as PostSeatData,
57+
});
58+
const { data, isLoading } = useSSE<{ seatStatus: boolean[][] }>({
59+
sseURL: `${BASE_URL}${API.BOOKING.GET_SEATS_SSE(Number(eventId))}`,
60+
});
61+
62+
const seatStatusList = data ? data.seatStatus : null;
63+
const selectedSeatStatus = seatStatusList ? seatStatusList[selectedSectionIndex] : null;
64+
const canRender = isLoading === false && seatStatusList && seatStatusList.length !== 0;
65+
66+
return (
67+
<>
68+
{canRender ? (
69+
renderSeatMap(
70+
selectedSection,
71+
selectedSectionIndex,
72+
selectedSeatStatus!,
73+
setSelectedSeats,
74+
maxSelectCount,
75+
selectedSeats,
76+
pickSeat,
77+
Number(eventId!),
78+
reservingList,
79+
)
80+
) : (
81+
<Loading />
82+
)}
83+
</>
84+
);
85+
}
86+
87+
const Loading = () => {
88+
return (
89+
<div className="absolute flex w-full items-center justify-center">
90+
<div className="flex flex-col items-center gap-4">
91+
<Icon iconName="Loading" className="h-16 w-16 animate-spin" color={'primary'} />
92+
<span className="text-heading3 text-typo">loading..</span>
93+
</div>
94+
</div>
95+
);
96+
};
97+
98+
const renderSeatMap = (
99+
selectedSection: Section,
100+
selectedSectionIndex: number,
101+
seatStatus: boolean[],
102+
setSelectedSeats: (seats: SelectedSeat[]) => void,
103+
maxSelectCount: number,
104+
selectedSeats: SelectedSeat[],
105+
pickSeat: (
106+
data: PostSeatData,
107+
mutateOption?: {
108+
onSuccess?: () => void;
109+
onError?: () => void;
110+
},
111+
) => void,
112+
eventId: number,
113+
reservingList: PostSeatData[],
114+
) => {
115+
let columnCount = 1;
116+
const { name, seats, colLen } = selectedSection;
117+
118+
return seats.map((seat, index) => {
119+
const rowsCount = Math.floor(index / colLen) + 1;
120+
const isNewLine = index % colLen === 0;
121+
if (isNewLine) columnCount = 1;
122+
const seatName = seat ? `${name}구역 ${rowsCount}${columnCount}열` : null;
123+
const isMine = seatName && selectedSeats.some((selected) => selected.name == seatName);
124+
125+
const isReserving = reservingList.some(
126+
(reserve) => reserve.seatIndex === index && reserve.sectionIndex === selectedSectionIndex,
127+
);
128+
const isOthers = !seatStatus[index];
129+
//TODO 삼항 연산자 제거
130+
const stateClass = !seat
131+
? 'bg-transparent pointer-events-none'
132+
: isReserving
133+
? 'bg-warning pointer-events-none'
134+
: isMine
135+
? 'bg-success cursor-pointer'
136+
: isOthers
137+
? `bg-surface-sub pointer-events-none`
138+
: 'bg-primary cursor-pointer';
139+
if (seat) columnCount++;
140+
return (
141+
<div
142+
className={`h-6 w-6 ${stateClass}`}
143+
data-name={seatName}
144+
onClick={() => {
145+
const selectedCount = selectedSeats.length;
146+
if (isMine) {
147+
const filtered = selectedSeats.filter((seat) => seatName !== seat.name);
148+
pickSeat(
149+
{
150+
sectionIndex: selectedSectionIndex,
151+
seatIndex: index,
152+
expectedStatus: 'deleted',
153+
eventId,
154+
},
155+
{
156+
onSuccess: () => {
157+
toast.warning(`${seatName!} 좌석을 취소했습니다`);
158+
},
159+
},
160+
);
161+
setSelectedSeats(filtered);
162+
return;
163+
}
164+
165+
if (maxSelectCount <= selectedCount) return;
166+
pickSeat(
167+
{
168+
sectionIndex: selectedSectionIndex,
169+
seatIndex: index,
170+
expectedStatus: 'reserved',
171+
eventId,
172+
},
173+
{
174+
onSuccess: () => {
175+
toast.success(`${seatName!} 좌석 선택에\n성공했습니다`);
176+
},
177+
},
178+
);
179+
setSelectedSeats([
180+
...selectedSeats,
181+
{ seatIndex: index, sectionIndex: selectedSectionIndex, name: seatName! },
182+
]);
183+
}}
184+
/>
185+
);
186+
});
187+
};
188+
189+
const PICK_SEAT_MUTATION_KEY_LIST = ['seat'];

0 commit comments

Comments
 (0)