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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,32 @@ import HomePage from "./pages/HomePage/HomePage";
import LoginPage from "./pages/LoginPage/LoginPage";
import MarketPage from "./pages/MarketPage/MarketPage";
import AddItemPage from "./pages/AddItemPage/AddItemPage";
import ItemDetailPage from "./pages/ItemsDetailPage/ItemsDetailPage";
import CommunityFeedPage from "./pages/CommunityFeedPage/CommunityFeedPage";
import { ThemeProvider } from "styled-components";
import Header from "./components/Layout/Header";
import theme from "./styles/theme";

function App() {
return (
<BrowserRouter>
{/* Global Navigation Bar */}
<Header />
<ThemeProvider theme={theme}>
<BrowserRouter>
{/* Global Navigation Bar */}
<Header />

<div className="withHeader">
<Routes>
{/* React Router v6부터는 path="/" 대신 간단하게 `index`라고 표기하면 돼요 */}
<Route index element={<HomePage />} />
<Route path="login" element={<LoginPage />} />
<Route path="items" element={<MarketPage />} />
<Route path="additem" element={<AddItemPage />} />
<Route path="community" element={<CommunityFeedPage />} />
</Routes>
</div>
</BrowserRouter>
<div className="withHeader">
<Routes>
{/* React Router v6부터는 path="/" 대신 간단하게 `index`라고 표기하면 돼요 */}
<Route index element={<HomePage />} />
<Route path="login" element={<LoginPage />} />
Comment on lines +17 to +23
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오잉 ? 혹시 로그인 페이지에서는 Global Navigation Bar가 없지 않나요?

만약 그렇다면 react-router-dom에서 outlet을 활용해볼 수 있습니다 !
Routes에서 element를 지정하여 중첩 레이아웃을 사용할 수 있으니 참고해서 설계해보세요 😊

tl;dr

// App.tsx
<Routes>
  <Route path="/" element={<Main />}> // 중첩 라우팅
    <Route path="user-management" element={<UserManagement />} />
    <Route path="child-management" element={<ChildManagement />} />
  </Route>
  <Route path="/login" element={<Login />} />
</Routes>
// Main.tsx
<MainWrapper>
  <MainMenu />
  <Outlet /> // children과 같은 효과 ! ✨
</MainWrapper>

[React] React-router 'Outlet' 사용하여 레이아웃 구성하기

react-routes-dom/layout

<Route path="items" element={<MarketPage />} />
<Route path="additem" element={<AddItemPage />} />
<Route path="community" element={<CommunityFeedPage />} />
<Route path="/products/:productId" element={<ItemDetailPage />} />
</Routes>
</div>
</BrowserRouter>
</ThemeProvider>
);
}

Expand Down
12 changes: 12 additions & 0 deletions src/api/commentApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//commentApi.js
import axios from "axios";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instance를 만들어서 export를 하고 사용해볼 수 있습니다 !

axios-instance과 같은 파일을 만들어서 instance를 생성하고 export한 후 사용해보는건 어떨까요?
다음과 같이 만들어볼 수 있어요:

const baseURL = process.env.NEXT_PUBLIC_LINKBRARY_BaseURL;

const instance = axios.create({
  baseURL: baseURL,
  headers: {
    'Content-Type': 'application/json',
  },
});

export default instance

axios instance

인가에 필요한 accessTokenlocalStorage가 있다면 axios의 인터셉터를 활용할 수 있습니다 !

인터셉터는 혼자 해결해보시는 것을 권장드립니다. 혹시 모르시겠으면 다음 위클리 미션에 질문해주세요. 😊


const BASE_URL = process.env.REACT_APP_BASE_URL;

Comment on lines +4 to +5
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

크으 ~ 환경 변수를 잘 활용하셨네요 👍👍

export async function getComments(productId) {
const response = await axios.get(
`${BASE_URL}/products/${productId}/comments?limit=3`
);
Comment on lines +7 to +9
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

쿼리는 URLSearchParams로 손쉽게 사용할 수 있어요 !

Suggested change
const response = await axios.get(
`${BASE_URL}/products/${productId}/comments?limit=3`
);
const params = new URLSearchParams({ limit: '100' });
const { data } = await axios.get(`${BASE_URL}/products/${productId}/comments`, { params });

axios를 사용하실 경우 URLSearchParams와 함께 객체로 손쉽게 핸들링할 수 있습니다 !
객체로 구성할 수 있어 가독성이 좋고, URL 인코딩을 자동으로 처리하여 특수 문자나 공백이 포함된 값에서도 안전하게 동작합니다 !

URLSearchParams: URLSearchParams 인터페이스는 URL의 쿼리 문자열을 대상으로 작업할 수 있는 유틸리티 메서드를 정의합니다.

쿼리를 생성하실 때에 참고해서 사용해보세요 😊


return response.data.list || [];
}
Comment on lines +6 to +12
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

limit을 고정하지 않고 기본값으로 넣는게 더 유연한 API 함수가 될 것 같아요 😊

Suggested change
export async function getComments(productId) {
const response = await axios.get(
`${BASE_URL}/products/${productId}/comments?limit=3`
);
return response.data.list || [];
}
export async function getComments(productId, limit = 3) {
const response = await axios.get(
`${BASE_URL}/products/${productId}/comments?limit=${limit}`
);
return response.data.list || [];
}

6 changes: 6 additions & 0 deletions src/api/itemApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ export async function getProducts({ page, pageSize, orderBy, keyword }) {
throw new Error(`HTTP error: ${error.response?.status || error.message}`);
}
}

