Skip to content

Commit

Permalink
[나지원] sprint12 (#137)
Browse files Browse the repository at this point in the history
* ✨ feat: implement items page

* ✨ feat: implement additem page

* ✨ feat: implement item detail page
  • Loading branch information
najitwo authored Dec 9, 2024
1 parent c432a31 commit d905c7c
Show file tree
Hide file tree
Showing 32 changed files with 1,496 additions and 23 deletions.
25 changes: 25 additions & 0 deletions components/additem/AddItemForm.module.css
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;
}
184 changes: 184 additions & 0 deletions components/additem/AddItemForm.tsx
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;
10 changes: 7 additions & 3 deletions components/board/Comments.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import { useState } from "react";
import Image from "next/image";
import EditCommentForm from "./EditCommentForm";
import Comment from "./Comment";
import { CommentProps } from "@/types/articleTypes";
import styles from "./Comments.module.css";
import replyEmptyImg from "@/public/Img_reply_empty.svg";
import Image from "next/image";
import { CommentProps } from "@/types/articleTypes";

interface CommentsProps {
comments: CommentProps[];
onUpdate: (id: number | null, value: string) => void;
onDelete: (id: number | null) => void;
}

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

const handleSelect = (id: number, option: string) => {
if (option === "edit") {
setEditingId(id);
}
if (option === "remove") {
onDelete(id);
}
};

const handleCancel = () => {
Expand Down
153 changes: 153 additions & 0 deletions components/item/ItemDetail.module.css
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;
}
}
Loading

0 comments on commit d905c7c

Please sign in to comment.