diff --git a/package-lock.json b/package-lock.json
index 9f51b7570..0e51b121a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"react-helmet": "^6.1.0",
"react-router-dom": "^6.24.0",
"react-scripts": "5.0.1",
+ "styled-components": "^6.1.11",
"web-vitals": "^2.1.4"
}
},
@@ -2272,6 +2273,24 @@
"postcss-selector-parser": "^6.0.10"
}
},
+ "node_modules/@emotion/is-prop-valid": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz",
+ "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==",
+ "dependencies": {
+ "@emotion/memoize": "^0.8.1"
+ }
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+ "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
@@ -4422,6 +4441,11 @@
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",
"integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw=="
},
+ "node_modules/@types/stylis": {
+ "version": "4.2.5",
+ "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz",
+ "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw=="
+ },
"node_modules/@types/testing-library__jest-dom": {
"version": "5.14.9",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz",
@@ -5836,6 +5860,14 @@
"node": ">= 6"
}
},
+ "node_modules/camelize": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
+ "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/caniuse-api": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz",
@@ -6271,6 +6303,14 @@
"postcss": "^8.4"
}
},
+ "node_modules/css-color-keywords": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
+ "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/css-declaration-sorter": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz",
@@ -6452,6 +6492,16 @@
"resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz",
"integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w=="
},
+ "node_modules/css-to-react-native": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
+ "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
+ "dependencies": {
+ "camelize": "^1.0.0",
+ "css-color-keywords": "^1.0.0",
+ "postcss-value-parser": "^4.0.2"
+ }
+ },
"node_modules/css-tree": {
"version": "1.0.0-alpha.37",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz",
@@ -6645,9 +6695,9 @@
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="
},
"node_modules/csstype": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
- "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
@@ -12434,9 +12484,9 @@
}
},
"node_modules/nanoid": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
- "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"funding": [
{
"type": "github",
@@ -13095,9 +13145,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.29",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz",
- "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==",
+ "version": "8.4.38",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
+ "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"funding": [
{
"type": "opencollective",
@@ -13113,9 +13163,9 @@
}
],
"dependencies": {
- "nanoid": "^3.3.6",
+ "nanoid": "^3.3.7",
"picocolors": "^1.0.0",
- "source-map-js": "^1.0.2"
+ "source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -15552,6 +15602,11 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
+ "node_modules/shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -15634,9 +15689,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
- "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
+ "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"engines": {
"node": ">=0.10.0"
}
@@ -16047,6 +16102,33 @@
"webpack": "^5.0.0"
}
},
+ "node_modules/styled-components": {
+ "version": "6.1.11",
+ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.11.tgz",
+ "integrity": "sha512-Ui0jXPzbp1phYij90h12ksljKGqF8ncGx+pjrNPsSPhbUUjWT2tD1FwGo2LF6USCnbrsIhNngDfodhxbegfEOA==",
+ "dependencies": {
+ "@emotion/is-prop-valid": "1.2.2",
+ "@emotion/unitless": "0.8.1",
+ "@types/stylis": "4.2.5",
+ "css-to-react-native": "3.2.0",
+ "csstype": "3.1.3",
+ "postcss": "8.4.38",
+ "shallowequal": "1.1.0",
+ "stylis": "4.3.2",
+ "tslib": "2.6.2"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/styled-components"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0",
+ "react-dom": ">= 16.8.0"
+ }
+ },
"node_modules/stylehacks": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
@@ -16062,6 +16144,11 @@
"postcss": "^8.2.15"
}
},
+ "node_modules/stylis": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz",
+ "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg=="
+ },
"node_modules/sucrase": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
diff --git a/package.json b/package.json
index 21f54aa69..5ab37b45d 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"react-helmet": "^6.1.0",
"react-router-dom": "^6.24.0",
"react-scripts": "5.0.1",
+ "styled-components": "^6.1.11",
"web-vitals": "^2.1.4"
},
"scripts": {
diff --git a/src/Main.js b/src/Main.js
index 8cac6a38d..26ce743f2 100644
--- a/src/Main.js
+++ b/src/Main.js
@@ -3,6 +3,7 @@ import HomePage from "./pages/HomePage";
import ItemsPage from "./pages/ItemsPage";
import AddItemPage from "./pages/AddItemPage";
import App from "./components/App";
+import ItemPage from "./pages/ItemPage";
function Main() {
return (
@@ -10,7 +11,10 @@ function Main() {
}>
} />
- } />
+
+ } />
+ } />
+
} />
diff --git a/src/api/api.js b/src/api/api.js
index 111a7d89b..fe48f633f 100644
--- a/src/api/api.js
+++ b/src/api/api.js
@@ -1,6 +1,6 @@
const BASE_URL = "https://panda-market-api.vercel.app";
-async function getProducts({ page = 1, pageSize = 10, orderBy = '', keyword = '' }) {
+async function getProducts({ page = 1, pageSize, orderBy = '', keyword = '' }) {
const query = `?page=${page}&pageSize=${pageSize}&orderBy=${orderBy}&keyword=${keyword}`
const response = await fetch(`${BASE_URL}/products` + query);
@@ -22,4 +22,23 @@ async function postProducts(formData) {
return result;
}
-export { getProducts, postProducts };
\ No newline at end of file
+async function getProductById(id) {
+
+ const response = await fetch(`${BASE_URL}/products/${id}`)
+ if (!response.ok) throw new Error("데이터를 불러오는데 실패했습니다.");
+ const result = await response.json();
+
+ return result;
+}
+
+async function getComments({ productId, limit = 10, cursor = 0 }) {
+
+ const query = `/products/${productId}/comments?limit=${limit}&cursor=${cursor}`;
+ const response = await fetch(`${BASE_URL}${query}`);
+ if (!response.ok) throw new Error("데이터를 불러오는데 실패했습니다.");
+ const result = await response.json();
+
+ return result;
+}
+
+export { getProducts, postProducts, getProductById, getComments };
\ No newline at end of file
diff --git a/src/assets/Img_inquiry_empty.png b/src/assets/Img_inquiry_empty.png
new file mode 100644
index 000000000..586bef6ad
Binary files /dev/null and b/src/assets/Img_inquiry_empty.png differ
diff --git a/src/assets/ic_back.svg b/src/assets/ic_back.svg
new file mode 100644
index 000000000..f8d47f89d
--- /dev/null
+++ b/src/assets/ic_back.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/ic_sort.svg b/src/assets/ic_sort.svg
new file mode 100644
index 000000000..657b44f93
--- /dev/null
+++ b/src/assets/ic_sort.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/panda_blog-imagery-_21_Khajitted.jpg b/src/assets/panda_blog-imagery-_21_Khajitted.jpg
deleted file mode 100644
index 09ae4ad18..000000000
Binary files a/src/assets/panda_blog-imagery-_21_Khajitted.jpg and /dev/null differ
diff --git a/src/components/ErrorScreen.js b/src/components/ErrorScreen.js
new file mode 100644
index 000000000..0d4684418
--- /dev/null
+++ b/src/components/ErrorScreen.js
@@ -0,0 +1,16 @@
+import styles from "./ErrorScreen.module.css";
+import pandaIsConfused from "../assets/Img_inquiry_empty.png"
+
+function ErrorScreen({ error }) {
+
+ return (
+
+
+ {error.message
+ ?
{error.message}
+ :
요청을 수행하는데 오류가 발생했습니다.}
+
+ )
+}
+
+export default ErrorScreen;
\ No newline at end of file
diff --git a/src/components/ErrorScreen.module.css b/src/components/ErrorScreen.module.css
new file mode 100644
index 000000000..7f70e9660
--- /dev/null
+++ b/src/components/ErrorScreen.module.css
@@ -0,0 +1,14 @@
+.errorComponent {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 1.5rem;
+
+ .errorMessage {
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray400);
+ }
+}
\ No newline at end of file
diff --git a/src/components/ItemComments.js b/src/components/ItemComments.js
new file mode 100644
index 000000000..765441660
--- /dev/null
+++ b/src/components/ItemComments.js
@@ -0,0 +1,142 @@
+import { useCallback, useEffect, useState } from "react";
+import styles from "./ItemComments.module.css";
+import useAsync from "../hooks/useAsync";
+import { getComments } from "../api/api";
+import { ReactComponent as GoBackIcon } from '../assets/ic_back.svg';
+import emptyCommentPanda from "../assets/Img_inquiry_empty.png"
+import { useNavigate } from "react-router-dom";
+import LoadingErrorHandler from "./LoadingErrorHandler";
+import formatComparedTime from "../utils/formatComparedTime";
+
+const COMMENTS_LIMIT = 5;
+
+function CommentSubmitForm() {
+
+ const [inputContent, setInputContent] = useState('');
+
+ const handleInputChange = (e) => {
+ setInputContent(e.target.value);
+ }
+
+ const handleButtonClick = (e) => {
+ e.preventDefault();
+ }
+
+ const commentPlaceholder = "개인정보를 공유 및 요청하거나, 명예 훼손, 무단 광고, 불법 정보 유포시 모니터링 후 삭제될 수 있으며, 이에 대한 민형사상 책임은 게시자에게 있습니다.";
+
+ return (
+
+ )
+}
+
+function EmptyCommentImage() {
+ return (
+
+
+
아직 문의가 없습니다.
+
+ )
+}
+
+function Comments({ comments }) {
+
+ return (
+
+ {comments.map((comment) => {
+ const dateFormat = formatComparedTime(comment.updatedAt);
+
+ return (
+ -
+ {comment.content}
+
+
+
+ {comment.writer.nickname}
+ {dateFormat}
+
+
+
+ )})}
+
+ )
+}
+
+function CommentsList({ itemId }) {
+
+ const [comments, setComments] = useState([]);
+ const [cursor, setCursor] = useState(null);
+ const [isLoading, error, getCommentsAsync] = useAsync(getComments);
+ const navigate = useNavigate();
+
+ const handleLoad = useCallback (async (query) => {
+ const result = await getCommentsAsync(query);
+ if (!result) return;
+ setComments((prev) => [...prev, ...result.list]);
+ setCursor(result.nextCursor);
+ }, [getCommentsAsync]);
+
+ const handleLoadMore = async () => {
+ handleLoad({ productId: itemId, limit: COMMENTS_LIMIT, cursor: cursor });
+ }
+
+ const handleGoBack = (e) => {
+ e.preventDefault();
+ navigate("/items");
+ }
+
+ useEffect(() => {
+ handleLoad({ productId: itemId, limit: COMMENTS_LIMIT, cursor: 0 });
+ }, [handleLoad, itemId]);
+
+ return (
+ <>
+ {!comments.length || }
+ {comments.length ||
+ (isLoading || error)
+ ?
+ : }
+ {(cursor && !isLoading) &&
+
+
+
+ }
+
+
+
+ >
+ )
+}
+
+function ItemComments({ itemId }) {
+
+ return (
+
+ )
+}
+
+export default ItemComments;
\ No newline at end of file
diff --git a/src/components/ItemComments.module.css b/src/components/ItemComments.module.css
new file mode 100644
index 000000000..d816e2906
--- /dev/null
+++ b/src/components/ItemComments.module.css
@@ -0,0 +1,167 @@
+.itemComments {
+ width: 100%;
+ padding: 1.5rem 0 2.5rem;
+
+ button:not(:disabled):hover {
+ cursor: pointer;
+ }
+
+ .commentForm {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 1rem;
+
+ .commentFormLabel {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--gray900);
+ }
+
+ .commentFormInput {
+ width: 100%;
+ height: 6.5rem;
+ border: none;
+ border-radius: 0.75rem;
+ background-color: var(--gray100);
+ resize: none;
+ padding: 1rem 1.5rem;
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray800);
+ font-family: inherit;
+
+ &:focus{
+ outline: none;
+ }
+
+ &::placeholder{
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray400);
+ }
+ }
+
+ .commentFormButtonContainer {
+ width: 100%;
+ display: flex;
+ justify-content: flex-end;
+ }
+
+ .commentFormButton {
+ width: 4.625rem;
+ height: 2.625rem;
+ border: none;
+ border-radius: 0.5rem;
+ background-color: var(--blue);
+ font-size: 1rem;
+ font-weight: 600;
+ color: #fff;
+
+ &:disabled {
+ background-color: var(--gray400);
+ }
+ }
+ }
+
+ .commentsList {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 1.5rem;
+ padding: 0;
+ margin: 1.5rem 0 2.5rem;
+
+ .comment {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: 1.5rem;
+ padding-bottom: 1.5rem;
+ border-bottom: solid 1px var(--gray200);
+
+ .commentContent {
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray800);
+ }
+
+ .commentInfo {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .commentInfoImage {
+ width: 2.5rem;
+ height: 2.5rem;
+ }
+
+ .commentInfoTexts {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ }
+
+ .commentInfoNickname {
+ font-size: 0.875rem;
+ font-weight: 400;
+ color: var(--gray600);
+ }
+
+ .commentInfoUpdatedAt {
+ font-size: 0.75rem;
+ font-weight: 400;
+ color: var(--gray400);
+ }
+ }
+ }
+
+ .loadMoreContainer {
+ display: flex;
+ justify-content: center;
+ }
+
+ .loadMoreButton {
+ border: none;
+ background-color: transparent;
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray400);
+ margin-bottom: 2.5rem;
+ }
+
+ .goBackButtonContainer {
+ display: flex;
+ justify-content: center;
+ }
+
+ .goBackButton {
+ width: 15rem;
+ height: 3rem;
+ border: none;
+ border-radius: 2.5rem;
+ background-color: var(--blue);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.625rem;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #fff;
+ }
+
+ .emptyCommentContainer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ }
+
+ .emptyCommentMessage {
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray400);
+ }
+}
\ No newline at end of file
diff --git a/src/components/ItemInfo.js b/src/components/ItemInfo.js
new file mode 100644
index 000000000..b3a67c27e
--- /dev/null
+++ b/src/components/ItemInfo.js
@@ -0,0 +1,41 @@
+import styles from "./ItemInfo.module.css"
+import { ReactComponent as HeartIcon } from '../assets/ic_heart.svg';
+
+function ItemInfo({ item }) {
+
+ return (
+
+
+ {item.images[0] &&
}
+
+
+
+ {item.name}
+ {item.price.toLocaleString()}원
+
+
+
+
상품 소개
+
{item.description}
+
+
+
상품 태그
+
+ {item.tags.map((tag, index) => {
+ return (- #{tag}
)
+ })}
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ItemInfo;
\ No newline at end of file
diff --git a/src/components/ItemInfo.module.css b/src/components/ItemInfo.module.css
new file mode 100644
index 000000000..c5c588b1e
--- /dev/null
+++ b/src/components/ItemInfo.module.css
@@ -0,0 +1,122 @@
+.itemInfo {
+ width: 100%;
+ min-height: 30.375rem;
+ padding: 1.5rem 0 2rem;
+ border-bottom: solid 1px var(--gray200);
+ display: flex;
+ justify-content: space-between;
+ gap: 1.5rem;
+
+ .itemImageContainer {
+ max-width: 30.375rem;
+ min-width: 21.25rem;
+ max-height: 30.375rem;
+ }
+
+ .itemImage {
+ max-width: 100%;
+ max-height: 100%;
+ border-radius: 1rem;
+ }
+
+ .itemContents {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+
+ .itemHeader {
+ padding-bottom: 1rem;
+ border-bottom: solid 1px var(--gray200);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+
+ .itemName {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--gray800);
+ }
+
+ .itemPrice {
+ font-size: 2.5rem;
+ font-weight: 600;
+ color: var(--gray800);
+ }
+ }
+
+ .itemBody {
+ padding-top: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ flex-grow: 1;
+
+ .itemBodyCategory {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--gray600);
+ margin-bottom: 0.5rem;
+ display: block;
+ }
+
+ .itemBodyDescription {
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray800);
+ }
+
+ .itemBodyTagList {
+ list-style: none;
+ padding: 0 0 1.5rem;
+ margin: 0;
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+
+ .itemBodyTag {
+ height: 2.25rem;
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray800);
+ padding: 0.375rem 1rem;
+ background-color: var(--gray100);
+ border-radius: 1.625rem;
+ &:hover {
+ cursor: default;
+ }
+ }
+ }
+ }
+
+ .favoriteButton {
+ background-color: #fff;
+ border: 1px solid var(--gray200);
+ border-radius: 2.25rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0.25rem 0.75rem;
+ gap: 0.25rem;
+
+ font-size: 1rem;
+ font-weight: 500;
+ color: var(--gray500);
+
+ .heartIcon {
+ width: 2rem;
+ height: 2rem;
+ }
+ }
+ }
+
+ @media (width <= 1199px) {
+ gap: 1rem;
+ padding: 1.5rem 0 1.5rem;
+ }
+
+ @media (width <= 767px) {
+ flex-direction: column;
+ align-items: center;
+ padding: 1rem 0 1.5rem;
+ }
+}
\ No newline at end of file
diff --git a/src/components/LoadingErrorHandler.js b/src/components/LoadingErrorHandler.js
new file mode 100644
index 000000000..883c22e48
--- /dev/null
+++ b/src/components/LoadingErrorHandler.js
@@ -0,0 +1,17 @@
+import ErrorScreen from "./ErrorScreen";
+import LoadingScreen from "./LoadingScreen";
+
+function LoadingErrorHandler({ isLoading = false, error = null }) {
+
+ if(isLoading) {
+ return
+ }
+
+ else if(error) {
+ return
+ }
+
+ return;
+}
+
+export default LoadingErrorHandler
\ No newline at end of file
diff --git a/src/components/LoadingImage.css b/src/components/LoadingImage.css
deleted file mode 100644
index b6e5789de..000000000
--- a/src/components/LoadingImage.css
+++ /dev/null
@@ -1,9 +0,0 @@
-.loading-image {
- background-image: url("../assets/panda_blog-imagery-_21_Khajitted.jpg");
- width: 100%;
- height: 22rem;
- border-radius: 2rem;
- background-repeat: no-repeat;
- background-size: cover;
- background-position: center;
-}
\ No newline at end of file
diff --git a/src/components/LoadingImage.js b/src/components/LoadingImage.js
deleted file mode 100644
index ded851bfa..000000000
--- a/src/components/LoadingImage.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import "./LoadingImage.css";
-
-function LoadingImage() {
-
- return
-}
-
-export default LoadingImage;
\ No newline at end of file
diff --git a/src/components/LoadingScreen.js b/src/components/LoadingScreen.js
new file mode 100644
index 000000000..80a8b6dfb
--- /dev/null
+++ b/src/components/LoadingScreen.js
@@ -0,0 +1,14 @@
+import styles from "./LoadingScreen.module.css";
+import pandaIsConfused from "../assets/Img_inquiry_empty.png"
+
+function LoadingScreen() {
+
+ return (
+
+
+
불러오는중...
+
+ )
+}
+
+export default LoadingScreen;
\ No newline at end of file
diff --git a/src/components/LoadingScreen.module.css b/src/components/LoadingScreen.module.css
new file mode 100644
index 000000000..c28169b4d
--- /dev/null
+++ b/src/components/LoadingScreen.module.css
@@ -0,0 +1,14 @@
+.loadingComponent {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 1.5rem;
+
+ .loadingMessage {
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray400);
+ }
+}
\ No newline at end of file
diff --git a/src/components/main/BestSection.css b/src/components/main/BestSection.css
index a7b8d7b78..bf8892e37 100644
--- a/src/components/main/BestSection.css
+++ b/src/components/main/BestSection.css
@@ -1,15 +1,9 @@
main .best-list {
display: flex;
+ justify-content: center;
gap: 1.5rem;
}
main .best-card {
- width: 17.625rem;
- height: 22.625rem;
- display: flex;
- flex-direction: column;
-}
-
-main .best-card .item-image {
- height: 17.625rem;
+ flex: 1;
}
\ No newline at end of file
diff --git a/src/components/main/BestSection.js b/src/components/main/BestSection.js
index ef9fe525d..fb8832eec 100644
--- a/src/components/main/BestSection.js
+++ b/src/components/main/BestSection.js
@@ -3,7 +3,7 @@ import { getProducts } from '../../api/api';
import Card from './Card';
import './BestSection.css';
import useAsync from '../../hooks/useAsync';
-import LoadingImage from '../LoadingImage';
+import LoadingErrorHandler from '../LoadingErrorHandler';
function BestList({itemList}) {
@@ -16,14 +16,12 @@ function BestList({itemList}) {
)
}
-function BestSection() {
+function BestSection({ getPageSize }) {
const [items, setItems] = useState([])
+ const [pageSize, setPageSize] = useState(getPageSize(4, 2, 1));
const [isLoading, error, getProductsAsync] = useAsync(getProducts);
const handleLoad = useCallback (async (query) => {
- // const response = await getProducts(query);
- // const { list } = response;
- // setItems(list);
const result = await getProductsAsync(query)
if(!result) return;
const { list } = result;
@@ -31,15 +29,17 @@ function BestSection() {
}, [getProductsAsync]);
useEffect(() => {
- handleLoad({ page: 1, pageSize: 4, orderBy: "favorite"})
- }, [handleLoad]);
+ setPageSize(getPageSize(4, 2, 1));
+ handleLoad({ page: 1, pageSize, orderBy: "favorite"})
+ }, [handleLoad, pageSize, getPageSize]);
return (
베스트 상품
- {isLoading && }
- {error?.message && {error.message}}
-
+ {(isLoading || error)
+ ?
+ :
+ }
);
}
diff --git a/src/components/main/Card.css b/src/components/main/Card.css
index 6fe90c38b..b778a733a 100644
--- a/src/components/main/Card.css
+++ b/src/components/main/Card.css
@@ -1,12 +1,23 @@
+main .card-container {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+main .card-container .card-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
main .card.item-image {
max-width: 100%;
border-radius: 16px;
- margin-bottom: 0.75rem;
+ aspect-ratio: 1 / 1;
}
main .card {
width: 100%;
- padding: 0.15rem 0;
letter-spacing: 0.04rem;
}
@@ -14,6 +25,7 @@ main .card.item-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--gray800);
+ text-decoration: none;
}
main .card.item-price {
diff --git a/src/components/main/Card.js b/src/components/main/Card.js
index d36ac3470..0ba5ff4c4 100644
--- a/src/components/main/Card.js
+++ b/src/components/main/Card.js
@@ -1,17 +1,22 @@
import './Card.css';
import { ReactComponent as HeartIcon } from '../../assets/ic_heart.svg';
+import { Link } from 'react-router-dom';
function Card({item}) {
const price = item.price.toLocaleString();
return (
- <>
-
- {item.name}
- {price}원
- {item.favoriteCount}
- >
+
+
+
+
+ {item.name}
+ {price}원
+ {item.favoriteCount}
+
+
+
)
}
diff --git a/src/components/main/Items.css b/src/components/main/Items.css
index ceccd1cee..a0392e326 100644
--- a/src/components/main/Items.css
+++ b/src/components/main/Items.css
@@ -3,7 +3,7 @@ main {
}
main .container {
- width: 75rem;
+ max-width: 75rem;
margin: 0 auto;
}
diff --git a/src/components/main/Items.js b/src/components/main/Items.js
index b8702a839..def7e6dbb 100644
--- a/src/components/main/Items.js
+++ b/src/components/main/Items.js
@@ -1,13 +1,36 @@
import './Items.css';
import BestSection from './BestSection';
import ItemsSection from './ItemsSection';
+import { useState } from 'react';
function Items() {
+
+ const getMediaWidth = () => {
+ if (window.innerWidth <= 767) return "mobile";
+ else if (window.innerWidth <= 1199) return "tablet";
+ else return "desktop";
+ }
+
+ const [mediaWidth, setMediaWidth] = useState(getMediaWidth());
+
+
+ const handleWidthChange = () => {
+ setMediaWidth(getMediaWidth());
+ }
+
+ const getPageSize = (desktop, tablet, mobile) => {
+ if (mediaWidth === "desktop") return desktop;
+ else if (mediaWidth === "tablet") return tablet;
+ else if (mediaWidth === "mobile") return mobile;
+ }
+
+ window.addEventListener("resize", handleWidthChange);
+
return (
-
-
-
+
+
+
);
diff --git a/src/components/main/ItemsSection.css b/src/components/main/ItemsSection.css
index 653737f68..4650ea3a9 100644
--- a/src/components/main/ItemsSection.css
+++ b/src/components/main/ItemsSection.css
@@ -1,5 +1,5 @@
main .items-section .section-header {
- margin-bottom: 2rem;
+ margin-bottom: 1.5rem;
position: relative;
display: flex;
gap: 0.625rem;
@@ -7,6 +7,7 @@ main .items-section .section-header {
.items-section .section-header h3 {
flex-grow: 1;
+ flex-shrink: 0;
}
.items-section .section-header .add-item-button {
@@ -22,7 +23,10 @@ main .items-section .section-header {
.items-section .section-header .search-container {
height: 100%;
+ max-width: 20.3125rem;
+ min-width: 17rem;
position: relative;
+ flex-grow: 0.5;
}
.items-section .section-header .search-container .search-icon {
@@ -34,7 +38,7 @@ main .items-section .section-header {
.items-section .section-header .search-input {
background-color: var(--gray100);
height: 100%;
- width: 20.25rem;
+ width: 100%;
border: none;
border-radius: 12px;
font-size: 1rem;
@@ -72,7 +76,7 @@ main .items-section .section-header {
border-radius: 12px;
position: absolute;
right: 0;
- top: 3rem;
+ bottom: -5.5rem;
display: flex;
flex-direction: column;
list-style: none;
@@ -116,22 +120,18 @@ main .items-section .section-header {
}
main .items-list {
- display: flex;
- flex-wrap: wrap;
- gap: 1.5rem;
+ display: grid;
+ grid-template-columns: repeat(5, 1fr);
+ column-gap: 1.5rem;
+ row-gap: 2.5rem;
}
main .items-card {
- width: 13.8rem;
- height: 18.75rem;
+ min-height: 1rem;
display: flex;
flex-direction: column;
}
-main .items-card .item-image {
- height: 13.8rem;
-}
-
main .pages {
display: flex;
justify-content: center;
@@ -157,4 +157,58 @@ main .pages .page-button:not(:disabled):hover {
main .pages .page-button.active {
background-color: var(--blue);
color: #fff;
+}
+
+@media (width <= 1199px) {
+ main .items-list {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ column-gap: 1rem;
+ row-gap: 2.5rem;
+ }
+}
+
+@media (width <= 767px) {
+ main .items-list {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ column-gap: 0.5rem;
+ row-gap: 2rem;
+ }
+
+ main .items-section .section-header {
+ width: 100%;
+ height: 5.75rem;
+ margin-bottom: 1rem;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .items-section .section-header .section-header-mobile-upper {
+ width: 100%;
+ height: 2.625rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .items-section .section-header .section-header-mobile-down {
+ width: 100%;
+ height: 2.625rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .section-header .section-header-mobile-down .search-container {
+ max-width: none;
+ flex-grow: 1;
+ }
+
+ .section-header .section-header-mobile-down .sort-dropdown {
+ height: 100%;
+ width: 2.625rem;
+ }
}
\ No newline at end of file
diff --git a/src/components/main/ItemsSection.js b/src/components/main/ItemsSection.js
index 1d759efd4..50bbdc23a 100644
--- a/src/components/main/ItemsSection.js
+++ b/src/components/main/ItemsSection.js
@@ -1,22 +1,22 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { getProducts } from "../../api/api";
import Card from "./Card";
import './ItemsSection.css';
import { ReactComponent as DropdownArrow} from '../../assets/dropdown_arrow.svg';
import { ReactComponent as SearchMark } from '../../assets/ic_search.svg';
+import { ReactComponent as SortIcon } from '../../assets/ic_sort.svg';
import { Link } from "react-router-dom";
import useAsync from "../../hooks/useAsync";
-import LoadingImage from "../LoadingImage";
+import LoadingErrorHandler from "../LoadingErrorHandler";
const ORDER_KOR = {
"recent": "최신순",
"favorite": "좋아요순",
}
-function ItemsSectionHeader({ orderBy, orderSetter, keywordSetter }) {
+function ItemsSectionHeader ({ orderBy, orderSetter, keywordSetter }) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const dropdown = useRef();
const dropdownHandler = () => setIsDropdownOpen(prev => !prev);
const searchSubmitHandler = (e) => {
@@ -35,10 +35,49 @@ function ItemsSectionHeader({ orderBy, orderSetter, keywordSetter }) {
-
+
+ - {orderSetter("recent")}}>
+ 최신순
+
+ - {orderSetter("favorite")}}>
+ 좋아요순
+
+
+
+ )
+}
+
+function ItemsSectionHeaderMobile ({ orderSetter, keywordSetter }) {
+
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+ const dropdownHandler = () => setIsDropdownOpen(prev => !prev);
+ const searchSubmitHandler = (e) => {
+ e.preventDefault();
+ keywordSetter(e.target['search'].value);
+ }
+
+ const dropdownClassName = `orderBy-dropdown ${isDropdownOpen ? '' : "hidden"}`;
+
+ return (
+
+
+
판매 중인 상품
+
+
+
+
- {orderSetter("recent")}}>
최신순
@@ -89,11 +128,11 @@ function Pages({ page, pageSize, total, pageSetter }) {
);
}
-function ItemsSection() {
+function ItemsSection({ getPageSize }) {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(10);
+ const [pageSize, setPageSize] = useState(getPageSize(10, 6, 4));
const [orderBy, setOrderBy] = useState("recent");
const [total, setTotal] = useState(0);
const [keyword, setKeyword] = useState('');
@@ -110,7 +149,7 @@ function ItemsSection() {
}, [getProductsAsync])
const pageClickHandler = (i) => {
- handleLoad({ page: i, pageSize: 10, orderBy, keyword });
+ handleLoad({ page: i, pageSize, orderBy, keyword });
}
const orderClickHandler = (order) => {
setOrderBy(order);
@@ -120,15 +159,20 @@ function ItemsSection() {
}
useEffect(() => {
- handleLoad({ page: 1, pageSize: 10, orderBy, keyword })
- }, [orderBy, keyword, handleLoad]);
+ setPageSize(getPageSize(10, 6, 4));
+ handleLoad({ page: 1, pageSize, orderBy, keyword })
+ }, [orderBy, keyword, handleLoad, pageSize, getPageSize]);
return (
-
- {isLoading && }
- {error?.message && {error.message}}
-
+ {(pageSize === 4)
+ ?
+ :
+ }
+ {(isLoading || error)
+ ?
+ :
+ }
)
diff --git a/src/components/styles.js b/src/components/styles.js
new file mode 100644
index 000000000..d6026fc85
--- /dev/null
+++ b/src/components/styles.js
@@ -0,0 +1,13 @@
+import styled from "styled-components";
+
+const Container = styled.div`
+ max-width: 78rem;
+ margin: 0 auto;
+ padding: 0 1.5rem;
+
+ @media(width <= 767px) {
+ padding: 0 1rem;
+ }
+`
+
+export { Container }
\ No newline at end of file
diff --git a/src/pages/ItemPage.js b/src/pages/ItemPage.js
new file mode 100644
index 000000000..84ed88766
--- /dev/null
+++ b/src/pages/ItemPage.js
@@ -0,0 +1,54 @@
+import { Navigate, useParams } from "react-router-dom";
+import Header from "../components/header/Header";
+import { getProductById } from "../api/api";
+import ItemInfo from "../components/ItemInfo";
+import useAsync from "../hooks/useAsync";
+import { useCallback, useEffect, useState } from "react";
+import { Container } from "../components/styles";
+import ItemComments from "../components/ItemComments";
+import LoadingErrorHandler from "../components/LoadingErrorHandler";
+
+const INITIAL_ITEM_VALUE = {
+ "id": 0,
+ "name": '',
+ "description": '',
+ "price": 0,
+ "tags": [],
+ "images": [],
+ "favoriteCount": 0,
+ "isFavorite": false,
+}
+
+function ItemPage() {
+
+ const { itemId } = useParams();
+ const [item, setItem] = useState(INITIAL_ITEM_VALUE);
+ const [isLoading, error, getProductByIdAsync] = useAsync(getProductById);
+
+ const handleLoad = useCallback (async () => {
+ const result = await getProductByIdAsync(itemId);
+
+ setItem(result);
+ }, [getProductByIdAsync, itemId])
+
+ useEffect(() => {
+ handleLoad();
+ }, [handleLoad]);
+
+ if(!item) {return }
+
+ return(
+ <>
+
+
+ {(isLoading || error)
+ ?
+ :
+ }
+
+
+ >
+ )
+}
+
+export default ItemPage;
\ No newline at end of file
diff --git a/src/utils/compareTime.js b/src/utils/compareTime.js
new file mode 100644
index 000000000..5221fc61a
--- /dev/null
+++ b/src/utils/compareTime.js
@@ -0,0 +1,8 @@
+function compareTime(targetTime) {
+ const target = Date.parse(targetTime);
+ const now = Date.now();
+
+ return now - target;
+}
+
+export default compareTime;
\ No newline at end of file
diff --git a/src/utils/formatComparedTime.js b/src/utils/formatComparedTime.js
new file mode 100644
index 000000000..acb33eade
--- /dev/null
+++ b/src/utils/formatComparedTime.js
@@ -0,0 +1,36 @@
+import compareTime from "./compareTime";
+
+const SEC = 1000;
+const MIN = 60 * SEC;
+const HOUR = 60 * MIN;
+const DAY = 24 * HOUR;
+
+function fitDigits(num) {
+ if (num < 10) return `0${num}`;
+ else return num;
+}
+
+function formatComparedTime(targetTime) {
+ const comparedTime = compareTime(targetTime);
+
+ if(comparedTime > 7 * DAY) {
+ const date = new Date(targetTime);
+ const hour = fitDigits(date.getHours());
+ const min = fitDigits(date.getMinutes());
+ const dateFormat = `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일 ${hour}:${min}`;
+
+ return dateFormat;
+ }
+
+ else if(comparedTime > 2 * DAY) return `${comparedTime / DAY}일 전`;
+
+ else if(comparedTime > DAY) return "어제";
+
+ else if(comparedTime > HOUR) return `${comparedTime / HOUR}시간 전`;
+
+ else if(comparedTime > MIN) return `${compareTime / MIN}분 전`;
+
+ else return "방금 전";
+}
+
+export default formatComparedTime;
\ No newline at end of file