diff --git a/app/gathering/[accessKey]/opinion/OpinionView.tsx b/app/gathering/[accessKey]/opinion/OpinionView.tsx new file mode 100644 index 00000000..26e319f2 --- /dev/null +++ b/app/gathering/[accessKey]/opinion/OpinionView.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; + +import { + IntroStep, + DistanceStepContent, + DistanceStepFooter, + DislikeStepContent, + DislikeStepFooter, + PreferenceStepContent, + PreferenceStepFooter, +} from "#/pageComponents/gathering/opinion"; +import { StepTransition } from "#/components/stepTransition"; +import { useOpinionForm, useOpinionFunnel } from "#/hooks/gathering"; +import { Button } from "#/components/button"; +import { Layout } from "#/components/layout"; +import { FormProvider } from "react-hook-form"; +import { BackwardButton } from "#/components/backwardButton"; +import { Toaster } from "#/components/toast"; +import { useGetGathering } from "#/hooks/apis/gathering"; + +export default function OpinionView() { + const { accessKey } = useParams<{ accessKey: string }>(); + const router = useRouter(); + + const { methods, onSubmit } = useOpinionForm(); + const { step, direction, next, back, isFirstStep } = useOpinionFunnel(); + const { data: gathering } = useGetGathering(accessKey); + + const handleBackward = () => { + if (isFirstStep) { + router.push(`/gathering/${accessKey}`); + } else { + back(); + } + }; + + if (step === "intro") { + return ( + <> + +
+ + + + + +
+ +
+
+ + ); + } + + const renderContent = () => { + switch (step) { + case "distance": + return ; + case "dislike": + return ; + case "preference": + return ; + default: + return null; + } + }; + + const renderFooter = () => { + switch (step) { + case "distance": + return ; + case "dislike": + return ; + case "preference": + return ; + default: + return null; + } + }; + + return ( + +
+ + + + + + {renderContent()} + + + {renderFooter()} + + +
+ ); +} diff --git a/app/gathering/[accessKey]/opinion/complete/CompleteView.tsx b/app/gathering/[accessKey]/opinion/complete/CompleteView.tsx new file mode 100644 index 00000000..d58d67a1 --- /dev/null +++ b/app/gathering/[accessKey]/opinion/complete/CompleteView.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Button } from "#/components/button"; +import { Layout } from "#/components/layout"; +import { + CompleteView, + SubmissionBottomSheet, +} from "#/pageComponents/gathering/opinion"; +import { useParams, redirect } from "next/navigation"; +import { useGetGatheringCapacity } from "#/hooks/apis/gathering"; + +export default function CompleteViewContainer() { + const { accessKey } = useParams<{ accessKey: string }>(); + const { data: capacity } = useGetGatheringCapacity(accessKey); + + const isPending = capacity.currentCount < capacity.maxCount; + + if (isPending) { + redirect(`/gathering/${accessKey}/opinion/pending`); + } + + const handleRedirectResult = () => { + redirect(`/gathering/${accessKey}/opinion/result`); + }; + + return ( + + + + + + +
+ +
+
+
+ ); +} diff --git a/app/gathering/[accessKey]/opinion/complete/page.tsx b/app/gathering/[accessKey]/opinion/complete/page.tsx index 9562c503..5a4b1fd2 100644 --- a/app/gathering/[accessKey]/opinion/complete/page.tsx +++ b/app/gathering/[accessKey]/opinion/complete/page.tsx @@ -1,51 +1,33 @@ -"use client"; - -import { Button } from "#/components/button"; -import { Layout } from "#/components/layout"; import { - CompleteView, - SubmissionBottomSheet, -} from "#/pageComponents/gathering/opinion"; -import { useParams } from "next/navigation"; -import { redirect } from "next/navigation"; - -export default function OpinionCompletePage() { - const { accessKey } = useParams<{ accessKey: string }>(); - - // TODO: API 연동 시 실제 데이터로 교체 - const totalCount = 5; - const submittedCount = 5; - - const isPending = submittedCount < totalCount; + HydrationBoundary, + QueryClient, + dehydrate, +} from "@tanstack/react-query"; + +import { gatheringOptions } from "#/apis/gathering"; +import CompleteView from "./CompleteView"; + +interface OpinionCompletePageProps { + params: Promise<{ + accessKey: string; + }>; +} - if (isPending) { - redirect(`/gathering/${accessKey}/opinion/pending`); - } +/** + * 의견 수렴 완료 페이지 (서버 컴포넌트) + * - gathering capacity 데이터를 서버에서 prefetch하여 무한 렌더링 방지 + */ +export default async function OpinionCompletePage({ + params, +}: OpinionCompletePageProps) { + const { accessKey } = await params; + const queryClient = new QueryClient(); - const handleRedirectResult = () => { - redirect(`/gathering/${accessKey}/opinion/result`); - }; + await queryClient.prefetchQuery(gatheringOptions.capacity(accessKey)); return ( - + - - - - -
- -
-
-
+ ); } diff --git a/app/gathering/[accessKey]/opinion/page.tsx b/app/gathering/[accessKey]/opinion/page.tsx index 7e07d439..011a92ac 100644 --- a/app/gathering/[accessKey]/opinion/page.tsx +++ b/app/gathering/[accessKey]/opinion/page.tsx @@ -1,118 +1,32 @@ -"use client"; - -import { useParams, useRouter } from "next/navigation"; - import { - IntroStep, - DistanceStepContent, - DistanceStepFooter, - DislikeStepContent, - DislikeStepFooter, - PreferenceStepContent, - PreferenceStepFooter, -} from "#/pageComponents/gathering/opinion"; -import { StepTransition } from "#/components/stepTransition"; -import { useOpinionForm, useOpinionFunnel } from "#/hooks/gathering"; -import { Button } from "#/components/button"; -import { Layout } from "#/components/layout"; -import { FormProvider } from "react-hook-form"; -import { BackwardButton } from "#/components/backwardButton"; -import { Toaster } from "#/components/toast"; -import { useMemo } from "react"; -import { MeetingContext } from "#/types/gathering"; -import { MOCK_MEETING_DATA } from "#/constants/gathering/opinion/meeting"; - -export default function OpinionPage() { - const { accessKey } = useParams<{ accessKey: string }>(); - const router = useRouter(); - - const form = useOpinionForm(); - const { step, direction, next, back, isFirstStep } = useOpinionFunnel(); - - const meetingContext = useMemo( - () => ({ - accessKey, - scheduledDate: MOCK_MEETING_DATA.DATE, - stationName: MOCK_MEETING_DATA.STATION_NAME, - }), - [accessKey], - ); - - const handleBackward = () => { - if (isFirstStep) { - router.push(`/gathering/${accessKey}`); - } else { - back(); - } - }; - - const handleComplete = () => { - router.replace(`/gathering/${accessKey}/opinion/pending`); - }; - - if (step === "intro") { - return ( - <> - -
- - - {/* TODO : API 연동 과정에서 대체가 필요한 코드 */} - - - -
- -
-
- - ); - } + HydrationBoundary, + QueryClient, + dehydrate, +} from "@tanstack/react-query"; + +import { gatheringOptions } from "#/apis/gathering"; +import OpinionView from "./OpinionView"; + +interface OpinionPageProps { + params: Promise<{ + accessKey: string; + }>; +} - const renderContent = () => { - switch (step) { - case "distance": - return ; - case "dislike": - return ; - case "preference": - return ; - default: - return null; - } - }; +/** + * 의견 수렴 페이지 (서버 컴포넌트) + * - gathering 데이터를 서버에서 prefetch하여 무한 렌더링 방지 + */ +export default async function OpinionPage({ params }: OpinionPageProps) { + const { accessKey } = await params; + const queryClient = new QueryClient(); - const renderFooter = () => { - switch (step) { - case "distance": - return ; - case "dislike": - return ; - case "preference": - return ; - default: - return null; - } - }; + // 서버에서 gathering 데이터 미리 가져오기 + await queryClient.prefetchQuery(gatheringOptions.detail(accessKey)); return ( - - - - - - - {renderContent()} - - - {renderFooter()} - - + + + ); } diff --git a/app/gathering/[accessKey]/opinion/pending/PendingView.tsx b/app/gathering/[accessKey]/opinion/pending/PendingView.tsx new file mode 100644 index 00000000..3f12d71c --- /dev/null +++ b/app/gathering/[accessKey]/opinion/pending/PendingView.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Button } from "#/components/button"; +import { Layout } from "#/components/layout"; +import { + PendingView, + SubmissionBottomSheet, +} from "#/pageComponents/gathering/opinion"; +import { useParams, redirect } from "next/navigation"; +import { useGetGatheringCapacity } from "#/hooks/apis/gathering"; + +export default function PendingViewContainer() { + const { accessKey } = useParams<{ accessKey: string }>(); + const { data: capacity } = useGetGatheringCapacity(accessKey); + + const isComplete = capacity.currentCount >= capacity.maxCount; + if (isComplete) { + redirect(`/gathering/${accessKey}/opinion/complete`); + } + + return ( + + + + + + +
+ +
+
+
+ ); +} diff --git a/app/gathering/[accessKey]/opinion/pending/page.tsx b/app/gathering/[accessKey]/opinion/pending/page.tsx index b0a34db1..3580815b 100644 --- a/app/gathering/[accessKey]/opinion/pending/page.tsx +++ b/app/gathering/[accessKey]/opinion/pending/page.tsx @@ -1,41 +1,33 @@ -"use client"; - -import { Button } from "#/components/button"; -import { Layout } from "#/components/layout"; import { - PendingView, - SubmissionBottomSheet, -} from "#/pageComponents/gathering/opinion"; -import { useParams } from "next/navigation"; -import { redirect } from "next/navigation"; + HydrationBoundary, + QueryClient, + dehydrate, +} from "@tanstack/react-query"; + +import { gatheringOptions } from "#/apis/gathering"; +import PendingView from "./PendingView"; -export default function OpinionPendingPage() { - const { accessKey } = useParams<{ accessKey: string }>(); +interface OpinionPendingPageProps { + params: Promise<{ + accessKey: string; + }>; +} - const totalCount = 5; - const submittedCount = 3; +/** + * 의견 수렴 대기 페이지 (서버 컴포넌트) + * - gathering capacity 데이터를 서버에서 prefetch하여 무한 렌더링 방지 + */ +export default async function OpinionPendingPage({ + params, +}: OpinionPendingPageProps) { + const { accessKey } = await params; + const queryClient = new QueryClient(); - const isComplete = submittedCount >= totalCount; - if (isComplete) { - redirect(`/gathering/${accessKey}/opinion/complete`); - } + await queryClient.prefetchQuery(gatheringOptions.capacity(accessKey)); return ( - + - - - - -
- -
-
-
+ ); } diff --git a/app/gathering/[accessKey]/opinion/result/ResultView.tsx b/app/gathering/[accessKey]/opinion/result/ResultView.tsx new file mode 100644 index 00000000..940c6dd3 --- /dev/null +++ b/app/gathering/[accessKey]/opinion/result/ResultView.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Layout } from "#/components/layout"; +import { ShareButton } from "#/components/shareButton"; +import { ResultView } from "#/pageComponents/gathering/opinion"; +import { useParams, redirect } from "next/navigation"; +import { BackwardButton } from "#/components/backwardButton"; +import { useGetGatheringCapacity } from "#/hooks/apis/gathering"; +import { useGetRecommendResult } from "#/hooks/apis/recommendResult"; + +export default function ResultViewContainer() { + const { accessKey } = useParams<{ accessKey: string }>(); + const { data: capacity } = useGetGatheringCapacity(accessKey); + const { data: recommendationResult } = useGetRecommendResult(accessKey); + + const isComplete = capacity.currentCount >= capacity.maxCount; + + if (!isComplete) { + redirect(`/gathering/${accessKey}/opinion/complete`); + } + + const handleClickBackward = () => { + redirect(`/gathering/${accessKey}/opinion/complete`); + }; + + return ( + + + + + + + +
+ +
+
+
+ ); +} diff --git a/app/gathering/[accessKey]/opinion/result/page.tsx b/app/gathering/[accessKey]/opinion/result/page.tsx index 71c77f61..5fb5c223 100644 --- a/app/gathering/[accessKey]/opinion/result/page.tsx +++ b/app/gathering/[accessKey]/opinion/result/page.tsx @@ -1,41 +1,37 @@ -"use client"; - -import { Layout } from "#/components/layout"; -import { ShareButton } from "#/components/shareButton"; -import { ResultView } from "#/pageComponents/gathering/opinion"; -import { MOCK_RECOMMENDATION_RESULT } from "#/constants/gathering/opinion/mockResults"; -import { useParams } from "next/navigation"; -import { redirect } from "next/navigation"; -import { BackwardButton } from "#/components/backwardButton"; - -export default function OpinionResultPage() { - const { accessKey } = useParams<{ accessKey: string }>(); - - const totalCount = 5; - const submittedCount = 5; - - const isComplete = submittedCount >= totalCount; - - if (!isComplete) { - redirect(`/gathering/${accessKey}/opinion/complete`); - } +import { + HydrationBoundary, + QueryClient, + dehydrate, +} from "@tanstack/react-query"; + +import { gatheringOptions } from "#/apis/gathering"; +import { recommendResultOptions } from "#/apis/recommendResult"; +import ResultView from "./ResultView"; + +interface OpinionResultPageProps { + params: Promise<{ + accessKey: string; + }>; +} - const handleClickBackward = () => { - redirect(`/gathering/${accessKey}/opinion/complete`); - }; +/** + * 의견 수렴 결과 페이지 (서버 컴포넌트) + * - gathering capacity, recommend-result 데이터를 서버에서 prefetch하여 무한 렌더링 방지 + */ +export default async function OpinionResultPage({ + params, +}: OpinionResultPageProps) { + const { accessKey } = await params; + const queryClient = new QueryClient(); + + await Promise.all([ + queryClient.prefetchQuery(gatheringOptions.capacity(accessKey)), + queryClient.prefetchQuery(recommendResultOptions.detail(accessKey)), + ]); return ( - - - - - - - -
- -
-
-
+ + + ); } diff --git a/package.json b/package.json index 3de9bbe1..b08161f5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "generate:colors": "node scripts/colors/generate.js" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@lottiefiles/react-lottie-player": "^3.6.0", "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^5.90.16", @@ -24,7 +25,8 @@ "react-dom": "19.2.3", "react-hook-form": "^7.71.0", "sonner": "^2.0.7", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a24a330..dbc20107 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@hookform/resolvers': + specifier: ^5.2.2 + version: 5.2.2(react-hook-form@7.71.0(react@19.2.3)) '@lottiefiles/react-lottie-player': specifier: ^3.6.0 version: 3.6.0(react@19.2.3) @@ -50,6 +53,9 @@ importers: tailwind-merge: specifier: ^3.4.0 version: 3.4.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@tailwindcss/postcss': specifier: ^4 @@ -208,6 +214,11 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@hookform/resolvers@5.2.2': + resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} + peerDependencies: + react-hook-form: ^7.55.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -480,6 +491,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -2141,8 +2155,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.3.5: - resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} snapshots: @@ -2310,6 +2324,11 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@hookform/resolvers@5.2.2(react-hook-form@7.71.0(react@19.2.3))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.71.0(react@19.2.3) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -2510,6 +2529,8 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -3233,8 +3254,8 @@ snapshots: '@babel/parser': 7.28.6 eslint: 9.39.2(jiti@2.6.1) hermes-parser: 0.25.1 - zod: 4.3.5 - zod-validation-error: 4.0.2(zod@4.3.5) + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color @@ -4345,8 +4366,8 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.3.5): + zod-validation-error@4.0.2(zod@4.3.6): dependencies: - zod: 4.3.5 + zod: 4.3.6 - zod@4.3.5: {} + zod@4.3.6: {} diff --git a/src/apis/gathering/queryKey.ts b/src/apis/gathering/queryKey.ts index d9c9db9e..e002bfb7 100644 --- a/src/apis/gathering/queryKey.ts +++ b/src/apis/gathering/queryKey.ts @@ -5,5 +5,7 @@ export const gatheringKeys = { all: ["gathering"] as const, create: () => [...gatheringKeys.all, "create"] as const, detail: (accessKey: string) => - [...gatheringKeys.all, "detail", accessKey] as const, + [...gatheringKeys.all, accessKey, "detail"] as const, + capacity: (accessKey: string) => + [...gatheringKeys.all, accessKey, "capacity"] as const, }; diff --git a/src/apis/gathering/queryOption.ts b/src/apis/gathering/queryOption.ts index d3531a36..2f50837e 100644 --- a/src/apis/gathering/queryOption.ts +++ b/src/apis/gathering/queryOption.ts @@ -1,7 +1,7 @@ import { queryOptions } from "@tanstack/react-query"; import { gatheringKeys } from "./queryKey"; -import { createGathering, getGathering } from "./api"; +import { createGathering, getGathering, getGatheringCapacity } from "./api"; import type { CreateGatheringRequest } from "./type"; /** @@ -18,5 +18,13 @@ export const gatheringOptions = { queryOptions({ queryKey: gatheringKeys.detail(accessKey), queryFn: () => getGathering(accessKey), + select: (response) => response.data, + }), + + capacity: (accessKey: string) => + queryOptions({ + queryKey: gatheringKeys.capacity(accessKey), + queryFn: () => getGatheringCapacity(accessKey), + select: (response) => response.data, }), }; diff --git a/src/apis/participant/index.ts b/src/apis/participant/index.ts index c9035689..34304901 100644 --- a/src/apis/participant/index.ts +++ b/src/apis/participant/index.ts @@ -6,3 +6,7 @@ export type { // API export { createParticipant } from "./api"; + +// Query Keys & Options +export { participantKeys } from "./queryKey"; +export { participantOptions } from "./queryOption"; diff --git a/src/apis/participant/queryKey.ts b/src/apis/participant/queryKey.ts new file mode 100644 index 00000000..63a03da0 --- /dev/null +++ b/src/apis/participant/queryKey.ts @@ -0,0 +1,7 @@ +/** + * 참가자 관련 Query/Mutation Key Factory + */ +export const participantKeys = { + all: ["participant"] as const, + create: () => [...participantKeys.all, "create"] as const, +}; diff --git a/src/apis/participant/queryOption.ts b/src/apis/participant/queryOption.ts new file mode 100644 index 00000000..a634c7a7 --- /dev/null +++ b/src/apis/participant/queryOption.ts @@ -0,0 +1,16 @@ +import { participantKeys } from "./queryKey"; +import { createParticipant } from "./api"; +import type { CreateParticipantRequest } from "./type"; +import { mutationOptions } from "@tanstack/react-query"; + +/** + * 참가자 관련 Query/Mutation Options Factory + */ +export const participantOptions = { + create: () => + mutationOptions({ + mutationKey: participantKeys.create(), + mutationFn: (request: CreateParticipantRequest) => + createParticipant(request), + }), +}; diff --git a/src/apis/participant/type.ts b/src/apis/participant/type.ts index 4f5bed22..8871bf0c 100644 --- a/src/apis/participant/type.ts +++ b/src/apis/participant/type.ts @@ -7,7 +7,7 @@ export interface CreateParticipantRequest { /** 모임 접근키 */ accessKey: string; /** 거리 (km) */ - distance: number; + distance: number | null; /** 싫어하는 음식 목록 */ dislikes: string[]; /** 선호하는 음식 목록 */ diff --git a/src/apis/recommend-result/index.ts b/src/apis/recommend-result/index.ts deleted file mode 100644 index 3c12d3e9..00000000 --- a/src/apis/recommend-result/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Types -export type { GetRecommendResultResponse } from "./type"; - -// API -export { getRecommendResult } from "./api"; diff --git a/src/apis/recommend-result/api.ts b/src/apis/recommendResult/api.ts similarity index 100% rename from src/apis/recommend-result/api.ts rename to src/apis/recommendResult/api.ts diff --git a/src/apis/recommendResult/index.ts b/src/apis/recommendResult/index.ts new file mode 100644 index 00000000..d34f36bf --- /dev/null +++ b/src/apis/recommendResult/index.ts @@ -0,0 +1,9 @@ +// Types +export type { GetRecommendResultResponse } from "./type"; + +// Query Key & Option +export { recommendResultKeys } from "./queryKey"; +export { recommendResultOptions } from "./queryOption"; + +// API +export { getRecommendResult } from "./api"; diff --git a/src/apis/recommendResult/queryKey.ts b/src/apis/recommendResult/queryKey.ts new file mode 100644 index 00000000..6e9ab4de --- /dev/null +++ b/src/apis/recommendResult/queryKey.ts @@ -0,0 +1,8 @@ +/** + * recommend-result API QueryKey 관리 + */ +export const recommendResultKeys = { + all: ["recommendResult"] as const, + detail: (accessKey: string) => + [...recommendResultKeys.all, accessKey] as const, +}; diff --git a/src/apis/recommendResult/queryOption.ts b/src/apis/recommendResult/queryOption.ts new file mode 100644 index 00000000..de70cb2a --- /dev/null +++ b/src/apis/recommendResult/queryOption.ts @@ -0,0 +1,15 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { recommendResultKeys } from "./queryKey"; +import { getRecommendResult } from "./api"; + +/** + * recommend-result API QueryOption 관리 + */ +export const recommendResultOptions = { + detail: (accessKey: string) => + queryOptions({ + queryKey: recommendResultKeys.detail(accessKey), + queryFn: () => getRecommendResult(accessKey), + }), +}; diff --git a/src/apis/recommend-result/type.ts b/src/apis/recommendResult/type.ts similarity index 100% rename from src/apis/recommend-result/type.ts rename to src/apis/recommendResult/type.ts diff --git a/src/components/chip/Chip.tsx b/src/components/chip/Chip.tsx index a8877566..11516378 100644 --- a/src/components/chip/Chip.tsx +++ b/src/components/chip/Chip.tsx @@ -37,6 +37,7 @@ export type ChipProps = Omit, "className"> & export const Chip = ({ ref, + type = "button", selected, children, disabled, @@ -45,6 +46,7 @@ export const Chip = ({ return ( - )} - /> +
); diff --git a/src/pageComponents/gathering/opinion/DislikedFoodButton.tsx b/src/pageComponents/gathering/opinion/DislikedFoodButton.tsx new file mode 100644 index 00000000..0e9cca00 --- /dev/null +++ b/src/pageComponents/gathering/opinion/DislikedFoodButton.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useFormContext, useController } from "react-hook-form"; +import { motion } from "motion/react"; +import { XIcon } from "#/icons/xIcon"; +import { cva } from "class-variance-authority"; +import { AnimatePresence } from "motion/react"; +import { twJoin } from "tailwind-merge"; +import Image from "next/image"; + +import { FOOD_CATEGORY_LABEL } from "#/constants/gathering/opinion"; +import type { FoodCategory } from "#/types/gathering"; +import type { OpinionFormSchema } from "#/schemas/gathering"; + +const dislikedFoodButtonVariants = cva( + [ + "ygi:flex ygi:flex-col ygi:items-center ygi:justify-center", + "ygi:size-[156px] ygi:rounded-full", + "ygi:gap-1 ygi:p-6", + "ygi:cursor-pointer ygi:transition", + "ygi:border ygi:border-solid ygi:bg-surface-lightgray", + ], + { + variants: { + isAny: { + false: [], + true: [], + }, + selected: { + false: [ + "ygi:bg-surface-lightgray-50", + "ygi:border-transparent", + ], + true: ["ygi:border-border-primary", "ygi:bg-surface-primary"], + }, + }, + compoundVariants: [ + { + isAny: true, + selected: true, + class: [ + "ygi:border-border-secondary", + "ygi:bg-surface-secondary", + ], + }, + ], + defaultVariants: { + isAny: false, + selected: false, + }, + }, +); + +interface DislikedFoodButtonProps { + category: FoodCategory; +} + +export const DislikedFoodButton = ({ category }: DislikedFoodButtonProps) => { + const { control } = useFormContext(); + const { field } = useController({ name: "dislikedFoods", control }); + + const dislikedFoods = field.value || []; + + const isSelected = dislikedFoods.includes(category); + const isAny = category === "ANY"; + const shouldShowXIcon = isSelected && !isAny; + + return ( + + ); +}; diff --git a/src/pageComponents/gathering/opinion/DistanceSelector.tsx b/src/pageComponents/gathering/opinion/DistanceSelector.tsx new file mode 100644 index 00000000..1d48e915 --- /dev/null +++ b/src/pageComponents/gathering/opinion/DistanceSelector.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useFormContext, useController } from "react-hook-form"; + +import { Chip } from "#/components/chip"; +import { DISTANCE_OPTIONS } from "#/constants/gathering/opinion"; +import type { OpinionFormSchema } from "#/schemas/gathering"; + +export const DistanceSelector = () => { + const { control } = useFormContext(); + const { field } = useController({ name: "distanceRange", control }); + + return ( +
+ {DISTANCE_OPTIONS.map((option) => ( + { + field.onChange(option.value); + }} + > + {option.label} + + ))} +
+ ); +}; diff --git a/src/pageComponents/gathering/opinion/DistanceStep.tsx b/src/pageComponents/gathering/opinion/DistanceStep.tsx index 0235ff5b..bba131fc 100644 --- a/src/pageComponents/gathering/opinion/DistanceStep.tsx +++ b/src/pageComponents/gathering/opinion/DistanceStep.tsx @@ -1,25 +1,28 @@ "use client"; -import { useCallback } from "react"; -import { useFormContext, useController } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; +import { isUndefined } from "es-toolkit/predicate"; import { Layout } from "#/components/layout"; import { StepIndicator } from "#/components/stepIndicator"; import { StepHeader } from "#/components/stepHeader"; import { Button } from "#/components/button"; -import { Chip } from "#/components/chip"; -import { useDistanceStepValidation } from "#/hooks/gathering"; import { - DISTANCE_OPTIONS, OPINION_TOTAL_STEPS, + REGION_OPTIONS, } from "#/constants/gathering/opinion"; -import type { OpinionForm, DistanceStepProps } from "#/types/gathering"; +import type { OpinionFormSchema } from "#/schemas/gathering"; +import type { GetGatheringResponse } from "#/apis/gathering"; +import { DistanceSelector } from "./DistanceSelector"; -export const DistanceStepContent = ({ - meetingContext, -}: Pick) => { - const { control } = useFormContext(); - const { field } = useController({ name: "distanceRange", control }); +interface DistanceStepContentProps { + region: GetGatheringResponse["region"]; +} + +export const DistanceStepContent = ({ region }: DistanceStepContentProps) => { + const stationName = + REGION_OPTIONS.find((currentRegion) => currentRegion.value === region) + ?.label ?? ""; return (
@@ -31,41 +34,25 @@ export const DistanceStepContent = ({ 괜찮으신지 선택해주세요 - {`${meetingContext.stationName} 기준으로 추천 범위를 정할게요`} + {`${stationName} 기준으로 추천 범위를 정할게요`} -
- {DISTANCE_OPTIONS.map((option) => ( - { - field.onChange( - field.value === option.value - ? undefined - : option.value, - ); - }} - > - {option.label} - - ))} -
+
); }; -export const DistanceStepFooter = ({ - onNext, -}: Pick) => { - const { control } = useFormContext(); - const isValid = useDistanceStepValidation(control); +interface DistanceStepFooterProps { + onNext: () => void; +} - const handleNext = useCallback(() => { - if (isValid) { - onNext(); - } - }, [isValid, onNext]); +export const DistanceStepFooter = ({ onNext }: DistanceStepFooterProps) => { + const { control } = useFormContext(); + const disabled = useWatch({ + control, + name: "distanceRange", + compute: (value) => isUndefined(value), + }); return ( @@ -73,8 +60,8 @@ export const DistanceStepFooter = ({ diff --git a/src/pageComponents/gathering/opinion/FoodCard.tsx b/src/pageComponents/gathering/opinion/FoodCard.tsx index f3c5dee1..2213f455 100644 --- a/src/pageComponents/gathering/opinion/FoodCard.tsx +++ b/src/pageComponents/gathering/opinion/FoodCard.tsx @@ -1,9 +1,10 @@ "use client"; import Image from "next/image"; +import type { FoodCategory } from "#/types/gathering"; interface FoodCardProps { - category: string; + category: FoodCategory; } export const FoodCard = ({ category }: FoodCardProps) => { diff --git a/src/pageComponents/gathering/opinion/FoodCategoryButton.tsx b/src/pageComponents/gathering/opinion/FoodCategoryButton.tsx index e6f29448..d833d157 100644 --- a/src/pageComponents/gathering/opinion/FoodCategoryButton.tsx +++ b/src/pageComponents/gathering/opinion/FoodCategoryButton.tsx @@ -68,6 +68,7 @@ export const FoodCategoryButton = ({ return ( - )} - /> +
); diff --git a/src/pageComponents/gathering/opinion/RankChip.tsx b/src/pageComponents/gathering/opinion/RankChip.tsx new file mode 100644 index 00000000..378d198a --- /dev/null +++ b/src/pageComponents/gathering/opinion/RankChip.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useCallback } from "react"; +import { useFormContext, useController } from "react-hook-form"; +import { omit, drop } from "es-toolkit"; + +import { Chip } from "#/components/chip"; +import { FOOD_CATEGORY_LABEL, RANKS } from "#/constants/gathering/opinion"; +import type { FoodCategory, RankKey } from "#/types/gathering"; +import type { OpinionFormSchema } from "#/schemas/gathering"; +import { toast } from "#/utils/toast"; + +interface RankChipProps { + rank: RankKey; + category: FoodCategory; + disabled: boolean; +} + +export const RankChip = ({ rank, category, disabled }: RankChipProps) => { + const { control } = useFormContext(); + const { field } = useController({ name: "preferredMenus", control }); + + const preferredMenus = field.value; + + const isSelected = preferredMenus[rank] === category; + + const handleClick = useCallback(() => { + if (preferredMenus[rank] === category) { + const newMenus = omit(preferredMenus, [rank]); + field.onChange(newMenus); + return; + } + + if (category !== "ANY") { + const existingRank = RANKS.find( + (r) => r !== rank && preferredMenus[r] === category, + ); + + if (existingRank) { + toast.warning("이미 선택되었어요. 다른 메뉴를 골라주세요!"); + return; + } + } + + let newMenus = { + ...preferredMenus, + [rank]: category, + } satisfies OpinionFormSchema["preferredMenus"]; + + if (category === "ANY") { + const ranksToRemove = drop(RANKS, RANKS.indexOf(rank) + 1); + newMenus = omit(newMenus, ranksToRemove); + } + + field.onChange(newMenus); + }, [rank, category, preferredMenus, field]); + + return ( + + {FOOD_CATEGORY_LABEL[category]} + + ); +}; diff --git a/src/pageComponents/gathering/opinion/RankSection.tsx b/src/pageComponents/gathering/opinion/RankSection.tsx index e7fcbb30..0aabe993 100644 --- a/src/pageComponents/gathering/opinion/RankSection.tsx +++ b/src/pageComponents/gathering/opinion/RankSection.tsx @@ -1,23 +1,33 @@ "use client"; -import { Chip } from "#/components/chip"; -import { FOOD_CATEGORIES, RANK_LABELS } from "#/constants/gathering/opinion"; -import type { FoodCategory, RankKey } from "#/types/gathering"; +import { useFormContext, useWatch } from "react-hook-form"; + +import { + RANK_LABELS, + FOOD_CATEGORIES, + RANKS, +} from "#/constants/gathering/opinion"; +import type { RankKey } from "#/types/gathering"; +import type { OpinionFormSchema } from "#/schemas/gathering"; +import { RankChip } from "./RankChip"; import { twJoin } from "tailwind-merge"; interface RankSectionProps { rank: RankKey; - selectedMenu?: FoodCategory; - isDisabled: boolean; - onMenuSelect: (menu: FoodCategory) => void; } -export const RankSection = ({ - rank, - selectedMenu, - isDisabled, - onMenuSelect, -}: RankSectionProps) => { +export const RankSection = ({ rank }: RankSectionProps) => { + const { control } = useFormContext(); + + const disabled = useWatch({ + control, + name: "preferredMenus", + compute: (data) => + RANKS.slice(0, RANKS.indexOf(rank)).some( + (prevRank) => data[prevRank] === "ANY", + ), + }); + return (
@@ -25,21 +35,14 @@ export const RankSection = ({ {RANK_LABELS[rank]}
-
+
{FOOD_CATEGORIES.map((category) => ( - onMenuSelect(category.value)} - > - {category.label} - + rank={rank} + category={category.value} + disabled={disabled} + /> ))}
diff --git a/src/pageComponents/gathering/opinion/ResultView.tsx b/src/pageComponents/gathering/opinion/ResultView.tsx index daab35a8..de6fd966 100644 --- a/src/pageComponents/gathering/opinion/ResultView.tsx +++ b/src/pageComponents/gathering/opinion/ResultView.tsx @@ -4,23 +4,62 @@ import { Layout } from "#/components/layout"; import { TopRecommendCard, OtherCandidateCard, -} from "#/components/restaurantCard"; -import { VoteSummarySection } from "./VoteSummarySection"; +} from "#/pageComponents/gathering/restaurantCard"; import type { RecommendationResult } from "#/types/gathering"; import { twJoin } from "tailwind-merge"; import { Chip } from "#/components/chip"; import { CrownIcon } from "#/icons/crownIcon"; +import { ProgressBar } from "#/components/progressBar"; +import { CircleIcon } from "#/icons/circleIcon"; +import { XIcon } from "#/icons/xIcon"; +import { FOOD_CATEGORY_LABEL } from "#/constants/gathering/opinion"; +import type { FoodCategory } from "#/types/gathering"; export interface ResultViewProps { recommendationResult: RecommendationResult; } +interface VoteListProps { + votes: Record; +} + +const VoteList = ({ votes }: VoteListProps) => { + const sortedVotes = Object.entries(votes) + .map(([category, count]) => ({ + category: category as FoodCategory, + count, + })) + .sort((a, b) => b.count - a.count); + + return ( +
+ {sortedVotes.map((vote) => ( +
+ + {FOOD_CATEGORY_LABEL[vote.category]} + + + {vote.count}표 + +
+ ))} +
+ ); +}; + export const ResultView = ({ recommendationResult }: ResultViewProps) => { return (
{/* Header */} -
1위 @@ -35,12 +74,73 @@ export const ResultView = ({ recommendationResult }: ResultViewProps) => { restaurant={recommendationResult.topRecommendation} /> - {/* Vote Summary */} - + {/* Vote Summary Section */} +
+ {/* 의견 일치율 */} +
+
+
+ 의견 일치율 +
+ + {Math.round(recommendationResult.agreementRate)} + % + +
+ +
+ + {/* Divider */} +
+ + {/* 좋아하는 음식 */} +
+
+
+ +
+

+ 좋아하는 음식 +

+
+ +
+ + {/* 피하고 싶은 음식 */} +
+
+
+ +
+

+ 피하고 싶은 음식 +

+
+ +
+
{/* Other Candidates */} {recommendationResult.otherCandidates.length > 0 && ( diff --git a/src/pageComponents/gathering/opinion/SubmissionBottomSheet.tsx b/src/pageComponents/gathering/opinion/SubmissionBottomSheet.tsx index 36255605..c05085db 100644 --- a/src/pageComponents/gathering/opinion/SubmissionBottomSheet.tsx +++ b/src/pageComponents/gathering/opinion/SubmissionBottomSheet.tsx @@ -3,22 +3,22 @@ import { ProgressBar } from "#/components/progressBar"; interface SubmissionBottomSheetProps { - totalCount: number; - submittedCount: number; + maxCount: number; + currentCount: number; } export const SubmissionBottomSheet = ({ - totalCount, - submittedCount, + maxCount, + currentCount, }: SubmissionBottomSheetProps) => { const percentage = - totalCount > 0 ? Math.round((submittedCount / totalCount) * 100) : 0; + maxCount > 0 ? Math.round((currentCount / maxCount) * 100) : 0; - const isComplete = submittedCount >= totalCount; + const isComplete = currentCount >= maxCount; const title = isComplete - ? `${totalCount}명 중 모두 참여했어요` - : `${totalCount}명 중 ${submittedCount}명이 제출했어요`; + ? `${maxCount}명 중 모두 참여했어요` + : `${maxCount}명 중 ${currentCount}명이 제출했어요`; const description = isComplete ? "모두의 취향을 반영한 추천 결과를 보여드릴게요" diff --git a/src/pageComponents/gathering/opinion/VoteSummarySection.tsx b/src/pageComponents/gathering/opinion/VoteSummarySection.tsx deleted file mode 100644 index 4929d3a1..00000000 --- a/src/pageComponents/gathering/opinion/VoteSummarySection.tsx +++ /dev/null @@ -1,129 +0,0 @@ -"use client"; - -import type { FoodCategory } from "#/types/gathering"; -import { ProgressBar } from "#/components/progressBar"; -import { CircleIcon } from "#/icons/circleIcon"; -import { XIcon } from "#/icons/xIcon"; -import { FOOD_CATEGORY_LABELS } from "#/constants/gathering/opinion"; -import { twJoin } from "tailwind-merge"; -import type { ReactNode } from "react"; - -export interface VoteSummarySectionProps { - preferences: Record; - dislikes: Record; - agreementRate: number; -} - -interface VoteCategoryProps { - icon: ReactNode; - title: string; - votes: Record; -} - -const VoteCategory = ({ icon, title, votes }: VoteCategoryProps) => { - const sortedVotes = Object.entries(votes) - .map(([category, count]) => ({ - category: category as FoodCategory, - count, - })) - .sort((a, b) => b.count - a.count); - - return ( -
- {/* Title with Icon */} -
- {icon} -

- {title} -

-
- - {/* Vote Items */} -
- {sortedVotes.map((vote) => ( -
- - {FOOD_CATEGORY_LABELS[vote.category]} - - - {vote.count}표 - -
- ))} -
-
- ); -}; - -export const VoteSummarySection = ({ - preferences, - dislikes, - agreementRate, -}: VoteSummarySectionProps) => { - return ( -
- {/* 의견 집계율 */} -
-
-
- 의견 일치율 -
- - {Math.round(agreementRate)}% - -
- -
- - {/* Divider */} -
- - {/* 좋아하는 음식 */} - - -
- } - title="좋아하는 음식" - votes={preferences} - /> - - {/* 피하고 싶은 음식 */} - - -
- } - title="피하고 싶은 음식" - votes={dislikes} - /> - - ); -}; diff --git a/src/components/restaurantCard/OtherCandidateCard.tsx b/src/pageComponents/gathering/restaurantCard/OtherCandidateCard.tsx similarity index 86% rename from src/components/restaurantCard/OtherCandidateCard.tsx rename to src/pageComponents/gathering/restaurantCard/OtherCandidateCard.tsx index 68754c9a..0c4324c4 100644 --- a/src/components/restaurantCard/OtherCandidateCard.tsx +++ b/src/pageComponents/gathering/restaurantCard/OtherCandidateCard.tsx @@ -4,11 +4,11 @@ import { StarIcon } from "#/icons/starIcon"; import { ChevronRightIcon } from "#/icons/chevronRightIcon"; import type { Restaurant } from "#/types/gathering"; import { - FOOD_CATEGORY_LABELS, - DISTANCE_LABELS, + FOOD_CATEGORY_LABEL, + DISTANCE_RANGE_LABEL, } from "#/constants/gathering/opinion"; import Image from "next/image"; -import { Tag } from "../tag"; +import { Tag } from "#/components/tag"; export interface OtherCandidateCardProps { restaurant: Restaurant; @@ -48,7 +48,7 @@ export const OtherCandidateCard = ({
diff --git a/src/components/restaurantCard/RestaurantCard.tsx b/src/pageComponents/gathering/restaurantCard/RestaurantCard.tsx similarity index 90% rename from src/components/restaurantCard/RestaurantCard.tsx rename to src/pageComponents/gathering/restaurantCard/RestaurantCard.tsx index 5025f81b..3af780e8 100644 --- a/src/components/restaurantCard/RestaurantCard.tsx +++ b/src/pageComponents/gathering/restaurantCard/RestaurantCard.tsx @@ -5,11 +5,11 @@ import { StarIcon } from "#/icons/starIcon"; import { ChevronRightIcon } from "#/icons/chevronRightIcon"; import type { Restaurant } from "#/types/gathering"; import { - FOOD_CATEGORY_LABELS, - DISTANCE_LABELS, + FOOD_CATEGORY_LABEL, + DISTANCE_RANGE_LABEL, } from "#/constants/gathering/opinion"; import Image from "next/image"; -import { Tag } from "../tag"; +import { Tag } from "#/components/tag"; export interface RestaurantCardProps { restaurant: Restaurant; @@ -62,7 +62,7 @@ export const RestaurantCard = ({
@@ -140,10 +140,10 @@ export const RestaurantCard = ({
- {`역에서 ${DISTANCE_LABELS[restaurant.majorityDistanceRange]}`} + {`역에서 ${DISTANCE_RANGE_LABEL[restaurant.majorityDistanceRange]}`} - {FOOD_CATEGORY_LABELS[restaurant.largeCategory]} + {FOOD_CATEGORY_LABEL[restaurant.largeCategory]}
diff --git a/src/components/restaurantCard/TopRecommendCard.tsx b/src/pageComponents/gathering/restaurantCard/TopRecommendCard.tsx similarity index 87% rename from src/components/restaurantCard/TopRecommendCard.tsx rename to src/pageComponents/gathering/restaurantCard/TopRecommendCard.tsx index 66669c97..0752a70a 100644 --- a/src/components/restaurantCard/TopRecommendCard.tsx +++ b/src/pageComponents/gathering/restaurantCard/TopRecommendCard.tsx @@ -5,11 +5,11 @@ import { ChevronRightIcon } from "#/icons/chevronRightIcon"; import type { SyntheticEvent } from "react"; import type { Restaurant } from "#/types/gathering"; import { - FOOD_CATEGORY_LABELS, - DISTANCE_LABELS, + FOOD_CATEGORY_LABEL, + DISTANCE_RANGE_LABEL, } from "#/constants/gathering/opinion"; import Image from "next/image"; -import { Tag } from "../tag"; +import { Tag } from "#/components/tag"; export interface TopRecommendCardProps { restaurant: Restaurant; @@ -45,7 +45,7 @@ export const TopRecommendCard = ({ restaurant }: TopRecommendCardProps) => {
diff --git a/src/components/restaurantCard/index.ts b/src/pageComponents/gathering/restaurantCard/index.ts similarity index 100% rename from src/components/restaurantCard/index.ts rename to src/pageComponents/gathering/restaurantCard/index.ts diff --git a/src/schemas/gathering/index.ts b/src/schemas/gathering/index.ts new file mode 100644 index 00000000..2a6b4a7f --- /dev/null +++ b/src/schemas/gathering/index.ts @@ -0,0 +1,6 @@ +export { + opinionFormSchema, + foodCategorySchema, + distanceRangeToKm, + type OpinionFormSchema, +} from "./opinionForm.schema"; diff --git a/src/schemas/gathering/opinionForm.schema.ts b/src/schemas/gathering/opinionForm.schema.ts new file mode 100644 index 00000000..9124079d --- /dev/null +++ b/src/schemas/gathering/opinionForm.schema.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; +import type { DistanceRange, FoodCategory } from "#/types/gathering"; +import { DISTANCE_RANGE } from "#/constants/gathering/opinion"; + +const distanceRangeSchema = z.enum([ + "RANGE_500M", + "RANGE_1KM", + "ANY", +] satisfies readonly DistanceRange[]); + +export const foodCategorySchema = z.enum([ + "KOREAN", + "JAPANESE", + "CHINESE", + "WESTERN", + "ASIAN", + "ANY", +] satisfies readonly FoodCategory[]); + +export const opinionFormSchema = z.object({ + distanceRange: distanceRangeSchema, + dislikedFoods: z + .array(foodCategorySchema) + .min(1, "싫어하는 음식을 선택해주세요"), + preferredMenus: z.object({ + first: foodCategorySchema.optional(), + second: foodCategorySchema.optional(), + third: foodCategorySchema.optional(), + }), +}); + +export type OpinionFormSchema = z.infer; + +/** + * DistanceRange를 실제 거리(km)로 변환하는 헬퍼 함수 + */ +export function distanceRangeToKm(range: DistanceRange): number | null { + return DISTANCE_RANGE[range]; +} diff --git a/src/types/gathering/createMeetingForm.ts b/src/types/gathering/createMeetingForm.ts new file mode 100644 index 00000000..601c6295 --- /dev/null +++ b/src/types/gathering/createMeetingForm.ts @@ -0,0 +1,11 @@ +import { Region } from "./region"; +import { TimeSlot } from "./timeSlot"; + +export type CreateMeetingStep = "people" | "date" | "region"; + +export interface CreateMeetingForm { + peopleCount?: number; + scheduledDate: string; + timeSlot?: TimeSlot; + region?: Region; +} diff --git a/src/types/gathering/createOpinionForm.ts b/src/types/gathering/createOpinionForm.ts new file mode 100644 index 00000000..22080229 --- /dev/null +++ b/src/types/gathering/createOpinionForm.ts @@ -0,0 +1,11 @@ +import { DistanceRange } from "./distance"; +import { PreferredMenu } from "./preferredMenu"; +import { FoodCategory } from "./foodCategory"; + +export interface OpinionForm { + distanceRange: DistanceRange; + dislikedFoods: FoodCategory[]; + preferredMenus: PreferredMenu; +} + +export type OpinionStep = "intro" | "distance" | "dislike" | "preference"; diff --git a/src/types/gathering/distance.ts b/src/types/gathering/distance.ts new file mode 100644 index 00000000..f45297a9 --- /dev/null +++ b/src/types/gathering/distance.ts @@ -0,0 +1 @@ +export type DistanceRange = "RANGE_500M" | "RANGE_1KM" | "ANY"; diff --git a/src/types/gathering/foodCategory.ts b/src/types/gathering/foodCategory.ts new file mode 100644 index 00000000..858c5bc6 --- /dev/null +++ b/src/types/gathering/foodCategory.ts @@ -0,0 +1,7 @@ +export type FoodCategory = + | "KOREAN" + | "JAPANESE" + | "CHINESE" + | "WESTERN" + | "ASIAN" + | "ANY"; diff --git a/src/types/gathering/index.ts b/src/types/gathering/index.ts index ed3c5236..44bb3c98 100644 --- a/src/types/gathering/index.ts +++ b/src/types/gathering/index.ts @@ -1,85 +1,12 @@ -export interface CreateMeetingForm { - peopleCount?: number; - scheduledDate: string; - timeSlot?: "LUNCH" | "DINNER"; - region?: "HONGDAE" | "GANGNAM"; -} - -export type TimeSlot = CreateMeetingForm["timeSlot"]; -export type Region = CreateMeetingForm["region"]; - -export type CreateMeetingStep = "people" | "date" | "region"; - -export type DistanceRange = "RANGE_500M" | "RANGE_1KM" | "ANY"; - -export type FoodCategory = - | "KOREAN" - | "JAPANESE" - | "CHINESE" - | "WESTERN" - | "ASIAN" - | "ANY"; - -export type RankKey = "first" | "second" | "third"; - -export interface OpinionForm { - distanceRange?: DistanceRange; - dislikedFoods: FoodCategory[]; - preferredMenus: { - first?: FoodCategory; - second?: FoodCategory; - third?: FoodCategory; - }; -} - -export type OpinionStep = "intro" | "distance" | "dislike" | "preference"; - -export interface MeetingContext { - accessKey: string; - scheduledDate: string; - stationName: string; - totalParticipants?: number; - submittedCount?: number; -} - -export interface Restaurant { - rank: number; - restaurantId: number; - restaurantName: string; - address: string; - rating: number; - imageUrl: string | null; - mapUrl: string; - representativeReview: string; - description: string; - region: string | null; - location: { - type: string; - coordinates: [number, number]; - }; - largeCategory: FoodCategory; - mediumCategory: string; - majorityDistanceRange: DistanceRange; -} - -export interface VoteStatistics { - preferences: Record; - dislikes: Record; - agreementRate: number; -} - -export interface RecommendationResult { - topRecommendation: Restaurant; - otherCandidates: Restaurant[]; - preferences: Record; - dislikes: Record; - agreementRate: number; -} - +export type { CreateMeetingStep, CreateMeetingForm } from "./createMeetingForm"; +export type { OpinionForm, OpinionStep } from "./createOpinionForm"; +export type { DistanceRange } from "./distance"; +export type { FoodCategory } from "./foodCategory"; +export type { RankKey, PreferredMenu } from "./preferredMenu"; export type { - BaseStepProps, - IntroStepProps, - DistanceStepProps, - DislikeStepProps, - PreferenceStepProps, -} from "./stepComponents"; + VoteStatistics, + RecommendationResult, +} from "./recommendationResult"; +export type { Region } from "./region"; +export type { Restaurant } from "./restaurant"; +export type { TimeSlot } from "./timeSlot"; diff --git a/src/types/gathering/preferredMenu.ts b/src/types/gathering/preferredMenu.ts new file mode 100644 index 00000000..2c00c64f --- /dev/null +++ b/src/types/gathering/preferredMenu.ts @@ -0,0 +1,5 @@ +import { FoodCategory } from "./foodCategory"; + +export type RankKey = "first" | "second" | "third"; + +export type PreferredMenu = Partial>; diff --git a/src/types/gathering/recommendationResult.ts b/src/types/gathering/recommendationResult.ts new file mode 100644 index 00000000..a966f0e8 --- /dev/null +++ b/src/types/gathering/recommendationResult.ts @@ -0,0 +1,15 @@ +import type { Restaurant } from "./restaurant"; + +export interface VoteStatistics { + preferences: Record; + dislikes: Record; + agreementRate: number; +} + +export interface RecommendationResult { + topRecommendation: Restaurant; + otherCandidates: Restaurant[]; + preferences: Record; + dislikes: Record; + agreementRate: number; +} diff --git a/src/types/gathering/region.ts b/src/types/gathering/region.ts new file mode 100644 index 00000000..2b6695b6 --- /dev/null +++ b/src/types/gathering/region.ts @@ -0,0 +1 @@ +export type Region = "HONGDAE" | "GANGNAM" | "GONGDEOK"; diff --git a/src/types/gathering/restaurant.ts b/src/types/gathering/restaurant.ts new file mode 100644 index 00000000..005818d1 --- /dev/null +++ b/src/types/gathering/restaurant.ts @@ -0,0 +1,22 @@ +import { DistanceRange } from "#/constants/gathering/opinion"; +import { FoodCategory } from "./foodCategory"; + +export interface Restaurant { + rank: number; + restaurantId: number; + restaurantName: string; + address: string; + rating: number; + imageUrl: string | null; + mapUrl: string; + representativeReview: string; + description: string; + region: string | null; + location: { + type: string; + coordinates: [number, number]; + }; + largeCategory: FoodCategory; + mediumCategory: string; + majorityDistanceRange: DistanceRange; +} diff --git a/src/types/gathering/stepComponents.ts b/src/types/gathering/stepComponents.ts index f1136cba..e69de29b 100644 --- a/src/types/gathering/stepComponents.ts +++ b/src/types/gathering/stepComponents.ts @@ -1,46 +0,0 @@ -import type { OpinionStep, MeetingContext } from "."; - -export interface BaseStepProps { - meetingContext: MeetingContext; -} - -export type IntroStepProps = BaseStepProps & { - step: "intro"; - onNext: () => void; -}; - -export type DistanceStepProps = BaseStepProps & { - step: "distance"; - onNext: () => void; -}; - -export type DislikeStepProps = BaseStepProps & { - step: "dislike"; - onNext: () => void; -}; - -export type PreferenceStepProps = BaseStepProps & { - step: "preference"; - onComplete: () => void; -}; - -export type StepComponentProps = - | IntroStepProps - | DistanceStepProps - | DislikeStepProps - | PreferenceStepProps; - -export interface StepRendererProps { - step: OpinionStep; - meetingContext: MeetingContext; - onNext: () => void; - onComplete: () => void; -} - -export interface StepLayoutProps { - step: OpinionStep; - children: React.ReactNode; - onBackward: () => void; - isFirstStep: boolean; - footerContent?: React.ReactNode; -} diff --git a/src/types/gathering/timeSlot.ts b/src/types/gathering/timeSlot.ts new file mode 100644 index 00000000..ffa02512 --- /dev/null +++ b/src/types/gathering/timeSlot.ts @@ -0,0 +1 @@ +export type TimeSlot = "LUNCH" | "DINNER";