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
151 changes: 151 additions & 0 deletions src/components/illustrations/ResultGeneratingIllustration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"use client";

import { motion } from "motion/react";

export const ResultGeneratingIllustration = () => {
return (
<div className="ygi:flex ygi:h-64.5 ygi:w-full ygi:items-center ygi:justify-center">
<svg
width="375"
height="258"
viewBox="0 0 375 258"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Shadow */}
<ellipse
cx="187.5"
cy="179.154"
rx="58.5859"
ry="25.1099"
fill="url(#paint0_linear_1865_35373)"
/>

{/* Bowl Bottom */}
<path
d="M256.889 93.3672C256.465 101.288 252.257 108.624 245.374 114.819C232.954 125.995 211.717 133.388 187.5 133.388C163.283 133.388 142.046 125.995 129.626 114.819C122.747 108.624 118.535 101.288 118.111 93.3672C118.042 93.1832 118 93.0699 118 93.0699V111.365C118 150.787 149.114 182.749 187.5 182.749C225.886 182.749 257 150.791 257 111.365V93.0699C257 93.0699 256.958 93.1879 256.889 93.3672Z"
fill="url(#paint1_linear_1865_35373)"
/>

{/* Bowl Top - Inner */}
<path
d="M187.5 56.4092C206.312 56.4092 223.212 60.7837 235.315 67.7188C247.493 74.6962 254.327 83.9303 254.327 93.5566C254.327 103.183 247.493 112.418 235.315 119.396C223.212 126.331 206.312 130.705 187.5 130.705C168.688 130.705 151.788 126.331 139.685 119.396C127.507 112.418 120.673 103.183 120.673 93.5566C120.673 83.9303 127.507 74.6962 139.685 67.7188C151.788 60.7837 168.688 56.4092 187.5 56.4092Z"
fill="url(#paint2_linear_1865_35373)"
stroke="#7FC3FF"
strokeWidth="5.34615"
strokeLinecap="round"
/>

{/* Bowl Top - Outer */}
<path
d="M187.5 56.4092C206.312 56.4092 223.212 60.7837 235.315 67.7188C247.493 74.6962 254.327 83.9303 254.327 93.5566C254.327 103.183 247.493 112.418 235.315 119.396C223.212 126.331 206.312 130.705 187.5 130.705C168.688 130.705 151.788 126.331 139.685 119.396C127.507 112.418 120.673 103.183 120.673 93.5566C120.673 83.9303 127.507 74.6962 139.685 67.7188C151.788 60.7837 168.688 56.4092 187.5 56.4092Z"
stroke="#ADD9FF"
strokeWidth="5.34615"
strokeLinecap="round"
/>

{/* Left Chopstick */}
<motion.g
style={{ originX: "162px", originY: "186px" }}
animate={{
rotate: [0, -18, 0],
}}
transition={{
duration: 0.4,
repeat: Infinity,
ease: "easeInOut",
}}
>
<path
d="M177.478 87.8644C178.537 88.1337 179.184 89.2029 178.931 90.2658L155.766 187.59L148.29 185.688L174.223 89.073C174.507 88.0161 175.587 87.3834 176.648 87.6532L177.478 87.8644Z"
fill="url(#paint3_linear_1865_35373)"
/>
<path
d="M153.921 195.343C153.663 196.43 152.565 197.094 151.483 196.818L147.662 195.847C146.581 195.572 145.934 194.467 146.223 193.39L148.291 185.688L155.767 187.59L153.921 195.343Z"
fill="#1F2933"
/>
</motion.g>

{/* Right Chopstick */}
<motion.g
style={{ originX: "219px", originY: "202px" }}
animate={{
rotate: [0, 18, 0],
}}
transition={{
duration: 0.4,
repeat: Infinity,
ease: "easeInOut",
}}
>
<path
d="M200.325 103.101C199.252 103.305 198.54 104.333 198.728 105.409L215.876 203.971L223.455 202.532L203.5 104.507C203.281 103.435 202.242 102.737 201.167 102.941L200.325 103.101Z"
fill="url(#paint4_linear_1865_35373)"
/>
<path
d="M217.242 211.823C217.433 212.923 218.489 213.654 219.586 213.445L223.459 212.71C224.554 212.502 225.268 211.438 225.045 210.346L223.455 202.532L215.876 203.971L217.242 211.823Z"
fill="#1F2933"
/>
</motion.g>

<defs>
<linearGradient
id="paint0_linear_1865_35373"
x1="188"
y1="171.5"
x2="187.5"
y2="204.264"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E5E7EB" />
<stop offset="1" stopColor="#F3F4F6" />
</linearGradient>
<linearGradient
id="paint1_linear_1865_35373"
x1="187.5"
y1="93.0699"
x2="187.5"
y2="182.749"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#66B9FF" />
<stop offset="1" stopColor="#ADD9FF" />
</linearGradient>
<linearGradient
id="paint2_linear_1865_35373"
x1="187.5"
y1="53.7363"
x2="187.5"
y2="133.378"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#7FC3FF" />
<stop offset="1" stopColor="#53B7FF" />
</linearGradient>
<linearGradient
id="paint3_linear_1865_35373"
x1="177.06"
y1="87.7582"
x2="149.452"
y2="196.302"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF5A3C" />
<stop offset="1" stopColor="#FF7F6B" />
</linearGradient>
<linearGradient
id="paint4_linear_1865_35373"
x1="200.748"
y1="103.021"
x2="221.644"
y2="213.054"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FF5A3C" />
<stop offset="1" stopColor="#FF7F6B" />
</linearGradient>
Comment on lines +21 to +146
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

SVG 내부에 fill, stroke, stopColor 등으로 색상이 하드코딩되어 있습니다. 스타일 가이드(라인 315-317, 489)에서는 하드코딩된 색상 값 대신 디자인 토큰 사용을 권장합니다.

SVG를 컴포넌트로 다루고 있으므로, 색상 값을 props로 받거나 CSS 변수를 활용하여 동적으로 주입하는 방식을 고려해볼 수 있습니다. 예를 들어, fill="var(--color-primary)"와 같이 사용할 수 있습니다. 이렇게 하면 디자인 시스템과의 일관성을 유지하고 향후 색상 변경에 유연하게 대처할 수 있습니다.

References
  1. 스타일링 시 하드코딩된 값을 사용하는 대신 항상 디자인 토큰을 사용해야 합니다. (link)

</defs>
</svg>
</div>
);
};
1 change: 1 addition & 0 deletions src/components/illustrations/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { MeetingCompleteIllustration } from "./MeetingCompleteIllustration";
export { NotFoundIllustration } from "./NotFoundIllustration";
export { ResultGeneratingIllustration } from "./ResultGeneratingIllustration";
184 changes: 93 additions & 91 deletions src/hooks/gathering/useProceedRecommendResult.ts
Original file line number Diff line number Diff line change
@@ -1,129 +1,131 @@
"use client";

