diff --git a/package.json b/package.json index 1c24977..a30c007 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@tanstack/react-query": "^5.90.17", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "jwt-decode": "^4.0.0", "ky": "^1.14.2", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e05cb55..baafd03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 ky: specifier: ^1.14.2 version: 1.14.2 @@ -1747,6 +1750,10 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4278,6 +4285,8 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 diff --git a/src/page/post-detail-page.tsx b/src/page/post-detail-page.tsx index 83bffb0..1b74e09 100644 --- a/src/page/post-detail-page.tsx +++ b/src/page/post-detail-page.tsx @@ -5,24 +5,20 @@ import UploadIcon from '@shared/assets/icon/upload.svg?react'; import { Carousel } from '@widgets/postDetail/carousel/carousel'; import { DetailInfo } from '@widgets/postDetail/detail-info'; import { Comment } from '@widgets/postDetail/comment/comment'; -import type { CommentItemProps } from '@widgets/postDetail/comment/comment-item'; import Input from '@shared/ui/input'; import { FloatingActionButton } from '@shared/ui/floatingActionButton'; import SendIcon from '@shared/assets/icon/send.svg?react'; import { Button } from '@shared/ui/button'; import { useQuery } from '@tanstack/react-query'; import { FEED_QUERY_OPTIONS } from '@shared/api/domain/feeds/query'; - -const mockComments: CommentItemProps[] = [ - { - id: 1, - author: '승택', - time: '19시간 전', - value: '제발 저요!!!', - parentId: null, - type: 'user', - }, -]; +import { useMutation } from '@tanstack/react-query'; +import { + PARTICIPATION_MUTATION_OPTIONS, + PARTICIPATION_QUERY_OPTIONS, +} from '@shared/api/domain/participations/query'; +import { getMyMemberId } from '@shared/utils/auth'; +import { COMMENT_QUERY_OPTIONS } from '@shared/api/domain/comments/query'; +import { queryClient } from '@app/providers/query-client'; const handleShare = async () => { const url = window.location.href; @@ -44,6 +40,57 @@ const PostDetailPage = () => { const navigate = useNavigate(); const { feedId } = useParams(); const numericFeedId = Number(feedId); + const { mutate: applyParticipation } = useMutation({ + ...PARTICIPATION_MUTATION_OPTIONS.APPLY(), + onSuccess: () => { + console.log('✅ 참가 신청 성공'); + + queryClient.invalidateQueries({ + queryKey: ['participations', numericFeedId], + }); + + queryClient.invalidateQueries({ + queryKey: ['comments', numericFeedId], + }); + }, + + onError: (error) => { + console.error('❌ 참가 신청 실패:', error); + }, + }); + const { data: participants } = useQuery( + PARTICIPATION_QUERY_OPTIONS.LIST(numericFeedId), + ); + const { data: commentsData } = useQuery( + COMMENT_QUERY_OPTIONS.LIST(numericFeedId), + ); + const { mutate: approveParticipation } = useMutation({ + ...PARTICIPATION_MUTATION_OPTIONS.APPROVE(), + + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['participations', numericFeedId], + }); + + queryClient.invalidateQueries({ + queryKey: ['comments', numericFeedId], + }); + }, + }); + + const { mutate: rejectParticipation } = useMutation({ + ...PARTICIPATION_MUTATION_OPTIONS.REJECT(), + + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['participations', numericFeedId], + }); + + queryClient.invalidateQueries({ + queryKey: ['comments', numericFeedId], + }); + }, + }); const { data, isLoading } = useQuery( FEED_QUERY_OPTIONS.DETAIL(numericFeedId), @@ -52,12 +99,14 @@ const PostDetailPage = () => { if (!data) return
no data
; - const isOwner = false; - const isApplied = false; + const myId = getMyMemberId(); + + const isOwner = data.writer?.writerId === myId; + const isApplied = participants?.some((p) => p.applicantId === myId) ?? false; const isClosed = false; const canApply = !isOwner && !isApplied && !isClosed; - console.log('feed detail:', data); + return (
{

{data.description}

- {/* 🔥 댓글 (아직 mock) */}
- + { + if (status === 'approved') { + approveParticipation(participationId); + } else { + rejectParticipation(participationId); + } + }} + />
{canApply && (
- -
- - } - /> -
+
)} +
+ + } + /> +
); }; diff --git a/src/shared/api/domain/comments/query.ts b/src/shared/api/domain/comments/query.ts new file mode 100644 index 0000000..5a5f5bc --- /dev/null +++ b/src/shared/api/domain/comments/query.ts @@ -0,0 +1,16 @@ +import { queryOptions } from '@tanstack/react-query'; +import { api } from '@shared/api/config/instance'; +import { END_POINT } from '@shared/api/end-point'; +import type { GetCommentsResponse } from '@shared/types/comments/type'; + +const getComments = async (feedId: number) => { + return api.get(END_POINT.FEED.COMMENTS(feedId)).json(); +}; + +export const COMMENT_QUERY_OPTIONS = { + LIST: (feedId: number) => + queryOptions({ + queryKey: ['comments', feedId], + queryFn: () => getComments(feedId), + }), +}; diff --git a/src/shared/api/domain/feeds/query.ts b/src/shared/api/domain/feeds/query.ts index 400085f..5c0d3d1 100644 --- a/src/shared/api/domain/feeds/query.ts +++ b/src/shared/api/domain/feeds/query.ts @@ -33,6 +33,7 @@ export const FEED_QUERY_OPTIONS = { queryKey: FEED_QUERY_KEY.DETAIL(feedId), queryFn: () => getFeedDetail(feedId), enabled: !!feedId, + staleTime: 0, }), }; diff --git a/src/shared/api/domain/participations/query.ts b/src/shared/api/domain/participations/query.ts new file mode 100644 index 0000000..81641f3 --- /dev/null +++ b/src/shared/api/domain/participations/query.ts @@ -0,0 +1,47 @@ +import { mutationOptions, queryOptions } from '@tanstack/react-query'; +import { api } from '@shared/api/config/instance'; +import { END_POINT } from '@shared/api/end-point'; +import type { GetParticipantsResponse } from '@shared/types/participations/type'; +const applyParticipation = async (feedId: number) => { + return api.post(END_POINT.FEED.PARTICIPATION(feedId)).json(); +}; +const approveParticipation = async (id: number) => { + return api.patch(END_POINT.PARTICIPATION.APPROVE(id)).json(); +}; + +const rejectParticipation = async (id: number) => { + return api.patch(END_POINT.PARTICIPATION.REJECT(id)).json(); +}; + +const getParticipants = async ( + feedId: number, +): Promise => { + return api + .get(END_POINT.FEED.PARTICIPATION(feedId)) + .json(); +}; + +export const PARTICIPATION_MUTATION_OPTIONS = { + APPLY: () => + mutationOptions({ + mutationFn: applyParticipation, + }), + + APPROVE: () => + mutationOptions({ + mutationFn: approveParticipation, + }), + + REJECT: () => + mutationOptions({ + mutationFn: rejectParticipation, + }), +}; + +export const PARTICIPATION_QUERY_OPTIONS = { + LIST: (feedId: number) => + queryOptions({ + queryKey: ['participations', feedId], + queryFn: () => getParticipants(feedId), + }), +}; diff --git a/src/shared/api/end-point.ts b/src/shared/api/end-point.ts index 3fbe654..8150945 100644 --- a/src/shared/api/end-point.ts +++ b/src/shared/api/end-point.ts @@ -2,7 +2,14 @@ export const END_POINT = { FEED: { LIST: 'api/feeds', DETAIL: (feedId: number) => `api/feeds/${feedId}`, + PARTICIPATION: (feedId: number) => `api/feeds/${feedId}/participations`, + COMMENTS: (feedId: number) => `api/feeds/${feedId}/comments`, // ✅ 추가 }, + PARTICIPATION: { + APPROVE: (id: number) => `api/participations/${id}/approve`, + REJECT: (id: number) => `api/participations/${id}/reject`, + }, + S3: { PRESIGNED_UPLOAD: 'api/s3/presigned-upload', }, diff --git a/src/shared/types/comments/type.ts b/src/shared/types/comments/type.ts new file mode 100644 index 0000000..07858eb --- /dev/null +++ b/src/shared/types/comments/type.ts @@ -0,0 +1,4 @@ +import type { paths } from '@shared/types/schema'; + +export type GetCommentsResponse = + paths['/api/feeds/{feedId}/comments']['get']['responses']['200']['content']['*/*']; diff --git a/src/shared/types/participations/type.ts b/src/shared/types/participations/type.ts new file mode 100644 index 0000000..ec0f8bc --- /dev/null +++ b/src/shared/types/participations/type.ts @@ -0,0 +1,4 @@ +import type { paths } from '@shared/types/schema'; + +export type GetParticipantsResponse = + paths['/api/feeds/{feedId}/participations']['get']['responses']['200']['content']['*/*']; diff --git a/src/shared/utils/auth.ts b/src/shared/utils/auth.ts new file mode 100644 index 0000000..98c4882 --- /dev/null +++ b/src/shared/utils/auth.ts @@ -0,0 +1,9 @@ +import { jwtDecode } from 'jwt-decode'; + +export const getMyMemberId = () => { + const token = localStorage.getItem('accessToken'); + if (!token) return null; + + const decoded: any = jwtDecode(token); + return decoded.memberId; +}; diff --git a/src/widgets/postDetail/comment/comment-item.tsx b/src/widgets/postDetail/comment/comment-item.tsx index 2d093bd..e59a74d 100644 --- a/src/widgets/postDetail/comment/comment-item.tsx +++ b/src/widgets/postDetail/comment/comment-item.tsx @@ -1,64 +1,81 @@ import MessageIcon from '@shared/assets/icon/message-square.svg?react'; import { Chip, type ApprovalStatus } from '@widgets/postDetail/chip/chip'; + export interface CommentItemProps { - id: number; - author: string; - time: string; - value: string; + commentId?: number; + nickname?: string; + description?: string; parentId?: number | null; - type?: 'user' | 'system'; + commentType?: string; + depth?: number; + memberId?: number; + participationId?: number; + createdAt?: string; +} - status?: ApprovalStatus; - applicantId?: number; +export interface Participant { + participationId?: number; + status?: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELED'; } interface CommentItemUIProps extends CommentItemProps { isOwner?: boolean; + participants?: Participant[]; onChangeApproval?: ( - commentId: number, + participationId: number, status: Exclude, ) => void; } export function CommentItem({ - author, - time, - value, - type = 'user', - status = 'pending', + nickname, + description, + commentType = 'USER', isOwner = false, onChangeApproval, - id, + participationId, + participants, }: CommentItemUIProps) { - const isSystem = type === 'system'; + const isSystem = commentType !== 'USER'; + + const participation = participants?.find( + (p) => p.participationId === participationId, + ); + + let computedStatus: ApprovalStatus = 'pending'; + + if (participation?.status === 'APPROVED') computedStatus = 'approved'; + if (participation?.status === 'REJECTED') computedStatus = 'rejected'; return (
- {/* 상단 라인: 작성자/시간 + (system이면 chip 우측) */}
- {author} - {time} + {nickname}
{isSystem && (isOwner ? ( onChangeApproval?.(id, next)} + status={computedStatus} + onChange={(next) => { + if (!participationId) return; + + console.log('🔥 participationId:', participationId); + onChangeApproval?.(participationId, next); + }} /> ) : ( - + ))}
- {/* 본문 */}
-

{value}

+

{description}

{!isSystem && (