diff --git a/backend/prisma_back/app.js b/backend/prisma_back/app.js index bc21492dc..97924ecdf 100644 --- a/backend/prisma_back/app.js +++ b/backend/prisma_back/app.js @@ -197,7 +197,6 @@ app.get( where: { productId }, skip: lastId ? 1 : 0, //cursor 항목 제외 take: parseInt(limit), - cursor: lastId, ...(lastId && { cursor: { id: lastId } }), //lastId를 쿼리로 받으면 커서 사용. //댓글에는 아직 favoriteCount가 없지만 일단 구현했습니다.(쿼리로 전달하지 말아주세요) @@ -231,7 +230,7 @@ app.post( const comment = await prisma.comment.create({ data: { content, - article: { + product: { connect: { id: productId }, }, }, diff --git a/backend/prisma_back/http/comment.http b/backend/prisma_back/http/comment.http index 44e7e807d..629d8866d 100644 --- a/backend/prisma_back/http/comment.http +++ b/backend/prisma_back/http/comment.http @@ -1,5 +1,5 @@ # 게시글 댓글 -GET http://localhost:3000/articles/733653fb-6a4d-4a0d-90ca-31de1072de91/comments +GET http://localhost:4000/articles/b717e4f5-159c-4ca8-81bb-a3397caaf387/comments ### @@ -62,3 +62,10 @@ Content-Type: application/json ### DELETE http://localhost:3000/products/comments/733653fb-6a4d-4a0d-90ca-31de1072de91 + + + +### + +# 상품 댓글 +GET http://localhost:4000/products/b8f11e76-0a9e-4b3f-bccf-8d9b4fbf331e/comments \ No newline at end of file diff --git a/backend/prisma_back/http/products.http b/backend/prisma_back/http/products.http index 0a9857fa5..352bdcaab 100644 --- a/backend/prisma_back/http/products.http +++ b/backend/prisma_back/http/products.http @@ -1,4 +1,4 @@ -GET http://localhost:3000/products +GET http://localhost:4000/products ### diff --git a/backend/prisma_back/prisma/migrations/20251004183544_product_user_name/migration.sql b/backend/prisma_back/prisma/migrations/20251004183544_product_user_name/migration.sql new file mode 100644 index 000000000..8bdbf83f3 --- /dev/null +++ b/backend/prisma_back/prisma/migrations/20251004183544_product_user_name/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Product" ADD COLUMN "userName" TEXT NOT NULL DEFAULT '판매자판다'; diff --git a/backend/prisma_back/prisma/schema.prisma b/backend/prisma_back/prisma/schema.prisma index 7b784722a..e13ec4d47 100644 --- a/backend/prisma_back/prisma/schema.prisma +++ b/backend/prisma_back/prisma/schema.prisma @@ -44,6 +44,7 @@ model Product { name String price Int description String + userName String @default("판매자판다") tags String[] images String[] favoriteCount Int @default(0) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2aad1efd1..bd36e74c0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "next": "15.5.3", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -6055,6 +6056,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 7d58dd31f..db6ec77af 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,15 +9,16 @@ "lint": "eslint" }, "dependencies": { + "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", - "next": "15.5.3" + "zustand": "^5.0.8" }, "devDependencies": { + "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.5.3", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4" } } diff --git a/frontend/public/images/logo/logo.svg b/frontend/public/images/logo/logo.svg new file mode 100644 index 000000000..d497acbfe --- /dev/null +++ b/frontend/public/images/logo/logo.svg @@ -0,0 +1,15 @@ + diff --git a/frontend/src/api/Auth.js b/frontend/src/api/Auth.js new file mode 100644 index 000000000..9bc1d70ac --- /dev/null +++ b/frontend/src/api/Auth.js @@ -0,0 +1,68 @@ +const url = "https://panda-market-api.vercel.app/"; + + +export async function login(body){ + const res = await fetch(`${url}/auth/signIn`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.json(); + }) + .then((res) => res.data) + .catch((err)=> { + console.log(`에러: ${err.message}`); + return err.message; + }); + + return res; +} + +export async function signup(body){ + const res = await fetch(`${url}/auth/signUp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.json(); + }) + .then((res) => res.data) + .catch((err)=> { + console.log(`에러: ${err.message}`); + return err.message; + }); + + return res; +} + +export async function getToken(refreshToken){ + const body = { + refreshToken + } + const res = await fetch(`${url}/auth/refresh-token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then((res) => { + if (!res.ok) { + throw new Error(`HTTP error! status: ${res.status}`); + } + return res.json(); + }) + .then((res) => res.data) + .catch((err)=> { + console.log(`에러: ${err.message}`); + return err.message; + }); + + return res; +} \ No newline at end of file diff --git a/frontend/src/api/ProductService.js b/frontend/src/api/ProductService.js index d19fa2648..2335d47ee 100644 --- a/frontend/src/api/ProductService.js +++ b/frontend/src/api/ProductService.js @@ -1,11 +1,22 @@ //const url = 'https://panda-market-api-crud.vercel.app/products' //판다마켓 코드잇 api (사용시 result.list 경로 사용) -const url = 'http://localhost:4000/products'; //직접 만든 개발용 백엔드 로컬 주소입니다. +const url = 'http://localhost:4000'; //직접 만든 개발용 백엔드 로컬 주소입니다. // const url = 'https://pandamarket-1.onrender.com/api/products' //Render 배포 백엔드 주소입니다. + +//리스폰스 에러만 잡는 코드 (fetch를 써서 필요) +export async function resErrorCatch(res) { + if (!res.ok) { + //404, 500 에러는 리퀘스트 에러가 아니라 리스폰스 에러. fetch는 따로 처리해줘야 합니다.(axois는 같이 담아준다) + const errorMessage = await res.text(); + throw new Error(`리스폰스 에러: ${res.status} - ${errorMessage}`); //수동으로 에러 던지기 + } +} + + //상품 목록 조회 - 요구사항 -async function getProductList(page = 1, pagesize = 10, orderBy = 'recent', keyword = '') { +export async function getProductList(page = 1, pagesize = 10, orderBy = 'recent', keyword = '') { const result = await fetch( - url + `?page=${page}&pageSize=${pagesize}&orderBy=${orderBy}&keyword=${keyword}` + `${url}/products?page=${page}&pageSize=${pagesize}&orderBy=${orderBy}&keyword=${keyword}` ) .then(async (res) => { await resErrorCatch(res); //fetch에서 404, 500 리스폰스 에러는 따로 처리 @@ -16,8 +27,8 @@ async function getProductList(page = 1, pagesize = 10, orderBy = 'recent', keywo } //상품 상세 조회 - 요구사항 (api만) -async function getProduct(id) { - const result = await fetch(url + `/${id}`) +export async function getProduct(id) { + const result = await fetch(`${url}/products/${id}`) .then(async (res) => { await resErrorCatch(res); return res.json(); @@ -27,8 +38,8 @@ async function getProduct(id) { } //상품 등록 - 요구사항 -async function createProduct(RqBody) { - const result = await fetch(url, { +export async function createProduct(RqBody) { + const result = await fetch(`${url}/products`, { method: 'POST', body: JSON.stringify(RqBody), headers: { 'Content-Type': 'application/json' }, @@ -42,8 +53,8 @@ async function createProduct(RqBody) { } //상품 수정 - 요구사항 (api만) -async function patchProduct(id, RqBody) { - const result = await fetch(url + `/${id}`, { +export async function patchProduct(id, RqBody) { + const result = await fetch(`${url}/products/${id}`, { method: 'PATCH', body: JSON.stringify(RqBody), headers: { 'Content-Type': 'application/json' }, @@ -57,8 +68,8 @@ async function patchProduct(id, RqBody) { } //상품 삭제 (api만) -async function deleteProduct(id) { - const result = await fetch(url + `/${id}`, { +export async function deleteProduct(id) { + const result = await fetch(`${url}/products/${id}`, { method: 'DELETE', }) .then(async (res) => { @@ -69,13 +80,47 @@ async function deleteProduct(id) { return result; } -//리스폰스 에러만 잡는 코드 (fetch를 써서 필요) -async function resErrorCatch(res) { - if (!res.ok) { - //404, 500 에러는 리퀘스트 에러가 아니라 리스폰스 에러. fetch는 따로 처리해줘야 합니다.(axois는 같이 담아준다) - const errorMessage = await res.text(); - throw new Error(`리스폰스 에러: ${res.status} - ${errorMessage}`); //수동으로 에러 던지기 - } +export async function getComments(id) { + const result = await fetch(`${url}/products/${id}/comments`) + .then(async (res) => { + await resErrorCatch(res); + return res.json(); + }) + .catch((err) => console.log(err)); + return result; +} + +export async function createComment(id, body) { + const result = await fetch(`${url}/products/${id}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(async (res) => { + await resErrorCatch(res); + return res.json(); + }) + .catch((err) => console.log(err)); + return result; } -export default { getProduct, getProductList, createProduct, patchProduct, deleteProduct }; +export async function updateComment(id, body) { + const result = await fetch(`${url}/comments/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + .then(async (res) => { + await resErrorCatch(res); + return res.json(); + }) + .catch((err) => console.log(err)); + return result; +} + +export async function deleteComment(id) { + const result = await fetch(`${url}/comments/${id}`, { + method: 'DELETE' + }) + return result; +} \ No newline at end of file diff --git a/frontend/src/app/articles/[id]/page.jsx b/frontend/src/app/articles/[id]/page.jsx index 6dcf113bb..b09f32b99 100644 --- a/frontend/src/app/articles/[id]/page.jsx +++ b/frontend/src/app/articles/[id]/page.jsx @@ -19,7 +19,7 @@ import backIcon from './ic_back.svg'; //스타일 import styles from './articlePage.module.css'; -import InputForm from '@/components/molecules/Articles/InputForm/InputForm'; +import InputForm from '@/components/molecules/InputForm/InputForm'; import Button from '@/components/Atoms/Button'; import useAsync from '@/hooks/useAsync'; diff --git a/frontend/src/app/global.css b/frontend/src/app/global.css index 432cb55d0..a957cf0fa 100644 --- a/frontend/src/app/global.css +++ b/frontend/src/app/global.css @@ -23,6 +23,7 @@ --Cool-Gray-500: #6B7280; --Cool-Gray-400: #9CA3AF; --Cool-Gray-200: #e5e7eb; + --Cool-Gray-100: #F3F4F6; --Cool-Gray-50: #f9fafb; /* Secondary */ @@ -46,6 +47,8 @@ /* Primary color */ --brand-blue: #3692ff; + --hover-blue: #1967d6; + --focus-blue: #1251aa; /* Layout dimensions */ --header-height: 70px; diff --git a/frontend/src/app/items/[id]/ic_back.svg b/frontend/src/app/items/[id]/ic_back.svg new file mode 100644 index 000000000..253a47d7b --- /dev/null +++ b/frontend/src/app/items/[id]/ic_back.svg @@ -0,0 +1,4 @@ + diff --git a/frontend/src/app/items/[id]/ic_heart.svg b/frontend/src/app/items/[id]/ic_heart.svg new file mode 100644 index 000000000..72f720d54 --- /dev/null +++ b/frontend/src/app/items/[id]/ic_heart.svg @@ -0,0 +1,3 @@ + diff --git a/frontend/src/app/items/[id]/ic_more.svg b/frontend/src/app/items/[id]/ic_more.svg new file mode 100644 index 000000000..dd7ed7f5e --- /dev/null +++ b/frontend/src/app/items/[id]/ic_more.svg @@ -0,0 +1,5 @@ + diff --git a/frontend/src/app/items/[id]/ic_noComment.svg b/frontend/src/app/items/[id]/ic_noComment.svg new file mode 100644 index 000000000..dbac5014b --- /dev/null +++ b/frontend/src/app/items/[id]/ic_noComment.svg @@ -0,0 +1,10 @@ + diff --git a/frontend/src/app/items/[id]/ic_profile.svg b/frontend/src/app/items/[id]/ic_profile.svg new file mode 100644 index 000000000..ab43f5282 --- /dev/null +++ b/frontend/src/app/items/[id]/ic_profile.svg @@ -0,0 +1,24 @@ + diff --git a/frontend/src/app/items/[id]/item.module.css b/frontend/src/app/items/[id]/item.module.css new file mode 100644 index 000000000..22b797c0e --- /dev/null +++ b/frontend/src/app/items/[id]/item.module.css @@ -0,0 +1,359 @@ +/* mobile */ +.frame { + width: 100%; + height: fit-content; + margin: 24px auto 319px; + padding: 0 16px; +} + +.productDetailBox { + margin-bottom: 40px; +} + +.productDetail { + width: 100%; + height: fit-content; + display: flex; + flex-direction: column; + gap: 24px; + margin-bottom: 24px; +} + +.price { + color: var(--Secondary-800, #1F2937); + font-family: Pretendard; + font-size: 40px; + font-style: normal; + font-weight: 600; + line-height: normal; +} + +.productImageBox { + border-radius: 24px; + overflow: hidden; + width: 100%; + height: 100%; +} + +.productImage { + width: 100%; + height: 100%; +} + +.productInfo { + display: flex; + flex-direction: column; + gap: 24px; +} + +.productInfoContent { + display: flex; + flex-direction: column; + gap: 16px; +} + +.productInfoContent label{ + color: var(--Secondary-600); + font: var(--font-16-semmibold); +} + +.productInfoContent p{ + color: var(--Secondary-600); + font: var(--font-16-regular); +} + +.tag { + width: fit-content; + padding: 6px 16px; + border-radius: 26px; + background-color: var(--Cool-Gray-100); + + color: var(--Secondary-800); + font: var(--font-16-regular); +} + +.headline { + width: 100%; + display: flex; + flex-direction: column; +} + +.titleDiv { + width: 100%; + display: flex; + justify-content: space-between; +} + +.title{ + font: var(--font-20-bold); +} + +.infoDiv { + width: 100%; + height: fit-content; + + display: flex; + justify-content: flex-start; + align-items: center; +} + +.userDiv { + width: fit-content; + height: fit-content; + display: flex; + gap: 16px; + margin: 16px 0; +} + +.articleUserProfile{ + width: 40px; + height: 40px; +} + +.userNameDiv { + display: flex; + gap: 8px; + align-items: center; +} + +.userName { + color: var(--Secondary-600); + font: var(--font-14-medium); +} + +.date { + color: var(--Cool-Gray-400); + font: var(--font-14-regular); +} + +.dividerV { + width: 0; + height: 34px; + border-right: 1px solid var(--Cool-Gray-200); + margin: 0 32px; +} + +.dividerV24 { + width: 0; + height: 34px; + border-right: 1px solid var(--Cool-Gray-200); + margin: 0 24px; +} + +.dividerH { + width: 100%; + border-top: 1px solid var(--Cool-Gray-200); +} + +.favoriteDiv { + width: fit-content; + height: 40px; + padding: 4px 12px; + + display: flex; + align-items: center; + gap: 4px; + + border-radius: 35px; + border: 1px solid var(--Secondary-200, #E5E7EB); +} + +.heartIcon { + width: 32px; + height: 32px; +} + +.favoriteCntText { + color: var(--Cool-Gray-500); + font: var(--font-16-medium); +} + +.content { + margin: 24px 0 36px; + color: var(--Secondary-800); + font: var(--font-18-regular); +} + +.buttonDiv { + display: flex; + justify-content: flex-end; +} + +.button { + padding: 12px 23px; + border-radius: 8px; +} + +.commnetForm { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 40px; +} + +.commentList { + width: 100%; + height: fit-content; + display: flex; + flex-direction: column; + gap: 24px; +} + +.comment { + width: 100%; + display: flex; + flex-direction: column; + gap: 8px; + + background-color: #FCFCFC; +} + +.commentContentDiv { + width: 100%; + display: flex; + justify-content: space-between; +} + +.commentInfoDiv { + width: 100%; + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.commentInfoDiv > div { + width: fit-content; + display: flex; + flex-direction: column; + gap: 4px; +} + +.commentUserProfile { + width: 32px; + height: 32px; +} + +.commentUserName { + color: var(--Secondary-600); + font: var(--font-12-regular); +} + +.commentDate { + color: var(--Secondary-400); + font: var(--font-12-regular); +} + +.noComment { + width: fit-content; + height: fit-content; + + display: flex; + flex-direction: column; + align-items: center; + + margin: 40px auto 0; +} + +.noComment p{ + color: var(--Secondary-400); + font: var(--font-16-regular); + text-align: center; +} + +.noCommentImg { + width: 140px; + height: 140px; +} + +.backButtonDiv { + display: flex; + flex-direction: column; + align-items: center; + + margin: 48px auto 0; +} + +.backButton{ + font-size: 16px; + font-weight: 600; + border-radius: 999px; + padding: 14.5px 33.5px; +} + + +.editCommentInput { + width: 100%; + display: flex; + padding: 16px 24px; + resize: none; /* 사용자가 크기 조절 못하게 */ + overflow: hidden; /* 스크롤 안 생기게 */ + + align-items: flex-start; + gap: 10px; + align-self: stretch; + + border-radius: 12px; + border: none; + background: var(--Cool-Gray-100, #f3f4f6); + margin-bottom: 16px; +} + +.editInfoDiv { + display: flex; + justify-content: space-between; + align-items: center; +} + +.editBtnDiv { + display: flex; + align-items: center; + gap: 16px; +} + +.cancle { + padding: 12px 23px; +} + +.edit { + padding: 12px 23px; + white-space: nowrap; +} + +/* tablet */ +@media (min-width: 744px) { + .frame { + width: 100%; + height: fit-content; + + margin: 34px auto 193px; + padding: 0 24px; + } + + .productDetail { + display: flex; + flex-direction: row; + gap: 24px; + margin-bottom: 32px; + } + + .productImageBox { + max-width: 500px; + max-height: 500px; + } +} + +/* desktop */ +@media (min-width: 1200px) { + .frame { + width: 100%; + max-width: 1200px; + height: fit-content; + + margin: 34px auto 193px; + padding: 0; + } + + .productDetail { + margin-bottom: 40px; + } +} diff --git a/frontend/src/app/items/[id]/page.jsx b/frontend/src/app/items/[id]/page.jsx new file mode 100644 index 000000000..68bcbaa58 --- /dev/null +++ b/frontend/src/app/items/[id]/page.jsx @@ -0,0 +1,291 @@ +"use client" + +//라이브러리 +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { getProduct, patchProduct, deleteProduct, getComments, createComment, updateComment, deleteComment } from '@/api/ProductService'; + +//컴포넌트 +import MainFrame from '@/components/organism/mainFrame'; +import { DropdownList } from '@/components/molecules/Dropdown/Dropdown'; + +//이미지 +import Image from 'next/image'; +import moreImg from './ic_more.svg'; +import userPanda from '@/images/userPanda.svg'; +import heartIcon from './ic_heart.svg'; +import noComment from './ic_noComment.svg'; +import backIcon from './ic_back.svg'; + +import productDefault from '../../../../public/images/items/product_default.png'; + +//스타일 +import styles from './item.module.css'; +import InputForm from '@/components/molecules/InputForm/InputForm'; +import Button from '@/components/Atoms/Button'; +import useAsync from '@/hooks/useAsync'; +import DeleteModal from '@/components/molecules/DeleteModal/DeleteModal'; + + + + +function ProductDetail({product, setModalOpen}){ + const router = useRouter(); + const { id } = useParams(); + + return ( +
{product.name}
+{product.price.toLocaleString() + '원'}
+{product.description}
+{`#${tag}`}
+{product.userName}
+{product.createdAt}
+{product.favoriteCount}
+{comment.content}
+{comment.userName}
+{comment.createdAt}
+아직 댓글이 없어요.
지금 댓글을 달아보세요!
{'#' + tag}
{validErrorMsg}
}{item.name}
{item.price.toLocaleString() + '원'}
- {/* 어째선지 리스폰스에 좋아요 수가 없습니다. 임의로 240을 넣었습니다.*/}