From ed7dda5e709660e77889368a29713816bfa5776e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=8A=B9=EC=99=84?= Date: Tue, 9 Dec 2025 08:47:12 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A6=AC=EC=9B=8C=EB=93=9C=20=EA=B4=91=EA=B3=A0=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B5=AC=ED=98=84=20(1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useDeleteInternshipReview.tsx | 23 +++++- .../review/hooks/useDeleteWorkReview.tsx | 22 ++++- src/shared/hooks/useFlutterCommunication.ts | 80 +++++++++++++++++++ src/shared/utils/webViewCommunication.ts | 33 ++++++++ .../my-post-list/ui/MyPostItem.tsx | 6 +- 5 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/entities/review/hooks/useDeleteInternshipReview.tsx b/src/entities/review/hooks/useDeleteInternshipReview.tsx index f6b5c8d..36d6f3f 100644 --- a/src/entities/review/hooks/useDeleteInternshipReview.tsx +++ b/src/entities/review/hooks/useDeleteInternshipReview.tsx @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/shared/hooks/useToast"; +import { useRewardAd } from "@/shared/hooks/useFlutterCommunication"; import { deleteInternshipReview } from "../api"; import { LikeResponse } from "../DTO.d"; @@ -12,6 +13,7 @@ import { LikeResponse } from "../DTO.d"; export const useDeleteInternshipReview = () => { const queryClient = useQueryClient(); const { toast } = useToast(); + const [showRewardAd] = useRewardAd(); return useMutation< LikeResponse, @@ -19,10 +21,27 @@ export const useDeleteInternshipReview = () => { { internshipReviewId: number; kindergartenId?: number; + skipAd?: boolean; // 광고 스킵 옵션 (테스트용) } >({ - mutationFn: ({ internshipReviewId }) => - deleteInternshipReview(internshipReviewId), + mutationFn: async ({ internshipReviewId, skipAd = false }) => { + // 광고를 스킵하지 않는 경우 보상형 광고 표시 + if (!skipAd) { + const adResult = await showRewardAd(); + + // 사용자가 광고를 중간에 닫은 경우에만 삭제 중단 + if (adResult.status === "cancelled") { + throw new Error("광고를 끝까지 시청해야 리뷰를 삭제할 수 있습니다."); + } + + // status가 success이지만 rewarded가 false인 경우 (광고 없음, 로드 실패 등) + // -> 사용자 책임이 아니므로 그냥 진행 + // rewarded가 true인 경우 -> 광고 시청 완료, 진행 + } + + // 광고 시청 완료 또는 광고 없음 -> 리뷰 삭제 + return deleteInternshipReview(internshipReviewId); + }, onSuccess: (_, variables) => { // 해당 유치원의 실습 리뷰 목록을 다시 불러오기 if (variables.kindergartenId) { diff --git a/src/entities/review/hooks/useDeleteWorkReview.tsx b/src/entities/review/hooks/useDeleteWorkReview.tsx index b4ff7d3..5c545cb 100644 --- a/src/entities/review/hooks/useDeleteWorkReview.tsx +++ b/src/entities/review/hooks/useDeleteWorkReview.tsx @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/shared/hooks/useToast"; +import { useRewardAd } from "@/shared/hooks/useFlutterCommunication"; import { deleteWorkReview } from "../api"; import { LikeResponse } from "../DTO.d"; @@ -8,6 +9,7 @@ import { LikeResponse } from "../DTO.d"; export const useDeleteWorkReview = () => { const queryClient = useQueryClient(); const { toast } = useToast(); + const [showRewardAd] = useRewardAd(); return useMutation< LikeResponse, @@ -15,9 +17,27 @@ export const useDeleteWorkReview = () => { { workReviewId: number; kindergartenId?: number; + skipAd?: boolean; // 광고 스킵 옵션 (테스트용) } >({ - mutationFn: ({ workReviewId }) => deleteWorkReview(workReviewId), + mutationFn: async ({ workReviewId, skipAd = false }) => { + // 광고를 스킵하지 않는 경우 보상형 광고 표시 + if (!skipAd) { + const adResult = await showRewardAd(); + + // 사용자가 광고를 중간에 닫은 경우에만 삭제 중단 + if (adResult.status === "cancelled") { + throw new Error("광고를 끝까지 시청해야 리뷰를 삭제할 수 있습니다."); + } + + // status가 success이지만 rewarded가 false인 경우 (광고 없음, 로드 실패 등) + // -> 사용자 책임이 아니므로 그냥 진행 + // rewarded가 true인 경우 -> 광고 시청 완료, 진행 + } + + // 광고 시청 완료 또는 광고 없음 -> 리뷰 삭제 + return deleteWorkReview(workReviewId); + }, onSuccess: (_, variables) => { // 해당 유치원의 근무 리뷰 목록을 다시 불러오기 if (variables.kindergartenId) { diff --git a/src/shared/hooks/useFlutterCommunication.ts b/src/shared/hooks/useFlutterCommunication.ts index 2f7071e..8175dcb 100644 --- a/src/shared/hooks/useFlutterCommunication.ts +++ b/src/shared/hooks/useFlutterCommunication.ts @@ -6,7 +6,9 @@ import { MessageType, PermissionResult, PermissionType, + RewardAdResult, requestKakaoShare, + requestRewardAd, sendToFlutter, } from "@/shared/utils/webViewCommunication"; @@ -244,3 +246,81 @@ export function useKakaoShare(): [ return [shareToKakao, isSharing, shareError]; } + +/** + * 보상형 광고 요청을 위한 훅 + * @returns [showRewardAd, isLoading, error] + * + * @example + * function DeleteReviewComponent() { + * const [showRewardAd, isLoading, error] = useRewardAd(); + * + * const handleDelete = async () => { + * const result = await showRewardAd(); + * + * if (result.status === 'success') { + * // 광고 시청 완료 - 리뷰 삭제 진행 + * await deleteReview(); + * } else if (result.status === 'cancelled') { + * // 사용자가 광고를 중단함 + * alert('광고를 끝까지 시청해야 삭제할 수 있습니다.'); + * } else { + * // 광고 로드 실패 + * alert('광고를 불러오는데 실패했습니다.'); + * } + * }; + * + * return ( + * + * ); + * } + */ +export function useRewardAd(): [ + () => Promise, + boolean, + string | null, +] { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const showRewardAd = useCallback(async (): Promise => { + setIsLoading(true); + setError(null); + + try { + // 앱 환경인지 확인 + if (!isFlutterWebView) { + console.warn("앱 환경이 아닙니다. 브라우저에서는 보상형 광고가 지원되지 않습니다."); + return { + status: "error", + rewarded: false, + message: "앱 환경에서만 지원되는 기능입니다.", + }; + } + + const result = await requestRewardAd(); + + if (result.status === "error") { + setError(result.message); + } + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "알 수 없는 오류"; + console.error("보상형 광고 요청 오류:", error); + setError(errorMessage); + return { + status: "error", + rewarded: false, + message: errorMessage, + }; + } finally { + setIsLoading(false); + } + }, []); + + return [showRewardAd, isLoading, error]; +} diff --git a/src/shared/utils/webViewCommunication.ts b/src/shared/utils/webViewCommunication.ts index 613fecb..88f8782 100644 --- a/src/shared/utils/webViewCommunication.ts +++ b/src/shared/utils/webViewCommunication.ts @@ -8,6 +8,7 @@ export enum MessageType { REQUEST_LAT_LONG = "REQUEST_LAT_LONG", REQUEST_PERMISSION = "REQUEST_PERMISSION", KAKAO_SHARE = "KAKAO_SHARE", + REQUEST_REWARD_AD = "REQUEST_REWARD_AD", // TODO : 테스트 메시지 추가 } @@ -194,3 +195,35 @@ export async function requestKakaoShare( }; } } + +// 보상형 광고 결과 인터페이스 +export interface RewardAdResult { + status: "success" | "error" | "cancelled"; + rewarded: boolean; // 실제로 광고를 시청하고 보상을 받았는지 여부 + message: string; + reward?: { + amount: number; + type: string; + }; +} + +/** + * 보상형 광고 요청 + * @returns 광고 시청 결과 + */ +export async function requestRewardAd(): Promise { + try { + const result = await sendToFlutter< + Record, + RewardAdResult + >(MessageType.REQUEST_REWARD_AD, {}); + return result; + } catch (error) { + console.error("보상형 광고 요청 오류:", error); + return { + status: "error", + rewarded: false, + message: error instanceof Error ? error.message : "알 수 없는 오류", + }; + } +} diff --git a/src/widgets/user-dashboard/my-post-list/ui/MyPostItem.tsx b/src/widgets/user-dashboard/my-post-list/ui/MyPostItem.tsx index cfeda35..ddac5ac 100644 --- a/src/widgets/user-dashboard/my-post-list/ui/MyPostItem.tsx +++ b/src/widgets/user-dashboard/my-post-list/ui/MyPostItem.tsx @@ -108,8 +108,10 @@ export default function MyPostItem({ data, index, style }: MyPostItemProps) { onConfirm={handleConfirmDelete} title="리뷰 삭제" > - 리뷰를 삭제하시겠습니까?
작성된 리뷰가 없으면,
다른 - 사용자의 리뷰를 볼 수 없습니다. + 리뷰를 삭제하시겠습니까?
+ 광고 시청 후 삭제가 진행됩니다.
+
+ 작성된 리뷰가 없으면,
다른 사용자의 리뷰를 볼 수 없습니다. ); From 1b6ae4183d5b173c915af6f10abfe6732f9b72ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=8A=B9=EC=99=84?= Date: Thu, 11 Dec 2025 13:22:51 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A6=AC=EC=9B=8C=EB=93=9C=20=EA=B4=91=EA=B3=A0=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B5=AC=ED=98=84=20(2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/hooks/useFlutterCommunication.ts | 10 ++++++---- src/shared/utils/webViewCommunication.ts | 21 ++++++++++++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/shared/hooks/useFlutterCommunication.ts b/src/shared/hooks/useFlutterCommunication.ts index 8175dcb..84a4ad8 100644 --- a/src/shared/hooks/useFlutterCommunication.ts +++ b/src/shared/hooks/useFlutterCommunication.ts @@ -293,10 +293,11 @@ export function useRewardAd(): [ // 앱 환경인지 확인 if (!isFlutterWebView) { console.warn("앱 환경이 아닙니다. 브라우저에서는 보상형 광고가 지원되지 않습니다."); + // 브라우저 환경에서는 광고 없이 진행 return { - status: "error", + status: "success", rewarded: false, - message: "앱 환경에서만 지원되는 기능입니다.", + message: "브라우저 환경에서는 광고가 지원되지 않습니다.", }; } @@ -312,10 +313,11 @@ export function useRewardAd(): [ error instanceof Error ? error.message : "알 수 없는 오류"; console.error("보상형 광고 요청 오류:", error); setError(errorMessage); + // 에러 발생 시에도 삭제 허용 (구버전 앱 대응) return { - status: "error", + status: "success", rewarded: false, - message: errorMessage, + message: "광고 기능을 사용할 수 없습니다.", }; } finally { setIsLoading(false); diff --git a/src/shared/utils/webViewCommunication.ts b/src/shared/utils/webViewCommunication.ts index 88f8782..7024f27 100644 --- a/src/shared/utils/webViewCommunication.ts +++ b/src/shared/utils/webViewCommunication.ts @@ -213,17 +213,32 @@ export interface RewardAdResult { */ export async function requestRewardAd(): Promise { try { - const result = await sendToFlutter< + // 타임아웃 설정 (5초) - 구버전 앱에서 응답이 없을 경우 대비 + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + console.warn("보상형 광고 응답 타임아웃 - 구버전 앱으로 판단하여 광고 없이 진행"); + resolve({ + status: "success", + rewarded: false, + message: "광고 기능을 지원하지 않는 버전입니다.", + }); + }, 5000); + }); + + const adPromise = sendToFlutter< Record, RewardAdResult >(MessageType.REQUEST_REWARD_AD, {}); + + const result = await Promise.race([adPromise, timeoutPromise]); return result; } catch (error) { console.error("보상형 광고 요청 오류:", error); + // 에러 발생 시에도 삭제 허용 (구버전 앱 대응) return { - status: "error", + status: "success", rewarded: false, - message: error instanceof Error ? error.message : "알 수 없는 오류", + message: "광고 기능을 사용할 수 없습니다.", }; } } From a6d59f7b309e4c133d6671c153e2b1c2631a81e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EC=8A=B9=EC=99=84?= Date: Wed, 24 Dec 2025 16:40:38 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=EB=A6=AC=EB=B7=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A6=AC=EC=9B=8C=EB=93=9C=20=EA=B4=91=EA=B3=A0=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EA=B5=AC=ED=98=84=20(3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/utils/webViewCommunication.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/shared/utils/webViewCommunication.ts b/src/shared/utils/webViewCommunication.ts index 7024f27..0af021c 100644 --- a/src/shared/utils/webViewCommunication.ts +++ b/src/shared/utils/webViewCommunication.ts @@ -213,16 +213,16 @@ export interface RewardAdResult { */ export async function requestRewardAd(): Promise { try { - // 타임아웃 설정 (5초) - 구버전 앱에서 응답이 없을 경우 대비 + // 타임아웃 설정 (30초) - 광고 로드 및 시청 시간 고려 const timeoutPromise = new Promise((resolve) => { setTimeout(() => { - console.warn("보상형 광고 응답 타임아웃 - 구버전 앱으로 판단하여 광고 없이 진행"); + console.warn("보상형 광고 응답 타임아웃"); resolve({ - status: "success", + status: "error", rewarded: false, - message: "광고 기능을 지원하지 않는 버전입니다.", + message: "광고를 불러오는데 시간이 너무 오래 걸립니다.", }); - }, 5000); + }, 30000); }); const adPromise = sendToFlutter< @@ -231,6 +231,17 @@ export async function requestRewardAd(): Promise { >(MessageType.REQUEST_REWARD_AD, {}); const result = await Promise.race([adPromise, timeoutPromise]); + + // 구버전 앱의 default 응답 처리 (rewarded 필드가 없는 경우) + if (result.status === "success" && result.rewarded === undefined) { + console.warn("구버전 앱 감지 - 광고 기능이 없는 버전입니다."); + return { + status: "success", + rewarded: false, + message: "광고 기능을 지원하지 않는 버전입니다.", + }; + } + return result; } catch (error) { console.error("보상형 광고 요청 오류:", error); From 7789e7937a3a81fd94a893c12487470ca1eeb8fd Mon Sep 17 00:00:00 2001 From: 0zuth Date: Sat, 3 Jan 2026 00:39:00 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EB=82=99=EA=B4=80=EC=A0=81?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=9B=85=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20#47?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로컬 상태와 서버 응답 자동 동기화 - 호출 에러 시 롤백 함수 사용 --- .../community/hooks/useToggleLike.tsx | 3 +- src/shared/hooks/useOptimisticUpdate.ts | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 src/shared/hooks/useOptimisticUpdate.ts diff --git a/src/entities/community/hooks/useToggleLike.tsx b/src/entities/community/hooks/useToggleLike.tsx index d0be178..5be28e3 100644 --- a/src/entities/community/hooks/useToggleLike.tsx +++ b/src/entities/community/hooks/useToggleLike.tsx @@ -10,7 +10,8 @@ export const useToggleLike = () => { mutationFn: (postId) => toggleLike(postId), onError: () => { toast({ - title: "좋아요 오류", + title: "좋아요 실패", + description: "잠시 후 다시 시도해주세요.", variant: "destructive", }); }, diff --git a/src/shared/hooks/useOptimisticUpdate.ts b/src/shared/hooks/useOptimisticUpdate.ts new file mode 100644 index 0000000..eba30f2 --- /dev/null +++ b/src/shared/hooks/useOptimisticUpdate.ts @@ -0,0 +1,48 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +/** + * 로컬 상태를 즉시 업데이트하고, 서버 응답이 오면 동기화 + * + * 롤백 함수로 에러 발생 시 이전 상태 복구 + * + * @param initialValue - 초기값 + * @param serverValue - 서버에서 받아온 실제 값 + * @returns [value, setValue, rollback] + */ +export function useOptimisticUpdate( + initialValue: T, + serverValue?: T +): [T, Dispatch>, () => void] { + const [value, setValue] = useState(initialValue); + const previousValueRef = useRef(initialValue); + + // 값이 변경되기 전 이전 값 저장 + const setValueWithHistory = useCallback((action: SetStateAction) => { + setValue((prev) => { + previousValueRef.current = prev; + return typeof action === "function" + ? (action as (prev: T) => T)(prev) + : action; + }); + }, []); + + const rollback = useCallback(() => { + setValue(previousValueRef.current); + }, []); + + useEffect(() => { + if (serverValue !== undefined) { + previousValueRef.current = serverValue; + setValue(serverValue); + } + }, [serverValue]); + + return [value, setValueWithHistory, rollback]; +} From 31b93bc84543e688393a320f970f6f60ca896490 Mon Sep 17 00:00:00 2001 From: 0zuth Date: Sat, 3 Jan 2026 00:41:23 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EB=AF=B8?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=8B=9C=20=ED=91=9C=EC=8B=9C=EB=90=98?= =?UTF-8?q?=EB=8A=94=20'=EB=A6=AC=EB=B7=B0=EC=93=B0=EA=B8=B0'=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=AF=B8=EC=9E=91?= =?UTF-8?q?=EB=8F=99=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유저 role이 아닌, 작성된 리뷰 type에 따라 라우팅 처리 - 예비교사일 경우 '리뷰쓰기' 버튼 비활성화 --- src/entities/review/hooks/useReviewLike.tsx | 2 +- src/widgets/review-list/ui/ReviewCardList.tsx | 120 +++++++++--------- src/widgets/review-panel/index.tsx | 5 +- 3 files changed, 64 insertions(+), 63 deletions(-) diff --git a/src/entities/review/hooks/useReviewLike.tsx b/src/entities/review/hooks/useReviewLike.tsx index 3074627..fe789e2 100644 --- a/src/entities/review/hooks/useReviewLike.tsx +++ b/src/entities/review/hooks/useReviewLike.tsx @@ -8,7 +8,7 @@ export function useReviewLike(type: string, reviewId: number) { const queryKey = type === REVIEW_TYPES.WORK ? "workReviews" : "internshipReviews"; - const { mutate: handleLike, isPending } = useMutation({ + const { mutateAsync: handleLike, isPending } = useMutation({ mutationFn: () => { if (type === REVIEW_TYPES.WORK) { return likeWorkReview(reviewId); diff --git a/src/widgets/review-list/ui/ReviewCardList.tsx b/src/widgets/review-list/ui/ReviewCardList.tsx index 3885dc9..47b9ad6 100644 --- a/src/widgets/review-list/ui/ReviewCardList.tsx +++ b/src/widgets/review-list/ui/ReviewCardList.tsx @@ -1,9 +1,10 @@ import { useAtom } from "jotai"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { userAtom } from "@/entities/auth/model"; import { useReviewLike } from "@/entities/review/hooks"; +import { useOptimisticUpdate } from "@/shared/hooks/useOptimisticUpdate"; import Button from "@/shared/ui/buttons/base-button"; import ReviewReportDropDown from "@/shared/ui/drop-down/review-report-drop-down"; import { ShareType } from "@/shared/utils/webViewCommunication"; @@ -38,20 +39,17 @@ export function ReviewCardList({ return (
{reviews.map((item, index) => { - const isLastItem = index === reviews.length - 1; + const key = + "workReviewId" in item ? item.workReviewId : item.internshipReviewId; return ( ); })} @@ -61,6 +59,15 @@ export function ReviewCardList({ // ------------------------------------------------------------------------------ +interface ReviewCardProps { + review: ReviewData; + fieldConfigs: ReviewFieldConfig[]; + type: string; + showResource?: boolean; + limitItems?: number; + isLastItem?: boolean; +} + export function ReviewCard({ review, fieldConfigs, @@ -68,57 +75,61 @@ export function ReviewCard({ showResource = false, limitItems, isLastItem = false, -}: { - review: ReviewData; - fieldConfigs: ReviewFieldConfig[]; - type: string; - showResource?: boolean; - limitItems?: number; - isLastItem?: boolean; -}) { +}: ReviewCardProps) { const [isExpanded, setIsExpanded] = useState(false); const [user] = useAtom(userAtom); const navigate = useNavigate(); - // 좋아요 수 낙관적 업데이트를 위한 상태 - const [localLikeCount, setLocalLikeCount] = useState(review.likeCount || 0); - const [localIsLiked, setLocalIsLiked] = useState(false); + // 리뷰 기본 정보 + const reviewId = + "workReviewId" in review ? review.workReviewId : review.internshipReviewId; + const isContentBlocked = !user?.hasWrittenReview; - const { handleLike, isPending, isLiked } = useReviewLike( - type, - "workReviewId" in review ? review.workReviewId : review.internshipReviewId + // 좋아요 기능 + const { handleLike, isPending, isLiked } = useReviewLike(type, reviewId); + const [likeCount, setLikeCount, rollbackLikeCount] = useOptimisticUpdate( + review.likeCount || 0 + ); + const [localIsLiked, setLocalIsLiked, rollbackIsLiked] = useOptimisticUpdate( + false, + isLiked ); - useEffect(() => { - setLocalIsLiked(isLiked); - }, [isLiked]); + const isWriteButtonDisabled = + !review.kindergartenId || + !user?.role || + (user.role === "PROSPECTIVE_TEACHER" && type === "work"); - const handleOptimisticLike = () => { - const wasLiked = localIsLiked; + // ReviewContent 공통 props + const reviewContentProps = { + review, + type, + fieldConfigs, + isExpanded, + onToggleExpand: () => setIsExpanded(!isExpanded), + limitItems, + }; - setLocalIsLiked(!wasLiked); - setLocalLikeCount((prev) => (wasLiked ? prev - 1 : prev + 1)); + const handleOptimisticLike = async () => { + setLocalIsLiked((prev: boolean) => !prev); + setLikeCount((prev: number) => prev + (localIsLiked ? -1 : 1)); - handleLike(); + try { + await handleLike(); + } catch { + rollbackIsLiked(); + rollbackLikeCount(); + } }; const handleWriteReview = () => { - if (review.kindergartenId && user) { - if (user.role === "TEACHER") { - navigate(`/kindergarten/${review.kindergartenId}/review/new?type=work`); - } else if (user.role === "PROSPECTIVE_TEACHER") { - navigate( - `/kindergarten/${review.kindergartenId}/review/new?type=learning` - ); - } - } + if (!review.kindergartenId || !user?.role) return; + const reviewType = user.role === "PROSPECTIVE_TEACHER" ? "learning" : type; + navigate( + `/kindergarten/${review.kindergartenId}/review/new?type=${reviewType}` + ); }; - const isContentBlocked = !user?.hasWrittenReview; - - const reviewId = - "workReviewId" in review ? review.workReviewId : review.internshipReviewId; - return (
- setIsExpanded(!isExpanded)} - limitItems={limitItems} - /> +

@@ -172,6 +176,7 @@ export function ReviewCard({ size="md" font="sm_sb" onClick={handleWriteReview} + disabled={isWriteButtonDisabled} className="px-4 text-primary-normal02" > 리뷰쓰기 @@ -179,19 +184,12 @@ export function ReviewCard({

) : ( - setIsExpanded(!isExpanded)} - limitItems={limitItems} - /> + )}
(); const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const type = searchParams.get("type") || REVIEW_TYPES.WORK; + const sortType = (searchParams.get("sortType") as SortType) || SortType.LATEST; - const navigate = useNavigate(); const safeKindergartenId = kindergartenId || "unknown"; + const { data: kindergartenData } = useKindergartenName(safeKindergartenId); const { schoolOptions, fieldConfigs, reviewData, currentPath, isDisabled } = From 4e53d12b265dd7b3e189f52bff142a9a1aafc51a Mon Sep 17 00:00:00 2001 From: 0zuth Date: Sun, 11 Jan 2026 23:07:40 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EC=9C=A0=EC=B9=98=EC=9B=90=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=A6=AC=EB=B7=B0=20=EB=AC=B4=ED=95=9C?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=B6=94=EA=B0=80=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/review/api.ts | 36 ++++--- src/entities/review/hooks/useGetReview.tsx | 88 ++++++++++++++++- src/widgets/review-panel/index.tsx | 22 ++++- .../review-panel/lib/useReviewPage.tsx | 98 ++++++++++++++++++- 4 files changed, 220 insertions(+), 24 deletions(-) diff --git a/src/entities/review/api.ts b/src/entities/review/api.ts index 20bee4a..64c16e1 100644 --- a/src/entities/review/api.ts +++ b/src/entities/review/api.ts @@ -7,36 +7,48 @@ import { SortType } from "./DTO.d"; import type { InternshipReview, - InternshipReviewResponse, LikeResponse, PaginatedReviewResponse, ReviewQueryParams, WorkReview, - WorkReviewResponse, } from "./DTO.d"; // ------------------------------------------------------------------------------ export const getWorkReviews = async ( kindergartenId: number, - sortType?: SortType -) => { - const queryParams = sortType ? `?sortType=${sortType}` : ""; - return apiCall({ + params?: ReviewQueryParams +): Promise> => { + const { page = 0, size = 10, sortType = SortType.LATEST } = params || {}; + + const queryParams = new URLSearchParams({ + page: page.toString(), + size: size.toString(), + sortType: sortType.toString(), + }); + + return apiCall>({ method: "GET", - path: API_PATHS.WORK.GET(kindergartenId) + queryParams, + path: `${API_PATHS.WORK.GET(kindergartenId)}?${queryParams.toString()}`, withAuth: true, }); }; export const getInternshipReviews = async ( kindergartenId: number, - sortType?: SortType -) => { - const queryParams = sortType ? `?sortType=${sortType}` : ""; - return apiCall({ + params?: ReviewQueryParams +): Promise> => { + const { page = 0, size = 10, sortType = SortType.LATEST } = params || {}; + + const queryParams = new URLSearchParams({ + page: page.toString(), + size: size.toString(), + sortType: sortType.toString(), + }); + + return apiCall>({ method: "GET", - path: API_PATHS.INTERNSHIP.GET(kindergartenId) + queryParams, + path: `${API_PATHS.INTERNSHIP.GET(kindergartenId)}?${queryParams.toString()}`, withAuth: true, }); }; diff --git a/src/entities/review/hooks/useGetReview.tsx b/src/entities/review/hooks/useGetReview.tsx index de45031..0beb60a 100644 --- a/src/entities/review/hooks/useGetReview.tsx +++ b/src/entities/review/hooks/useGetReview.tsx @@ -1,5 +1,9 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { DYNAMIC_CACHE_CONFIG } from "@/shared/config/query"; import { REVIEW_TYPES } from "@/shared/constants/review"; import { safeParseId } from "@/shared/utils/idValidation"; @@ -39,7 +43,7 @@ export const useWorkReviews = (kindergartenId: string, sortType?: SortType) => { if (!numericId) { return Promise.resolve({ content: [], totalPages: 0 }); } - return getWorkReviews(numericId, sortType); + return getWorkReviews(numericId, { sortType }); }, }); }; @@ -62,7 +66,7 @@ export const useInternshipReviews = ( if (!numericId) { return Promise.resolve({ content: [], totalPages: 0 }); } - return getInternshipReviews(numericId, sortType); + return getInternshipReviews(numericId, { sortType }); }, }); }; @@ -87,7 +91,7 @@ export function useGetReview( if (!numericId) { return Promise.resolve({ content: [], totalPages: 0 }); } - return getWorkReviews(numericId, sortType); + return getWorkReviews(numericId, { sortType }); }, }); @@ -97,7 +101,7 @@ export function useGetReview( if (!numericId) { return Promise.resolve({ content: [], totalPages: 0 }); } - return getInternshipReviews(numericId, sortType); + return getInternshipReviews(numericId, { sortType }); }, }); @@ -167,3 +171,77 @@ export function useGetReview( scores, }; } + +// ------------------------------------------------------------------------------ + +/** + * 특정 유치원의 근무 리뷰 무한 스크롤 훅 + */ +export const useInfiniteWorkReviews = ( + kindergartenId: string, + sortType?: SortType, + pageSize: number = 10 +) => { + const numericId = safeParseId(kindergartenId); + + return useSuspenseInfiniteQuery({ + queryKey: ["workReviews", kindergartenId, sortType, "infinite", pageSize], + queryFn: ({ pageParam = 0 }) => { + if (!numericId) { + return Promise.resolve({ content: [], totalPages: 0 }); + } + return getWorkReviews(numericId, { + page: pageParam as number, + size: pageSize, + sortType, + }); + }, + getNextPageParam: (lastPage, allPages) => { + if (!lastPage?.content || lastPage.content.length === 0) return undefined; + const currentPage = allPages.length - 1; + const isLastPage = currentPage + 1 >= lastPage.totalPages; + return isLastPage ? undefined : currentPage + 1; + }, + initialPageParam: 0, + ...DYNAMIC_CACHE_CONFIG, + }); +}; + +/** + * 특정 유치원의 실습 리뷰 무한 스크롤 훅 + */ +export const useInfiniteInternshipReviews = ( + kindergartenId: string, + sortType?: SortType, + pageSize: number = 10 +) => { + const numericId = safeParseId(kindergartenId); + + return useSuspenseInfiniteQuery({ + queryKey: [ + "internshipReviews", + kindergartenId, + sortType, + "infinite", + pageSize, + ], + queryFn: ({ pageParam = 0 }) => { + if (!numericId) { + return Promise.resolve({ content: [], totalPages: 0 }); + } + return getInternshipReviews(numericId, { + page: pageParam as number, + size: pageSize, + sortType, + }); + }, + getNextPageParam: (lastPage, allPages) => { + if (!lastPage?.content || lastPage.content.length === 0) return undefined; + const currentPage = allPages.length - 1; + const isLastPage = currentPage + 1 >= lastPage.totalPages; + return isLastPage ? undefined : currentPage + 1; + }, + initialPageParam: 0, + ...DYNAMIC_CACHE_CONFIG, + }); +}; diff --git a/src/widgets/review-panel/index.tsx b/src/widgets/review-panel/index.tsx index 1b18c16..54964c5 100644 --- a/src/widgets/review-panel/index.tsx +++ b/src/widgets/review-panel/index.tsx @@ -3,8 +3,10 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useKindergartenName } from "@/entities/kindergarten/hooks"; import { SortType } from "@/entities/review/DTO.d"; import NavBar from "@/features/nav/ui/NavBar"; +import AutoFetchSentinel from "@/shared/components/AutoFetchSentinel"; import { REVIEW_TYPES } from "@/shared/constants/review"; import PostButton from "@/shared/ui/buttons/post-button"; +import LoadingSpinner from "@/shared/ui/loading/loading-spinner"; import ReviewList from "@/widgets/review-list"; import { useReviewPage } from "@/widgets/review-panel/lib/useReviewPage"; import TotalRatingSection from "@/widgets/review-panel/ui/TotalRatingSection"; @@ -25,8 +27,16 @@ export default function ReviewPanel() { const { data: kindergartenData } = useKindergartenName(safeKindergartenId); - const { schoolOptions, fieldConfigs, reviewData, currentPath, isDisabled } = - useReviewPage(safeKindergartenId, type, sortType); + const { + schoolOptions, + fieldConfigs, + reviewData, + currentPath, + isDisabled, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useReviewPage(safeKindergartenId, type, sortType); const kindergartenName = kindergartenData?.name || SCHOOL_DEFAULT_NAME; @@ -54,6 +64,14 @@ export default function ReviewPanel() { kindergartenName={kindergartenName} initialSortType={sortType} /> + {hasNextPage && ( + fetchNextPage()} + /> + )} + {isFetchingNextPage && } { + const allPages = infiniteQuery.data?.pages || []; + const allReviews: ReviewData[] = []; + allPages.forEach((page) => { + if (page.content) { + allReviews.push(...(page.content as ReviewData[])); + } + }); + return allReviews; + }, [infiniteQuery.data]); + + // 평균 점수 계산 + const reviewData = useMemo(() => { + const totalRating = + (reviews as ReviewData[]).reduce((acc: number, review: ReviewData) => { + if (type === REVIEW_TYPES.WORK && "workReviewId" in review) { + return ( + acc + + (review.benefitAndSalaryScore + + review.workLifeBalanceScore + + review.workEnvironmentScore + + review.managerScore + + review.customerScore) / + 5 + ); + } else if ("internshipReviewId" in review) { + return ( + acc + + (review.workEnvironmentScore + + review.learningSupportScore + + review.instructionTeacherScore) / + 3 + ); + } + return acc; + }, 0) / reviews.length || 0; + + const scores = (reviews as ReviewData[]).reduce( + (acc: Record, review: ReviewData) => { + if (type === REVIEW_TYPES.WORK && "workReviewId" in review) { + acc.welfare = (acc.welfare || 0) + review.benefitAndSalaryScore; + acc.workLabel = (acc.workLabel || 0) + review.workLifeBalanceScore; + acc.atmosphere = (acc.atmosphere || 0) + review.workEnvironmentScore; + acc.manager = (acc.manager || 0) + review.managerScore; + acc.customer = (acc.customer || 0) + review.customerScore; + } else if ("internshipReviewId" in review) { + acc.atmosphere = (acc.atmosphere || 0) + review.workEnvironmentScore; + acc.studyHelp = (acc.studyHelp || 0) + review.learningSupportScore; + acc.teacherGuide = + (acc.teacherGuide || 0) + review.instructionTeacherScore; + } + return acc; + }, + {} as Record + ); + + Object.keys(scores).forEach((key) => { + scores[key] = scores[key] / reviews.length || 0; + }); + + return { + reviews, + rating: { total: totalRating }, + scores, + }; + }, [reviews, type]); + // 페이지 접근 시 최근 방문 경로 저장 useEffect(() => { setReviewState({ @@ -27,7 +113,6 @@ export function useReviewPage( }); }, [kindergartenId, type]); - // 리뷰 작성 버튼 비활성화 조건 const isDisabled = () => { if (!user) return true; @@ -51,5 +136,8 @@ export function useReviewPage( pageTitle: `원바원 | ${kindergartenId} ${REVIEW_TYPE_LABELS[type as "work" | "learning"]}`, currentPath: `/kindergarten/${kindergartenId}/review?type=${type}`, isDisabled: isDisabled(), + fetchNextPage: infiniteQuery.fetchNextPage, + hasNextPage: infiniteQuery.hasNextPage, + isFetchingNextPage: infiniteQuery.isFetchingNextPage, }; } From f863315d5ee66feb075ab69819179fd3ad227934 Mon Sep 17 00:00:00 2001 From: 0zuth Date: Sun, 11 Jan 2026 23:27:06 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EA=B7=BC=EB=AC=B4=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=EC=9D=98=20'=EA=B3=A0=EA=B0=9D'=20=ED=95=AD=EB=AA=A9?= =?UTF-8?q?=20=E2=86=92=20=20'=ED=95=99=EA=B8=89=20=EC=9A=B4=EC=98=81'=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/form/ui/fields/ScoredCommentField.tsx | 8 ++++++++ src/widgets/review-editor/ui/WorkReviewForm.tsx | 5 +++-- src/widgets/review-panel/lib/getFieldConfigsByType.ts | 4 ++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/features/form/ui/fields/ScoredCommentField.tsx b/src/features/form/ui/fields/ScoredCommentField.tsx index 2f94a12..fccc60c 100644 --- a/src/features/form/ui/fields/ScoredCommentField.tsx +++ b/src/features/form/ui/fields/ScoredCommentField.tsx @@ -7,6 +7,7 @@ import { } from "@/shared/ui/form"; import Textarea from "@/shared/ui/form/textarea"; import { BoxRatingGroup } from "@/shared/ui/rating/box-rating"; +import ToolTip from "@/shared/ui/tool-tip"; import type { Control, FieldPath, FieldValues } from "react-hook-form"; @@ -17,6 +18,7 @@ interface ScoredCommentFieldProps { label: string; showCounter?: boolean; maxLength?: number; + tooltipText?: string; } export default function ScoredCommentField({ @@ -26,6 +28,7 @@ export default function ScoredCommentField({ label, showCounter = false, maxLength, + tooltipText, }: ScoredCommentFieldProps) { return (
@@ -38,6 +41,11 @@ export default function ScoredCommentField({ {label}에 대해서 알려주세요 + {tooltipText && ( +
+ {tooltipText} +
+ )} {showCounter && typeof field.value === "string" && maxLength && ( *{field.value.length}/{maxLength}자{" "} diff --git a/src/widgets/review-editor/ui/WorkReviewForm.tsx b/src/widgets/review-editor/ui/WorkReviewForm.tsx index bcd1881..2bbf683 100644 --- a/src/widgets/review-editor/ui/WorkReviewForm.tsx +++ b/src/widgets/review-editor/ui/WorkReviewForm.tsx @@ -13,7 +13,7 @@ export interface WorkReviewFormValues { workType?: string; oneLineComment: string; - // 2 step: 복지/급여, 워라벨, 관리자, 고객 + // 2 step: 복지/급여, 워라벨, 관리자, 학급 운영 benefitAndSalaryComment: string; benefitAndSalaryScore: number; workLifeBalanceComment: string; @@ -99,7 +99,8 @@ export default function WorkReviewForm({ form, step }: WorkReviewFormProps) { control={form.control} commentName="customerComment" scoreName="customerScore" - label="고객" + label="학급 운영" + tooltipText="행사, 학급 운영 방식, 유아 관리, 학부모와의 소통 등 해당 기관에 근무하면서 겪은 운영 경험을 공유하는 항목입니다." showCounter maxLength={REVIEW_COMMENT_MAX_LENGTH} /> diff --git a/src/widgets/review-panel/lib/getFieldConfigsByType.ts b/src/widgets/review-panel/lib/getFieldConfigsByType.ts index 72ed0c1..ff4824a 100644 --- a/src/widgets/review-panel/lib/getFieldConfigsByType.ts +++ b/src/widgets/review-panel/lib/getFieldConfigsByType.ts @@ -10,7 +10,7 @@ const workRatingConfigs: RatingFieldConfig[] = [ { key: "workLabel", label: "워라벨" }, { key: "atmosphere", label: "분위기" }, { key: "manager", label: "관리자" }, - { key: "customer", label: "고객" }, + { key: "customer", label: "학급 운영" }, ]; const workReviewConfigs: ReviewFieldConfig[] = [ @@ -18,7 +18,7 @@ const workReviewConfigs: ReviewFieldConfig[] = [ { key: "workLabel", label: "워라벨" }, { key: "atmosphere", label: "분위기" }, { key: "manager", label: "관리자" }, - { key: "customer", label: "고객" }, + { key: "customer", label: "학급 운영" }, ]; // 실습 리뷰 From 2e6f95d03a67837b849c61267e07384990c6bab3 Mon Sep 17 00:00:00 2001 From: 0zuth Date: Mon, 12 Jan 2026 00:00:36 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20FAQ=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 질문/답변 4항목 추가 - 임의의 디자인 적용 --- src/pages/user/inquiry/index.tsx | 15 ++++- src/shared/constants/inquiry.ts | 27 ++++++++ src/widgets/user-dashboard/faq-list/index.tsx | 26 ++++++++ .../user-dashboard/faq-list/ui/FaqItem.tsx | 61 +++++++++++++++++++ src/widgets/user-dashboard/ui/MenuItem.tsx | 2 + 5 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/widgets/user-dashboard/faq-list/index.tsx create mode 100644 src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx diff --git a/src/pages/user/inquiry/index.tsx b/src/pages/user/inquiry/index.tsx index da1210c..58336c2 100644 --- a/src/pages/user/inquiry/index.tsx +++ b/src/pages/user/inquiry/index.tsx @@ -1,13 +1,16 @@ import { useAtomValue } from "jotai"; +import { useState } from "react"; import { userAtom } from "@/entities/auth/model"; import { SVG_PATHS } from "@/shared/constants/assets-path"; import { URL_PATHS } from "@/shared/constants/url-path"; import PageLayout from "@/shared/ui/layout/page-layout"; +import FaqList from "@/widgets/user-dashboard/faq-list"; import MenuItem from "@/widgets/user-dashboard/ui/MenuItem"; export default function InquiryPage() { const user = useAtomValue(userAtom); + const [showFaq, setShowFaq] = useState(true); return ( )} +
+ setShowFaq(!showFaq)} + iconAlt="공지 아이콘" + label="FAQ" + isActive={showFaq} + /> + {showFaq && } +
(null); + + const handleToggleExpand = (faqId: number) => { + setExpandedFaqId(expandedFaqId === faqId ? null : faqId); + }; + + return ( +
+ {FAQ_LIST.map((faq) => ( + handleToggleExpand(faq.id)} + /> + ))} +
+ ); +} diff --git a/src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx b/src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx new file mode 100644 index 0000000..4d0180b --- /dev/null +++ b/src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx @@ -0,0 +1,61 @@ +import clsx from "clsx"; + +import { SVG_PATHS } from "@/shared/constants/assets-path"; + +interface FaqItemProps { + faq: { + id: number; + question: string; + answer: string; + }; + expanded: boolean; + onToggle: () => void; +} + +export default function FaqItem({ faq, expanded, onToggle }: FaqItemProps) { + return ( +
+ + +
+
+
+
+ + A. + +

+ {faq.answer} +

+
+
+
+
+
+ ); +} diff --git a/src/widgets/user-dashboard/ui/MenuItem.tsx b/src/widgets/user-dashboard/ui/MenuItem.tsx index 5463c9b..885871f 100644 --- a/src/widgets/user-dashboard/ui/MenuItem.tsx +++ b/src/widgets/user-dashboard/ui/MenuItem.tsx @@ -8,6 +8,7 @@ interface MenuItemProps { label: string; to?: string; onClick?: () => void; + isActive?: boolean; } export default function MenuItem({ @@ -16,6 +17,7 @@ export default function MenuItem({ label, to, onClick, + isActive = false, }: MenuItemProps) { const arrowIcon = ( Date: Mon, 12 Jan 2026 00:02:33 +0900 Subject: [PATCH 09/10] fix: build error --- src/pages/user/inquiry/index.tsx | 1 - src/widgets/user-dashboard/ui/MenuItem.tsx | 2 -- 2 files changed, 3 deletions(-) diff --git a/src/pages/user/inquiry/index.tsx b/src/pages/user/inquiry/index.tsx index 58336c2..ad1f71d 100644 --- a/src/pages/user/inquiry/index.tsx +++ b/src/pages/user/inquiry/index.tsx @@ -38,7 +38,6 @@ export default function InquiryPage() { onClick={() => setShowFaq(!showFaq)} iconAlt="공지 아이콘" label="FAQ" - isActive={showFaq} /> {showFaq && }
diff --git a/src/widgets/user-dashboard/ui/MenuItem.tsx b/src/widgets/user-dashboard/ui/MenuItem.tsx index 885871f..5463c9b 100644 --- a/src/widgets/user-dashboard/ui/MenuItem.tsx +++ b/src/widgets/user-dashboard/ui/MenuItem.tsx @@ -8,7 +8,6 @@ interface MenuItemProps { label: string; to?: string; onClick?: () => void; - isActive?: boolean; } export default function MenuItem({ @@ -17,7 +16,6 @@ export default function MenuItem({ label, to, onClick, - isActive = false, }: MenuItemProps) { const arrowIcon = ( Date: Tue, 13 Jan 2026 08:42:34 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EC=9D=B8=EA=B8=B0=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=A3=BC=EA=B0=84/=EC=9B=94=EA=B0=84/=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=ED=83=AD=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메인 화면 인기 게시글에 주간/월간/전체 탭 추가 - 커뮤니티 NavBar에 "인기글" 탭 추가 (제일 앞) - 커뮤니티 카테고리에서 TOP10 제거 - 인기글과 일반 게시글 UI/UX 명확하게 분리 - 새로운 API 엔드포인트 /community/popular 연동 BREAKING CHANGE: - 커뮤니티 URL 구조 변경: ?type=popular 추가 - 기존 top10 카테고리 제거 @0juicy 확인 부탁드립니다 --- .storybook/preview.tsx | 2 +- src/entities/community/DTO.d.ts | 13 ++- src/entities/community/api.ts | 13 ++- src/entities/community/hooks/index.ts | 1 + .../hooks/usePopularPostsByPeriod.tsx | 14 ++++ src/pages/community/index.tsx | 82 +++++++++++-------- src/pages/home/index.tsx | 2 +- src/shared/config/api.ts | 1 + src/widgets/community-feed/lib/category.ts | 12 ++- .../post-list/ui/PopularPostList.tsx | 25 ++++-- .../community-feed/ui/CategorySelector.tsx | 26 +++--- .../community-feed/ui/PeriodSelector.tsx | 46 +++++++++++ .../home-dashboard/ui/PopularPostsPreview.tsx | 69 +++++++++++++--- 13 files changed, 234 insertions(+), 72 deletions(-) create mode 100644 src/entities/community/hooks/usePopularPostsByPeriod.tsx create mode 100644 src/widgets/community-feed/ui/PeriodSelector.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ba0da12..a8c385f 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,7 +2,7 @@ import "@/app/styles/global.css"; import React from "react"; import { BrowserRouter } from "react-router-dom"; -import QueryProvider from "../src/components/@shared/providers/QueryProvider"; +import QueryProvider from "../src/app/providers/QueryProvider"; import type { Preview } from "@storybook/react"; diff --git a/src/entities/community/DTO.d.ts b/src/entities/community/DTO.d.ts index 864ea83..3ae2b65 100644 --- a/src/entities/community/DTO.d.ts +++ b/src/entities/community/DTO.d.ts @@ -91,12 +91,23 @@ export interface CommunityPostDetailResponse { message: string; } -// 인기 게시글 TOP 10 +// 인기 게시글 TOP 10 (기존) export interface PopularPostsResponse { success: boolean; data: CommunityPostItem[]; message: string; } +// 인기 게시글 주간/월간/전체 +export interface PopularPostsByPeriodResponse { + success: boolean; + data: { + weekly: CommunityPostItem[]; + monthly: CommunityPostItem[]; + all: CommunityPostItem[]; + }; + message: string; +} + export type UserRole = "TEACHER" | "PROSPECTIVE_TEACHER" | "ADMIN"; export type CommentStatus = "PENDING" | "PROCESSED" | "REJECTED" | "YET"; diff --git a/src/entities/community/api.ts b/src/entities/community/api.ts index 82dfa82..df991b4 100644 --- a/src/entities/community/api.ts +++ b/src/entities/community/api.ts @@ -10,10 +10,11 @@ import { LikeStatusRequest, LikeStatusResponse, PopularPostsResponse, + PopularPostsByPeriodResponse, } from "./DTO"; /** - * 인기 게시글 조회 + * 인기 게시글 조회 (기존) */ export const getPopularPosts = async (): Promise => { return apiCall({ @@ -22,6 +23,16 @@ export const getPopularPosts = async (): Promise => { }); }; +/** + * 인기 게시글 조회 (주간/월간/전체) + */ +export const getPopularPostsByPeriod = async (): Promise => { + return apiCall({ + method: "GET", + path: API_PATHS.COMMUNITY.POST.POPULAR, + }); +}; + /** * 게시글 목록 조회 * @param params 게시글 목록 조회 파라미터 diff --git a/src/entities/community/hooks/index.ts b/src/entities/community/hooks/index.ts index 3c7df9c..c202f52 100644 --- a/src/entities/community/hooks/index.ts +++ b/src/entities/community/hooks/index.ts @@ -4,4 +4,5 @@ export * from "./useCreatePost"; export * from "./useDeletePost"; export * from "./useLikeStatus"; export * from "./usePopularPosts"; +export * from "./usePopularPostsByPeriod"; export * from "./useToggleLike"; diff --git a/src/entities/community/hooks/usePopularPostsByPeriod.tsx b/src/entities/community/hooks/usePopularPostsByPeriod.tsx new file mode 100644 index 0000000..ff69d3b --- /dev/null +++ b/src/entities/community/hooks/usePopularPostsByPeriod.tsx @@ -0,0 +1,14 @@ +import { useSuspenseQuery } from "@tanstack/react-query"; + +import { getPopularPostsByPeriod } from "@/entities/community/api"; +import { DYNAMIC_CACHE_CONFIG } from "@/shared/config/query"; + +import { PopularPostsByPeriodResponse } from "../DTO.d"; + +export const usePopularPostsByPeriod = () => { + return useSuspenseQuery({ + queryKey: ["popularPostsByPeriod"], + queryFn: getPopularPostsByPeriod, + ...DYNAMIC_CACHE_CONFIG, + }); +}; diff --git a/src/pages/community/index.tsx b/src/pages/community/index.tsx index 4249f7b..19e45c2 100644 --- a/src/pages/community/index.tsx +++ b/src/pages/community/index.tsx @@ -2,7 +2,6 @@ import { Suspense, useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import NavBar from "@/features/nav/ui/NavBar"; -import { SVG_PATHS } from "@/shared/constants/assets-path"; import PostButton from "@/shared/ui/buttons/post-button"; import PageLayout from "@/shared/ui/layout/page-layout"; import LoadingSpinner from "@/shared/ui/loading/loading-spinner"; @@ -15,14 +14,25 @@ import { import PostList from "@/widgets/community-feed/post-list"; import PopularPostList from "@/widgets/community-feed/post-list/ui/PopularPostList"; import CategorySelector from "@/widgets/community-feed/ui/CategorySelector"; +import PeriodSelector, { + type PeriodType, +} from "@/widgets/community-feed/ui/PeriodSelector"; export default function CommunityPage() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const communityType = - searchParams.get("type") === "pre-teacher" ? "pre-teacher" : "teacher"; - const categoryName = searchParams.get("category") || "top10"; + const typeParam = searchParams.get("type") || "popular"; // 기본값: 인기글 + const period = (searchParams.get("period") || "weekly") as PeriodType; + const categoryName = searchParams.get("category") || "all"; + + // type에 따라 모드 결정 + const isPopularMode = typeParam === "popular"; + const communityType = isPopularMode + ? "teacher" + : typeParam === "pre-teacher" + ? "pre-teacher" + : "teacher"; const categoryOptions = communityType === "pre-teacher" @@ -31,54 +41,62 @@ export default function CommunityPage() { // 세션 스토리지에 위치 정보 저장 useEffect(() => { + const path = isPopularMode + ? `/community?type=popular&period=${period}` + : `/community?type=${communityType}&category=${categoryName}`; + const newState = { - path: `/community?type=${communityType}&category=${categoryName}`, + path, category: communityType as "teacher" | "pre-teacher", - communityCategoryName: categoryName, + communityCategoryName: isPopularMode ? period : categoryName, }; setCommunityState(newState); - }, [communityType, categoryName]); + }, [communityType, period, categoryName, isPopularMode]); + + const currentPath = isPopularMode + ? `/community?type=popular&period=${period}` + : `/community?type=${communityType}&category=${categoryName}`; + + const pageTitle = isPopularMode + ? "원바원 | 인기 게시글" + : `원바원 | ${communityType === "teacher" ? "교사" : "예비교사"} 커뮤니티`; return ( - + -
- - {categoryName === "top10" ? ( -
-
- 그래프 -

실시간 인기 게시글

-
+
+ {/* 인기글 모드: 주간/월간/전체 탭만 표시 */} + {isPopularMode ? ( + <> + }> - + -
+ ) : ( - }> - + {/* 일반 모드: 카테고리 선택만 표시 */} + - + }> + + + )}
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index 0aab1a0..17daa60 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -53,7 +53,7 @@ export default function HomePage() {
리뷰 배너 `/community/${id}`, LIKE: (id: number) => `/community/${id}/like`, TOP: "/community/top", + POPULAR: "/community/popular", DELETE: (id: number) => `/community/${id}`, }, COMMENT: { diff --git a/src/widgets/community-feed/lib/category.ts b/src/widgets/community-feed/lib/category.ts index 75a6b4f..331f407 100644 --- a/src/widgets/community-feed/lib/category.ts +++ b/src/widgets/community-feed/lib/category.ts @@ -17,6 +17,16 @@ export interface CategoryInfo { // 커뮤니티 카테고리 아이콘 export const CATEGORY_ICONS = [ + { + href: "/community?type=popular", + label: "인기글", + icon: { + path: SVG_PATHS.KINDERGARTEN_INFO.CHART, + alt: "인기글 아이콘", + width: 32, + height: 32, + }, + }, { href: "/community?type=teacher", label: "교사", @@ -45,7 +55,6 @@ export const CATEGORY_INFO: Record = { name: "교사", description: "교사 커뮤니티", categories: [ - { value: "top10", label: "Top 10" }, { value: "all", label: "전체" }, { value: "free", label: "자유" }, { value: "salary", label: "월급/취업" }, @@ -63,7 +72,6 @@ export const CATEGORY_INFO: Record = { name: "예비교사", description: "예비교사 커뮤니티", categories: [ - { value: "top10", label: "Top 10" }, { value: "all", label: "전체" }, { value: "university", label: "대학생활" }, { value: "practice", label: "실습" }, diff --git a/src/widgets/community-feed/post-list/ui/PopularPostList.tsx b/src/widgets/community-feed/post-list/ui/PopularPostList.tsx index 03cc6ae..267d3b4 100644 --- a/src/widgets/community-feed/post-list/ui/PopularPostList.tsx +++ b/src/widgets/community-feed/post-list/ui/PopularPostList.tsx @@ -1,13 +1,25 @@ import { useEffect, useRef, useState } from "react"; -import { usePopularPosts } from "@/entities/community/hooks"; +import { usePopularPostsByPeriod } from "@/entities/community/hooks"; import Empty from "@/shared/ui/layout/empty"; import { getCategoryLabel } from "@/shared/utils/categoryUtils"; import PostCard from "@/widgets/community-feed/post-list/ui/PostCard"; -export default function PopularPostList() { - const { data: popularPostsData } = usePopularPosts(); - const posts = popularPostsData?.data || []; +type PeriodType = "weekly" | "monthly" | "all"; + +const PERIOD_LABELS = { + weekly: "주간", + monthly: "월간", + all: "전체", +} as const; + +interface PopularPostListProps { + period: PeriodType; +} + +export default function PopularPostList({ period }: PopularPostListProps) { + const { data: popularPostsData } = usePopularPostsByPeriod(); + const posts = popularPostsData?.data[period] || []; const [isAnimationStarted, setIsAnimationStarted] = useState(false); const firstCardRef = useRef(null); @@ -33,8 +45,9 @@ export default function PopularPostList() { <> {posts.length === 0 ? ( ) : (
    diff --git a/src/widgets/community-feed/ui/CategorySelector.tsx b/src/widgets/community-feed/ui/CategorySelector.tsx index 91d5423..f815b8f 100644 --- a/src/widgets/community-feed/ui/CategorySelector.tsx +++ b/src/widgets/community-feed/ui/CategorySelector.tsx @@ -16,7 +16,7 @@ export default function CategorySelector({ }: CategorySelectorProps) { const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); - const currentCategory = searchParams.get("category") || "top10"; + const currentCategory = searchParams.get("category") || "all"; const handleCategoryChange = (category: string) => { if (category === currentCategory) return; @@ -34,22 +34,16 @@ export default function CategorySelector({ }); // 카테고리 변경 시 데이터 재조회 - if (category === "all" || category !== "top10") { - const queryParams = { - pageSize: 10, - category: type === "teacher" ? "TEACHER" : "PROSPECTIVE_TEACHER", - categoryName: category !== "all" ? category : undefined, - }; + const queryParams = { + pageSize: 10, + category: type === "teacher" ? "TEACHER" : "PROSPECTIVE_TEACHER", + categoryName: category !== "all" ? category : undefined, + }; - queryClient.refetchQueries({ - queryKey: ["communityPosts", queryParams], - exact: false, - }); - } else if (category === "top10") { - queryClient.refetchQueries({ - queryKey: ["popularPosts"], - }); - } + queryClient.refetchQueries({ + queryKey: ["communityPosts", queryParams], + exact: false, + }); }; return ( diff --git a/src/widgets/community-feed/ui/PeriodSelector.tsx b/src/widgets/community-feed/ui/PeriodSelector.tsx new file mode 100644 index 0000000..0bf4744 --- /dev/null +++ b/src/widgets/community-feed/ui/PeriodSelector.tsx @@ -0,0 +1,46 @@ +import { useSearchParams } from "react-router-dom"; + +import Button from "@/shared/ui/buttons/base-button"; + +export type PeriodType = "weekly" | "monthly" | "all"; + +const PERIOD_OPTIONS = [ + { value: "weekly" as const, label: "주간" }, + { value: "monthly" as const, label: "월간" }, + { value: "all" as const, label: "전체" }, +]; + +interface PeriodSelectorProps { + type: "popular"; +} + +export default function PeriodSelector({ type }: PeriodSelectorProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const currentPeriod = (searchParams.get("period") || "weekly") as PeriodType; + + const handlePeriodChange = (period: PeriodType) => { + if (period === currentPeriod) return; + + const newSearchParams = new URLSearchParams(searchParams); + newSearchParams.set("period", period); + newSearchParams.set("type", type); + setSearchParams(newSearchParams); + }; + + return ( +
    + {PERIOD_OPTIONS.map((option) => ( + + ))} +
    + ); +} diff --git a/src/widgets/home-dashboard/ui/PopularPostsPreview.tsx b/src/widgets/home-dashboard/ui/PopularPostsPreview.tsx index 01bcb6f..7a233c9 100644 --- a/src/widgets/home-dashboard/ui/PopularPostsPreview.tsx +++ b/src/widgets/home-dashboard/ui/PopularPostsPreview.tsx @@ -1,27 +1,72 @@ +import { useState } from "react"; import { Link } from "react-router-dom"; -import { usePopularPosts } from "@/entities/community/hooks/usePopularPosts"; +import { usePopularPostsByPeriod } from "@/entities/community/hooks/usePopularPostsByPeriod"; import { URL_PATHS } from "@/shared/constants/url-path"; import Button from "@/shared/ui/buttons/base-button"; +import Empty from "@/shared/ui/layout/empty"; import { getCategoryLabel } from "@/shared/utils/categoryUtils"; import PostCard from "@/widgets/community-feed/post-list/ui/PostCard"; +type PeriodType = "weekly" | "monthly" | "all"; + +const PERIOD_OPTIONS = [ + { value: "weekly" as const, label: "주간" }, + { value: "monthly" as const, label: "월간" }, + { value: "all" as const, label: "전체" }, +]; + +const PERIOD_LABELS = { + weekly: "주간", + monthly: "월간", + all: "전체", +} as const; + export default function PopularPostsPreview() { - const { data: posts } = usePopularPosts(); + const [selectedPeriod, setSelectedPeriod] = useState("weekly"); + const { data: postsData } = usePopularPostsByPeriod(); + + const currentPosts = postsData?.data[selectedPeriod] || []; return (
    - {[0, 1, 2].map((i) => - posts?.data[i] ? ( - - ) : null + {/* 기간 선택 탭 */} +
    + {PERIOD_OPTIONS.map((option) => ( + + ))} +
    + + {/* 게시글 목록 */} + {currentPosts.length === 0 ? ( + + ) : ( + [0, 1, 2].map((i) => + currentPosts[i] ? ( + + ) : null + ) )} +