diff --git a/src/App.js b/src/App.js index afabad3f..a037d4be 100644 --- a/src/App.js +++ b/src/App.js @@ -1,13 +1,12 @@ import { Routes, Route } from "react-router-dom"; -import Home from "./pages/Home"; -import Items from "./pages/Items/Items.js"; +import Home from "./pages/home/Home.js"; import DefaultLayout from "./layouts/DefaultLayout"; import "pretendard/dist/web/static/pretendard.css"; import "./styles/global.css"; -import Additem from "./pages/Additem"; +import Additem from "./pages/additem/Additem.js"; +import Items from "./pages/Items/Items.js"; function App() { - return (
diff --git a/src/layouts/DefaultLayout.js b/src/layouts/DefaultLayout.js index 52452abe..1a919dd5 100644 --- a/src/layouts/DefaultLayout.js +++ b/src/layouts/DefaultLayout.js @@ -1,7 +1,6 @@ import { Outlet } from "react-router-dom"; -import Navbar from "../components/Navbar/Navbar.js"; -import styles from "./DefaultLayout.module.css" - +import styles from "./DefaultLayout.module.css"; +import Navbar from "../components/Navbar/Navbar"; function DefaultLayout({ children }) { return ( diff --git a/src/pages/Additem.js b/src/pages/Additem.js deleted file mode 100644 index b34e90a3..00000000 --- a/src/pages/Additem.js +++ /dev/null @@ -1,6 +0,0 @@ -// pages/Additem.js -function Additem() { - return
๐Ÿงบ ์•„์ดํ…œ ์ถ”๊ฐ€ ํŽ˜์ด์ง€์ž…๋‹ˆ๋‹ค
; -} - -export default Additem; \ No newline at end of file diff --git a/src/pages/Items/Items.js b/src/pages/Items/Items.js index 449a1f1c..4537f25f 100644 --- a/src/pages/Items/Items.js +++ b/src/pages/Items/Items.js @@ -1,6 +1,6 @@ import style from "./Items.module.css" -import BestProducts from "./components/BestProducts/BestProducts"; -import AllProducts from "./components/AllProducts/AllProducts"; +import BestProducts from "./components/best-products/BestProducts"; +import AllProducts from "./components/all-products/AllProducts"; import { BEST_PRODUCTS_PER_DEVICE,ALL_PRODUCTS_PER_DEVICE } from "../../constants/products"; import { TITLE_ALL_PRODUCTS_COMP,TITLE_BEST_PRODUCTS_COMP } from "../../constants/titles"; function Items() { diff --git a/src/pages/Items/components/AllProducts/AllProducts.js b/src/pages/Items/components/all-products/AllProducts.js similarity index 91% rename from src/pages/Items/components/AllProducts/AllProducts.js rename to src/pages/Items/components/all-products/AllProducts.js index 475cb504..dc756026 100644 --- a/src/pages/Items/components/AllProducts/AllProducts.js +++ b/src/pages/Items/components/all-products/AllProducts.js @@ -1,10 +1,10 @@ import styles from "./AllProducts.module.css"; import useProductsPagination from "../../hooks/useProductsPagination"; -import ProductSection from "../ProductSection/ProductSection"; +import ProductSection from "../product-section/ProductSection"; import Pagination from "../Pagination/Pagination"; function AllProducts({ title, itemsPerDevice }) { - const { products, totalPages, page, changePage, sort, handleSortChange, } = + const { products, totalPages, page, changePage, sort, handleSortChange } = useProductsPagination(itemsPerDevice); return ( diff --git a/src/pages/Items/components/AllProducts/AllProducts.module.css b/src/pages/Items/components/all-products/AllProducts.module.css similarity index 100% rename from src/pages/Items/components/AllProducts/AllProducts.module.css rename to src/pages/Items/components/all-products/AllProducts.module.css diff --git a/src/pages/Items/components/BestProducts/BestProducts.js b/src/pages/Items/components/best-products/BestProducts.js similarity index 93% rename from src/pages/Items/components/BestProducts/BestProducts.js rename to src/pages/Items/components/best-products/BestProducts.js index 1a7de7c3..2a3a3849 100644 --- a/src/pages/Items/components/BestProducts/BestProducts.js +++ b/src/pages/Items/components/best-products/BestProducts.js @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { fetchProducts } from "../../../../api/products"; import { getLimitFromWindowWidth } from "../../../../utils/getLimitFromWindowWidth"; -import ProductSection from "../ProductSection/ProductSection"; +import ProductSection from "../product-section/ProductSection"; function BestProducts({ title, itemsPerDevice }) { const [bestProducts, setBestProducts] = useState([]); diff --git a/src/pages/Items/components/ProductCard/ImageWithFallback.js b/src/pages/Items/components/product-card/ImageWithFallback.js similarity index 100% rename from src/pages/Items/components/ProductCard/ImageWithFallback.js rename to src/pages/Items/components/product-card/ImageWithFallback.js diff --git a/src/pages/Items/components/ProductCard/ProductCard.js b/src/pages/Items/components/product-card/ProductCard.js similarity index 100% rename from src/pages/Items/components/ProductCard/ProductCard.js rename to src/pages/Items/components/product-card/ProductCard.js diff --git a/src/pages/Items/components/ProductCard/ProductCard.module.css b/src/pages/Items/components/product-card/ProductCard.module.css similarity index 100% rename from src/pages/Items/components/ProductCard/ProductCard.module.css rename to src/pages/Items/components/product-card/ProductCard.module.css diff --git a/src/pages/Items/components/ProductSection/ProductSection.js b/src/pages/Items/components/product-section/ProductSection.js similarity index 93% rename from src/pages/Items/components/ProductSection/ProductSection.js rename to src/pages/Items/components/product-section/ProductSection.js index cb3a6dd5..b1a62822 100644 --- a/src/pages/Items/components/ProductSection/ProductSection.js +++ b/src/pages/Items/components/product-section/ProductSection.js @@ -1,8 +1,8 @@ import style from "./ProductSection.module.css"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; -import SortSelect from "../SortSelect/SortSelect"; -import ProductCard from "../ProductCard/ProductCard"; +import SortSelect from "../sort-select/SortSelect"; +import ProductCard from "../product-card/ProductCard"; import { useNavigate } from "react-router-dom"; function ProductSection({ diff --git a/src/pages/Items/components/ProductSection/ProductSection.module.css b/src/pages/Items/components/product-section/ProductSection.module.css similarity index 100% rename from src/pages/Items/components/ProductSection/ProductSection.module.css rename to src/pages/Items/components/product-section/ProductSection.module.css diff --git a/src/pages/Items/components/SortSelect/SortSelect.js b/src/pages/Items/components/sort-select/SortSelect.js similarity index 100% rename from src/pages/Items/components/SortSelect/SortSelect.js rename to src/pages/Items/components/sort-select/SortSelect.js diff --git a/src/pages/Items/components/SortSelect/SortSelect.module.css b/src/pages/Items/components/sort-select/SortSelect.module.css similarity index 100% rename from src/pages/Items/components/SortSelect/SortSelect.module.css rename to src/pages/Items/components/sort-select/SortSelect.module.css diff --git a/src/pages/additem/Additem.js b/src/pages/additem/Additem.js new file mode 100644 index 00000000..909ab09b --- /dev/null +++ b/src/pages/additem/Additem.js @@ -0,0 +1,148 @@ +import styles from "./Additem.module.css"; +import { useForm } from "./hooks/useForm"; +import ImageUpload from "./components/ImageUpload"; +import TextInputField from "./components/TextInputField"; +import TagInput from "./components/TagInput"; +import { useValidation } from "./hooks/useValidation"; +import { useRef } from "react"; +import { formatWithCommas } from "./utils/formatUtils"; + +export default function Additem() { + const productNameRef = useRef(); + const productDescriptionRef = useRef(); + const productPriceRef = useRef(); + const { validations } = useValidation(); + const { data, errors, handleChange, handleBlur, handleSubmit } = useForm({ + initialValues: { + productName: "", + productDescription: "", + productPrice: "", + productTag: [], + uploadImage: null, + }, + validations, + onSubmit: (formData) => { + // ์ฝค๋งˆ ์ œ๊ฑฐ + ์ˆซ์žํ˜•์œผ๋กœ ๋ณ€ํ™˜ + const numericPrice = Number(formData.productPrice.replace(/,/g, "")); + const payload = { + ...formData, + productPrice: numericPrice, + }; + console.log("์ œ์ถœ ๋ฐ์ดํ„ฐ:", payload); + }, + }); + + const handlePriceChange = (e) => { + const formatted = formatWithCommas(e.target.value); + // 1) React state ๊ฐฑ์‹  + handleChange( + "productPrice", + formatWithCommas + )({ target: { value: formatted } }); + // 2) DOM value๋„ ์ฆ‰์‹œ ๊ฐฑ์‹  + productPriceRef.current.value = formatted; + }; + + // ํ•„์ˆ˜ ํ•„๋“œ๋งŒ ๋ชจ๋‘ ์ฑ„์›Œ์กŒ๋Š”์ง€ ํ™•์ธ + const isSubmitEnabled = + data.productName.trim() !== "" && + data.productDescription.trim() !== "" && + data.productPrice.trim() !== "" && + Array.isArray(data.productTag) && + data.productTag.length > 0; + + return ( +
+
+

์ƒํ’ˆ ๋“ฑ๋กํ•˜๊ธฐ

+ +
+
+ { + handleChange( + "uploadImage", + () => file + )({ target: { value: file } }); + }} + previewUrl={data.uploadImage && URL.createObjectURL(data.uploadImage)} + /> + {

{errors.uploadImage || "\u00A0"}

} + + + + + + + + + // newTags ๋ฐฐ์—ด์„ e.target.value์ฒ˜๋Ÿผ ํ‰๋‚ด๋‚ด์„œ useForm์˜ handleChange ์‹คํ–‰ + handleChange( + "productTag", + () => newTags + )({ + target: { value: newTags }, + }) + } + onChange={handleChange("productTag")} + onBlur={handleBlur("productTag")} + error={errors.productTag} + wrapperClass={styles.inputWrapper} + inputClass={styles.input} + errorClass={styles.errorText} + tagContainerClass={styles.tagsContainer} + tagClass={styles.tag} + removeButtonClass={styles.removeTagButton} + /> +
+
+ ); +} diff --git a/src/pages/additem/Additem.module.css b/src/pages/additem/Additem.module.css new file mode 100644 index 00000000..147328da --- /dev/null +++ b/src/pages/additem/Additem.module.css @@ -0,0 +1,99 @@ +.form { + display: flex; + flex-direction: column; + color: var(--color-gray-800); +} + +.title { + font-size: var(--font-size-md-lg); + font-weight: var(--font-weight-bold); + vertical-align: middle; + padding-bottom: 24px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.inputContainer { + display: flex; + flex-direction: column; + gap: 24px; +} + +.inputWrapper { + display: flex; + flex-direction: column; + gap: 16px; +} + +.submitButton { + background-color: #3692ff; + color: white; + padding: var(--form-submit-button); + border: none; + border-radius: 8px; + cursor: pointer; +} + +.submitButton:disabled { + background-color: #ccc; + cursor: not-allowed; +} + +.input{ + border: none; + border-radius: 12px; + color: #1F2937; + font-size: 16px; + font-weight: 400; + background-color: #F3F4F6; + height: 56px; + padding: 16px 24px; +} + +.textArea{ + resize: none; + border: none; + border-radius: 12px; + color: #1F2937; + font-size: 16px; + font-weight: 400; + background-color: #F3F4F6; + height: var(--text-area-height); + padding: 16px 24px; +} + +.errorText { + color: red; + font-size: 14px; + margin-top: 4px; +} + +.tagsContainer { + display: flex; + flex-wrap: wrap; +} + +.tag { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 12px; + background-color: #F3F4F6; + border-radius: 26px; + margin-right: 8px; + margin-bottom: 8px; + font-size: 16px; + font-weight: 400; +} + +.removeTagButton { + margin-left: 8px; + background: transparent; + border: none; + cursor: pointer; + font-weight: bold; +} \ No newline at end of file diff --git a/src/pages/additem/components/ImageUpload.js b/src/pages/additem/components/ImageUpload.js new file mode 100644 index 00000000..850ce630 --- /dev/null +++ b/src/pages/additem/components/ImageUpload.js @@ -0,0 +1,73 @@ +import { useRef, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPlus, faTimesCircle } from "@fortawesome/free-solid-svg-icons"; +import styles from "./ImageUpload.module.css" + +export default function ImageUpload({ onImageChange }) { + const uploadImgRef = useRef(); + const [previewUrl, setPreviewUrl] = useState(""); // ํ”„๋ฆฌ๋ทฐ ์ด๋ฏธ์ง€ URL + + // ์ด๋ฏธ์ง€ ๋“ฑ๋ก ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ์ˆจ๊ฒจ์ ธ ์žˆ๋Š” ์‹ค์ œ fileํƒ€์ž… inputํƒœ๊ทธ ํด๋ฆญํ•จ + const handleUpload = () => { + if (uploadImgRef.current) { + uploadImgRef.current.value = ""; // โ† ๊ฐ™์€ ํŒŒ์ผ๋„ ๋‹ค์‹œ ์„ ํƒ ๊ฐ€๋Šฅํ•˜๊ฒŒ + uploadImgRef.current.click(); + } + }; + // URL.createObjectURL(file)๋กœ ์ž„์‹œ URL ์ƒ์„ฑํ•ด์„œ ํ”„๋ฆฌ๋ทฐ ๋„์šฐ๋Š” ํ•จ์ˆ˜ + const handleFileChange = () => { + const file = uploadImgRef.current?.files?.[0]; + if (file) { + const url = URL.createObjectURL(file); + setPreviewUrl(url); + onImageChange(file, url); // ๋ถ€๋ชจ์—๊ฒŒ ์ „๋‹ฌ + } + }; + // ํ”„๋ฆฌ๋ทฐ ์ด๋ฏธ์ง€ ๋‹ซ๊ธฐ ํ•จ์ˆ˜ + const handleRemovePreviewButton = ()=>{ + setPreviewUrl(null); + if (uploadImgRef.current) { + uploadImgRef.current.value = ""; + } + onImageChange(null, null); // ์‚ญ์ œ ์•Œ๋ฆผ + } + + return ( +
+

์ƒํ’ˆ ์ด๋ฏธ์ง€

+ {/* ์•ˆ ๋ณด์ด๊ฒŒ ์ˆจ๊ฒจ์ ธ ์žˆ์Œ */} + + {/* ์‹ค์ œ๋กœ ๋ณด์ด๋Š” ๋ถ€๋ถ„ */} +
+
+ +
์ด๋ฏธ์ง€ ๋“ฑ๋ก
+
+ {/* preview ์ด๋ฏธ์ง€ */} + {previewUrl && ( +
+ ๋ฏธ๋ฆฌ๋ณด๊ธฐ +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/pages/additem/components/ImageUpload.module.css b/src/pages/additem/components/ImageUpload.module.css new file mode 100644 index 00000000..4f2e4a6d --- /dev/null +++ b/src/pages/additem/components/ImageUpload.module.css @@ -0,0 +1,64 @@ +.inputWrapper { + display: flex; + flex-direction: column; + gap: 16px; +} + +.imageWrapper { + display: flex; + gap: 10px; +} + +.imageUploadBox { + width: var(--preview-image-width); + height: var(--preview-image-height); + border: none; + border-radius: 12px; + background-color: #F3F4F6; + + display: flex; + gap: 12px; + flex-direction: column; + align-items: center; + justify-content: center; + + color: #9CA3AF; + font-size: 16px; + font-weight: 400; + line-height: var(--line-height-relaxed); + cursor: pointer; + transition: background-color 0.2s ease; +} + +.imageUploadBox:hover { + background-color: #eaeaea; +} + +.plus { + font-size: 48px; +} + +.imagePreviewWrapper { + position: relative; + display: inline-block; +} + +.imagePreview { + display: block; + object-fit: cover; + width: var(--preview-image-width); + height: var(--preview-image-height); + border-radius: 12px; +} + +.removeImageButton { + position: absolute; + top: 12px; + right: 12px; + background: #FFFFFF; + border-radius: 50%; + color: #9CA3AF; + font-size: 22px; + display: flex; + cursor: pointer; +} \ No newline at end of file diff --git a/src/pages/additem/components/TagInput.js b/src/pages/additem/components/TagInput.js new file mode 100644 index 00000000..7e1ce6eb --- /dev/null +++ b/src/pages/additem/components/TagInput.js @@ -0,0 +1,85 @@ +import { useRef, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTimesCircle } from "@fortawesome/free-solid-svg-icons"; + +function TagInput({ + name = "productTag", + label = "ํƒœ๊ทธ", + tags, + setTags, + error, + wrapperClass, + inputClass, + errorClass, + tagContainerClass, + tagClass, + removeButtonClass, + onBlur, + onChange, +}) { + const inputRef = useRef(null); + const [isComposing, setIsComposing] = useState(false); + + const notifyChange = (newTags) => { + // ์—…๋ฐ์ดํŠธ๋œ ํƒœ๊ทธ ๋ฐฐ์—ด๋กœ onChange ํ˜ธ์ถœ + onChange?.({ target: { name, value: newTags } }); + }; + + const handleKeyDown = (e) => { + if (e.key === "Enter" && !isComposing) { + e.preventDefault(); + const trimmed = inputRef.current.value.trim(); + if (trimmed && !tags.includes(trimmed)) { + const newTags = [...tags, trimmed]; + setTags(newTags); + inputRef.current.value = ""; + notifyChange(newTags); + } + } + }; + + const handleRemove = (tagToRemove) => { + const newTags = tags.filter((tag) => tag !== tagToRemove); + setTags(newTags); + + // ์‚ญ์ œ ํ›„์—๋„ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + notifyChange(newTags); + }; + + return ( +
+

{label}

+ { + onBlur?.({ target: { name, value: tags } }); + }} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + /> + {error &&

{error}

} +
+ {tags.map((tag, idx) => ( +
+ #{tag} + +
+ ))} +
+
+ ); +} + +export default TagInput; diff --git a/src/pages/additem/components/TextInputField.js b/src/pages/additem/components/TextInputField.js new file mode 100644 index 00000000..c58326a1 --- /dev/null +++ b/src/pages/additem/components/TextInputField.js @@ -0,0 +1,52 @@ +import React, { forwardRef } from "react"; +const TextInputField = forwardRef( + ( + { + label, + name, + defaultValue, + onChange, + onBlur, + placeholder, + error, + type = "text", + as = "input", // 'textarea'๋กœ๋„ ๊ฐ€๋Šฅ + wrapperClass, + inputClass, + textAreaClass, + errorClass, + }, + ref + ) => ( +
+

{label}

+ {as === "textarea" ? ( +