Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/apis/recommendResult/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
type GetRecommendResultResponse,
PostProcessRecommendResultRequest,
PostProcessRecommendResultResponse,
type RerollRecommendResultRequest,
type RerollRecommendResultResponse,
} from "./type";

/**
Expand All @@ -24,3 +26,12 @@ export const postProcessRecommendResult = (accessKey: string) => {
accessKey,
});
};

export const postRerollRecommendResult = (
request: RerollRecommendResultRequest,
) => {
return apiClient.post<
RerollRecommendResultResponse,
RerollRecommendResultRequest
>(`recommend-results/reroll`, request);
};
6 changes: 5 additions & 1 deletion src/apis/recommendResult/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ export { recommendResultKeys } from "./queryKey";
export { recommendResultOptions } from "./queryOption";

// Types
export type { GetRecommendResultResponse } from "./type";
export type {
GetRecommendResultResponse,
RerollRecommendResultRequest,
RerollRecommendResultResponse,
} from "./type";
2 changes: 2 additions & 0 deletions src/apis/recommendResult/mutationKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export const recommendResultMutationKeys = {
all: ["recommendResult"],
proceed: (accessKey: string) =>
[...recommendResultMutationKeys.all, "proceed", accessKey] as const,
reroll: (accessKey: string) =>
[...recommendResultMutationKeys.all, "reroll", accessKey] as const,
};
7 changes: 6 additions & 1 deletion src/apis/recommendResult/mutationOptions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mutationOptions } from "@tanstack/react-query";

import { postProcessRecommendResult } from "./api";
import { postProcessRecommendResult, postRerollRecommendResult } from "./api";
import { recommendResultMutationKeys } from "./mutationKey";

/**
Expand All @@ -12,4 +12,9 @@ export const recommendResultMutationOptions = {
mutationKey: recommendResultMutationKeys.proceed(accessKey),
mutationFn: postProcessRecommendResult,
}),
reroll: (accessKey: string) =>
mutationOptions({
mutationKey: recommendResultMutationKeys.reroll(accessKey),
mutationFn: postRerollRecommendResult,
}),
};
11 changes: 10 additions & 1 deletion src/apis/recommendResult/type.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RecommendationResult } from "#/types/gathering";
import type { RecommendationResult, Restaurant } from "#/types/gathering";

/** 추천 결과 조회 응답 */
export type GetRecommendResultResponse = RecommendationResult;
Expand All @@ -8,3 +8,12 @@ export type PostProcessRecommendResultRequest = {
};

export type PostProcessRecommendResultResponse = boolean;

export type RerollRecommendResultRequest = {
accessKey: string;
restaurantIds: number[];
};

export type RerollRecommendResultResponse = {
list: Restaurant[];
};
1 change: 1 addition & 0 deletions src/hooks/apis/recommendResult/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { useGetRecommendResult } from "./useGetRecommendResult";
export { usePostProceedRecommendResult } from "./usePostProceedRecommendResult";
export { useRerollRecommendResult } from "./useRerollRecommendResult";
9 changes: 9 additions & 0 deletions src/hooks/apis/recommendResult/useRerollRecommendResult.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"use client";

import { useMutation } from "@tanstack/react-query";

import { recommendResultMutationOptions } from "#/apis/recommendResult";

export const useRerollRecommendResult = (accessKey: string) => {
return useMutation(recommendResultMutationOptions.reroll(accessKey));
};
1 change: 1 addition & 0 deletions src/hooks/gathering/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { useOpinionForm } from "./useOpinionForm";
export { useOpinionFunnel } from "./useOpinionFunnel";
export { useProceedRecommendResult } from "./useProceedRecommendResult";
export { useRandomNickname } from "./useRandomNickname";
export { useRerollRestaurants } from "./useRerollRestaurants";
59 changes: 59 additions & 0 deletions src/hooks/gathering/useRerollRestaurants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client";

import { useMutationState } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";

import { recommendResultMutationKeys } from "#/apis/recommendResult";
import type { RerollRecommendResultResponse } from "#/apis/recommendResult";
import { useRerollRecommendResult } from "#/hooks/apis/recommendResult";
import type { Restaurant } from "#/types/gathering";
import type { ApiResponse } from "#/utils/api/types";

interface UseRerollRestaurantsProps {
accessKey: string;
initialList: Restaurant[];
maxRerollCount: number;
}

