From e53c16c3df1ca73603fbb222a1cd12cd7175fac1 Mon Sep 17 00:00:00 2001 From: BangDori <44726494+BangDori@users.noreply.github.com> Date: Sat, 18 May 2024 13:55:47 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20=ED=94=BC=EB=93=9C=20=EC=8B=A0?= =?UTF-8?q?=EA=B3=A0=20UI=20=EB=B0=98=EC=98=81=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: siren 아이콘 추가 * feat: 숨김 관리 저장소 Map 타입 수정 * feat: 신고 후 피드 숨기기 기능 * feat: 숨김 취소 버튼 padding 으로 스타일 수정 * feat: 신고 완료 후 토스트 메시지 렌더링 * feat: 피드 숨기기 및 취소 기능 동기식 처리 * feat: 피드 숨기기 취소 API 동기식 처리 * feat: 숨김 처리 onMutate에서 처리 * feat: 신고하기 체크박스 체크되어 있지 않은 상태로 반영 * feat: 피드 신고 중 에러 발생 시 에러 토스트 메시지 표시 * feat: useReportCategories -> useReportForm * feat: 신고 양식 유효성 검사 모델에서 진행 * feat: 신고하기 양식 body 생성 함수 생성 * feat: 피드 신고 양식 타입 분리 * feat: 피드 신고 실패 시 기존 상태 유지, 성공 시 상태 제거 * feat: 피드 신고 실패 시, 기존 상태 복구 Closes #65 --- public/assets/sprites/common.svg | 19 +++++ src/entitites/feed/hide-store.ts | 42 ++++++++--- src/features/feed-hides/api/useHideCancle.tsx | 4 +- src/features/feed-hides/api/useHides.tsx | 6 +- src/features/feed-hides/ui/HiddenFeed.scss | 3 +- src/features/feed-hides/ui/HiddenFeed.tsx | 10 +-- src/features/feed-hides/ui/HideButton.tsx | 6 +- .../feed-reports/api/useSubmitReports.tsx | 30 +++++--- src/features/feed-reports/consts/index.ts | 1 + src/features/feed-reports/consts/reports.ts | 2 +- src/features/feed-reports/consts/type.ts | 5 ++ src/features/feed-reports/model/index.ts | 2 +- .../model/useReportCategories.tsx | 10 --- .../feed-reports/model/useReportForm.tsx | 39 ++++++++++ src/features/feed-reports/store/index.ts | 1 + .../feed-reports/store/report-store.ts | 71 +++++++++++++++++++ .../feed-reports/ui/FeedReportsForm.tsx | 33 ++++----- src/shared/react-query/consts/client.ts | 4 +- src/shared/react-query/dir/handleQuery.ts | 20 +++++- src/shared/ui/icon/consts/sprite.ts | 3 +- .../feed-main-list/ui/FeedMainList.tsx | 16 ++--- 21 files changed, 251 insertions(+), 76 deletions(-) create mode 100644 src/features/feed-reports/consts/type.ts delete mode 100644 src/features/feed-reports/model/useReportCategories.tsx create mode 100644 src/features/feed-reports/model/useReportForm.tsx create mode 100644 src/features/feed-reports/store/index.ts create mode 100644 src/features/feed-reports/store/report-store.ts diff --git a/public/assets/sprites/common.svg b/public/assets/sprites/common.svg index d79a775..31f8492 100644 --- a/public/assets/sprites/common.svg +++ b/public/assets/sprites/common.svg @@ -187,4 +187,23 @@ d='M16.71 9.381l-6.482 6.482-2.947-2.946' /> + + + + \ No newline at end of file diff --git a/src/entitites/feed/hide-store.ts b/src/entitites/feed/hide-store.ts index c6cc393..3a1372b 100644 --- a/src/entitites/feed/hide-store.ts +++ b/src/entitites/feed/hide-store.ts @@ -1,17 +1,27 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; -const HIDDEN = true; -const VISIBLE = false; +export type HiddenType = 'hidden' | 'siren'; + +const HiddenMessage = { + hidden: { + reasonMsg: '게시물이 숨겨졌어요', + cancleMsg: '취소', + }, + siren: { + reasonMsg: '신고가 접수되었어요', + cancleMsg: '게시물 보기', + }, +}; interface HiddenFeedState { - hiddenFeeds: Map; + hiddenFeeds: Map; } export const useHiddenFeedStore = create()( devtools( (): HiddenFeedState => ({ - hiddenFeeds: new Map(), + hiddenFeeds: new Map(), }), { name: 'feed-hidden-store' }, ), @@ -20,11 +30,12 @@ export const useHiddenFeedStore = create()( /** * 숨김 피드 목록에 피드를 추가합니다. * @param feedId 피드 아이디 + * @param type 숨김 타입 (hidden, siren) */ -export function addHiddenFeed(feedId: number) { +export function addHiddenFeed(feedId: number, type: HiddenType) { useHiddenFeedStore.setState( ({ hiddenFeeds: prevHiddenFeeds }) => ({ - hiddenFeeds: new Map(prevHiddenFeeds).set(feedId, HIDDEN), + hiddenFeeds: new Map(prevHiddenFeeds).set(feedId, type), }), false, 'feed/addHiddenFeed', @@ -37,10 +48,23 @@ export function addHiddenFeed(feedId: number) { */ export function cancleHiddenFeed(feedId: number) { useHiddenFeedStore.setState( - ({ hiddenFeeds: prevHiddenFeeds }) => ({ - hiddenFeeds: new Map(prevHiddenFeeds).set(feedId, VISIBLE), - }), + ({ hiddenFeeds: prevHiddenFeeds }) => { + prevHiddenFeeds.delete(feedId); + + return { + hiddenFeeds: new Map(prevHiddenFeeds), + }; + }, false, 'feed/cancleHiddenFeed', ); } + +/** + * 숨김 메시지를 반환합니다. + * @param {hidden | siren} type 숨김 타입 + * @returns 숨김 사유 메시지, 숨김 해제 메시지 + */ +export function getHiddenMessageByType(type: HiddenType) { + return HiddenMessage[type]; +} diff --git a/src/features/feed-hides/api/useHideCancle.tsx b/src/features/feed-hides/api/useHideCancle.tsx index 7587a6a..38044fc 100644 --- a/src/features/feed-hides/api/useHideCancle.tsx +++ b/src/features/feed-hides/api/useHideCancle.tsx @@ -10,9 +10,9 @@ async function requestHideCancelFeed(feedId: number) { } export const useHideCancel = (feedId: number) => { - const { mutateAsync: hideCancelFeed, isPending } = useMutation({ + const { mutate: hideCancelFeed, isPending } = useMutation({ mutationFn: () => requestHideCancelFeed(feedId), - onSuccess: () => cancleHiddenFeed(feedId), + onMutate: () => cancleHiddenFeed(feedId), }); return { hideCancelFeed, isPending }; diff --git a/src/features/feed-hides/api/useHides.tsx b/src/features/feed-hides/api/useHides.tsx index 8849a9e..65267cb 100644 --- a/src/features/feed-hides/api/useHides.tsx +++ b/src/features/feed-hides/api/useHides.tsx @@ -10,10 +10,10 @@ async function requestHideFeed(feedId: number) { } export const useHides = (feedId: number) => { - const { mutateAsync: hideFeedAsync, isPending } = useMutation({ + const { mutate: hideFeed, isPending } = useMutation({ mutationFn: () => requestHideFeed(feedId), - onSuccess: () => addHiddenFeed(feedId), + onMutate: () => addHiddenFeed(feedId, 'hidden'), }); - return { hideFeedAsync, isPending }; + return { hideFeed, isPending }; }; diff --git a/src/features/feed-hides/ui/HiddenFeed.scss b/src/features/feed-hides/ui/HiddenFeed.scss index 23127c4..c583761 100644 --- a/src/features/feed-hides/ui/HiddenFeed.scss +++ b/src/features/feed-hides/ui/HiddenFeed.scss @@ -13,8 +13,7 @@ .hidden-cancel-btn { margin-top: 16px; - width: 39px; - height: 24px; + padding: 5px 10px; border-radius: 30px; diff --git a/src/features/feed-hides/ui/HiddenFeed.tsx b/src/features/feed-hides/ui/HiddenFeed.tsx index 94cb120..1c85c9b 100644 --- a/src/features/feed-hides/ui/HiddenFeed.tsx +++ b/src/features/feed-hides/ui/HiddenFeed.tsx @@ -1,3 +1,4 @@ +import { HiddenType, getHiddenMessageByType } from '@/entitites/feed'; import { Icon } from '@/shared/ui'; import './HiddenFeed.scss'; @@ -5,23 +6,24 @@ import { useHideCancel } from '../api'; interface HiddenFeedProps { feedId: number; - message: string; + type: HiddenType; } -export const HiddenFeed: React.FC = ({ feedId, message }) => { +export const HiddenFeed: React.FC = ({ feedId, type }) => { const { hideCancelFeed, isPending } = useHideCancel(feedId); + const { reasonMsg, cancleMsg } = getHiddenMessageByType(type); return (
-

