Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion public/icons/delete.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 9 additions & 4 deletions src/app/detail/[postingId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import Link from "next/link";

import { apiFetch } from "@/shared/api/fetcher";
import { POST_PAGE_SIZE } from "@/entities/post/model/constants/api";
Expand All @@ -12,6 +11,7 @@ import { useAuthStore } from "@/features/auth/model/auth.store";
import { useModalStore } from "@/shared/model/modal.store";
import { usePostEditModal } from "@/features/editPost/lib/usePostEditModal";
import { useLike } from "@/features/like/lib/useLike";
import { useChatStore } from "@/features/chat/model/chat.store";

import PostCard from "@/entities/post/ui/card/PostCard";
import PostCarousel from "@/entities/post/ui/carousel/PostCarousel";
Expand All @@ -24,10 +24,11 @@ import type { User } from "@/entities/user/model/types/user";
export default function DetailPage() {
const router = useRouter();
const params = useParams();
const postingId = params?.postingId as string;
const postingId = Number(params?.postingId);

const isLogined = useAuthStore((state) => state.isLogined);
const { openModal, closeModal } = useModalStore();
const openChat = useChatStore((state) => state.mount);

const [post, setPost] = useState<PostDetail | null>(null);
const [isPostLoading, setIsPostLoading] = useState(true);
Expand Down Expand Up @@ -165,8 +166,12 @@ export default function DetailPage() {
}, [page]);

const handleChatClick = () => {
if (!isLogined) router.push("/login");
else router.push("/chat");
if (!isLogined) {
router.push("/login");
return;
}
if (!post) return;
openChat({ postingId, otherId: post.sellerId });
};

const handleEditClick = () => {
Expand Down
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import localFont from "next/font/local";
import "@/shared/styles/globals.css";
import Header from "@/widgets/header/ui/Header";
import { ModalContainer } from "@/shared/ui/ModalContainer/ModalContainer";
import ChatContainer from "@/features/chat/ui/ChatContainer";
const myFont = localFont({
src: "../shared/fonts/PretendardVariable.woff2",
});
Expand All @@ -23,6 +24,7 @@ export default function RootLayout({
<Header />
{children}
<div id="modal-root" />
<ChatContainer />
<ModalContainer />
</body>
</html>
Expand Down
12 changes: 12 additions & 0 deletions src/features/chat/model/chat.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { apiFetch } from "@/shared/api/fetcher";
import type { Chat } from "@/entities/chat/model/types";

export async function fetchChatList(role?: "buyer" | "seller") {
const query = new URLSearchParams();
if (role) query.set("role", role);

const data = await apiFetch<Chat[]>(`/api/chat/me?${query.toString()}`, {
method: "GET",
});
return data;
}
31 changes: 31 additions & 0 deletions src/features/chat/model/chat.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { create } from "zustand";

interface ChatState {
isOpen: boolean;
isVisible: boolean;
chatInfo: {
postingId: number;
otherId: number;
chatId?: number;
} | null;

mount: (info?: ChatState["chatInfo"]) => void;
hide: () => void;
unmount: () => void;
setChatInfo: (info?: ChatState["chatInfo"]) => void;
}

export const useChatStore = create<ChatState>((set) => ({
isOpen: false,
isVisible: false,
chatInfo: null,

mount: (info) => {
set({ isOpen: true, chatInfo: info ?? null });
requestAnimationFrame(() => set({ isVisible: true }));
},

hide: () => set({ isVisible: false }),
unmount: () => set({ isOpen: false, chatInfo: null }),
setChatInfo: (info) => set({ chatInfo: info ?? null }),
}));
115 changes: 115 additions & 0 deletions src/features/chat/ui/ChatContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useChatStore } from "@/features/chat/model/chat.store";
import ChatList from "@/features/chat/ui/ChatList";
import { ChattingRoom } from "@/entities/chat/ui/ChattingRoom/ChattingRoom";
import cn from "@/shared/lib/cn";

type TabKey = "all" | "buyer" | "seller";
const TABS: { key: TabKey; label: string }[] = [
{ key: "all", label: "전체" },
{ key: "buyer", label: "구매" },
{ key: "seller", label: "판매" },
];

export default function ChatContainer() {
const { isOpen, isVisible, hide, unmount, chatInfo, setChatInfo } =
useChatStore();

const [tab, setTab] = useState<TabKey>("all");
const [mounted, setMounted] = useState(false);

const handleTransitionEnd = (e: React.TransitionEvent<HTMLElement>) => {
if (e.propertyName !== "opacity") return;
if (!isVisible) unmount();
};

useEffect(() => setMounted(true), []);

if (!mounted || !isOpen) return null;

return createPortal(
<div>
<div
className={`fixed inset-0 z-[900] bg-black/40 transition-opacity ${
isVisible ? "opacity-100" : "opacity-0"
}`}
onClick={hide}
onTransitionEnd={handleTransitionEnd}
/>

<aside
role="dialog"
aria-modal="true"
className={`fixed top-0 right-0 z-[901] h-[100dvh] w-full max-w-[520px] transform bg-[#1F1F28] shadow-2xl transition-transform ease-out ${
isVisible ? "translate-x-0" : "translate-x-full"
}`}
>
<header className="flex h-[60px] items-center justify-between border-b border-white/10 px-4 py-3">
{chatInfo && (
<button
onClick={() => setChatInfo(null)}
className="cursor-pointer rounded px-2 py-1 text-white/80"
>
</button>
)}
<h2 className="text-md font-semibold text-white">
{chatInfo ? "채팅방" : "채팅"}
</h2>
<button onClick={hide}>
<img src="/icons/delete.svg" alt="" className="h-4 w-4" />
</button>
</header>

<div className="flex h-[calc(100dvh-60px)] flex-col">
{!chatInfo && (
<div className="relative border-b border-white/10 px-2">
<div className="relative flex gap-1">
{TABS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
className={cn(
"w-full px-3 py-2 text-sm",
tab === key
? "text-white"
: "text-white/60 hover:text-white/80",
)}
>
{label}
</button>
))}
<span
className="absolute -bottom-[1px] left-0 h-[2px] bg-white transition-transform duration-200"
style={{
width: `${100 / TABS.length}%`,
transform: `translateX(${
TABS.findIndex((t) => t.key === tab) * 100
}%)`,
}}
/>
</div>
</div>
)}

<div className="min-h-0 flex-1 overflow-y-auto px-2">
{chatInfo ? (
<ChattingRoom
postingId={chatInfo.postingId}
otherId={chatInfo.otherId}
chatId={chatInfo.chatId}
/>
) : (
<ChatList tab={tab} onSelect={(info) => setChatInfo(info)} />
)}
</div>
</div>
</aside>
</div>,
document.body,
);
}
75 changes: 75 additions & 0 deletions src/features/chat/ui/ChatList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { useEffect, useState } from "react";
import ChatItem from "@/entities/chat/ui/ChatItem";
import { fetchChatList } from "../model/chat.api";
import type { Chat } from "@/entities/chat/model/types";

const ChatList = ({
onSelect,
tab,
}: {
onSelect: (info: {
postingId: number;
otherId: number;
chatId?: number;
}) => void;
tab?: "all" | "buyer" | "seller";
}) => {
const [chats, setChats] = useState<Chat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const load = async () => {
setLoading(true);
setError(null);
try {
const role =
tab === "all" ? undefined : tab === "buyer" ? "buyer" : "seller";
const data = await fetchChatList(role);
console.log(data);
setChats(data);
} catch (err) {
console.error("채팅 목록 불러오기 실패:", err);
setError("채팅 목록을 불러오는 중 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
load();
}, [tab]);

if (loading) {
return <p className="p-4 text-center text-white/70">불러오는 중...</p>;
}

if (error) {
return <p className="p-4 text-center text-red-400">{error}</p>;
}

if (!chats?.length) {
return (
<p className="p-4 text-center text-white/70">채팅 내역이 없습니다.</p>
);
}
return (
<div className="flex flex-col divide-y divide-gray-600">
{chats.map((chat) => (
<ChatItem
key={chat.chatId}
chat={chat}
onClick={() =>
onSelect({
postingId: chat.postingId,
otherId: chat.otherId,
chatId: chat.chatId,
})
}
/>
))}
</div>
);
};

export default ChatList;
20 changes: 0 additions & 20 deletions src/features/chatList/ui/ChatList.tsx

This file was deleted.

Loading