export const useRerollRestaurants = ({
accessKey,
initialList,
maxRerollCount,
}: UseRerollRestaurantsProps) => {
const { mutate, isPending } = useRerollRecommendResult(accessKey);

// 성공한 reroll 결과 이력을 TanStack Query 캐시에서 직접 읽음
// → isMaxReached 가 isPending 과 동일 렌더 사이클에서 업데이트되어 race condition 없음
const successfulRerolls = useMutationState({
filters: {
mutationKey: recommendResultMutationKeys.reroll(accessKey),
status: "success",
},
select: (mutation) =>
mutation.state.data as ApiResponse<RerollRecommendResultResponse>,
});

const isMaxReached = successfulRerolls.length >= maxRerollCount;

const displayList = useMemo(
() => [
...initialList,
...successfulRerolls.flatMap((result) => result.data.list),
],
[initialList, successfulRerolls],
);

const handleReroll = useCallback(() => {
if (isMaxReached || isPending) return;

const excludedIds = displayList.map((r) => r.restaurantId);
mutate({ accessKey, restaurantIds: excludedIds });
}, [accessKey, displayList, isMaxReached, isPending, mutate]);

return {
displayList,
isMaxReached,
isPending,
handleReroll,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,19 @@ import type { Restaurant } from "#/types/gathering";
export interface OtherCandidateCardProps {
restaurant: Restaurant;
ranking: number;
rankType: "top" | "other";
}

export const OtherCandidateCard = ({
restaurant,
ranking,
rankType,
}: OtherCandidateCardProps) => {
const handleMapClick = () => {
trackRestaurantClick({
page_id: "추천_결과",
restaurant_name: restaurant.restaurantName,
rank_type: "other",
rank_type: rankType,
});
window.open(restaurant.mapUrl, "_blank", "noopener,noreferrer");
};
Expand Down
89 changes: 55 additions & 34 deletions src/pageComponents/gathering/opinion/result/ResultPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,21 @@
import { format, parse } from "date-fns";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import { twJoin } from "tailwind-merge";

import { trackShareClick, trackViewPage } from "#/components/analytics";
import {
trackCtaClick,
trackShareClick,
trackViewPage,
} from "#/components/analytics";
import { BackwardButton } from "#/components/backwardButton";
import { Layout } from "#/components/layout";
import { ShareButton } from "#/components/shareButton";
import { Toaster } from "#/components/toast";
import { REGION_LABEL, TIME_SLOT_LABEL } from "#/constants/gathering/opinion";
import { useGetRecommendResult } from "#/hooks/apis/recommendResult";

import { OtherCandidateCard } from "./OtherCandidateCard";
import { RecommendedRestaurantSection } from "./recommendedRestaurantSection";
import { TasteSummaryCard } from "./TasteSummaryCard";
import { VoteSummarySection } from "./VoteSummarySection";

Expand All @@ -32,6 +37,12 @@ export function ResultPage() {
const { accessKey } = useParams<{ accessKey: string }>();
const { data: recommendationResult } = useGetRecommendResult(accessKey);

// TODO: Top Recommendation 맛집과 Other Candidates 맛집 View 분리가 통합되면서 구분이 필요없어짐 -> API Response 도 하나의 필드로 구성되도 좋을 듯함.
const initialRestaurantList = [
recommendationResult.topRecommendation,
...recommendationResult.otherCandidates,
];

const handleClickBackward = () => {
router.push(`/gathering/${accessKey}/opinion/complete`);
};
Expand All @@ -40,6 +51,14 @@ export function ResultPage() {
trackShareClick({ page_id: PAGE_ID, share_location: "Footer" });
};

const handleRecreateLink = () => {
trackCtaClick({
page_id: PAGE_ID,
button_name: "모임 링크 다시 만들기",
});
router.push("/gathering/create");
};

useEffect(() => {
if (recommendationResult && accessKey) {
trackViewPage({
Expand All @@ -56,7 +75,7 @@ export function ResultPage() {
</Layout.Header>

<Layout.Content background="gray">
<div className="ygi:flex ygi:flex-col ygi:gap-7 ygi:px-6 ygi:pb-8">
<div className="ygi:flex ygi:flex-col ygi:gap-7 ygi:px-6 ygi:pb-20">
{/* Head Section */}
<div className="ygi:flex ygi:flex-col ygi:gap-2 ygi:pt-3">
<span className="ygi:body-16-md ygi:text-text-secondary">
Expand Down Expand Up @@ -87,32 +106,11 @@ export function ResultPage() {
/>

{/* Restaurant List Section */}
<section className="ygi:flex ygi:flex-col ygi:gap-3">
<h2 className="ygi:heading-22-bd ygi:text-text-primary">
약속 장소는 여기 어때요?
</h2>
<div className="ygi:space-y-4 ygi:rounded-md ygi:bg-surface-white ygi:p-4">
<p className="ygi:body-16-bd ygi:text-text-primary">
요기잇 추천 맛집
</p>
<div className="ygi:flex ygi:flex-col ygi:gap-4 ygi:divide-y ygi:divide-dashed ygi:divide-border-default">
{[
recommendationResult.topRecommendation,
...recommendationResult.otherCandidates,
].map((restaurant, index) => (
<div
key={restaurant.restaurantId}
className="ygi:not-last:pb-4"
>
<OtherCandidateCard
restaurant={restaurant}
ranking={index + 1}
/>
</div>
))}
</div>
</div>
</section>
<RecommendedRestaurantSection
accessKey={accessKey}
initialList={initialRestaurantList}
/>

<VoteSummarySection
preferences={recommendationResult.preferences}
dislikes={recommendationResult.dislikes}
Expand All @@ -121,13 +119,36 @@ export function ResultPage() {
</div>
</Layout.Content>

<Layout.Footer background="gray">
<div className="ygi:px-6">
<ShareButton onShare={handleShare} />
<footer
className={twJoin(
"ygi:fixed ygi:bottom-0 ygi:left-0 ygi:z-layout-footer",
"ygi:flex ygi:w-full ygi:items-center ygi:justify-center",
)}
>
<div
className={twJoin(
"ygi:w-full ygi:max-w-root-layout ygi:bg-bg-gray",
"ygi:pb-[env(safe-area-inset-bottom)]",
)}
>
<div className="ygi:flex ygi:flex-col ygi:gap-1 ygi:px-6 ygi:py-4">
<ShareButton onShare={handleShare} />
<button
type="button"
onClick={handleRecreateLink}
className={twJoin(
"ygi:flex ygi:h-14 ygi:items-center ygi:justify-center",
"ygi:body-16-md ygi:text-text-secondary ygi:underline",
"ygi:cursor-pointer ygi:bg-transparent",
)}
>
모임 링크 다시 만들기
</button>
</div>
</div>
</Layout.Footer>
</footer>

<Toaster offset={{ bottom: 96 }} mobileOffset={{ bottom: 96 }} />
<Toaster offset={{ bottom: 148 }} mobileOffset={{ bottom: 148 }} />
</Layout.Root>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { useRerollRestaurants } from "#/hooks/gathering";
import type { Restaurant } from "#/types/gathering";

import { RerollButton } from "./RerollButton";
import { RestaurantList } from "./RestaurantList";

interface RecommendedRestaurantSectionProps {
accessKey: string;
initialList: Restaurant[];
}

export const RecommendedRestaurantSection = ({
accessKey,
initialList,
}: RecommendedRestaurantSectionProps) => {
const { displayList, isPending, isMaxReached, handleReroll } =
useRerollRestaurants({ accessKey, initialList, maxRerollCount: 2 });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

maxRerollCount 값으로 하드코딩된 숫자 2를 사용하고 있습니다. 이 값을 MAX_REROLL_COUNT와 같은 이름의 상수로 정의하여 사용하면 코드의 가독성과 유지보수성을 높일 수 있습니다. 관련 상수를 모아두는 파일(예: #/constants/gathering.ts)에 정의하는 것을 권장합니다.

Suggested change
useRerollRestaurants({ accessKey, initialList, maxRerollCount: 2 });
useRerollRestaurants({ accessKey, initialList, maxRerollCount: MAX_REROLL_COUNT });
References
  1. 하드코딩된 값(매직 넘버) 대신 명명된 상수를 사용하면 코드의 의도를 명확히 하고, 향후 값이 변경될 때 한 곳에서만 수정하면 되므로 유지보수성이 향상됩니다. 이는 'self-documenting code' 작성에 도움이 됩니다.


return (
<section className="ygi:flex ygi:flex-col ygi:gap-3">
<h2 className="ygi:heading-22-bd ygi:text-text-primary">
약속 장소는 여기 어때요?
</h2>

<div className="ygi:space-y-4 ygi:rounded-md ygi:bg-surface-white ygi:p-4">
<p className="ygi:body-16-bd ygi:text-text-primary">
요기잇 추천 맛집
</p>
<RestaurantList restaurants={displayList} />
</div>

<RerollButton
isMaxReached={isMaxReached}
isPending={isPending}
onReroll={handleReroll}
/>
</section>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { Button } from "#/components/button";
import { Spinner } from "#/components/spinner";

interface RerollButtonProps {
isMaxReached: boolean;
isPending: boolean;
onReroll: () => void;
}

export const RerollButton = ({
isMaxReached,
isPending,
onReroll,
}: RerollButtonProps) => {
return (
<Button
variant={isMaxReached ? "tertiary" : "secondary"}
width="full"
disabled={isMaxReached || isPending}
onClick={onReroll}
>
{isPending ? (
<Spinner size="small" />
) : isMaxReached ? (
"맛집 더 보기 횟수를 모두 사용했어요"
) : (
"다른 맛집 더 보기"
)}
</Button>
);
};
Loading
Loading