Skip to content

Commit

Permalink
[Feature] - 여행 계획 등록 페이지 구현 (#125)
Browse files Browse the repository at this point in the history
* chore: react-datepicker 임시로 사용(데모데이때 빠르게 보여주기 위함)

* chore: css-loader, style-loader 추가

데모데이때 사용하는 date-picker를 위해 임시로 설치

* feat(DateRangePicker): 임시로 사용할 DateRangePicker 컴포넌트 구현

* refactor(Input): maxCount, count optional로 변경

* chore(main): datepicker css 추가

* refactor: Place 내 name을 placeName으로 변경

* refactor(useTravelDays): onAddDay에 useCallback 추가

* feat(usePostTravelPlan): 여행 계획 post 요청 hook 구현

* feat(TravelogueRegisterPage): 여행 계획 등록 페이지 구현
  • Loading branch information
jinyoung234 authored Jul 25, 2024
1 parent e0111a4 commit 74b3847
Show file tree
Hide file tree
Showing 18 changed files with 453 additions and 79 deletions.
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"axios": "^1.7.2",
"dotenv-webpack": "^8.1.0",
"react": "^18.3.1",
"react-datepicker": "^7.3.0",
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1"
},
Expand All @@ -42,6 +43,7 @@
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"css-loader": "^7.1.2",
"eslint": "8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-compat": "^5.0.0",
Expand All @@ -60,6 +62,7 @@
"prettier": "^3.3.2",
"storybook": "^8.2.4",
"storybook-addon-remix-react-router": "^3.0.0",
"style-loader": "^4.0.0",
"stylelint": "16.6.1",
"stylelint-config-standard": "^36.0.1",
"stylelint-order": "^6.0.4",
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/components/common/DateRangePicker/DateRangePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import DatePicker from "react-datepicker";

import { ko } from "date-fns/locale";

import Input from "@components/common/Input/Input";

const DateRangePicker = ({
startDate,
endDate,
onChangeStartDate,
onChangeEndDate,
}: {
startDate: Date | null;
endDate: Date | null;
onChangeStartDate: (date: Date | null) => void;
onChangeEndDate: (date: Date | null) => void;
}) => {
const formatDate = (date: Date | null) => {
if (!date) return "";
return date
.toLocaleDateString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit" })
.replace(/\. /g, "년 ")
.replace(".", "일");
};

return (
<DatePicker
selected={startDate}
onChange={(dates) => {
const [start, end] = dates;
onChangeStartDate(start);
onChangeEndDate(end);
}}
startDate={startDate as Date}
endDate={endDate as Date}
selectsRange={true}
locale={ko}
dateFormat="yyyy년 MM월 dd일"
customInput={
<Input
label="여행 기간"
count={0}
value={`${formatDate(startDate)} - ${formatDate(endDate)}`}
readOnly
/>
}
/>
);
};

