diff --git a/src/app/my/page.tsx b/src/app/my/page.tsx index c6ce3d3d..20bb37af 100644 --- a/src/app/my/page.tsx +++ b/src/app/my/page.tsx @@ -7,6 +7,7 @@ import Tab from "@/widgets/mypage/ui/Tab.tsx/Tab"; import { apiFetch } from "@/shared/api/fetcher"; import { useModalStore } from "@/shared/model/modal.store"; import { usePostCreateModal } from "@/features/createPost/lib/usePostCreateModal"; +import { PostStatus } from "@/entities/post/model/types/post"; const options = [ { label: "판매중 상품", value: "selling" }, @@ -27,6 +28,7 @@ interface PostListItem { chatCount: number; viewCount: number; thumbnail: string; + status: PostStatus; } const Mypage = () => { diff --git a/src/entities/chat/lib/useChatPost.ts b/src/entities/chat/lib/useChatPost.ts index 30cab466..a1aa3c87 100644 --- a/src/entities/chat/lib/useChatPost.ts +++ b/src/entities/chat/lib/useChatPost.ts @@ -2,12 +2,10 @@ import { useEffect, useState } from "react"; import { PostDetail } from "@/entities/post/model/types/post"; import { apiFetch } from "@/shared/api/fetcher"; import { useModalStore } from "@/shared/model/modal.store"; - export const useChatPost = (postingId: number) => { const [post, setPost] = useState(null); const [isLoading, setIsLoading] = useState(false); const { openModal, closeModal } = useModalStore(); - useEffect(() => { async function fetchPost() { try { diff --git a/src/entities/chat/lib/useChatSocket.ts b/src/entities/chat/lib/useChatSocket.ts index f5c38b98..6169f11b 100644 --- a/src/entities/chat/lib/useChatSocket.ts +++ b/src/entities/chat/lib/useChatSocket.ts @@ -1,11 +1,16 @@ import { useEffect, useRef } from "react"; import { ChatSocket } from "../model/socket"; -import { MessageProps } from "../model/types"; +import { DealStatus, MessageProps } from "../model/types"; +import { PostStatus } from "@/entities/post/model/types/post"; export const useChatSocket = ( chatId: number | null, pushMessageToCache: (msg: MessageProps) => void, scrollToBottom?: () => void, + onDealUpdate?: (update: { + postStatus: PostStatus; + dealStatus: DealStatus; + }) => void, ) => { const socketRef = useRef(null); const isConnectedRef = useRef(false); @@ -22,6 +27,21 @@ export const useChatSocket = ( pushMessageToCache(msg); requestAnimationFrame(() => scrollToBottom?.()); }, + onDealUpdate: (update) => { + onDealUpdate?.({ + postStatus: update.postStatus, + dealStatus: update.dealStatus, + }); + pushMessageToCache({ + messageId: Date.now(), + type: "system", + content: update.message, + isMine: false, + sendAt: new Date().toISOString(), + isRead: true, + }); + requestAnimationFrame(() => scrollToBottom?.()); + }, onSystem: (sys) => console.log("[System]", sys.message), onClose: (code) => { console.log("[Socket] Closed:", code); diff --git a/src/entities/chat/lib/useDealStatus.ts b/src/entities/chat/lib/useDealStatus.ts new file mode 100644 index 00000000..689c7365 --- /dev/null +++ b/src/entities/chat/lib/useDealStatus.ts @@ -0,0 +1,107 @@ +import { useState, useEffect } from "react"; +import { useQueryClient, useMutation } from "@tanstack/react-query"; +import { apiFetch } from "@/shared/api/fetcher"; +import { Chat, DealStatus } from "../model/types"; +import { PostStatus, PostDetail } from "@/entities/post/model/types/post"; +import { useModalStore } from "@/shared/model/modal.store"; +1; + +interface DealResponse { + chatId: number; + postingId: number; + sellerId: number; + buyerId: number; + dealStatus: DealStatus; + postStatus: PostStatus; + changedBy: number; + changedAt: string; +} + +export const useDealStatus = ( + chatId: number | null, + initialPostStatus: PostStatus, + initialDealStatus: DealStatus, +) => { + const queryClient = useQueryClient(); + const [postStatus, setPostStatus] = useState(initialPostStatus); + const [dealStatus, setDealStatus] = useState(initialDealStatus); + + useEffect(() => { + setPostStatus(initialPostStatus); + }, [initialPostStatus]); + + useEffect(() => { + setDealStatus(initialDealStatus); + }, [initialDealStatus]); + + const applyUpdate = (update: { + postStatus: PostStatus; + dealStatus: DealStatus; + }) => { + setPostStatus(update.postStatus); + setDealStatus(update.dealStatus); + }; + + const { openModal, closeModal } = useModalStore(); + const { mutate: onDealChange, isPending: isLoading } = useMutation< + DealResponse, + Error, + DealStatus + >({ + mutationFn: async (nextStatus: DealStatus) => { + if (!chatId) { + throw new Error("아직 채팅이 시작되지 않았습니다."); + } + return await apiFetch(`/api/chat/${chatId}/deal`, { + method: "PATCH", + body: JSON.stringify({ status: nextStatus }), + }); + }, + onSuccess: (res) => { + //optimistic update + const { + postStatus: updatedPostStatus, + dealStatus: updatedDealStatus, + postingId, + sellerId, + } = res; + + setPostStatus(updatedPostStatus); + setDealStatus(updatedDealStatus); + queryClient.setQueryData(["postDetail", postingId], (old) => { + if (!old) return old; + return { + ...old, + status: updatedPostStatus, + }; + }); + + const roles = [undefined, "buyer", "seller"]; + roles.forEach((role) => { + queryClient.setQueryData(["chats", role], (oldChats) => { + if (!oldChats) return oldChats; + + return oldChats.map((chat) => + chat.postingId === postingId + ? { ...chat, status: updatedDealStatus } + : chat, + ); + }); + }); + }, + onError: () => { + openModal("normal", { + message: "거래 상태 변경에 실패했습니다.", + onClick: closeModal, + }); + }, + }); + + return { + postStatus, + dealStatus, + isLoading, + onDealChange, + applyUpdate, + }; +}; diff --git a/src/entities/chat/model/socket.ts b/src/entities/chat/model/socket.ts index 702de261..55e53c9f 100644 --- a/src/entities/chat/model/socket.ts +++ b/src/entities/chat/model/socket.ts @@ -1,12 +1,18 @@ import { error } from "console"; -import { MessageProps } from "./types"; +import { DealStatus, MessageProps } from "./types"; import { useAuthStore } from "@/features/auth/model/auth.store"; import { refreshAccessToken } from "@/shared/api/refresh"; +import { PostStatus } from "@/entities/post/model/types/post"; export interface ChatSocketEvents { onOpen?: () => void; onMessage?: (message: MessageProps) => void; onSystem?: (system: { type: string; message: string }) => void; + onDealUpdate?: (update: { + postStatus: PostStatus; + dealStatus: DealStatus; + message: string; + }) => void; onClose?: (code: number, reason?: string) => void; onError?: (event: Event) => void; } @@ -45,6 +51,15 @@ export class ChatSocket { this.socket.onmessage = (event) => { try { const data = JSON.parse(event.data); + if (data.type === "deal_update") { + this.events.onDealUpdate?.({ + dealStatus: data.dealStatus, + postStatus: data.postStatus, + message: data.systemMessage, + }); + return; + } + if (["welcome", "system", "read"].includes(data.type)) { this.events.onSystem?.(data); return; diff --git a/src/entities/chat/model/types.ts b/src/entities/chat/model/types.ts index 2b49aa33..8dacc277 100644 --- a/src/entities/chat/model/types.ts +++ b/src/entities/chat/model/types.ts @@ -1,3 +1,5 @@ +export type DealStatus = "ACTIVE" | "RESERVED" | "COMPLETED"; + export interface Chat { chatId: number; postingId: number; @@ -5,7 +7,7 @@ export interface Chat { role: string; lastMessage: MessageProps; createdAt: string; - status: string; + status: DealStatus; otherId: number; otherNickname: string; otherImage: string; diff --git a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx index df147a93..9dc302fd 100644 --- a/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx +++ b/src/entities/chat/ui/ChattingRoom/ChattingRoom.tsx @@ -6,11 +6,15 @@ import { useModalStore } from "@/shared/model/modal.store"; import Button from "@/shared/ui/Button/Button"; import { TextField } from "@/shared/ui/TextField/TextField"; import DeleteIcon from "@/shared/icons/delete.svg"; +import PostStatusBadge from "@/entities/post/ui/badge/PostStatusBadge"; +import DealActionPanel from "@/features/deal/ui/DealActionPanel/DealActionPanel"; import { apiFetch } from "@/shared/api/fetcher"; import { useChatMessages } from "../../lib/useChatMessages"; import { useChatSocket } from "../../lib/useChatSocket"; +import { useDealStatus } from "../../lib/useDealStatus"; import { useInfiniteScroll } from "@/shared/lib/useInfiniteScroll"; +import { DealStatus } from "../../model/types"; import { getPostDetail } from "@/entities/post/api/getPostDetail"; import { getUser } from "@/entities/user/api/getUser"; @@ -20,10 +24,12 @@ export const ChattingRoom = ({ postingId, otherId, chatId: initialChatId, + status: dealStatus, }: { postingId: number; otherId: number; chatId?: number; + status?: DealStatus; }) => { const [chatId, setChatId] = useState(initialChatId ?? null); const { @@ -55,6 +61,18 @@ export const ChattingRoom = ({ messagesEndRef, } = useChatMessages(chatId); + const { + postStatus: currentPostStatus, + dealStatus: currentDealStatus, + isLoading: isDealLoading, + onDealChange, + applyUpdate, + } = useDealStatus( + chatId ?? null, + post?.status ?? "SELLING", + dealStatus ?? "ACTIVE", + ); + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; @@ -72,6 +90,7 @@ export const ChattingRoom = ({ chatId, pushMessageToCache, scrollToBottom, + applyUpdate, ); const { openModal, closeModal } = useModalStore(); @@ -201,11 +220,28 @@ export const ChattingRoom = ({ /> {/* 제목과 가격 세로 정렬 */}
- {post?.title} + + {post?.title} + {post && ( + + )} + {post?.price.toLocaleString("ko-KR") + " 원"}
+ +
+ {post && ( + + )} +
{/* 메시지 리스트 영역 */} diff --git a/src/entities/post/model/types/post.ts b/src/entities/post/model/types/post.ts index a86a271d..a8e41bd7 100644 --- a/src/entities/post/model/types/post.ts +++ b/src/entities/post/model/types/post.ts @@ -1,3 +1,4 @@ +export type PostStatus = "SELLING" | "RESERVED" | "SOLD"; export interface Post { postingId: number; title: string; @@ -9,6 +10,7 @@ export interface Post { chatCount: number; viewCount: number; thumbnail: string; + status: PostStatus; } export interface PostDetail { @@ -26,4 +28,5 @@ export interface PostDetail { images: string[]; isOwner: boolean; isFavorite: boolean; + status: PostStatus; } diff --git a/src/entities/post/ui/badge/PostStatusBadge.tsx b/src/entities/post/ui/badge/PostStatusBadge.tsx new file mode 100644 index 00000000..ae9d6aa2 --- /dev/null +++ b/src/entities/post/ui/badge/PostStatusBadge.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import cn from "@/shared/lib/cn"; +import { PostStatus } from "../../model/types/post"; + +const postStatusMessageMap: Record = { + SELLING: "판매 중", + RESERVED: "예약 중", + SOLD: "판매 완료", +}; + +const postStatusColorMap: Record = { + SELLING: "bg-linear-to-r from-[#5097fa] to-[#5363ff] text-[#F1F1F5]", + RESERVED: "bg-white text-[#5097FA]", + SOLD: "bg-black text-[#9FA6B2]", +}; + +interface PostStatusBadgeProps { + status: PostStatus; + className?: string; +} + +const PostStatusBadge = ({ status, className }: PostStatusBadgeProps) => { + const message = postStatusMessageMap[status]; + const colorClass = postStatusColorMap[status]; + + return ( + + {message} + + ); +}; + +export default PostStatusBadge; diff --git a/src/entities/post/ui/card/PostCard.stories.ts b/src/entities/post/ui/card/PostCard.stories.ts index a604515d..57f0be50 100644 --- a/src/entities/post/ui/card/PostCard.stories.ts +++ b/src/entities/post/ui/card/PostCard.stories.ts @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/nextjs-vite"; import PostCard from "./PostCard"; +import { PostStatus } from "../../model/types/post"; const meta: Meta = { title: "Post/PostCard", @@ -16,6 +17,10 @@ const meta: Meta = { chatCount: { control: "number" }, viewCount: { control: "number" }, thumbnail: { control: "text" }, + status: { + control: { type: "radio" }, + options: ["SELLING", "RESERVED", "SOLD"], + }, }, }; @@ -33,6 +38,7 @@ export const Mobile: Story = { chatCount: 2, viewCount: 101, thumbnail: "", + status: "SELLING", }, globals: { viewport: { value: "mobile2", isRotated: false }, @@ -51,6 +57,7 @@ export const Tablet: Story = { chatCount: 2, viewCount: 101, thumbnail: "", + status: "RESERVED", }, globals: { viewport: { value: "tablet", isRotated: false }, @@ -69,6 +76,7 @@ export const Desktop: Story = { chatCount: 2, viewCount: 101, thumbnail: "", + status: "SOLD", }, globals: { viewport: { value: "desktop", isRotated: false }, diff --git a/src/entities/post/ui/card/PostCard.tsx b/src/entities/post/ui/card/PostCard.tsx index 213525f5..ff12f884 100644 --- a/src/entities/post/ui/card/PostCard.tsx +++ b/src/entities/post/ui/card/PostCard.tsx @@ -1,5 +1,7 @@ import Image from "next/image"; import Link from "next/link"; +import PostStatusBadge from "../badge/PostStatusBadge"; +import { PostStatus } from "../../model/types/post"; interface PostCardProps { postingId: number; @@ -12,6 +14,7 @@ interface PostCardProps { chatCount: number; viewCount: number; thumbnail: string; + status: PostStatus; } const PostCard = ({ @@ -22,6 +25,7 @@ const PostCard = ({ chatCount, viewCount, thumbnail, + status, }: PostCardProps) => { return ( @@ -47,7 +51,7 @@ const PostCard = ({

{title}

- +

{price?.toLocaleString()} 원

diff --git a/src/features/chat/model/chat.store.ts b/src/features/chat/model/chat.store.ts index b169be3b..70212a43 100644 --- a/src/features/chat/model/chat.store.ts +++ b/src/features/chat/model/chat.store.ts @@ -1,3 +1,4 @@ +import { DealStatus } from "@/entities/chat/model/types"; import { create } from "zustand"; interface ChatState { @@ -7,6 +8,7 @@ interface ChatState { postingId: number; otherId: number; chatId?: number; + status?: DealStatus; } | null; mount: (info?: ChatState["chatInfo"]) => void; diff --git a/src/features/chat/ui/ChatContainer.tsx b/src/features/chat/ui/ChatContainer.tsx index 5203e833..fc7e1fba 100644 --- a/src/features/chat/ui/ChatContainer.tsx +++ b/src/features/chat/ui/ChatContainer.tsx @@ -102,6 +102,7 @@ export default function ChatContainer() { postingId={chatInfo.postingId} otherId={chatInfo.otherId} chatId={chatInfo.chatId} + status={chatInfo.status} /> ) : ( setChatInfo(info)} /> diff --git a/src/features/chat/ui/ChatList.tsx b/src/features/chat/ui/ChatList.tsx index 4eca9fe4..8959d2f3 100644 --- a/src/features/chat/ui/ChatList.tsx +++ b/src/features/chat/ui/ChatList.tsx @@ -4,12 +4,14 @@ import ChatItem from "@/entities/chat/ui/ChatItem"; import { fetchChatList } from "../model/chat.api"; import type { Chat } from "@/entities/chat/model/types"; import { useQuery } from "@tanstack/react-query"; +import { DealStatus } from "@/entities/chat/model/types"; interface ChatListProps { onSelect: (info: { postingId: number; otherId: number; chatId?: number; + status?: DealStatus; }) => void; tab?: "all" | "buyer" | "seller"; } @@ -52,6 +54,7 @@ const ChatList = ({ onSelect, tab = "all" }: ChatListProps) => { postingId: chat.postingId, otherId: chat.otherId, chatId: chat.chatId, + status: chat.status, }) } /> diff --git a/src/features/deal/lib/resolveDealAction.ts b/src/features/deal/lib/resolveDealAction.ts new file mode 100644 index 00000000..b0945639 --- /dev/null +++ b/src/features/deal/lib/resolveDealAction.ts @@ -0,0 +1,49 @@ +import { PostStatus } from "@/entities/post/model/types/post"; +import { DealStatus } from "@/entities/chat/model/types"; + +type DealAction = + | { type: "single"; next: DealStatus; label: string } + | { + type: "double"; + nextA: DealStatus; + nextB: DealStatus; + labelA: string; + labelB: string; + } + | { type: "none" }; + +const resolveDealAction = ( + isOwner: boolean, + postStatus: PostStatus, + dealStatus: DealStatus, +): DealAction => { + // 판매자일 때 + if (isOwner) { + // 판매중 → 구매 요청 가능 + if (postStatus === "SELLING" && dealStatus === "ACTIVE") { + return { type: "single", next: "RESERVED", label: "예약하기" }; + } + + // 예약중 → 예약취소 가능 + if (postStatus === "RESERVED" && dealStatus === "RESERVED") { + return { type: "single", next: "ACTIVE", label: "예약취소" }; + } + } + + // 구매자일 때 + if (!isOwner) { + if (postStatus === "RESERVED" && dealStatus === "RESERVED") { + return { + type: "double", + nextA: "COMPLETED", + labelA: "구매확정", + nextB: "ACTIVE", + labelB: "구매취소", + }; + } + } + + return { type: "none" }; +}; + +export default resolveDealAction; diff --git a/src/features/deal/ui/DealActionPanel/DealActionPanel.tsx b/src/features/deal/ui/DealActionPanel/DealActionPanel.tsx new file mode 100644 index 00000000..221fca80 --- /dev/null +++ b/src/features/deal/ui/DealActionPanel/DealActionPanel.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { PostStatus } from "@/entities/post/model/types/post"; +import { DealStatus } from "@/entities/chat/model/types"; +import resolveDealAction from "../../lib/resolveDealAction"; + +import Button from "@/shared/ui/Button/Button"; + +interface DealActionPanelProps { + isOwner: boolean; + postStatus: PostStatus; + dealStatus: DealStatus; + onDealChange: (nextStatus: DealStatus) => void; + isLoading: boolean; +} + +const DealActionPanel = ({ + isOwner, + postStatus, + dealStatus, + onDealChange, + isLoading, +}: DealActionPanelProps) => { + const buttonClass = + "w-[70px] text-[14px] py-2 md:w-[70px] md:text-[14px] md:py-2 xl:w-[70px] xl:text-[14px] xl:py-2"; + const doubleButtonClass = + "w-[50px] text-[12px] py-5 md:w-[50px] md:text-[12px] md:py-5 xl:w-[50px] xl:text-[12px] xl:py-5"; + const action = resolveDealAction(isOwner, postStatus, dealStatus); + const getLoadingText = (s: DealStatus) => { + switch (s) { + case "RESERVED": + return "예약 처리중..."; + case "ACTIVE": + return "취소 처리중..."; + case "COMPLETED": + return "구매 확정중..."; + default: + return "처리중..."; + } + }; + + // 클릭 함수 + const handleClick = (next: DealStatus) => { + onDealChange(next); + }; + + if (action.type === "none") return null; + if (action.type === "single") { + return ( + + ); + } + + if (action.type === "double") { + return ( +
+ + + +
+ ); + } +}; + +export default DealActionPanel; diff --git a/src/widgets/postDetail/ui/PostDetailSection.tsx b/src/widgets/postDetail/ui/PostDetailSection.tsx index 8bfa29ba..5d44440a 100644 --- a/src/widgets/postDetail/ui/PostDetailSection.tsx +++ b/src/widgets/postDetail/ui/PostDetailSection.tsx @@ -16,6 +16,7 @@ import { usePostEditModal } from "@/features/editPost/lib/usePostEditModal"; import { useChatStore } from "@/features/chat/model/chat.store"; import { useModalStore } from "@/shared/model/modal.store"; import { PostDetail } from "@/entities/post/model/types/post"; +import PostStatusBadge from "@/entities/post/ui/badge/PostStatusBadge"; export function PostDetailSection({ post }: { post: PostDetail }) { const router = useRouter(); @@ -111,12 +112,16 @@ export function PostDetailSection({ post }: { post: PostDetail }) {
-
+

{post.title}

{post.price.toLocaleString()}

{post.content}

+