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
7 changes: 6 additions & 1 deletion src/pages/MarketPage/AllProducts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import getMediaCount from '@/utils/getMediaCount';
import ProductCard from '@/components/Cards/ProductCard';
import SkeletonCard from '@/components/Cards/SkeletonCard';
import useDebounce from '@/hooks/useDebounce';
import { useNavigate } from 'react-router-dom';
function AllProducts() {
const navigate = useNavigate();
// 미디어 쿼리에 따라 페이지당 제품의 개수를 설정합니다.
const { allProductsCount: pageSize } = getMediaCount();

Expand Down Expand Up @@ -43,9 +45,12 @@ function AllProducts() {
setSort(newSort);
setPage(1); // 정렬 변경 시에도 1페이지로
};

// 상품 추가 버튼 클릭 핸들러
const handleAddClick = () => {
// todo: /additem 으로 이동
// /additem 으로 이동
navigate('/additem');
console.log('상품 추가 버튼 클릭');
Copy link
Collaborator

Choose a reason for hiding this comment

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

불필요한 로그는 PR 올리실때 지워봅시다! :)

};

return (
Expand Down
5 changes: 4 additions & 1 deletion src/pages/MarketPage/AllProductsHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ function AllProductsHeader({
className={style['all-products-header__search']}
onChange={(e) => onKeywordChange(e.currentTarget.value)}
/>
<button className={style['all-products-header__add']}>
<button
className={style['all-products-header__add']}
onClick={onAddClick}
>
상품 등록하기
</button>

Expand Down
155 changes: 155 additions & 0 deletions src/pages/ProductAddPage/ProductAddPage.module.scss
Copy link
Collaborator

Choose a reason for hiding this comment

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

BEM 방식으로 잘 nesting 되어있네요. 굳굳 👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/* block */
.product-add-page {
display: flex;
flex-direction: column;
gap: 1.6rem;
padding: 2rem;
font-size: 1.4rem;

// ----- header -----
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.2rem;
}

&__title {
font-size: 2rem;
font-weight: 600;
}

&__submit-btn {
background: #a6abb7;
color: #fff;
border: none;
border-radius: 6px;
padding: 0.6rem 1.4rem;
cursor: pointer;

&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}

// ----- images -----
&__images {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}

// 업로더 카드
&__uploader {
width: 160px;
height: 160px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
background: #fafafa;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
text-align: center;
transition: background 0.15s;

&:hover {
background: #f0f0f0;
}
}

&__uploader-plus {
font-size: 3.2rem;
line-height: 1;
}
&__uploader-text {
margin-top: 0.4rem;
font-size: 1.2rem;
color: #777;
}
&__images-error {
margin-top: 0.4rem;
font-size: 1.2rem;
color: #ff4d4f; // 빨간색
}

// 썸네일
&__thumb {
position: relative;
width: 160px;
height: 160px;
border-radius: 8px;
overflow: hidden;

img {
width: 100%;
height: 100%;
object-fit: cover;
}
}

&__thumb-delete {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
line-height: 24px;
font-size: 1.4rem;
cursor: pointer;
}

// 일반 인풋 / textarea
&__input,
&__textarea {
width: 100%;
border: none;
border-radius: 6px;
background: #f5f6f8;
padding: 1.2rem 1.4rem;
font-size: 1.4rem;
resize: none;

&:focus {
outline: 2px solid #4f7cff;
background: #fff;
}
}

// textarea만 별도
&__textarea {
min-height: 140px;
}

// ----- tags -----
&__tags {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}

&__tag {
background: #f1f1f1;
border-radius: 9999px;
padding: 0.4rem 0.8rem 0.4rem 1rem;
font-size: 1.2rem;
position: relative;
display: flex;
align-items: center;
}

&__tag-delete {
margin-left: 0.4rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 1.2rem;
line-height: 1;
}
}
157 changes: 157 additions & 0 deletions src/pages/ProductAddPage/ProductAddPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import React, { useRef, useState, ChangeEvent, KeyboardEvent } from 'react';
import style from './ProductAddPage.module.scss';

interface ImagePreview {
file: File;
url: string;
}

const MAX_IMAGES = 1;

export default function ProductAddPage() {
const [images, setImages] = useState<ImagePreview[]>([]);
const [errorMsg, setErrorMsg] = useState('');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [price, setPrice] = useState('');
const [tagInput, setTagInput] = useState('');
const [tags, setTags] = useState<string[]>([]);

const fileInputRef = useRef<HTMLInputElement | null>(null);

const handleSelectImages = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;

if (images.length >= MAX_IMAGES) {
setErrorMsg('*이미지 등록은 최대 1개까지 가능합니다.');
e.target.value = '';
return;
}

const [file] = files;
setImages([{ file, url: URL.createObjectURL(file) }]);
setErrorMsg('');
e.target.value = '';
};

const handleRemoveImage = () => {
images.forEach((i) => URL.revokeObjectURL(i.url));
setImages([]);
setErrorMsg('');
};

const handleTagKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;

if (e.key !== 'Enter' && e.key !== ',') return;
e.preventDefault();

const value = tagInput.trim();
if (!value || tags.includes(value)) return;

setTags((prev) => [...prev, value]);
Copy link
Collaborator

Choose a reason for hiding this comment

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

spread 연산자를 사용해 배열을 복사 후 새 아이템을 추가하는군요!
Array또한 레퍼런스 타입이라 array.push로 직접적으로 변경하지않고 지금과 같이 원본 배열을 변경하지않는 방식이 좋습니다 :)

이 과정에서 배열을 다룰때 발생할 수 있는 예외 케이스 (예를 들어, 중복값을 허용할것인지 등) 를 꼼꼼하게 더 처리해볼까요?

setTagInput('');
};
const removeTag = (t: string) =>
Comment on lines +55 to +56
Copy link
Collaborator

