diff --git a/package.json b/package.json index 6707df3..78f54bb 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "test": "jest --coverage=false --watchAll=false", - "prepare": "husky install" + "prepare": "husky" }, "lint-staged": { "**/*.{ts,tsx,mdx}": [ @@ -41,6 +41,7 @@ "es-toolkit": "^1.31.0", "lucide-react": "^0.473.0", "react": "^18.3.1", + "react-calendar": "^5.1.0", "react-dom": "^18.3.1", "react-error-boundary": "^5.0.0", "react-helmet-async": "^2.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 282ff18..a267cab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,6 +40,9 @@ importers: react: specifier: ^18.3.1 version: 18.3.1 + react-calendar: + specifier: ^5.1.0 + version: 5.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -3631,6 +3634,12 @@ packages: integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ== } + "@wojtekmaj/date-utils@1.5.1": + resolution: + { + integrity: sha512-+i7+JmNiE/3c9FKxzWFi2IjRJ+KzZl1QPu6QNrsgaa2MuBgXvUy4gA1TVzf/JMdIIloB76xSKikTWuyYAIVLww== + } + abab@2.0.6: resolution: { @@ -5513,6 +5522,12 @@ packages: } engines: { node: ">= 0.4" } + get-user-locale@2.3.2: + resolution: + { + integrity: sha512-O2GWvQkhnbDoWFUJfaBlDIKUEdND8ATpBXD6KXcbhxlfktyD/d8w6mkzM/IlQEqGZAMz/PW6j6Hv53BiigKLUQ== + } + glob-parent@5.1.2: resolution: { @@ -6793,6 +6808,13 @@ packages: integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== } + map-age-cleaner@0.1.3: + resolution: + { + integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== + } + engines: { node: ">=6" } + map-or-similar@1.5.0: resolution: { @@ -6818,6 +6840,13 @@ packages: integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== } + mem@8.1.1: + resolution: + { + integrity: sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA== + } + engines: { node: ">=10" } + memoizerific@1.11.3: resolution: { @@ -6880,6 +6909,13 @@ packages: } engines: { node: ">=6" } + mimic-fn@3.1.0: + resolution: + { + integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ== + } + engines: { node: ">=8" } + mimic-fn@4.0.0: resolution: { @@ -7177,6 +7213,13 @@ packages: integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA== } + p-defer@1.0.0: + resolution: + { + integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw== + } + engines: { node: ">=4" } + p-limit@2.3.0: resolution: { @@ -7817,6 +7860,19 @@ packages: integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== } + react-calendar@5.1.0: + resolution: + { + integrity: sha512-09o/rQHPZGEi658IXAJtWfra1N69D1eFnuJ3FQm9qUVzlzNnos1+GWgGiUeSs22QOpNm32aoVFOimq0p3Ug9Eg== + } + peerDependencies: + "@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + react-confetti@6.2.2: resolution: { @@ -9032,6 +9088,12 @@ packages: integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== } + warning@4.0.3: + resolution: + { + integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w== + } + webidl-conversions@7.0.0: resolution: { @@ -11724,6 +11786,8 @@ snapshots: loupe: 3.1.3 tinyrainbow: 1.2.0 + "@wojtekmaj/date-utils@1.5.1": {} + abab@2.0.6: {} acorn-globals@7.0.1: @@ -13031,6 +13095,10 @@ snapshots: call-bind: 1.0.2 get-intrinsic: 1.2.1 + get-user-locale@2.3.2: + dependencies: + mem: 8.1.1 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -13959,6 +14027,10 @@ snapshots: dependencies: tmpl: 1.0.5 + map-age-cleaner@0.1.3: + dependencies: + p-defer: 1.0.0 + map-or-similar@1.5.0: {} math-intrinsics@1.1.0: {} @@ -13967,6 +14039,11 @@ snapshots: mdn-data@2.0.30: {} + mem@8.1.1: + dependencies: + map-age-cleaner: 0.1.3 + mimic-fn: 3.1.0 + memoizerific@1.11.3: dependencies: map-or-similar: 1.5.0 @@ -13992,6 +14069,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-fn@3.1.0: {} + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -14164,6 +14243,8 @@ snapshots: outvariant@1.4.3: {} + p-defer@1.0.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -14483,6 +14564,17 @@ snapshots: queue-microtask@1.2.3: {} + react-calendar@5.1.0(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + "@wojtekmaj/date-utils": 1.5.1 + clsx: 2.1.1 + get-user-locale: 2.3.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + warning: 4.0.3 + optionalDependencies: + "@types/react": 18.3.18 + react-confetti@6.2.2(react@18.3.1): dependencies: react: 18.3.1 @@ -15242,6 +15334,10 @@ snapshots: dependencies: makeerror: 1.0.12 + warning@4.0.3: + dependencies: + loose-envify: 1.4.0 + webidl-conversions@7.0.0: {} webpack-virtual-modules@0.6.2: {} diff --git a/src/app/router.tsx b/src/app/router.tsx index 10681f2..6f488ff 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -47,6 +47,18 @@ const createAppRouter = (queryClient: QueryClient) => ], ErrorBoundary: RootErrorBoundary }, + { + path: paths.auth.kakaoCallback.path, + element: + }, + { + path: paths.auth.googleCallback.path, + element: + }, + { + path: paths.auth.login.path, + element: + }, { path: paths.map.search.path, lazy: () => import("./routes/map/search").then(convert(queryClient)) @@ -72,10 +84,6 @@ const createAppRouter = (queryClient: QueryClient) => path: paths.goal.root.path, lazy: () => import("./routes/goal").then(convert(queryClient)) }, - { - path: "*", - lazy: () => import("./routes/not-found").then(convert(queryClient)) - }, { path: paths.auth.agreement.path, element: @@ -91,32 +99,20 @@ const createAppRouter = (queryClient: QueryClient) => { path: paths.goal.date.path, element: + }, + { + path: paths.user.myPage.path, + element: + }, + { + path: paths.user.account.path, + element: + }, + { + path: "*", + lazy: () => import("./routes/not-found").then(convert(queryClient)) } ] - }, - { - path: paths.auth.kakaoCallback.path, - element: - }, - { - path: paths.auth.googleCallback.path, - element: - }, - { - path: paths.auth.login.path, - element: - }, - { - path: paths.user.myPage.path, - element: - }, - { - path: paths.user.account.path, - element: - }, - { - path: "*", - lazy: () => import("./routes/not-found").then(convert(queryClient)) } ]); diff --git a/src/app/routes/PrivateRoute.tsx b/src/app/routes/PrivateRoute.tsx index 3236831..14d7180 100644 --- a/src/app/routes/PrivateRoute.tsx +++ b/src/app/routes/PrivateRoute.tsx @@ -8,7 +8,6 @@ import { paths } from "@/config/paths"; const PrivateRoute = () => { const navigate = useNavigate(); const { isAuthenticated } = useAuthStore((state) => state); - console.log("로그인 상태 확인:", isAuthenticated); useEffect(() => { if (!isAuthenticated) { diff --git a/src/app/routes/goal/date-pick.tsx b/src/app/routes/goal/date-pick.tsx new file mode 100644 index 0000000..7cbc389 --- /dev/null +++ b/src/app/routes/goal/date-pick.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; + +import { + addMonths, + differenceInDays, + eachDayOfInterval, + endOfMonth, + format, + getDay, + startOfMonth, + subDays, + subMonths +} from "date-fns"; +import { useLocation, useNavigate } from "react-router"; + +const DatePick = () => { + const location = useLocation(); + const navigate = useNavigate(); + + const goalName = location.state?.goalName || ""; + const mode: "start" | "end" = location.state?.mode || "start"; + const startDate: Date | null = location.state?.startDate + ? new Date(location.state.startDate) + : null; + + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); + + const daysOfWeek = ["일", "월", "화", "수", "목", "금", "토"]; + + const firstDayOfMonth = startOfMonth(currentMonth); + const lastDayOfMonth = endOfMonth(currentMonth); + const firstWeekday = getDay(firstDayOfMonth); + + const prevMonthLastDay = subDays(firstDayOfMonth, firstWeekday); + const days = eachDayOfInterval({ + start: prevMonthLastDay, + end: lastDayOfMonth + }); + + const handleDateClick = (date: Date) => { + setSelectedDate(date); + }; + + const handleConfirm = () => { + if (!selectedDate) return; + if (mode === "end") { + navigate("/goal", { + state: { startDate, endDate: selectedDate, goalName } + }); + } else { + navigate("/goal", { state: { startDate: selectedDate, goalName } }); + } + }; + + return ( +
+

+ {mode === "end" ? "목표 날짜 설정" : "시작 날짜 설정"} +

+ +
+ + {format(currentMonth, "yyyy.MM")} + +
+
+
+
+ {daysOfWeek.map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {days.map((date, index) => { + const isBeforeToday = date < new Date(); + const isInRange = + startDate && + selectedDate && + date > startDate && + date < selectedDate; + const isStartOrEnd = + (startDate && + format(startDate, "yyyy-MM-dd") === + format(date, "yyyy-MM-dd")) || + (selectedDate && + format(selectedDate, "yyyy-MM-dd") === + format(date, "yyyy-MM-dd")); + + return ( + + ); + })} +
+
+ +

+ 최소 7일, 최대 3개월까지 설정할 수 있어요. +

+
+
+ {mode === "end" && startDate && selectedDate && ( +
+ {format(startDate, "yyyy-MM-dd")} ~{" "} + {format(selectedDate, "yyyy-MM-dd")}{" "} + + {differenceInDays(selectedDate, startDate) + 1}일 + +
+ )} + + +
+
+ ); +}; + +export default DatePick; diff --git a/src/config/envs.ts b/src/config/envs.ts index de6c8f6..95f1d3d 100644 --- a/src/config/envs.ts +++ b/src/config/envs.ts @@ -6,9 +6,10 @@ export const USE_ENC = import.meta.env.VITE_USE_ENC; export const MEDIUM_REQUEST_TIMEOUT = Number( import.meta.env.VITE_MEDIUM_REQUEST_TIMEOUT ); -export const KAKAO_CLIENT_ID = import.meta.env.VITE_KAKAO_CLIENT_ID; +export const KAKAO_APP_KEY = import.meta.env.VITE_KAKAO_APP_KEY; export const KAKAO_REDIRECT_URI = import.meta.env.VITE_KAKAO_REDIRECT_URI; export const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; +export const KAKAO_CLIENT_ID = import.meta.env.VITE_KAKAO_CLIENT_ID; export const GOOGLE_REDIRECT_URI = import.meta.env.VITE_GOOGLE_REDIRECT_URI; export const isProduction = NODE_ENV === "production"; diff --git a/src/features/goal/components/create-goal/RepeatDaysSelector.tsx b/src/features/goal/components/create-goal/RepeatDaysSelector.tsx new file mode 100644 index 0000000..ef30e7b --- /dev/null +++ b/src/features/goal/components/create-goal/RepeatDaysSelector.tsx @@ -0,0 +1,40 @@ +const DAYS = ["월", "화", "수", "목", "금", "토", "일"]; + +const RepeatDaysSelector = ({ selectedDays, setSelectedDays }) => { + const toggleDay = (day) => { + if (selectedDays.includes(day)) { + setSelectedDays(selectedDays.filter((d) => d !== day)); + } else { + setSelectedDays([...selectedDays, day]); + } + }; + + const selectAllDays = () => { + setSelectedDays(DAYS); + }; + + return ( +
+ +
+ {DAYS.map((day) => ( + + ))} +
+ +
+ ); +}; + +export default RepeatDaysSelector; diff --git a/src/features/goal/routes/CreateGoal.tsx b/src/features/goal/routes/CreateGoal.tsx index eb0e7db..9d8ed44 100644 --- a/src/features/goal/routes/CreateGoal.tsx +++ b/src/features/goal/routes/CreateGoal.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import containTargetUrl from "@/asset/map/contain-target.svg?url"; import { getTempGoal, postCreateGoal, @@ -12,6 +13,8 @@ import { DAY_MAPPING } from "@/features/goal/components/create-goal/goal.constan import SaveButtons from "@/features/goal/components/create-goal/SaveButtons"; import { GoalData, GoalStatus } from "@/features/goal/types/goal-create"; import { getPoint } from "@/features/point/api/point"; +import { debounce } from "es-toolkit"; +import { Map, MapMarker } from "react-kakao-maps-sdk"; import { useLocation, useNavigate } from "react-router"; import { toast } from "react-toastify"; @@ -41,6 +44,9 @@ const CreateGoal: React.FC = ({ goalId }) => { const [targetLocation, setTargetLocation] = useState(""); const [balancePoint, setBalancePoint] = useState(0); const [selectedDays, setSelectedDays] = useState([]); + const [center, setCenter] = useState({ lat: 33.450701, lng: 126.570667 }); + const [position, setPosition] = useState(null); + const isFormValid = useMemo(() => { return ( goalName.trim().length >= 2 && @@ -60,6 +66,32 @@ const CreateGoal: React.FC = ({ goalId }) => { } }, [userId]); + const updateCenterWhenMapMoved = useMemo( + () => + debounce((map: kakao.maps.Map) => { + setCenter({ + lat: map.getCenter().getLat(), + lng: map.getCenter().getLng() + }); + }, 500), + [] + ); + + useEffect(() => { + if (location.state) { + const { position, placeName } = location.state; + setTargetLocation(placeName); + setPosition(position); + setCenter(position); + } else { + navigator.geolocation.getCurrentPosition( + ({ coords: { latitude, longitude } }) => { + setCenter({ lat: latitude, lng: longitude }); + } + ); + } + }, [location.state]); + useEffect(() => { if (!userId) return; fetchBalancePoint(); @@ -127,6 +159,8 @@ const CreateGoal: React.FC = ({ goalId }) => { } }; + const navigatePositionSearch = () => navigate(paths.map.search.getHref()); + return (
@@ -167,12 +201,30 @@ const CreateGoal: React.FC = ({ goalId }) => {
-
- 📍 지도 (추후 추가) +
+ + {position && ( + + )} +
{ return getFormattedDistance({ - originLat: position.lat, - originLng: position.lng, + originLat: center.lat, + originLng: center.lng, destinationLat, destinationLng }); }, - [position.lat, position.lng] + [center.lat, center.lng] ); const memoizedSelectedMarker = useMemo(() => { diff --git a/src/features/map-search/components/place-list/index.tsx b/src/features/map-search/components/place-list/index.tsx index ac2cdfc..3296fe6 100644 --- a/src/features/map-search/components/place-list/index.tsx +++ b/src/features/map-search/components/place-list/index.tsx @@ -77,7 +77,7 @@ function PlaceList({
- + ); } diff --git a/src/features/map-search/components/place-list/setting-popover.tsx b/src/features/map-search/components/place-list/setting-popover.tsx index 78f5607..eca01d5 100644 --- a/src/features/map-search/components/place-list/setting-popover.tsx +++ b/src/features/map-search/components/place-list/setting-popover.tsx @@ -7,24 +7,32 @@ import { import { useNavigate } from "react-router"; import { toast } from "react-toastify"; +import { paths } from "@/config/paths"; + interface SettingPopoverProps { lat: number; lng: number; + placeName: string; } -function SettingPopover({ lat, lng }: SettingPopoverProps) { +function SettingPopover({ lat, lng, placeName }: SettingPopoverProps) { const navigate = useNavigate(); - const handleReturnToFormPage = async (location: { - lat: number; - lng: number; - }) => { - if (!location) { + const handleReturnToFormPage = async ( + position: { + lat: number; + lng: number; + }, + placeName: string + ) => { + if (!position || !placeName) { toast.error("위치 선택을 다시해주세요."); return; } - return navigate("/", { state: { location } }); + return navigate(paths.goal.create.getHref(), { + state: { position, placeName } + }); }; return ( @@ -41,7 +49,7 @@ function SettingPopover({ lat, lng }: SettingPopoverProps) { 해당 위치로 설정하시겠습니까?