export async function getProductInfo(productId) {
const response = await axios.get(`${BASE_URL}/products/${productId}`);

return response.data;
}
6 changes: 6 additions & 0 deletions src/assets/images/icons/backToListBtn.svg
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 버튼은 htmlcss로 만드는게 어떨까요 ?!

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/assets/images/icons/ic_kebab.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions src/assets/images/icons/ic_user.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions src/assets/images/icons/img_default.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions src/components/Comments.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as S from "./Comments.styles";
import ic_user from "../assets/images/icons/ic_user.svg";
import EditDropdown from "./common/EditDropdown";

export default function Comments({ comments }) {
return (
<S.CommentContainer>
{comments.map((comment) => (
<S.InquiryItem key={comment.id}>
<S.CommentHeader>
<S.CommentText>{comment.content}</S.CommentText>
<EditDropdown />
</S.CommentHeader>
<S.UserInfo>
<S.ProfileImage src={ic_user} alt="프로필" />
<S.UserDetails>
<S.Nickname>
{comment.writer?.nickname || "알 수 없음"}
</S.Nickname>
<S.TimeAgo>{getTimeAgo(comment.createdAt)}</S.TimeAgo>
</S.UserDetails>
</S.UserInfo>
</S.InquiryItem>
))}
</S.CommentContainer>
);
}

function getTimeAgo(createdAt) {
if (!createdAt) return "방금 전";

const now = new Date();
const createdDate = new Date(createdAt);
const diffInMs = now - createdDate; // 밀리초 단위 차이
const diffInHours = diffInMs / (1000 * 60 * 60); // 시간 단위 변환

if (diffInHours < 1) {
return "방금 전";
} else if (diffInHours < 24) {
return `${Math.floor(diffInHours)}시간 전`; // 24시간 미만이면 "n시간 전"
} else {
const diffInDays = Math.floor(diffInHours / 24);
return `${diffInDays}일 전`; // 1일 이상이면 "n일 전"
}
}
55 changes: 55 additions & 0 deletions src/components/Comments.styles.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import styled from "styled-components";
import theme from "../styles/theme";

export const CommentContainer = styled.div`
margin-top: 16px;
`;

export const InquiryItem = styled.div`
padding: 14px;
border-bottom: 1px solid ${theme.colors.Gray200};
display: flex;
flex-direction: column;
gap: 12px;
`;

export const CommentHeader = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 10px;
`;

export const CommentText = styled.p`
font: ${theme.fonts.H6Regular};
color: ${theme.colors.Gray800};
`;

export const UserInfo = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;

export const ProfileImage = styled.img`
width: 32px;
height: 32px;
border-radius: 50%;
`;

export const UserDetails = styled.div`
display: flex;
flex-direction: column;
`;

export const Nickname = styled.span`
font: ${theme.fonts.H8};
color: ${theme.colors.Gray600};
`;

export const TimeAgo = styled.span`
font-size: 12px;
color: ${theme.colors.Gray400};
margin-top: 4px;
`;
91 changes: 91 additions & 0 deletions src/components/ItemDetail.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// itemDetail.jsx
import * as S from "./ItemDetail.styles";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { getProductInfo } from "../api/itemApi";
import img_default from "../assets/images/icons/img_default.svg";
import ic_heart from "../assets/images/icons/ic_heart.svg";
import ic_user from "../assets/images/icons/ic_user.svg";
import EditDropdown from "./common/EditDropdown";

export default function ItemDetail() {
const { productId } = useParams();
const [product, setProduct] = useState({
name: "",
description: "",
price: 0,
tags: [],
images: null,
favoriteCount: 0,
createdAt: "",
updatedAt: "",
ownerNickname: "",
});
const [isImgError, setIsImgError] = useState(false);

useEffect(() => {
getProductInfo(productId)
.then((result) => setProduct(result))
.catch((error) => console.error(error));
}, [productId]);

return (
<S.DetailContainer>
{/* 아이템 이미지 */}
{product.images && !isImgError ? (
<S.Image
src={product.images}
onLoad={() => setIsImgError(false)}
onError={() => setIsImgError(true)}
/>
) : (
<S.NoneImageContainer>
<S.NoneImage src={img_default} />
</S.NoneImageContainer>
)}

{/* 아이템 정보 */}
<S.Detail>
<div>
<S.Header>
<S.TitleWrapper>
<S.Title>{product.name}</S.Title>
<EditDropdown />
</S.TitleWrapper>
<S.Price>{product.price.toLocaleString()}원</S.Price>
</S.Header>
<S.ItemInfo>
<div>
<S.Label>상품소개</S.Label>
<S.Content>{product.description}</S.Content>
</div>
<S.TagContainer>
<S.Label>상품태그</S.Label>
<S.TagWrapper>
{product.tags.map((tag) => (
<S.Tag key={tag}>#{tag}</S.Tag>
))}
</S.TagWrapper>
</S.TagContainer>
</S.ItemInfo>
</div>

<S.UserWrapper>
{/* 판매자 정보 */}
<S.SellerContainer>
<img src={ic_user} alt="판매자 프로필이미지" />
<S.SellerInfo>
<S.SellerName>{product.ownerNickname}</S.SellerName>
<S.Updated>{product.createdAt}</S.Updated>
</S.SellerInfo>
</S.SellerContainer>
{/* 좋아요버튼 */}
<S.LikeWrapper>
<S.Like src={ic_heart} />
<S.LikeCount>{product.favoriteCount}</S.LikeCount>
</S.LikeWrapper>
</S.UserWrapper>
</S.Detail>
</S.DetailContainer>
);
}
Loading
Loading