diff --git a/.yarn/cache/react-calendar-npm-4.6.1-86ff8fbeeb-54e9414a79.zip b/.yarn/cache/react-calendar-npm-4.6.1-86ff8fbeeb-54e9414a79.zip index 1849e9c1..55843943 100644 Binary files a/.yarn/cache/react-calendar-npm-4.6.1-86ff8fbeeb-54e9414a79.zip and b/.yarn/cache/react-calendar-npm-4.6.1-86ff8fbeeb-54e9414a79.zip differ diff --git a/public/images/samples/arrow.svg b/public/images/samples/arrow.svg new file mode 100644 index 00000000..91d3c114 --- /dev/null +++ b/public/images/samples/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/samples/category_all.svg b/public/images/samples/category_all.svg new file mode 100644 index 00000000..e51dc882 --- /dev/null +++ b/public/images/samples/category_all.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/samples/category_camping.svg b/public/images/samples/category_camping.svg new file mode 100644 index 00000000..9b0a368b --- /dev/null +++ b/public/images/samples/category_camping.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/samples/category_culture.svg b/public/images/samples/category_culture.svg new file mode 100644 index 00000000..34ead103 --- /dev/null +++ b/public/images/samples/category_culture.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/samples/category_etc.svg b/public/images/samples/category_etc.svg new file mode 100644 index 00000000..ca0e2682 --- /dev/null +++ b/public/images/samples/category_etc.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/samples/category_festival.svg b/public/images/samples/category_festival.svg new file mode 100644 index 00000000..d87f9d6d --- /dev/null +++ b/public/images/samples/category_festival.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/samples/category_food.svg b/public/images/samples/category_food.svg new file mode 100644 index 00000000..6e01ad29 --- /dev/null +++ b/public/images/samples/category_food.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/samples/category_hiking.svg b/public/images/samples/category_hiking.svg new file mode 100644 index 00000000..518d6bf1 --- /dev/null +++ b/public/images/samples/category_hiking.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/samples/category_movie.svg b/public/images/samples/category_movie.svg new file mode 100644 index 00000000..fd9a5881 --- /dev/null +++ b/public/images/samples/category_movie.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/samples/category_shopping.svg b/public/images/samples/category_shopping.svg new file mode 100644 index 00000000..fca75581 --- /dev/null +++ b/public/images/samples/category_shopping.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/samples/category_tour.svg b/public/images/samples/category_tour.svg new file mode 100644 index 00000000..7061d097 --- /dev/null +++ b/public/images/samples/category_tour.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/create-schedule/components/Categories.tsx b/src/create-schedule/components/Categories.tsx new file mode 100644 index 00000000..bca80d67 --- /dev/null +++ b/src/create-schedule/components/Categories.tsx @@ -0,0 +1,33 @@ +import { CATEGORY_TAGS } from "@create-schedule/constants"; +import { CategoryTags } from "@shared/types"; +import CategoryTagBox from "./CategoryTagBox"; +import ScheduleTitle from "./ScheduleTitle"; + +interface CategoriesProps { + clickedCategory: CategoryTags; + handleClickCategory: (value: CategoryTags) => void; +} + +const Categories = ({ + clickedCategory, + handleClickCategory, +}: CategoriesProps) => { + return ( + <> + +
+ {CATEGORY_TAGS.map(({ imageSrc, title }) => ( + + ))} +
+ + ); +}; + +export default Categories; diff --git a/src/create-schedule/components/CategoryFrame.tsx b/src/create-schedule/components/CategoryFrame.tsx new file mode 100644 index 00000000..80743763 --- /dev/null +++ b/src/create-schedule/components/CategoryFrame.tsx @@ -0,0 +1,103 @@ +import { handleImageSrc } from "@create-schedule/util"; +import Image from "next/image"; +import React from "react"; +import { useSetRecoilState } from "recoil"; +import { selectedScheduleItem } from "@shared/recoil"; +import { CategoryItem, CategoryTags } from "@shared/types"; + +interface CategoryFrameProps { + hasPointer?: boolean; + itemHeight?: number; + clickedCategory?: CategoryTags; + category: CategoryItem[]; +} + +const CategoryFrame = ({ + hasPointer, + itemHeight = 21, + clickedCategory, + category, +}: CategoryFrameProps) => { + const setSelectedItem = useSetRecoilState(selectedScheduleItem); + + const onDragStart = (index: number) => { + setSelectedItem({ + ...category[index], + selectedTime: 0, + }); + }; + + const filteredCategory = clickedCategory + ? category.filter( + (_category) => + clickedCategory === "전체" || clickedCategory === _category.category, + ) + : category; + + return ( + <> + {filteredCategory.map((_category, index) => { + const isLastIndex = index === category.length - 1; + const imageSrc = handleImageSrc(_category.category); + + return ( +
onDragStart(index)} + onDragOver={(e) => e.preventDefault()} + className={`${isLastIndex ? "" : "mb-2 "}${ + hasPointer ? "cursor-pointer " : "" + }flex w-[250px]`} + key={_category.city + _category.tagBackground} + style={{ height: `${itemHeight}px` }} + > +
+
+
+
+ {imageSrc && ( + {imageSrc} + )} + + {_category.category} + + + {_category.title} + + + {_category.city} + +
+
+
+
+
+ ); + })} + + ); +}; + +export default CategoryFrame; diff --git a/src/create-schedule/components/CategoryItems.tsx b/src/create-schedule/components/CategoryItems.tsx new file mode 100644 index 00000000..b52d1b50 --- /dev/null +++ b/src/create-schedule/components/CategoryItems.tsx @@ -0,0 +1,33 @@ +import { SCHEDULE_TITLE } from "@create-schedule/constants"; +import { CategoryItem, CategoryTags } from "@shared/types"; +import CategoryFrame from "./CategoryFrame"; +import ScheduleTitle from "./ScheduleTitle"; + +interface CategoryItemsProps { + category: CategoryItem[]; + clickedCategory: CategoryTags; +} + +const CategoryItems = ({ category, clickedCategory }: CategoryItemsProps) => { + return ( +
+ +
+ +
+
+ +
+
+ ); +}; + +export default CategoryItems; diff --git a/src/create-schedule/components/CategoryTagBox.tsx b/src/create-schedule/components/CategoryTagBox.tsx new file mode 100644 index 00000000..42c38deb --- /dev/null +++ b/src/create-schedule/components/CategoryTagBox.tsx @@ -0,0 +1,42 @@ +import Image from "next/image"; +import { CategoryTags } from "@shared/types"; + +interface CategoryTagBoxProps { + imageSrc: string; + title: CategoryTags; + clickedCategory: CategoryTags; + handleClickCategory: (value: CategoryTags) => void; +} + +const CategoryTagBox = ({ + imageSrc, + title, + clickedCategory, + handleClickCategory, +}: CategoryTagBoxProps) => { + return ( +
handleClickCategory(title)} + > +
+ {imageSrc} + {title} +
+
+ ); +}; + +export default CategoryTagBox; diff --git a/src/create-schedule/components/CurrentDate.tsx b/src/create-schedule/components/CurrentDate.tsx new file mode 100644 index 00000000..43c499d5 --- /dev/null +++ b/src/create-schedule/components/CurrentDate.tsx @@ -0,0 +1,20 @@ +import { getDay, handleDateFormat } from "@create-schedule/util"; + +interface CurrentDateProps { + currentDate: Date | null; +} + +const CurrentDate = ({ currentDate }: CurrentDateProps) => { + return ( +
+

+ {currentDate && handleDateFormat(currentDate)} +

+

+ {currentDate && getDay(currentDate)} +

+
+ ); +}; + +export default CurrentDate; diff --git a/src/create-schedule/components/CustomItems.tsx b/src/create-schedule/components/CustomItems.tsx new file mode 100644 index 00000000..d82c6df4 --- /dev/null +++ b/src/create-schedule/components/CustomItems.tsx @@ -0,0 +1,32 @@ +import { useRecoilValue } from "recoil"; +import { useModal } from "@shared/hook"; +import { customItem } from "@shared/recoil"; +import CategoryFrame from "./CategoryFrame"; + +const CustomItems = () => { + const { openModal } = useModal(); + const customItems = useRecoilValue(customItem); + + const makeNewItem = () => { + openModal({ + contentId: "customScheduleSelector", + isHeaderCloseBtn: true, + }); + }; + + return ( + <> +
+ {customItems && } +
+ + + ); +}; + +export default CustomItems; diff --git a/src/create-schedule/components/DateCityInput.tsx b/src/create-schedule/components/DateCityInput.tsx index aa6b92c0..23e88b5e 100644 --- a/src/create-schedule/components/DateCityInput.tsx +++ b/src/create-schedule/components/DateCityInput.tsx @@ -1,3 +1,5 @@ +import { handleDateFormat } from "@create-schedule/util"; +import { useMemo } from "react"; import { useRecoilValue } from "recoil"; import { scheduleAnswers } from "@shared/recoil"; import DateCityHandler from "./DateCityHandler"; @@ -15,6 +17,18 @@ const DateCityInput = ({ }: DateCityInputProps) => { const answer = useRecoilValue(scheduleAnswers); + const startDate = useMemo(() => { + if (answer.startedAt) { + return handleDateFormat(answer.startedAt); + } + }, [answer.startedAt]); + + const endDate = useMemo(() => { + if (answer.endedAt) { + return handleDateFormat(answer.endedAt); + } + }, [answer.endedAt]); + return (
@@ -25,7 +39,9 @@ const DateCityInput = ({ placeholder={placeholder} value={ answerType && callType.includes("date") - ? answer[answerType] + ? callType === "date_start" + ? startDate + : endDate : answer.city } /> diff --git a/src/create-schedule/components/DateContainer.tsx b/src/create-schedule/components/DateContainer.tsx new file mode 100644 index 00000000..d07d57fc --- /dev/null +++ b/src/create-schedule/components/DateContainer.tsx @@ -0,0 +1,46 @@ +import { handleCurrentDate } from "@create-schedule/util"; +import { useState } from "react"; +import { useRecoilValue } from "recoil"; +import { scheduleAnswers } from "@shared/recoil"; +import CurrentDate from "./CurrentDate"; +import DateMoveButton from "./DateMoveButton"; +import ScheduleTitle from "./ScheduleTitle"; + +const DateContainer = () => { + const { startedAt, endedAt } = useRecoilValue(scheduleAnswers); + const [currentDate, setCurrentDate] = useState(startedAt); + + const onClick = (type: "next" | "prev") => { + if (type === "next") { + if ( + endedAt?.getMonth() === currentDate?.getMonth() && + endedAt?.getDate() === currentDate?.getDate() + ) + return; + + setCurrentDate((prev) => { + return handleCurrentDate(prev, type); + }); + } else if (type === "prev") { + if ( + startedAt?.getMonth() === currentDate?.getMonth() && + startedAt?.getDate() === currentDate?.getDate() + ) + return; + + setCurrentDate((prev) => { + return handleCurrentDate(prev, type); + }); + } + }; + + return ( + <> + + + + + ); +}; + +export default DateContainer; diff --git a/src/create-schedule/components/DateMoveButton.tsx b/src/create-schedule/components/DateMoveButton.tsx new file mode 100644 index 00000000..8d8c781c --- /dev/null +++ b/src/create-schedule/components/DateMoveButton.tsx @@ -0,0 +1,38 @@ +import Image from "next/image"; + +interface DateMoveButtonProps { + onClick: (type: "next" | "prev") => void; +} + +const DateMoveButton = ({ onClick }: DateMoveButtonProps) => { + return ( +
+
onClick("prev")} + > + arrow +
+
onClick("next")} + > + arrow +
+
+ ); +}; + +export default DateMoveButton; diff --git a/src/create-schedule/components/DragnDropContainer.tsx b/src/create-schedule/components/DragnDropContainer.tsx new file mode 100644 index 00000000..d258df35 --- /dev/null +++ b/src/create-schedule/components/DragnDropContainer.tsx @@ -0,0 +1,30 @@ +import { category } from "@create-schedule/constants"; +import { useState } from "react"; +import { CategoryTags } from "@shared/types"; +import Categories from "./Categories"; +import CategoryItems from "./CategoryItems"; +import MakeCustomItem from "./MakeCustomItem"; + +const DragnDropContainer = () => { + const [clickedCategory, setClickedCategory] = useState("전체"); + // TODO: fetch category + + const handleClickCategory = (value: CategoryTags) => { + setClickedCategory(value); + }; + + return ( + <> +
+ + + +
+ + ); +}; + +export default DragnDropContainer; diff --git a/src/create-schedule/components/MakeCustomItem.tsx b/src/create-schedule/components/MakeCustomItem.tsx new file mode 100644 index 00000000..1ce2e109 --- /dev/null +++ b/src/create-schedule/components/MakeCustomItem.tsx @@ -0,0 +1,13 @@ +import CustomItems from "./CustomItems"; +import ScheduleTitle from "./ScheduleTitle"; + +const MakeCustomItem = () => { + return ( + <> + + + + ); +}; + +export default MakeCustomItem; diff --git a/src/create-schedule/components/MakeScheduleButton.tsx b/src/create-schedule/components/MakeScheduleButton.tsx index 89a069e5..824ff141 100644 --- a/src/create-schedule/components/MakeScheduleButton.tsx +++ b/src/create-schedule/components/MakeScheduleButton.tsx @@ -1,6 +1,7 @@ -import { HTMLAttributes } from "react"; +import { ButtonHTMLAttributes } from "react"; -interface MakeScheduleButtonProps extends HTMLAttributes { +interface MakeScheduleButtonProps + extends ButtonHTMLAttributes { value: string; buttonStyle: string; } diff --git a/src/create-schedule/components/PageContent.tsx b/src/create-schedule/components/PageContent.tsx index 7e6226b6..9fcea2be 100644 --- a/src/create-schedule/components/PageContent.tsx +++ b/src/create-schedule/components/PageContent.tsx @@ -1,9 +1,12 @@ import { SUBTITLE, TITLE } from "@create-schedule/constants"; import { useRecoilValue } from "recoil"; import { currentProgress } from "@shared/recoil"; +import DragnDropContainer from "./DragnDropContainer"; import PlanDefaultInfo from "./PlanDefaultInfo"; import Remains from "./Remains"; +import ScheduleNextButton from "./ScheduleNextButton"; import ScheduleTagTemplate from "./ScheduleTagTemplate"; +import TimeTableContainer from "./TimeTableContainer"; import Title from "./Title"; const PageContent = () => { @@ -35,6 +38,26 @@ const PageContent = () => {
)} + {current === 4 && ( + <> +
+ + </div> + <div className="flex flex-wrap"> + <TimeTableContainer /> + <DragnDropContainer /> + </div> + <div className="w-[628px] mt-10 text-center"> + <ScheduleNextButton + value="다음으로 넘어갈까요?" + callType="template" + /> + </div> + </> + )} </> ); }; diff --git a/src/create-schedule/components/PlanTitleInput.tsx b/src/create-schedule/components/PlanTitleInput.tsx index c1d294d7..1e0fa31a 100644 --- a/src/create-schedule/components/PlanTitleInput.tsx +++ b/src/create-schedule/components/PlanTitleInput.tsx @@ -1,11 +1,11 @@ -import { SCHEDULE_TITLE } from "@create-schedule/constants"; +import { SCHEDULE_TITLE, TITLE_MAX_LENGTH } from "@create-schedule/constants"; import { useRecoilState } from "recoil"; import { scheduleAnswers } from "@shared/recoil"; -import RemainChar from "./RemainChar"; import ScheduleTitle from "./ScheduleTitle"; const PlanTitleInput = () => { const [{ title }, setTitle] = useRecoilState(scheduleAnswers); + const remainChar = TITLE_MAX_LENGTH - title.length; const handleTitle = (title: string) => { setTitle((prev) => ({ ...prev, title })); @@ -18,10 +18,12 @@ const PlanTitleInput = () => { className="w-[628px] h-[55px] border border-[#E0E0E0] rounded-[5px] px-[26px]" type="text" placeholder="일정 제목을 입력해주세요." - maxLength={40} + maxLength={TITLE_MAX_LENGTH} onChange={({ target: { value } }) => handleTitle(value)} /> - <RemainChar title={title} /> + <div className="text-[12px] text-[#8D8D8D] font-medium -tracking-[0.01em]"> + {remainChar < 0 ? 0 : remainChar}자 남음 + </div> </div> ); }; diff --git a/src/create-schedule/components/RemainChar.tsx b/src/create-schedule/components/RemainChar.tsx deleted file mode 100644 index fbb49385..00000000 --- a/src/create-schedule/components/RemainChar.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useState } from "react"; - -interface RemainCharProps { - title: string; -} - -const RemainChar = ({ title }: RemainCharProps) => { - const [remainChar, setRemainChar] = useState(40); - - useEffect(() => { - setRemainChar(() => { - if (40 - title.length < 0) return 0; - return 40 - title.length; - }); - }, [title.length]); - - return ( - <div className="text-[12px] text-[#8D8D8D] font-medium -tracking-[0.01em]"> - {remainChar}자 남음 - </div> - ); -}; - -export default RemainChar; diff --git a/src/create-schedule/components/ScheduleNextButton.tsx b/src/create-schedule/components/ScheduleNextButton.tsx index d512869c..7597898f 100644 --- a/src/create-schedule/components/ScheduleNextButton.tsx +++ b/src/create-schedule/components/ScheduleNextButton.tsx @@ -1,28 +1,41 @@ +import { handleDateFormat } from "@create-schedule/util"; +import { ButtonHTMLAttributes } from "react"; import { useRecoilValue, useSetRecoilState } from "recoil"; import { useModal } from "@shared/hook"; import { currentProgress, scheduleAnswers } from "@shared/recoil"; -interface ScheduleNextButtonProps { +interface ScheduleNextButtonProps + extends ButtonHTMLAttributes<HTMLButtonElement> { value: string; callType: "basicInfo" | "template"; } -const ScheduleNextButton = ({ value, callType }: ScheduleNextButtonProps) => { +const ScheduleNextButton = ({ + value, + callType, + ...props +}: ScheduleNextButtonProps) => { const { openAlert } = useModal(); const answer = useRecoilValue(scheduleAnswers); const setCurrentProgress = useSetRecoilState(currentProgress); const handleInputCheck = () => { - const startDate = parseInt(answer.startedAt.replace(/\./g, "")); - const endDate = parseInt(answer.endedAt.replace(/\./g, "")); + if (answer.startedAt && answer.endedAt) { + const startDate = parseInt( + handleDateFormat(answer.startedAt).replace(/\./g, ""), + ); + const endDate = parseInt( + handleDateFormat(answer.endedAt).replace(/\./g, ""), + ); - if (endDate - startDate < 0) { - openAlert({ - title: "종료일은 시작일보다 전일 수 없어요.", - isHeaderCloseBtn: true, - }); + if (endDate - startDate < 0) { + openAlert({ + title: "종료일은 시작일보다 전일 수 없어요.", + isHeaderCloseBtn: true, + }); - return false; + return false; + } } return true; @@ -39,8 +52,13 @@ const ScheduleNextButton = ({ value, callType }: ScheduleNextButtonProps) => { return ( <> <button - className="w-[423px] h-[48px] text-[14px] text-white font-bold -tracking-[0.03em] bg-[#F864A1] rounded-[5px] mb-[9px]" + className={`${ + props.disabled + ? "text-[#B1B1B1] bg-[#E9ECEF] " + : "text-white bg-[#F864A1] " + }w-[423px] h-[48px] text-[14px] font-bold -tracking-[0.03em] rounded-[5px] mb-[9px]`} onClick={handleCurrentProgress} + {...props} > {value} </button> diff --git a/src/create-schedule/components/ScheduleRegisterButton.tsx b/src/create-schedule/components/ScheduleRegisterButton.tsx new file mode 100644 index 00000000..bcd5d327 --- /dev/null +++ b/src/create-schedule/components/ScheduleRegisterButton.tsx @@ -0,0 +1,82 @@ +import { + checkDuplication, + handleTimeFormat, + sortByStartTime, +} from "@create-schedule/util"; +import { useRecoilState, useSetRecoilState } from "recoil"; +import { useModal } from "@shared/hook"; +import { appliedScheduleItem, selectedScheduleItem } from "@shared/recoil"; +import { SelectedCategoryItem, Start2EndTime } from "@shared/types"; +import ShowDuration from "./ShowDuration"; + +interface ScheduleRegisterButtonProps { + time: Start2EndTime; + selectedItem: SelectedCategoryItem | null; +} + +const ScheduleRegisterButton = ({ + time, + selectedItem, +}: ScheduleRegisterButtonProps) => { + const { closeModal } = useModal(); + const setSelectedItem = useSetRecoilState(selectedScheduleItem); + const [appliedItem, setAppliedItem] = useRecoilState(appliedScheduleItem); + + const handleDone = () => { + const { startHour, startMin, endHour, endMin } = handleTimeFormat(time); + // 시작 시간이 종료 시간보다 늦는 경우, 중복된 시간이 있는 경우 버튼 클릭 이벤트 x + if (startHour * 60 + startMin >= endHour * 60 + endMin) return; + if ( + checkDuplication(startHour, startMin, endHour, endMin, appliedItem).some( + (boolean) => boolean === true, + ) + ) + return; + + setAppliedItem((prev) => { + if (prev && selectedItem) { + const newArray = [...prev]; + newArray.push({ + ...selectedItem, + startTime: { hour: startHour, minute: startMin }, + endTime: { hour: endHour, minute: endMin }, + }); + sortByStartTime(newArray); + + return newArray; + } else if (!prev && selectedItem) { + return [ + { + ...selectedItem, + startTime: { hour: startHour, minute: startMin }, + endTime: { hour: endHour, minute: endMin }, + }, + ]; + } + + return null; + }); + setSelectedItem(null); + closeModal(); + }; + + return ( + <> + <button + className="w-[120px] h-[48px] text-[14px] text-[#B1B1B1] font-bold -tracking-[0.03em] bg-[#E9ECEF] rounded-[5px]" + onClick={closeModal} + > + 취소 + </button> + <button + className="w-[120px] h-[48px] text-white -tracking-[0.03em] bg-[#F864A1] rounded-[5px]" + onClick={() => handleDone()} + > + <div className="text-[14px] font-bold">일정등록</div> + <ShowDuration time={time} /> + </button> + </> + ); +}; + +export default ScheduleRegisterButton; diff --git a/src/create-schedule/components/ScheduleTagTemplate.tsx b/src/create-schedule/components/ScheduleTagTemplate.tsx index f0847de7..211644c7 100644 --- a/src/create-schedule/components/ScheduleTagTemplate.tsx +++ b/src/create-schedule/components/ScheduleTagTemplate.tsx @@ -1,12 +1,21 @@ import { SCHEDULE_TITLE } from "@create-schedule/constants"; +import { useState } from "react"; import ScheduleNextButton from "./ScheduleNextButton"; import ScheduleTagInput from "./ScheduleTagInput"; import ScheduleTitle from "./ScheduleTitle"; import TagRecommend from "./TagRecommend"; -import TemplateButton from "./TemplateButton"; import TemplateRecommend from "./TemplateRecommend"; const ScheduleTagTemplate = () => { + const [isClicked, setIsClicked] = useState({ + template: false, + custom: false, + }); + + const onClick = (key: "template" | "custom") => { + setIsClicked((prev) => ({ ...prev, [key]: !prev[key] })); + }; + return ( <> <ScheduleTitle title={SCHEDULE_TITLE.tag} /> @@ -14,9 +23,13 @@ const ScheduleTagTemplate = () => { <div className="mb-[38px]"> <TagRecommend /> </div> - <TemplateRecommend /> + <TemplateRecommend isClicked={isClicked} onClick={onClick} /> <div className="w-full text-center mt-[60px]"> - <ScheduleNextButton value="다음으로 넘어갈까요?" callType="template" /> + <ScheduleNextButton + disabled={!isClicked.custom} + value="다음으로 넘어갈까요?" + callType="template" + /> </div> </> ); diff --git a/src/create-schedule/components/ScheduleTimeSelect.tsx b/src/create-schedule/components/ScheduleTimeSelect.tsx new file mode 100644 index 00000000..ddb8d0b0 --- /dev/null +++ b/src/create-schedule/components/ScheduleTimeSelect.tsx @@ -0,0 +1,69 @@ +import { SelectedCategoryItem } from "@shared/types"; + +interface ScheduleTimeSelectProps { + selectedItem: SelectedCategoryItem | null; + handleTime: ( + key: "start" | "end", + time: "hour" | "minute", + value: number, + ) => void; +} + +const ScheduleTimeSelect = ({ + selectedItem, + handleTime, +}: ScheduleTimeSelectProps) => { + const hourArray = Array.from({ length: 25 }, (_, index) => index); + const minuteArray = Array.from({ length: 4 }, (_, index) => index * 15); + + return ( + <> + <select + defaultValue={`${selectedItem?.selectedTime}시`} + className="h-[29px] text-[12px] text-[#727578] -tracking-[0.01em] border border-[#E0E0E0] rounded-[5px] px-[9px] outline-0 mr-[4px]" + onChange={({ target: { value } }) => + handleTime("start", "hour", parseInt(value, 10)) + } + > + {hourArray.map((hour) => ( + <option key={hour}>{hour}시</option> + ))} + </select> + <select + className="h-[29px] text-[12px] text-[#727578] -tracking-[0.01em] border border-[#E0E0E0] rounded-[5px] px-[9px] outline-0 mr-[4px]" + onChange={({ target: { value } }) => + handleTime("start", "minute", parseInt(value, 10)) + } + > + {minuteArray.map((minute) => ( + <option key={minute}>{minute}분</option> + ))} + </select> + <span className="w-[8px] border border-[#333333] mr-[4px]"></span> + <select + defaultValue={`${selectedItem?.selectedTime}시`} + className="h-[29px] text-[12px] text-[#727578] -tracking-[0.01em] border border-[#E0E0E0] rounded-[5px] px-[9px] outline-0 mr-[4px]" + onChange={({ target: { value } }) => + handleTime("end", "hour", parseInt(value, 10)) + } + > + {hourArray.map((hour) => ( + <option key={hour}>{hour}시</option> + ))} + </select> + <select + defaultValue={"15분"} + className="h-[29px] text-[12px] text-[#727578] -tracking-[0.01em] border border-[#E0E0E0] rounded-[5px] px-[9px] outline-0" + onChange={({ target: { value } }) => + handleTime("end", "minute", parseInt(value, 10)) + } + > + {minuteArray.map((minute) => ( + <option key={minute}>{minute}분</option> + ))} + </select> + </> + ); +}; + +export default ScheduleTimeSelect; diff --git a/src/create-schedule/components/ScheduleTitle.tsx b/src/create-schedule/components/ScheduleTitle.tsx index b4280987..d583de6e 100644 --- a/src/create-schedule/components/ScheduleTitle.tsx +++ b/src/create-schedule/components/ScheduleTitle.tsx @@ -15,13 +15,13 @@ const ScheduleTitle = ({ <> <div className={`${ - hasSubTitle ? "mb-[9px]" : "mb-[12px]" + hasSubTitle ? "mb-[9px]" : "mb-3" } ${textSize} w-full text-[#333333] font-medium -tracking-[0.5px]`} > {title} </div> {hasSubTitle && ( - <div className="text-[12px] text-[#8D8D8D] font-medium -tracking-[0.01em] mb-[12px]"> + <div className="text-[12px] text-[#8D8D8D] font-medium -tracking-[0.01em] mb-3"> {subTitle} </div> )} diff --git a/src/create-schedule/components/ShowDuration.tsx b/src/create-schedule/components/ShowDuration.tsx new file mode 100644 index 00000000..5b8d4162 --- /dev/null +++ b/src/create-schedule/components/ShowDuration.tsx @@ -0,0 +1,21 @@ +import { handleDuration } from "@create-schedule/util"; +import { useEffect, useState } from "react"; +import { Start2EndTime } from "@shared/types"; + +interface ShowDurationProps { + time: Start2EndTime; +} + +const ShowDuration = ({ time }: ShowDurationProps) => { + const [duration, setDuration] = useState(0); + + useEffect(() => { + handleDuration({ time, setDuration }); + }, [time]); + + return ( + <div className="text-[8px] font-medium -tracking-[0.03em]">{`(${duration}시간)`}</div> + ); +}; + +export default ShowDuration; diff --git a/src/create-schedule/components/ShowSelectedDate.tsx b/src/create-schedule/components/ShowSelectedDate.tsx new file mode 100644 index 00000000..2830b317 --- /dev/null +++ b/src/create-schedule/components/ShowSelectedDate.tsx @@ -0,0 +1,13 @@ +const ShowSelectedDate = () => { + // TODO: Date 객체 사용 + return ( + <div className="flex items-center"> + <div className="min-w-[80px] text-[#333333]">일정</div> + <div className="w-full h-[29px] text-[#464646] font-medium leading-[29px] -tracking-[0.05em] bg-[#ECECEC] rounded-[5px] px-[13px]"> + 2023.10.30 + </div> + </div> + ); +}; + +export default ShowSelectedDate; diff --git a/src/create-schedule/components/ShowSelectedItem.tsx b/src/create-schedule/components/ShowSelectedItem.tsx new file mode 100644 index 00000000..0494b8f0 --- /dev/null +++ b/src/create-schedule/components/ShowSelectedItem.tsx @@ -0,0 +1,16 @@ +import { useRecoilValue } from "recoil"; +import { selectedScheduleItem } from "@shared/recoil"; +import CategoryFrame from "./CategoryFrame"; + +const ShowSelectedItem = () => { + const selectedItem = useRecoilValue(selectedScheduleItem); + + return ( + <div className="flex items-center my-[23px]"> + <div className="min-w-[80px] text-[#333333]">선택 아이템</div> + <div>{selectedItem && <CategoryFrame category={[selectedItem]} />}</div> + </div> + ); +}; + +export default ShowSelectedItem; diff --git a/src/create-schedule/components/Template.tsx b/src/create-schedule/components/Template.tsx index 54bed351..e6687ee5 100644 --- a/src/create-schedule/components/Template.tsx +++ b/src/create-schedule/components/Template.tsx @@ -1,8 +1,23 @@ import { templateContent } from "@create-schedule/constants"; import { ScheduleCard } from "@shared/components"; -const Template = () => { - return <ScheduleCard content={templateContent} callType="template" />; +interface TemplateProps { + isClicked: { + template: boolean; + custom: boolean; + }; + onClick: (key: "template" | "custom") => void; +} + +const Template = ({ isClicked, onClick }: TemplateProps) => { + return ( + <ScheduleCard + content={templateContent} + callType="template" + isClicked={isClicked} + onClick={onClick} + /> + ); }; export default Template; diff --git a/src/create-schedule/components/TemplateButton.tsx b/src/create-schedule/components/TemplateButton.tsx index 9b593c7c..a261fc06 100644 --- a/src/create-schedule/components/TemplateButton.tsx +++ b/src/create-schedule/components/TemplateButton.tsx @@ -4,15 +4,19 @@ import MakeScheduleButton from "./MakeScheduleButton"; interface TemplateButtonProps { clickedContent: number | null; + template: boolean; + custom: boolean; + onClick: (key: "template" | "custom") => void; } -const TemplateButton = ({ clickedContent }: TemplateButtonProps) => { +const TemplateButton = ({ + template, + custom, + clickedContent, + onClick, +}: TemplateButtonProps) => { const setCurrentProgress = useSetRecoilState(currentProgress); - const handleCurrentProgress = () => { - setCurrentProgress((prev) => prev + 1); - }; - const handleSelectTemplate = () => { if (clickedContent) { setCurrentProgress((prev) => prev + 1); @@ -22,14 +26,23 @@ const TemplateButton = ({ clickedContent }: TemplateButtonProps) => { return ( <div className="w-full text-center mt-[16px]"> <MakeScheduleButton + disabled={!template} value="선택한 템플릿 바로가기" - buttonStyle="text-[#00D179] border-[#00D179] mr-[9px]" + buttonStyle={`${ + !template + ? "text-[#666666] border-[#E0E0E0] cursor-not-allowed" + : "text-[#00D179] border-[#00D179] cursor-pointer" + } mr-[9px]`} onClick={handleSelectTemplate} /> <MakeScheduleButton value="직접 만들기" - buttonStyle="text-[#666666] border-[#E0E0E0] hover:bg-[#E7F9EE] hover:text-[#00D179]" - onClick={handleCurrentProgress} + buttonStyle={`${ + !custom + ? "text-[#666666] border-[#E0E0E0]" + : "text-[#00D179] bg-[#E7F9EE]" + }`} + onClick={() => onClick("custom")} /> </div> ); diff --git a/src/create-schedule/components/TemplateRecommend.tsx b/src/create-schedule/components/TemplateRecommend.tsx index f11c1d45..fc373d71 100644 --- a/src/create-schedule/components/TemplateRecommend.tsx +++ b/src/create-schedule/components/TemplateRecommend.tsx @@ -2,14 +2,22 @@ import { SCHEDULE_TITLE } from "@create-schedule/constants"; import ScheduleTitle from "./ScheduleTitle"; import Template from "./Template"; -const TemplateRecommend = () => { +interface TemplateRecommendProps { + isClicked: { + template: boolean; + custom: boolean; + }; + onClick: (key: "template" | "custom") => void; +} + +const TemplateRecommend = ({ isClicked, onClick }: TemplateRecommendProps) => { return ( <> <ScheduleTitle title={SCHEDULE_TITLE.templateRecommend("명란마요")} textSize="text-[16px]" /> - <Template /> + <Template isClicked={isClicked} onClick={onClick} /> </> ); }; diff --git a/src/create-schedule/components/TimeDetailSelector.tsx b/src/create-schedule/components/TimeDetailSelector.tsx new file mode 100644 index 00000000..17374efa --- /dev/null +++ b/src/create-schedule/components/TimeDetailSelector.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { useRecoilValue } from "recoil"; +import { selectedScheduleItem } from "@shared/recoil"; +import ScheduleRegisterButton from "./ScheduleRegisterButton"; +import ScheduleTimeSelect from "./ScheduleTimeSelect"; + +const TimeDetailSelector = () => { + const selectedItem = useRecoilValue(selectedScheduleItem); + const [time, setTime] = useState({ + start: { hour: `${selectedItem?.selectedTime}시`, minute: "0분" }, + end: { hour: `${selectedItem?.selectedTime}시`, minute: "15분" }, + }); + + const handleTime = ( + key: "start" | "end", + time: "hour" | "minute", + value: number, + ) => { + setTime((prev) => ({ ...prev, [key]: { ...prev[key], [time]: value } })); + }; + + return ( + <> + <div className="flex items-center mt-[23px]"> + <div className="min-w-[80px] text-[#333333]">시간 설정</div> + <ScheduleTimeSelect + selectedItem={selectedItem} + handleTime={handleTime} + /> + </div> + <div className="text-[12px] text-[#8D8D8D] font-medium -tracking-[0.01em] ml-[80px] mt-[2px] mb-[15px]"> + 15분 단위로 선택이 가능합니다. + </div> + <div className="mb-[47px]"> + <div className="min-w-[80px] text-[#333333] mb-[5px]"> + 세부내용(선택) + </div> + <div className="h-[78px] border border-[#ADADAD] rounded-[9px] px-[16px] py-[10px] focus-within:border-[#4285F4]"> + <textarea className="w-full resize-none outline-0" /> + </div> + </div> + <div className="flex gap-[10px] justify-center"> + <ScheduleRegisterButton time={time} selectedItem={selectedItem} /> + </div> + </> + ); +}; + +export default TimeDetailSelector; diff --git a/src/create-schedule/components/TimeModal.tsx b/src/create-schedule/components/TimeModal.tsx new file mode 100644 index 00000000..debe8469 --- /dev/null +++ b/src/create-schedule/components/TimeModal.tsx @@ -0,0 +1,17 @@ +import ScheduleTitle from "./ScheduleTitle"; +import ShowSelectedDate from "./ShowSelectedDate"; +import ShowSelectedItem from "./ShowSelectedItem"; +import TimeDetailSelector from "./TimeDetailSelector"; + +const TimeModal = () => { + return ( + <> + <ScheduleTitle title="일정 등록" /> + <ShowSelectedItem /> + <ShowSelectedDate /> + <TimeDetailSelector /> + </> + ); +}; + +export default TimeModal; diff --git a/src/create-schedule/components/TimeTable.tsx b/src/create-schedule/components/TimeTable.tsx new file mode 100644 index 00000000..8947a421 --- /dev/null +++ b/src/create-schedule/components/TimeTable.tsx @@ -0,0 +1,116 @@ +import { PIXEL } from "@create-schedule/constants"; +import { + calculateItemHeight, + calculateTableHeight, + calculateTop, +} from "@create-schedule/util"; +import { DragEvent, useEffect, useState } from "react"; +import { useRecoilState, useRecoilValue } from "recoil"; +import { useModal } from "@shared/hook"; +import { appliedScheduleItem, selectedScheduleItem } from "@shared/recoil"; +import { AppliedItem, CategoryItem } from "@shared/types"; +import CategoryFrame from "./CategoryFrame"; + +export interface TableArrayType { + time: number; + cellHeight: number; +} + +interface TimeTableProps { + callType: "template" | "custom"; + initialData?: AppliedItem[]; +} + +const TimeTable = ({ callType, initialData }: TimeTableProps) => { + const [tableArray, setTableArray] = useState<TableArrayType[]>( + Array.from({ length: 25 }, (_, index) => ({ + time: index, + cellHeight: PIXEL.cellHeight, + })), + ); + + const { openModal } = useModal(); + const appliedItem = useRecoilValue(appliedScheduleItem); + const [selectedItem, setSelectedItem] = useRecoilState(selectedScheduleItem); + + const onDrop = (e: DragEvent<HTMLTableRowElement>, index: number) => { + setSelectedItem((prev) => ({ + ...(prev as CategoryItem), + selectedTime: index, + })); + if ( + selectedItem?.title && + selectedItem.category && + selectedItem.tagBackground + ) { + openModal({ contentId: "scheduleTimeSelector", isHeaderCloseBtn: true }); + } + e.preventDefault(); + }; + + const renderItems = ( + items: AppliedItem[], + cellHeight: number, + index: number, + ) => { + return items.map((item, _index) => { + const { type, itemHeight } = calculateItemHeight( + item.startTime, + item.endTime, + ); + const top = calculateTop(cellHeight); + + if (item.startTime.hour === index) + return ( + <div + key={item.category + item.tagBackground + item.startTime} + style={type === "sameCell" ? {} : { top: top }} + className={`${ + item.startTime.hour === item.endTime.hour || + (item.endTime.minute === 0 && + item.endTime.hour - item.startTime.hour === 1) + ? "relative" + : "absolute left-[80px]" + }`} + > + <CategoryFrame category={[item]} itemHeight={itemHeight} /> + </div> + ); + }); + }; + + useEffect(() => { + calculateTableHeight({ appliedItem, tableArray, setTableArray }); + }, [appliedItem]); + + return ( + <table className="w-full"> + <tbody> + {tableArray.map(({ time, cellHeight }, index) => ( + <tr + key={time} + className={`${ + time !== 24 ? "border-b-[0.5px] border-b-[#ACBEFF] " : "" + }min-h-[35px]`} + onDrop={(e) => onDrop(e, index)} + onDragOver={(e) => e.preventDefault()} + > + <td + style={{ height: cellHeight }} + className="relative list-item list-none text-[#ACBEFF] font-semibold leading-[22px] pl-[15px] pr-[30px] py-1.5" + > + <div className="inline-block w-[56px]">{time}:00</div> + <div className="flex flex-col gap-[1px] float-right w-[250px]"> + {callType === "template" + ? initialData && renderItems(initialData, cellHeight, index) + : appliedItem && renderItems(appliedItem, cellHeight, index)} + </div> + </td> + </tr> + ))} + </tbody> + </table> + ); +}; + +export default TimeTable; diff --git a/src/create-schedule/components/TimeTableContainer.tsx b/src/create-schedule/components/TimeTableContainer.tsx new file mode 100644 index 00000000..be6a7d5e --- /dev/null +++ b/src/create-schedule/components/TimeTableContainer.tsx @@ -0,0 +1,19 @@ +import DateContainer from "./DateContainer"; +import TimeTable from "./TimeTable"; + +interface TimeTableContainerProps {} + +const TimeTableContainer = ({}: TimeTableContainerProps) => { + return ( + <> + <div className="min-w-[362px] max-w-[362px]"> + <DateContainer /> + <div className="w-full border border-[#ACBEFF] rounded-[5px] bg-[#FFF9FC]"> + <TimeTable callType="custom" /> + </div> + </div> + </> + ); +}; + +export default TimeTableContainer; diff --git a/src/create-schedule/components/index.ts b/src/create-schedule/components/index.ts index 34b8e9f8..2dad7c63 100644 --- a/src/create-schedule/components/index.ts +++ b/src/create-schedule/components/index.ts @@ -1,12 +1,21 @@ export { default as BasicInfo } from "./BasicInfo"; export { default as BlockShowing } from "./BlockShowing"; +export { default as Categories } from "./Categories"; +export { default as CategoryFrame } from "./CategoryFrame"; +export { default as CategoryItems } from "./CategoryItems"; +export { default as CategoryTagBox } from "./CategoryTagBox"; export { default as Continue } from "./Continue"; +export { default as CurrentDate } from "./CurrentDate"; export { default as CurrentPage } from "./CurrentPage"; export { default as CurrentRecommendTag } from "./CurrentRecommendTag"; +export { default as CustomItems } from "./CustomItems"; export { default as DateCityHandler } from "./DateCityHandler"; export { default as DateCityInput } from "./DateCityInput"; +export { default as DateMoveButton } from "./DateMoveButton"; +export { default as DragnDropContainer } from "./DragnDropContainer"; export { default as FillPlan } from "./FillPlan"; export { default as FinishWriting } from "./FinishWriting"; +export { default as MakeCustomItem } from "./MakeCustomItem"; export { default as MakeScheduleButton } from "./MakeScheduleButton"; export { default as MenuContent } from "./MenuContent"; export { default as MenuContentContainer } from "./MenuContentContainer"; @@ -16,22 +25,31 @@ export { default as PlanDefaultInfo } from "./PlanDefaultInfo"; export { default as PlanMainImage } from "./PlanMainImage"; export { default as PlanSideBar } from "./PlanSideBar"; export { default as PlanTitleInput } from "./PlanTitleInput"; -export { default as RemainChar } from "./RemainChar"; export { default as RemainContent } from "./RemainContent"; export { default as Remains } from "./Remains"; export { default as ScheduleNextButton } from "./ScheduleNextButton"; +export { default as ScheduleRegisterButton } from "./ScheduleRegisterButton"; export { default as ScheduleTagInput } from "./ScheduleTagInput"; export { default as ScheduleTagTemplate } from "./ScheduleTagTemplate"; +export { default as ScheduleTimeSelect } from "./ScheduleTimeSelect"; export { default as ScheduleTitle } from "./ScheduleTitle"; export { default as SelectCity } from "./SelectCity"; export { default as SelectDate } from "./SelectDate"; export { default as SelectMainImage } from "./SelectMainImage"; export { default as SelectMainImageBox } from "./SelectMainImageBox"; +export { default as ShowDuration } from "./ShowDuration"; +export { default as ShowSelectedDate } from "./ShowSelectedDate"; +export { default as ShowSelectedItem } from "./ShowSelectedItem"; export { default as SideBarIntro } from "./SideBarIntro"; export { default as SideBarMenuBox } from "./SideBarMenuBox"; +export { default as TagInput } from "./TagInput"; export { default as TagNTemplate } from "./TagNTemplate"; export { default as TagRecommend } from "./TagRecommend"; export { default as Template } from "./Template"; export { default as TemplateButton } from "./TemplateButton"; export { default as TemplateRecommend } from "./TemplateRecommend"; +export { default as TimeDetailSelector } from "./TimeDetailSelector"; +export { default as TimeModal } from "./TimeModal"; +export { default as TimeTable } from "./TimeTable"; +export { default as TimeTableContainer } from "./TimeTableContainer"; export { default as Title } from "./Title"; diff --git a/src/create-schedule/constants/index.ts b/src/create-schedule/constants/index.ts index b9d133e1..de041a3c 100644 --- a/src/create-schedule/constants/index.ts +++ b/src/create-schedule/constants/index.ts @@ -1,4 +1,9 @@ -import { PlanSubTitle, PlanTitle } from "@shared/types"; +import { + CategoryItem, + CategoryTags, + PlanSubTitle, + PlanTitle, +} from "@shared/types"; export const TITLE: PlanTitle = { remains: "잠깐, 작성중이던 일정이 있어요", @@ -24,6 +29,7 @@ export const SCHEDULE_TITLE = { tagRecommend: "이런 태그는 어떤가요?", templateRecommend: (nickname: string) => `${nickname} 님을 위한 추천 일정 템플릿이 있어요!`, + categoryItems: "카테고리별 아이템", }; export const SCHEDULE_SUBTITLE = { @@ -33,6 +39,93 @@ export const SCHEDULE_SUBTITLE = { city: "일정을 수행할 위치를 선택해주세요.", }; +export const PIXEL = { + itemHeight: 21, + cellHeight: 35, + padding: 6, + gap: 1, +}; + +export const TIME = { + hour: 60, +}; + +export const CATEGORY_TAGS: { imageSrc: string; title: CategoryTags }[] = [ + { imageSrc: "/images/samples/category_all.svg", title: "전체" }, + { imageSrc: "/images/samples/category_movie.svg", title: "영화" }, + { imageSrc: "/images/samples/category_festival.svg", title: "축제" }, + { imageSrc: "/images/samples/category_camping.svg", title: "캠핑" }, + { imageSrc: "/images/samples/category_tour.svg", title: "관광" }, + { imageSrc: "/images/samples/category_shopping.svg", title: "쇼핑" }, + { imageSrc: "/images/samples/category_food.svg", title: "음식점" }, + { imageSrc: "/images/samples/category_culture.svg", title: "문화생활" }, + { imageSrc: "/images/samples/category_hiking.svg", title: "등산" }, + { imageSrc: "/images/samples/category_etc.svg", title: "기타" }, +]; + +export const category: CategoryItem[] = [ + { + category: "쇼핑", + title: "용산 아이파크몰", + city: "용산", + tagBackground: "bg-[#A3FAF2]", + }, + { + category: "쇼핑", + title: "용산 아이파크몰", + city: "용산", + tagBackground: "bg-[#FFE779]", + }, + { + category: "쇼핑", + title: "용산 아이파크몰", + city: "용산", + tagBackground: "bg-[#FFC395]", + }, + { + category: "쇼핑", + title: "용산 아이파크몰", + city: "용산", + tagBackground: "bg-[#CFE1FF]", + }, + { + category: "문화생활", + title: "마블 영화 감상", + city: "연남동", + tagBackground: "bg-[#A3FAF2]", + }, + { + category: "문화생활", + title: "도서관 가서 신간 읽기", + city: "연남동", + tagBackground: "bg-[#FFE779]", + }, + { + category: "문화생활", + title: "국립미술관 가서 전시 관람", + city: "연남동", + tagBackground: "bg-[#FFC395]", + }, + { + category: "문화생활", + title: "독서모임 참가하기", + city: "연남동", + tagBackground: "bg-[#CFE1FF]", + }, + { + category: "문화생활", + title: "예술의 전당에서 뮤지컬 관람", + city: "연남동", + tagBackground: "bg-[#DDD1FF]", + }, + { + category: "문화생활", + title: "오일파스텔 그림그리기", + city: "연남동", + tagBackground: "bg-[#FFDCDC]", + }, +]; + export const templateContent = [ { id: 1, @@ -63,3 +156,25 @@ export const templateContent = [ requiredTime: "2~3일 일정", }, ]; + +export const itemColor = [ + "bg-[#A3FAF2]", + "bg-[#FFE779]", + "bg-[#FFC395]", + "bg-[#CFE1FF]", + "bg-[#DDD1FF]", + "bg-[#FFDCDC]", + "bg-[#9BF2CE]", + "bg-[#FFB8B4]", + "bg-[#95CCFF]", + "bg-[#EEB785]", + "bg-[#FFD1EA]", + "bg-[#D8D8D8]", + "bg-[#D8B9F8]", + "bg-[#FF9292]", + "bg-[#A1A1A1]", +]; + +export const TITLE_MAX_LENGTH = 40; + +export const WEEK = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; diff --git a/src/create-schedule/util/index.ts b/src/create-schedule/util/index.ts new file mode 100644 index 00000000..3fc44e70 --- /dev/null +++ b/src/create-schedule/util/index.ts @@ -0,0 +1,228 @@ +import { TableArrayType } from "@create-schedule/components/TimeTable"; +import { PIXEL, TIME, WEEK } from "@create-schedule/constants"; +import { Dispatch, SetStateAction } from "react"; +import { AppliedItem, CategoryTags, Start2EndTime } from "@shared/types"; + +export const calculateItemHeight = ( + start: { hour: number; minute: number }, + end: { hour: number; minute: number }, +) => { + const { hour: startHour, minute: startMin } = start; + const { hour: endHour, minute: endMin } = end; + const hourGap = endHour - startHour; + + if (endMin < startMin) { + if (hourGap === 1 && endMin === 0) { + return { type: "sameCell", itemHeight: PIXEL.itemHeight }; + } else if (hourGap === 1 && endMin !== 0) { + return { + type: "otherCell", + itemHeight: + PIXEL.itemHeight + + ((TIME.hour + endMin - startMin) / TIME.hour) * PIXEL.cellHeight - + PIXEL.padding * 2, + }; + } else if (hourGap > 1 && endMin === 0) { + return { + type: "otherCell", + itemHeight: + (hourGap - 1) * PIXEL.cellHeight + PIXEL.itemHeight + PIXEL.gap * 2, + }; + } + return { + type: "ohterCell", + itemHeight: + PIXEL.cellHeight * (hourGap - 1) + + PIXEL.itemHeight + + PIXEL.padding * 2 + + PIXEL.gap * (hourGap + 1), + }; + } + if (endHour === startHour || (hourGap === 1 && endMin === 0)) { + return { type: "sameCell", itemHeight: PIXEL.itemHeight }; + } else if (hourGap >= 1 && endMin !== 0) { + return { + type: "otherCell", + itemHeight: + PIXEL.cellHeight * hourGap + + PIXEL.itemHeight * 0.25 - + PIXEL.padding + + PIXEL.gap * hourGap, + }; + } + + return { + type: "otherCell", + itemHeight: PIXEL.cellHeight * hourGap - PIXEL.padding * 2, + }; +}; + +export const calculateTop = (cellHeight: number) => { + if (cellHeight === 35) { + return PIXEL.padding; + } else if (cellHeight > 35 && cellHeight <= 57) { + return PIXEL.itemHeight + PIXEL.gap + PIXEL.padding; + } else if (cellHeight > 57 && cellHeight <= 79) { + return (PIXEL.itemHeight + PIXEL.gap) * 2 + PIXEL.padding; + } + return (PIXEL.itemHeight + PIXEL.gap) * 3 + PIXEL.padding; +}; + +interface CalculateTableHeightProps { + appliedItem: AppliedItem[] | null; + tableArray: TableArrayType[]; + setTableArray: Dispatch<SetStateAction<TableArrayType[]>>; +} + +export const calculateTableHeight = ({ + appliedItem, + tableArray, + setTableArray, +}: CalculateTableHeightProps) => { + if (appliedItem) { + const newTableArray = tableArray.map(({ time, cellHeight }, index) => { + const startTableItems = appliedItem.filter( + (item) => item.startTime.hour === time, + ).length; + + return { + time, + cellHeight: + startTableItems > 0 + ? PIXEL.cellHeight + + (startTableItems - 1) * PIXEL.itemHeight + + PIXEL.gap * (startTableItems - 1) + : cellHeight, + }; + }); + setTableArray(newTableArray); + } +}; + +export const handleTimeFormat = (time: Start2EndTime) => { + const startHour = parseInt(time.start.hour, 10); + const startMin = parseInt(time.start.minute, 10); + const endHour = parseInt(time.end.hour, 10); + const endMin = parseInt(time.end.minute, 10); + + return { startHour, startMin, endHour, endMin }; +}; + +interface HandleDurationProps { + time: Start2EndTime; + setDuration: Dispatch<SetStateAction<number>>; +} + +export const handleDuration = ({ time, setDuration }: HandleDurationProps) => { + const { startHour, startMin, endHour, endMin } = handleTimeFormat(time); + if (endMin < startMin) { + const hour = endHour - startHour - 1; + const min = 60 + endMin - startMin; + setDuration(hour + min / 60); + + return; + } + + const hour = endHour - startHour; + const min = endMin - startMin; + setDuration(hour + min / 60); +}; + +export const checkDuplication = ( + startHour: number, + startMin: number, + endHour: number, + endMin: number, + appliedItem: AppliedItem[] | null, +) => { + // 이미 존재하는 테이블 아이템에 대해 검사 + if (appliedItem) { + const isDuplicated = appliedItem.map((item) => { + const startRange = item.startTime.hour * 60 + item.startTime.minute; + const endRange = item.endTime.hour * 60 + item.endTime.minute; + const newItemStartRange = startHour * 60 + startMin; + const newItemEndRange = endHour * 60 + endMin; + + if (startRange <= newItemStartRange && endRange > newItemStartRange) { + return true; + } else if (newItemEndRange > startRange && newItemEndRange <= endRange) { + return true; + } else if (startRange > newItemStartRange && endRange < newItemEndRange) { + return true; + } + return false; + }); + + return isDuplicated; + } + // 테이블이 비었을 경우 + return [false]; +}; + +export const sortByStartTime = (appliedItem: AppliedItem[]) => { + appliedItem.sort((a, b) => { + if (a.startTime.hour !== b.startTime.hour) { + return a.startTime.hour - b.startTime.hour; + } + return a.startTime.minute - b.startTime.minute; + }); +}; + +export const handleImageSrc = (category: CategoryTags) => { + switch (category) { + case "영화": + return "/images/samples/category_movie.svg"; + case "축제": + return "/images/samples/category_festival.svg"; + case "캠핑": + return "/images/samples/category_camping.svg"; + case "관광": + return "/images/samples/category_tour.svg"; + case "쇼핑": + return "/images/samples/category_shopping.svg"; + case "음식점": + return "/images/samples/category_food.svg"; + case "문화생활": + return "/images/samples/category_culture.svg"; + case "등산": + return "/images/samples/category_hiking.svg"; + case "기타": + return "/images/samples/category_etc.svg"; + } +}; + +export const handleDateFormat = (value: Date) => { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + + return `${year}.${month}.${day}`; +}; + +export const handleCurrentDate = (prev: Date | null, type: "next" | "prev") => { + if (prev && type === "next") { + const newDate = new Date( + prev.getFullYear(), + prev.getMonth(), + prev.getDate() + 1, + ); + + return newDate; + } else if (prev && type === "prev") { + const newDate = new Date( + prev.getFullYear(), + prev.getMonth(), + prev.getDate() - 1, + ); + + return newDate; + } + + return null; +}; + +export const getDay = (value: Date) => { + const currentDayIndex = value.getDay(); + + return WEEK[currentDayIndex]; +}; diff --git a/src/modalContent/CalendarSelector.tsx b/src/modalContent/CalendarSelector.tsx index c9153417..8fd5807a 100644 --- a/src/modalContent/CalendarSelector.tsx +++ b/src/modalContent/CalendarSelector.tsx @@ -13,10 +13,7 @@ const CalendarSelector = ({ type }: CalendarSelectorProps) => { const setAnswer = useSetRecoilState(scheduleAnswers); const handleDate = (date: Date) => { - const year = date.getFullYear(); - const month = date.getMonth() + 1; - const day = String(date.getDate()).padStart(2, "0"); - setAnswer((prev) => ({ ...prev, [type]: `${year}.${month}.${day}` })); + setAnswer((prev) => ({ ...prev, [type]: date })); closeModal(); }; diff --git a/src/modalContent/CustomScheduleSelector.tsx b/src/modalContent/CustomScheduleSelector.tsx new file mode 100644 index 00000000..fe8e4f49 --- /dev/null +++ b/src/modalContent/CustomScheduleSelector.tsx @@ -0,0 +1,155 @@ +import { ScheduleTitle } from "@create-schedule/components"; +import { itemColor } from "@create-schedule/constants"; +import Image from "next/image"; +import { useState } from "react"; +import { useSetRecoilState } from "recoil"; +import { useModal } from "@shared/hook"; +import { customItem } from "@shared/recoil"; +import { CategoryItem } from "@shared/types"; + +const CustomScheduleSelector = () => { + const { closeModal } = useModal(); + const [isError, setIsError] = useState(false); + const [newItem, setNewItem] = useState<CategoryItem>({ + category: "기타", + title: "", + city: "", + tagBackground: "", + }); + const setCustomItems = useSetRecoilState(customItem); + + const handleInput = (key: keyof CategoryItem, value: string) => { + setNewItem((prev) => ({ ...prev, [key]: value })); + }; + + const makeNewItem = () => { + if (newItem.category && newItem.tagBackground && newItem.title) { + setCustomItems((prev) => { + if (prev) { + const newArray = [...prev]; + newArray.push(newItem); + + return newArray; + } + + return [newItem]; + }); + closeModal(); + return; + } + setIsError(true); + }; + + return ( + <div className="w-[362px]"> + <ScheduleTitle title="나만의 아이템 만들기" /> + <div className="text-[#333333] mb-[8px]"> + 카테고리 + <span className="w-[11px] h-[22px] text-[#FF0000] text-xl font-light"> + * + </span> + </div> + <div className="w-full h-[57px] border border-[#ADADAD] rounded-[9px] px-[16px] mb-[13px] focus-within:border-[#4285F4]"> + <select + defaultValue="기타" + className="w-full h-full text-[#333333] outline-0" + onChange={({ target: { value } }) => handleInput("category", value)} + > + <option>기타</option> + <option>영화</option> + <option>축제</option> + <option>캠핑</option> + <option>관광</option> + <option>쇼핑</option> + <option>음식점</option> + <option>문화생활</option> + <option>등산</option> + </select> + </div> + <div className="text-[#333333] mb-[8px]"> + 아이템 색 + <span className="w-[11px] h-[22px] text-[#FF0000] text-xl font-light"> + * + </span> + </div> + <div className="flex flex-wrap w-full gap-[5px] mb-[12px]"> + {itemColor.map((color, index) => ( + <span + key={`${color}-${index}`} + className={`${ + newItem.tagBackground === color ? "brightness-90 " : "" + }${color} w-[23px] h-[23px] rounded-[50%] text-[0px] cursor-pointer`} + onClick={() => handleInput("tagBackground", color)} + > + color + {newItem.tagBackground === color && ( + <div className="text-center pt-[4px] brightness-0"> + <Image + src="/images/samples/checked.svg" + alt="checked" + width={15} + height={15} + /> + </div> + )} + </span> + ))} + </div> + <div className="inline-block"> + <div className="text-[#333333] mb-[8px]"> + 아이템명 + <span className="w-[11px] h-[22px] text-[#FF0000] text-xl font-light"> + * + </span> + </div> + <input + className="w-[180px] h-[57px] border border-[#ADADAD] px-[16px] rounded-[9px] mb-[13px] mr-[2px]" + type="text" + placeholder="산책하기" + onChange={({ target: { value } }) => handleInput("title", value)} + /> + </div> + <div className="inline-block"> + <div className="text-[#333333] mb-[8px]">위치(선택)</div> + <input + className="w-[180px] h-[57px] border border-[#ADADAD] px-[16px] rounded-[9px] mb-[13px]" + type="text" + placeholder="위치" + onChange={({ target: { value } }) => handleInput("city", value)} + /> + </div> + <div className="text-[#333333] mb-[8px]">세부내용(선택)</div> + <div className="w-full h-[78px] border border-[#ADADAD] rounded-[9px] px-[16px] py-[10px] focus-within:border-[#4285F4]"> + <textarea + className="w-full resize-none outline-0" + placeholder="집 근처 공원 산책" + /> + </div> + + {isError ? ( + <div className="w-full h-[18px] text-center text-[12px] text-[#FF2330] leading-[18px] my-[6px]"> + 필수 입력 정보를 모두 입력해주세요.(카테고리, 아이템 색, 아이템명) + </div> + ) : ( + <div className="w-full h-[18px] my-[6px]"></div> + )} + + <div className="w-full text-center"> + <button + className="w-[120px] h-[48px] text-[14px] text-[#B1B1B1] font-bold -tracking-[0.03em] bg-[#E9ECEF] rounded-[5px] mr-[10px]" + onClick={closeModal} + > + 취소 + </button> + <button + className="w-[120px] h-[48px] text-[14px] text-white font-bold -tracking-[0.03em] bg-[#F864A1] rounded-[5px]" + onClick={makeNewItem} + > + 아이템 등록 + </button> + </div> + </div> + ); +}; + +export default CustomScheduleSelector; diff --git a/src/modalContent/ScheduleTimeSelector.tsx b/src/modalContent/ScheduleTimeSelector.tsx new file mode 100644 index 00000000..de6ca9fc --- /dev/null +++ b/src/modalContent/ScheduleTimeSelector.tsx @@ -0,0 +1,7 @@ +import { TimeModal } from "@create-schedule/components"; + +const ScheduleTimeSelector = () => { + return <TimeModal />; +}; + +export default ScheduleTimeSelector; diff --git a/src/modalContent/TemplatePreview.tsx b/src/modalContent/TemplatePreview.tsx new file mode 100644 index 00000000..38d0026c --- /dev/null +++ b/src/modalContent/TemplatePreview.tsx @@ -0,0 +1,32 @@ +import { CurrentDate, TimeTable } from "@create-schedule/components"; +import { useModal } from "@shared/hook"; + +const TemplatePreview = () => { + const { closeModal } = useModal(); + const currentDate = new Date(); // TODO: 날짜 받아오기 + + return ( + <div className="w-[95%]"> + <div className="text-[18px] text-[#333333] font-medium -tracking-[0.5px] mb-[12px]"> + 템플릿 미리 보기 + </div> + <div> + <CurrentDate currentDate={currentDate} /> + </div> + <div className="w-full border border-[#ACBEFF] rounded-[5px] bg-[#FFF9FC] mb-[12px]"> + {/* TODO: initialData 받아오기 */} + <TimeTable callType="template" /> + </div> + <div className="text-center"> + <button + className="w-[81px] h-[48px] text-[14px] text-white font-bold -tracking-[0.03em] bg-[#F864A1] rounded-[5px]" + onClick={closeModal} + > + 확인 + </button> + </div> + </div> + ); +}; + +export default TemplatePreview; diff --git a/src/modalContent/index.tsx b/src/modalContent/index.tsx index 769a22e4..d4db894d 100644 --- a/src/modalContent/index.tsx +++ b/src/modalContent/index.tsx @@ -1,6 +1,9 @@ import { ModalContentId } from "@shared/recoil/modal"; import CalendarSelector from "./CalendarSelector"; +import CustomScheduleSelector from "./CustomScheduleSelector"; import RecruitManage, { RecruitManageProps } from "./RecruitManage"; +import ScheduleTimeSelector from "./ScheduleTimeSelector"; +import TemplatePreview from "./TemplatePreview"; import ThumbnailSelector, { ThumbnailSelectorProps } from "./ThumbnailSelector"; export interface ModalContentProps { @@ -18,6 +21,12 @@ const ModalContent = <T,>(modalProps: ModalContentProps) => { return <CalendarSelector type="startedAt" />; case "calendarSelector_end": return <CalendarSelector type="endedAt" />; + case "scheduleTimeSelector": + return <ScheduleTimeSelector />; + case "customScheduleSelector": + return <CustomScheduleSelector />; + case "templatePreview": + return <TemplatePreview />; default: return <></>; } diff --git a/src/shared/components/ScheduleCard.tsx b/src/shared/components/ScheduleCard.tsx index 729c221f..0ccad844 100644 --- a/src/shared/components/ScheduleCard.tsx +++ b/src/shared/components/ScheduleCard.tsx @@ -1,5 +1,6 @@ import { Continue, TemplateButton } from "@create-schedule/components"; import { useState } from "react"; +import { useModal } from "@shared/hook"; // TODO: 일정 타입 지정 export interface Schedule { @@ -14,12 +15,27 @@ export interface Schedule { interface ScheduleCardProps { content: Schedule[]; callType: "remain" | "template"; + isClicked?: { + template: boolean; + custom: boolean; + }; + onClick?: (key: "template" | "custom") => void; } -const ScheduleCard = ({ content, callType }: ScheduleCardProps) => { +const ScheduleCard = ({ + content, + callType, + isClicked, + onClick: handleClickCard, +}: ScheduleCardProps) => { + const { openModal } = useModal(); const [clickedContent, setClickedContent] = useState<number | null>(null); - const handleClick = (index: number) => { + const onClick = (index: number) => { + if (callType === "template" && handleClickCard) { + handleClickCard("template"); + openModal({ contentId: "templatePreview", isHeaderCloseBtn: true }); + } setClickedContent(index); }; @@ -32,7 +48,7 @@ const ScheduleCard = ({ content, callType }: ScheduleCardProps) => { className={`${ clickedContent === index ? "border-[#22AFFF]" : "border-[#E0E0E0]" } w-[151px] h-[196px] border rounded-[5px] cursor-pointer`} - onClick={() => handleClick(index)} + onClick={() => onClick(index)} > <div className="w-[149px] h-[113px] bg-black rounded-t-[5px]"> {_content.imageSrc} @@ -60,8 +76,12 @@ const ScheduleCard = ({ content, callType }: ScheduleCardProps) => { ))} </div> <div> - {callType === "template" ? ( - <TemplateButton clickedContent={clickedContent} /> + {callType === "template" && handleClickCard && isClicked ? ( + <TemplateButton + {...isClicked} + clickedContent={clickedContent} + onClick={handleClickCard} + /> ) : ( <Continue /> )} diff --git a/src/shared/recoil/createSchedule.tsx b/src/shared/recoil/createSchedule.tsx index 318a3b2c..bb2a9583 100644 --- a/src/shared/recoil/createSchedule.tsx +++ b/src/shared/recoil/createSchedule.tsx @@ -1,24 +1,54 @@ +import { uniqueId } from "lodash"; import { atom } from "recoil"; -import { CurrentPageType, ScheduleAnswerType } from "@shared/types"; +import { + AppliedItem, + CategoryItem, + CurrentPageType, + ScheduleAnswerType, + SelectedCategoryItem, +} from "@shared/types"; export const currentPageName = atom<CurrentPageType>({ - key: "currentPage", + key: `currentSchedulePage/${uniqueId()}`, default: "작성 중인 일정", }); export const currentProgress = atom<number>({ - key: "currentProgress", + key: `currentProgress/${uniqueId()}`, default: 1, }); export const scheduleAnswers = atom<ScheduleAnswerType>({ - key: "scheduleAnswers", + key: `scheduleAnswers/${uniqueId()}`, default: { title: "", imageSrc: "", - startedAt: "", - endedAt: "", + startedAt: null, + endedAt: null, city: "", tag: [], + items: [ + { + tag: "", + title: "", + background: "", + city: "", + }, + ], }, }); + +export const selectedScheduleItem = atom<SelectedCategoryItem | null>({ + key: `selectedScheduleItem/${uniqueId()}`, + default: null, +}); + +export const appliedScheduleItem = atom<AppliedItem[] | null>({ + key: `appliedScheduleItem${uniqueId()}`, + default: null, +}); + +export const customItem = atom<CategoryItem[] | null>({ + key: `customScheduleItem${uniqueId()}`, + default: null, +}); diff --git a/src/shared/recoil/modal.tsx b/src/shared/recoil/modal.tsx index 892a6219..cc64bf18 100644 --- a/src/shared/recoil/modal.tsx +++ b/src/shared/recoil/modal.tsx @@ -89,4 +89,7 @@ export type ModalContentId = | "thumbnailSelector" | "calendarSelector_start" | "calendarSelector_end" + | "scheduleTimeSelector" + | "customScheduleSelector" + | "templatePreview" | "RecruitManage"; diff --git a/src/shared/recoil/planTitle.tsx b/src/shared/recoil/planTitle.tsx deleted file mode 100644 index 134da2f5..00000000 --- a/src/shared/recoil/planTitle.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { SUBTITLE, TITLE } from "@create-schedule/constants"; -import { atom } from "recoil"; -import { PlanSubTitle, PlanTitle } from "@shared/types"; - -export const planTitle = atom<PlanTitle>({ - key: "planTitle", - default: { - remains: TITLE.remains, - nthPlan: (nickname: string, number: number) => - `${nickname} 님의 ${number}번째 일정`, - tag: "태그 및 일정 템플릿", - fill: "일정 채우기", - finish: "작성 마무리", - }, -}); - -export const planSubTitle = atom<PlanSubTitle>({ - key: "planSubTitle", - default: { - fighting: (nickname: string) => SUBTITLE.fighting(nickname), - withyou: SUBTITLE.withyou, - fillyourplan: (nickname: string) => SUBTITLE.fillyourplan(nickname), - }, -}); diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 26e747c7..bb5a1e3b 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -54,11 +54,64 @@ export interface PlanSubTitle { export interface ScheduleAnswerType { title: string; imageSrc: string; - startedAt: string; - endedAt: string; + startedAt: Date | null; + endedAt: Date | null; city: string; tag: string[]; + items: [ + { + tag: string; + title: string; + background: string; + city: string; + }, + ]; } export type CalendarSelectorType = "startedAt" | "endedAt"; + +export interface CategoryItem { + category: CategoryTags; + title: string; + city: string; + tagBackground: string; +} + +export interface SelectedCategoryItem extends CategoryItem { + selectedTime: number; +} + +export interface AppliedItem extends CategoryItem { + startTime: { + hour: number; + minute: number; + }; + endTime: { + hour: number; + minute: number; + }; +} + +export interface Start2EndTime { + start: { + hour: string; + minute: string; + }; + end: { + hour: string; + minute: string; + }; +} + +export type CategoryTags = + | "전체" + | "영화" + | "축제" + | "캠핑" + | "관광" + | "쇼핑" + | "음식점" + | "문화생활" + | "등산" + | "기타"; export type LoginType = "kakao" | "naver" | "catcher";