{message}

+

{reasonMsg}

diff --git a/src/features/feed-hides/ui/HideButton.tsx b/src/features/feed-hides/ui/HideButton.tsx index df9c094..6a38bf3 100644 --- a/src/features/feed-hides/ui/HideButton.tsx +++ b/src/features/feed-hides/ui/HideButton.tsx @@ -6,10 +6,10 @@ interface HideButtonProps { } export const HideButton: React.FC = ({ feedId, onClose }) => { - const { hideFeedAsync, isPending } = useHides(feedId); + const { hideFeed, isPending } = useHides(feedId); - const handleClickHideBtn = async () => { - await hideFeedAsync(); + const handleClickHideBtn = () => { + hideFeed(); onClose(); }; diff --git a/src/features/feed-reports/api/useSubmitReports.tsx b/src/features/feed-reports/api/useSubmitReports.tsx index 9ac7a24..c67ab45 100644 --- a/src/features/feed-reports/api/useSubmitReports.tsx +++ b/src/features/feed-reports/api/useSubmitReports.tsx @@ -1,25 +1,33 @@ import { useMutation } from '@tanstack/react-query'; +import { addHiddenFeed } from '@/entitites/feed'; import { axiosInstance } from '@/shared/axios'; -interface ReportBody { - category: string; - content: string; - isBlind: boolean; -} +import { FeedReportForm } from '../consts'; +import { removeFeedReportForm, saveFeedReportForm } from '../store'; -async function requestFeedReports(feedId: number, body: ReportBody) { +async function requestFeedReports(feedId: number, body: FeedReportForm) { const { data } = await axiosInstance.post(`feeds/${feedId}/reports`, body); return data; } export const useSubmitReports = (feedId: number) => { - const { mutateAsync: reportFeedAsync, isPending } = useMutation({ - mutationFn: (body: ReportBody) => requestFeedReports(feedId, body), - onError: () => {}, - onSuccess: () => {}, + const { mutate: reportFeed, isPending } = useMutation({ + mutationKey: ['feed-report'], + mutationFn: (body: FeedReportForm) => requestFeedReports(feedId, body), + onError: (_, body) => saveFeedReportForm(feedId, body), + onSuccess: (_, body) => { + const { isBlind } = body; + + // 숨김 처리 + if (isBlind) { + addHiddenFeed(feedId, 'siren'); + } + + removeFeedReportForm(feedId); + }, }); - return { reportFeedAsync, isPending }; + return { reportFeed, isPending }; }; diff --git a/src/features/feed-reports/consts/index.ts b/src/features/feed-reports/consts/index.ts index dc23413..ecd30e2 100644 --- a/src/features/feed-reports/consts/index.ts +++ b/src/features/feed-reports/consts/index.ts @@ -1 +1,2 @@ export * from './reports'; +export * from './type'; diff --git a/src/features/feed-reports/consts/reports.ts b/src/features/feed-reports/consts/reports.ts index c22ca88..c80f7a9 100644 --- a/src/features/feed-reports/consts/reports.ts +++ b/src/features/feed-reports/consts/reports.ts @@ -9,4 +9,4 @@ export const REPORT_CATEGORIES = [ ]; export const MAX_REPORT_CONTENT_LENGTH = 60; -export const DEFAULT_CLICKED_ID = 0; +export const UNCLICKED_STATUS_ID = -1; diff --git a/src/features/feed-reports/consts/type.ts b/src/features/feed-reports/consts/type.ts new file mode 100644 index 0000000..e3b0727 --- /dev/null +++ b/src/features/feed-reports/consts/type.ts @@ -0,0 +1,5 @@ +export interface FeedReportForm { + category: string; + content: string; + isBlind: boolean; +} diff --git a/src/features/feed-reports/model/index.ts b/src/features/feed-reports/model/index.ts index 33ebb3d..8957a73 100644 --- a/src/features/feed-reports/model/index.ts +++ b/src/features/feed-reports/model/index.ts @@ -1 +1 @@ -export * from './useReportCategories'; +export * from './useReportForm'; diff --git a/src/features/feed-reports/model/useReportCategories.tsx b/src/features/feed-reports/model/useReportCategories.tsx deleted file mode 100644 index f4218f6..0000000 --- a/src/features/feed-reports/model/useReportCategories.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useState } from 'react'; - -import { DEFAULT_CLICKED_ID } from '../consts'; - -export const useReportCategories = () => { - const [clickedId, setClicked] = useState(DEFAULT_CLICKED_ID); - const handleClickCategory = (id: number) => setClicked(id); - - return { clickedId, handleClickCategory }; -}; diff --git a/src/features/feed-reports/model/useReportForm.tsx b/src/features/feed-reports/model/useReportForm.tsx new file mode 100644 index 0000000..03aa12a --- /dev/null +++ b/src/features/feed-reports/model/useReportForm.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; + +import { useInput, useToggle } from '@/shared/hooks'; + +import { REPORT_CATEGORIES, UNCLICKED_STATUS_ID } from '../consts'; +import { getFeedReportForm } from '../store/report-store'; + +export const useReportForm = (feedId: number) => { + const { + clickedId: prevClickedId, + content: prevContent, + isBlind: prevIsBlind, + } = getFeedReportForm(feedId); + + const [clickedId, setClickedId] = useState(prevClickedId); + const [content, handleInputContent] = useInput(prevContent); + const [isBlind, toggleBlind] = useToggle(prevIsBlind); + + const isValidReportForm = clickedId !== UNCLICKED_STATUS_ID; + const isDisabledReportForm = !isValidReportForm; + + const handleClickCategory = (id: number) => setClickedId(id); + const createReportBody = () => ({ + category: REPORT_CATEGORIES[clickedId], + content, + isBlind, + }); + + return { + clickedId, + content, + isBlind, + isDisabledReportForm, + handleClickCategory, + handleInputContent, + toggleBlind, + createReportBody, + }; +}; diff --git a/src/features/feed-reports/store/index.ts b/src/features/feed-reports/store/index.ts new file mode 100644 index 0000000..1d98233 --- /dev/null +++ b/src/features/feed-reports/store/index.ts @@ -0,0 +1 @@ +export * from './report-store'; diff --git a/src/features/feed-reports/store/report-store.ts b/src/features/feed-reports/store/report-store.ts new file mode 100644 index 0000000..7883f96 --- /dev/null +++ b/src/features/feed-reports/store/report-store.ts @@ -0,0 +1,71 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +import { FeedReportForm, REPORT_CATEGORIES } from '../consts'; + +interface FeedReportFailState { + [feedId: number]: FeedReportForm; +} + +/** + * 피드 신고 실패시, 사용자가 작성한 정보에 대한 상태를 관리하는 스토어입니다. + */ +export const useFeedReportFailStore = create()( + devtools((): FeedReportFailState => ({}), { name: 'feed-report-store' }), +); + +/** + * 피드 신고 실패시, 사용자가 작성한 정보를 저장합니다. + * @param feedId 피드 아이디 + */ +export function saveFeedReportForm(feedId: number, body: FeedReportForm) { + useFeedReportFailStore.setState( + (prev) => ({ + ...prev, + [feedId]: body, + }), + false, + 'feed/saveFeedReportForm', + ); +} + +/** + * 피드 신고 성공시, 사용자가 작성한 정보를 삭제합니다. + * @param feedId 피드 아이디 + */ +export function removeFeedReportForm(feedId: number) { + if (!useFeedReportFailStore.getState()[feedId]) return; + + useFeedReportFailStore.setState( + (prev) => { + const nextState = { ...prev }; + delete nextState[feedId]; + return nextState; + }, + false, + 'feed/removeFeedReportForm', + ); +} + +/** + * 사용자가 이전에 작성한 피드 신고 정보를 가져옵니다. + * @if 만약 없다면, 초기 상태를 반환합니다. + * @param feedId 피드 아이디 + * @returns 피드 신고 양식 + */ +export function getFeedReportForm(feedId: number) { + const body = useFeedReportFailStore.getState()[feedId]; + + if (!body) { + return { + clickedId: -1, + content: '', + isBlind: false, + }; + } + + return { + clickedId: REPORT_CATEGORIES.indexOf(body.category), + ...body, + }; +} diff --git a/src/features/feed-reports/ui/FeedReportsForm.tsx b/src/features/feed-reports/ui/FeedReportsForm.tsx index fbe6e3e..6e00007 100644 --- a/src/features/feed-reports/ui/FeedReportsForm.tsx +++ b/src/features/feed-reports/ui/FeedReportsForm.tsx @@ -1,9 +1,8 @@ -import { useInput, useToggle } from '@/shared/hooks'; import { Icon } from '@/shared/ui'; import { useSubmitReports } from '../api'; import { MAX_REPORT_CONTENT_LENGTH, REPORT_CATEGORIES } from '../consts'; -import { useReportCategories } from '../model'; +import { useReportForm } from '../model'; import { ConfirmReportModal } from './ConfirmReportModal'; import './FeedReportsForm.scss'; @@ -17,29 +16,31 @@ export const FeedReportsForm: React.FC = ({ feedId, onClose, }) => { - const { clickedId, handleClickCategory } = useReportCategories(); - const [content, handleInputContent] = useInput(); - const [isBlind, toggleBlind] = useToggle(false); + const { reportFeed, isPending } = useSubmitReports(feedId); + const { + clickedId, + content, + isBlind, + isDisabledReportForm, + handleClickCategory, + handleInputContent, + toggleBlind, + createReportBody, + } = useReportForm(feedId); - const { reportFeedAsync, isPending } = useSubmitReports(feedId); - - const handleSubmitReports = async (event: React.FormEvent) => { + const handleSubmitReports = (event: React.FormEvent) => { event.preventDefault(); + if (isDisabledReportForm || isPending) return; - const body = { - category: REPORT_CATEGORIES[clickedId], - content, - isBlind, - }; - - await reportFeedAsync(body); + const body = createReportBody(); + reportFeed(body); onClose(); }; return ( {/* 신고 카테고리 */} diff --git a/src/shared/react-query/consts/client.ts b/src/shared/react-query/consts/client.ts index a2e6b12..0a02b81 100644 --- a/src/shared/react-query/consts/client.ts +++ b/src/shared/react-query/consts/client.ts @@ -7,6 +7,7 @@ import { import { handleMutationError, + handleMutationSuccess, handleQueryError, handleQuerySuccess, } from '../dir'; @@ -25,7 +26,8 @@ const queryClientOptions: QueryClientConfig = { onError: (_, query) => handleQueryError(query), }), mutationCache: new MutationCache({ - onError: () => handleMutationError(), + onSuccess: (_, __, ___, mutation) => handleMutationSuccess(mutation), + onError: (_, __, ___, mutation) => handleMutationError(mutation), }), }; export const queryClient = new QueryClient(queryClientOptions); diff --git a/src/shared/react-query/dir/handleQuery.ts b/src/shared/react-query/dir/handleQuery.ts index 8ec0036..f4b021c 100644 --- a/src/shared/react-query/dir/handleQuery.ts +++ b/src/shared/react-query/dir/handleQuery.ts @@ -1,4 +1,4 @@ -import { Query, QueryKey } from '@tanstack/react-query'; +import { Mutation, Query, QueryKey } from '@tanstack/react-query'; import { removeToastHandler, showToastHandler } from '@/shared/toast'; @@ -24,9 +24,23 @@ export function handleQueryError( showToastHandler('caution', '인터넷 연결이 불안정해요'); } +export function handleMutationSuccess( + mutation: Mutation, +) { + const { options } = mutation; + + if (options.mutationKey?.includes('feed-report')) + showToastHandler('siren', '신고가 접수되었어요'); +} + /** * 뮤테이션 에러 핸들러 */ -export function handleMutationError() { - return; +export function handleMutationError( + mutation: Mutation, +) { + const { options } = mutation; + + if (options.mutationKey?.includes('feed-report')) + showToastHandler('caution', '다시 시도해 주세요'); } diff --git a/src/shared/ui/icon/consts/sprite.ts b/src/shared/ui/icon/consts/sprite.ts index 7e59643..2d52ff0 100644 --- a/src/shared/ui/icon/consts/sprite.ts +++ b/src/shared/ui/icon/consts/sprite.ts @@ -15,4 +15,5 @@ export type IconName = | 'checkbox-circle_on' | 'checkbox-square_on' | 'checkbox-square_off' - | 'check_mint'; + | 'check_mint' + | 'siren'; diff --git a/src/widgets/feed-main-list/ui/FeedMainList.tsx b/src/widgets/feed-main-list/ui/FeedMainList.tsx index d9f75f1..f716db9 100644 --- a/src/widgets/feed-main-list/ui/FeedMainList.tsx +++ b/src/widgets/feed-main-list/ui/FeedMainList.tsx @@ -33,17 +33,15 @@ export const FeedMainList = () => {
{feeds?.pages.map((pageData) => { - return pageData.data.feeds.map((feed) => - hiddenFeeds.get(feed.id) ? ( - + return pageData.data.feeds.map((feed) => { + const hiddenType = hiddenFeeds.get(feed.id); + + return hiddenType ? ( + ) : ( - ), - ); + ); + }); })} {!isFetching && (