import { useState, useEffect, useRef } from "react";
import { useState, useRef, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "#/utils/toast";

import { RecommendationResultStatus } from "#/constants/gathering/opinion";
import {
usePostProceedRecommendResult,
useGetRecommendResult,
} from "#/hooks/apis";
import {
getRecommendResult,
recommendResultKeys,
} from "#/apis/recommendResult";
import { ERROR_CODES, isApiError } from "#/utils/api";
import { RecommendationResultStatus } from "#/constants/gathering/opinion";
import { isApiError } from "#/utils/api";
import { toast } from "#/utils/toast";

const NAVIGATION_DELAY = 2_500;

const PROCEED_STATUS = {
IDLE: "idle",
PROCESSING: "processing",
} as const;

type ProceedState =
| { status: typeof PROCEED_STATUS.IDLE }
| { status: typeof PROCEED_STATUS.PROCESSING; startTime: number };

export const useProceedRecommendResult = () => {
const router = useRouter();
const queryClient = useQueryClient();
const { accessKey } = useParams<{ accessKey: string }>();

const [manualPollingTrigger, setManualPollingTrigger] = useState(false);
const [proceedState, setProceedState] = useState<ProceedState>({
status: PROCEED_STATUS.IDLE,
});

const { data: recommendResult } = useGetRecommendResult(accessKey);

const { mutateAsync: proceedMutation, isPending: isMutating } =
const { refetch: fetchRecommendResult } = useGetRecommendResult(accessKey);
const { mutateAsync: proceedMutation } =
usePostProceedRecommendResult(accessKey);

const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isPollingRef = useRef(false);

// NOTE : 현재 추천 결과가 생성 중이거나 사용자가 명시적으로 생성을 시도한 경우 Polling 을 시작함
const shouldPoll =
recommendResult.status === RecommendationResultStatus.PENDING ||
manualPollingTrigger;
const isProcessingRef = useRef(false);
const isPending = proceedState.status === PROCEED_STATUS.PROCESSING;

useEffect(() => {
if (!shouldPoll || isPollingRef.current) return;
const onResultComplete = useCallback(async () => {
if (!isProcessingRef.current) {
return;
}

isPollingRef.current = true;
const remaining = Math.max(0, NAVIGATION_DELAY);

const poll = async () => {
try {
const response = await getRecommendResult(accessKey);
const { status } = response.data;
setTimeout(async () => {
const { data: latestResult } = await fetchRecommendResult();

switch (status) {
case RecommendationResultStatus.COMPLETED:
isPollingRef.current = false;
setManualPollingTrigger(false);
queryClient.setQueryData(
recommendResultKeys.detail(accessKey),
response,
);
router.push(`/gathering/${accessKey}/opinion/result`);
break;

case RecommendationResultStatus.PENDING:
timeoutRef.current = setTimeout(() => {
poll();
}, 1000);
break;

case RecommendationResultStatus.FAILED:
default:
isPollingRef.current = false;
setManualPollingTrigger(false);
toast.warning(
"추천 결과 생성에 실패했습니다. 다시 시도해주세요.",
);
break;
}
} catch {
isPollingRef.current = false;
setManualPollingTrigger(false);
toast.warning("추천 결과 조회에 실패했습니다.");
if (latestResult?.status === RecommendationResultStatus.COMPLETED) {
router.push(`/gathering/${accessKey}/opinion/result`);
return;
}
};

poll();

return () => {
isPollingRef.current = false;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [shouldPoll, accessKey, router, queryClient]);
isProcessingRef.current = false;
setProceedState({ status: PROCEED_STATUS.IDLE });
toast.warning(
"추천 결과가 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.",
);
}, remaining);
}, [accessKey, router, fetchRecommendResult]);
Comment on lines +40 to +61
Copy link
Contributor

Choose a reason for hiding this comment

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

high

현재 onResultComplete 함수에서 NAVIGATION_DELAY를 그대로 사용하여 항상 2.5초를 기다리도록 되어 있습니다. 이로 인해 proceed 함수에서 기록한 startTime이 무시되고, 애니메이션이 시작된 시점부터 최소 2.5초를 보장하려는 의도와 다르게 동작할 수 있습니다.

useServerSentEvent 훅의 구현상 onResultComplete 콜백은 stale closure가 될 수 있으므로, startTimeuseRef로 관리하여 항상 최신 값을 참조하도록 수정하는 것이 좋습니다. proceed 함수 내에서 setProceedState를 호출할 때 startTimeRef.current도 함께 업데이트해야 합니다.

수정 제안:

  1. 훅 최상단에 const startTimeRef = useRef(0);를 추가합니다.
  2. proceed 함수 내에서 setProceedState를 호출하는 모든 위치에 startTimeRef.current = Date.now();을 추가합니다.
  3. 아래와 같이 onResultComplete 함수를 수정합니다.
 	const onResultComplete = useCallback(async () => {
 		if (!isProcessingRef.current) {
 			return;
 		}
 
 		const elapsedTime = Date.now() - startTimeRef.current;
 		const remaining = Math.max(0, NAVIGATION_DELAY - elapsedTime);
 
 		setTimeout(async () => {
 			const { data: latestResult } = await fetchRecommendResult();
 
 			if (latestResult?.status === RecommendationResultStatus.COMPLETED) {
 				router.push(`/gathering/${accessKey}/opinion/result`);
 				return;
 			}
 
 			isProcessingRef.current = false;
 			setProceedState({ status: PROCEED_STATUS.IDLE });
 			toast.warning(
 				"추천 결과가 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.",
 			);
 		}, remaining);
 	}, [accessKey, router, fetchRecommendResult]);


const proceed = async () => {
if (recommendResult.status === RecommendationResultStatus.COMPLETED) {
router.push(`/gathering/${accessKey}/opinion/result`);
return;
}

if (recommendResult.status === RecommendationResultStatus.PENDING) {
if (isProcessingRef.current) {
return;
}

try {
await proceedMutation(accessKey);
setManualPollingTrigger(true);
} catch (error) {
if (isApiError(error)) {
switch (error.errorCode) {
case ERROR_CODES.RECOMMEND_ALREADY_PROCEEDED:
setManualPollingTrigger(true);
return;

default:
toast.warning(error.message);
return;
const { data: latestResult } = await fetchRecommendResult();

if (!latestResult?.status) {
isProcessingRef.current = true;
const startTime = Date.now();
setProceedState({
status: PROCEED_STATUS.PROCESSING,
startTime,
});

await proceedMutation(accessKey);
return;
}

switch (latestResult.status) {
case RecommendationResultStatus.COMPLETED: {
isProcessingRef.current = true;
const startTime = Date.now();
setProceedState({
status: PROCEED_STATUS.PROCESSING,
startTime,
});

setTimeout(() => {
router.push(`/gathering/${accessKey}/opinion/result`);
}, NAVIGATION_DELAY);
return;
}

case RecommendationResultStatus.FAILED:
toast.warning(
"추천 결과 생성에 실패했습니다. 다시 시도해주세요.",
);
return;

case RecommendationResultStatus.PENDING: {
isProcessingRef.current = true;
const startTime = Date.now();
setProceedState({
status: PROCEED_STATUS.PROCESSING,
startTime,
});
return;
}
}
} catch (error) {
setProceedState({ status: PROCEED_STATUS.IDLE });
isProcessingRef.current = false;

toast.warning(
"추천 결과 생성 요청에 실패했습니다. 다시 시도해주세요.",
);
const errorMessage = isApiError(error)
? error.message
: "추천 결과 생성 요청에 실패했습니다. 다시 시도해주세요.";

toast.warning(errorMessage);
}
};

const isPending = isMutating || shouldPoll;

return {
proceed,
isPending,
onResultComplete,
};
};
Loading