From 5a5ffef087dc9615ba51a033c7e68b56e8383822 Mon Sep 17 00:00:00 2001 From: KimGyeongwon Date: Mon, 23 Dec 2024 17:07:28 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat=20:=20product=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=88=98=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?store=20=EC=A0=9C=ED=92=88=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=88=98=EC=A0=95=20product=20ty?= =?UTF-8?q?pe=20=EB=B3=80=EA=B2=BD=20category=20=3D>=20mainCategory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(product)/store/ProductCards.tsx | 54 ++++---- .../(product)/store/ProductsByCategory.tsx | 22 --- src/app/(product)/store/page.tsx | 129 ++++++++---------- src/services/sanity/products.ts | 100 +++++++------- src/type/products.ts | 70 +++++++--- 5 files changed, 186 insertions(+), 189 deletions(-) delete mode 100644 src/app/(product)/store/ProductsByCategory.tsx diff --git a/src/app/(product)/store/ProductCards.tsx b/src/app/(product)/store/ProductCards.tsx index 20440d5..249d10f 100644 --- a/src/app/(product)/store/ProductCards.tsx +++ b/src/app/(product)/store/ProductCards.tsx @@ -1,26 +1,28 @@ -import Card from "@/components/card/Card"; -import URLS from "@/constants/urls"; -import { ProductForList } from "@/type/products"; - -interface Props { - products: ProductForList[]; -} - -const ProductCards = ({ products }: Props) => { - return ( -
- {products.map((product: any) => ( - - ))} -
- ); -}; - -export default ProductCards; +import Card from "@/components/card/Card"; +import URLS from "@/constants/urls"; +import { Product } from "@/type/products"; + +interface Props { + products: Product[]; +} + +const ProductCards = ({ products }: Props) => { + return ( +
+ {products.map((product: Product) => ( + + ))} +
+ ); +}; + +export default ProductCards; diff --git a/src/app/(product)/store/ProductsByCategory.tsx b/src/app/(product)/store/ProductsByCategory.tsx deleted file mode 100644 index 25def9c..0000000 --- a/src/app/(product)/store/ProductsByCategory.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import ProductCards from "./ProductCards"; -import { ProductForList } from "@/type/products"; -interface DetailCategoryProps { - products: ProductForList[]; - category: string; -} - -const ProductsByCategory = ({ products, category }: DetailCategoryProps) => { - const filteredProducts = products - .filter((product: any) => { - if (category === "all") return true; - return product.category === category; - }) - .slice(0, 7); - return ( - <> - - - ); -}; - -export default ProductsByCategory; diff --git a/src/app/(product)/store/page.tsx b/src/app/(product)/store/page.tsx index c93a16c..c8e03a2 100644 --- a/src/app/(product)/store/page.tsx +++ b/src/app/(product)/store/page.tsx @@ -1,69 +1,60 @@ -import { categorys } from "@/constants/categorys"; -import ProductsByCategory from "./ProductsByCategory"; -import { getProducts } from "@/services/sanity/products"; -import BrandSlider from "./BrandsSlider"; -import Heading from "@/components/heading/Heading"; -import ProductBanner from "./ProductBanner"; -import CategoryToSearchLink from "./CategoryToSearchLink"; -import Loader from "@/components/loader/Loader"; -import URLS from "@/constants/urls"; - -export default async function Products({}) { - const products = await getProducts(); - const categoryValues = categorys.map(category => category.value); - - if (!products) return ; - if (products.length === 0) - return ( -
- 제품이 없습니다! -
- ); - - return ( -
-
-
-

- STORE -

-

- 공식 스토어 -

-
-
-
-
- -
-
- {categoryValues.map((category: string, index: number) => ( -
-
-
- {categorys[index].title} -
- - {"상품 더보기 >"} - -
- - {index === 0 && } -
- ))} -
-
-
- ); -} +import { categorys } from "@/constants/categorys"; +import BrandSlider from "./BrandsSlider"; +import ProductBanner from "./ProductBanner"; +import CategoryToSearchLink from "./CategoryToSearchLink"; +import URLS from "@/constants/urls"; +import { getProducts } from "@/services/sanity/products"; +import { Product } from "@/type/products"; +import ProductCards from "./ProductCards"; + +export default async function Products() { + const categoryValues = categorys.map(category => category.value); + const products = await getProducts(); + return ( +
+
+
+

+ STORE +

+

+ 공식 스토어 +

+
+
+
+
+ +
+
+ {categoryValues.map((category: string, index: number) => ( +
+
+
+ {categorys[index].title} +
+ + {"상품 더보기 >"} + +
+ product.mainCategory === category, + )} + /> + {index === 0 && } +
+ ))} +
+
+
+ ); +} diff --git a/src/services/sanity/products.ts b/src/services/sanity/products.ts index c5d3b5c..88bc47a 100644 --- a/src/services/sanity/products.ts +++ b/src/services/sanity/products.ts @@ -1,50 +1,50 @@ -import { client } from "@/services/sanity/sanity"; - -export function getProducts() { - try { - const products = client.fetch( - '*[_type == "product"] {..., mainImage {"imageUrl": asset->url}}', - ); - return products; - } catch (error: any) { - console.error(`전체 제품 불러오기 실패: ${error.message}`); - } -} - -export function getDetailProduct(id: string) { - try { - const product = client.fetch( - `*[_type == "product" && _id == $id][0] { - _id, - productName, - category, - price, - detailCategory, - "images": images[] { - "imageUrl": asset->url - }, - description, - discount, - "detailImage": detailImage.asset->url, - }`, - { id }, - ); - return product; - } catch (error: any) { - console.error(`상세 제품 불러오기 실패: ${error.message}`); - } -} - -export function getProductsByCategory( - category: string, - isDetail: boolean = false, -) { - try { - let param = isDetail ? "detailCategory" : "category"; - const query = `*[_type == "product" && ${param} == $category] {_id, productName, category, price, mainImage {"imageUrl": asset->url}, detailCategory}`; - const products = client.fetch(query, { category }); - return products; - } catch (error: any) { - console.error(`카테고리별 제품 불러오기 실패: ${error.message}`); - } -} +import { client } from "@/services/sanity/sanity"; + +export function getProducts() { + try { + const products = client.fetch( + '*[_type == "product"] {..., mainImage {"imageUrl": asset->url}}', + ); + return products; + } catch (error: any) { + console.error(`전체 제품 불러오기 실패: ${error.message}`); + } +} + +export function getDetailProduct(id: string) { + try { + const product = client.fetch( + `*[_type == "product" && _id == $id][0] { + _id, + productName, + category, + price, + detailCategory, + "images": images[] { + "imageUrl": asset->url + }, + description, + discount, + "detailImage": detailImage.asset->url, + }`, + { id }, + ); + return product; + } catch (error: any) { + console.error(`상세 제품 불러오기 실패: ${error.message}`); + } +} + +export function getProductsByCategory( + category: string, + isDetail: boolean = false, +) { + try { + let param = isDetail ? "detailCategory" : "category"; + const query = `*[_type == "product" && ${param} == $category] {_id, productName, category, price, mainImage {"imageUrl": asset->url}, detailCategory}`; + const products = client.fetch(query, { category }); + return products; + } catch (error: any) { + console.error(`카테고리별 제품 불러오기 실패: ${error.message}`); + } +} diff --git a/src/type/products.ts b/src/type/products.ts index 32f1023..89e2f80 100644 --- a/src/type/products.ts +++ b/src/type/products.ts @@ -1,22 +1,48 @@ -export type Product = { - _id: string; - productName: string; - price: number; - discount: number; - mainImage: ProductImages; - description: string; - images: ProductImages[]; - category: string; - detailCategory: string; - detailImage: string; -}; - -export type ProductForDetail = Omit; -export type ProductForList = Pick< - Product, - "_id" | "productName" | "price" | "mainImage" ->; - -type ProductImages = { - imageUrl: string; -}; +// export type Product = { +// _id: string; +// productName: string; +// price: number; +// discount: number; +// mainImage: ProductImage; +// description: string; +// images: ProductImage[]; +// category: string; +// detailCategory: string; +// detailImage: string; +// }; + +export interface Product { + _id: string; + productName: string; + mainImage: ProductImage; + images: ProductImage[]; + description: string; + mainCategory: "midasMetal" | "modernMasters"; + subCategory: string; + options: { + color: { + colorName: string; + colorCode: string; + }; + sizes: { + size: string; + price: number; + discount: number; + stock: number; + }[]; + }[]; + slug?: string; + tags?: string[]; + isNewProduct?: boolean; + isBestSeller?: boolean; +} + +export type ProductForDetail = Omit; +export type ProductForList = Pick< + Product, + "_id" | "productName" | "options" | "mainImage" +>; + +type ProductImage = { + imageUrl: string; +}; From dfde187fe49d3abe9284a7b94007ec4f4e58d9c0 Mon Sep 17 00:00:00 2001 From: KimGyeongwon Date: Mon, 23 Dec 2024 20:57:07 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat=20:=20=EC=A0=9C=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EB=B3=84=20=EC=A0=9C=ED=92=88=20=EC=B6=94=EA=B0=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/details/[id]/ProductSelects.tsx | 119 +++++ .../store/details/[id]/ProductSummary.tsx | 409 +++++++++--------- .../(product)/store/details/[id]/TempItem.tsx | 66 +++ src/app/(product)/store/details/[id]/page.tsx | 76 ++-- src/components/slider/SliderPreview.tsx | 56 +-- src/redux/slice/filterSlice.ts | 141 ------ src/redux/slice/productOptionsSlice.ts | 39 ++ src/redux/slice/productSlice.ts | 48 -- src/redux/slice/productTempSlice.ts | 56 +++ src/redux/store.ts | 66 +-- src/services/sanity/products.ts | 26 +- src/type/products.ts | 29 +- 12 files changed, 598 insertions(+), 533 deletions(-) create mode 100644 src/app/(product)/store/details/[id]/ProductSelects.tsx create mode 100644 src/app/(product)/store/details/[id]/TempItem.tsx delete mode 100644 src/redux/slice/filterSlice.ts create mode 100644 src/redux/slice/productOptionsSlice.ts delete mode 100644 src/redux/slice/productSlice.ts create mode 100644 src/redux/slice/productTempSlice.ts diff --git a/src/app/(product)/store/details/[id]/ProductSelects.tsx b/src/app/(product)/store/details/[id]/ProductSelects.tsx new file mode 100644 index 0000000..575cf18 --- /dev/null +++ b/src/app/(product)/store/details/[id]/ProductSelects.tsx @@ -0,0 +1,119 @@ +"use client"; +import { useState } from "react"; +import { ProductOption } from "@/type/products"; +import { useDispatch, useSelector } from "react-redux"; +import { + setColor, + setSize, + resetOptions, + selectColor, + selectSize, +} from "@/redux/slice/productOptionsSlice"; + +const ProductSelects = ({ options }: { options: ProductOption[] }) => { + const dispatch = useDispatch(); + const selectedColor = useSelector(selectColor); + const selectedSize = useSelector(selectSize); + + const handleColorChange = (colorName: string) => { + dispatch(setColor(colorName)); + // 색상이 변경되면 해당 색상의 첫 번째 사이즈로 자동 선택 + const selectedOption = options.find( + opt => opt.color.colorName === colorName, + ); + if (selectedOption?.sizes[0]) { + dispatch(setSize(selectedOption.sizes[0].size)); + } + }; + + const handleSizeChange = (size: string) => { + dispatch(setSize(size)); + }; + + const getSelectedColorSizes = () => { + return ( + options.find(opt => opt.color.colorName === selectedColor)?.sizes || [] + ); + }; + + return ( +
+ + +
+ ); +}; + +export default ProductSelects; +/* +options 형태 +[ + { + "color": { + "colorName": "루비", + "colorCode": "#E0115F" + }, + "sizes": [ + { + "size": "50ml", + "price": 78000, + "discount": 15, + "_key": "3e94c8749c34", + "stock": 5 + }, + { + "size": "1L", + "price": 250000, + "discount": 15, + "_key": "e8128aafb1d1", + "stock": 8 + } + ], + "_key": "89e2684c19f4" + }, + { + "sizes": [ + { + "stock": 9, + "size": "946ml", + "price": 95000, + "discount": 0, + "_key": "d4856614abfd" + } + ], + "_key": "d16922d3ff8e", + "color": { + "colorName": "그린", + "colorCode": "#599468" + } + } +] +*/ diff --git a/src/app/(product)/store/details/[id]/ProductSummary.tsx b/src/app/(product)/store/details/[id]/ProductSummary.tsx index 42b7d6c..f2e2c79 100644 --- a/src/app/(product)/store/details/[id]/ProductSummary.tsx +++ b/src/app/(product)/store/details/[id]/ProductSummary.tsx @@ -1,207 +1,202 @@ -"use client"; - -import { ProductForDetail } from "@/type/products"; -import Button from "@/components/button/Button"; -import { ADD_TO_CART } from "@/redux/slice/cartSlice"; -import { useDispatch } from "react-redux"; -import { useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; -import Slider from "@/components/slider/Slider"; -import URLS from "@/constants/urls"; -import SliderPreview from "@/components/slider/SliderPreview"; -import { Slide, toast } from "react-toastify"; - -type ProductSummaryProps = Omit< - ProductForDetail, - "category" | "detailCategory" | "detailImage" ->; - -export default function ProductSummary({ - _id, - productName, - price, - description, - images, - discount, -}: ProductSummaryProps) { - const dispatch = useDispatch(); - const router = useRouter(); - const [count, setCount] = useState(1); - const [isPending, startTransition] = useTransition(); - - const handleAddToCart = (redirectToCart: boolean = false) => { - try { - dispatch( - ADD_TO_CART({ - id: _id, - name: productName, - price, - imageURL: images[0].imageUrl, - discount, - quantity: count, - }), - ); - - toast.success("장바구니에 추가되었습니다.", { - position: "top-right", - autoClose: 2000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: true, - theme: "light", - transition: Slide, - }); - - if (redirectToCart) { - startTransition(() => { - router.push(URLS.CART); - }); - } - } catch (error) { - console.error("Failed to add to cart:", error); - toast.error( - error instanceof Error - ? error.message - : "장바구니에 추가하는 중 오류가 발생했습니다.", - { - position: "top-right", - autoClose: 2000, - hideProgressBar: false, - closeOnClick: true, - pauseOnHover: true, - draggable: true, - theme: "light", - transition: Slide, - }, - ); - } - }; - - const handleCountChange = (increment: boolean) => { - setCount(prev => { - const newCount = increment ? prev + 1 : prev - 1; - return Math.max(1, newCount); // 최소값 1 보장 - }); - }; - - const calculateDiscountedPrice = ( - originalPrice: number, - discountPercent: number, - ) => { - return originalPrice - (originalPrice * discountPercent) / 100; - }; - - const discountedPrice = calculateDiscountedPrice(price, discount); - - return ( - <> -
- {/* Image */} -
- -
- {/* Carousel */} -
- {images.map((image, index) => ( -
- -
- ))} -
-
-

- {productName} -

-

- {description} -

- {/* 가격 */} -
-
-

- {discount}% -

-

- {price.toLocaleString()} 원 -

-
-

- {discountedPrice.toLocaleString()}원 -

-
- {/* 수량 버튼 */} -
-
-
- -

- {count} -

- -
-
-
-

- {(discountedPrice * count).toLocaleString()}원 -

-
-
- {/* 주문 금액 */} -
-
-

주문금액

-

- {(discountedPrice * count).toLocaleString()}원 -

-
-
- {/* 구매하기 버튼 */} -
- - -
-
-
- - ); -} +"use client"; + +import { Product } from "@/type/products"; +import Button from "@/components/button/Button"; +import { ADD_TO_CART } from "@/redux/slice/cartSlice"; +import { useDispatch, useSelector } from "react-redux"; +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import Slider from "@/components/slider/Slider"; +import URLS from "@/constants/urls"; +import SliderPreview from "@/components/slider/SliderPreview"; +import { Slide, toast } from "react-toastify"; +import ProductSelects from "./ProductSelects"; +import { selectColor, selectSize } from "@/redux/slice/productOptionsSlice"; +import TempItem from "./TempItem"; +import { + addTempItem, + updateTempItemQuantity, + removeTempItem, + selectTempItems, +} from "@/redux/slice/productTempSlice"; + +export default function ProductSummary({ product }: { product: Product }) { + const dispatch = useDispatch(); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const selectedColor = useSelector(selectColor); + const selectedSize = useSelector(selectSize); + const tempItems = useSelector(selectTempItems); + + const { _id, productName, options, description, images } = product; + + const handleAddOption = () => { + if (!selectedColor || !selectedSize) { + toast.error("옵션을 선택해주세요."); + return; + } + + const colorOption = options.find( + opt => opt.color.colorName === selectedColor, + ); + const sizeOption = colorOption?.sizes.find( + size => size.size === selectedSize, + ); + + if (!sizeOption) return; + + const newItem = { + color: selectedColor, + size: selectedSize, + price: sizeOption.price, + discount: sizeOption.discount, + quantity: 1, + }; + + dispatch(addTempItem(newItem)); + }; + + const handleQuantityChange = (index: number, quantity: number) => { + dispatch(updateTempItemQuantity({ index, quantity })); + }; + + const handleDeleteItem = (index: number) => { + dispatch(removeTempItem(index)); + }; + + const getTotalPrice = () => { + return tempItems.reduce((total, item) => { + const discountedPrice = item.price - (item.price * item.discount) / 100; + return total + discountedPrice * item.quantity; + }, 0); + }; + + const handleAddToCart = (redirectToCart: boolean = false) => { + if (tempItems.length === 0) { + toast.error("상품을 선택해주세요."); + return; + } + + try { + tempItems.forEach(item => { + dispatch( + ADD_TO_CART({ + id: _id, + name: productName, + price: item.price - (item.price * item.discount) / 100, + imageURL: images[0].imageUrl, + discount: item.discount, + quantity: item.quantity, + selectedColor: item.color, + selectedSize: item.size, + }), + ); + }); + + toast.success("장바구니에 추가되었습니다."); + + if (redirectToCart) { + startTransition(() => { + router.push(URLS.CART); + }); + } + } catch (error) { + console.error("Failed to add to cart:", error); + toast.error("장바구니에 추가하는 중 오류가 발생했습니다."); + } + }; + + return ( + <> +
+ {/* Image */} +
+ +
+ {/* Carousel */} +
+ {images.map((image, index) => ( +
+ +
+ ))} +
+
+

+ {productName} +

+

+ {description} +

+ + {/* Option Select */} +
+ + +
+ + {/* Selected Items */} +
+ {tempItems.map((item, index) => ( + + handleQuantityChange(index, quantity) + } + onDelete={() => handleDeleteItem(index)} + /> + ))} +
+ + {/* Total Price */} + {tempItems.length > 0 && ( +
+ 총 상품금액 + + {getTotalPrice().toLocaleString()}원 + +
+ )} + + {/* Buttons */} +
+ + +
+
+
+ + ); +} diff --git a/src/app/(product)/store/details/[id]/TempItem.tsx b/src/app/(product)/store/details/[id]/TempItem.tsx new file mode 100644 index 0000000..e337d9b --- /dev/null +++ b/src/app/(product)/store/details/[id]/TempItem.tsx @@ -0,0 +1,66 @@ +interface TempItemType { + color: string; + size: string; + price: number; + discount: number; + quantity: number; +} + +interface Props { + item: TempItemType; + onQuantityChange: (quantity: number) => void; + onDelete: () => void; +} + +const TempItem = ({ item, onQuantityChange, onDelete }: Props) => { + const { color, size, price, discount, quantity } = item; + const discountedPrice = price - (price * discount) / 100; + + return ( +
+
+ {color} + | + {size} +
+
+
+ + + {quantity} + + +
+
+ {discount > 0 && ( + + {price.toLocaleString()}원 + + )} + + {(discountedPrice * quantity).toLocaleString()}원 + +
+ +
+
+ ); +}; + +export default TempItem; diff --git a/src/app/(product)/store/details/[id]/page.tsx b/src/app/(product)/store/details/[id]/page.tsx index 2f9029e..9415225 100644 --- a/src/app/(product)/store/details/[id]/page.tsx +++ b/src/app/(product)/store/details/[id]/page.tsx @@ -1,43 +1,33 @@ -import ProductSummary from "./ProductSummary"; -import { ProductForDetail } from "@/type/products"; -import { getDetailProduct } from "@/services/sanity/products"; -import ProductInfoNav from "./ProductInfoNav"; -import { notFound } from "next/navigation"; - -interface PageProps { - params: Promise<{ id: string }>; -} - -export default async function ProductDetails({ params }: PageProps) { - const resolvedParams = await params; - const { id } = resolvedParams; - - if (!id) { - notFound(); - } - - const product: ProductForDetail = await getDetailProduct(id); - - if (!product) { - notFound(); - } - - const { productName, price, description, images, discount, detailImage } = - product; - - return ( -
- -
- -
-
- ); -} +import ProductSummary from "./ProductSummary"; +import { getDetailProduct } from "@/services/sanity/products"; +import ProductInfoNav from "./ProductInfoNav"; +import { notFound } from "next/navigation"; +import { Product } from "@/type/products"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function ProductDetails({ params }: PageProps) { + const resolvedParams = await params; + const { id } = resolvedParams; + + if (!id) { + notFound(); + } + + const product: Product = await getDetailProduct(id); + + if (!product) { + notFound(); + } + + return ( +
+ +
+ +
+
+ ); +} diff --git a/src/components/slider/SliderPreview.tsx b/src/components/slider/SliderPreview.tsx index 9f31153..d8a26c5 100644 --- a/src/components/slider/SliderPreview.tsx +++ b/src/components/slider/SliderPreview.tsx @@ -1,28 +1,28 @@ -"use client"; -import { useDispatch } from "react-redux"; -import { setCurrentSlide } from "@/redux/slice/sliderSlice"; -import Image, { StaticImageData } from "next/image"; - -interface SliderPreviewProps { - id: string; - imgUrl: StaticImageData; - index: number; -} - -const SliderPreview = ({ id, imgUrl, index }: SliderPreviewProps) => { - const dispatch = useDispatch(); - return ( - introSub dispatch(setCurrentSlide({ id, index }))} - className="cursor-pointer" - /> - ); -}; - -export default SliderPreview; +"use client"; +import { useDispatch } from "react-redux"; +import { setCurrentSlide } from "@/redux/slice/sliderSlice"; +import Image, { StaticImageData } from "next/image"; + +interface Props { + id: string; + imgUrl: string | StaticImageData; + index: number; +} + +const SliderPreview = ({ id, imgUrl, index }: Props) => { + const dispatch = useDispatch(); + return ( + introSub dispatch(setCurrentSlide({ id, index }))} + className="cursor-pointer" + /> + ); +}; + +export default SliderPreview; diff --git a/src/redux/slice/filterSlice.ts b/src/redux/slice/filterSlice.ts deleted file mode 100644 index ea063c9..0000000 --- a/src/redux/slice/filterSlice.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Product } from "@/type/products"; -import { createSlice } from "@reduxjs/toolkit"; -import { RootState } from "../store"; - -interface IFilterState { - filteredProducts: Product[]; -} - -const initialState: IFilterState = { - filteredProducts: [], -}; - -const filterSlice = createSlice({ - name: "filter", - initialState, - reducers: { - FILTER_BY_CATEGORY: ( - state, - action: { payload: { products: Product[]; category: string } }, - ) => { - const { products, category } = action.payload; - let tempProducts = []; - if (category === "All") { - tempProducts = products; - } else { - tempProducts = products.filter( - product => product.category === category, - ); - } - state.filteredProducts = tempProducts; - }, - // FILTER_BY_BRAND: ( - // state, - // action: { payload: { products: Product[]; brand: string } }, - // ) => { - // const { products, brand } = action.payload; - // console.log("brand", brand); - // let tempProducts = []; - // if (brand === "All") { - // tempProducts = products; - // } else { - // tempProducts = products.filter(product => product.brand === brand); - // } - // state.filteredProducts = tempProducts; - // }, - FILTER_BY_PRICE: ( - state, - action: { payload: { products: Product[]; price: number } }, - ) => { - const { products, price } = action.payload; - let tempProducts = []; - - tempProducts = products.filter(product => product.price <= price); - - state.filteredProducts = tempProducts; - }, - FILTER_BY: ( - state, - action: { - payload: { - products: Product[]; - price: number; - brand: string; - category: string; - }; - }, - ) => { - const { products, price, brand, category } = action.payload; - let tempProducts = []; - - if (category === "All") { - tempProducts = products; - } else { - tempProducts = products.filter( - product => product.category === category, - ); - } - // if (brand === "All") { - // tempProducts = tempProducts; - // } else { - // tempProducts = tempProducts.filter(product => product.brand === brand); - // } - - tempProducts = tempProducts.filter(product => product.price <= price); - - state.filteredProducts = tempProducts; - }, - SORT_PRODUCTS: ( - state, - action: { payload: { products: Product[]; sort: string } }, - ) => { - const { products, sort } = action.payload; - let tempProducts: Product[] = []; - if (sort === "latest") { - tempProducts = products; - } - - if (sort === "lowest-price") { - tempProducts = products.slice().sort((a, b) => { - return a.price - b.price; - }); - } - - if (sort === "highest-price") { - tempProducts = products.slice().sort((a, b) => { - return b.price - a.price; - }); - } - - state.filteredProducts = tempProducts; - }, - FILTER_BY_SEARCH: ( - state, - action: { payload: { products: Product[]; search: string } }, - ) => { - const { products, search } = action.payload; - - const tempProducts = products.filter( - product => - product.productName.toLowerCase().includes(search.toLowerCase()) || - product.category.toLowerCase().includes(search.toLowerCase()), - ); - - state.filteredProducts = tempProducts; - }, - }, -}); - -export const { - // FILTER_BY_BRAND, - FILTER_BY_CATEGORY, - FILTER_BY_PRICE, - SORT_PRODUCTS, - FILTER_BY_SEARCH, - FILTER_BY, -} = filterSlice.actions; - -export const selectFilteredProducts = (state: RootState) => - state.filter.filteredProducts; - -export default filterSlice.reducer; diff --git a/src/redux/slice/productOptionsSlice.ts b/src/redux/slice/productOptionsSlice.ts new file mode 100644 index 0000000..e99abf4 --- /dev/null +++ b/src/redux/slice/productOptionsSlice.ts @@ -0,0 +1,39 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { RootState } from "../store"; + +interface ProductOptionsState { + selectedColor: string | null; + selectedSize: string | null; +} + +const initialState: ProductOptionsState = { + selectedColor: null, + selectedSize: null, +}; + +const productOptionsSlice = createSlice({ + name: "productOptions", + initialState, + reducers: { + setColor(state, action: PayloadAction) { + state.selectedColor = action.payload; + state.selectedSize = null; // 색상이 변경되면 크기 초기화 + }, + setSize(state, action: PayloadAction) { + state.selectedSize = action.payload; + }, + resetOptions(state) { + state.selectedColor = null; + state.selectedSize = null; + }, + }, +}); + +export const { setColor, setSize, resetOptions } = productOptionsSlice.actions; + +export const selectColor = (state: RootState) => + state.productOptions.selectedColor; +export const selectSize = (state: RootState) => + state.productOptions.selectedSize; + +export default productOptionsSlice.reducer; diff --git a/src/redux/slice/productSlice.ts b/src/redux/slice/productSlice.ts deleted file mode 100644 index 91a4d07..0000000 --- a/src/redux/slice/productSlice.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Product } from "@/type/products"; -import { createSlice } from "@reduxjs/toolkit"; -import { RootState } from "../store"; - -interface IProductState { - products: Product[]; - minPrice: number; - maxPrice: number; -} - -const initialState: IProductState = { - products: [], - minPrice: 0, - maxPrice: 10000, -}; - -const productSlice = createSlice({ - name: "product", - initialState, - reducers: { - STORE_PRODUCTS(state, action) { - state.products = action.payload.products; - }, - GET_PRICE_RANGE(state, action) { - const { products } = action.payload; - - const array: number[] = []; - products.map((product: Product) => { - const price = product.price; - return array.push(price); - }); - - const max = Math.max(...array); - const min = Math.min(...array); - - state.minPrice = min; - state.maxPrice = max; - }, - }, -}); - -export const { STORE_PRODUCTS, GET_PRICE_RANGE } = productSlice.actions; - -export const selectProducts = (state: RootState) => state.product.products; -export const selectMinPrice = (state: RootState) => state.product.minPrice; -export const selectMaxPrice = (state: RootState) => state.product.maxPrice; - -export default productSlice.reducer; diff --git a/src/redux/slice/productTempSlice.ts b/src/redux/slice/productTempSlice.ts new file mode 100644 index 0000000..d0d83f4 --- /dev/null +++ b/src/redux/slice/productTempSlice.ts @@ -0,0 +1,56 @@ +// 상품 상세에서 옵션 선택 시 임시 저장 슬라이스 +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { RootState } from "../store"; + +interface TempItem { + color: string; + size: string; + price: number; + discount: number; + quantity: number; +} + +interface ProductTempState { + tempItems: TempItem[]; +} + +const initialState: ProductTempState = { + tempItems: [], +}; + +const productTempSlice = createSlice({ + name: "productTemp", + initialState, + reducers: { + addTempItem(state, action: PayloadAction) { + state.tempItems.push(action.payload); + }, + updateTempItemQuantity( + state, + action: PayloadAction<{ index: number; quantity: number }>, + ) { + const { index, quantity } = action.payload; + if (state.tempItems[index]) { + state.tempItems[index].quantity = quantity; + } + }, + removeTempItem(state, action: PayloadAction) { + state.tempItems.splice(action.payload, 1); + }, + resetTempItems(state) { + state.tempItems = []; + }, + }, +}); + +export const { + addTempItem, + updateTempItemQuantity, + removeTempItem, + resetTempItems, +} = productTempSlice.actions; + +export const selectTempItems = (state: RootState) => + state.productTemp.tempItems; + +export default productTempSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 59ee5d2..779aed0 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,33 +1,33 @@ -import { combineReducers, configureStore } from "@reduxjs/toolkit"; -import productReducer from "./slice/productSlice"; -import filterReducer from "./slice/filterSlice"; -import cartReducer from "./slice/cartSlice"; -import checkoutReducer from "./slice/checkoutSlice"; -import orderReducer from "./slice/orderSlice"; -import periodReducer from "./slice/periodSlice"; -import searchReducer from "./slice/searchSlice"; -import sliderSlice from "./slice/sliderSlice"; - -const rootReducer = combineReducers({ - product: productReducer, - filter: filterReducer, - cart: cartReducer, - checkout: checkoutReducer, - orders: orderReducer, - period: periodReducer, - search: searchReducer, - slider: sliderSlice, -}); - -const store = configureStore({ - reducer: rootReducer, - middleware: getDefaultMiddleware => - getDefaultMiddleware({ - serializableCheck: false, - }), - devTools: true, -}); - -export type RootState = ReturnType; - -export default store; +import { combineReducers, configureStore } from "@reduxjs/toolkit"; +import cartReducer from "./slice/cartSlice"; +import checkoutReducer from "./slice/checkoutSlice"; +import orderReducer from "./slice/orderSlice"; +import periodReducer from "./slice/periodSlice"; +import searchReducer from "./slice/searchSlice"; +import sliderSlice from "./slice/sliderSlice"; +import productTempReducer from "./slice/productTempSlice"; +import productOptionsReducer from "./slice/productOptionsSlice"; + +const rootReducer = combineReducers({ + cart: cartReducer, + checkout: checkoutReducer, + orders: orderReducer, + period: periodReducer, + search: searchReducer, + slider: sliderSlice, + productTemp: productTempReducer, + productOptions: productOptionsReducer, +}); + +const store = configureStore({ + reducer: rootReducer, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: false, + }), + devTools: true, +}); + +export type RootState = ReturnType; + +export default store; diff --git a/src/services/sanity/products.ts b/src/services/sanity/products.ts index 88bc47a..ae5f83e 100644 --- a/src/services/sanity/products.ts +++ b/src/services/sanity/products.ts @@ -17,15 +17,17 @@ export function getDetailProduct(id: string) { `*[_type == "product" && _id == $id][0] { _id, productName, - category, - price, - detailCategory, + mainCategory, + subCategory, "images": images[] { "imageUrl": asset->url }, - description, - discount, "detailImage": detailImage.asset->url, + description, + options, + tags, + isNewProduct, + isBestSeller, }`, { id }, ); @@ -34,17 +36,3 @@ export function getDetailProduct(id: string) { console.error(`상세 제품 불러오기 실패: ${error.message}`); } } - -export function getProductsByCategory( - category: string, - isDetail: boolean = false, -) { - try { - let param = isDetail ? "detailCategory" : "category"; - const query = `*[_type == "product" && ${param} == $category] {_id, productName, category, price, mainImage {"imageUrl": asset->url}, detailCategory}`; - const products = client.fetch(query, { category }); - return products; - } catch (error: any) { - console.error(`카테고리별 제품 불러오기 실패: ${error.message}`); - } -} diff --git a/src/type/products.ts b/src/type/products.ts index 89e2f80..16e5b5c 100644 --- a/src/type/products.ts +++ b/src/type/products.ts @@ -16,28 +16,16 @@ export interface Product { productName: string; mainImage: ProductImage; images: ProductImage[]; + detailImage: string; description: string; mainCategory: "midasMetal" | "modernMasters"; subCategory: string; - options: { - color: { - colorName: string; - colorCode: string; - }; - sizes: { - size: string; - price: number; - discount: number; - stock: number; - }[]; - }[]; + options: ProductOption[]; slug?: string; tags?: string[]; isNewProduct?: boolean; isBestSeller?: boolean; } - -export type ProductForDetail = Omit; export type ProductForList = Pick< Product, "_id" | "productName" | "options" | "mainImage" @@ -46,3 +34,16 @@ export type ProductForList = Pick< type ProductImage = { imageUrl: string; }; + +export type ProductOption = { + color: { + colorName: string; + colorCode: string; + }; + sizes: { + size: string; + price: number; + discount: number; + stock: number; + }[]; +}; From df23049e0c5d1be9951cfd3a83166b1b075bce4d Mon Sep 17 00:00:00 2001 From: KimGyeongwon Date: Tue, 24 Dec 2024 18:31:10 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat=20:=20product=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?cart,=20payment=20=ED=83=80=EC=9E=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(cart)/cart/CartClient.tsx | 11 - src/app/(cart)/cart/CartInfoArticle.tsx | 88 +++---- src/app/(cart)/cart/components/CartItem.tsx | 40 ++- src/app/(cart)/cart/components/CartList.tsx | 2 +- .../(checkout)/checkout/CheckoutClient.tsx | 7 +- .../store/details/[id]/ProductSelects.tsx | 18 +- .../store/details/[id]/ProductSummary.tsx | 13 +- src/components/checkoutForm/CheckoutForm.tsx | 186 ++++++++------ src/redux/slice/cartSlice.ts | 162 ++++++++---- src/redux/slice/productOptionsSlice.ts | 7 +- src/redux/slice/productTempSlice.ts | 1 + src/services/order.ts | 239 +++++++++--------- src/type/action.ts | 84 +++--- src/type/cart.ts | 22 +- 14 files changed, 500 insertions(+), 380 deletions(-) diff --git a/src/app/(cart)/cart/CartClient.tsx b/src/app/(cart)/cart/CartClient.tsx index 7664932..d54871f 100644 --- a/src/app/(cart)/cart/CartClient.tsx +++ b/src/app/(cart)/cart/CartClient.tsx @@ -9,10 +9,7 @@ import { ALTERNATE_CHECKED_ITEMS, SELECT_ALL_ITEMS, UNCHECK_ALL_ITEMS, - CALCULATE_SUBTOTAL, - CALCULATE_CHECKED_ITEMS_SUBTOTAL, selectCartItems, - selectCheckedCartItems, selectAllChecked, } from "@/redux/slice/cartSlice"; import { useEffect, useState, useTransition, Suspense } from "react"; @@ -53,7 +50,6 @@ const LoadingFallback = () => ( export default function CartClient() { const cartItems = useSelector(selectCartItems); - const checkedItems = useSelector(selectCheckedCartItems); const isAllChecked = useSelector(selectAllChecked); const [isDisabled, setIsDisabled] = useState(false); const [mounted, setMounted] = useState(false); @@ -67,13 +63,6 @@ export default function CartClient() { setMounted(true); }, []); - useEffect(() => { - startTransition(() => { - dispatch(CALCULATE_SUBTOTAL()); - dispatch(CALCULATE_CHECKED_ITEMS_SUBTOTAL()); - }); - }, [dispatch, cartItems]); - useEffect(() => { setIsDisabled( cartItems.length === 0 || cartItems.every(item => !item.isChecked), diff --git a/src/app/(cart)/cart/CartInfoArticle.tsx b/src/app/(cart)/cart/CartInfoArticle.tsx index 282794c..f6fa57c 100644 --- a/src/app/(cart)/cart/CartInfoArticle.tsx +++ b/src/app/(cart)/cart/CartInfoArticle.tsx @@ -1,44 +1,44 @@ -"use client"; -import { useSelector } from "react-redux"; -import { - selectCartItems, - selectCheckedTotalAmount, -} from "@/redux/slice/cartSlice"; -import deliveryFee from "@/constants/deliveryFee"; -import priceFormat from "@/utils/priceFormat"; - -const CartInfoArticle = () => { - const cartItems = useSelector(selectCartItems); - const checkedTotalAmount = useSelector(selectCheckedTotalAmount); - const checkedItemsQuantity = cartItems - .filter(item => item.isChecked) - .reduce((total, item) => total + item.cartQuantity, 0); - - return ( -
-
-
-

전체 상품 개수

-

{checkedItemsQuantity} 개

-
-
-

배송비

-

{deliveryFee} 원

-
-
-
-

총 결제금액

-
-

{priceFormat(checkedTotalAmount)}

-

-
-
-
- ); -}; - -export default CartInfoArticle; +"use client"; +import { useSelector } from "react-redux"; +import { + selectCartItems, + selectCheckedTotalAmount, +} from "@/redux/slice/cartSlice"; +import deliveryFee from "@/constants/deliveryFee"; +import priceFormat from "@/utils/priceFormat"; + +const CartInfoArticle = () => { + const cartItems = useSelector(selectCartItems); + const checkedTotalAmount = useSelector(selectCheckedTotalAmount); + const checkedItemsQuantity = cartItems + .filter(item => item.isChecked) + .reduce((total, item) => total + item.quantity, 0); + + return ( +
+
+
+

전체 상품 개수

+

{checkedItemsQuantity} 개

+
+
+

배송비

+

{deliveryFee} 원

+
+
+
+

총 결제금액

+
+

{priceFormat(checkedTotalAmount)}

+

+
+
+
+ ); +}; + +export default CartInfoArticle; diff --git a/src/app/(cart)/cart/components/CartItem.tsx b/src/app/(cart)/cart/components/CartItem.tsx index 8b1f5c8..89ee9d2 100644 --- a/src/app/(cart)/cart/components/CartItem.tsx +++ b/src/app/(cart)/cart/components/CartItem.tsx @@ -100,14 +100,26 @@ function CartItem({ onDecreaseQuantity, onDeleteItem, }: CartItemProps) { - const { id, name, imageURL, price, discount, cartQuantity, isChecked } = cart; + const { + name, + imageURL, + price, + discount, + quantity, + isChecked, + color, + colorCode, + size, + key, + } = cart; const discountedPrice = price - price * (discount / 100); + console.log(cart); const handleToggleCheck = useCallback(() => { if (!disabled) { - onToggleCheck(id); + onToggleCheck(key); } - }, [disabled, id, onToggleCheck]); + }, [disabled, key, onToggleCheck]); const handleIncreaseQuantity = useCallback(() => { if (!disabled) { @@ -129,7 +141,7 @@ function CartItem({ if (isMobile) { return ( -
+

{name}

+
+

+

{color}

+
+

{size}

- {priceFormat(discountedPrice * cartQuantity)} 원 + {priceFormat(discountedPrice * quantity)} 원

{/* Selected Items */} @@ -165,6 +130,7 @@ export default function ProductSummary({ product }: { product: Product }) { handleQuantityChange(index, quantity) } onDelete={() => handleDeleteItem(index)} + productName={product && productName} /> ))}
diff --git a/src/app/(product)/store/details/[id]/TempItem.tsx b/src/app/(product)/store/details/[id]/TempItem.tsx index e337d9b..1c081d4 100644 --- a/src/app/(product)/store/details/[id]/TempItem.tsx +++ b/src/app/(product)/store/details/[id]/TempItem.tsx @@ -1,3 +1,5 @@ +import { getDiscountPrice } from "@/utils/getDiscount"; + interface TempItemType { color: string; size: string; @@ -10,20 +12,33 @@ interface Props { item: TempItemType; onQuantityChange: (quantity: number) => void; onDelete: () => void; + productName: string; } -const TempItem = ({ item, onQuantityChange, onDelete }: Props) => { +const TempItem = ({ item, onQuantityChange, onDelete, productName }: Props) => { const { color, size, price, discount, quantity } = item; - const discountedPrice = price - (price * discount) / 100; + const discountedPrice = getDiscountPrice(price, discount); return ( -
-
- {color} - | - {size} +
+ {/* Product Name & Options */} +
+
+ {productName} + | + {color} + | + {size} +
+
-
+ {/* Quantity & Price */} +
); diff --git a/src/components/customSelect/CustomSelect.tsx b/src/components/customSelect/CustomSelect.tsx new file mode 100644 index 0000000..8811f8a --- /dev/null +++ b/src/components/customSelect/CustomSelect.tsx @@ -0,0 +1,162 @@ +import { useState, useRef, useEffect, KeyboardEvent } from "react"; + +interface CustomSelectProps { + value: string; + onChange: (value: string) => void; + options: Array<{ + value: string; + label: string; + }>; + placeholder: string; + disabled?: boolean; + id: string; +} + +const CustomSelect = ({ + value, + onChange, + options, + placeholder, + disabled = false, + id, +}: CustomSelectProps) => { + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const containerRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleKeyDown = (event: KeyboardEvent) => { + if (disabled) return; + + switch (event.key) { + case "Enter": + case " ": + event.preventDefault(); + if (!isOpen) { + setIsOpen(true); + } else if (highlightedIndex !== -1) { + onChange(options[highlightedIndex].value); + setIsOpen(false); + } + break; + case "Escape": + setIsOpen(false); + break; + case "ArrowUp": + event.preventDefault(); + if (!isOpen) { + setIsOpen(true); + } else { + setHighlightedIndex(prev => + prev <= 0 ? options.length - 1 : prev - 1, + ); + } + break; + case "ArrowDown": + event.preventDefault(); + if (!isOpen) { + setIsOpen(true); + } else { + setHighlightedIndex(prev => + prev >= options.length - 1 ? 0 : prev + 1, + ); + } + break; + } + }; + + useEffect(() => { + if (isOpen && listRef.current && highlightedIndex >= 0) { + const element = listRef.current.children[highlightedIndex] as HTMLElement; + element.scrollIntoView({ block: "nearest" }); + } + }, [highlightedIndex, isOpen]); + + return ( +
+ + {isOpen && !disabled && ( +
    + {options.map((option, index) => ( +
  • { + onChange(option.value); + setIsOpen(false); + }} + > + {option.label} +
  • + ))} +
+ )} +
+ ); +}; + +export default CustomSelect; diff --git a/src/redux/slice/cartSlice.ts b/src/redux/slice/cartSlice.ts index 65c6d33..9d1aba2 100644 --- a/src/redux/slice/cartSlice.ts +++ b/src/redux/slice/cartSlice.ts @@ -1,6 +1,7 @@ import { CartItem } from "@/type/cart"; import { createSelector, createSlice } from "@reduxjs/toolkit"; import { RootState } from "../store"; +import { getDiscountPrice } from "@/utils/getDiscount"; interface ICartState { cartItems: CartItem[]; @@ -35,7 +36,7 @@ const initialState: ICartState = { const calculateTotalAmount = (items: CartItem[]) => { return items.reduce((total, item) => { const { price, quantity, discount } = item; - const discountedPrice = price - price * (discount / 100); + const discountedPrice = getDiscountPrice(price, discount); return total + discountedPrice * quantity; }, 0); }; @@ -45,7 +46,7 @@ const calculateCheckedTotalAmount = (items: CartItem[]) => { .filter(item => item.isChecked) .reduce((total, item) => { const { price, quantity, discount } = item; - const discountedPrice = price - price * (discount / 100); + const discountedPrice = getDiscountPrice(price, discount); return total + discountedPrice * quantity; }, 0); }; @@ -204,7 +205,7 @@ const cartSlice = createSlice({ .filter(item => item.isChecked) .map(item => { const { price, quantity, discount } = item; - const discountedPrice = price - price * (discount / 100); + const discountedPrice = getDiscountPrice(price, discount); const cartItemAmount = discountedPrice * quantity; return array.push(cartItemAmount); }); diff --git a/src/redux/slice/productOptionsSlice.ts b/src/redux/slice/productOptionsSlice.ts index d3041eb..ff5643b 100644 --- a/src/redux/slice/productOptionsSlice.ts +++ b/src/redux/slice/productOptionsSlice.ts @@ -3,12 +3,10 @@ import { RootState } from "../store"; interface ProductOptionsState { selectedColor: { colorName: string; colorCode: string } | null; - selectedSize: string | null; } const initialState: ProductOptionsState = { selectedColor: null, - selectedSize: null, }; const productOptionsSlice = createSlice({ @@ -20,23 +18,16 @@ const productOptionsSlice = createSlice({ action: PayloadAction<{ colorName: string; colorCode: string }>, ) { state.selectedColor = action.payload; - state.selectedSize = null; // 색상이 변경되면 크기 초기화 }, - setSize(state, action: PayloadAction) { - state.selectedSize = action.payload; - }, - resetOptions(state) { + resetColor(state) { state.selectedColor = null; - state.selectedSize = null; }, }, }); -export const { setColor, setSize, resetOptions } = productOptionsSlice.actions; +export const { setColor, resetColor } = productOptionsSlice.actions; export const selectColor = (state: RootState) => state.productOptions.selectedColor; -export const selectSize = (state: RootState) => - state.productOptions.selectedSize; export default productOptionsSlice.reducer; diff --git a/src/utils/getDiscount.ts b/src/utils/getDiscount.ts index 8e1977e..74bce40 100644 --- a/src/utils/getDiscount.ts +++ b/src/utils/getDiscount.ts @@ -1,4 +1,4 @@ // 할인 가격 계산 export const getDiscountPrice = (price: number, discount: number) => { - return price - price * (discount / 100); + return Math.floor(price - price * (discount / 100)); }; From a614037ccf34ddb80f94d82fb3edafd402c92f13 Mon Sep 17 00:00:00 2001 From: KimGyeongwon Date: Mon, 30 Dec 2024 21:21:17 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=ED=99=98=EA=B2=BD=20=EA=B3=A0=EC=A0=95=20=EC=9A=94?= =?UTF-8?q?=EC=86=8C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Intersection=20Obs?= =?UTF-8?q?erver=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제품 상세 페이지에서 스크롤 시 고정되는 요소를 추가하여 사용자 경험을 개선했습니다. - Intersection Observer를 사용하여 요소가 뷰포트를 벗어날 때 고정 상태를 관리하도록 구현했습니다. - 선택된 아이템과 총 가격 표시를 고정된 상태에서 더 나은 레이아웃으로 개선했습니다. --- .../store/details/[id]/ProductSummary.tsx | 53 +++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/src/app/(product)/store/details/[id]/ProductSummary.tsx b/src/app/(product)/store/details/[id]/ProductSummary.tsx index 49ef7c6..c666f3a 100644 --- a/src/app/(product)/store/details/[id]/ProductSummary.tsx +++ b/src/app/(product)/store/details/[id]/ProductSummary.tsx @@ -4,7 +4,7 @@ import { Product } from "@/type/products"; import Button from "@/components/button/Button"; import { ADD_TO_CART } from "@/redux/slice/cartSlice"; import { useDispatch, useSelector } from "react-redux"; -import { useTransition } from "react"; +import { useTransition, useEffect, useState, useRef } from "react"; import { useRouter } from "next/navigation"; import Slider from "@/components/slider/Slider"; import URLS from "@/constants/urls"; @@ -25,6 +25,35 @@ export default function ProductSummary({ product }: { product: Product }) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const tempItems = useSelector(selectTempItems); + const [isFixed, setIsFixed] = useState(false); + const observerRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + // 요소가 뷰포트 상단을 벗어났는지 확인 + const boundingRect = entry.boundingClientRect; + console.log(boundingRect.top); + + // 요소가 뷰포트 상단을 벗어났을 때만 fixed 적용 + if (boundingRect.top < 0) { + setIsFixed(true); + } else { + setIsFixed(false); + } + }, + { + threshold: 0, + rootMargin: "0px", // 뷰포트의 경계를 기준으로 함 + }, + ); + + if (observerRef.current) { + observer.observe(observerRef.current); + } + + return () => observer.disconnect(); + }, []); const { _id, productName, options, description, images } = product; @@ -120,8 +149,17 @@ export default function ProductSummary({ product }: { product: Product }) {
+ {/* Observer Target */} +
+ {/* Selected Items */} -
+
0 + ? "sm:fixed sm:bottom-20 sm:left-0 sm:w-full sm:h-32 sm:px-7 sm:py-4 sm:bg-white sm:border-t sm:border-gray-200 sm:overflow-y-auto" + : "" + }`} + > {tempItems.map((item, index) => ( 0 && ( -
+
총 상품금액 {getTotalPrice().toLocaleString()}원 @@ -146,7 +184,14 @@ export default function ProductSummary({ product }: { product: Product }) { )} {/* Buttons */} -
+
- )} -
-
-
- - -
- -
- - ); -} diff --git a/src/app/(order)/order/history/OrderList.tsx b/src/app/(order)/order/history/OrderList.tsx index 6ea188e..0d61ab7 100644 --- a/src/app/(order)/order/history/OrderList.tsx +++ b/src/app/(order)/order/history/OrderList.tsx @@ -5,11 +5,11 @@ import OrderProduct from "./OrderProduct"; import { Order } from "@/type/order"; export default function OrderList() { - const { orders, trackDelivery, isPending, isLoading } = useOrders(); + const { orders, isLoading } = useOrders(); - if (!orders?.length) { + if (!orders?.length && !isLoading) { return ( -
+

데이터를 불러오는 중입니다...

@@ -19,7 +19,7 @@ export default function OrderList() { if (!isLoading && !orders?.length) { return ( -
+

주문 내역이 없습니다

@@ -28,19 +28,14 @@ export default function OrderList() { } return ( -
+
{orders - .sort( + ?.sort( (a: Order, b: Order) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ) .map((order: Order) => ( - + ))}
); diff --git a/src/app/(order)/order/history/OrderProduct.tsx b/src/app/(order)/order/history/OrderProduct.tsx index 7c0f1cc..2c8a1fa 100644 --- a/src/app/(order)/order/history/OrderProduct.tsx +++ b/src/app/(order)/order/history/OrderProduct.tsx @@ -11,22 +11,12 @@ import Link from "next/link"; interface OrderProductProps { order: Order; - onTrackDelivery?: (order: Order) => void; - disabled: boolean; } -export default function OrderProduct({ - order, - onTrackDelivery, - disabled, -}: OrderProductProps) { +export default function OrderProduct({ order }: OrderProductProps) { const { orderDate, cartItems, orderAmount, shippingInfo, createdAt } = order; const pathname = usePathname(); - const handleTrackDelivery = () => { - onTrackDelivery && onTrackDelivery(order); - }; - return (
- {shippingInfo?.trackingNumber && ( - - )}
diff --git a/src/app/(order)/order/history/StatusProgress.tsx b/src/app/(order)/order/history/StatusProgress.tsx index d2882ab..a6547d7 100644 --- a/src/app/(order)/order/history/StatusProgress.tsx +++ b/src/app/(order)/order/history/StatusProgress.tsx @@ -1,44 +1,57 @@ -"use client"; -import { BsChevronRight } from "react-icons/bs"; -import { ORDER_STATUS } from "@/constants/status"; -import { useSelector } from "react-redux"; -import { selectOrders } from "@/redux/slice/orderSlice"; - -const StatusProgress = () => { - const statusArray = ORDER_STATUS.map(status => status.value).slice(1, 5); - const statusTitleArr = ORDER_STATUS.map(status => status.title).slice(1, 5); - const orders = useSelector(selectOrders); - - const statusCount = orders.reduce((acc: any, cur: any) => { - if (acc[cur.orderStatus]) { - acc[cur.orderStatus] += 1; - } else { - acc[cur.orderStatus] = 1; - } - return acc; - }, {}); - - return ( -
- {statusArray.map((status, index) => ( -
-
-

- {statusTitleArr[index]} -

-

- {statusCount[status] ? statusCount[status] : 0} -

-
- {index < statusArray.length - 1 && ( -
- -
- )} -
- ))} -
- ); -}; - -export default StatusProgress; +"use client"; +import { BsChevronRight } from "react-icons/bs"; +import { ORDER_STATUS } from "@/constants/status"; +import { useOrders } from "@/hooks/useOrders"; + +type StatusCount = { + payed: number; + preparing: number; + moving: number; + done: number; + canceled: number; +}; + +const StatusProgress = () => { + const statusArray = ORDER_STATUS.map(status => status.value).slice(1, 5); + const statusTitleArr = ORDER_STATUS.map(status => status.title).slice(1, 5); + + const { orders } = useOrders(); + + const statusCount: StatusCount = { + payed: 0, + preparing: 0, + moving: 0, + done: 0, + canceled: 0, + }; + + orders?.forEach(order => { + if (order.orderStatus && statusCount.hasOwnProperty(order.orderStatus)) { + statusCount[order.orderStatus as keyof StatusCount]++; + } + }); + + return ( +
+ {statusArray.map((status, index) => ( +
+
+

+ {statusTitleArr[index]} +

+

+ {statusCount[status as keyof StatusCount] || 0} +

+
+ {index < statusArray.length - 1 && ( +
+ +
+ )} +
+ ))} +
+ ); +}; + +export default StatusProgress; diff --git a/src/app/(order)/order/history/page.tsx b/src/app/(order)/order/history/page.tsx index 749f5aa..2ddbc32 100644 --- a/src/app/(order)/order/history/page.tsx +++ b/src/app/(order)/order/history/page.tsx @@ -1,5 +1,23 @@ -import OrderHistoryClient from "./OrderHistoryClient"; - -export default function OrderHistory() { - return ; -} +import StatusProgress from "./StatusProgress"; +import PeriodSelector from "@/layouts/periodSelector/PeriodSelector"; +import Heading from "@/components/heading/Heading"; +import OrderList from "./OrderList"; + +export default function OrderHistory() { + return ( +
+
+ +
+ +
+
+
+ + +
+ +
+
+ ); +} diff --git a/src/app/actions.ts b/src/app/actions.ts index 74fc60d..a54f8f5 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -1,99 +1,75 @@ -"use server"; - -import { cookies } from "next/headers"; -import { trackDelivery } from "@/services/deliveryTracker"; -import { redirect } from "next/navigation"; -import URLS from "@/constants/urls"; -import { revalidatePath } from "next/cache"; -import { - HandleCheckoutErrorAction, - TrackDeliveryAction, - SetCheckoutDataAction, - DeleteCheckoutDataAction, - ServerActionResult, - CheckoutData, -} from "@/type/action"; - -export const handleCheckoutError: HandleCheckoutErrorAction = async ( - message, - orderId, -) => { - redirect( - `${URLS.CHECKOUT_FAIL}?message=${encodeURIComponent( - message, - )}&orderId=${orderId}`, - ); -}; - -export const serverTrackDelivery: TrackDeliveryAction = async ( - carrierId, - trackingNumber, -) => { - "use server"; - - try { - const deliveryData = await trackDelivery(carrierId, trackingNumber); - - if (deliveryData.errors) { - throw new Error(deliveryData.errors[0].message); - } - - revalidatePath("/order/history"); - return deliveryData; - } catch (error: any) { - console.error("배송 조회 중 오류가 발생했습니다:", error); - throw new Error("배송 조회 중 오류가 발생했습니다."); - } -}; - -export const setCheckoutData: SetCheckoutDataAction = async data => { - "use server"; - - try { - // 데이터 유효성 검사 - const checkoutData = JSON.parse(data) as CheckoutData; - if ( - !checkoutData.cartItems?.length || - !checkoutData.shippingAddress || - !checkoutData.billingAddress - ) { - throw new Error("체크아웃 데이터가 유효하지 않습니다."); - } - - const cookieStore = await cookies(); - cookieStore.set("checkoutData", data, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 60 * 60, // 1시간 - }); - - revalidatePath("/checkout"); - revalidatePath("/checkout-success"); - } catch (error) { - console.error("Error setting checkout data:", error); - throw error; - } -}; - -export const deleteCheckoutData: DeleteCheckoutDataAction = async () => { - "use server"; - - try { - const cookieStore = await cookies(); - cookieStore.set("checkoutData", "", { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: 0, // 즉시 만료 - }); - - revalidatePath("/checkout"); - revalidatePath("/checkout-success"); - } catch (error) { - console.error("Error deleting checkout data:", error); - throw error; - } -}; +"use server"; + +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import URLS from "@/constants/urls"; +import { revalidatePath } from "next/cache"; +import { + HandleCheckoutErrorAction, + SetCheckoutDataAction, + DeleteCheckoutDataAction, + CheckoutData, +} from "@/type/action"; + +export const handleCheckoutError: HandleCheckoutErrorAction = async ( + message, + orderId, +) => { + redirect( + `${URLS.CHECKOUT_FAIL}?message=${encodeURIComponent( + message, + )}&orderId=${orderId}`, + ); +}; + +export const setCheckoutData: SetCheckoutDataAction = async data => { + "use server"; + + try { + // 데이터 유효성 검사 + const checkoutData = JSON.parse(data) as CheckoutData; + if ( + !checkoutData.cartItems?.length || + !checkoutData.shippingAddress || + !checkoutData.billingAddress + ) { + throw new Error("체크아웃 데이터가 유효하지 않습니다."); + } + + const cookieStore = await cookies(); + cookieStore.set("checkoutData", data, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60, // 1시간 + }); + + revalidatePath("/checkout"); + revalidatePath("/checkout-success"); + } catch (error) { + console.error("Error setting checkout data:", error); + throw error; + } +}; + +export const deleteCheckoutData: DeleteCheckoutDataAction = async () => { + "use server"; + + try { + const cookieStore = await cookies(); + cookieStore.set("checkoutData", "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, // 즉시 만료 + }); + + revalidatePath("/checkout"); + revalidatePath("/checkout-success"); + } catch (error) { + console.error("Error deleting checkout data:", error); + throw error; + } +}; diff --git a/src/app/api/delivery/track/route.ts b/src/app/api/delivery/track/route.ts new file mode 100644 index 0000000..cac551a --- /dev/null +++ b/src/app/api/delivery/track/route.ts @@ -0,0 +1,17 @@ +// src/app/api/delivery/track/route.ts +import { NextRequest, NextResponse } from "next/server"; +import { trackDelivery } from "@/services/deliveryTracker"; + +export async function POST(req: NextRequest) { + try { + const { carrierId, trackingNumber } = await req.json(); + const result = await trackDelivery(carrierId, trackingNumber); + return NextResponse.json(result); + } catch (error) { + console.error("배송 조회 중 오류:", error); + return NextResponse.json( + { error: "배송 조회 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/webhook/delivery/route.ts b/src/app/api/webhook/delivery/route.ts index 008d61e..a506ea7 100644 --- a/src/app/api/webhook/delivery/route.ts +++ b/src/app/api/webhook/delivery/route.ts @@ -1,86 +1,86 @@ -import { NextRequest, NextResponse } from "next/server"; -import { updateOrderStatus } from "@/services/sanity/orders"; -import { client } from "@/services/sanity/sanity"; -import { trackDelivery, unregisterWebhook } from "@/services/deliveryTracker"; -import { DELIVERY_STATUS } from "@/constants/deliveryStatus"; - -export async function POST(req: NextRequest) { - try { - const data = await req.json(); - const { carrierId, trackingNumber } = data; - - if (!carrierId || !trackingNumber) { - return NextResponse.json( - { error: "Missing required fields" }, - { status: 400 }, - ); - } - - // 배송 정보 조회 - const trackingResult = await trackDelivery(carrierId, trackingNumber); - - if (!trackingResult.data?.track) { - return NextResponse.json( - { error: "Failed to fetch tracking information" }, - { status: 500 }, - ); - } - - // trackingNumber로 해당 주문 찾기 - const order = await client.fetch( - `*[_type == "order" && shippingInfo.trackingNumber == $trackingNumber][0]`, - { trackingNumber }, - ); - - if (!order) { - return NextResponse.json({ error: "Order not found" }, { status: 404 }); - } - - // 배송 상태 매핑 - const status = trackingResult.data.track.lastEvent.status.code; - const orderStatus = - DELIVERY_STATUS[status as keyof typeof DELIVERY_STATUS] || - DELIVERY_STATUS.UNKNOWN; - - // 이전 상태와 동일한 경우 업데이트 스킵 - if (order.orderStatus === orderStatus) { - return NextResponse.json( - { success: true, message: "Status unchanged" }, - { status: 202 }, - ); - } - - // Sanity 업데이트 - await updateOrderStatus( - order._id, - orderStatus, - trackingResult.data.track.events.edges, - ); - - // 배송 완료나 실패 상태인 경우 webhook 해제 - if (["done", "failed"].includes(orderStatus)) { - try { - await unregisterWebhook(carrierId, trackingNumber); - } catch (error) { - console.error("Failed to unregister webhook:", error); - } - } - - return NextResponse.json( - { - success: true, - data: { - previousStatus: order.orderStatus, - newStatus: orderStatus, - }, - }, - { status: 202 }, - ); - } catch (error) { - console.error("Delivery webhook error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { updateOrderStatus } from "@/services/sanity/orders"; +import { client } from "@/services/sanity/sanity"; +import { trackDelivery, unregisterWebhook } from "@/services/deliveryTracker"; +import { DELIVERY_STATUS } from "@/constants/deliveryStatus"; + +export async function POST(req: NextRequest) { + try { + const data = await req.json(); + const { carrierId, trackingNumber } = data; + + if (!carrierId || !trackingNumber) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 }, + ); + } + + // 배송 정보 조회 + const trackingResult = await trackDelivery(carrierId, trackingNumber); + + if (!trackingResult.data?.track) { + return NextResponse.json( + { error: "Failed to fetch tracking information" }, + { status: 500 }, + ); + } + + // trackingNumber로 해당 주문 찾기 + const order = await client.fetch( + `*[_type == "order" && shippingInfo.trackingNumber == $trackingNumber][0]`, + { trackingNumber }, + ); + + if (!order) { + return NextResponse.json({ error: "Order not found" }, { status: 404 }); + } + + // 배송 상태 매핑 + const status = trackingResult.data.track.lastEvent.status.code; + const orderStatus = + DELIVERY_STATUS[status as keyof typeof DELIVERY_STATUS] || + DELIVERY_STATUS.UNKNOWN; + + // 이전 상태와 동일한 경우 업데이트 스킵 + if (order.orderStatus === orderStatus) { + return NextResponse.json( + { success: true, message: "Status unchanged" }, + { status: 202 }, + ); + } + + // Sanity 업데이트 + await updateOrderStatus( + order._id, + orderStatus, + trackingResult.data.track.events.edges, + ); + + // 배송 완료나 실패 상태인 경우 webhook 해제 + if (["done", "failed"].includes(orderStatus)) { + try { + await unregisterWebhook(carrierId, trackingNumber); + } catch (error) { + console.error("Failed to unregister webhook:", error); + } + } + + return NextResponse.json( + { + success: true, + data: { + previousStatus: order.orderStatus, + newStatus: orderStatus, + }, + }, + { status: 202 }, + ); + } catch (error) { + console.error("Delivery webhook error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/constants/deliveryStatus.ts b/src/constants/deliveryStatus.ts index 45f8f7a..3c34093 100644 --- a/src/constants/deliveryStatus.ts +++ b/src/constants/deliveryStatus.ts @@ -1,22 +1,14 @@ -export const DELIVERY_STATUS = { - // 배송 준비중 - INFORMATION_RECEIVED: "ready", - AT_PICKUP: "ready", - - // 배송중 - IN_TRANSIT: "moving", - OUT_FOR_DELIVERY: "moving", - - // 배송 완료 - DELIVERED: "done", - - // 배송 실패/보류 - DELIVERY_FAILED: "failed", - PENDING: "pending", - - // 기타 - UNKNOWN: "unknown", -} as const; - -export type DeliveryStatusCode = keyof typeof DELIVERY_STATUS; -export type OrderStatusType = (typeof DELIVERY_STATUS)[DeliveryStatusCode]; +export const DELIVERY_STATUS = { + UNKNOWN: "unknown", + INFORMATION_RECEIVED: "ready", + AT_PICKUP: "ready", + IN_TRANSIT: "moving", + OUT_FOR_DELIVERY: "moving", + ATTEMPT_FAIL: "unknown", + DELIVERED: "done", + AVAILABLE_FOR_PICKUP: "moving", + EXCEPTION: "unknown", +} as const; + +export type DeliveryStatusCode = keyof typeof DELIVERY_STATUS; +export type OrderStatusType = (typeof DELIVERY_STATUS)[DeliveryStatusCode]; diff --git a/src/hooks/useOrderTracking.ts b/src/hooks/useOrderTracking.ts new file mode 100644 index 0000000..32173e6 --- /dev/null +++ b/src/hooks/useOrderTracking.ts @@ -0,0 +1,95 @@ +// src/hooks/useOrderTracking.ts +import useSWR from "swr"; +import { Order } from "@/type/order"; +import { DeliveryTrackingResponse } from "@/type/order"; +import { DELIVERY_STATUS } from "@/constants/deliveryStatus"; + +export function useOrderTracking(order: Order) { + const { data, error, mutate } = useSWR( + order.shippingInfo?.trackingNumber + ? [ + `delivery-tracking`, + order.shippingInfo.carrierId, + order.shippingInfo.trackingNumber, + ] + : null, + async ([_, carrierId, trackingNumber]) => { + const response = await fetch("/api/delivery/track", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ carrierId, trackingNumber }), + }); + + if (!response.ok) { + throw new Error("배송 조회 실패"); + } + + return response.json(); + }, + { + // 60초마다 자동 갱신 + refreshInterval: 60000, + // 포커스시 자동 갱신 + revalidateOnFocus: true, + }, + ); + + // 배송 상태 업데이트 함수 + const updateOrderStatus = async () => { + if (!order.shippingInfo?.trackingNumber) return; + + const trackingStatus = data?.data?.track?.lastEvent?.status?.code; + const orderStatus = + DELIVERY_STATUS[trackingStatus as keyof typeof DELIVERY_STATUS] || + "unknown"; + console.log("orderStatus", orderStatus); + // 현재 상태와 새로운 상태가 같으면 업데이트하지 않음 + if (order.orderStatus === orderStatus) { + return; + } + + try { + const response = await fetch("/api/orders/update-status", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + orderId: order._id, + status: orderStatus, // 매핑된 상태값 사용 + events: data?.data?.track?.events?.edges, + }), + }); + + if (!response.ok) { + throw new Error("주문 상태 업데이트 실패"); + } + + mutate(); + } catch (error) { + console.error("주문 상태 업데이트 중 오류:", error); + } + }; + + // 배송 추적 시작 + const startTracking = async () => { + if ( + !order.shippingInfo?.carrierId || + !order.shippingInfo?.trackingNumber || + order.orderStatus === "payed" || + order.orderStatus === "preparing" || + order.orderStatus === "canceled" + ) { + return; + } + + await mutate(); + await updateOrderStatus(); + }; + + return { + trackingData: data, + isLoading: !error && !data, + isError: error, + startTracking, + refreshTracking: mutate, + }; +} diff --git a/src/hooks/useOrders.ts b/src/hooks/useOrders.ts index 7756450..28de965 100644 --- a/src/hooks/useOrders.ts +++ b/src/hooks/useOrders.ts @@ -1,21 +1,11 @@ import { useSession } from "next-auth/react"; -import { useDispatch, useSelector } from "react-redux"; -import { - STORE_ORDER, - selectOrders, - trackDeliveryThunk, -} from "@/redux/slice/orderSlice"; import { getOrders } from "@/services/sanity/orders"; import useSWR from "swr"; import { Order } from "@/type/order"; -import { useEffect, useRef, useTransition } from "react"; export function useOrders() { const { data: session } = useSession(); const userId = session?.user?.id; - const dispatch = useDispatch(); - const updatingRef = useRef<{ [key: string]: boolean }>({}); - const [isPending, startTransition] = useTransition(); // SWR로 주문 데이터 가져오기 const { @@ -23,83 +13,14 @@ export function useOrders() { isLoading, error, mutate, - } = useSWR(userId ? ["orders", userId] : null, () => getOrders(userId!)); - - // Redux에서 주문 상태 가져오기 - const reduxOrders = useSelector(selectOrders); - - // 주문 상태 동기화 - useEffect(() => { - if (reduxOrders && orders) { - reduxOrders.forEach(reduxOrder => { - const matchedOrder = orders.find( - (order: Order) => order._id === reduxOrder._id, - ); - if (matchedOrder) { - if ( - matchedOrder.orderStatus !== reduxOrder.orderStatus && - reduxOrder.shippingInfo?.events && - !updatingRef.current[matchedOrder._id] - ) { - startTransition(async () => { - try { - updatingRef.current[matchedOrder._id] = true; - const response = await fetch("/api/orders/update-status", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - orderId: matchedOrder._id, - status: reduxOrder.orderStatus, - events: reduxOrder.shippingInfo.events, - }), - }); - - if (!response.ok) { - throw new Error("Failed to update order status"); - } - - await mutate(); - } catch (error) { - console.error("Failed to update order status:", error); - } finally { - delete updatingRef.current[matchedOrder._id]; - } - }); - } - } - }); - } - }, [reduxOrders, orders, mutate]); - - // Redux 스토어 업데이트 - useEffect(() => { - if (orders) { - dispatch(STORE_ORDER(orders)); - } - }, [orders, dispatch]); - - // 배송 추적 시작 - 배송 정보가 있고 결제완료 이후 상태일 때만 실행 - const trackDelivery = (order: Order) => { - if ( - order.shippingInfo?.carrierId && - order.shippingInfo?.trackingNumber && - order.orderStatus !== "payed" && - order.orderStatus !== "preparing" && - order.orderStatus !== "canceled" - ) { - startTransition(() => { - dispatch(trackDeliveryThunk(order) as any); - }); - } - }; + } = useSWR(userId ? ["orders", userId] : null, () => + getOrders(userId!), + ); return { orders, error, isLoading, - trackDelivery, - isPending, + refreshOrders: mutate, }; } diff --git a/src/redux/slice/orderSlice.ts b/src/redux/slice/orderSlice.ts deleted file mode 100644 index abaeb55..0000000 --- a/src/redux/slice/orderSlice.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; -import { serverTrackDelivery } from "@/app/actions"; -import { Order, DeliveryTrackingResponse } from "@/type/order"; -import { RootState } from "../store"; - -type IOrderState = Order[]; - -const initialState: IOrderState = []; - -// 비동기 작업 정의 -export const trackDeliveryThunk = createAsyncThunk( - "orders/trackDelivery", - async (order: Order, thunkAPI) => { - try { - const { carrierId, trackingNumber } = order.shippingInfo; - const deliveryData = await serverTrackDelivery(carrierId, trackingNumber); - - if (deliveryData.data?.track) { - const deliveryStatus = deliveryData.data.track.lastEvent.status.code; - const status = changeOrderStatus(deliveryStatus); - const events = deliveryData.data.track.events.edges; - - // API를 통해 Sanity 데이터베이스 업데이트 - const response = await fetch("/api/orders/update-status", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - orderId: order._id, - status, - events, - }), - }); - - if (!response.ok) { - throw new Error("Failed to update order status"); - } - } - - return { data: deliveryData, trackingNumber }; - } catch (error: any) { - return thunkAPI.rejectWithValue(error.message); - } - }, -); - -const changeOrderStatus = (deliveryStatus: string) => { - if ( - deliveryStatus === "AT_PICKUP" || - deliveryStatus === "IN_TRANSIT" || - deliveryStatus === "OUT_FOR_DELIVERY" - ) { - return "moving"; - } - if (deliveryStatus === "DELIVERED") { - return "done"; - } - if (deliveryStatus === "INFORMATION_RECEIVED") { - return "payed"; - } else return "unknown"; -}; - -const orderSlice = createSlice({ - name: "orders", - initialState, - reducers: { - STORE_ORDER(state, action) { - //중복되지 않게 push - action.payload.forEach((order: Order) => { - if (!state.find(stateOrder => stateOrder._id === order._id)) { - state.push(order); - } - }); - }, - }, - extraReducers: builder => { - builder - .addCase(trackDeliveryThunk.fulfilled, (state, action) => { - const { data, trackingNumber } = action.payload as { - data: DeliveryTrackingResponse; - trackingNumber: string; - }; - - if (data.data) { - const deliveryStatus = data.data.track.lastEvent.status.code; - const deliveryTime = data.data.track.lastEvent.time; - const deliveryEvents = data.data.track.events.edges; - - if (state.length > 0) { - state.forEach((order, index) => { - if (!order.shippingInfo) return; - if (order.shippingInfo.trackingNumber === trackingNumber) { - state[index].orderStatus = changeOrderStatus(deliveryStatus); - state[index].shippingInfo.lastEventTime = deliveryTime; - state[index].shippingInfo.events = deliveryEvents; - } - }); - } - } - }) - .addCase(trackDeliveryThunk.rejected, (state, action) => { - console.error("배송 조회 실패:", action.payload); - }); - }, -}); - -export const { STORE_ORDER } = orderSlice.actions; - -export const selectOrders = (state: RootState) => state.orders; - -export default orderSlice.reducer; diff --git a/src/redux/store.ts b/src/redux/store.ts index 779aed0..7ad3aa1 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,7 +1,6 @@ import { combineReducers, configureStore } from "@reduxjs/toolkit"; import cartReducer from "./slice/cartSlice"; import checkoutReducer from "./slice/checkoutSlice"; -import orderReducer from "./slice/orderSlice"; import periodReducer from "./slice/periodSlice"; import searchReducer from "./slice/searchSlice"; import sliderSlice from "./slice/sliderSlice"; @@ -11,7 +10,6 @@ import productOptionsReducer from "./slice/productOptionsSlice"; const rootReducer = combineReducers({ cart: cartReducer, checkout: checkoutReducer, - orders: orderReducer, period: periodReducer, search: searchReducer, slider: sliderSlice, diff --git a/src/services/deliveryTracker.ts b/src/services/deliveryTracker.ts index 9ce3650..25a51a5 100644 --- a/src/services/deliveryTracker.ts +++ b/src/services/deliveryTracker.ts @@ -1,121 +1,150 @@ -import { DeliveryTrackingResponse } from "@/type/order"; - -const TRACKER_API_URL = "https://apis.tracker.delivery/graphql"; -const WEBHOOK_URL = `${process.env.NEXT_PUBLIC_APP_URL}/api/webhook/delivery`; -const CLIENT_ID = process.env.DELIVERY_CLIENT_ID; -const CLIENT_SECRET = process.env.DELIVERY_CLIENT_SECRET; - -const getAuthHeader = () => { - if (!CLIENT_ID || !CLIENT_SECRET) { - throw new Error("Delivery tracker credentials are not configured"); - } - return `TRACKQL-API-KEY ${CLIENT_ID}:${CLIENT_SECRET}`; -}; - -export async function registerWebhook( - carrierId: string, - trackingNumber: string, - expirationHours: number = 48, // 기본값 48시간 -) { - try { - const expirationTime = new Date(); - expirationTime.setHours(expirationTime.getHours() + expirationHours); - - const response = await fetch(TRACKER_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: getAuthHeader(), - }, - body: JSON.stringify({ - query: `mutation RegisterTrackWebhook($input: RegisterTrackWebhookInput!) { - registerTrackWebhook(input: $input) - }`, - variables: { - input: { - carrierId, - trackingNumber, - callbackUrl: WEBHOOK_URL, - expirationTime: expirationTime.toISOString(), - }, - }, - }), - }); - - const data = await response.json(); - - if (data.errors) { - throw new Error(data.errors[0]?.message || "Webhook registration failed"); - } - - return data; - } catch (error) { - console.error("Error registering webhook:", error); - throw error; - } -} - -// Webhook 등록 해제 함수 -export async function unregisterWebhook( - carrierId: string, - trackingNumber: string, -) { - // expirationTime을 현재 시간으로 설정하여 즉시 만료 - return registerWebhook(carrierId, trackingNumber, 0); -} - -export async function trackDelivery( - carrierId: string, - trackingNumber: string, -): Promise { - try { - const response = await fetch(TRACKER_API_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: getAuthHeader(), - }, - body: JSON.stringify({ - query: `query Track($carrierId: ID!, $trackingNumber: String!) { - track(carrierId: $carrierId, trackingNumber: $trackingNumber) { - lastEvent { - status { - code - } - time - } - events { - edges { - node { - status { - code - } - time - description - } - } - } - } - }`, - variables: { - carrierId, - trackingNumber, - }, - }), - }); - - const result = await response.json(); - - if (result.errors) { - throw new Error(result.errors[0]?.message || "Tracking query failed"); - } - - // 배송 추적 성공 시 webhook 등록 - await registerWebhook(carrierId, trackingNumber); - - return result; - } catch (error) { - console.error("배송 조회 중 오류가 발생했습니다:", error); - throw error; - } -} +import { DeliveryTrackingResponse } from "@/type/order"; + +const TRACKER_API_URL = "https://apis.tracker.delivery/graphql"; +const WEBHOOK_URL = `${process.env.NEXT_PUBLIC_APP_URL}/api/webhook/delivery`; +const CLIENT_ID = process.env.DELIVERY_CLIENT_ID; +const CLIENT_SECRET = process.env.DELIVERY_CLIENT_SECRET; + +const getAuthHeader = () => { + if (!CLIENT_ID || !CLIENT_SECRET) { + console.error("Missing credentials:", { + hasClientId: !!CLIENT_ID, + hasClientSecret: !!CLIENT_SECRET, + }); + throw new Error("Delivery tracker credentials are not configured"); + } + + // API 키 형식 검증 추가 + if ( + !CLIENT_ID.match(/^[A-Za-z0-9]+$/) || + !CLIENT_SECRET.match(/^[A-Za-z0-9]+$/) + ) { + throw new Error("Invalid credential format"); + } + + console.log("Auth Header:", `TRACKQL-API-KEY ${CLIENT_ID}:${CLIENT_SECRET}`); + + return `TRACKQL-API-KEY ${CLIENT_ID}:${CLIENT_SECRET}`; +}; + +export async function registerWebhook( + carrierId: string, + trackingNumber: string, + expirationHours: number = 48, // 기본값 48시간 +) { + try { + const expirationTime = new Date(); + expirationTime.setHours(expirationTime.getHours() + expirationHours); + + const response = await fetch(TRACKER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: getAuthHeader(), + }, + body: JSON.stringify({ + query: `mutation RegisterTrackWebhook($input: RegisterTrackWebhookInput!) { + registerTrackWebhook(input: $input) + }`, + variables: { + input: { + carrierId, + trackingNumber, + callbackUrl: WEBHOOK_URL, + expirationTime: expirationTime.toISOString(), + }, + }, + }), + }); + + const data = await response.json(); + + if (data.errors) { + throw new Error(data.errors[0]?.message || "Webhook registration failed"); + } + + return data; + } catch (error) { + console.error("Error registering webhook:", error); + throw error; + } +} + +// Webhook 등록 해제 함수 +export async function unregisterWebhook( + carrierId: string, + trackingNumber: string, +) { + // expirationTime을 현재 시간으로 설정하여 즉시 만료 + return registerWebhook(carrierId, trackingNumber, 0); +} + +export async function trackDelivery( + carrierId: string, + trackingNumber: string, +): Promise { + try { + const response = await fetch(TRACKER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: getAuthHeader(), + }, + body: JSON.stringify({ + query: `query Track($carrierId: ID!, $trackingNumber: String!) { + track(carrierId: $carrierId, trackingNumber: $trackingNumber) { + lastEvent { + status { + code + } + time + } + events(last: 10) { + edges { + node { + status { + code + } + time + description + } + } + } + } + }`, + variables: { + carrierId, + trackingNumber, + }, + }), + }); + + const result = await response.json(); + + if (result.errors) { + console.error("API Error Details:", result.errors); + throw new Error(result.errors[0]?.message || "Tracking query failed"); + } + + // 2. Webhook 등록 실패를 별도로 처리 + try { + if (process.env.NODE_ENV === "development") { + console.log("no resgister webhook"); + } else { + await registerWebhook(carrierId, trackingNumber); + } + } catch (webhookError) { + console.error("Webhook registration failed:", webhookError); + // Webhook 등록 실패해도 배송 조회 결과는 반환 + } + + return result; + } catch (error) { + console.error("Delivery tracking error details:", { + error, + message: (error as Error).message, + stack: (error as Error).stack, + }); + throw error; + } +} From 094903172c7c11df5fadf75a8fd883eacab15487 Mon Sep 17 00:00:00 2001 From: KimGyeongwon Date: Thu, 9 Jan 2025 22:59:06 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20=EC=A0=9C=ED=92=88=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=20=EB=B0=8F=20=EC=B9=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EA=B2=A9=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 총 원래 가격 및 할인된 가격, 할인율을 계산하고 표시하도록 ProductSummary 컴포넌트를 개선했습니다. - 제품 옵션을 활용하여 동적 가격 및 할인 계산을 수행하도록 Card 컴포넌트를 업데이트했습니다. - 사용자 경험을 개선하기 위해 레이아웃 및 스타일을 개선하고, 모바일 뷰에 대한 반응형 조정을 포함했습니다. - 제품 목록에서 가시성을 높이기 위해 스크롤바 사용자 지정을 추가했습니다. --- src/app/(cart)/cart/CartClient.tsx | 2 +- src/app/api/cron/webhook-refresh/route.ts | 130 +++++----- src/layouts/footer/Footer.tsx | 298 +++++++++++----------- src/redux/slice/cartSlice.ts | 3 +- 4 files changed, 217 insertions(+), 216 deletions(-) diff --git a/src/app/(cart)/cart/CartClient.tsx b/src/app/(cart)/cart/CartClient.tsx index d54871f..edfb19d 100644 --- a/src/app/(cart)/cart/CartClient.tsx +++ b/src/app/(cart)/cart/CartClient.tsx @@ -131,7 +131,7 @@ export default function CartClient() { return (
-
+
{ - try { - const { carrierId, trackingNumber } = order.shippingInfo; - if (!carrierId || !trackingNumber) return null; - - // webhook 갱신 - await registerWebhook(carrierId, trackingNumber); - return { orderId: order._id, status: "success" }; - } catch (error) { - console.error( - `Failed to refresh webhook for order ${order._id}:`, - error, - ); - return { orderId: order._id, status: "failed", error }; - } - }), - ); - - const succeeded = results.filter( - r => r.status === "fulfilled" && r.value?.status === "success", - ).length; - const failed = results.filter( - r => r.status === "rejected" || r.value?.status === "failed", - ).length; - - return NextResponse.json({ - success: true, - summary: { - total: orders.length, - succeeded, - failed, - }, - }); - } catch (error) { - console.error("Webhook refresh cron error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} +import { NextRequest, NextResponse } from "next/server"; +import { client } from "@/services/sanity/sanity"; +import { registerWebhook } from "@/services/deliveryTracker"; + +// Vercel Cron Job에서 24시간마다 호출 +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + // API 키 검증 + const authHeader = req.headers.get("authorization"); + if (authHeader !== `Bearer ${process.env.DELIVERY_CRON_SECRET}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // 배송중인 주문 조회 (moving, ready 상태) + const orders = await client.fetch(` + *[_type == "order" && (orderStatus == "moving" || orderStatus == "ready") && defined(shippingInfo.trackingNumber)] { + _id, + shippingInfo + } + `); + + const results = await Promise.allSettled( + orders.map(async (order: any) => { + try { + const { carrierId, trackingNumber } = order.shippingInfo; + if (!carrierId || !trackingNumber) return null; + + // webhook 갱신 + await registerWebhook(carrierId, trackingNumber); + return { orderId: order._id, status: "success" }; + } catch (error) { + console.error( + `Failed to refresh webhook for order ${order._id}:`, + error, + ); + return { orderId: order._id, status: "failed", error }; + } + }), + ); + + const succeeded = results.filter( + r => r.status === "fulfilled" && r.value?.status === "success", + ).length; + const failed = results.filter( + r => r.status === "rejected" || r.value?.status === "failed", + ).length; + + return NextResponse.json({ + success: true, + summary: { + total: orders.length, + succeeded, + failed, + }, + }); + } catch (error) { + console.error("Webhook refresh cron error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/layouts/footer/Footer.tsx b/src/layouts/footer/Footer.tsx index 8816958..99de8c1 100644 --- a/src/layouts/footer/Footer.tsx +++ b/src/layouts/footer/Footer.tsx @@ -1,148 +1,150 @@ -import Image from "next/image"; -import Link from "next/link"; -import URLS from "@/constants/urls"; - -export default function Footer() { - const personalData = [ - "개인정보 처리방침", - "서비스 이용약관", - "이용안내", - "협회 소개", - ]; - - const pagesData = [ - { - title: "SYATT", - manu1: "협회 소개", - manu2: "교육 소개", - manu3: "마이페이지", - link1: URLS.GREETINGS, - link2: URLS.EDUCATION, - link3: URLS.ORDER_HISTORY, - }, - { - title: "모던마스터즈", - manu1: "브랜드 소개", - manu2: "브랜드 상품 스토어", - manu3: null, - link1: URLS.MODERN_MASTERS, - link2: URLS.PRODUCT_STORE, - link3: null, - }, - { - title: "마이다스메탈", - manu1: "브랜드 소개", - manu2: "브랜드 상품 스토어", - manu3: null, - link1: URLS.MIDAS_METAL, - link2: URLS.PRODUCT_STORE, - link3: null, - }, - ]; - - return ( -
-
- {/* 왼쪽 메뉴 */} -
-
-
-
문의전화
-
- AM 09:00 ~ PM 05:00 -
-
-
- 1566-1000 -
-
-
SYATT
-
- 사업자 등록번호 : 150-66-100004 | 대표 : 김OO -
- 호스팅 서비스 : 서버회사 | 통신판매업 신고번호 : 신고번호 -
- 주소 기입 고객센터 : 고객센터 기입 -
-
- {personalData.map(data => ( - <> -
{data}
- - ))} -
-
- - {/* 중앙 메뉴 */} -
- {pagesData.map(data => ( - <> -
    -
  • - {data.title} -
  • -
  • - - {data.manu1 === null ? "" : "• " + data.manu1} - -
  • -
  • - - {data.manu2 === null ? "" : "• " + data.manu2} - -
  • -
  • - - {data.manu3 === null ? "" : "• " + data.manu3} - -
  • -
- - ))} -
- - {/* 오른쪽 메뉴 */} -
-
- {"푸터로고"} -
-
-
- {"푸터로고"} -
-
- {"푸터로고"} -
-
- {"푸터로고"} -
-
-
- © 2023. SYATT All rights reserved. -
-
-
-
- ); -} +import Image from "next/image"; +import Link from "next/link"; +import URLS from "@/constants/urls"; + +export default function Footer() { + const personalData = [ + "개인정보 처리방침", + "서비스 이용약관", + "이용안내", + "협회 소개", + ]; + + const pagesData = [ + { + title: "SYATT", + manu1: "협회 소개", + manu2: "교육 소개", + manu3: "마이페이지", + link1: URLS.GREETINGS, + link2: URLS.EDUCATION, + link3: URLS.ORDER_HISTORY, + }, + { + title: "모던마스터즈", + manu1: "브랜드 소개", + manu2: "브랜드 상품 스토어", + manu3: null, + link1: URLS.MODERN_MASTERS, + link2: URLS.PRODUCT_STORE, + link3: null, + }, + { + title: "마이다스메탈", + manu1: "브랜드 소개", + manu2: "브랜드 상품 스토어", + manu3: null, + link1: URLS.MIDAS_METAL, + link2: URLS.PRODUCT_STORE, + link3: null, + }, + ]; + + return ( +
+
+ {/* 왼쪽 메뉴 */} +
+
+
+
문의전화
+
+ AM 09:00 ~ PM 05:00 +
+
+
+ 1566-1000 +
+
+
SYATT
+
+ 사업자 등록번호 : 150-66-100004 | 대표 : 김OO +
+ 호스팅 서비스 : 서버회사 | 통신판매업 신고번호 : 신고번호 +
+ 주소 기입 고객센터 : 고객센터 기입 +
+
+ {personalData.map((data, index) => ( + <> +
+ {data} +
+ + ))} +
+
+ + {/* 중앙 메뉴 */} +
+ {pagesData.map(data => ( + <> +
    +
  • + {data.title} +
  • +
  • + + {data.manu1 === null ? "" : "• " + data.manu1} + +
  • +
  • + + {data.manu2 === null ? "" : "• " + data.manu2} + +
  • +
  • + + {data.manu3 === null ? "" : "• " + data.manu3} + +
  • +
+ + ))} +
+ + {/* 오른쪽 메뉴 */} +
+
+ {"푸터로고"} +
+
+
+ {"푸터로고"} +
+
+ {"푸터로고"} +
+
+ {"푸터로고"} +
+
+
+ © 2023. SYATT All rights reserved. +
+
+
+
+ ); +} diff --git a/src/redux/slice/cartSlice.ts b/src/redux/slice/cartSlice.ts index 9d1aba2..0099c85 100644 --- a/src/redux/slice/cartSlice.ts +++ b/src/redux/slice/cartSlice.ts @@ -149,9 +149,8 @@ const cartSlice = createSlice({ ALTERNATE_CHECKED_ITEMS: (state, action) => { const productIndex = state.cartItems.findIndex( - item => item.key === action.payload.key, + item => item.key === action.payload.id, ); - state.cartItems[productIndex].isChecked = !state.cartItems[productIndex].isChecked; From 77d14114906df437b0618bdeeeb4ddc67612737b Mon Sep 17 00:00:00 2001 From: KimGyeongwon Date: Fri, 10 Jan 2025 23:55:15 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20vercel=20SpeedInsight=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 35 ++++++++++++++ package.json | 1 + src/app/layout.tsx | 114 +++++++++++++++++++++++---------------------- 3 files changed, 94 insertions(+), 56 deletions(-) diff --git a/package-lock.json b/package-lock.json index 005d619..6c80c95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@tosspayments/payment-sdk": "^1.6.1", "@tosspayments/payment-widget-sdk": "^0.10.1", "@types/react-responsive": "^8.0.8", + "@vercel/speed-insights": "^1.1.0", "axios": "^1.6.7", "dayjs": "^1.11.10", "eslint": "8.41.0", @@ -2435,6 +2436,40 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vercel/speed-insights": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-1.1.0.tgz", + "integrity": "sha512-rAXxuhhO4mlRGC9noa5F7HLMtGg8YF1zAN6Pjd1Ny4pII4cerhtwSG4vympbCl+pWkH7nBS9kVXRD4FAn54dlg==", + "hasInstallScript": true, + "peerDependencies": { + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, "node_modules/@vercel/stega": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@vercel/stega/-/stega-0.1.0.tgz", diff --git a/package.json b/package.json index b509c06..70a9493 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@tosspayments/payment-sdk": "^1.6.1", "@tosspayments/payment-widget-sdk": "^0.10.1", "@types/react-responsive": "^8.0.8", + "@vercel/speed-insights": "^1.1.0", "axios": "^1.6.7", "dayjs": "^1.11.10", "eslint": "8.41.0", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 263f2b1..007a489 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,56 +1,58 @@ -import "./globals.css"; -import { EB_Garamond, Crimson_Pro, Noto_Sans_KR } from "next/font/google"; -import Footer from "@/layouts/footer/Footer"; -import Providers from "./Providers"; -import Header from "@/layouts/header/Header"; - -export const garamond = EB_Garamond({ - subsets: ["latin"], - weight: ["700", "600", "500", "400"], - variable: "--font-garamond", - display: "swap", -}); - -export const crimson = Crimson_Pro({ - subsets: ["latin"], - weight: ["700", "600", "500", "400", "300", "200"], - variable: "--font-crimson", - display: "swap", -}); - -export const notoSansKR = Noto_Sans_KR({ - subsets: ["latin"], - weight: ["900", "700", "500", "400", "300"], - variable: "--font-noto-sans-kr", - display: "swap", -}); - -export const metadata = { - title: "Syatt", - description: - "SYATT 특수페인팅 세계 독특하고 창의적인 페인팅 기술의 아름다운 세계에서 새로운 예술과 디자인을 경험해보세요.", -}; - -interface RootLayoutProps { - children: React.ReactNode; -} - -export default async function RootLayout({ children }: RootLayoutProps) { - return ( - - - -
-
- {children} -
-