Choose a reason for hiding this comment

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

55, 56 사이 공백 한칸 띄워줄까요? 함수와 함수 사이엔 공백 한칸씩 꼭 띄우도록합시다 :)

setTags((prev) => prev.filter((x) => x !== t));

const handleSubmit = () => {
console.log({ images, name, description, price: Number(price), tags });
};
Comment on lines +59 to +61
Copy link
Collaborator

Choose a reason for hiding this comment

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

해당 함수는 실제 제출을 담당하지는 않고 로그만 찍는 용도인가요? :)


return (
<section className={style['product-add-page']}>
{/* === 헤더 === */}
<header className={style['product-add-page__header']}>
<h3 className={style['product-add-page__title']}>상품 등록하기</h3>
<button
type="button"
className={style['product-add-page__submit-btn']}
onClick={handleSubmit}
disabled={!name || !price || !images.length}
>
등록
</button>
</header>
상품 이미지
<div className={style['product-add-page__images']}>
<label className={style['product-add-page__uploader']}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleSelectImages}
hidden
/>
<span className={style['product-add-page__uploader-plus']}></span>
<span className={style['product-add-page__uploader-text']}>
이미지 등록
</span>
</label>

{images.map(({ url }) => (
<div key={url} className={style['product-add-page__thumb']}>
<img src={url} alt="preview" />
<button
type="button"
className={style['product-add-page__thumb-delete']}
onClick={handleRemoveImage}
>
×
</button>
</div>
))}
</div>
{errorMsg && (
<p className={style['product-add-page__images-error']}>{errorMsg}</p>
)}
상품명
<input
className={style['product-add-page__input']}
placeholder="상품명을 입력해주세요"
value={name}
onChange={(e) => setName(e.target.value)}
/>
Comment on lines +110 to +115
Copy link
Collaborator

Choose a reason for hiding this comment

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

ProductAddPage의 복잡도를 감소시키기위해,
폼 컨트롤 + 업데이트 로직을 다른 컴포넌트 단위로 분리해보는건 어떨까요?
Form, FormInput 두개의 컴포넌트를 사용해 리팩토링해봐요!

상품 소개
<textarea
className={style['product-add-page__textarea']}
placeholder="상품 소개를 입력해주세요"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={6}
/>
판매 가격
<input
type="number"
min={0}
className={style['product-add-page__input']}
placeholder="판매 가격을 입력해주세요"
value={price}
onChange={(e) => setPrice(e.target.value)}
/>
태그
<input
className={style['product-add-page__input']}
placeholder="태그를 입력해주세요"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
/>
<ul className={style['product-add-page__tags']}>
{tags.map((t) => (
<li key={t} className={style['product-add-page__tag']}>
#{t}
<button
type="button"
className={style['product-add-page__tag-delete']}
onClick={() => removeTag(t)}
>
×
</button>
</li>
))}
</ul>
</section>
);
}
4 changes: 2 additions & 2 deletions src/routes/routes.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// src/router/routes.ts
//라우터 경로 정의
import MarketPage from '@/pages/MarketPage/MarketPage';

import ProductAddPage from '@/pages/ProductAddPage/ProductAddPage';
//라우터 인터페이스 설정
export interface AppRoute {
path: string;
element: React.ComponentType;
}
export const routes: AppRoute[] = [
{ path: '/items', element: MarketPage },

{ path: '/additem', element: ProductAddPage }, // 상품 등록 페이지
// …추가 라우트
];
Loading