export default DateRangePicker;
15 changes: 10 additions & 5 deletions frontend/src/components/common/DayContent/DayContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const DayContent = ({
onChangePlaceDescription,
onAddPlace,
}: {
children: (placeIndex: number) => JSX.Element;
children?: (placeIndex: number) => JSX.Element;
travelDay: TravelRegisterDay;
dayIndex: number;
onDeleteDay: (dayIndex: number) => void;
Expand All @@ -36,7 +36,7 @@ const DayContent = ({
const [isPopupOpen, setIsPopupOpen] = useState(false);

const onSelectSearchResult = (
placeInfo: Pick<TravelRegisterPlace, "name" | "position">,
placeInfo: Pick<TravelRegisterPlace, "placeName" | "position">,
dayIndex: number,
) => {
onAddPlace(dayIndex, placeInfo);
Expand All @@ -54,14 +54,19 @@ const DayContent = ({
</Accordion.Trigger>
<Accordion.Content>
<Accordion.Root>
<GoogleMapView places={travelDay.places.map((place) => place.position)} />
<GoogleMapView
places={travelDay.places.map((place) => ({
lat: Number(place.position.lat),
lng: Number(place.position.lng),
}))}
/>
{travelDay.places.map((place, placeIndex) => (
<Accordion.Item key={`${place}-${dayIndex}}`} value={`place-${dayIndex}-${placeIndex}`}>
<Accordion.Trigger onDeleteItem={() => onDeletePlace(dayIndex, placeIndex)}>
{place.name || `장소 ${placeIndex + 1}`}
{place.placeName || `장소 ${placeIndex + 1}`}
</Accordion.Trigger>
<Accordion.Content>
{children(placeIndex)}
{children && children(placeIndex)}
<Textarea
placeholder="장소에 대한 간단한 설명을 남겨주세요"
onChange={(e) => onChangePlaceDescription(e, dayIndex, placeIndex)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Place } from "@type/domain/travelogue";
import * as S from "./GoogleSearchPopup.styled";

interface GoogleSearchPopupProps {
onSearchPlaceInfo: (placeInfo: Pick<Place, "name" | "position">) => void;
onSearchPlaceInfo: (placeInfo: Pick<Place, "placeName" | "position">) => void;
}

const GoogleSearchPopup = ({ onSearchPlaceInfo }: GoogleSearchPopupProps) => {
Expand All @@ -32,8 +32,8 @@ const GoogleSearchPopup = ({ onSearchPlaceInfo }: GoogleSearchPopupProps) => {
lng: place.geometry.location.lng(),
};

const placeInfo: Pick<Place, "name" | "position"> = {
name: place.name || "",
const placeInfo: Pick<Place, "placeName" | "position"> = {
placeName: place.name || "",
position: newCenter,
};

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/common/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import CharacterCount from "../CharacterCount/CharacterCount";
import * as S from "./Input.styled";

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
count: number;
maxCount: number;
count?: number;
maxCount?: number;
label: string;
}

Expand All @@ -14,7 +14,7 @@ const Input = ({ label, count, maxCount, ...props }: InputProps) => {
<S.InputContainer>
<S.Label>{label}</S.Label>
<S.Input {...props} />
<CharacterCount count={count} maxCount={maxCount} />
{count && maxCount ? <CharacterCount count={count} maxCount={maxCount} /> : null}
</S.InputContainer>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";

import theme from "@styles/theme";
import { PRIMITIVE_COLORS, SPACING } from "@styles/tokens";

export const addButtonStyle = css`
display: flex;
width: 100%;
height: 4rem;
margin-bottom: 3.2rem;
padding: 1.2rem 1.6rem;
border: 1px solid ${theme.colors.border};
color: ${PRIMITIVE_COLORS.black};
font-weight: 700;
font-size: 1.6rem;
gap: ${SPACING.s};
border-radius: ${SPACING.s};
`;

export const addTravelAddButtonStyle = css`
display: flex;
width: 100%;
height: 4rem;
padding: 1.2rem 1.6rem;
border: 1px solid ${theme.colors.border};
color: ${PRIMITIVE_COLORS.black};
font-weight: 700;
font-size: 1.6rem;
gap: ${SPACING.s};
border-radius: ${SPACING.s};
`;

export const Layout = styled.div`
display: flex;
padding: 1.6rem;
flex-direction: column;
gap: 3.2rem;
`;

export const AccordionRootContainer = styled.div`
& > * {
margin-bottom: 1.6rem;
}
`;

export const PageInfoContainer = styled.div`
display: flex;
flex-direction: column;
gap: 0.8rem;
`;

export const addDayButtonStyle = css`
margin-top: 1.6rem;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

import { usePostTravelPlan } from "@queries/usePostTravelPlan/usePostTravelPlan";
import { differenceInDays } from "date-fns";

import {
Accordion,
Button,
DayContent,
GoogleMapLoadScript,
IconButton,
Input,
ModalBottomSheet,
PageInfo,
} from "@components/common";
import DateRangePicker from "@components/common/DateRangePicker/DateRangePicker";

import { useTravelDays } from "@hooks/pages/useTravelDays";

import * as S from "./TravelPlanRegisterPage.styled";

const MAX_TITLE_LENGTH = 20;

const TravelPlanRegisterPage = () => {
const [title, setTitle] = useState("");
const [startDate, setStartDate] = useState<Date | null>(null);
const [endDate, setEndDate] = useState<Date | null>(null);

const { travelDays, onAddDay, onAddPlace, onDeleteDay, onChangePlaceDescription, onDeletePlace } =
useTravelDays();

useEffect(() => {
if (startDate && endDate) {
const dayDiff = differenceInDays(endDate, startDate) + 1;

onAddDay(dayDiff);
}
}, [startDate, endDate, onAddDay]);

const handleStartDateChange = (date: Date | null) => {
setStartDate(date);
};

const handleEndDateChange = (date: Date | null) => {
setEndDate(date);
};

const handleChangeTitle = (e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
};

const [isOpen, setIsOpen] = useState(false);

const handleOpenBottomSheet = () => {
setIsOpen(true);
};

const handleCloseBottomSheet = () => {
setIsOpen(false);
};

const navigate = useNavigate();

const handleConfirmBottomSheet = async () => {
if (!startDate) return;

const formattedStartDate = startDate.toISOString().split("T")[0]; // "YYYY-MM-DD" 형식으로 변환

handleAddTravelPlan(
{ title, startDate: formattedStartDate, days: travelDays },
{
onSuccess: ({ data }) => {
handleCloseBottomSheet();
navigate(`/travel-plan/${data.id}`);
},
},
);
handleCloseBottomSheet();
};

const { mutateAsync: handleAddTravelPlan } = usePostTravelPlan();

return (
<>
<S.Layout>
<PageInfo mainText="여행 계획 등록" />
<Input
value={title}
maxLength={MAX_TITLE_LENGTH}
label="제목"
count={title.length}
maxCount={MAX_TITLE_LENGTH}
onChange={handleChangeTitle}
/>

<DateRangePicker
startDate={startDate}
endDate={endDate}
onChangeStartDate={handleStartDateChange}
onChangeEndDate={handleEndDateChange}
/>

<S.AccordionRootContainer>
<GoogleMapLoadScript libraries={["places", "maps"]}>
<Accordion.Root>
{travelDays.map((travelDay, dayIndex) => (
<DayContent
key={`${travelDay}-${dayIndex}`}
travelDay={travelDay}
dayIndex={dayIndex}
onAddPlace={onAddPlace}
onDeletePlace={onDeletePlace}
onDeleteDay={onDeleteDay}
onChangePlaceDescription={onChangePlaceDescription}
/>
))}
</Accordion.Root>
<IconButton
size="16"
iconType="plus"
position="left"
css={[S.addButtonStyle, S.addDayButtonStyle]}
onClick={() => onAddDay()}
>
일자 추가하기
</IconButton>
</GoogleMapLoadScript>
<Button variants="primary" onClick={handleOpenBottomSheet}>
등록
</Button>
</S.AccordionRootContainer>
</S.Layout>
<ModalBottomSheet
isOpen={isOpen}
mainText="여행 계획을 등록할까요?"
subText="등록한 후에도 다시 여행 계획을 수정할 수 있어요."
onClose={handleCloseBottomSheet}
onConfirm={handleConfirmBottomSheet}
/>
</>
);
};

export default TravelPlanRegisterPage;
Loading

0 comments on commit 74b3847

Please sign in to comment.