Skip to content

Commit d905c7c

Browse files
authored
[나지원] sprint12 (#137)
* ✨ feat: implement items page * ✨ feat: implement additem page * ✨ feat: implement item detail page
1 parent c432a31 commit d905c7c

32 files changed

+1496
-23
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
.form {
2+
display: flex;
3+
flex-direction: column;
4+
gap: 24px;
5+
}
6+
7+
.header {
8+
display: flex;
9+
justify-content: space-between;
10+
align-items: center;
11+
}
12+
13+
.title {
14+
font-size: 1.25rem;
15+
font-weight: 700;
16+
}
17+
18+
.button {
19+
padding: 12px 23px 12px 23px;
20+
line-height: 1.125rem;
21+
}
22+
23+
.tags {
24+
margin-top: -10px;
25+
}

components/additem/AddItemForm.tsx

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import {
2+
useState,
3+
useEffect,
4+
ChangeEvent,
5+
KeyboardEvent,
6+
MouseEvent,
7+
} from "react";
8+
import { useRouter } from "next/router";
9+
import { useMutation } from "@tanstack/react-query";
10+
import FileInput from "../ui/FileInput";
11+
import Input from "../ui/Input";
12+
import Textarea from "../ui/Textarea";
13+
import Button from "../ui/Button";
14+
import Tags from "../ui/Tags";
15+
import { uploadImage } from "@/lib/imageService";
16+
import { addProduct } from "@/lib/productService";
17+
import styles from "./AddItemForm.module.css";
18+
19+
interface ProductFormValues {
20+
imgFile: File | null;
21+
product: string;
22+
description: string;
23+
price: string;
24+
tags: string[];
25+
}
26+
27+
type ProductField = keyof ProductFormValues;
28+
29+
export type CreateProductRequestBody = Omit<ProductFormValues, "imgFile"> & {
30+
imgUrl: string;
31+
};
32+
33+
const INITIAL_PRODUCT = {
34+
imgFile: null,
35+
product: "",
36+
description: "",
37+
price: "",
38+
tags: [],
39+
};
40+
41+
const AddItemForm = () => {
42+
const [isDisabled, setIsDisabled] = useState(true);
43+
const [values, setValues] = useState<ProductFormValues>(INITIAL_PRODUCT);
44+
const router = useRouter();
45+
46+
const uploadImageMutation = useMutation({
47+
mutationFn: (imgFile: File) => uploadImage(imgFile),
48+
});
49+
50+
const addProductMutation = useMutation({
51+
mutationFn: (formValues: CreateProductRequestBody) =>
52+
addProduct(formValues),
53+
onSuccess: (id) => {
54+
router.push(`/items/${id}`);
55+
},
56+
});
57+
58+
const handleChange = (
59+
name: string,
60+
value: ProductFormValues[ProductField]
61+
) => {
62+
setValues((prevValues) => {
63+
return {
64+
...prevValues,
65+
[name]: value,
66+
};
67+
});
68+
};
69+
70+
const handleInputChange = (
71+
e: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>
72+
) => {
73+
const { name, value } = e.target;
74+
handleChange(name, value);
75+
};
76+
77+
const checkFormEmpty = (values: ProductFormValues) => {
78+
const { imgFile, ...otherValues } = values;
79+
80+
return Object.values(otherValues).some((value) => {
81+
if (Array.isArray(value)) {
82+
return value.length === 0;
83+
}
84+
return value === "";
85+
});
86+
};
87+
88+
const handleSubmit = async (
89+
e: MouseEvent<HTMLButtonElement>
90+
): Promise<void> => {
91+
e.preventDefault();
92+
93+
const { imgFile, ...otherValues } = values;
94+
let imgUrl = "https://example.com/...";
95+
96+
if (imgFile) {
97+
imgUrl = await uploadImageMutation.mutateAsync(imgFile);
98+
}
99+
100+
addProductMutation.mutate({ imgUrl, ...otherValues });
101+
};
102+
103+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
104+
if (e.key === "Enter" && e.nativeEvent.isComposing === false) {
105+
e.preventDefault();
106+
e.stopPropagation();
107+
108+
const { name, value } = e.currentTarget;
109+
e.currentTarget.value = "";
110+
if (values.tags.includes(value) || value.trim() === "") return;
111+
112+
handleChange(name, [...values.tags, value]);
113+
}
114+
};
115+
116+
const handleTagRemove = (
117+
e: MouseEvent<HTMLButtonElement>,
118+
target: string
119+
) => {
120+
e.preventDefault();
121+
const nextValue: string[] = values.tags.filter((tag) => tag !== target);
122+
handleChange("tags", nextValue);
123+
};
124+
125+
useEffect(() => {
126+
setIsDisabled(checkFormEmpty(values));
127+
}, [values]);
128+
129+
return (
130+
<form className={styles.form}>
131+
<header className={styles.header}>
132+
<h2 className={styles.title}>상품 등록하기</h2>
133+
<Button
134+
type="submit"
135+
className={styles.button}
136+
disabled={isDisabled}
137+
onClick={handleSubmit}
138+
>
139+
등록
140+
</Button>
141+
</header>
142+
<FileInput
143+
label="상품 이미지"
144+
name="imgFile"
145+
value={values.imgFile}
146+
onChange={handleChange}
147+
/>
148+
<Input
149+
type="text"
150+
label="상품명"
151+
name="product"
152+
placeholder="상품명을 입력해주세요"
153+
value={values.product}
154+
onChange={handleInputChange}
155+
/>
156+
<Textarea
157+
label="상품 소개"
158+
name="description"
159+
placeholder="상품 소개를 입력해주세요"
160+
value={values.description}
161+
onChange={handleInputChange}
162+
/>
163+
<Input
164+
type="text"
165+
label="판매가격"
166+
name="price"
167+
placeholder="판매가격을 입력해주세요"
168+
value={values.price}
169+
onChange={handleInputChange}
170+
/>
171+
<Input
172+
type="text"
173+
label="태그"
174+
name="tags"
175+
placeholder="태그를 입력해주세요"
176+
onKeyDown={handleKeyDown}
177+
className={styles.tags}
178+
/>
179+
<Tags tags={values.tags} onRemove={handleTagRemove} />
180+
</form>
181+
);
182+
};
183+
184+
export default AddItemForm;

