Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] - 여행 계획 등록 페이지 구현 #125

Merged
merged 10 commits into from
Jul 25, 2024
Merged
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
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