diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ba0da120..a8c385fa 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 864ea834..3ae2b659 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 82dfa825..df991b45 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 3c7df9c6..c202f52d 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 00000000..ff69d3b8 --- /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/entities/community/hooks/useToggleLike.tsx b/src/entities/community/hooks/useToggleLike.tsx index d0be1789..5be28e3b 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/entities/review/api.ts b/src/entities/review/api.ts index 20bee4ac..64c16e10 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/useDeleteInternshipReview.tsx b/src/entities/review/hooks/useDeleteInternshipReview.tsx index 8b1f24ee..692145be 100644 --- a/src/entities/review/hooks/useDeleteInternshipReview.tsx +++ b/src/entities/review/hooks/useDeleteInternshipReview.tsx @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { getUserInfo } from "@/entities/user/api"; import { useToast } from "@/shared/hooks/useToast"; +import { useRewardAd } from "@/shared/hooks/useFlutterCommunication"; import { deleteInternshipReview } from "../api"; import { LikeResponse } from "../DTO.d"; @@ -9,6 +10,7 @@ import { LikeResponse } from "../DTO.d"; export const useDeleteInternshipReview = () => { const queryClient = useQueryClient(); const { toast } = useToast(); + const [showRewardAd] = useRewardAd(); return useMutation< LikeResponse, @@ -16,11 +18,29 @@ 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: async (_, variables) => { + // 해당 유치원의 실습 리뷰 목록을 다시 불러오기 if (variables.kindergartenId) { queryClient.invalidateQueries({ queryKey: ["internshipReviews", variables.kindergartenId.toString()], diff --git a/src/entities/review/hooks/useDeleteWorkReview.tsx b/src/entities/review/hooks/useDeleteWorkReview.tsx index 9a9290c3..7e7329bd 100644 --- a/src/entities/review/hooks/useDeleteWorkReview.tsx +++ b/src/entities/review/hooks/useDeleteWorkReview.tsx @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { getUserInfo } from "@/entities/user/api"; import { useToast } from "@/shared/hooks/useToast"; +import { useRewardAd } from "@/shared/hooks/useFlutterCommunication"; import { deleteWorkReview } from "../api"; import { LikeResponse } from "../DTO.d"; @@ -9,6 +10,7 @@ import { LikeResponse } from "../DTO.d"; export const useDeleteWorkReview = () => { const queryClient = useQueryClient(); const { toast } = useToast(); + const [showRewardAd] = useRewardAd(); return useMutation< LikeResponse, @@ -16,10 +18,29 @@ 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: async (_, variables) => { + // 해당 유치원의 근무 리뷰 목록을 다시 불러오기 if (variables.kindergartenId) { queryClient.invalidateQueries({ queryKey: ["workReviews", variables.kindergartenId.toString()], diff --git a/src/entities/review/hooks/useGetReview.tsx b/src/entities/review/hooks/useGetReview.tsx index de450310..0beb60ae 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/entities/review/hooks/useReviewLike.tsx b/src/entities/review/hooks/useReviewLike.tsx index 30746275..fe789e27 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/features/form/ui/fields/ScoredCommentField.tsx b/src/features/form/ui/fields/ScoredCommentField.tsx index 2f94a122..fccc60cc 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/pages/community/index.tsx b/src/pages/community/index.tsx index 4249f7b5..19e45c2e 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 0aab1a0d..17daa609 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -53,7 +53,7 @@ export default function HomePage() {
리뷰 배너 )} +
+ setShowFaq(!showFaq)} + iconAlt="공지 아이콘" + label="FAQ" + /> + {showFaq && } +
`/community/${id}`, LIKE: (id: number) => `/community/${id}/like`, TOP: "/community/top", + POPULAR: "/community/popular", DELETE: (id: number) => `/community/${id}`, }, COMMENT: { diff --git a/src/shared/constants/inquiry.ts b/src/shared/constants/inquiry.ts index f4536467..4e68d40e 100644 --- a/src/shared/constants/inquiry.ts +++ b/src/shared/constants/inquiry.ts @@ -26,3 +26,30 @@ export const INQUIRY_TAB_OPTIONS = [ ] as const; export type InquiryTab = (typeof INQUIRY_TAB_OPTIONS)[number]["type"]; + +export const FAQ_LIST = [ + { + id: 1, + question: "리뷰 삭제 어떻게 하나요?", + answer: + "작성하신 리뷰는 [앱 상단 ☰ 버튼 클릭 → '작성한 리뷰 관리' 메뉴 클릭 → ⋮ 버튼 클릭]에서 직접 수정 및 삭제가 가능합니다. ☺️ 리뷰 삭제 시 해당 내용은 복구되지 않으니 신중하게 결정해 주세요!", + }, + { + id: 2, + question: "유치원 관리자가 알게되어 신고하면 어떻게 되나요?", + answer: + "작성된 리뷰는 익명으로 처리됩니다. 다만, 리뷰에 대해 공식적인 신고가 접수될 경우 서비스 이용 가이드에 명시된 바와 같이 관련 법령 및 절차에 따라 필요한 범위 내에서 정보 제공이 이루어질 수 있음을 안내드립니다. 또한 리뷰는 공지 및 이용 가이드라인에 안내된 작성 기준에 맞게 작성해 주시기를 요청드리며, 해당 기준에 부합하지 않는 표현이나 오해의 소지가 있는 내용은 수정 요청 또는 조치가 이루어질 수 있습니다. 현재로서는 리뷰를 지속적으로 관리·모니터링하며, 특정 인원 언급이나 오해의 소지가 있는 내용에 대해서는 선제적으로 대응하고 있습니다.", + }, + { + id: 3, + question: "어린이집은 볼 수 없나요?", + answer: + "어린이집 정보는 향후 서비스 확장 시 추가될 예정이오니 많은 관심 부탁드립니다! ☺️", + }, + { + id: 4, + question: "문의 답변은 언제 오나요?", + answer: + "문의는 접수 순서대로 1주 이내에 안내드리고 있으며, 운영 상황에 따라 답변까지 시간이 소요될 수 있는 점 양해 부탁드립니다. 동일 문의에 대한 재문의는 오히려 답변이 지연될 수 있어, 기다려주시면 순차적으로 안내드리겠습니다. ☺️", + }, +] as const; diff --git a/src/shared/hooks/useFlutterCommunication.ts b/src/shared/hooks/useFlutterCommunication.ts index 2f7071e3..84a4ad81 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,83 @@ 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: "success", + 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: "success", + rewarded: false, + message: "광고 기능을 사용할 수 없습니다.", + }; + } finally { + setIsLoading(false); + } + }, []); + + return [showRewardAd, isLoading, error]; +} diff --git a/src/shared/hooks/useOptimisticUpdate.ts b/src/shared/hooks/useOptimisticUpdate.ts new file mode 100644 index 00000000..eba30f26 --- /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]; +} diff --git a/src/shared/utils/webViewCommunication.ts b/src/shared/utils/webViewCommunication.ts index 613fecb5..0af021c4 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,61 @@ 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 { + // 타임아웃 설정 (30초) - 광고 로드 및 시청 시간 고려 + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => { + console.warn("보상형 광고 응답 타임아웃"); + resolve({ + status: "error", + rewarded: false, + message: "광고를 불러오는데 시간이 너무 오래 걸립니다.", + }); + }, 30000); + }); + + const adPromise = sendToFlutter< + Record, + RewardAdResult + >(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); + // 에러 발생 시에도 삭제 허용 (구버전 앱 대응) + return { + status: "success", + rewarded: false, + message: "광고 기능을 사용할 수 없습니다.", + }; + } +} diff --git a/src/widgets/community-feed/lib/category.ts b/src/widgets/community-feed/lib/category.ts index 75a6b4f9..331f4073 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 03cc6ae7..267d3b4b 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 91d54237..f815b8f5 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 00000000..0bf47446 --- /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 01bcb6fb..7a233c98 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 + ) )} + + +
    +
    +
    +
    + + A. + +

    + {faq.answer} +

    +
    +
    +
    +
    +
    + ); +} 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 cfeda353..ddac5ac9 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="리뷰 삭제" > - 리뷰를 삭제하시겠습니까?
    작성된 리뷰가 없으면,
    다른 - 사용자의 리뷰를 볼 수 없습니다. + 리뷰를 삭제하시겠습니까?
    + 광고 시청 후 삭제가 진행됩니다.
    +
    + 작성된 리뷰가 없으면,
    다른 사용자의 리뷰를 볼 수 없습니다. );