components/board/Comments.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
11
import { useState } from "react";
2+
import Image from "next/image";
23
import EditCommentForm from "./EditCommentForm";
34
import Comment from "./Comment";
5+
import { CommentProps } from "@/types/articleTypes";
46
import styles from "./Comments.module.css";
57
import replyEmptyImg from "@/public/Img_reply_empty.svg";
6-
import Image from "next/image";
7-
import { CommentProps } from "@/types/articleTypes";
88

99
interface CommentsProps {
1010
comments: CommentProps[];
1111
onUpdate: (id: number | null, value: string) => void;
12+
onDelete: (id: number | null) => void;
1213
}
1314

14-
const Comments = ({ comments, onUpdate }: CommentsProps) => {
15+
const Comments = ({ comments, onUpdate, onDelete }: CommentsProps) => {
1516
const [editingId, setEditingId] = useState<number | null>(null);
1617

1718
const handleSelect = (id: number, option: string) => {
1819
if (option === "edit") {
1920
setEditingId(id);
2021
}
22+
if (option === "remove") {
23+
onDelete(id);
24+
}
2125
};
2226

2327
const handleCancel = () => {

components/item/ItemDetail.module.css

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
.productDetail {
2+
border-bottom: 1px solid var(--gray200);
3+
margin-bottom: 24px;
4+
}
5+
6+
.imageWrapper {
7+
position: relative;
8+
aspect-ratio: 1;
9+
width: 100%;
10+
margin-bottom: 16px;
11+
}
12+
13+
.productImg {
14+
object-fit: cover;
15+
border-radius: 12px;
16+
}
17+
18+
.nameContainer {
19+
display: flex;
20+
justify-content: space-between;
21+
align-items: center;
22+
}
23+
24+
.name {
25+
font-size: 1rem;
26+
font-weight: 600;
27+
color: var(--gray800);
28+
}
29+
30+
.price {
31+
font-size: 1.5rem;
32+
font-weight: 600;
33+
color: var(--gray800);
34+
border-bottom: 1px solid var(--gray200);
35+
padding: 8px 0 16px;
36+
margin-bottom: 16px;
37+
}
38+
39+
.subContainer {
40+
margin-bottom: 24px;
41+
}
42+
43+
.subTitle {
44+
display: block;
45+
font-size: 0.875rem;
46+
font-weight: 600;
47+
color: var(--gray800);
48+
margin-bottom: 8px;
49+
}
50+
51+
.description {
52+
font-size: 1rem;
53+
font-weight: 400;
54+
color: var(--gray800);
55+
}
56+
57+
.userInfo {
58+
display: flex;
59+
justify-content: space-between;
60+
align-items: center;
61+
padding: 16px 0 24px;
62+
}
63+
64+
.authorInfo {
65+
gap: 16px;
66+
}
67+
68+
.authorInfo > div {
69+
display: flex;
70+
flex-direction: column;
71+
align-items: flex-start;
72+
gap: 2px;
73+
}
74+
75+
.authorInfo img {
76+
width: 40px;
77+
height: 40px;
78+
border-radius: 50%;
79+
}
80+
81+
.authorInfo span {
82+
font-size: 0.875rem;
83+
font-weight: 500;
84+
line-height: 1.5rem;
85+
}
86+
87+
.authorInfo time {
88+
font-size: 0.875rem;
89+
font-weight: 400;
90+
line-height: 1.5rem;
91+
}
92+
93+
.heartButtonContainer {
94+
border-left: 1px solid var(--gray200);
95+
padding-left: 24px;
96+
}
97+
98+
@media screen and (min-width: 768px) {
99+
.productDetail {
100+
display: flex;
101+
gap: 16px;
102+
margin-bottom: 40px;
103+
}
104+
105+
.imageWrapper {
106+
width: 40%;
107+
}
108+
109+
.productContainer {
110+
flex: 1 1 0%;
111+
display: flex;
112+
flex-direction: column;
113+
justify-content: space-between;
114+
}
115+
116+
.name {
117+
font-size: 1.25rem;
118+
}
119+
120+
.price {
121+
font-size: 2rem;
122+
}
123+
124+
.userInfo {
125+
padding-bottom: 32px;
126+
}
127+
}
128+
129+
@media screen and (min-width: 1200px) {
130+
.productDetail {
131+
gap: 24px;
132+
}
133+
134+
.name {
135+
font-size: 1.5rem;
136+
}
137+
138+
.price {
139+
font-size: 2.5rem;
140+
font-weight: 600;
141+
padding: 16px 0;
142+
margin-bottom: 24px;
143+
}
144+
145+
.subTitle {
146+
font-size: 1rem;
147+
margin-bottom: 16px;
148+
}
149+
150+
.userInfo {
151+
padding-bottom: 40px;
152+
}
153+
}

0 commit comments

Comments
 (0)