diff --git a/vite-project/.env.development b/vite-project/.env.development
deleted file mode 100644
index 887a088a..00000000
--- a/vite-project/.env.development
+++ /dev/null
@@ -1 +0,0 @@
-VITE_API_BASE_URL=https://panda-market-api.vercel.app
\ No newline at end of file
diff --git a/vite-project/.env.production b/vite-project/.env.production
deleted file mode 100644
index 887a088a..00000000
--- a/vite-project/.env.production
+++ /dev/null
@@ -1 +0,0 @@
-VITE_API_BASE_URL=https://panda-market-api.vercel.app
\ No newline at end of file
diff --git a/vite-project/.gitignore b/vite-project/.gitignore
index a547bf36..a3ab6e36 100644
--- a/vite-project/.gitignore
+++ b/vite-project/.gitignore
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
+
+.env.*
\ No newline at end of file
diff --git a/vite-project/src/api/api.js b/vite-project/src/api/api.js
new file mode 100644
index 00000000..1dc0828f
--- /dev/null
+++ b/vite-project/src/api/api.js
@@ -0,0 +1,22 @@
+function trimPath(str) {
+ let trimmed = str.trim();
+ while (trimmed.startsWith("/")) {
+ trimmed = trimmed.slice(1);
+ }
+ return trimmed;
+}
+
+export function createUrl(path, params) {
+ const trimmed = trimPath(path);
+ const url = new URL(`${import.meta.env.VITE_API_BASE_URL}/${trimmed}`);
+
+ if (!params) {
+ return url;
+ }
+
+ Object.keys(params).forEach((key) =>
+ url.searchParams.append(key, params[key])
+ );
+
+ return url;
+}
diff --git a/vite-project/src/api/comments-mock.json b/vite-project/src/api/comments-mock.json
new file mode 100644
index 00000000..c2c22343
--- /dev/null
+++ b/vite-project/src/api/comments-mock.json
@@ -0,0 +1,60 @@
+{
+ "nextCursor": 0,
+ "list": [
+ {
+ "writer": {
+ "image": "",
+ "nickname": "똑똑한 판다",
+ "id": 1
+ },
+ "updatedAt": "2024-08-06T02:57:22.000Z",
+ "createdAt": "2024-08-06T02:57:22.000Z",
+ "content": "혹시 사용 기간이 어떻게 되실까요?",
+ "id": 1
+ },
+ {
+ "writer": {
+ "image": "",
+ "nickname": "똑똑한 고양이",
+ "id": 2
+ },
+ "updatedAt": "2025-07-06T02:57:22.000Z",
+ "createdAt": "2025-07-06T02:57:22.000Z",
+ "content": "잘 작동하나요??",
+ "id": 2
+ },
+ {
+ "writer": {
+ "image": "",
+ "nickname": "똑똑한 강아지",
+ "id": 3
+ },
+ "updatedAt": "2025-08-03T02:57:22.000Z",
+ "createdAt": "2025-08-03T02:57:22.000Z",
+ "content": "저도 사고싶어요!",
+ "id": 3
+ },
+ {
+ "writer": {
+ "image": "",
+ "nickname": "똑똑한 고양이",
+ "id": 2
+ },
+ "updatedAt": "2025-08-06T02:57:22.000Z",
+ "createdAt": "2025-08-06T02:57:22.000Z",
+ "content": "제가 샀습니다.",
+ "id": 4
+ },
+ {
+ "writer": {
+ "image": "",
+ "nickname": "똑똑한 토끼",
+ "id": 4
+ },
+ "updatedAt": "2025-08-06T07:10:22.000Z",
+ "createdAt": "2025-08-06T07:10:22.000Z",
+ "content": "까비...",
+ "id": 5
+ }
+ ]
+}
diff --git a/vite-project/src/api/comments.js b/vite-project/src/api/comments.js
new file mode 100644
index 00000000..c7114311
--- /dev/null
+++ b/vite-project/src/api/comments.js
@@ -0,0 +1,15 @@
+import mock from "./comments-mock.json";
+
+export async function fetchComments(productId) {
+ // const url = createUrl(`products/${productId}/comments`, { limit });
+ // const response = await fetch(url);
+ // if (!response.ok) {
+ // throw new Error("Failed to fetch comments");
+ // }
+
+ // const json = await response.json();
+ // return json.list;
+
+ if (!productId) return;
+ return mock.list;
+}
diff --git a/vite-project/src/api/products.js b/vite-project/src/api/products.js
index fe32ac72..b73847af 100644
--- a/vite-project/src/api/products.js
+++ b/vite-project/src/api/products.js
@@ -1,19 +1,23 @@
+import { createUrl } from "./api";
+
export async function fetchProducts({
keyword = "",
page = 1,
pageSize = 10,
orderBy = "recent",
} = {}) {
- const url = new URL(`${import.meta.env.VITE_API_BASE_URL}/products`);
- url.searchParams.append("page", page);
- url.searchParams.append("pageSize", pageSize);
- url.searchParams.append("orderBy", orderBy);
+ const params = {
+ page,
+ pageSize,
+ orderBy,
+ };
+
if (keyword) {
- url.searchParams.append("keyword", keyword);
+ params.keyword = keyword;
}
+ const url = createUrl("products", params);
const response = await fetch(url);
-
if (!response.ok) {
throw new Error("Failed to fetch products");
}
@@ -24,3 +28,14 @@ export async function fetchProducts({
numberOfPages: Math.ceil(json.totalCount / pageSize),
};
}
+
+export async function fetchProduct(id) {
+ const url = createUrl(`products/${id}`);
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error("Failed to fetch product");
+ }
+
+ const json = await response.json();
+ return json;
+}
diff --git a/vite-project/src/assets/comments-empty.png b/vite-project/src/assets/comments-empty.png
new file mode 100644
index 00000000..25019b5a
Binary files /dev/null and b/vite-project/src/assets/comments-empty.png differ
diff --git a/vite-project/src/assets/ic-arrow-back.svg b/vite-project/src/assets/ic-arrow-back.svg
new file mode 100644
index 00000000..9ba5ad94
--- /dev/null
+++ b/vite-project/src/assets/ic-arrow-back.svg
@@ -0,0 +1,4 @@
+
diff --git a/vite-project/src/assets/ic-dots-3-vertical.svg b/vite-project/src/assets/ic-dots-3-vertical.svg
new file mode 100644
index 00000000..dd7ed7f5
--- /dev/null
+++ b/vite-project/src/assets/ic-dots-3-vertical.svg
@@ -0,0 +1,5 @@
+
diff --git a/vite-project/src/components/Button.jsx b/vite-project/src/components/Button.jsx
deleted file mode 100644
index 4019d336..00000000
--- a/vite-project/src/components/Button.jsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import styled from "styled-components";
-
-const StyledButton = styled.button`
- background-color: ${({ disabled }) =>
- disabled ? "var(--color-cool-gray-400)" : "var(--color-primary-100)"};
- padding: 8px 23px;
- color: var(--color-cool-gray-100);
- font-size: 16px;
- font-weight: 600;
- line-height: 26px;
- border-radius: 8px;
- border: none;
- cursor: ${({ disabled }) => (disabled ? "default" : "pointer")};
-`;
-
-function Button({ children, ...props }) {
- return {children};
-}
-
-export default Button;
diff --git a/vite-project/src/components/Nav.jsx b/vite-project/src/components/Nav.jsx
index ed76fa9c..a95cbe47 100644
--- a/vite-project/src/components/Nav.jsx
+++ b/vite-project/src/components/Nav.jsx
@@ -1,9 +1,8 @@
import { NavLink, useLocation } from "react-router-dom";
import largeLogo from "../assets/logo-large.svg";
import smallLogo from "../assets/logo-small.svg";
-import profileImg from "../assets/profile-default.svg";
-
import "./Nav.css";
+import Avatar from "./avatar";
function NavigationLink({ to, activePaths = [], children }) {
const location = useLocation();
@@ -40,9 +39,7 @@ function Nav() {
중고마켓
-
-

-
+
);
diff --git a/vite-project/src/components/OrderBySelect.css b/vite-project/src/components/OrderBySelect.css
index 8c006ce8..42f4f3a2 100644
--- a/vite-project/src/components/OrderBySelect.css
+++ b/vite-project/src/components/OrderBySelect.css
@@ -16,7 +16,7 @@
.OrderBySelect {
background-color: white;
- padding: 12px 20px;
+ padding: 8px 20px;
display: flex;
gap: 24px;
outline: none;
diff --git a/vite-project/src/components/SearchInput.css b/vite-project/src/components/SearchInput.css
deleted file mode 100644
index bf0b8a14..00000000
--- a/vite-project/src/components/SearchInput.css
+++ /dev/null
@@ -1,47 +0,0 @@
-@import url("../palette.css");
-
-.SearchInput {
- background-color: var(--color-secondary-100);
- padding: 9px 16px;
- display: flex;
- gap: 4px;
- border-radius: 12px;
- width: 325px;
-}
-
-.SearchInput img {
- width: 24px;
-}
-
-.SearchInput input {
- border: none;
- background: none;
- font-size: 16px;
- font-weight: 400;
- line-height: 26px;
- flex-grow: 1;
-}
-.SearchInput input:focus {
- outline: none;
-}
-.SearchInput input::placeholder {
- color: var(--color-secondary-400);
- font-size: 16px;
- font-weight: 400;
- line-height: 26px;
-}
-
-/* Tablet */
-@media (max-width: 1199px) {
- .SearchInput {
- width: 242px;
- }
-}
-
-/* Mobile */
-@media (max-width: 767px) {
- .SearchInput {
- width: 100%;
- flex-grow: 1;
- }
-}
diff --git a/vite-project/src/components/SearchInput.jsx b/vite-project/src/components/SearchInput.jsx
deleted file mode 100644
index 8f0e0b6d..00000000
--- a/vite-project/src/components/SearchInput.jsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import searchImg from "../assets/ic-magnifier.svg";
-import "./SearchInput.css";
-
-function SearchInput({ placeholder }) {
- return (
-
-

-
-
- );
-}
-
-export default SearchInput;
diff --git a/vite-project/src/components/add-item/adding-item-image.jsx b/vite-project/src/components/add-item/adding-item-image.jsx
index 8c4f3b8c..20729d78 100644
--- a/vite-project/src/components/add-item/adding-item-image.jsx
+++ b/vite-project/src/components/add-item/adding-item-image.jsx
@@ -3,11 +3,12 @@ import removeImg from "../../assets/ic-xmark-fill.svg";
const StyledAddingItemImage = styled.div`
width: 282px;
+ height: 282px;
position: relative;
& > img {
width: 100%;
- aspect-ratio: 1;
+ height: 100%;
object-fit: cover;
border-radius: 12px;
}
@@ -25,10 +26,12 @@ const StyledAddingItemImage = styled.div`
@media (max-width: 1199px) {
width: 168px;
+ height: 168px;
}
@media (max-width: 767px) {
width: 50%;
+ height: auto;
}
`;
diff --git a/vite-project/src/components/avatar.jsx b/vite-project/src/components/avatar.jsx
new file mode 100644
index 00000000..c8773a6a
--- /dev/null
+++ b/vite-project/src/components/avatar.jsx
@@ -0,0 +1,26 @@
+import styled from "styled-components";
+import defaultImg from "../assets/profile-default.svg";
+
+const StyledAvatar = styled.div`
+ width: ${({ $size }) => $size}px;
+ height: ${({ $size }) => $size}px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ border-radius: 20;
+ }
+`;
+
+const DEFAULT_SIZE = 40;
+
+function Avatar({ imageUrl, size = DEFAULT_SIZE }) {
+ return (
+
+
+
+ );
+}
+
+export default Avatar;
diff --git a/vite-project/src/components/button/button-styles.js b/vite-project/src/components/button/button-styles.js
new file mode 100644
index 00000000..29da424a
--- /dev/null
+++ b/vite-project/src/components/button/button-styles.js
@@ -0,0 +1,9 @@
+export const BUTTON_SIZE = {
+ medium: "medium",
+ small: "small",
+};
+
+export const BUTTON_TYPE = {
+ round: "round",
+ pill: "pill",
+};
diff --git a/vite-project/src/components/button/button.jsx b/vite-project/src/components/button/button.jsx
new file mode 100644
index 00000000..18602a68
--- /dev/null
+++ b/vite-project/src/components/button/button.jsx
@@ -0,0 +1,52 @@
+import styled from "styled-components";
+import { BUTTON_SIZE, BUTTON_TYPE } from "./button-styles";
+
+function padding(buttonSize) {
+ return buttonSize === BUTTON_SIZE.medium ? "11px 40px" : "8px 23px";
+}
+
+function fontSize(buttonSize) {
+ return buttonSize === BUTTON_SIZE.medium ? 18 : 16;
+}
+
+function borderRadius(buttonType) {
+ return buttonType === BUTTON_TYPE.round ? 8 : 24;
+}
+
+const StyledButton = styled.button`
+ display: flex;
+ gap: 8px;
+ background-color: var(--color-primary-100);
+ padding: ${({ $size }) => padding($size)};
+ color: var(--color-cool-gray-100);
+ font-size: ${({ $size }) => fontSize($size)}px;
+ font-weight: 600;
+ line-height: 26px;
+ border-radius: ${({ $type }) => borderRadius($type)}px;
+ border: none;
+ cursor: pointer;
+
+ &:disabled {
+ background-color: var(--color-cool-gray-400);
+ cursor: default;
+ }
+
+ &:hover:not(:disabled) {
+ opacity: 0.5;
+ }
+`;
+
+function Button({
+ children,
+ size = BUTTON_SIZE.small,
+ type = BUTTON_TYPE.round,
+ ...props
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default Button;
diff --git a/vite-project/src/components/button/icon-button.jsx b/vite-project/src/components/button/icon-button.jsx
new file mode 100644
index 00000000..71d1af15
--- /dev/null
+++ b/vite-project/src/components/button/icon-button.jsx
@@ -0,0 +1,25 @@
+import styled from "styled-components";
+
+const StyledIconButton = styled.button`
+ width: ${({ $size }) => $size}px;
+ height: ${({ $size }) => $size}px;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+
+ img {
+ width: 100%;
+ height: 100%;
+ }
+`;
+
+function IconButton({ className, src, size = 24, onClick }) {
+ return (
+
+
+
+ );
+}
+
+export default IconButton;
diff --git a/vite-project/src/components/favorite-button.jsx b/vite-project/src/components/favorite-button.jsx
new file mode 100644
index 00000000..7c1f2290
--- /dev/null
+++ b/vite-project/src/components/favorite-button.jsx
@@ -0,0 +1,42 @@
+import styled from "styled-components";
+import favoriteImg from "../assets/ic-heart.svg";
+
+const StyledFavoriteButton = styled.div`
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ border: 1px solid var(--color-secondary-200);
+ border-radius: 40px;
+ background: none;
+ padding: 4px 12px;
+
+ img {
+ width: 32px;
+ height: 32px;
+ }
+
+ span {
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 26px;
+ color: var(--color-cool-gray-500);
+ }
+
+ @media (max-width: 1199px) {
+ div {
+ width: 24px;
+ height: 24px;
+ }
+ }
+`;
+
+function FavoriteButton({ count, isFavorite }) {
+ return (
+
+
+ {count}
+
+ );
+}
+
+export default FavoriteButton;
diff --git a/vite-project/src/components/input/search-input.jsx b/vite-project/src/components/input/search-input.jsx
new file mode 100644
index 00000000..58963e11
--- /dev/null
+++ b/vite-project/src/components/input/search-input.jsx
@@ -0,0 +1,53 @@
+import styled from "styled-components";
+import searchImg from "../../assets/ic-magnifier.svg";
+
+const StyledSearchInput = styled.div`
+ background-color: var(--color-secondary-100);
+ padding: 9px 16px;
+ display: flex;
+ gap: 4px;
+ border-radius: 12px;
+ width: 325px;
+
+ img {
+ width: 24px;
+ }
+
+ input {
+ border: none;
+ background: none;
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 26px;
+ flex-grow: 1;
+ }
+ input:focus {
+ outline: none;
+ }
+ input::placeholder {
+ color: var(--color-secondary-400);
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 26px;
+ }
+
+ @media (max-width: 1199px) {
+ width: 242px;
+ }
+
+ @media (max-width: 767px) {
+ width: 100%;
+ flex-grow: 1;
+ }
+`;
+
+function SearchInput({ placeholder }) {
+ return (
+
+
+
+
+ );
+}
+
+export default SearchInput;
diff --git a/vite-project/src/components/text-input.jsx b/vite-project/src/components/input/text-input.jsx
similarity index 90%
rename from vite-project/src/components/text-input.jsx
rename to vite-project/src/components/input/text-input.jsx
index 986e0316..8a3c79c2 100644
--- a/vite-project/src/components/text-input.jsx
+++ b/vite-project/src/components/input/text-input.jsx
@@ -24,7 +24,6 @@ const StyledTextInput = styled.div`
background-color: var(--color-secondary-100);
padding: 16px 24px;
border-radius: 12px;
- ${({ $multiline }) => ($multiline ? "height: 282px;" : "")}
input {
${inputStyle}
@@ -51,7 +50,7 @@ const StyledTextInput = styled.div`
function TextInput({ multiline = false, ...props }) {
return (
-
+
{multiline ? : }
);
diff --git a/vite-project/src/components/item-comment/Item-comment-list.jsx b/vite-project/src/components/item-comment/Item-comment-list.jsx
new file mode 100644
index 00000000..d8944c93
--- /dev/null
+++ b/vite-project/src/components/item-comment/Item-comment-list.jsx
@@ -0,0 +1,192 @@
+import { useMemo, useRef, useState } from "react";
+import styled from "styled-components";
+import moreImg from "../../assets/ic-dots-3-vertical.svg";
+import { formatElapsedTime } from "../../utils/formatter";
+import Button from "../button/button";
+import IconButton from "../button/icon-button";
+import TextInput from "../input/text-input";
+import UserProfileCard from "../user-profile-card/user-profile-card";
+import { USER_PROFILE_CARD_SIZE } from "../user-profile-card/user-profile-card-size";
+
+const StyledItemComment = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ $isEditing }) => ($isEditing ? 16 : 24)}px;
+ position: relative;
+ padding-bottom: ${({ $isEditing }) => ($isEditing ? 24 : 12)}px;
+ border-bottom: 1px solid var(--color-secondary-300);
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ line-height: 24px;
+ }
+`;
+
+const BottomContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const EditingButtonContainer = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 24px;
+
+ button:first-child {
+ background: none;
+ border: none;
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 26px;
+ color: var(--color-gray-500);
+ cursor: pointer;
+ }
+`;
+
+const MoreButton = styled(IconButton)`
+ position: absolute;
+ top: 0;
+ right: 0;
+`;
+
+const Dropdown = styled.div`
+ min-width: 139px;
+ border: 1px solid var(--color-cool-gray-300);
+ border-radius: 8px;
+ position: absolute;
+ top: 34px;
+ right: 0;
+ background-color: white;
+ z-index: 1;
+
+ button {
+ display: block;
+ font-size: 16px;
+ line-height: 26px;
+ text-align: center;
+ color: var(--color-secondary-500);
+ height: 45px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: none;
+ border: none;
+ width: 100%;
+ cursor: pointer;
+ }
+`;
+
+function CommentListItem({ writer, updatedAt, content, onEdit, onDelete }) {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const inputRef = useRef();
+
+ const handleInputChange = () => {
+ // TODO: Comment 등록 구현
+ };
+
+ const handleMoreClick = () => {
+ setIsMenuOpen((prev) => !prev);
+ };
+
+ const handleEditClick = () => {
+ setIsMenuOpen(false);
+ setIsEditing(true);
+ };
+
+ const handleDeleteClick = () => {
+ setIsMenuOpen(false);
+ onDelete();
+ };
+
+ const handleCancelClick = () => {
+ setIsEditing(false);
+ };
+
+ const handleEditDoneClick = () => {
+ setIsEditing(false);
+ onEdit(inputRef.current.value);
+ };
+
+ return (
+
+ {isEditing ? (
+
+ ) : (
+ {content}
+ )}
+
+
+ {isEditing && (
+
+
+
+
+ )}
+
+ {isEditing || }
+ {isMenuOpen && (
+
+
+
+
+ )}
+
+ );
+}
+
+const StyledItemCommentList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ @media (max-width: 767px) {
+ gap: 16px;
+ }
+`;
+
+function ItemCommentList({ comments }) {
+ const handleEdit = (/* commentId, content */) => {
+ // TODO: Comment 수정 API 연동
+ };
+
+ const handleDelete = (/* commentId */) => {
+ // TODO: Comment 삭제 API 연동
+ };
+
+ const sortedComments = useMemo(
+ () =>
+ comments.sort(
+ (a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)
+ ),
+ [comments]
+ );
+
+ return (
+
+ {sortedComments.map((comment) => (
+
+ ))}
+
+ );
+}
+
+export default ItemCommentList;
diff --git a/vite-project/src/components/item-comment/item-comment-form.jsx b/vite-project/src/components/item-comment/item-comment-form.jsx
new file mode 100644
index 00000000..7edcf99a
--- /dev/null
+++ b/vite-project/src/components/item-comment/item-comment-form.jsx
@@ -0,0 +1,60 @@
+import { useState } from "react";
+import styled from "styled-components";
+import Button from "../button/button";
+import TextInput from "../input/text-input";
+
+const StyledItemCommentForm = styled.form`
+ display: flex;
+ flex-direction: column;
+
+ label {
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 26px;
+ color: var(--color-secondary-800);
+ }
+
+ div:nth-child(2) {
+ margin-top: 9px;
+ margin-bottom: 16px;
+ }
+
+ button {
+ align-self: flex-end;
+ }
+
+ @media (max-width: 767px) {
+ div:nth-child(2) {
+ margin-top: 16px;
+ }
+ }
+`;
+
+const INPUT_PLACEHOLDER =
+ "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다.";
+
+function ItemCommentForm() {
+ const [comment, setComment] = useState("");
+ const canSubmit = comment.length > 0;
+
+ const handleInputChange = (event) => {
+ setComment(event.target.value);
+ };
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default ItemCommentForm;
diff --git a/vite-project/src/components/item-comment/item-comment.jsx b/vite-project/src/components/item-comment/item-comment.jsx
new file mode 100644
index 00000000..1aef73f5
--- /dev/null
+++ b/vite-project/src/components/item-comment/item-comment.jsx
@@ -0,0 +1,39 @@
+import { useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import styled from "styled-components";
+import { fetchComments } from "../../api/comments";
+import ItemCommentForm from "./item-comment-form";
+import ItemCommentList from "./Item-comment-list";
+import ItemCommentsEmpty from "./item-comments-empty";
+
+const StyledItemComment = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ @media (max-width: 1199px) {
+ gap: 40px;
+ }
+`;
+
+function ItemComment() {
+ const [comments, setComments] = useState([]);
+ const { id } = useParams();
+
+ useEffect(() => {
+ fetchComments(id).then((newComments) => setComments(newComments));
+ }, [id]);
+
+ return (
+
+
+ {comments.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default ItemComment;
diff --git a/vite-project/src/components/item-comment/item-comments-empty.jsx b/vite-project/src/components/item-comment/item-comments-empty.jsx
new file mode 100644
index 00000000..ff0889fb
--- /dev/null
+++ b/vite-project/src/components/item-comment/item-comments-empty.jsx
@@ -0,0 +1,35 @@
+import styled from "styled-components";
+import emptyImg from "../../assets/comments-empty.png";
+
+const StyledItemCommentsEmpty = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-size: 16px;
+ line-height: 26px;
+ color: var(--color-cool-gray-400);
+ margin: 0 auto;
+
+ img {
+ width: 196px;
+ height: 196px;
+ }
+
+ @media (max-width: 1199px) {
+ img {
+ width: 140px;
+ height: 140px;
+ }
+ }
+`;
+
+function ItemCommentsEmpty() {
+ return (
+
+
+ 아직 문의가 없어요.
+
+ );
+}
+
+export default ItemCommentsEmpty;
diff --git a/vite-project/src/components/item-detail/item-detail-section.jsx b/vite-project/src/components/item-detail/item-detail-section.jsx
new file mode 100644
index 00000000..e0b4ed80
--- /dev/null
+++ b/vite-project/src/components/item-detail/item-detail-section.jsx
@@ -0,0 +1,43 @@
+import styled from "styled-components";
+
+const StyledItemDetailSection = styled.section`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ h3 {
+ font-size: 16px;
+ font-weight: 600;
+ line-height: 26px;
+ color: var(--color-secondary-800);
+ margin: 0;
+ }
+
+ p {
+ font-size: 16px;
+ line-height: 26px;
+ color: var(--color-secondary-600);
+ margin: 0;
+ }
+
+ @media (max-width: 1199px) {
+ gap: 8px;
+
+ h3 {
+ font-size: 14px;
+ line-height: 24px;
+ }
+ }
+`;
+
+function ItemDetailSection({ title, description, children }) {
+ return (
+
+ {title}
+ {description && {description}
}
+ {children}
+
+ );
+}
+
+export default ItemDetailSection;
diff --git a/vite-project/src/components/item-detail/item-detail-title.jsx b/vite-project/src/components/item-detail/item-detail-title.jsx
new file mode 100644
index 00000000..b5dc2723
--- /dev/null
+++ b/vite-project/src/components/item-detail/item-detail-title.jsx
@@ -0,0 +1,69 @@
+import styled from "styled-components";
+import moreImg from "../../assets/ic-dots-3-vertical.svg";
+import { formatPrice } from "../../utils/formatter";
+import IconButton from "../button/icon-button";
+
+const StyledItemDetailTitle = styled.div``;
+
+const StyledItemTitleHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+`;
+
+const StyledItemName = styled.h2`
+ margin: 0;
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 32px;
+ color: var(--color-secondary-800);
+
+ @media (max-width: 1199px) {
+ font-size: 20px;
+ }
+
+ @media (max-width: 767px) {
+ font-size: 16px;
+ line-height: 26px;
+ }
+`;
+
+const StyledItemPrice = styled.span`
+ display: block;
+ font-size: 40px;
+ font-weight: 600;
+ line-height: 100%;
+ color: var(--color-secondary-800);
+ margin-top: 16px;
+ margin-bottom: 16px;
+
+ @media (max-width: 1199px) {
+ font-size: 32px;
+ line-height: 42px;
+ margin-top: 8px;
+ }
+
+ @media (max-width: 767px) {
+ font-size: 24px;
+ line-height: 32px;
+ }
+`;
+
+const StyledSeparator = styled.div`
+ border-bottom: 1px solid var(--color-cool-gray-200);
+`;
+
+function ItemDetailTitle({ title, price }) {
+ return (
+
+
+ {title}
+
+
+ {formatPrice(price, "원")}
+
+
+ );
+}
+
+export default ItemDetailTitle;
diff --git a/vite-project/src/components/item-detail/item-detail.jsx b/vite-project/src/components/item-detail/item-detail.jsx
new file mode 100644
index 00000000..8736fa8b
--- /dev/null
+++ b/vite-project/src/components/item-detail/item-detail.jsx
@@ -0,0 +1,124 @@
+import { useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import styled from "styled-components";
+import { fetchProduct } from "../../api/products";
+import { formatDateYYYYMMDD } from "../../utils/formatter";
+import FavoriteButton from "../favorite-button";
+import TagList from "../tag/tag-list";
+import UserProfileCard from "../user-profile-card/user-profile-card";
+import ItemDetailSection from "./item-detail-section";
+import ItemDetailTitle from "./item-detail-title";
+
+const StyledItemDetail = styled.div`
+ display: flex;
+ gap: 24px;
+
+ @media (max-width: 1199px) {
+ gap: 16px;
+ }
+
+ @media (max-width: 767px) {
+ flex-direction: column;
+ }
+`;
+
+const ItemImage = styled.div`
+ width: 486px;
+ height: 486px;
+ aspect-ratio: 1 / 1;
+
+ img {
+ width: 100%;
+ height: 100%;
+ border-radius: 16px;
+ object-fit: cover;
+ }
+
+ @media (max-width: 1199px) {
+ width: 340px;
+ height: 340px;
+ }
+
+ @media (max-width: 767px) {
+ width: 100%;
+ height: 100%;
+ }
+`;
+
+const ItemInfo = styled.div`
+ width: 100%;
+`;
+
+const InfoSectionContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ margin-top: 24px;
+
+ @media (max-width: 1199px) {
+ margin-top: 16px;
+ }
+`;
+
+const ProfileContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 24px;
+ margin-top: 62px;
+
+ @media (max-width: 1199px) {
+ margin-top: 40px;
+ }
+`;
+
+const FavoriteContainer = styled.div`
+ border-left: 1px solid var(--color-cool-gray-200);
+ padding-left: 24px;
+`;
+
+function ItemDetail() {
+ const [product, setProduct] = useState(null);
+ const { id } = useParams();
+
+ useEffect(() => {
+ fetchProduct(id).then(setProduct);
+ }, [id]);
+
+ return product ? (
+
+
+ {product.images[0] &&
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ // TODO: Product loading 중 보여줄 UI
+ Loading...
+ );
+}
+
+export default ItemDetail;
diff --git a/vite-project/src/components/item/item.jsx b/vite-project/src/components/item/item.jsx
index cb0fed39..a4f383cf 100644
--- a/vite-project/src/components/item/item.jsx
+++ b/vite-project/src/components/item/item.jsx
@@ -58,7 +58,7 @@ function Item({ imageUrl, title, price, likeCount }) {
{title}
- {formatPrice(price, "원")}
+ {formatPrice(price, "원")}
{likeCount}
diff --git a/vite-project/src/components/item/items-grid.jsx b/vite-project/src/components/item/items-grid.jsx
index ceab0b45..50b48c21 100644
--- a/vite-project/src/components/item/items-grid.jsx
+++ b/vite-project/src/components/item/items-grid.jsx
@@ -1,3 +1,4 @@
+import { Link } from "react-router-dom";
import styled from "styled-components";
import Item from "./item";
@@ -15,13 +16,18 @@ function ItemsGrid({ items, numberOfColumns }) {
return (
{items.map((product) => (
-
+ style={{ textDecoration: "none" }}
+ >
+
+
))}
);
diff --git a/vite-project/src/components/section/section-header-action.jsx b/vite-project/src/components/section/section-header-action.jsx
index 26422767..f25bbdb8 100644
--- a/vite-project/src/components/section/section-header-action.jsx
+++ b/vite-project/src/components/section/section-header-action.jsx
@@ -1,5 +1,5 @@
import styled from "styled-components";
-import Button from "../Button";
+import Button from "../button/button";
const StyledSectionHeaderAction = styled(Button)`
@media (max-width: 767px) {
diff --git a/vite-project/src/components/section/section-header.jsx b/vite-project/src/components/section/section-header.jsx
index 0a419dde..ee3f49b5 100644
--- a/vite-project/src/components/section/section-header.jsx
+++ b/vite-project/src/components/section/section-header.jsx
@@ -17,6 +17,7 @@ const StyledSectionHeader = styled.div`
const StyledSectionHeaderActions = styled.div`
display: flex;
+ align-items: center;
gap: 12px;
@media (max-width: 767px) {
diff --git a/vite-project/src/components/tag/tag-list.jsx b/vite-project/src/components/tag/tag-list.jsx
index a6e64e3e..dfac0325 100644
--- a/vite-project/src/components/tag/tag-list.jsx
+++ b/vite-project/src/components/tag/tag-list.jsx
@@ -4,7 +4,6 @@ import Tag from "./tag";
const StyledTagList = styled.div`
display: flex;
gap: 12px;
- margin-top: 14px;
flex-wrap: wrap;
`;
@@ -12,7 +11,7 @@ function TagList({ tags, onRemove }) {
return (
{tags.map((tag) => (
- onRemove(tag)}>
+ onRemove(tag) : undefined}>
{"#" + tag}
))}
diff --git a/vite-project/src/components/tag/tag.jsx b/vite-project/src/components/tag/tag.jsx
index bcf7813e..b9e29a15 100644
--- a/vite-project/src/components/tag/tag.jsx
+++ b/vite-project/src/components/tag/tag.jsx
@@ -5,11 +5,10 @@ const StyledTag = styled.div`
background-color: var(--color-cool-gray-100);
color: var(--color-secondary-800);
font-size: 16px;
- fontweight: 400;
line-height: 26px;
padding: 5px 0;
padding-left: 16px;
- padding-right: 12px;
+ padding-right: ${({ onRemove }) => (onRemove ? 12 : 16)}px;
border-radius: 26px;
display: flex;
gap: 8px;
@@ -29,9 +28,11 @@ function Tag({ children, onRemove }) {
return (
{children}
-
+ {onRemove && (
+
+ )}
);
}
diff --git a/vite-project/src/components/user-profile-card/user-profile-card-size.js b/vite-project/src/components/user-profile-card/user-profile-card-size.js
new file mode 100644
index 00000000..4df3737c
--- /dev/null
+++ b/vite-project/src/components/user-profile-card/user-profile-card-size.js
@@ -0,0 +1,4 @@
+export const USER_PROFILE_CARD_SIZE = {
+ large: "large",
+ small: "small",
+};
diff --git a/vite-project/src/components/user-profile-card/user-profile-card.jsx b/vite-project/src/components/user-profile-card/user-profile-card.jsx
new file mode 100644
index 00000000..26fe9037
--- /dev/null
+++ b/vite-project/src/components/user-profile-card/user-profile-card.jsx
@@ -0,0 +1,72 @@
+import styled from "styled-components";
+import Avatar from "../avatar";
+import { USER_PROFILE_CARD_SIZE } from "./user-profile-card-size";
+
+function infoGap(cardSize) {
+ return cardSize === USER_PROFILE_CARD_SIZE.large ? 2 : 4;
+}
+
+function fontSize(cardSize) {
+ return cardSize === USER_PROFILE_CARD_SIZE.large ? 14 : 12;
+}
+
+function lineHeight(cardSize) {
+ return cardSize === USER_PROFILE_CARD_SIZE.large ? 24 : 18;
+}
+
+function cardGap(cardSize) {
+ return cardSize === USER_PROFILE_CARD_SIZE.large ? 16 : 8;
+}
+
+function avatarSize(cardSize) {
+ return cardSize === USER_PROFILE_CARD_SIZE.large ? 40 : 32;
+}
+
+const ProfileInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ $size }) => infoGap($size)}px;
+
+ span {
+ font-size: ${({ $size }) => fontSize($size)}px;
+ line-height: ${({ $size }) => lineHeight($size)}px;
+ }
+
+ span:first-child {
+ font-weight: 500;
+ color: var(--color-secondary-600);
+ }
+
+ span:last-child {
+ color: var(--color-cool-gray-400);
+ }
+`;
+
+const StyledUserProfileCard = styled.div`
+ display: flex;
+ align-items: center;
+ gap: ${({ $size }) => cardGap($size)}px;
+
+ ${ProfileInfo} {
+ flex-grow: 1;
+ }
+`;
+
+function UserProfileCard({
+ imageUrl,
+ name,
+ status,
+ size = USER_PROFILE_CARD_SIZE.large,
+}) {
+ return (
+
+
+
+ {name}
+ {status}
+
+
+ );
+}
+
+export default UserProfileCard;
diff --git a/vite-project/src/hooks/useDevice.jsx b/vite-project/src/hooks/useDevice.jsx
index 4de29bee..05156a0e 100644
--- a/vite-project/src/hooks/useDevice.jsx
+++ b/vite-project/src/hooks/useDevice.jsx
@@ -32,7 +32,7 @@ function useDevice() {
matchesDesktop.removeEventListener("change", handleDesktopMatchesChange);
_matchesDesktop = null;
};
- }, []);
+ }, [matchesDesktop]);
useEffect(() => {
const handleTabletMatchesChange = (e) => {
@@ -48,7 +48,7 @@ function useDevice() {
matchesTablet.removeEventListener("change", handleTabletMatchesChange);
_matchesTablet = null;
};
- }, []);
+ }, [matchesTablet]);
useEffect(() => {
const handleMobileMatchesChange = (e) => {
@@ -64,7 +64,7 @@ function useDevice() {
matchesMobile.removeEventListener("change", handleMobileMatchesChange);
_matchesMobile = null;
};
- }, []);
+ }, [matchesMobile]);
return deviceInfo;
}
diff --git a/vite-project/src/main.jsx b/vite-project/src/main.jsx
index e88db0e8..e07e1629 100644
--- a/vite-project/src/main.jsx
+++ b/vite-project/src/main.jsx
@@ -1,6 +1,7 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import App from "./components/App";
import AddItemPage from "./pages/add-item/add-item-page";
+import ItemDetailPage from "./pages/items/item-detail-page";
import ItemsPage from "./pages/items/items-page";
function Main() {
@@ -9,6 +10,7 @@ function Main() {
} />
+ } />
} />
NOT IMPLEMENTED} />
diff --git a/vite-project/src/pages/add-item/add-item-page.jsx b/vite-project/src/pages/add-item/add-item-page.jsx
index 10493852..a24fcdc7 100644
--- a/vite-project/src/pages/add-item/add-item-page.jsx
+++ b/vite-project/src/pages/add-item/add-item-page.jsx
@@ -1,12 +1,12 @@
import { useState } from "react";
import styled from "styled-components";
import AddingItemImageContainer from "../../components/add-item/adding-item-image-container";
+import TextInput from "../../components/input/text-input";
import Section from "../../components/section/section";
import SectionHeader from "../../components/section/section-header";
import SectionHeaderAction from "../../components/section/section-header-action";
import SectionHeaderSize from "../../components/section/section-header-size";
import TagList from "../../components/tag/tag-list";
-import TextInput from "../../components/text-input";
import { formatPrice } from "../../utils/formatter";
const INITIAL_INPUT_VALUES = {
@@ -20,6 +20,12 @@ const StyledAddItemForm = styled.form`
padding-bottom: 69px;
`;
+const StyledTagSectionContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+`;
+
function AddItemPage() {
const [inputValues, setInputValues] = useState(INITIAL_INPUT_VALUES);
const [tags, setTags] = useState([]);
@@ -101,6 +107,7 @@ function AddItemPage() {
placeholder={"상품 소개를 입력해주세요"}
onChange={handleInputChange}
multiline
+ rows="10"
/>
@@ -114,7 +121,7 @@ function AddItemPage() {
diff --git a/vite-project/src/pages/items/item-detail-page.jsx b/vite-project/src/pages/items/item-detail-page.jsx
new file mode 100644
index 00000000..288b9df1
--- /dev/null
+++ b/vite-project/src/pages/items/item-detail-page.jsx
@@ -0,0 +1,73 @@
+import { Link } from "react-router-dom";
+import styled from "styled-components";
+import backImg from "../../assets/ic-arrow-back.svg";
+import Button from "../../components/button/button";
+import {
+ BUTTON_SIZE,
+ BUTTON_TYPE,
+} from "../../components/button/button-styles";
+import ItemComment from "../../components/item-comment/item-comment";
+import ItemDetail from "../../components/item-detail/item-detail";
+
+const StyledItemDetailPage = styled.div`
+ display: flex;
+ flex-direction: column;
+
+ margin-bottom: 200px;
+
+ @media (max-width: 767px) {
+ margin-bottom: 64px;
+ }
+`;
+
+const Separator = styled.div`
+ width: 100%;
+ border-bottom: 1px solid var(--color-cool-gray-200);
+ margin: 40px 0;
+
+ @media (max-width: 1199px) {
+ margin-top: 32px;
+ }
+
+ @media (max-width: 767px) {
+ margin: 24px 0;
+ }
+`;
+
+const StyledLink = styled(Link)`
+ align-self: center;
+ margin-top: 64px;
+ text-decoration: none;
+
+ @media (max-width: 1199px) {
+ margin-top: 48px;
+ }
+
+ @media (max-width: 767px) {
+ margin-top: 40px;
+ }
+`;
+
+function BackButton({ children }) {
+ return (
+
+
+
+ );
+}
+
+function ItemDetailPage() {
+ return (
+
+
+
+
+ 목록으로 돌아가기
+
+ );
+}
+
+export default ItemDetailPage;
diff --git a/vite-project/src/pages/items/items-page.jsx b/vite-project/src/pages/items/items-page.jsx
index 2a606b3f..2c9773fe 100644
--- a/vite-project/src/pages/items/items-page.jsx
+++ b/vite-project/src/pages/items/items-page.jsx
@@ -2,12 +2,12 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { fetchProducts } from "../../api/products";
+import SearchInput from "../../components/input/search-input";
import ItemsGrid from "../../components/item/items-grid";
import OrderBySelect, {
ORDER_BY_DEFAULT,
} from "../../components/OrderBySelect";
import PageControl from "../../components/PageControl";
-import SearchInput from "../../components/SearchInput";
import Section from "../../components/section/section";
import SectionHeader from "../../components/section/section-header";
import SectionHeaderAction from "../../components/section/section-header-action";
diff --git a/vite-project/src/palette.css b/vite-project/src/palette.css
index bbbc00c5..46335f0f 100644
--- a/vite-project/src/palette.css
+++ b/vite-project/src/palette.css
@@ -4,14 +4,21 @@
/* Secondary */
--color-secondary-100: #f3f4f6;
+ --color-secondary-200: #e5e7eb;
+ --color-secondary-300: #e5e7eb;
--color-secondary-400: #9ca3af;
+ --color-secondary-500: #6b7280;
--color-secondary-600: #4b5563;
--color-secondary-800: #1f2937;
--color-secondary-900: #111827;
+ /* Gray */
+ --color-gray-500: #737373;
+
/* Cool Gray */
--color-cool-gray-100: #f3f4f6;
--color-cool-gray-200: #e5e7eb;
+ --color-cool-gray-300: #d1d5db;
--color-cool-gray-400: #9ca3af;
--color-cool-gray-500: #6b7280;
diff --git a/vite-project/src/utils/formatter.js b/vite-project/src/utils/formatter.js
index a1ba1305..c6995d1a 100644
--- a/vite-project/src/utils/formatter.js
+++ b/vite-project/src/utils/formatter.js
@@ -1,3 +1,44 @@
export function formatPrice(price, suffix = "") {
return Intl.NumberFormat().format(price) + suffix;
}
+
+export function formatDateYYYYMMDD(dateString) {
+ const date = new Date(dateString);
+ const isoString = date.toISOString();
+ const index = isoString.indexOf("T");
+ return isoString.slice(0, index);
+}
+
+export function formatElapsedTime(dateString) {
+ const now = new Date();
+ const target = new Date(dateString);
+ let diff = (now.getTime() - target.getTime()) / 1000;
+
+ const resultString = (diff, unit) => `${Math.ceil(diff)}${unit} 전`;
+
+ if (diff < 60) {
+ return resultString(diff, "초");
+ }
+
+ diff /= 60;
+ if (diff < 60) {
+ return resultString(diff, "분");
+ }
+
+ diff /= 60;
+ if (diff < 24) {
+ return resultString(diff, "시간");
+ }
+
+ diff /= 24;
+ if (diff < 30) {
+ return resultString(diff, "일");
+ }
+
+ diff /= 30;
+ if (diff < 12) {
+ return resultString(diff, "개월");
+ }
+
+ return `${now.getFullYear() - target.getFullYear()}년 전`;
+}