-
Notifications
You must be signed in to change notification settings - Fork 21
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* ✨ feat: implement items page * ✨ feat: implement additem page * ✨ feat: implement item detail page
- Loading branch information
Showing
32 changed files
with
1,496 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
.form { | ||
display: flex; | ||
flex-direction: column; | ||
gap: 24px; | ||
} | ||
|
||
.header { | ||
display: flex; | ||
justify-content: space-between; | ||
align-items: center; | ||
} | ||
|
||
.title { | ||
font-size: 1.25rem; | ||
font-weight: 700; | ||
} | ||
|
||
.button { | ||
padding: 12px 23px 12px 23px; | ||
line-height: 1.125rem; | ||
} | ||
|
||
.tags { | ||
margin-top: -10px; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,184 @@ | ||
import { | ||
useState, | ||
useEffect, | ||
ChangeEvent, | ||
KeyboardEvent, | ||
MouseEvent, | ||
} from "react"; | ||
import { useRouter } from "next/router"; | ||
import { useMutation } from "@tanstack/react-query"; | ||
import FileInput from "../ui/FileInput"; | ||
import Input from "../ui/Input"; | ||
import Textarea from "../ui/Textarea"; | ||
import Button from "../ui/Button"; | ||
import Tags from "../ui/Tags"; | ||
import { uploadImage } from "@/lib/imageService"; | ||
import { addProduct } from "@/lib/productService"; | ||
import styles from "./AddItemForm.module.css"; | ||
|
||
interface ProductFormValues { | ||
imgFile: File | null; | ||
product: string; | ||
description: string; | ||
price: string; | ||
tags: string[]; | ||
} | ||
|
||
type ProductField = keyof ProductFormValues; | ||
|
||
export type CreateProductRequestBody = Omit<ProductFormValues, "imgFile"> & { | ||
imgUrl: string; | ||
}; | ||
|
||
const INITIAL_PRODUCT = { | ||
imgFile: null, | ||
product: "", | ||
description: "", | ||
price: "", | ||
tags: [], | ||
}; | ||
|
||
const AddItemForm = () => { | ||
const [isDisabled, setIsDisabled] = useState(true); | ||
const [values, setValues] = useState<ProductFormValues>(INITIAL_PRODUCT); | ||
const router = useRouter(); | ||
|
||
const uploadImageMutation = useMutation({ | ||
mutationFn: (imgFile: File) => uploadImage(imgFile), | ||
}); | ||
|
||
const addProductMutation = useMutation({ | ||
mutationFn: (formValues: CreateProductRequestBody) => | ||
addProduct(formValues), | ||
onSuccess: (id) => { | ||
router.push(`/items/${id}`); | ||
}, | ||
}); | ||
|
||
const handleChange = ( | ||
name: string, | ||
value: ProductFormValues[ProductField] | ||
) => { | ||
setValues((prevValues) => { | ||
return { | ||
...prevValues, | ||
[name]: value, | ||
}; | ||
}); | ||
}; | ||
|
||
const handleInputChange = ( | ||
e: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement> | ||
) => { | ||
const { name, value } = e.target; | ||
handleChange(name, value); | ||
}; | ||
|
||
const checkFormEmpty = (values: ProductFormValues) => { | ||
const { imgFile, ...otherValues } = values; | ||
|
||
return Object.values(otherValues).some((value) => { | ||
if (Array.isArray(value)) { | ||
return value.length === 0; | ||
} | ||
return value === ""; | ||
}); | ||
}; | ||
|
||
const handleSubmit = async ( | ||
e: MouseEvent<HTMLButtonElement> | ||
): Promise<void> => { | ||
e.preventDefault(); | ||
|
||
const { imgFile, ...otherValues } = values; | ||
let imgUrl = "https://example.com/..."; | ||
|
||
if (imgFile) { | ||
imgUrl = await uploadImageMutation.mutateAsync(imgFile); | ||
} | ||
|
||
addProductMutation.mutate({ imgUrl, ...otherValues }); | ||
}; | ||
|
||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { | ||
if (e.key === "Enter" && e.nativeEvent.isComposing === false) { | ||
e.preventDefault(); | ||
e.stopPropagation(); | ||
|
||
const { name, value } = e.currentTarget; | ||
e.currentTarget.value = ""; | ||
if (values.tags.includes(value) || value.trim() === "") return; | ||
|
||
handleChange(name, [...values.tags, value]); | ||
} | ||
}; | ||
|
||
const handleTagRemove = ( | ||
e: MouseEvent<HTMLButtonElement>, | ||
target: string | ||
) => { | ||
e.preventDefault(); | ||
const nextValue: string[] = values.tags.filter((tag) => tag !== target); | ||
handleChange("tags", nextValue); | ||
}; | ||
|
||
useEffect(() => { | ||
setIsDisabled(checkFormEmpty(values)); | ||
}, [values]); | ||
|
||
return ( | ||
<form className={styles.form}> | ||
<header className={styles.header}> | ||
<h2 className={styles.title}>상품 등록하기</h2> | ||
<Button | ||
type="submit" | ||
className={styles.button} | ||
disabled={isDisabled} | ||
onClick={handleSubmit} | ||
> | ||
등록 | ||
</Button> | ||
</header> | ||
<FileInput | ||
label="상품 이미지" | ||
name="imgFile" | ||
value={values.imgFile} | ||
onChange={handleChange} | ||
/> | ||
<Input | ||
type="text" | ||
label="상품명" | ||
name="product" | ||
placeholder="상품명을 입력해주세요" | ||
value={values.product} | ||
onChange={handleInputChange} | ||
/> | ||
<Textarea | ||
label="상품 소개" | ||
name="description" | ||
placeholder="상품 소개를 입력해주세요" | ||
value={values.description} | ||
onChange={handleInputChange} | ||
/> | ||
<Input | ||
type="text" | ||
label="판매가격" | ||
name="price" | ||
placeholder="판매가격을 입력해주세요" | ||
value={values.price} | ||
onChange={handleInputChange} | ||
/> | ||
<Input | ||
type="text" | ||
label="태그" | ||
name="tags" | ||
placeholder="태그를 입력해주세요" | ||
onKeyDown={handleKeyDown} | ||
className={styles.tags} | ||
/> | ||
<Tags tags={values.tags} onRemove={handleTagRemove} /> | ||
</form> | ||
); | ||
}; | ||
|
||
export default AddItemForm; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,153 @@ | ||
.productDetail { | ||
border-bottom: 1px solid var(--gray200); | ||
margin-bottom: 24px; | ||
} | ||
|
||
.imageWrapper { | ||
position: relative; | ||
aspect-ratio: 1; | ||
width: 100%; | ||
margin-bottom: 16px; | ||
} | ||
|
||
.productImg { | ||
object-fit: cover; | ||
border-radius: 12px; | ||
} | ||
|
||
.nameContainer { | ||
display: flex; | ||
justify-content: space-between; | ||
align-items: center; | ||
} | ||
|
||
.name { | ||
font-size: 1rem; | ||
font-weight: 600; | ||
color: var(--gray800); | ||
} | ||
|
||
.price { | ||
font-size: 1.5rem; | ||
font-weight: 600; | ||
color: var(--gray800); | ||
border-bottom: 1px solid var(--gray200); | ||
padding: 8px 0 16px; | ||
margin-bottom: 16px; | ||
} | ||
|
||
.subContainer { | ||
margin-bottom: 24px; | ||
} | ||
|
||
.subTitle { | ||
display: block; | ||
font-size: 0.875rem; | ||
font-weight: 600; | ||
color: var(--gray800); | ||
margin-bottom: 8px; | ||
} | ||
|
||
.description { | ||
font-size: 1rem; | ||
font-weight: 400; | ||
color: var(--gray800); | ||
} | ||
|
||
.userInfo { | ||
display: flex; | ||
justify-content: space-between; | ||
align-items: center; | ||
padding: 16px 0 24px; | ||
} | ||
|
||
.authorInfo { | ||
gap: 16px; | ||
} | ||
|
||
.authorInfo > div { | ||
display: flex; | ||
flex-direction: column; | ||
align-items: flex-start; | ||
gap: 2px; | ||
} | ||
|
||
.authorInfo img { | ||
width: 40px; | ||
height: 40px; | ||
border-radius: 50%; | ||
} | ||
|
||
.authorInfo span { | ||
font-size: 0.875rem; | ||
font-weight: 500; | ||
line-height: 1.5rem; | ||
} | ||
|
||
.authorInfo time { | ||
font-size: 0.875rem; | ||
font-weight: 400; | ||
line-height: 1.5rem; | ||
} | ||
|
||
.heartButtonContainer { | ||
border-left: 1px solid var(--gray200); | ||
padding-left: 24px; | ||
} | ||
|
||
@media screen and (min-width: 768px) { | ||
.productDetail { | ||
display: flex; | ||
gap: 16px; | ||
margin-bottom: 40px; | ||
} | ||
|
||
.imageWrapper { | ||
width: 40%; | ||
} | ||
|
||
.productContainer { | ||
flex: 1 1 0%; | ||
display: flex; | ||
flex-direction: column; | ||
justify-content: space-between; | ||
} | ||
|
||
.name { | ||
font-size: 1.25rem; | ||
} | ||
|
||
.price { | ||
font-size: 2rem; | ||
} | ||
|
||
.userInfo { | ||
padding-bottom: 32px; | ||
} | ||
} | ||
|
||
@media screen and (min-width: 1200px) { | ||
.productDetail { | ||
gap: 24px; | ||
} | ||
|
||
.name { | ||
font-size: 1.5rem; | ||
} | ||
|
||
.price { | ||
font-size: 2.5rem; | ||
font-weight: 600; | ||
padding: 16px 0; | ||
margin-bottom: 24px; | ||
} | ||
|
||
.subTitle { | ||
font-size: 1rem; | ||
margin-bottom: 16px; | ||
} | ||
|
||
.userInfo { | ||
padding-bottom: 40px; | ||
} | ||
} |
Oops, something went wrong.