Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1fdc791
chore(Sprint6): 환경 변수 파일 git 추적 제거
cskime Aug 4, 2025
01597ba
refactor(Sprint6): Button component의 disabled style을 CSS 코드만 사용해서 적용
cskime Aug 4, 2025
c92aa1f
refactor(Sprint6): <p> -> <span> tag 변경
cskime Aug 4, 2025
1735fa3
feat(Sprint7) 상품 클릭 시 상세 페이지 이동
cskime Aug 4, 2025
4716e30
fix(Sprint6): 상품 이미지 추가할 때 image input과 image preview 크기가 맞지 않는 문제 수정
cskime Aug 5, 2025
9fe818f
feat(Sprint7): Product detail page로 이동했을 때 서버에서 product 상세 정보 가져오기
cskime Aug 5, 2025
94f5653
feat(Sprint7): 상품 상세 - image, title, price 구현
cskime Aug 5, 2025
5696e74
refactor(Sprint7): TagList component에서 margin 제거
cskime Aug 5, 2025
5cb1e3a
feat(Sprint7): Tag 및 TagList component에 삭제 기능 on/off 구현
cskime Aug 5, 2025
3bcc639
feat(Sprint7): 상품 상세 - description, tags 구현
cskime Aug 5, 2025
9cc8034
feat(Sprint7): User profile 사진을 Avatar component로 분리
cskime Aug 5, 2025
e9c28ad
feat(Sprint7): 상품 상세 - profile, favorite button구현
cskime Aug 5, 2025
104fe67
refactor(Sprint7): Component 외부와 접점이 없는 styled-components 이름을 단순하게 변경
cskime Aug 5, 2025
9c59b12
feat(Sprint7): 상품 상세 - 상품 정보와 comment를 구분하는 Separator component 구현
cskime Aug 5, 2025
cb8a160
feat(Sprint7): TextInput component의 multiline height 설정을 `rows` 기반으로 변경
cskime Aug 5, 2025
ff0fc7b
feat(Sprint7): 상품 상세 - comment form 구현
cskime Aug 5, 2025
9bbfb33
refactor(Sprint7): User profile component를 재사용 가능하도록 추출
cskime Aug 5, 2025
4545044
feat(Sprint7): 아이콘 버튼을 component로 추출
cskime Aug 5, 2025
754b92a
feat(Sprint7): 상품 상세 - comment 목록 UI 구현
cskime Aug 5, 2025
5bb3815
feat(Sprint7): Button에 medium/small size 및 round/pill type에 따라 다른 sty…
cskime Aug 5, 2025
da3f3b5
feat(Sprint7): 상품 상세 - '목록으로 돌아가기' 버튼 구현
cskime Aug 5, 2025
d973617
feat(Sprint7): Comments API 연동, comments 없을 때 empty UI 표시
cskime Aug 5, 2025
cfc9d17
feat(Sprint7): '목록으로 돌아가기' 버튼 클릭 시 '/items' path로 이동
cskime Aug 5, 2025
3c447c9
feat(Sprint7): Comment more button 클릭할 때 dropdown 표시
cskime Aug 6, 2025
e3d97ff
feat(Sprint7): Comment 수정 UI 구현
cskime Aug 6, 2025
5bd5689
feat(Sprint7): 상품 상세 - comments list를 mock data를 사용해서 구현
cskime Aug 6, 2025
ec223d8
feat(Sprint7): Button component에 hover 효과 적용
cskime Aug 6, 2025
481f0d9
fix(Sprint7): SectionHeaderActions에서 child element의 세로 정렬 수정
cskime Aug 6, 2025
177c0b7
fix(Sprint7): Input component에 `value` prop 전달 시 `onChange` 설정 error 해결
cskime Aug 6, 2025
46ac20a
fix(Sprint7): useEffect missing dependencies warning 제거
cskime Aug 6, 2025
29eb44b
refactor(Sprint7): SearchInput을 styled-components로 구현, input 폴더로 묶음
cskime Aug 6, 2025
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
1 change: 0 additions & 1 deletion vite-project/.env.development

This file was deleted.

1 change: 0 additions & 1 deletion vite-project/.env.production

This file was deleted.

2 changes: 2 additions & 0 deletions vite-project/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?

.env.*
22 changes: 22 additions & 0 deletions vite-project/src/api/api.js
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +9 to +22
Copy link
Collaborator

Choose a reason for hiding this comment

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

(심화/응용) 해당 함수는 Base URL을 만드는 유틸 함수로 보이는군요 !

다음과 같이 fetch를 간단히 사용할 수 있으면 얼마나 좋을까요?:

const response = codeitApiInstance.get("products") //  => fetch("https://bootcamp-api.codeit.kr/api/products")

그 외에도 여러 상황들을 고려해볼 수 있어요.
예를 들어서 method를 고려해야 하며, 그 중 post일 경우 body를 고려해볼 수 있고, get일 경우에는 쿼리(?query=1)를 고려하게 될겁니다 !

더 나아가면 공통적인 예외 처리, 응답 실패 시의 예외 처리, 특정 path에 대한 예외 처리 등등도 고려될 수 있겠네요.
이러한 모듈을 만들어 보는 것도 좋은 경험이 될 수 있을거예요.

다음은 GPT로 만들어낸 예시입니다:

class HttpClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }

  async request(endpoint, { method = 'GET', body, headers = {}, query } = {}) {
    const url = new URL(`${this.baseURL}/${endpoint}`);

    // GET 요청일 때 쿼리스트링 처리
    if (method === 'GET' && query) {
      Object.entries(query).forEach(([key, value]) => {
        url.searchParams.append(key, value);
      });
    }

    const options = {
      method,
      headers: {
        'Content-Type': 'application/json',
        ...headers,
      },
    };

    if (body && method !== 'GET') {
      options.body = JSON.stringify(body);
    }

    try {
      const response = await fetch(url.toString(), options);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return await response.json();
    } catch (error) {
      console.error('Fetch error:', error.message);
      throw error;
    }
  }

  get(endpoint, config = {}) {
    return this.request(endpoint, { ...config, method: 'GET' });
  }

  post(endpoint, body, config = {}) {
    return this.request(endpoint, { ...config, method: 'POST', body });
  }

  put(endpoint, body, config = {}) {
    return this.request(endpoint, { ...config, method: 'PUT', body });
  }

  delete(endpoint, config = {}) {
    return this.request(endpoint, { ...config, method: 'DELETE' });
  }
}

그리고 사용 예시:

const api = new HttpClient('https://bootcamp-api.codeit.kr/api');

// GET 요청
api.get('products', { query: { page: 1, pageSize: 10 } })
  .then((data) => console.log(data));

// POST 요청
api.post('login', {
  email: '[email protected]',
  password: '1234',
});

참솔님이라면 이러한 학습 욕심도 있으실 것 같아서 제안드려봅니다 ~!

Copy link
Collaborator

Choose a reason for hiding this comment

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

혹은 편하게 axios를 사용하는 방법도 있어요 !

fetch 모듈을 잘 만든다는 것은 어렵습니다. 다음 사항들을 고려해볼 수 있어요:

  1. 만약 get이 아닌 메써드(post, patch, delete 등)일 경우는 어떻게 처리할 수 있을까요?
  2. querybody가 필요할 때는 어떻게 처리 할 수 있을까요?
  3. 로그인 인가를 위한 토큰을 request 전에 자동으로 삽입할 수는 없을까요? (인증/인가를 자동으로 할 수 없을까요?)
  4. 처음 한 번에 Base URL을 지정할 수는 없을까요?
    1. Base URL을 사용하다가 타 Domain에 보내야 될 때는 어떻게 할 수 있을까요?
      이 모든 요구사항들을 '잘 만든다는 것'은 어려워요. 따라서 이 모든걸 만들어진 fetch 모듈을 사용해보고 후에 fetch모듈을 만들어 보는 것도 좋은 학습 방법이 될 수 있어요.

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의 인터셉터를 활용할 수 있습니다 !

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

사용 방법 🚀

사용 방법은 정말 간단해요. 다음과 같이 사용할 수 있습니다:

instance.get(`/user/${userId}`)

딱 보니. 마이그레이션도 정말 쉽게 할 수 있겠죠? 😊

axios API

60 changes: 60 additions & 0 deletions vite-project/src/api/comments-mock.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
15 changes: 15 additions & 0 deletions vite-project/src/api/comments.js
Original file line number Diff line number Diff line change
@@ -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;
}
27 changes: 21 additions & 6 deletions vite-project/src/api/products.js
Original file line number Diff line number Diff line change
@@ -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");
}
Expand All @@ -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;
}
Binary file added vite-project/src/assets/comments-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions vite-project/src/assets/ic-arrow-back.svg
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 vite-project/src/assets/ic-dots-3-vertical.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 0 additions & 20 deletions vite-project/src/components/Button.jsx

This file was deleted.

7 changes: 2 additions & 5 deletions vite-project/src/components/Nav.jsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -40,9 +39,7 @@ function Nav() {
중고마켓
</NavigationLink>
</ul>
<div className="Nav__profile">
<img src={profileImg} />
</div>
<Avatar />
</div>
</nav>
);
Expand Down
2 changes: 1 addition & 1 deletion vite-project/src/components/OrderBySelect.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

.OrderBySelect {
background-color: white;
padding: 12px 20px;
padding: 8px 20px;
display: flex;
gap: 24px;
outline: none;
Expand Down
47 changes: 0 additions & 47 deletions vite-project/src/components/SearchInput.css

This file was deleted.

13 changes: 0 additions & 13 deletions vite-project/src/components/SearchInput.jsx

This file was deleted.

5 changes: 4 additions & 1 deletion vite-project/src/components/add-item/adding-item-image.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -25,10 +26,12 @@ const StyledAddingItemImage = styled.div`

@media (max-width: 1199px) {
width: 168px;
height: 168px;
}

@media (max-width: 767px) {
width: 50%;
height: auto;
}
`;

Expand Down
26 changes: 26 additions & 0 deletions vite-project/src/components/avatar.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<StyledAvatar $size={size}>
<img src={imageUrl || defaultImg} alt="프로필 사진" />
</StyledAvatar>
);
}

export default Avatar;
9 changes: 9 additions & 0 deletions vite-project/src/components/button/button-styles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const BUTTON_SIZE = {
medium: "medium",
small: "small",
};

export const BUTTON_TYPE = {
round: "round",
pill: "pill",
};
52 changes: 52 additions & 0 deletions vite-project/src/components/button/button.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<StyledButton $size={size} $type={type} {...props}>
{children}
</StyledButton>
);
}

export default Button;
Loading
Loading