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 (
- {/* 🔥 댓글 (아직 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 && (