상품이 없습니다.
Loading...
}
+ {productList &&
+ productList.map((item, index) => (
+
+
+ checkedItem.product.id === item.product.id)}
+ onChange={() => {
+ if (checkedList.some((checkedItem) => checkedItem.product.id === item.product.id)) {
+ setCheckedList(checkedList.filter((checkedItem) => checkedItem.product.id !== item.product.id));
+ } else {
+ setCheckedList([...checkedList, item]);
+ }
+ }}
+ />
+
+
+ handleChangeClick(item)}
+ className='block w-3/4 px-4 py-2 text-base text-white duration-300 ease-in-out bg-blue-500 rounded-md hover:scale-105'
+ >
+ 수정
+
+
+
+ handleDelete(item.product.id)}
+ className='block w-3/4 px-4 py-2 text-base text-white duration-300 ease-in-out bg-red-500 rounded-md hover:scale-105'
+ >
+ 삭제
+
+
+
{item.product.id}
+
{item.product.product_code}
+
{item.product.name}
+
+
{item.product.status === 'Y' ? '판매중' : '판매중지'}
+
+ {
+ setQuantitPopupData(item);
+ handleShowPopup();
+ }}
+ className='block rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 재고확인
+
+
+
+ {item.product.price.toString().replace(/\B(?
+
+ {item.product.discount.toString().replace(/\B(?
+
+ {(item.product.price - item.product.discount)
+ .toString()
+ .replace(/\B(?
+
+ ))}
+
+
+ {
+ if (checkedList.length === 0) {
+ alert('상품을 선택해주세요.');
+ return;
+ }
+ handlePatchStatus(
+ checkedList.map((item) => item.product.id),
+ 'N'
+ );
+ }}
+ className='block w-1/4 rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 판매중지
+
+ {
+ if (checkedList.length === 0) {
+ alert('상품을 선택해주세요.');
+ return;
+ }
+ handlePatchStatus(
+ checkedList.map((item) => item.product.id),
+ 'Y'
+ );
+ }}
+ className='block w-1/4 rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 판매중
+
+
+
+
+
+ );
+};
+
+export default ProductList;
diff --git a/src/pages/admin/product/components/QuantitPopup.tsx b/src/pages/admin/product/components/QuantitPopup.tsx
new file mode 100644
index 0000000..c35547d
--- /dev/null
+++ b/src/pages/admin/product/components/QuantitPopup.tsx
@@ -0,0 +1,80 @@
+import { useState } from 'react';
+import { Sizes } from '../type';
+
+const QuantitPopup = ({ onClose, quantitPopupData }: { onClose: () => void; quantitPopupData: any }) => {
+ const [color, setColor] = useState(quantitPopupData.options[0] || null);
+ return (
+
+
+
+
{quantitPopupData?.product.name}
+
+ {quantitPopupData.options.map((data: any) => {
+ if (quantitPopupData.options.length === 0) return;
+ return (
+
+ setColor(data)}>
+ {data.color}
+
+
+ );
+ })}
+
+
+
색상정보
+
+ 이름
+ {(color && color.color) || '-'}
+
+
+ 코드
+ {color?.color_code ? (
+
+ ) : undefined}
+
+ {(color && color.color_code) || '-'}
+
+
사이즈 및 재고
+
+
+
+
+ 사이즈
+ 재고
+
+
+
+ {quantitPopupData.options.length === 0 ? (
+
+ -
+ -
+
+ ) : (
+ quantitPopupData?.options[0].sizes.map((size: Sizes) => {
+ return (
+
+ {size.size}
+ {size.stock}
+
+ );
+ })
+ )}
+
+
+
+
+
+ 닫기
+
+
+
+ );
+};
+
+export default QuantitPopup;
diff --git a/src/pages/admin/product/components/SizeArray.tsx b/src/pages/admin/product/components/SizeArray.tsx
new file mode 100644
index 0000000..3b32c13
--- /dev/null
+++ b/src/pages/admin/product/components/SizeArray.tsx
@@ -0,0 +1,52 @@
+import { useFieldArray, UseFormRegister } from 'react-hook-form';
+
+const SizeArray = ({
+ control,
+ colorIndex,
+ register,
+}: {
+ control: any;
+ colorIndex: number;
+ register: UseFormRegister
;
+ errors: any;
+}) => {
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: `colorOptions.${colorIndex}.sizes`,
+ });
+
+ return (
+
+ );
+};
+
+export default SizeArray;
diff --git a/src/pages/admin/product/type.ts b/src/pages/admin/product/type.ts
new file mode 100644
index 0000000..e29f0c0
--- /dev/null
+++ b/src/pages/admin/product/type.ts
@@ -0,0 +1,62 @@
+interface Image {
+ id: number;
+ image_url: string;
+}
+export interface Sizes {
+ size: string;
+ stock: number;
+}
+interface Options {
+ id: number;
+ color: string;
+ color_code: string;
+ images: Image[];
+ sizes: Sizes[];
+}
+
+export interface ProductListType {
+ product: {
+ id: number;
+ name: string;
+ price: number;
+ discount: number;
+ discount_option: 'percent' | 'price';
+ origin_price: number;
+ description: string;
+ detail: string;
+ brand: string;
+ status: string;
+ product_code: string;
+ };
+ options: Options[];
+}
+
+export interface ProductListProps {
+ productListArray: { products: ProductListType[]; total_count: number };
+ isPending: boolean;
+ error: any;
+ pageLimit: number;
+ handleShowPopup: () => void;
+ page: number;
+ setPage: (page: number) => void;
+ setPageLimit: (limit: number) => void;
+ setQuantitPopupData: (data: any) => void;
+ setSearchParams: (params: any) => void;
+}
+export interface ProductFilterProps {
+ pageLimit: number;
+ setSearchParams: (params: any) => void;
+ onSubmit: () => void;
+ searchParams?: URLSearchParams;
+}
+
+export interface ProductFilterFormData {
+ productName?: string;
+ productNumber?: string;
+ sellerProductCode?: string;
+ productStatus?: string;
+ category_id?: string;
+ startDate?: string;
+ endDate?: string;
+ pageLimit?: number;
+}
diff --git a/src/pages/admin/sale/delivery/SaleDelivery.tsx b/src/pages/admin/sale/delivery/SaleDelivery.tsx
new file mode 100644
index 0000000..72b4ffc
--- /dev/null
+++ b/src/pages/admin/sale/delivery/SaleDelivery.tsx
@@ -0,0 +1,18 @@
+import ProductStatusDashboard from '../../components/ProductStatusDashboard';
+import DeliveryFilter from './components/DeliveryFilter';
+import DeliveryList from './components/DeliveryList';
+const productStatusArray = [
+ { title: '배송 중', count: 0 },
+ { title: '배송 완료', count: 0 },
+];
+const SaleDelivery = () => {
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+export default SaleDelivery;
diff --git a/src/pages/admin/sale/delivery/components/DeliveryFilter.tsx b/src/pages/admin/sale/delivery/components/DeliveryFilter.tsx
new file mode 100644
index 0000000..f64db02
--- /dev/null
+++ b/src/pages/admin/sale/delivery/components/DeliveryFilter.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react';
+import { useForm, FormProvider } from 'react-hook-form';
+import arrowDropDown from '@/assets/icons/arrowDropDown.svg';
+import arrowDropUp from '@/assets/icons/arrowDropUp.svg';
+import DatePickInputs from '@/pages/admin/components/DatePickInputs';
+
+const DeliveryFilter = () => {
+ const methods = useForm();
+ const { handleSubmit, register, reset } = methods;
+
+ const handlerSubmit = (data: any) => console.log(data);
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
+
+ );
+};
+
+export default DeliveryFilter;
diff --git a/src/pages/admin/sale/delivery/components/DeliveryList.tsx b/src/pages/admin/sale/delivery/components/DeliveryList.tsx
new file mode 100644
index 0000000..c568047
--- /dev/null
+++ b/src/pages/admin/sale/delivery/components/DeliveryList.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useState } from 'react';
+import { SectionBox } from '@/pages/admin/components/SectionBox';
+import ListHeader from '@/pages/admin/components/ListHeader';
+import { DeliveryListType } from '../type';
+import { useQuery } from '@tanstack/react-query';
+import { orderApi } from '@/api';
+
+const ListHeaderArray = [
+ { className: 'w-1/12', title: '체크박스' },
+ { className: 'basis-full', title: '주문번호' },
+ { className: 'basis-full', title: '상품주문번호' },
+ { className: 'basis-full', title: '발송처리일' },
+ { className: 'basis-full', title: '배송속성' },
+ { className: 'basis-full', title: '클레임상태' },
+ { className: 'basis-full', title: '택배사' },
+ { className: 'basis-full', title: '송장번호' },
+];
+const productListArray: DeliveryListType[] = [
+ {
+ id: 0,
+ orderNumber: '주문번호',
+ productOrderNumber: '상품주문번호',
+ orderDate: '발송처리일',
+ orderStatus: '배송속성',
+ claimStatus: '클레임상태',
+ deliveryCompany: 'cj대한통운',
+ invoiceNumber: '1234567890',
+ },
+];
+const DeliveryList = () => {
+ const [checkedList, setCheckedList] = useState([]);
+ const { data: orderSearchData, refetch } = useQuery({
+ queryKey: ['orderSearch'],
+ queryFn: async () => {
+ const response = await orderApi.getPageType('SHIPPING');
+ if (!response) return null;
+ return response.data;
+ },
+ });
+ useEffect(() => {
+ refetch();
+ }, [refetch]);
+ return (
+
+
+
+
+ {orderSearchData && orderSearchData.length === 0 ? (
+
주문이 없습니다.
+ ) : (
+ orderSearchData &&
+ orderSearchData.map((order: any) => {
+ if (orderSearchData.length === 0) return
주문이 없습니다.
;
+ if (order.products.length === 0) return console.log('주문상태에 상품이 포함되어있지 않음');
+ order.products.map((products: any) => {
+ return (
+
+
+ checkedItem.id === products.id)}
+ onChange={() => {
+ if (checkedList.some((checkedItem) => checkedItem.id === products.id)) {
+ setCheckedList(checkedList.filter((checkedItem) => checkedItem.id !== products.id));
+ } else {
+ setCheckedList([...checkedList, products]);
+ }
+ }}
+ disabled={false}
+ />
+
+
+
{order.order_number}
+
{products.id}
+
{order.created_at}
+
{products.shipping.status}
+
{order.claimStatus}
+
{order.shipping.courier}
+
{order.shipping.tracking_number}
+
+ );
+ });
+ })
+ )}
+
+
+ {}}
+ className='block w-2/4 rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 배송속성변경
+
+
+
+
+ );
+};
+
+export default DeliveryList;
diff --git a/src/pages/admin/sale/delivery/type.ts b/src/pages/admin/sale/delivery/type.ts
new file mode 100644
index 0000000..ebcd5a0
--- /dev/null
+++ b/src/pages/admin/sale/delivery/type.ts
@@ -0,0 +1,10 @@
+export type DeliveryListType = {
+ id: number;
+ orderNumber: string;
+ productOrderNumber: string;
+ orderDate: string;
+ orderStatus: string;
+ claimStatus: string;
+ deliveryCompany: string;
+ invoiceNumber: string;
+};
diff --git a/src/pages/admin/sale/ordering/SaleOrdering.tsx b/src/pages/admin/sale/ordering/SaleOrdering.tsx
new file mode 100644
index 0000000..cf1be00
--- /dev/null
+++ b/src/pages/admin/sale/ordering/SaleOrdering.tsx
@@ -0,0 +1,45 @@
+import { useState, useEffect } from 'react';
+import ProductStatusDashboard from '../../components/ProductStatusDashboard';
+import OrderingList from './components/OrderList';
+import OrderStatePopup from './components/OrderStatePopup';
+import { OrderingListType } from './type';
+import { useQuery } from '@tanstack/react-query';
+import { orderApi } from '@/api';
+
+const SaleOrdering = () => {
+ const [checkedList, setCheckedList] = useState([]);
+
+ const [isOpen, setIsOpen] = useState(false);
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'auto';
+ }
+ return () => {
+ document.body.style.overflow = 'auto';
+ };
+ }, [isOpen]);
+ const { data: orderStatisticsData } = useQuery({
+ queryKey: ['orderStatisiecs'],
+ queryFn: async () => {
+ const response = await orderApi.getOrderStatistics();
+ if (!response) return null;
+ return response.data;
+ },
+ staleTime: 1000 * 60,
+ });
+ const productStatusArray = [
+ { title: '신규주문(발주확인 처리 전)', count: orderStatisticsData?.pending_orders },
+ { title: '신규주문(발주확인 처리 후)', count: orderStatisticsData?.shipping_orders },
+ ];
+ return (
+ <>
+
+ setIsOpen(true)} checkedList={checkedList} setCheckedList={setCheckedList} />
+ {isOpen && setIsOpen(false)} checkedList={checkedList} />}
+ >
+ );
+};
+
+export default SaleOrdering;
diff --git a/src/pages/admin/sale/ordering/components/DeliveryCompony.tsx b/src/pages/admin/sale/ordering/components/DeliveryCompony.tsx
new file mode 100644
index 0000000..a806161
--- /dev/null
+++ b/src/pages/admin/sale/ordering/components/DeliveryCompony.tsx
@@ -0,0 +1,72 @@
+import axios from 'axios';
+import { useEffect, useState } from 'react';
+import { useFormContext } from 'react-hook-form';
+import { DeliveryCompanyType } from '../type';
+const sweetTrackerApiKey = import.meta.env.VITE_SWEETTRACKER_API_KEY;
+
+const DeliveryCompony = () => {
+ const [deliveryCompanyList, setDeliveryCompanyList] = useState([]);
+ const { register, watch, setValue } = useFormContext();
+ const deliveryCompany = watch('deliveryCompany');
+ const [isShowList, setIsShowList] = useState(false);
+ const handleCompanyClick = (name: string) => {
+ setValue('deliveryCompany', name);
+ setIsShowList(false);
+ };
+ const handleInputFocus = () => {
+ setIsShowList(true);
+ };
+
+ const handleInputBlur = () => {
+ setTimeout(() => {
+ setIsShowList(false);
+ }, 1000);
+ };
+ useEffect(() => {
+ axios
+ .get(`https://info.sweettracker.co.kr/api/v1/companylist?t_key=${sweetTrackerApiKey}`)
+ .then((data) => {
+ setDeliveryCompanyList(data.data.Company);
+ })
+ .catch(() => {});
+ }, []);
+ const filteredDeliveryCompanyList = deliveryCompanyList.filter((company) => company.Name.includes(deliveryCompany));
+ return (
+
+
+ 택배사
+
+
+ {filteredDeliveryCompanyList.length === 0 &&
검색된 배송사가 없습니다.
}
+ {filteredDeliveryCompanyList.map((company) => (
+
handleCompanyClick(company.Name)} key={company.Code}>
+ {company.Name}
+
+ ))}
+
+
+
+ 송장번호
+
+
+
+ );
+};
+
+export default DeliveryCompony;
diff --git a/src/pages/admin/sale/ordering/components/OrderList.tsx b/src/pages/admin/sale/ordering/components/OrderList.tsx
new file mode 100644
index 0000000..2231973
--- /dev/null
+++ b/src/pages/admin/sale/ordering/components/OrderList.tsx
@@ -0,0 +1,162 @@
+import { SectionBox } from '@/pages/admin/components/SectionBox';
+import ListHeader from '@/pages/admin/components/ListHeader';
+import { OrderingListType } from '../type';
+import { useQuery } from '@tanstack/react-query';
+import { useEffect, useState } from 'react';
+import { orderApi } from '@/api';
+// import Pagination from '@/pages/admin/components/Pagination';
+// import { useSearchParams } from 'react-router-dom';
+
+const ListHeaderArray = [
+ { className: 'w-1/12', title: '체크박스' },
+ { className: 'basis-full', title: '주문번호' },
+ { className: 'basis-full', title: '상품주문번호' },
+ { className: 'basis-full', title: '발주상태' },
+ { className: 'basis-full', title: '택배사' },
+ { className: 'basis-full', title: '송장번호' },
+];
+const OrderingList = ({
+ handleShowPopup,
+ checkedList,
+ setCheckedList,
+}: {
+ handleShowPopup: () => void;
+ checkedList: OrderingListType[];
+ setCheckedList: React.Dispatch>;
+}) => {
+ // const [page, setPage] = useState(1);
+ const [pageLimit] = useState(10);
+
+ const { data: orderSearchData, refetch } = useQuery({
+ queryKey: ['orderSearch'],
+ queryFn: async () => {
+ const response = await orderApi.getPageType('UNPAID');
+ if (!response) return null;
+ return response.data;
+ },
+ });
+ useEffect(() => {
+ refetch();
+ }, [pageLimit, refetch]);
+ const formatDate = (isoString: string) => {
+ const date = new Date(isoString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+
+ return `${year}:${month}:${day} ${hours}:${minutes}:${seconds}`;
+ };
+ return (
+
+
+
+
+
+ {orderSearchData && orderSearchData.length === 0 ? (
+
주문이 없습니다.
+ ) : (
+ orderSearchData &&
+ orderSearchData.map((order: any) => {
+ if (orderSearchData.length === 0) return
주문이 없습니다.
;
+ if (order.products.length === 0) return console.log('주문상태에 상품이 포함되어있지 않음');
+ order.products.map((order: any) => {
+ return (
+
+
+ checkedItem.id === order.id)}
+ onChange={() => {
+ if (checkedList.some((checkedItem) => checkedItem.id === order.id)) {
+ setCheckedList(checkedList.filter((checkedItem) => checkedItem.id !== order.id));
+ } else {
+ setCheckedList([...checkedList, order]);
+ }
+ }}
+ disabled={false}
+ />
+
+
+
{order.order_number}
+
{order.products.id}
+
{formatDate(order.created_at)}
+
{order.shipping.courier}
+
{order.shipping.tracking_number}
+
+ );
+ });
+ })
+ )}
+
+
+ {}}
+ className='block w-2/4 rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 발주확인
+
+ {}}
+ className='block w-2/4 rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 발송처리
+
+ {}}
+ className='block w-2/4 rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 발송지연처리
+
+ {}}
+ className='block w-2/4 rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 판매자 취소 처리
+
+
+
+ 송장입력
+
+
+ 송장수정
+
+
+ {/*
*/}
+
+
+ );
+};
+
+export default OrderingList;
diff --git a/src/pages/admin/sale/ordering/components/OrderStatePopup.tsx b/src/pages/admin/sale/ordering/components/OrderStatePopup.tsx
new file mode 100644
index 0000000..d526cc4
--- /dev/null
+++ b/src/pages/admin/sale/ordering/components/OrderStatePopup.tsx
@@ -0,0 +1,65 @@
+import DeliveryCompony from './DeliveryCompony';
+import { OrderingListType } from '../type';
+import { useForm, FormProvider } from 'react-hook-form';
+
+const OrderStatePopup = ({ onClose, checkedList }: { onClose: () => void; checkedList: OrderingListType[] }) => {
+ const orderNumber = [...new Set(checkedList.map((item) => item.order_number))];
+ const methods = useForm();
+ const { handleSubmit } = methods;
+ const handlerSubmit = (data: any) => console.log(data);
+
+ return (
+
+ );
+};
+
+export default OrderStatePopup;
diff --git a/src/pages/admin/sale/ordering/type.ts b/src/pages/admin/sale/ordering/type.ts
new file mode 100644
index 0000000..3eefd7e
--- /dev/null
+++ b/src/pages/admin/sale/ordering/type.ts
@@ -0,0 +1,14 @@
+export type OrderingListType = {
+ id: number;
+ order_number: string;
+ productOrderNumber: string;
+ orderDate: string;
+ orderStatus: string;
+ depositDueDate: string;
+};
+
+export type DeliveryCompanyType = {
+ Name: string;
+ Code: string;
+ International: boolean;
+};
diff --git a/src/pages/admin/sale/payment/SalePayment.tsx b/src/pages/admin/sale/payment/SalePayment.tsx
new file mode 100644
index 0000000..0e3027b
--- /dev/null
+++ b/src/pages/admin/sale/payment/SalePayment.tsx
@@ -0,0 +1,12 @@
+import PaymentFilter from './components/PaymentFilter';
+import PaymentList from './components/PaymentList';
+const SalePayment = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default SalePayment;
diff --git a/src/pages/admin/sale/payment/components/PaymentFilter.tsx b/src/pages/admin/sale/payment/components/PaymentFilter.tsx
new file mode 100644
index 0000000..0dd84b1
--- /dev/null
+++ b/src/pages/admin/sale/payment/components/PaymentFilter.tsx
@@ -0,0 +1,40 @@
+import { useForm, FormProvider } from 'react-hook-form';
+import DatePickInputs from '@/pages/admin/components/DatePickInputs';
+
+const SaleFilter = () => {
+ const methods = useForm();
+ const { handleSubmit, reset } = methods;
+
+ const handlerSubmit = (data: any) => console.log(data);
+
+ return (
+
+
+
+ );
+};
+
+export default SaleFilter;
diff --git a/src/pages/admin/sale/payment/components/PaymentList.tsx b/src/pages/admin/sale/payment/components/PaymentList.tsx
new file mode 100644
index 0000000..71f8699
--- /dev/null
+++ b/src/pages/admin/sale/payment/components/PaymentList.tsx
@@ -0,0 +1,123 @@
+import { useState, useEffect } from 'react';
+import { SectionBox } from '@/pages/admin/components/SectionBox';
+import ListHeader from '@/pages/admin/components/ListHeader';
+import { PaymentListType } from '../type';
+import { useQuery } from '@tanstack/react-query';
+import { orderApi } from '@/api';
+
+const ListHeaderArray = [
+ { className: 'w-1/12', title: '체크박스' },
+ { className: 'basis-full', title: '주문번호' },
+ { className: 'basis-full', title: '상품주문번호' },
+ { className: 'basis-full', title: '주문일자' },
+ { className: 'basis-full', title: '주문상태' },
+ { className: 'basis-full', title: '입금기한' },
+ { className: 'basis-full', title: '상품번호' },
+ { className: 'basis-full', title: '상품명' },
+ { className: 'basis-full', title: '옵션정보' },
+];
+const productListArray: PaymentListType[] = [
+ {
+ id: 1,
+ orderNumber: '주문번호',
+ productOrderNumber: '상품주문번호',
+ orderDate: '주문일자',
+ orderStatus: '주문상태',
+ depositDueDate: '입금기한',
+ productNumber: '상품번호',
+ productName: '상품명',
+ optionInfo: '옵션정보',
+ },
+ {
+ id: 2,
+ orderNumber: '주문번호',
+ productOrderNumber: '상품주문번호',
+ orderDate: '주문일자',
+ orderStatus: '주문상태',
+ depositDueDate: '입금기한',
+ productNumber: '상품번호',
+ productName: '상품명',
+ optionInfo: '옵션정보',
+ },
+];
+const ProductList = () => {
+ const [checkedList, setCheckedList] = useState([]);
+ const { data: orderSearchData, refetch } = useQuery({
+ queryKey: ['orderSearch'],
+ queryFn: async () => {
+ const response = await orderApi.getPageType('UNPAID');
+ if (!response) return null;
+ return response.data;
+ },
+ });
+ useEffect(() => {
+ refetch();
+ }, [refetch]);
+ useEffect(() => {}, [checkedList]);
+ return (
+
+
+
+
+ {orderSearchData && orderSearchData.length === 0 ? (
+
주문이 없습니다.
+ ) : (
+ orderSearchData &&
+ orderSearchData.map((order: any) => {
+ if (orderSearchData.length === 0) return
주문이 없습니다.
;
+ if (order.products.length === 0) return console.log('주문상태에 상품이 포함되어있지 않음');
+ order.products.map((products: any) => {
+ return (
+
+
+ checkedItem.id === products.id)}
+ onChange={() => {
+ if (checkedList.some((checkedItem) => checkedItem.id === products.id)) {
+ setCheckedList(checkedList.filter((checkedItem) => checkedItem.id !== products.id));
+ } else {
+ setCheckedList([...checkedList, products]);
+ }
+ }}
+ disabled={false}
+ />
+
+
+
{order.order_number}
+
{products.product_id}
+
{order.orderDate}
+
{products.created_at}
+
{products.shipping.updated_at}
+
{products.product_id}
+
{products.product_name}
+
{products.optionInfo}
+
+ );
+ });
+ })
+ )}
+
+
+ {}}
+ className='block w-2/4 rounded-md border-[1px] border-neutral-200 bg-white px-4 py-2 text-base text-black duration-300 ease-in-out hover:scale-105 hover:bg-black hover:text-white'
+ >
+ 판매자 주문취소
+
+
+
+
+ );
+};
+
+export default ProductList;
diff --git a/src/pages/admin/sale/payment/type.ts b/src/pages/admin/sale/payment/type.ts
new file mode 100644
index 0000000..b3784c2
--- /dev/null
+++ b/src/pages/admin/sale/payment/type.ts
@@ -0,0 +1,11 @@
+export interface PaymentListType {
+ id: number;
+ orderNumber: string;
+ productOrderNumber: string;
+ orderDate: string;
+ orderStatus: string;
+ depositDueDate: string;
+ productNumber: string;
+ productName: string;
+ optionInfo: string;
+}
diff --git a/src/pages/admin/sale/search/SaleSearch.tsx b/src/pages/admin/sale/search/SaleSearch.tsx
new file mode 100644
index 0000000..b76e337
--- /dev/null
+++ b/src/pages/admin/sale/search/SaleSearch.tsx
@@ -0,0 +1,47 @@
+import { useQuery } from '@tanstack/react-query';
+import SaleFilter from './components/SaleFilter';
+import SaleList from './components/SaleList';
+import { orderApi } from '@/api';
+import { useEffect, useState } from 'react';
+import { useSearchParams } from 'react-router-dom';
+export const SaleSearch = () => {
+ const [page, setPage] = useState(1);
+ const [pageLimit, setPageLimit] = useState(10);
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const {
+ data: orderSearchData,
+ isPending,
+ error,
+ refetch,
+ } = useQuery({
+ queryKey: ['orderSearch', searchParams.toString(), pageLimit],
+ queryFn: async () => {
+ const response = await orderApi.getOrderSearch(searchParams);
+ if (!response) return null;
+ return response.data;
+ },
+ });
+
+ useEffect(() => {
+ refetch();
+ }, [pageLimit, refetch]);
+ return (
+ <>
+ refetch()} pageLimit={pageLimit} />
+
+ >
+ );
+};
+
+export default SaleSearch;
diff --git a/src/pages/admin/sale/search/components/SaleFilter.tsx b/src/pages/admin/sale/search/components/SaleFilter.tsx
new file mode 100644
index 0000000..75679ee
--- /dev/null
+++ b/src/pages/admin/sale/search/components/SaleFilter.tsx
@@ -0,0 +1,82 @@
+import { useState } from 'react';
+import { useForm, FormProvider } from 'react-hook-form';
+import arrowDropDown from '@/assets/icons/arrowDropDown.svg';
+import arrowDropUp from '@/assets/icons/arrowDropUp.svg';
+import DatePickInputs from '@/pages/admin/components/DatePickInputs';
+import { ProductFilterProps } from '@/pages/admin/product/type';
+
+const SaleFilter = ({ setSearchParams, onSubmit, pageLimit }: ProductFilterProps) => {
+ const searchParamsData = new URLSearchParams();
+ const methods = useForm();
+ const { handleSubmit, register, reset } = methods;
+ const handlerSubmit = (data: any) => {
+ switch (data.searchType) {
+ case 'orderNumber':
+ searchParamsData.append('order_number', data.searchKeyword);
+ break;
+ }
+ if (data.startDate) searchParamsData.append('start_date', data.startDate);
+ if (data.endDate) searchParamsData.append('end_date', data.endDate);
+
+ searchParamsData.append('page', '1');
+ searchParamsData.append('page_size', String(pageLimit));
+ searchParamsData.append('sort', 'created_at');
+ setSearchParams(searchParamsData);
+ onSubmit();
+ };
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+
+
+
+ );
+};
+
+export default SaleFilter;
diff --git a/src/pages/admin/sale/search/components/SaleList.tsx b/src/pages/admin/sale/search/components/SaleList.tsx
new file mode 100644
index 0000000..1b2e1fe
--- /dev/null
+++ b/src/pages/admin/sale/search/components/SaleList.tsx
@@ -0,0 +1,73 @@
+import ListHeader from '@/pages/admin/components/ListHeader';
+import Pagination from '@/pages/admin/components/Pagination';
+import { SectionBox } from '@/pages/admin/components/SectionBox';
+
+const ListHeaderArray = [
+ { className: 'basis-full', title: '주문번호' },
+ { className: 'basis-full', title: '상품주문번호' },
+ { className: 'basis-full', title: '주문일시' },
+ { className: 'basis-full', title: '주문상태' },
+ { className: 'basis-full', title: '배송속성' },
+ { className: 'basis-full', title: '클레임상태' },
+ { className: 'basis-full', title: '상품번호' },
+ { className: 'basis-full', title: '상품명' },
+ { className: 'basis-full', title: '옵션정보' },
+];
+
+const SaleList = ({ orderSearchData, page, setPage, pageLimit, setPageLimit, setSearchParams }: any) => {
+ const formatDate = (isoString: string) => {
+ const date = new Date(isoString);
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const hours = String(date.getHours()).padStart(2, '0');
+ const minutes = String(date.getMinutes()).padStart(2, '0');
+ const seconds = String(date.getSeconds()).padStart(2, '0');
+
+ return `${year}:${month}:${day} ${hours}:${minutes}:${seconds}`;
+ };
+ const orderSearchOrders = orderSearchData?.orders ? orderSearchData?.orders : undefined;
+ return (
+
+
+
+
+ {orderSearchOrders &&
+ orderSearchOrders.map((order: any) => {
+ if (orderSearchOrders.length === 0 || orderSearchOrders == undefined) return
주문이 없습니다.
;
+ if (order.products.length === 0) return console.log('주문상태에 상품이 포함되어있지 않음');
+ order.products.map((item: any) => {
+ return (
+
+
{order.order_number}
+
{item.id}
+
{formatDate(order.created_at)}
+
{order.order_status}
+
{item.shipping_status}
+
{order.claimStatus ? order.claimStatus : '-'}
+
{order.productNumber}
+
{item.product_name}
+
+ {item.option.size}, {item.option.color}
+
+
+ );
+ });
+ })}
+
+
+
+
+ );
+};
+
+export default SaleList;
diff --git a/src/pages/admin/store/BannerPage.tsx b/src/pages/admin/store/BannerPage.tsx
new file mode 100644
index 0000000..ba22939
--- /dev/null
+++ b/src/pages/admin/store/BannerPage.tsx
@@ -0,0 +1,33 @@
+import BannerDataList from './components/BannerDataList';
+import AddBannerOrPromotion from './components/AddBannerOrPromotion';
+import { useState } from 'react';
+import { Banner } from './type';
+
+const BannerPage = () => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editingData, setEditingData] = useState(null);
+
+ const handleEdit = (data: Banner) => {
+ setIsEditing(true);
+ setEditingData(data);
+ };
+
+ const handleEditSubmit = () => {
+ setIsEditing(false);
+ setEditingData(null);
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default BannerPage;
diff --git a/src/pages/admin/store/BestItemPage.tsx b/src/pages/admin/store/BestItemPage.tsx
new file mode 100644
index 0000000..15ef7c6
--- /dev/null
+++ b/src/pages/admin/store/BestItemPage.tsx
@@ -0,0 +1,13 @@
+import BestItemDataList from './components/BestItemDataList';
+import AddBestItemOrMdsChoice from './components/AddBestItemOrMdsChoice';
+
+const BestItemPage = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default BestItemPage;
diff --git a/src/pages/admin/store/MdsChoicePage.tsx b/src/pages/admin/store/MdsChoicePage.tsx
new file mode 100644
index 0000000..813d90b
--- /dev/null
+++ b/src/pages/admin/store/MdsChoicePage.tsx
@@ -0,0 +1,13 @@
+import MdsChoiceDataList from './components/MdsChoiceDataList';
+import AddBestItemOrMdsChoice from './components/AddBestItemOrMdsChoice';
+
+const MdsChoicePage = () => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default MdsChoicePage;
diff --git a/src/pages/admin/store/PromotionPage.tsx b/src/pages/admin/store/PromotionPage.tsx
new file mode 100644
index 0000000..8af820d
--- /dev/null
+++ b/src/pages/admin/store/PromotionPage.tsx
@@ -0,0 +1,33 @@
+import { useState } from 'react';
+import AddBannerOrPromotion from './components/AddBannerOrPromotion';
+import PromotionDataList from './components/PromotionDataList';
+import { Banner } from './type';
+
+const PromotionPage = () => {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editingData, setEditingData] = useState(null);
+
+ const handleEdit = (data: Banner) => {
+ setIsEditing(true);
+ setEditingData(data);
+ };
+
+ const handleEditSubmit = () => {
+ setIsEditing(false);
+ setEditingData(null);
+ };
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default PromotionPage;
diff --git a/src/pages/admin/store/components/AddBannerOrPromotion.tsx b/src/pages/admin/store/components/AddBannerOrPromotion.tsx
new file mode 100644
index 0000000..e3ab243
--- /dev/null
+++ b/src/pages/admin/store/components/AddBannerOrPromotion.tsx
@@ -0,0 +1,164 @@
+import { client } from '@/api/client';
+import { Input } from '@/components/Input';
+import { useMutation } from '@tanstack/react-query';
+import { Plus } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { Banner } from '../type';
+
+type BannerFormData = {
+ title: string;
+ eventUrl: string;
+ subTitle: string;
+ image: File[];
+};
+
+type locationType = 'banner' | 'promotion';
+
+type Props = {
+ location: locationType;
+ isEditing: boolean;
+ editingData: Banner | null;
+ onEditSubmit: () => void;
+};
+
+const AddBannerOrPromotion = ({ location, isEditing, editingData, onEditSubmit }: Props) => {
+ const [imagePreview, setImagePreview] = useState(null);
+ const {
+ handleSubmit,
+ register,
+ watch,
+ setValue,
+ formState: { errors },
+ } = useForm();
+ const image = watch('image');
+
+ const mutation = useMutation({
+ mutationFn: async (formData) => {
+ const url = isEditing ? `banners/${editingData?.id}` : 'banners';
+ const method = isEditing ? 'patch' : 'post';
+ const { data } = await client({
+ url,
+ method,
+ data: formData,
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+ return data;
+ },
+ onSuccess: (data) => {
+ console.log('성공', data);
+ alert(isEditing ? '수정되었습니다.' : '등록되었습니다.');
+ onEditSubmit();
+ },
+ onError: (error) => {
+ console.log('에러', error);
+ alert(isEditing ? '수정에 실패했습니다.' : '등록에 실패했습니다.');
+ },
+ });
+
+ const handlerSubmit = (data: BannerFormData) => {
+ const formData = new FormData();
+ formData.append('title', data.title);
+ formData.append('sub_title', data.subTitle);
+ formData.append('event_url', data.eventUrl);
+ formData.append('category_type', location);
+
+ if (data.image && data.image.length > 0) {
+ Array.from(data.image).forEach((file) => {
+ formData.append('image', file);
+ });
+ }
+
+ mutation.mutate(formData);
+ };
+
+ useEffect(() => {
+ if (isEditing && editingData) {
+ const { title, sub_title, event_url, image_url } = editingData;
+ setValue('title', title);
+ setValue('subTitle', sub_title);
+ setValue('eventUrl', event_url);
+ setImagePreview(image_url);
+ }
+ }, [isEditing, editingData, setValue]);
+
+ useEffect(() => {
+ if (image && image[0]) {
+ const file = image[0];
+ setImagePreview(URL.createObjectURL(file));
+ }
+ }, [image]);
+
+ return (
+
+
{location === 'banner' ? '배너' : '프로모션'}
+
+
+ );
+};
+
+export default AddBannerOrPromotion;
diff --git a/src/pages/admin/store/components/AddBestItemOrMdsChoice.tsx b/src/pages/admin/store/components/AddBestItemOrMdsChoice.tsx
new file mode 100644
index 0000000..1b6e98c
--- /dev/null
+++ b/src/pages/admin/store/components/AddBestItemOrMdsChoice.tsx
@@ -0,0 +1,69 @@
+import { client } from '@/api/client';
+import { Input } from '@/components/Input';
+import { useMutation } from '@tanstack/react-query';
+import { useForm } from 'react-hook-form';
+
+type FormValues = {
+ productCode: string;
+};
+
+type Props = {
+ location: 'best' | 'md_pick';
+};
+
+const AddBestItemOrMdsChoice = ({ location }: Props) => {
+ const methods = useForm();
+ const {
+ handleSubmit,
+ register,
+ formState: { errors },
+ } = methods;
+
+ const mutation = useMutation({
+ mutationFn: async (data: FormValues) => {
+ const response = await client.post('/promotion-products/add', {
+ promotion_type: location,
+ is_active: true,
+ product_code: data.productCode,
+ });
+
+ return response;
+ },
+ onSuccess: () => {
+ alert('등록되었습니다.');
+ },
+ onError: (error) => {
+ console.error(error);
+ alert('등록에 실패했습니다.');
+ },
+ });
+
+ const handlerSubmit = (data: FormValues) => {
+ mutation.mutate(data);
+ };
+
+ return (
+
+ );
+};
+
+export default AddBestItemOrMdsChoice;
diff --git a/src/pages/admin/store/components/BannerDataList.tsx b/src/pages/admin/store/components/BannerDataList.tsx
new file mode 100644
index 0000000..1266d94
--- /dev/null
+++ b/src/pages/admin/store/components/BannerDataList.tsx
@@ -0,0 +1,131 @@
+import { Link } from 'react-router-dom';
+import { Banner } from '../type';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { homeApi } from '@/api';
+import { client } from '@/api/client';
+
+const TableHeadArray = [
+ { className: 'w-2/12', title: '제목' },
+ { className: 'w-2/12', title: '소제목' },
+ { className: 'w-2/12', title: '링크' },
+ { className: 'w-full', title: '이미지' },
+ { className: 'w-2/12', title: '비노출/노출' },
+ { className: 'w-2/12', title: '삭제/수정' },
+];
+
+type Props = {
+ onEdit: (data: Banner) => void;
+};
+
+const BannerDataList = ({ onEdit }: Props) => {
+ const {
+ data: bannerList,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['bannerList'],
+ queryFn: async () => {
+ const response = await homeApi.getBannersOrPromotions({ type: 'banner' });
+ return response.data;
+ },
+ });
+
+ const toggleMutation = useMutation({
+ mutationFn: async (id: number) => {
+ const response = await client.patch(`/banners/${id}/toggle`);
+ return response.data;
+ },
+ onSuccess: (data) => {
+ console.log('data:', data);
+ alert('성공적으로 변경되었습니다.');
+ },
+ onError: (error) => {
+ console.log(error);
+ alert('오류가 발생했습니다.');
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (id: number) => {
+ const response = await client.delete(`/banners/${id}`);
+ return response.data;
+ },
+ onSuccess: (data) => {
+ console.log('data:', data);
+ alert('성공적으로 삭제되었습니다.');
+ },
+ onError: (error) => {
+ console.log(error);
+ alert('삭제에 실패했습니다.');
+ },
+ });
+
+ if (isLoading) return null;
+ if (isError) return null;
+
+ console.log(bannerList.items);
+
+ return (
+
+
배너목록 총({bannerList.length}개)
+
+
+ );
+};
+
+export default BannerDataList;
diff --git a/src/pages/admin/store/components/BestItemDataList.tsx b/src/pages/admin/store/components/BestItemDataList.tsx
new file mode 100644
index 0000000..1d4b833
--- /dev/null
+++ b/src/pages/admin/store/components/BestItemDataList.tsx
@@ -0,0 +1,107 @@
+import { Link } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { homeApi } from '@/api';
+import { SectionDataType } from './MdsChoiceDataList';
+import { client } from '@/api/client';
+
+const TableHeadArray = [
+ { className: 'w-2/12', title: '제목' },
+ { className: 'w-2/12', title: '소제목' },
+ { className: 'w-2/12', title: '링크' },
+ { className: 'w-full', title: '이미지' },
+ { className: 'w-2/12', title: '비노출/노출' },
+ { className: 'w-2/12', title: '삭제/수정' },
+];
+
+const BestItemDataList = () => {
+ const queryClient = useQueryClient();
+
+ const {
+ data: bestItemList,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['bestItemList'],
+ queryFn: async () => {
+ const response = await homeApi.getBestProductOrMdsChoice({ type: 'best' });
+ return response.data;
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (id: string) => {
+ const response = await client.delete('/promotion-products/delete', {
+ params: {
+ product_code: id,
+ promotion_type: 'best',
+ },
+ });
+ return response.data;
+ },
+ onSuccess: (data) => {
+ console.log('data:', data);
+ alert('성공적으로 삭제되었습니다.');
+ queryClient.invalidateQueries({ queryKey: ['bestItemList'] });
+ },
+ onError: (error) => {
+ console.log(error);
+ alert('삭제에 실패했습니다.');
+ },
+ });
+
+ if (isLoading || isError) return null;
+
+ console.log(bestItemList.items);
+
+ return (
+
+
BestProduct 목록 총({bestItemList.items.length}개)
+
+
+ );
+};
+
+export default BestItemDataList;
diff --git a/src/pages/admin/store/components/MdsChoiceDataList.tsx b/src/pages/admin/store/components/MdsChoiceDataList.tsx
new file mode 100644
index 0000000..b0e00c4
--- /dev/null
+++ b/src/pages/admin/store/components/MdsChoiceDataList.tsx
@@ -0,0 +1,115 @@
+import { Link } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { homeApi } from '@/api';
+import { client } from '@/api/client';
+
+const TableHeadArray = [
+ { className: 'w-2/12', title: '이름' },
+ { className: 'w-2/12', title: '가격' },
+ { className: 'w-2/12', title: '링크' },
+ { className: 'w-full', title: '이미지' },
+ { className: 'w-2/12', title: '비노출/노출' },
+ { className: 'w-2/12', title: '삭제/수정' },
+];
+
+export type SectionDataType = {
+ id: number;
+ is_active: boolean;
+ price: number;
+ product_id: number;
+ product_name: string;
+ promotion_type: 'best' | 'md_pick';
+ image_url: string;
+ product_code: string;
+};
+
+const MdsChoiceDataList = () => {
+ const queryClient = useQueryClient();
+
+ const {
+ data: mdsChoiceList,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['mdsChoiceList'],
+ queryFn: async () => {
+ const response = await homeApi.getBestProductOrMdsChoice({ type: 'md_pick' });
+ return response.data;
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (product_code: string) => {
+ const response = await client.delete(`/promotion-products/delete`, {
+ params: {
+ product_code,
+ promotion_type: 'md_pick',
+ },
+ });
+ return response.data;
+ },
+ onSuccess: (data) => {
+ console.log('data:', data);
+ alert('성공적으로 삭제되었습니다.');
+ queryClient.invalidateQueries({ queryKey: ['mdsChoiceList'] });
+ },
+ onError: (error) => {
+ console.log(error);
+ alert('삭제에 실패했습니다.');
+ },
+ });
+
+ if (isLoading || isError) return null;
+
+ return (
+
+
Md's choice 목록 총({mdsChoiceList.items.length}개)
+
+
+ );
+};
+
+export default MdsChoiceDataList;
diff --git a/src/pages/admin/store/components/PromotionDataList.tsx b/src/pages/admin/store/components/PromotionDataList.tsx
new file mode 100644
index 0000000..3f9a6da
--- /dev/null
+++ b/src/pages/admin/store/components/PromotionDataList.tsx
@@ -0,0 +1,130 @@
+import { Link } from 'react-router-dom';
+import { Banner } from '../type';
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { homeApi } from '@/api';
+import { client } from '@/api/client';
+
+const TableHeadArray = [
+ { className: 'w-2/12', title: '제목' },
+ { className: 'w-2/12', title: '소제목' },
+ { className: 'w-2/12', title: '링크' },
+ { className: 'w-full', title: '이미지' },
+ { className: 'w-2/12', title: '비노출/노출' },
+ { className: 'w-2/12', title: '삭제/수정' },
+];
+
+type Props = {
+ onEdit: (data: Banner) => void;
+};
+
+const PromotionDataList = ({ onEdit }: Props) => {
+ const {
+ data: promotionList,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['promotionList'],
+ queryFn: async () => {
+ const response = await homeApi.getBannersOrPromotions({ type: 'promotion' });
+ return response.data;
+ },
+ });
+
+ const toggleMutation = useMutation({
+ mutationFn: async (id: number) => {
+ const response = await client.patch(`/banners/${id}/toggle`);
+ return response.data;
+ },
+ onSuccess: (data) => {
+ console.log('data:', data);
+ alert('성공적으로 변경되었습니다.');
+ },
+ onError: (error) => {
+ console.log(error);
+ alert('오류가 발생했습니다.');
+ },
+ });
+
+ const deleteMutation = useMutation({
+ mutationFn: async (id: number) => {
+ const response = await client.delete(`/banners/${id}`);
+ return response.data;
+ },
+ onSuccess: (data) => {
+ console.log('data:', data);
+ alert('성공적으로 삭제되었습니다.');
+ },
+ onError: (error) => {
+ console.log(error);
+ alert('삭제에 실패했습니다.');
+ },
+ });
+
+ if (isLoading) return null;
+ if (isError) return null;
+
+ return (
+
+
프로모션 목록 총({promotionList.items.length}개)
+
+
+ );
+};
+
+export default PromotionDataList;
diff --git a/src/pages/admin/store/type.ts b/src/pages/admin/store/type.ts
new file mode 100644
index 0000000..b1be4b1
--- /dev/null
+++ b/src/pages/admin/store/type.ts
@@ -0,0 +1,10 @@
+export interface Banner {
+ id: number;
+ title: string;
+ sub_title: string;
+ event_url: string;
+ image_url: string;
+ is_active: boolean;
+ category_type: string;
+ display_order: number;
+}
diff --git a/src/pages/auth/FindIdCompletePage.tsx b/src/pages/auth/FindIdCompletePage.tsx
new file mode 100644
index 0000000..7310ca5
--- /dev/null
+++ b/src/pages/auth/FindIdCompletePage.tsx
@@ -0,0 +1,46 @@
+import comepleteCheck from '@/assets/icons/completeCheck.svg';
+import { Link, useLocation } from 'react-router-dom';
+
+type FindIdData = {
+ login_id: string;
+ login_type: string[];
+};
+
+const FindIdCompletePage = () => {
+ const location = useLocation();
+ const { data }: { data: FindIdData } = location.state;
+
+ return (
+
+
+
+
회원 정보를 찾았어요!
+
+
+
+
+
+
회원님, 안녕하세요
+
MIC Golf ID
+
{data.login_id}
+
가입된 계정 종류
+ {data.login_type.map((type) => (
+
{type}
+ ))}
+
+
+
+
+
+ 로그인 이동
+
+
+
+
+ );
+};
+
+export default FindIdCompletePage;
diff --git a/src/pages/auth/FindIdPage.tsx b/src/pages/auth/FindIdPage.tsx
new file mode 100644
index 0000000..62e93df
--- /dev/null
+++ b/src/pages/auth/FindIdPage.tsx
@@ -0,0 +1,120 @@
+import { Link, useNavigate } from 'react-router-dom';
+import { useForm } from 'react-hook-form';
+import { Input } from '@/components/Input';
+import { client } from '@/api/client';
+import axios from 'axios';
+
+type FindIdFormData = {
+ name: string;
+ email: string;
+};
+
+const FindIdPage = () => {
+ const navigate = useNavigate();
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ formState: { errors },
+ } = useForm();
+
+ const hadleFindIdClick = async (name: string, email: string) => {
+ try {
+ const response = await client.get('auth/find-id', {
+ params: { name: name, email: email },
+ });
+
+ if (response.status === 200) {
+ navigate('/auth/findId/complete', {
+ state: { data: response.data },
+ });
+ // 이동된 페이지로 데이터를 전송해야함
+ }
+ } catch (error: unknown) {
+ console.error(error);
+
+ if (axios.isAxiosError(error)) {
+ if (error.response) {
+ // 서버가 응답했으나 에러 상태 코드
+ alert('일치하는 회원 정보가 없습니다.');
+ } else if (error.request) {
+ // 요청은 전송되었으나 응답 없음
+ alert('서버와의 통신에 실패했습니다. 인터넷 연결을 확인하세요.');
+ } else {
+ // 기타 Axios 관련 에러
+ alert('믹골프 서버에서 오류가 발생했습니다. 잠시 후 다시 요청해주세요.');
+ }
+ } else {
+ console.error('알 수 없는 에러: ', error);
+ alert('알 수 없는 에러가 발생했습니다.');
+ }
+ }
+ };
+
+ const handleFindIdClick = (data: FindIdFormData) => {
+ hadleFindIdClick(watch('name'), watch('email'));
+ console.log(data);
+ };
+
+ return (
+
+ );
+};
+
+export default FindIdPage;
diff --git a/src/pages/auth/FindPwChangePage.tsx b/src/pages/auth/FindPwChangePage.tsx
new file mode 100644
index 0000000..d021172
--- /dev/null
+++ b/src/pages/auth/FindPwChangePage.tsx
@@ -0,0 +1,91 @@
+import { client } from '@/api/client';
+import { Input } from '@/components/Input';
+import { useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+
+type Pw = {
+ password: string | null;
+ passwordRe: string | null;
+};
+
+const FindPwChangePage = () => {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (searchParams.get('token') === null) {
+ navigate('/', { replace: true });
+ }
+ }, []);
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ formState: { errors },
+ } = useForm();
+
+ const handleChangePwClick = async () => {
+ try {
+ const response = await client.post('/reset-password', {
+ token: searchParams.get('token'),
+ new_password: watch('password'),
+ new_password2: watch('passwordRe'),
+ });
+ if (response.status === 200) {
+ alert('비밀번호가 변경되었습니다');
+ navigate('/auth/signin/complete', { replace: true });
+ }
+ } catch (error) {
+ console.error(error);
+ alert('비밀번호 변경에 실패했습니다. 다시 시도해주세요.');
+ }
+ };
+
+ return (
+
+ {/* section 1 */}
+
비밀번호 재설정
+
+ {/* section 2 */}
+
+
+ );
+};
+
+export default FindPwChangePage;
diff --git a/src/pages/auth/FindPwCompletePage.tsx b/src/pages/auth/FindPwCompletePage.tsx
new file mode 100644
index 0000000..a7e4707
--- /dev/null
+++ b/src/pages/auth/FindPwCompletePage.tsx
@@ -0,0 +1,22 @@
+import completeCheck from '@/assets/icons/completeCheck.svg';
+import { Link } from 'react-router-dom';
+
+const FindPwCompletePage = () => {
+ return (
+
+
+
+
비밀번호 변경이 완료되었습니다!
+
+
+ 로그인 이동
+
+
+ );
+};
+
+export default FindPwCompletePage;
diff --git a/src/pages/auth/FindPwPage.tsx b/src/pages/auth/FindPwPage.tsx
new file mode 100644
index 0000000..4a7f925
--- /dev/null
+++ b/src/pages/auth/FindPwPage.tsx
@@ -0,0 +1,93 @@
+import { Link } from 'react-router-dom';
+import { useForm } from 'react-hook-form';
+import { Input } from '@/components/Input';
+import { client } from '@/api/client';
+
+type UserInfo = {
+ name: string | null;
+ id: string | null;
+};
+
+const FindPwPage = () => {
+ const {
+ register: registerInfo,
+ handleSubmit: handleSubmitInfo,
+ watch,
+ formState: { errors: errorsInfo },
+ } = useForm();
+
+ const handleFindPwClick = async () => {
+ try {
+ const response = await client.post('/auth/request-password-reset-test', {
+ name: watch('name'),
+ login_id: watch('id'),
+ });
+
+ if (response.status === 200) {
+ alert('가입 시 입력하신 이메일 주소로 비밀번호 재설정 링크가 전송되었습니다. 메일을 확인해주세요.');
+ }
+ } catch (error) {
+ console.error(error);
+ alert('일치하는 정보가 없습니다.');
+ }
+ };
+
+ return (
+
+ {/* section 1 */}
+
비밀번호 찾기
+
+ {/* section 2 */}
+
+
+ );
+};
+
+export default FindPwPage;
diff --git a/src/pages/auth/KakaoCallbackPage.tsx b/src/pages/auth/KakaoCallbackPage.tsx
new file mode 100644
index 0000000..8848d19
--- /dev/null
+++ b/src/pages/auth/KakaoCallbackPage.tsx
@@ -0,0 +1,43 @@
+import { client } from '@/api/client';
+import { useEffect } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+
+const KakaoCallbackPage = () => {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ const code = searchParams.get('code');
+ useEffect(() => {
+ const fetchOauthCodePost = async () => {
+ if (!code) {
+ throw new Error('인가 코드가 누락되었습니다.');
+ }
+
+ try {
+ const response = await client.post(
+ '/oauth/kakao',
+ {},
+ {
+ headers: {
+ code,
+ },
+ }
+ );
+ localStorage.setItem('accessToken', response.data.access_token);
+
+ navigate('/');
+ return response.data;
+ } catch (error) {
+ console.error(error);
+ alert('로그인 중 에러가 발생했습니다.');
+ navigate('/auth/signin', { replace: true });
+ }
+ };
+
+ fetchOauthCodePost();
+ }, []);
+
+ return 로그인 처리
;
+};
+
+export default KakaoCallbackPage;
diff --git a/src/pages/auth/NaverCallbackPage.tsx b/src/pages/auth/NaverCallbackPage.tsx
new file mode 100644
index 0000000..7a2c2c1
--- /dev/null
+++ b/src/pages/auth/NaverCallbackPage.tsx
@@ -0,0 +1,44 @@
+import { client } from '@/api/client';
+import { useEffect } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+
+const NaverCallbackPage = () => {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const fetchOauthCodePost = async () => {
+ const code = searchParams.get('code');
+ const state = searchParams.get('state');
+
+ if (!code) {
+ throw new Error('인가 코드가 누락되었습니다.');
+ }
+
+ try {
+ const response = await client.post(
+ `oauth/naver?state=${state}`,
+ {},
+ {
+ headers: {
+ code,
+ },
+ }
+ );
+ localStorage.setItem('accessToken', response.data.access_token);
+
+ navigate('/');
+ return response.data;
+ } catch (error) {
+ console.error(error);
+ alert('로그인 중 에러가 발생했습니다.');
+ navigate('/auth/signin', { replace: true });
+ }
+ };
+
+ fetchOauthCodePost();
+ }, []);
+ return 로그인 처리
;
+};
+
+export default NaverCallbackPage;
diff --git a/src/pages/auth/PasswordResetPage.tsx b/src/pages/auth/PasswordResetPage.tsx
deleted file mode 100644
index 816bf96..0000000
--- a/src/pages/auth/PasswordResetPage.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const PasswordResetPage = () => {
- return PasswordResetPage
;
-};
-
-export default PasswordResetPage;
diff --git a/src/pages/auth/SignInPage.tsx b/src/pages/auth/SignInPage.tsx
index edd486f..cf80809 100644
--- a/src/pages/auth/SignInPage.tsx
+++ b/src/pages/auth/SignInPage.tsx
@@ -1,5 +1,136 @@
+import { Link, useNavigate } from 'react-router-dom';
+import logoWhite from '@/assets/imgs/logoWhite.svg';
+import kakao from '@/assets/icons/kakao.svg';
+import naver from '@/assets/icons/naver.svg';
+import { LoginType } from './types';
+import { useForm } from 'react-hook-form';
+import { Input } from '@/components/Input';
+import { client } from '@/api/client';
+import { useAuthStore } from '@/config/store';
+import { decodeJwt } from '@/utils/decodeJwt';
+
+const { VITE_KAKAO_REST_API_KEY, VITE_KAKAO_REDIRECT_URI, VITE_NAVER_CLIENT_ID, VITE_NAVER_REDIRECT_URI } = import.meta
+ .env;
+
+type SignInFormData = {
+ login_id: string;
+ password: string;
+};
+
const SignInPage = () => {
- return SignInPage
;
+ const navigate = useNavigate();
+ const { setUser } = useAuthStore();
+
+ const methods = useForm();
+ const {
+ handleSubmit,
+ register,
+ formState: { errors },
+ setValue,
+ } = methods;
+
+ const handleSocialLogin = (type: LoginType) => {
+ switch (type) {
+ case 'kakao': {
+ window.location.href = `https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${VITE_KAKAO_REST_API_KEY}&redirect_uri=${VITE_KAKAO_REDIRECT_URI}`;
+ break;
+ }
+ case 'naver': {
+ window.location.href = `https://nid.naver.com/oauth2.0/authorize?response_type=code&client_id=${VITE_NAVER_CLIENT_ID}&redirect_uri=${VITE_NAVER_REDIRECT_URI}&state=${Date.now()}`;
+ break;
+ }
+ }
+ };
+
+ const handleEmailLogin = async (data: SignInFormData) => {
+ try {
+ const response = await client.post('/auth/login', {
+ login_id: data.login_id,
+ password: data.password,
+ });
+
+ const decodeToken = decodeJwt(response.data.access_token);
+ setUser(decodeToken);
+ localStorage.setItem('accessToken', response.data.access_token);
+
+ navigate('/');
+
+ return decodeToken;
+ } catch (error) {
+ console.error(error);
+ setValue('login_id', '');
+ setValue('password', '');
+ alert('로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.');
+ }
+ };
+
+ return (
+
+ {/* section 1 */}
+
로그인
+
+ {/* section 2 */}
+
+
+
+
+ 아이디 찾기
+
+ |
+
+ 비밀번호 찾기
+
+
+
+
+ {/* section 3 */}
+
+
+
+
+
navigate('/auth/signup')} className='flex-1'>
+ 회원가입
+
+
+
+
+
handleSocialLogin('kakao')} className='flex-1'>
+ 카카오 로그인
+
+
+
+
+
handleSocialLogin('naver')} className='flex-1'>
+ 네이버 로그인
+
+
+
+
+
+ );
};
export default SignInPage;
diff --git a/src/pages/auth/SignUpCompletePage.tsx b/src/pages/auth/SignUpCompletePage.tsx
index 111d06c..5b3522e 100644
--- a/src/pages/auth/SignUpCompletePage.tsx
+++ b/src/pages/auth/SignUpCompletePage.tsx
@@ -1,5 +1,30 @@
+import completeCheck from '@/assets/icons/completeCheck.svg';
+import { Link, useLocation } from 'react-router-dom';
+
const SignUpCompletePage = () => {
- return SignUpCompletePage
;
+ const location = useLocation();
+ const { user } = location.state;
+
+ return (
+
+
+
+
회원 가입이 완료 되었습니다.
+
+
+ {user.name} 님의 회원가입을 축하합니다.
+
+
지금 바로 믹골프 회원만의 다양한 혜택을 받아보세요!
+
+
+
+
+
+ 홈페이지 이동
+
+
+
+ );
};
export default SignUpCompletePage;
diff --git a/src/pages/auth/SignUpPage.tsx b/src/pages/auth/SignUpPage.tsx
index b49703c..9fa8a23 100644
--- a/src/pages/auth/SignUpPage.tsx
+++ b/src/pages/auth/SignUpPage.tsx
@@ -1,5 +1,317 @@
+import { client } from '@/api/client';
+import { Input } from '@/components/Input';
+import { useAuthStore } from '@/config/store';
+import { decodeJwt } from '@/utils/decodeJwt';
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { useNavigate } from 'react-router-dom';
+
+type CheckboxType = '개인정보' | '이용약관';
+
+type SignUpFormData = {
+ id: string;
+ name: string;
+ password: string;
+ passwordRe: string;
+ email: string;
+ phone: string;
+ verification: number;
+};
+
const SignUpPage = () => {
- return SignUpPage
;
+ const [selectedCheckbox, setSelectedCheckbox] = useState([]);
+ const [isAllChecked, setIsAllChecked] = useState(false);
+ const navigate = useNavigate();
+ const { setUser } = useAuthStore();
+
+ const {
+ handleSubmit,
+ register,
+ watch,
+ formState: { errors },
+ setError,
+ clearErrors,
+ } = useForm();
+
+ const handleToggle = (type: CheckboxType) => {
+ setSelectedCheckbox((prev) => (prev.includes(type) ? prev.filter((v) => v !== type) : [...prev, type]));
+ };
+
+ const handleToggleAll = () => {
+ setSelectedCheckbox(isAllChecked ? [] : ['개인정보', '이용약관']);
+ setIsAllChecked((prev) => !prev);
+ };
+
+ const handleSignUpSubmit = async (data: SignUpFormData) => {
+ const signUp = async () => {
+ return client.post('auth/sign-up', {
+ name: data.name,
+ email: data.email,
+ phone: data.phone,
+ login_id: data.id,
+ password: data.password,
+ password2: data.passwordRe,
+ });
+ };
+
+ const login = async () => {
+ return client.post('auth/login', { login_id: data.id, password: data.password });
+ };
+
+ try {
+ const signUpResponse = await signUp();
+
+ if (signUpResponse.status === 201) {
+ const loginResponse = await login();
+
+ setUser(decodeJwt(signUpResponse.data));
+ localStorage.setItem('accessToken', loginResponse.data.access_token);
+
+ navigate('/auth/signup/complete', { state: { user: data } });
+ }
+ } catch (error) {
+ console.error('회원가입 중 오류 발생: ', error);
+ }
+ };
+
+ useEffect(() => {
+ selectedCheckbox.includes('개인정보') && selectedCheckbox.includes('이용약관')
+ ? setIsAllChecked(true)
+ : setIsAllChecked(false);
+ }, [selectedCheckbox]);
+
+ const handleDuplicateCheckId = async () => {
+ try {
+ const response = await client.get(`auth/check-login-id?login_id=${watch('id')}`);
+ if (response.status === 422) {
+ setError('id', { type: 'manual', message: '이미 사용중인 아이디입니다.' });
+ } else {
+ clearErrors('id');
+ }
+ } catch (error) {
+ setError('id', { type: 'manual', message: '중복 확인 중 문제가 발생했습니다. 다시 시도해주세요.' });
+ }
+ };
+
+ return (
+
+ {/* section 1 */}
+
회원 정보 입력
+
+ {/* section 2 */}
+
+
+ );
};
export default SignUpPage;
diff --git a/src/pages/auth/types.ts b/src/pages/auth/types.ts
new file mode 100644
index 0000000..c938b14
--- /dev/null
+++ b/src/pages/auth/types.ts
@@ -0,0 +1 @@
+export type LoginType = 'email' | 'kakao' | 'naver';
diff --git a/src/pages/cart/CartPage.tsx b/src/pages/cart/CartPage.tsx
index e8fc1e3..a9981d4 100644
--- a/src/pages/cart/CartPage.tsx
+++ b/src/pages/cart/CartPage.tsx
@@ -1,5 +1,149 @@
+import { useEffect, useState } from 'react';
+import { useCartSelection } from '@/hooks/useCartSelection';
+import useCartCalculations from '@/hooks/useCartCalculations';
+import { useNavigate } from 'react-router-dom';
+import useModalState from '@/hooks/useModalState/useModalState';
+import { useMediaQuery } from 'react-responsive';
+import PaymentModal from './components/PaymentModal';
+import PaymentStickyBox from './components/PaymentStickyBox';
+import CartItem from './components/CartItem';
+import SelectAllCheckBox from './components/SelectAllCheckBox';
+import CartItemSkeleton from './components/skeletons/CartItemSkeleton';
+import { useAuthStore } from '@/config/store';
+import useGetCartItem from '@/hooks/useGetCartItem';
+import { deleteCartItem } from '@/hooks/useDeleteCartItem';
+import { useQueryClient } from '@tanstack/react-query';
const CartPage = () => {
- return CartPage
;
+ const { user } = useAuthStore();
+ const accessToken = localStorage.getItem('accessToken') || '';
+ const { cartItems, isPending, isError, error } = useGetCartItem();
+ const queryClient = useQueryClient();
+ const {
+ cartItemArr,
+ selectedItems,
+ selectedProducts,
+ handleBuyNow,
+ handleCartSelectToggle,
+ handleUpdateCount,
+ handleSelectAll,
+ handleRemoveSelectedItems,
+ handleRemoveSingleItem,
+ selectAll,
+ } = useCartSelection(cartItems);
+ const { totalPrice, totalDeliveryFee } = useCartCalculations(selectedProducts);
+ const navigate = useNavigate();
+ const paymentData = {
+ items: selectedProducts,
+ totalPrice,
+ totalDeliveryFee,
+ };
+ const { handleModalOpen, renderModalContent } = useModalState({ paymentData });
+ const [globalSelectCount, setGlobalSelectCount] = useState(0);
+ const shouldResponsive = useMediaQuery({ maxWidth: '1280px' });
+
+ const handlePayment = async () => {
+ if (user) {
+ navigate('/checkout', {
+ state: paymentData,
+ });
+ } else {
+ handleModalOpen('결제모달');
+ }
+ };
+
+ const handleAsyncDeleteSelectedItems = async () => {
+ try {
+ await Promise.all(selectedProducts.map((item) => deleteCartItem(item.productId, item.optionId, accessToken)));
+ } catch (err) {
+ throw err;
+ } finally {
+ queryClient.invalidateQueries({ queryKey: ['cartItems'] });
+ }
+ };
+
+ useEffect(() => {
+ setGlobalSelectCount(selectedProducts.length);
+ }, [selectedProducts, cartItemArr]);
+
+ return (
+
+ {/* 타이틀 */}
+ 장바구니
+
+
+ {/* 장바구니 헤더 영역 */}
+
+
+
+ 전체선택 ({globalSelectCount})
+
+
+ {
+ handleAsyncDeleteSelectedItems();
+ handleRemoveSelectedItems();
+ }
+ : handleRemoveSelectedItems
+ }
+ >
+ 선택삭제
+
+
+
+ {/* 장바구니 리스트 영역 */}
+
+ {isError && (
+
+ {error?.message}
+
+ )}
+ {isPending && }
+ {!isError &&
+ cartItems &&
+ cartItemArr.map((item, idx) => (
+
+ ))}
+
+
+
+ {/* 결제 창 : 모달타입 OR 배너타입 */}
+ {shouldResponsive ? (
+
+ ) : (
+
+ )}
+
+ {/* 비회원일 경우 뜨는 모달 창 */}
+ {renderModalContent()}
+
+ );
};
export default CartPage;
diff --git a/src/pages/cart/Categories.tsx b/src/pages/cart/Categories.tsx
new file mode 100644
index 0000000..449d55e
--- /dev/null
+++ b/src/pages/cart/Categories.tsx
@@ -0,0 +1,69 @@
+import { categoryApi } from '@/api';
+import { MajorCategory } from '@/assets/dummys/types';
+import { useQuery } from '@tanstack/react-query';
+import { Link } from 'react-router-dom';
+import { CategoryData, CategoryWithSubCategories } from '../admin/components/type';
+
+interface CategoriesProps {
+ activeNav: string | null;
+ categoryData: MajorCategory[];
+}
+
+const Categories = ({ activeNav, categoryData }: CategoriesProps) => {
+ if (activeNav !== 'shop' || categoryData.length === 0) return null;
+
+ const fetchCategoriesWithSubCategories = async () => {
+ // 대분류 가져오기
+ const mainResponse = await categoryApi.getCategory();
+ const mainCategories: CategoryData[] = mainResponse.data;
+
+ // 각 대분류에 해당하는 중분류 가져오기
+ const categoriesWithSubcategories = await Promise.all(
+ mainCategories.map(async (mainCategory) => {
+ const subResponse = await categoryApi.getCategory(mainCategory.id);
+ const subCategories: CategoryData[] = subResponse.data;
+
+ return { ...mainCategory, subCategories };
+ })
+ );
+
+ return categoriesWithSubcategories;
+ };
+
+ const {
+ data: categories,
+ isLoading,
+ isError,
+ } = useQuery({
+ queryKey: ['categoriesWithSubcategories'],
+ queryFn: fetchCategoriesWithSubCategories,
+ });
+
+ if (isLoading) return null;
+ if (isError) return null;
+
+ return (
+
+
+ {categories?.map((category, i) => (
+
+
+ {category.name}
+
+
+ {category.subCategories?.map((subCategory, i) => (
+
+
+ {subCategory.name}
+
+
+ ))}
+
+
+ ))}
+
+
+ );
+};
+
+export default Categories;
diff --git a/src/pages/cart/components/BuyNowButton.tsx b/src/pages/cart/components/BuyNowButton.tsx
new file mode 100644
index 0000000..bb3e68e
--- /dev/null
+++ b/src/pages/cart/components/BuyNowButton.tsx
@@ -0,0 +1,34 @@
+import { CartItemData } from '@/assets/dummys/types';
+import { SignUpModalType } from '@/hooks/useModalState/useModalState';
+
+interface BuyNowButtonProps {
+ data: CartItemData;
+ size?: 's' | 'm' | 'l';
+ handleBuyNow: (itemId: number) => void;
+ handleModalOpen: (type: SignUpModalType) => void;
+}
+
+const BuyNowButton = ({ size = 'm', data, handleModalOpen, handleBuyNow }: BuyNowButtonProps) => {
+ // 사이즈별 클래스 매핑
+ const sizeClasses = {
+ s: 'h-[30px] w-[80px] text-xs',
+ m: 'h-[40px] w-[130px] text-sm',
+ l: 'h-[50px] w-[170px] text-md',
+ };
+
+ const handleClick = () => {
+ handleBuyNow(data.id);
+ handleModalOpen('결제모달');
+ };
+
+ return (
+
+ 바로구매
+
+ );
+};
+
+export default BuyNowButton;
diff --git a/src/pages/cart/components/CartItem.tsx b/src/pages/cart/components/CartItem.tsx
new file mode 100644
index 0000000..cb30faa
--- /dev/null
+++ b/src/pages/cart/components/CartItem.tsx
@@ -0,0 +1,148 @@
+import { Link } from 'react-router-dom';
+import CheckBox from './CheckBox';
+import { SaleProvider } from '@/components/SaleProvider';
+import SaleLabel from '@/components/SaleLabel';
+import SalePrice from '@/components/SalePrice';
+import GlobalCounterBtn from '@/components/GlobalCounterBtn';
+import CloseIco from '@/assets/icons/CloseIco';
+import { CartItemData } from '@/assets/dummys/types';
+import { useMediaQuery } from 'react-responsive';
+import BuyNowButton from './BuyNowButton';
+import { SignUpModalType } from '@/hooks/useModalState/useModalState';
+import useDefaultImage from '@/hooks/useDefaultImage';
+import ImageOnLoadSkeleton from '@/pages/shop/detailPage/components/skeletons/ImageOnLoadSkeleton';
+import DefaultImage from '@/pages/shop/detailPage/components/DefaultImage';
+import { useAuthStore } from '@/config/store';
+import useDeleteCartItem from '@/hooks/useDeleteCartItem';
+
+interface CartItemProps {
+ data: CartItemData;
+ selectedItems: number[];
+ handleBuyNow: (itemId: number) => void;
+ handleModalOpen: (type: SignUpModalType) => void;
+ handleCartSelectToggle: (itemId: number) => void;
+ handleUpdateCount: (id: number, newCount: number) => void;
+ handleRemoveSingleItem: (itemId: number) => void;
+}
+
+const CartItem = ({
+ data,
+ selectedItems,
+ handleBuyNow,
+ handleCartSelectToggle,
+ handleUpdateCount,
+ handleRemoveSingleItem,
+ handleModalOpen,
+}: CartItemProps) => {
+ const accessToken = localStorage.getItem('accessToken') || '';
+ const { user } = useAuthStore();
+ const mutation = useDeleteCartItem(data.productId, data.optionId, accessToken);
+ const isChecked = selectedItems.map(Number).includes(data.id);
+ const isMobile = useMediaQuery({ maxWidth: 468 });
+ const { discount, discountOption, price, originPrice, productId, name, id, image, size, color, stock } = data;
+ const isThumbnailExist = !!image;
+ const { isImageError, handleOnError, isImageLoading, handleOnLoad } = useDefaultImage(isThumbnailExist);
+
+ return (
+
+
+
+
+
+
+ {isImageLoading && }
+ {isThumbnailExist ? (
+
+
+ {isImageError &&
이미지 로드 실패
}
+
+ ) : (
+
+ )}
+
+
+ {!isMobile && (
+ <>
+
+
+
+
{name}
+
+
+
+ [옵션: {color} / {size}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ user ? mutation.mutate() : handleRemoveSingleItem(id);
+ }}
+ >
+
+
+ >
+ )}
+ {isMobile && (
+
+
+
+
+
+
{name}
+
+
+ [옵션: {color} / {size}]
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default CartItem;
diff --git a/src/pages/cart/components/CheckBox.tsx b/src/pages/cart/components/CheckBox.tsx
new file mode 100644
index 0000000..291a26b
--- /dev/null
+++ b/src/pages/cart/components/CheckBox.tsx
@@ -0,0 +1,34 @@
+import CheckIco from '@/assets/icons/CheckIco';
+
+interface CheckBoxProps {
+ handleCartSelectToggle: (itemId: number) => void;
+ itemId: number;
+ isChecked: boolean;
+}
+
+const CheckBox = ({ handleCartSelectToggle, itemId, isChecked }: CheckBoxProps) => {
+ const handleOnChange = () => {
+ handleCartSelectToggle(itemId);
+ };
+
+ return (
+
+
+
+ {isChecked && }
+
+
+ );
+};
+
+export default CheckBox;
diff --git a/src/pages/cart/components/PaymentModal.tsx b/src/pages/cart/components/PaymentModal.tsx
new file mode 100644
index 0000000..2afc875
--- /dev/null
+++ b/src/pages/cart/components/PaymentModal.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useState } from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import PaymentModalToggler from './PaymentModalToggler';
+import { CartItemData } from '@/assets/dummys/types';
+import LoadingSpinner from '@/components/LoadingSpinner';
+
+interface PaymentModalProps {
+ isLoading: boolean;
+ selectedItems: number[];
+ totalPrice: number;
+ totalDeliveryFee: number;
+ globalSelectCount: number;
+ cartItemArr: CartItemData[];
+ handlePayment: () => void;
+}
+
+const PaymentModal = ({
+ isLoading,
+ selectedItems,
+ totalPrice,
+ totalDeliveryFee,
+ globalSelectCount,
+ cartItemArr,
+ handlePayment,
+}: PaymentModalProps) => {
+ const [isOpen, setIsOpen] = useState(true);
+ const [isDisabled, setIsDisabled] = useState(true);
+ const calculateTotal = () => {
+ return totalPrice + totalDeliveryFee;
+ };
+
+ const handlePaymentButton = () => {
+ handlePayment();
+ };
+
+ useEffect(() => {
+ if (cartItemArr.length === 0 || globalSelectCount === 0) {
+ setIsDisabled(true);
+ }
+ if (selectedItems.length > 0) {
+ setIsDisabled(false);
+ }
+ }, [cartItemArr.length, globalSelectCount, selectedItems.length]);
+
+ return (
+ <>
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
총 상품 {globalSelectCount}개
+
+
+ 상품금액
+ ₩{totalPrice.toLocaleString()}
+
+
+ 배송비
+ ₩{totalDeliveryFee.toLocaleString()}
+
+
+
+
+ 결제 예상 금액
+ ₩{calculateTotal().toLocaleString()}
+
+
+ 결제하기
+
+ >
+ )}
+
+
+ {isOpen && (
+ setIsOpen(false)}
+ />
+ )}
+
+ >
+ );
+};
+
+export default PaymentModal;
diff --git a/src/pages/cart/components/PaymentModalToggler.tsx b/src/pages/cart/components/PaymentModalToggler.tsx
new file mode 100644
index 0000000..103c4ab
--- /dev/null
+++ b/src/pages/cart/components/PaymentModalToggler.tsx
@@ -0,0 +1,23 @@
+interface PaymentModalTogglerProps {
+ isOpen: boolean;
+ setIsOpen: (state: boolean) => void;
+}
+
+const PaymentModalToggler = ({ isOpen, setIsOpen }: PaymentModalTogglerProps) => {
+ const handleToggler = () => {
+ setIsOpen(!isOpen);
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+export default PaymentModalToggler;
diff --git a/src/pages/cart/components/PaymentStickyBox.tsx b/src/pages/cart/components/PaymentStickyBox.tsx
new file mode 100644
index 0000000..f514392
--- /dev/null
+++ b/src/pages/cart/components/PaymentStickyBox.tsx
@@ -0,0 +1,86 @@
+import { CartItemData } from '@/assets/dummys/types';
+import LoadingSpinner from '@/components/LoadingSpinner';
+import { useEffect, useState } from 'react';
+
+interface PaymentStickyBoxProps {
+ isLoading: boolean;
+ selectedItems: number[];
+ totalPrice: number;
+ totalDeliveryFee: number;
+ globalSelectCount: number;
+ cartItemArr: CartItemData[];
+ handlePayment: () => void;
+}
+
+const PaymentStickyBox = ({
+ isLoading,
+ selectedItems,
+ totalPrice,
+ totalDeliveryFee,
+ globalSelectCount,
+ cartItemArr,
+ handlePayment,
+}: PaymentStickyBoxProps) => {
+ const [isDisabled, setIsDisabled] = useState(true);
+ const calculateTotal = () => {
+ return totalPrice + totalDeliveryFee;
+ };
+
+ const handlePaymentButton = () => {
+ handlePayment();
+ };
+
+ useEffect(() => {
+ if (cartItemArr.length === 0 || globalSelectCount === 0) {
+ setIsDisabled(true);
+ }
+ if (selectedItems.length > 0) {
+ setIsDisabled(false);
+ }
+ }, [cartItemArr.length, globalSelectCount, selectedItems.length]);
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+
총 상품 {globalSelectCount}개
+
+
+ 상품금액
+ ₩{totalPrice.toLocaleString()}
+
+
+ 배송비
+ ₩{totalDeliveryFee.toLocaleString()}
+
+
+
+
+ 결제 예상 금액
+ ₩{calculateTotal().toLocaleString()}
+
+
+ 결제하기
+
+ >
+ )}
+
+
+ );
+};
+
+export default PaymentStickyBox;
diff --git a/src/pages/cart/components/SelectAllCheckBox.tsx b/src/pages/cart/components/SelectAllCheckBox.tsx
new file mode 100644
index 0000000..c8c0343
--- /dev/null
+++ b/src/pages/cart/components/SelectAllCheckBox.tsx
@@ -0,0 +1,39 @@
+import { CartItemData } from '@/assets/dummys/types';
+import CheckIco from '@/assets/icons/CheckIco';
+import { useEffect, useState } from 'react';
+
+interface SelectAllCheckBoxProps {
+ handleSelectAll: () => void;
+ isChecked: boolean;
+ cartItemArr: CartItemData[];
+}
+
+const SelectAllCheckBox = ({ handleSelectAll, isChecked, cartItemArr }: SelectAllCheckBoxProps) => {
+ const [isDisabled, setIsDisabled] = useState(false);
+
+ useEffect(() => {
+ // 카트 아이템이 비어있으면 체크박스를 비활성화
+ setIsDisabled(cartItemArr.length === 0);
+ }, [cartItemArr]);
+
+ return (
+
+
+
+ {!isDisabled && isChecked && }
+
+
+ );
+};
+
+export default SelectAllCheckBox;
diff --git a/src/pages/cart/components/skeletons/CartItemSkeleton.tsx b/src/pages/cart/components/skeletons/CartItemSkeleton.tsx
new file mode 100644
index 0000000..4bd4557
--- /dev/null
+++ b/src/pages/cart/components/skeletons/CartItemSkeleton.tsx
@@ -0,0 +1,15 @@
+import CartItemSkeletonUI from './CartItemSkeletonUI';
+
+const skeletonArr = [1, 2, 3, 4];
+
+const CartItemSkeleton = () => {
+ return (
+ <>
+ {skeletonArr.map((_, idx) => (
+
+ ))}
+ >
+ );
+};
+
+export default CartItemSkeleton;
diff --git a/src/pages/cart/components/skeletons/CartItemSkeletonUI.tsx b/src/pages/cart/components/skeletons/CartItemSkeletonUI.tsx
new file mode 100644
index 0000000..414e911
--- /dev/null
+++ b/src/pages/cart/components/skeletons/CartItemSkeletonUI.tsx
@@ -0,0 +1,30 @@
+const CartItemSkeletonUI = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default CartItemSkeletonUI;
diff --git a/src/pages/checkout/CheckoutCompletePage.tsx b/src/pages/checkout/CheckoutCompletePage.tsx
new file mode 100644
index 0000000..002ce33
--- /dev/null
+++ b/src/pages/checkout/CheckoutCompletePage.tsx
@@ -0,0 +1,62 @@
+import completeCheck from '@/assets/icons/completeCheck.svg';
+import { useEffect, useState } from 'react';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
+
+const CheckoutCompletePage = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [timer, setTimer] = useState(5);
+ const fromCheckout = location.state?.from === '/checkout';
+ const linkStyle =
+ 'bg-primary px-4 py-3 text-secondary transition-colors duration-700 border border-primary hover:bg-secondary hover:text-primary';
+
+ useEffect(() => {
+ if (!fromCheckout) {
+ alert('잘못된 접근입니다.');
+ navigate('/', { replace: true });
+ }
+ }, [fromCheckout]);
+
+ useEffect(() => {
+ const timerId = setInterval(() => {
+ setTimer((prev) => {
+ if (prev <= 1) {
+ clearInterval(timerId); // 타이머를 정리
+ navigate('/'); // 랜딩 페이지로 이동
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000); // 1초 간격으로 실행
+
+ return () => clearInterval(timerId);
+ }, []);
+
+ if (!fromCheckout) {
+ return null;
+ }
+
+ return (
+
+
+
+
주문을 완료 했습니다
+
+
주문 정보를 확인하세요
+
{timer}초 후 랜딩페이지로 이동합니다.
+
+
+
+
+
+ 홈페이지 이동
+
+
+ 결제내역 이동
+
+
+
+ );
+};
+
+export default CheckoutCompletePage;
diff --git a/src/pages/checkout/CheckoutPage.tsx b/src/pages/checkout/CheckoutPage.tsx
new file mode 100644
index 0000000..38ca022
--- /dev/null
+++ b/src/pages/checkout/CheckoutPage.tsx
@@ -0,0 +1,463 @@
+import { CartItemData } from '@/assets/dummys/types';
+import { useEffect, useState } from 'react';
+import { Address, useDaumPostcodePopup } from 'react-daum-postcode';
+import { FormProvider, useForm } from 'react-hook-form';
+import { useLocation, useNavigate } from 'react-router-dom';
+import dropDownIco from '@/assets/icons/dropDownIco.svg';
+import kakaopay from '@/assets/icons/kakaopay.svg';
+import { Input } from '@/components/Input';
+import PaymentItem from './components/PaymentItem';
+import PaymentItemMobile from './components/PaymentItemMobile';
+import { nanoid } from 'nanoid';
+import * as PortOne from '@portone/browser-sdk/v2';
+
+type CheckboxType = '개인정보' | '이용약관';
+
+type CheckoutFormData = {
+ deliveryRequest: string;
+ detailAddress: string;
+ fullAddress: string;
+ paymentType: string;
+ phoneNumber: string;
+ senderName: string;
+ zoneCode: string;
+ email: string;
+};
+
+const CheckoutPage = () => {
+ const storeId = import.meta.env.VITE_PORTONE_STORE_ID;
+ const channelKey = import.meta.env.VITE_PORTONE_CHANNEL_KEY;
+ const [selectedCheckbox, setSelectedCheckbox] = useState([]);
+ const [isAllChecked, setIsAllChecked] = useState(false);
+ const disabled = selectedCheckbox.includes('개인정보') && selectedCheckbox.includes('이용약관') ? false : true;
+
+ const methods = useForm();
+
+ const {
+ handleSubmit,
+ register,
+ setValue,
+ watch,
+ formState: { errors },
+ } = methods;
+
+ const paymentType = watch('paymentType');
+
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const [loadingflag, setLoadingFlag] = useState(false);
+ const [isVisible, setIsVisible] = useState(false);
+
+ const postcodeScriptUrl = 'https://t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js';
+ const open = useDaumPostcodePopup(postcodeScriptUrl);
+
+ const handleTogglePayment = () => {
+ setIsVisible((prev) => !prev);
+ };
+
+ // INFO: 패키지를 열어보면 Address 타입이 정의되어 있습니다.
+ const handleComplete = (data: Address) => {
+ let fullAddress = data.address;
+ let extraAddress = '';
+ let localAddress = data.sido + ' ' + data.sigungu;
+
+ if (data.addressType === 'R') {
+ if (data.bname !== '') {
+ extraAddress += data.bname;
+ }
+ if (data.buildingName !== '') {
+ extraAddress += extraAddress !== '' ? `, ${data.buildingName}` : data.buildingName;
+ }
+ fullAddress = fullAddress.replace(localAddress, '');
+ fullAddress += extraAddress !== '' ? ` (${extraAddress})` : '';
+ }
+
+ setValue('zoneCode', data.zonecode);
+ setValue('fullAddress', localAddress + fullAddress);
+ };
+
+ const handleFindAddressClick = () => {
+ open({ onComplete: handleComplete });
+ };
+
+ const handleToggle = (type: CheckboxType) => {
+ setSelectedCheckbox((prev) => (prev.includes(type) ? prev.filter((v) => v !== type) : [...prev, type]));
+ };
+
+ const handleToggleAll = () => {
+ setSelectedCheckbox(isAllChecked ? [] : ['개인정보', '이용약관']);
+ setIsAllChecked((prev) => !prev);
+ };
+
+ useEffect(() => {
+ selectedCheckbox.includes('개인정보') && selectedCheckbox.includes('이용약관')
+ ? setIsAllChecked(true)
+ : setIsAllChecked(false);
+ }, [selectedCheckbox]);
+
+ // FIXME: 로케이션 상태로 받아오는게 아니라 주문번호 요청 및 응답을 통해 받아온 데이터로 처리
+ const { items, totalDeliveryFee, totalPrice } = location.state as {
+ items: CartItemData[];
+ totalDeliveryFee: number;
+ totalPrice: number;
+ };
+
+ // INFO: PortOne 결제 로직
+ const handlePaymentClick = async (data: CheckoutFormData) => {
+ const paymentId = `payment-${nanoid()}`; // DB쪽에서 사용하는 결제id 양식이 있는지 확인해야함.
+ const paymentData: PortOne.PaymentRequest = {
+ storeId,
+ channelKey,
+ paymentId,
+ orderName: `${items[0].name}외 ${items.length}건`,
+ totalAmount: 1000, //totalPrice,
+ currency: 'CURRENCY_KRW',
+ payMethod: 'CARD',
+ customer: {
+ customerId: 'example', // 사용자 아이디(string)으로 받아와야함
+ firstName: data.senderName.slice(1), // 사용자 이름
+ lastName: data.senderName[0], // 사용자 성
+ fullName: data.senderName,
+ phoneNumber: data.phoneNumber,
+ email: data.email, // 사용자 이메일 받아와야함
+ address: {
+ addressLine1: data.fullAddress,
+ addressLine2: data.detailAddress,
+ },
+ zipcode: data.zoneCode,
+ },
+ };
+
+ if (data) {
+ setLoadingFlag(true);
+ try {
+ const response = await PortOne.requestPayment(paymentData);
+ console.log(response);
+ navigate('/checkout/success', { state: { from: '/checkout' } });
+ } catch (err) {
+ throw err;
+ } finally {
+ setLoadingFlag(false);
+ }
+ }
+ };
+
+ return (
+ <>
+
+
+ {/* 결제중 loading 모달 */}
+ {loadingflag && (
+
+
+
+
결제 중입니다
+
+ 결제 처리에는 약간의 시간이 소요될 수 있습니다.
+ 잠시만 기다려 주세요.
+
+
+
+ )}
+
+ >
+ );
+};
+
+export default CheckoutPage;
diff --git a/src/pages/checkout/components/PaymentItem.tsx b/src/pages/checkout/components/PaymentItem.tsx
new file mode 100644
index 0000000..9d46ff4
--- /dev/null
+++ b/src/pages/checkout/components/PaymentItem.tsx
@@ -0,0 +1,50 @@
+import { CartItemData } from '@/assets/dummys/types';
+import useDefaultImage from '@/hooks/useDefaultImage';
+import DefaultImage from '@/pages/shop/detailPage/components/DefaultImage';
+import ImageOnLoadSkeleton from '@/pages/shop/detailPage/components/skeletons/ImageOnLoadSkeleton';
+
+const PaymentItem = ({ data, isThumbnailExist }: { data: CartItemData; isThumbnailExist: boolean }) => {
+ const { isImageError, handleOnError, isImageLoading, handleOnLoad } = useDefaultImage(isThumbnailExist);
+ return (
+
+
+ {isImageLoading &&
}
+ {isThumbnailExist ? (
+
+
+ {isImageError &&
이미지 로드 실패
}
+
+ ) : (
+
+
+
+ )}
+
+
+
+
{data.name}
+
+ [옵션: {data.color} / {data.size} / {data.amount}개]
+
+
+
+
+ ₩{data.price.toLocaleString()}
+
+
+ );
+};
+
+export default PaymentItem;
diff --git a/src/pages/checkout/components/PaymentItemMobile.tsx b/src/pages/checkout/components/PaymentItemMobile.tsx
new file mode 100644
index 0000000..a078790
--- /dev/null
+++ b/src/pages/checkout/components/PaymentItemMobile.tsx
@@ -0,0 +1,50 @@
+import { CartItemData } from '@/assets/dummys/types';
+import useDefaultImage from '@/hooks/useDefaultImage';
+import DefaultImage from '@/pages/shop/detailPage/components/DefaultImage';
+import ImageOnLoadSkeleton from '@/pages/shop/detailPage/components/skeletons/ImageOnLoadSkeleton';
+
+const PaymentItemMobile = ({ data, isThumbnailExist }: { data: CartItemData; isThumbnailExist: boolean }) => {
+ const { isImageError, handleOnError, isImageLoading, handleOnLoad } = useDefaultImage(isThumbnailExist);
+ return (
+
+
+ {isImageLoading &&
}
+ {isThumbnailExist ? (
+
+
+ {isImageError &&
이미지 로드 실패
}
+
+ ) : (
+
+
+
+ )}
+
+
+
+
{data.name}
+
+ [옵션: {data.color} / {data.size} / {data.amount}개]
+
+
+
+
+ ₩{data.price.toLocaleString()}
+
+
+ );
+};
+
+export default PaymentItemMobile;
diff --git a/src/pages/event/EventDetailPage.tsx b/src/pages/event/EventDetailPage.tsx
index ac62ad6..fa1c5aa 100644
--- a/src/pages/event/EventDetailPage.tsx
+++ b/src/pages/event/EventDetailPage.tsx
@@ -1,5 +1,84 @@
+import { useState } from 'react';
+
const EventDetailPage = () => {
- return EventDetailPage
;
+ const [currentPage, setCurrentPage] = useState(1);
+ const commentsPerPage = 5;
+
+ const dummyComments = Array.from({ length: 20 }, (_, index) => ({
+ id: index + 1,
+ author: `ID ${index + 1}`,
+ date: `2000.00.00`,
+ content: `댓글작성 ${index + 1}`,
+ }));
+
+ const totalPages = Math.ceil(dummyComments.length / commentsPerPage);
+
+ const handlePageChange = (page: any) => {
+ setCurrentPage(page);
+ };
+
+ return (
+
+ {/* Event Header */}
+
+
제목
+
2024.10.21 ~ 2024.10.21
+
+
+
+ {/* Comment Form */}
+
+
댓글쓰기
+
+
+ 0/500
+
+
등록하기
+
+
+ {/* Comments */}
+
+
댓글
+ {dummyComments.slice((currentPage - 1) * commentsPerPage, currentPage * commentsPerPage).map((comment) => (
+
+
{comment.author}
+
{comment.date}
+
{comment.content}
+
+ ))}
+
+
+ {/* Pagination */}
+
+ handlePageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ className={`p-2 ${currentPage === 1 ? 'text-gray-300' : 'text-black'}`}
+ >
+ <
+
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
+ handlePageChange(page)}
+ className={`px-2 ${page === currentPage ? 'font-bold text-black' : 'text-gray-500'}`}
+ >
+ {page}
+
+ ))}
+ handlePageChange(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ className={`p-2 ${currentPage === totalPages ? 'text-gray-300' : 'text-black'}`}
+ >
+ >
+
+
+
+ );
};
export default EventDetailPage;
diff --git a/src/pages/event/EventMainPage.tsx b/src/pages/event/EventMainPage.tsx
index c7738a3..fad0c7f 100644
--- a/src/pages/event/EventMainPage.tsx
+++ b/src/pages/event/EventMainPage.tsx
@@ -1,5 +1,103 @@
+import { useState } from 'react';
+import ArrowLeft from '@/assets/arrow_L.svg';
+import ArrowRight from '@/assets/arrow_R.svg';
+import DropdownIcon from '@/assets/dropdown.svg';
+import { Link } from 'react-router-dom';
+
const EventMainPage = () => {
- return EventMainPage
;
+ const [currentPage, setCurrentPage] = useState(1);
+ const itemsPerPage = 10;
+ const pageRange = 5;
+
+ const dummyData = Array.from({ length: 120 }, (_, index) => ({
+ id: index + 1,
+ title: `이벤트 ${index + 1}`,
+ date: `2024.11.01 ~ 2024.11.11`,
+ }));
+
+ const totalPages = Math.ceil(dummyData.length / itemsPerPage);
+
+ const handlePageChange = (page: number) => {
+ setCurrentPage(page);
+ };
+
+ // 페이지 범위 계산
+ const getPageRange = () => {
+ const startPage = Math.floor((currentPage - 1) / pageRange) * pageRange + 1;
+ const endPage = Math.min(startPage + pageRange - 1, totalPages);
+ return { startPage, endPage };
+ };
+
+ const { startPage, endPage } = getPageRange();
+
+ return (
+
+
Event
+
+
+
+ 전체({dummyData.length})
+
+
+ 진행중(0)
+
+
+ 종료(0)
+
+
+
+
+
+ 인기순
+
+
+
+
+
+ {dummyData.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage).map((item) => (
+
+
+ {/* 이미지 */}
+
+ {/* 타이틀, 날짜 */}
+
{item.title}
+
{item.date}
+
+
+ ))}
+
+
+
+
handlePageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ className={`p-2 ${currentPage === 1 ? 'text-gray300' : 'text-primary'}`}
+ >
+
+
+
+ {Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i).map((page) => (
+
handlePageChange(page)}
+ className={`px-2 py-1 ${
+ page === currentPage ? 'border-b-2 border-primary font-bold text-primary' : 'text-gray300'
+ }`}
+ >
+ {page}
+
+ ))}
+
+
handlePageChange(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ className={`p-2 ${currentPage === totalPages ? 'text-gray300' : 'text-primary'}`}
+ >
+
+
+
+
+ );
};
export default EventMainPage;
diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx
index b2f0bf4..c26e0fe 100644
--- a/src/pages/home/HomePage.tsx
+++ b/src/pages/home/HomePage.tsx
@@ -1,5 +1,44 @@
+import BannerSection from './components/BannerSection';
+import Section from './components/Section';
+import PromotionSection from './components/PromotionSection';
+import { motion } from 'framer-motion';
+import { Homeimages1, Homeimages2, promotionImage } from '@/assets/dummys/productListDatas';
+// import { useQuery } from '@tanstack/react-query';
+// import { homeApi } from '@/api';
+
const HomePage = () => {
- return HomePage
;
+ // const { data: bestProductData } = useQuery({
+ // queryKey: ['BestProduct'],
+ // queryFn: async () => {
+ // const response = await homeApi.getBestProductOrMdsChoice({ type: 'best' });
+ // return response.data;
+ // },
+ // });
+
+ // const { data: mdsChoiceData } = useQuery({
+ // queryKey: ['mdsChoiceData'],
+ // queryFn: async () => {
+ // const response = await homeApi.getBestProductOrMdsChoice({ type: 'md_pick' });
+ // return response.data;
+ // },
+ // });
+ console.log('메인 페이지 입장');
+
+ return (
+
+ {/* 배너 */}
+
+
+
+
+ {/* 컨텐츠 */}
+
+
+ );
};
export default HomePage;
diff --git a/src/pages/home/components/BannerSection.tsx b/src/pages/home/components/BannerSection.tsx
new file mode 100644
index 0000000..b93e372
--- /dev/null
+++ b/src/pages/home/components/BannerSection.tsx
@@ -0,0 +1,44 @@
+import { Swiper, SwiperSlide } from 'swiper/react';
+import { Pagination, Navigation, Autoplay } from 'swiper/modules';
+import 'swiper/css';
+import 'swiper/css/pagination';
+
+import { bannerImage } from '@/assets/dummys/productListDatas';
+// import { useQuery } from '@tanstack/react-query';
+// import { homeApi } from '@/api';
+
+const BannerSection = () => {
+ // const { data: bannerData } = useQuery({
+ // queryKey: ['banner'],
+ // queryFn: async () => {
+ // const response = await homeApi.getBannersOrPromotions({ type: 'banner' });
+ // return response.data;
+ // },
+ // });
+
+ return (
+
+
+ {bannerImage.map((image, index) => (
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default BannerSection;
diff --git a/src/pages/home/components/Intro.tsx b/src/pages/home/components/Intro.tsx
new file mode 100644
index 0000000..07611a9
--- /dev/null
+++ b/src/pages/home/components/Intro.tsx
@@ -0,0 +1,42 @@
+import { AnimatePresence, motion } from 'framer-motion';
+import logoWhite from '@/assets/imgs/logoWhite.svg';
+
+type IntroProps = {
+ showIntro: boolean;
+};
+
+const Intro = ({ showIntro }: IntroProps) => {
+ return (
+
+ {showIntro && (
+
+
+
+
+ Make It Count
+
+
+
+ )}
+
+ );
+};
+
+export default Intro;
diff --git a/src/pages/home/components/MultipleItems.tsx b/src/pages/home/components/MultipleItems.tsx
new file mode 100644
index 0000000..adad846
--- /dev/null
+++ b/src/pages/home/components/MultipleItems.tsx
@@ -0,0 +1,51 @@
+import { useState, useRef } from 'react';
+import Slider from 'react-slick';
+import 'slick-carousel/slick/slick.css';
+import 'slick-carousel/slick/slick-theme.css';
+
+export default function MultipleItems() {
+ const [activeSlide, setActiveSlide] = useState(0);
+ const sliderRef = useRef(null); // Slider 레퍼런스 생성
+ const pagePerSlide = 4;
+ const slideLength = 19;
+ const totalSlides = Math.ceil(slideLength / pagePerSlide); // Assuming 100/4 (slidesToScroll) = 25 total segments
+
+ const settings = {
+ dots: true,
+ slidesToShow: pagePerSlide,
+ slidesToScroll: pagePerSlide,
+ beforeChange: (_current: number, next: number) => setActiveSlide(next / 4),
+ appendDots: () => (
+
+
+ {Array.from({ length: totalSlides }).map((_, index) => (
+
sliderRef.current?.slickGoTo(index * 4)} // 특정 슬라이드로 이동
+ className={`relative w-full py-6`}
+ >
+
+
+ ))}
+
+
+ ),
+ customPaging: () =>
, // Hide default dots but keep functionality
+ };
+
+ return (
+
+
+
+ {Array.from({ length: slideLength }).map((_, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/pages/home/components/PromotionSection.tsx b/src/pages/home/components/PromotionSection.tsx
new file mode 100644
index 0000000..0689993
--- /dev/null
+++ b/src/pages/home/components/PromotionSection.tsx
@@ -0,0 +1,48 @@
+// import { homeApi } from '@/api';
+// import { useQuery } from '@tanstack/react-query';
+import { motion } from 'framer-motion';
+import { ArrowRight } from 'lucide-react';
+
+type PromotionSectionProps = {
+ image: string;
+};
+
+// WARNING: images 의 데이터 형식이 어떻게 되는지 확인 후 타입 변경이 필요할 수 있음.
+// WARNING: 또는 직접적으로 데이터를 컴포넌트에서 불러오는 방식도 고려해야함.
+const PromotionSection = ({ image }: PromotionSectionProps) => {
+ // const { data: promotionData } = useQuery({
+ // queryKey: ['promotionData'],
+ // queryFn: async () => {
+ // const response = await homeApi.getBannersOrPromotions({ type: 'promotion' });
+ // return response.data;
+ // },
+ // });
+
+ return (
+
+
+
+
+
믹골프 런칭 특별 프로모션
+
놓치면 후회할 특별한 제안
+
+ 바로가기
+
+
+
+
+
+ );
+};
+
+export default PromotionSection;
diff --git a/src/pages/home/components/Section.tsx b/src/pages/home/components/Section.tsx
new file mode 100644
index 0000000..6174675
--- /dev/null
+++ b/src/pages/home/components/Section.tsx
@@ -0,0 +1,101 @@
+import { motion } from 'framer-motion';
+import { Swiper, SwiperSlide } from 'swiper/react';
+import { Pagination } from 'swiper/modules';
+import 'swiper/css';
+import 'swiper/css/pagination';
+
+type SectionProps = {
+ title: string;
+ images: string[];
+};
+
+// WARNING: images 의 데이터 형식이 어떻게 되는지 확인 후 타입 변경이 필요할 수 있음.
+// WARNING: 또는 직접적으로 데이터를 컴포넌트에서 불러오는 방식도 고려해야함.
+const Section = ({ title, images }: SectionProps) => {
+ const sectionVariant = {
+ hidden: { opacity: 0, y: 50 },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: {
+ type: 'spring',
+ stiffness: 100,
+ damping: 20,
+ staggerChildren: 0.2,
+ delayChildren: 0.3,
+ },
+ },
+ };
+
+ const itemVariant = {
+ hidden: { opacity: 0, y: 20 },
+ visible: { opacity: 1, y: 0 },
+ };
+
+ return (
+
+
+ {title}
+
+
+
+
{
+ return `
+
+
+
+
+ `;
+ },
+ }}
+ breakpoints={{
+ 1024: {
+ slidesPerView: 2,
+ slidesPerGroup: 2,
+ },
+ 1280: {
+ slidesPerView: 3,
+ slidesPerGroup: 3,
+ },
+ 1560: {
+ slidesPerView: 4,
+ slidesPerGroup: 4,
+ },
+ }}
+ >
+ {images.map((image, index) => (
+
+
+
+
+
+ ))}
+ {/* 점 버튼을 출력할 컨테이너 */}
+
+
+
+
+
+ );
+};
+
+export default Section;
diff --git a/src/pages/mypage/MyPage.tsx b/src/pages/mypage/MyPage.tsx
index 706b622..68b7b8a 100644
--- a/src/pages/mypage/MyPage.tsx
+++ b/src/pages/mypage/MyPage.tsx
@@ -1,5 +1,36 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+
const MyPage = () => {
- return MyPage
;
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const accessToken = localStorage.getItem('accessToken');
+
+ if (!accessToken) {
+ navigate('/auth/signin', { replace: true });
+ }
+ }, []);
+
+ return (
+
+
+
+
+
+ 추후 업데이트를 통해 마이페이지를 추가 할 예정이니,
+ 많은 관심 부탁드립니다.
+
+
+ 곧 더 많은 기능이 추가될 예정입니다
+
+
+
+
+ );
};
export default MyPage;
diff --git a/src/pages/notice/NoticePage.tsx b/src/pages/notice/NoticePage.tsx
new file mode 100644
index 0000000..0c6f658
--- /dev/null
+++ b/src/pages/notice/NoticePage.tsx
@@ -0,0 +1,23 @@
+const NoticePage = () => {
+ return (
+
+
+
+
+
+ 추후 업데이트를 통해 공지사항 페이지를 추가 할 예정이니,
+ 많은 관심 부탁드립니다.
+
+
+ 곧 더 많은 기능이 추가될 예정입니다
+
+
+
+
+ );
+};
+
+export default NoticePage;
diff --git a/src/pages/shop/CategoryPage.tsx b/src/pages/shop/CategoryPage.tsx
deleted file mode 100644
index 98641f2..0000000
--- a/src/pages/shop/CategoryPage.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const CategoryPage = () => {
- return CategoryPage
;
-};
-
-export default CategoryPage;
diff --git a/src/pages/shop/DetailPage.tsx b/src/pages/shop/DetailPage.tsx
deleted file mode 100644
index 3230781..0000000
--- a/src/pages/shop/DetailPage.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const DetailPage = () => {
- return DetailPage
;
-};
-
-export default DetailPage;
diff --git a/src/pages/shop/ShopPage.tsx b/src/pages/shop/ShopPage.tsx
deleted file mode 100644
index 720c373..0000000
--- a/src/pages/shop/ShopPage.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-const ShopPage = () => {
- return ShopPage
;
-};
-
-export default ShopPage;
diff --git a/src/pages/shop/categoryPage/CategoryPage.tsx b/src/pages/shop/categoryPage/CategoryPage.tsx
new file mode 100644
index 0000000..5d3b301
--- /dev/null
+++ b/src/pages/shop/categoryPage/CategoryPage.tsx
@@ -0,0 +1,141 @@
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useInView } from 'react-intersection-observer';
+import { useEffect } from 'react';
+import ProductCard from '../components/ProductCard';
+import SortDropdown from '../components/SortDropdown';
+import useSort from '@/hooks/useSort';
+import ProductCardSkeleton from '../components/skeletons/ProductCardSkeleton';
+import { productsApi } from '@/api';
+import { ProductDatas } from '@/api/type';
+import { handleApiError } from '@/utils/handleApiError';
+import { useParams } from 'react-router-dom';
+import LoadingSpinner from '@/components/LoadingSpinner';
+
+const CategoryPage = () => {
+ const { majorCategory, middleCategory, subCategory } = useParams();
+ const { ref, inView } = useInView({
+ threshold: 1,
+ });
+
+ const parseCategoryId = (param: string | undefined): number | null => {
+ if (!param) return null;
+ const id = Number(param);
+ return isNaN(id) ? null : id;
+ };
+
+ const majorCategoryId = parseCategoryId(majorCategory as string);
+ const middleCategoryId = parseCategoryId(middleCategory as string);
+ const subCategoryId = parseCategoryId(subCategory as string);
+
+ if (majorCategoryId === null) {
+ return (
+
+ 유효하지 않은 카테고리입니다. 다시 시도해주세요.
+
+ );
+ }
+
+ if (middleCategory && middleCategoryId === null) {
+ return (
+
+ 유효하지 않은 중간 카테고리입니다. 다시 시도해주세요.
+
+ );
+ }
+
+ if (subCategory && subCategoryId === null) {
+ return (
+
+ 유효하지 않은 소분류입니다. 다시 시도해주세요.
+
+ );
+ }
+
+ const currentCategory =
+ subCategoryId !== null ? subCategoryId : middleCategoryId !== null ? middleCategoryId : majorCategoryId;
+
+ const { currentSort, currentOrder, sortResult, setCurrentSort } = useSort();
+
+ const pageSize = 8;
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError, error } =
+ useInfiniteQuery({
+ queryKey: ['categoryProducts', sortResult, currentOrder, currentCategory],
+ queryFn: async ({ pageParam }) => {
+ try {
+ const response = await productsApi.getProductsData({
+ page: pageParam as number,
+ pageSize,
+ sort: sortResult,
+ order: currentOrder,
+ categoryId: currentCategory,
+ });
+ return response.data;
+ } catch (err) {
+ handleApiError(err);
+ }
+ },
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ const currentPage = allPages.length;
+ const totalPages = Math.ceil(lastPage.total_count / pageSize);
+ return currentPage < totalPages ? currentPage + 1 : undefined;
+ },
+ staleTime: 1000 * 5 * 60,
+ });
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage]);
+
+ const categoryProductData = data?.pages.flatMap((page) => page.products);
+
+ if (isError) {
+ return (
+
+
{(error as Error).message}
+
+ );
+ }
+
+ if (categoryProductData?.length === 0) {
+ return (
+
+ 상품이 없어요.
+
+ );
+ }
+
+ return (
+
+
+
+
+ {isPending && }
+ {categoryProductData &&
+ categoryProductData.map((item) => (
+
+
+
+ ))}
+
+ {isFetchingNextPage && (
+
+ )}
+
+
+ {(isFetchingNextPage || hasNextPage) && }
+
+
+ );
+};
+
+export default CategoryPage;
diff --git a/src/pages/shop/components/ProductCard.tsx b/src/pages/shop/components/ProductCard.tsx
new file mode 100644
index 0000000..38a32c7
--- /dev/null
+++ b/src/pages/shop/components/ProductCard.tsx
@@ -0,0 +1,88 @@
+import useSaleState from '@/hooks/useSaleState';
+import useSoldOutState from '@/hooks/useSoldoutState';
+import { Link } from 'react-router-dom';
+import SaleLabel from '@/components/SaleLabel';
+import { ProductDetail2, ProductOption } from '@/api/type';
+import { SaleProvider } from '@/components/SaleProvider';
+import SalePrice from '@/components/SalePrice';
+import useDefaultImage from '@/hooks/useDefaultImage';
+import ImageOnLoadSkeleton from '../detailPage/components/skeletons/ImageOnLoadSkeleton';
+import DefaultImage from '../detailPage/components/DefaultImage';
+
+interface ProductCardProps {
+ productData: ProductDetail2;
+ optionData: ProductOption;
+ queryKey: any[];
+}
+
+const ProductCard = ({ productData, optionData, queryKey }: ProductCardProps) => {
+ const { id, name, price, discount, discount_option: discountOption, origin_price: originPrice } = productData;
+ const images = optionData?.images || [];
+ const thumbnail = images.length > 0 ? images[0].image_url : '';
+ const isThumbnailExist = images.length > 0;
+ const { isImageError, handleOnError, isImageLoading, handleOnLoad } = useDefaultImage(isThumbnailExist);
+ const { isSoldOut } = useSoldOutState(optionData);
+ const { isSale } = useSaleState({ discount, discountOption });
+
+ return (
+
+
+
+ {isSoldOut && (
+ <>
+
+ Sold Out
+
+ >
+ )}
+ {isImageLoading &&
}
+ {isThumbnailExist ? (
+
+
+ {isImageError &&
이미지 로드 실패
}
+
+ ) : (
+
+
+
+ )}
+
+
+
+
{name}
+
+
+
+
+ {isSale ? (
+
+ ) : (
+
₩{originPrice.toLocaleString()}
+ )}
+
+
+
+ );
+};
+
+export default ProductCard;
diff --git a/src/pages/shop/components/SortDropdown.tsx b/src/pages/shop/components/SortDropdown.tsx
new file mode 100644
index 0000000..1b5b20e
--- /dev/null
+++ b/src/pages/shop/components/SortDropdown.tsx
@@ -0,0 +1,97 @@
+import { SortMode, SortModeSync } from '@/hooks/useSort';
+import { ChevronDown } from 'lucide-react';
+import { useState, useRef, useEffect } from 'react';
+
+interface SortDropdownProps {
+ currentSort: SortMode;
+ setCurrentSort: (value: SortMode) => void;
+ sortResult: SortModeSync;
+}
+
+const sortModes = [
+ { id: 1, mode: '최신순' },
+ { id: 2, mode: '오래된 순' },
+ { id: 3, mode: '가격 낮은순' },
+ { id: 4, mode: '가격 높은순' },
+];
+
+const SortDropdown = ({ currentSort, setCurrentSort }: SortDropdownProps) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const dropdownRef = useRef(null);
+ const labelRef = useRef(null);
+
+ const handleClick = (e: React.MouseEvent) => {
+ const value = e.currentTarget.value as SortMode;
+ setCurrentSort(value);
+ setIsOpen(false);
+ };
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node) &&
+ labelRef.current &&
+ !labelRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (dropdownRef.current) {
+ if (isOpen) {
+ dropdownRef.current.style.maxHeight = `${dropdownRef.current.scrollHeight}px`;
+ dropdownRef.current.style.opacity = '1';
+ } else {
+ dropdownRef.current.style.maxHeight = '0';
+ dropdownRef.current.style.opacity = '0';
+ }
+ }
+ }, [isOpen]);
+
+ return (
+
+
+ setIsOpen((prev) => !prev)}
+ />
+ {currentSort}
+
+
+
+
+
+ {sortModes.map((item) => (
+
+
+ {item.mode}
+
+
+ ))}
+
+
+
+ );
+};
+
+export default SortDropdown;
diff --git a/src/pages/shop/components/skeletons/CardSkeletonUI.tsx b/src/pages/shop/components/skeletons/CardSkeletonUI.tsx
new file mode 100644
index 0000000..07f4053
--- /dev/null
+++ b/src/pages/shop/components/skeletons/CardSkeletonUI.tsx
@@ -0,0 +1,46 @@
+import LogoWhite from '@/assets/imgs/LogoWhite';
+
+const CardSkeletonUI = () => {
+ return (
+
+
+ {/* 이미지 영역 */}
+
+ {/* 타이틀, 라벨 영역 */}
+
+
+
+ );
+};
+
+export default CardSkeletonUI;
diff --git a/src/pages/shop/components/skeletons/ProductCardSkeleton.tsx b/src/pages/shop/components/skeletons/ProductCardSkeleton.tsx
new file mode 100644
index 0000000..77d091f
--- /dev/null
+++ b/src/pages/shop/components/skeletons/ProductCardSkeleton.tsx
@@ -0,0 +1,15 @@
+import CardSkeletonUI from './CardSkeletonUI';
+
+const skeletonArr = new Array(4).fill(null);
+
+const ProductCardSkeleton = () => {
+ return (
+ <>
+ {skeletonArr.map((_, idx) => (
+
+ ))}
+ >
+ );
+};
+
+export default ProductCardSkeleton;
diff --git a/src/pages/shop/detailPage/DetailPage.tsx b/src/pages/shop/detailPage/DetailPage.tsx
new file mode 100644
index 0000000..e10a544
--- /dev/null
+++ b/src/pages/shop/detailPage/DetailPage.tsx
@@ -0,0 +1,52 @@
+import ProductDetailView from './components/ProductDetailView';
+import ProductDetails from './components/ProductDetails';
+import { productsApi } from '@/api';
+import { useQuery } from '@tanstack/react-query';
+import { ProductData } from '@/api/type';
+import { useCachedData } from '@/hooks/useCachedData';
+import DetailPageSkeleton from './components/skeletons/DetailPageSkeleton';
+import { handleApiError } from '@/utils/handleApiError';
+import useHistoryFab from '@/hooks/useHistoryFab';
+
+const DetailPage = () => {
+ const { cachedData, id, hasCachedData } = useCachedData();
+ const {
+ data: productDetailData,
+ isFetching,
+ isError,
+ error,
+ } = useQuery({
+ queryKey: ['productData', id],
+ queryFn: async () => {
+ try {
+ const response = await productsApi.getSingleProduct(Number(id));
+ return response.data;
+ } catch (err: unknown) {
+ handleApiError(err);
+ }
+ },
+ initialData: cachedData,
+ enabled: !hasCachedData,
+ });
+
+ useHistoryFab(productDetailData);
+
+ return (
+
+ {isError && (
+
+ )}
+ {isFetching && }
+ {productDetailData && (
+ <>
+
+
+ >
+ )}
+
+ );
+};
+
+export default DetailPage;
diff --git a/src/pages/shop/detailPage/components/AddCartBtn.tsx b/src/pages/shop/detailPage/components/AddCartBtn.tsx
new file mode 100644
index 0000000..5b2389c
--- /dev/null
+++ b/src/pages/shop/detailPage/components/AddCartBtn.tsx
@@ -0,0 +1,112 @@
+import { ProductDetail2, ProductImage } from '@/api/type';
+import useLocalStorage from '@/hooks/useLocalStorage';
+import { SignUpModalType } from '@/hooks/useModalState/useModalState';
+import { useState } from 'react';
+import { OptionState } from '../types';
+import { CartItemData } from '@/assets/dummys/types';
+import { useAuthStore } from '@/config/store';
+import { UserCartItemParam } from '@/hooks/usePostCartItem';
+
+interface AddCartBtnProps {
+ mutation: any;
+ productData: ProductDetail2;
+ selectedOption: OptionState;
+ detailImage: ProductImage[] | null;
+ handleModalOpen: (type: SignUpModalType) => void;
+}
+
+const AddCartBtn = ({ productData, selectedOption, detailImage, mutation, handleModalOpen }: AddCartBtnProps) => {
+ const { user } = useAuthStore();
+ const [storedValue, setValue] = useLocalStorage('cartItems', []);
+ const [cartItems, setCartItems] = useState(storedValue);
+
+ const isCartButtonEnabled =
+ !!selectedOption.selectedColor && !!selectedOption.selectedSize && selectedOption.amount > 0;
+
+ const handleAddCart = () => {
+ const existingCartItemIndex = cartItems.findIndex((item: CartItemData) => {
+ const isProductIdMatch = item.productId === selectedOption.productData?.id;
+ const isColorMatch =
+ item.color?.trim().toLowerCase() === selectedOption.selectedColor?.color.trim().toLowerCase();
+ const isProductCodeMatch =
+ item.productCode.trim().toLowerCase() === selectedOption.productCode.trim().toLowerCase();
+ const isSizeMatch = item.size?.trim().toLowerCase() === selectedOption.selectedSize?.size.trim().toLowerCase();
+
+ return isProductIdMatch && isColorMatch && isProductCodeMatch && isSizeMatch;
+ });
+
+ let updatedCartItems;
+
+ // 비회원 장바구니 항목
+ const newCartItem: CartItemData = {
+ id: new Date().getTime(),
+ productId: productData.id,
+ productCode: productData.product_code,
+ optionId: selectedOption.optionData?.id,
+ name: productData.name,
+ image: detailImage?.[0].image_url,
+ stock: selectedOption.stock,
+ color: selectedOption.selectedColor?.color,
+ size: selectedOption.selectedSize?.size,
+ amount: selectedOption.amount,
+ originPrice: productData.origin_price,
+ price: productData.price,
+ discount: productData.discount,
+ discountOption: productData.discount_option,
+ };
+
+ // 회원 장바구니 항목
+ const newUserCartItem: UserCartItemParam = {
+ productId: productData.id,
+ color: selectedOption.selectedColor?.color,
+ size: selectedOption.selectedSize?.size,
+ amount: selectedOption.amount,
+ };
+
+ if (existingCartItemIndex > -1) {
+ updatedCartItems = cartItems.map((item: CartItemData, index: number) =>
+ index === existingCartItemIndex ? { ...item, amount: item.amount + selectedOption.amount } : item
+ );
+ } else {
+ updatedCartItems = [...cartItems, newCartItem];
+ }
+
+ setCartItems(updatedCartItems);
+ setValue(updatedCartItems);
+
+ // 로그인 체크
+ if (!user) {
+ handleModalOpen('장바구니');
+ return;
+ }
+
+ // 회원일 경우 회원 장바구니로 추가
+ mutation.mutate(newUserCartItem, {
+ onSuccess: () => {
+ localStorage.removeItem('cartItems');
+ handleModalOpen('장바구니');
+ },
+ onError: () => {
+ handleModalOpen('장바구니추가실패');
+ },
+ });
+ };
+
+ return (
+
+ 장바구니 추가하기
+
+ );
+};
+
+export default AddCartBtn;
diff --git a/src/pages/shop/detailPage/components/BuyNowButton.tsx b/src/pages/shop/detailPage/components/BuyNowButton.tsx
new file mode 100644
index 0000000..786f0b2
--- /dev/null
+++ b/src/pages/shop/detailPage/components/BuyNowButton.tsx
@@ -0,0 +1,58 @@
+import { ProductDetail2, ProductImage } from '@/api/type';
+import { OptionState } from '../types';
+import { SignUpModalType } from '@/hooks/useModalState/useModalState';
+import { CartItemData } from '@/assets/dummys/types';
+
+interface BuyNowButtonProps {
+ productData: ProductDetail2;
+ selectedOption: OptionState;
+ detailImage: ProductImage[] | null;
+ handleModalOpen: (type: SignUpModalType) => void;
+ setInstanceCartData: React.Dispatch>;
+}
+
+const BuyNowButton = ({
+ productData,
+ selectedOption,
+ detailImage,
+ handleModalOpen,
+ setInstanceCartData,
+}: BuyNowButtonProps) => {
+ const handleOnClick = () => {
+ const instanceCart: CartItemData = {
+ id: new Date().getTime(),
+ productId: productData.id,
+ optionId: selectedOption.optionData?.id,
+ productCode: productData.product_code,
+ name: productData.name,
+ image: detailImage?.[0].image_url,
+ stock: selectedOption.stock,
+ color: selectedOption.selectedColor?.color,
+ size: selectedOption.selectedSize?.size,
+ amount: selectedOption.amount,
+ originPrice: productData.origin_price,
+ price: productData.price,
+ discount: productData.discount,
+ discountOption: productData.discount_option,
+ };
+ setInstanceCartData([instanceCart]);
+ handleModalOpen('결제모달');
+ };
+
+ const isBuyNowButtonEnabled =
+ !!selectedOption.selectedColor && !!selectedOption.selectedSize && selectedOption.amount > 0;
+
+ return (
+
+ 바로구매
+
+ );
+};
+
+export default BuyNowButton;
diff --git a/src/pages/shop/detailPage/components/ColorBtns.tsx b/src/pages/shop/detailPage/components/ColorBtns.tsx
new file mode 100644
index 0000000..a9a186f
--- /dev/null
+++ b/src/pages/shop/detailPage/components/ColorBtns.tsx
@@ -0,0 +1,69 @@
+import { useState, useEffect } from 'react';
+import { OptionState } from '../types';
+import { ProductData, ProductOption } from '@/api/type';
+
+interface ColorBtnsProps {
+ data: ProductData;
+ selectedOption: OptionState;
+ onSelect: (prevOption: OptionState) => void;
+}
+
+const ColorBtns = ({ data, selectedOption, onSelect }: ColorBtnsProps) => {
+ const [selectedColorName, setSelectedColorName] = useState('');
+
+ // 컬러 radio 버튼 초기값 세팅
+ useEffect(() => {
+ setSelectedColorName(selectedOption.selectedColor?.color || '');
+ }, [selectedOption.selectedColor]);
+
+ const handleColorChange = (item: ProductOption) => {
+ const isSelected = selectedColorName.trim() === item.color.trim();
+ setSelectedColorName(isSelected ? '' : item.color);
+ // 컬러가 변경될 때 사이즈와 수량 초기화
+ onSelect({
+ ...selectedOption,
+ productCode: data.product.product_code,
+ productData: data.product,
+ optionData: isSelected ? null : item,
+ selectedColor: isSelected ? null : { color: item.color, color_code: item.color_code },
+ selectedSize: null, // 사이즈 초기화
+ amount: 1,
+ ...(isSelected && { stock: 0 }),
+ });
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ };
+
+ return (
+
+
색상
+
+ {data.options.map((item) => (
+
+ handleColorChange(item)}
+ className='sr-only'
+ aria-label={`색상: ${item.color}`}
+ />
+
+
+ ))}
+
+
+ );
+};
+
+export default ColorBtns;
diff --git a/src/pages/shop/detailPage/components/CounterBtn.tsx b/src/pages/shop/detailPage/components/CounterBtn.tsx
new file mode 100644
index 0000000..67c30bf
--- /dev/null
+++ b/src/pages/shop/detailPage/components/CounterBtn.tsx
@@ -0,0 +1,111 @@
+import minus from '@/assets/icons/minus.svg';
+import plus from '@/assets/icons/plus.svg';
+import { AnimatePresence, motion } from 'framer-motion';
+import CounterMessage from '@/components/CounterMessage';
+import { OptionState } from '../types';
+import { useEffect, useState } from 'react';
+
+interface CounterBtnProps {
+ selectedOption: OptionState;
+ isSoldOut: boolean;
+ onSelect: (updatedOption: OptionState) => void;
+}
+
+const CounterBtn = ({ selectedOption, isSoldOut, onSelect }: CounterBtnProps) => {
+ const [count, setCount] = useState(1);
+ const maxCount = selectedOption.selectedSize?.stock ?? 0; // 최대 수량
+ const isSizeSelected = selectedOption.selectedSize !== null;
+ const isMaxStock = count >= maxCount;
+
+ const handleDecrease = () => {
+ if (count > 1) {
+ onSelect({
+ ...selectedOption,
+ amount: count - 1,
+ });
+ }
+ };
+
+ const handleIncrease = () => {
+ if (!isMaxStock) {
+ onSelect({
+ ...selectedOption,
+ amount: count + 1,
+ });
+ }
+ };
+
+ useEffect(() => {
+ onSelect({
+ ...selectedOption,
+ amount: 1,
+ });
+ }, [selectedOption.selectedSize, selectedOption.selectedColor]);
+
+ useEffect(() => {
+ if (!selectedOption.amount) {
+ setCount(0);
+ } else {
+ setCount(selectedOption.amount);
+ }
+ }, [selectedOption.amount]);
+
+ return (
+
+
수량
+ {!isSizeSelected && (
+
+ 사이즈를 선택해주세요!
+
+ )}
+ {isSizeSelected && (
+
+
+ {/* 수량 감소 버튼 */}
+
+
+
+
+ {/* 수량 표시 */}
+
+
+
+ {isSoldOut ? 0 : count}
+
+
+
+
+ {/* 수량 증가 버튼 */}
+
+
+
+
+ {/* 최대 수량 메시지 */}
+
+
+
+ )}
+
+ );
+};
+
+export default CounterBtn;
diff --git a/src/pages/shop/detailPage/components/DefaultImage.tsx b/src/pages/shop/detailPage/components/DefaultImage.tsx
new file mode 100644
index 0000000..c81883d
--- /dev/null
+++ b/src/pages/shop/detailPage/components/DefaultImage.tsx
@@ -0,0 +1,22 @@
+import LogoWhite from '@/assets/imgs/LogoWhite';
+
+const DefaultImage = ({ option = '디테일' }: { option?: '디테일' | '썸네일' }) => {
+ return (
+ <>
+ {option === '디테일' && (
+
+ )}
+ {option === '썸네일' && (
+
+
+
+ )}
+ >
+ );
+};
+
+export default DefaultImage;
diff --git a/src/pages/shop/detailPage/components/MobileModalToggler.tsx b/src/pages/shop/detailPage/components/MobileModalToggler.tsx
new file mode 100644
index 0000000..0b900a6
--- /dev/null
+++ b/src/pages/shop/detailPage/components/MobileModalToggler.tsx
@@ -0,0 +1,21 @@
+interface MobileModalTogglerProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+}
+
+const MobileModalToggler = ({ isOpen, setIsOpen }: MobileModalTogglerProps) => {
+ const handleToggler = () => {
+ setIsOpen(!isOpen);
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default MobileModalToggler;
diff --git a/src/pages/shop/detailPage/components/MobileOptionSelectBox.tsx b/src/pages/shop/detailPage/components/MobileOptionSelectBox.tsx
new file mode 100644
index 0000000..e3b3698
--- /dev/null
+++ b/src/pages/shop/detailPage/components/MobileOptionSelectBox.tsx
@@ -0,0 +1,131 @@
+import SaleLabel from '@/components/SaleLabel';
+import SalePrice from '@/components/SalePrice';
+import { SaleProvider } from '@/components/SaleProvider';
+import ColorBtns from './ColorBtns';
+import SizeBtns from './SizeBtns';
+import CounterBtn from './CounterBtn';
+import AddCartBtn from './AddCartBtn';
+import { SignUpModalType } from '@/hooks/useModalState/useModalState';
+import MobileModalToggler from './MobileModalToggler';
+import { motion, AnimatePresence } from 'framer-motion';
+import useSaleState from '@/hooks/useSaleState';
+import useSoldOutState from '@/hooks/useSoldoutState';
+import { ProductData, ProductImage } from '@/api/type';
+import { OptionState } from '../types';
+import { CartItemData } from '@/assets/dummys/types';
+import BuyNowButton from './BuyNowButton';
+
+interface MobileOptionSelectBoxProps {
+ data: ProductData;
+ selectedOption: OptionState;
+ isOpen: boolean;
+ detailImage: ProductImage[] | null;
+ mutation: any;
+ setInstanceCartData: React.Dispatch>;
+ setSelectedOption: (prevOption: OptionState) => void;
+ setIsOpen: (isOpen: boolean) => void;
+ handleModalOpen: (type: SignUpModalType) => void;
+}
+
+const MobileOptionSelectBox = ({
+ data,
+ selectedOption,
+ isOpen,
+ detailImage,
+ mutation,
+ setInstanceCartData,
+ setSelectedOption,
+ setIsOpen,
+ handleModalOpen,
+}: MobileOptionSelectBoxProps) => {
+ const { isSale } = useSaleState({
+ discount: data.product.discount,
+ discountOption: data.product.discount_option,
+ });
+ const { isSoldOut } = useSoldOutState(selectedOption?.optionData);
+ const handleOnClose = () => {
+ setIsOpen(false);
+ };
+ return (
+
+
+ {/* 토글 버튼 */}
+
+
+ {/* 메인 컨텐츠 시작 */}
+
+
+
+
+
+ {data.product.name}
+
+
+
+
+
+
+ {isSale ? (
+
+ ) : (
+
+
₩{data.product.origin_price.toLocaleString()}
+
+ )}
+
+
+ {/* 옵션 선택 영역 */}
+
+
+
+
+
+
+
+ {/* 장바구니 & 네이버페이 버튼 영역 */}
+
+ {isSoldOut && (
+
+ Sold Out
+
+ )}
+
+
+
+
+
+
+ {isOpen && (
+
+ )}
+
+ );
+};
+
+export default MobileOptionSelectBox;
diff --git a/src/pages/shop/detailPage/components/OptionDropdown.tsx b/src/pages/shop/detailPage/components/OptionDropdown.tsx
new file mode 100644
index 0000000..006399a
--- /dev/null
+++ b/src/pages/shop/detailPage/components/OptionDropdown.tsx
@@ -0,0 +1,144 @@
+// FIXME: 이거 나중에 쓸꺼임 그때 다시 소생시킴
+// import { AdditionalOption } from '@/assets/dummys/types';
+// import { ChevronDown } from 'lucide-react';
+// import { useEffect, useRef, useState } from 'react';
+
+// interface OptionDropdownProps {
+// options: AdditionalOption[];
+// placeholder: string;
+// onSelect: any;
+// }ㄴ
+
+// const OptionDropdown = ({ options, placeholder, onSelect }: OptionDropdownProps) => {
+// const [isOpen, setIsOpen] = useState(false);
+// const [selectedOption, setSelectedOption] = useState('');
+// const dropdownRef = useRef(null);
+
+// useEffect(() => {
+// const handleClickOutside = (event: MouseEvent) => {
+// if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+// setIsOpen(false);
+// }
+// };
+// useEffect(() => {
+// const handleClickOutside = (event: MouseEvent) => {
+// if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+// setIsOpen(false);
+// }
+// };
+
+// document.addEventListener('mousedown', handleClickOutside);
+// return () => {
+// document.removeEventListener('mousedown', handleClickOutside);
+// };
+// }, []);
+// document.addEventListener('mousedown', handleClickOutside);
+// return () => {
+// document.removeEventListener('mousedown', handleClickOutside);
+// };
+// }, []);
+
+// const handleToggle = () => setIsOpen(!isOpen);
+// const handleToggle = () => setIsOpen(!isOpen);
+
+// const handleSelect = (option: AdditionalOption) => {
+// setSelectedOption(option.name);
+// onSelect(option);
+// setIsOpen(false);
+// };
+
+// return (
+//
+//
추가옵션
+//
+// {selectedOption || placeholder}
+//
+//
+// {isOpen && (
+//
+// {options.map((option) => (
+// handleSelect(option)}
+// >
+// {`${option.name} / +${option.extra_cost}원`}
+// {selectedOption === option.name && (
+//
+//
+//
+//
+//
+// )}
+//
+// ))}
+//
+// )}
+//
+// );
+// };
+// return (
+//
+//
추가옵션
+//
+// {selectedOption || placeholder}
+//
+//
+// {isOpen && (
+//
+// {options.map((option) => (
+// handleSelect(option)}
+// >
+// {`${option.name} / +${option.extra_cost}원`}
+// {selectedOption === option.name && (
+//
+//
+//
+//
+//
+// )}
+//
+// ))}
+//
+// )}
+//
+// );
+// };
+
+// export default OptionDropdown;
+// export default OptionDropdown;
diff --git a/src/pages/shop/detailPage/components/OptionListItem.tsx b/src/pages/shop/detailPage/components/OptionListItem.tsx
new file mode 100644
index 0000000..2a43dce
--- /dev/null
+++ b/src/pages/shop/detailPage/components/OptionListItem.tsx
@@ -0,0 +1,61 @@
+// FIXME: 언제 사용할지 모름
+// import { StockItem } from '@/assets/dummys/types';
+// import Counter from './CounterBtn';
+// import { useEffect, useState } from 'react';
+
+// interface CounterProps {
+// stock: StockItem[];
+// colorName: string;
+// colorId: string;
+// price: number;
+// setCount?: (count: number) => void;
+// }
+// interface CounterProps {
+// stock: StockItem[];
+// colorName: string;
+// colorId: string;
+// price: number;
+// setCount?: (count: number) => void;
+// }
+
+// const OptionListItem = ({ stock, colorId, colorName, price }: CounterProps) => {
+// const [count, setCount] = useState(1);
+// const [itemPrice, setItemPrice] = useState(price);
+// const currentStock = stock.find((item) => item.id === colorId);
+// const maxCount = currentStock?.quantity;
+// const OptionListItem = ({ stock, colorId, colorName, price }: CounterProps) => {
+// const [count, setCount] = useState(1);
+// const [itemPrice, setItemPrice] = useState(price);
+// const currentStock = stock.find((item) => item.id === colorId);
+// const maxCount = currentStock?.quantity;
+
+// useEffect(() => {
+// setItemPrice(count * price);
+// }, [count]);
+// useEffect(() => {
+// setItemPrice(count * price);
+// }, [count]);
+
+// return (
+//
+//
{colorName}
+//
+//
+//
{`${itemPrice.toLocaleString()}원`}
+//
+//
+// );
+// };
+// return (
+//
+//
{colorName}
+//
+//
+//
{`${itemPrice.toLocaleString()}원`}
+//
+//
+// );
+// };
+
+// export default OptionListItem;
+// export default OptionListItem;
diff --git a/src/pages/shop/detailPage/components/OptionSelectBox.tsx b/src/pages/shop/detailPage/components/OptionSelectBox.tsx
new file mode 100644
index 0000000..2e87200
--- /dev/null
+++ b/src/pages/shop/detailPage/components/OptionSelectBox.tsx
@@ -0,0 +1,103 @@
+import SaleLabel from '@/components/SaleLabel';
+import SalePrice from '@/components/SalePrice';
+import { SaleProvider } from '@/components/SaleProvider';
+import ColorBtns from './ColorBtns';
+import SizeBtns from './SizeBtns';
+import CounterBtn from './CounterBtn';
+import AddCartBtn from './AddCartBtn';
+import { SignUpModalType } from '@/hooks/useModalState/useModalState';
+import { ProductData, ProductImage } from '@/api/type';
+import { OptionState } from '../types';
+import useSaleState from '@/hooks/useSaleState';
+import useSoldOutState from '@/hooks/useSoldoutState';
+import BuyNowButton from './BuyNowButton';
+import { CartItemData } from '@/assets/dummys/types';
+
+interface OptionSelectBoxProps {
+ data: ProductData;
+ selectedOption: OptionState;
+ isOpen: boolean;
+ detailImage: ProductImage[] | null;
+ mutation: any;
+ setUserCart: (cartItems: any) => void;
+ setInstanceCartData: React.Dispatch>;
+ setSelectedOption: (prevOption: OptionState) => void;
+ setIsOpen: (isOpen: boolean) => void;
+ handleModalOpen: (type: SignUpModalType) => void;
+}
+
+const OptionSelectBox = ({
+ data,
+ selectedOption,
+ detailImage,
+ mutation,
+ setInstanceCartData,
+ setSelectedOption,
+ handleModalOpen,
+}: OptionSelectBoxProps) => {
+ const { isSale } = useSaleState({
+ discount: data.product.discount,
+ discountOption: data.product.discount_option,
+ });
+ const { isSoldOut } = useSoldOutState(selectedOption?.optionData);
+
+ return (
+
+
+
+
+
+
+ {data.product.name}
+
+
+
+
+
+
+ {isSale ? (
+
+ ) : (
+
+
₩{data.product.origin_price.toLocaleString()}
+
+ )}
+
+
+
+ {/* 옵션 선택 영역 */}
+
+
+
+
+
+
+ {/* 장바구니 & 네이버페이 버튼 영역 */}
+
+ {isSoldOut && (
+
+ Sold Out
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default OptionSelectBox;
diff --git a/src/pages/shop/detailPage/components/ProductDetailImage.tsx b/src/pages/shop/detailPage/components/ProductDetailImage.tsx
new file mode 100644
index 0000000..27b26e9
--- /dev/null
+++ b/src/pages/shop/detailPage/components/ProductDetailImage.tsx
@@ -0,0 +1,63 @@
+import useDefaultImage from '@/hooks/useDefaultImage';
+import DefaultImage from './DefaultImage';
+import { memo, useEffect } from 'react';
+import { ProductData, ProductImage } from '@/api/type';
+import { OptionState } from '../types';
+import ImageOnLoadSkeleton from './skeletons/ImageOnLoadSkeleton';
+
+interface ProductDetailImageProps {
+ data: ProductData;
+ selectedOption: OptionState;
+ detailImage: ProductImage[] | [];
+ setDetailImage: (images: ProductImage[] | []) => void;
+}
+
+const ProductDetailImage = ({ data, selectedOption, detailImage, setDetailImage }: ProductDetailImageProps) => {
+ const isDetailImagesExist = detailImage.length > 0 && detailImage[1] !== undefined;
+ const { isImageError, handleOnError, isImageLoading, handleOnLoad } = useDefaultImage(isDetailImagesExist);
+
+ // 컬러 옵션이 바뀔때마다 이미지 배열을 동적으로 안전하게 셋팅
+ useEffect(() => {
+ if (selectedOption.optionData) {
+ setDetailImage(selectedOption.optionData.images);
+ } else {
+ setDetailImage([]);
+ }
+ }, [selectedOption.selectedColor]);
+
+ return (
+
+ {/* 이미지 로드 중 스켈레톤UI */}
+ {isImageLoading &&
}
+ {/* 디테일 이미지 영역 (이미지 로드 중 에러 발생 시 디폴트 이미지 포함) */}
+ {isDetailImagesExist ? (
+ detailImage?.map(
+ (img, idx) =>
+ idx !== 0 /* 첫번째 사진은 썸네일용이므로 렌더링하지 않음 */ && (
+
+
+ {isImageError &&
이미지 로드 실패
}
+
+ )
+ )
+ ) : (
+
+
+
+ )}
+
+ );
+};
+
+export default memo(ProductDetailImage);
diff --git a/src/pages/shop/detailPage/components/ProductDetailView.tsx b/src/pages/shop/detailPage/components/ProductDetailView.tsx
new file mode 100644
index 0000000..dc49184
--- /dev/null
+++ b/src/pages/shop/detailPage/components/ProductDetailView.tsx
@@ -0,0 +1,106 @@
+import { useEffect, useState } from 'react';
+import { OptionState, ProductDetailViewProps } from '../types';
+import { useMediaQuery } from 'react-responsive';
+import useModalState from '@/hooks/useModalState/useModalState';
+import OptionSelectBox from './OptionSelectBox';
+import MobileOptionSelectBox from './MobileOptionSelectBox';
+import { ProductImage } from '@/api/type';
+import ProductDetailImage from './ProductDetailImage';
+import { CartItemData } from '@/assets/dummys/types';
+import useCartCalculations from '@/hooks/useCartCalculations';
+import LoadingSpinner from '@/components/LoadingSpinner';
+import usePostCartItem from '@/hooks/usePostCartItem';
+
+const ProductDetailView = ({ data }: ProductDetailViewProps) => {
+ const mutation = usePostCartItem();
+ const shouldResponsive = useMediaQuery({ maxWidth: 767 });
+ const [userCart, setUserCart] = useState({});
+ const [detailImage, setDetailImage] = useState([]);
+ const [isOpen, setIsOpen] = useState(true);
+ const [instanceCartData, setInstanceCartData] = useState([]);
+ const { totalPrice, totalDeliveryFee } = useCartCalculations(instanceCartData);
+ const paymentData = {
+ items: instanceCartData,
+ totalPrice,
+ totalDeliveryFee,
+ };
+ const { handleModalOpen, renderModalContent } = useModalState({ paymentData, mutation });
+ const [selectedOption, setSelectedOption] = useState({
+ productCode: data.product.product_code,
+ productData: data.product,
+ optionData: data.options[0],
+ selectedColor: null,
+ selectedSize: null,
+ amount: 1,
+ stock: 0,
+ });
+
+ const optionSelectBoxProps = {
+ data,
+ selectedOption,
+ isOpen,
+ detailImage,
+ mutation,
+ userCart,
+ setInstanceCartData,
+ setSelectedOption,
+ setIsOpen,
+ handleModalOpen,
+ setUserCart,
+ };
+
+ // 첫 렌더링시 스크롤 최상단으로
+ useEffect(() => {
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }, []);
+
+ return (
+
+ {mutation.isPending && (
+
+
+
+ )}
+ {/* 디테일 이미지 영역 */}
+
+ {/* 옵션 선택 박스 영역 */}
+ {shouldResponsive ? (
+
+ ) : (
+
+ )}
+ {/* 모달 영역 */}
+ {renderModalContent()}
+
+ );
+};
+
+export default ProductDetailView;
+
+{
+ /* */
+}
+
+{
+ /*
+ {cartItems.length > 0 ? (
+ cartItems.map((item, idx) => (
+
+ ))
+ ) : (
+
옵션을 선택해주세요!
+ )}
+
+
*/
+}
diff --git a/src/pages/shop/detailPage/components/ProductDetails.tsx b/src/pages/shop/detailPage/components/ProductDetails.tsx
new file mode 100644
index 0000000..c8dbbea
--- /dev/null
+++ b/src/pages/shop/detailPage/components/ProductDetails.tsx
@@ -0,0 +1,59 @@
+import ReviewCarousel from './ReviewCarousel';
+import TextContent from './TextContent';
+import ReviewCard from './ReviewCard';
+import ReviewDropdown from './ReviewDropDown';
+import { ProductDetailsProps } from '../types';
+import RefundPolicy from './RefundPolicy';
+
+const ProductDetails = ({ data, isFetching }: ProductDetailsProps) => {
+ const detailBoxes = [
+ {
+ id: 1,
+ title: '제품 설명',
+ content: ,
+ show: true,
+ },
+ {
+ id: 2,
+ title: '제품 특징',
+ content: ,
+ show: true,
+ },
+ {
+ id: 3,
+ title: '리뷰',
+ content: ,
+ show: false, // 이 항목은 숨김 처리
+ hasCarousel: true,
+ },
+ {
+ id: 4,
+ title: '환불 정책',
+ content: ,
+ show: true,
+ },
+ ];
+
+ return (
+
+ {detailBoxes
+ .filter((item) => item.show) // show가 true인 항목만 표시
+ .map((item) => (
+
+
+
{item.title}
+
{item.content}
+
+ {item.hasCarousel && (
+
+
+
+
+ )}
+
+ ))}
+
+ );
+};
+
+export default ProductDetails;
diff --git a/src/pages/shop/detailPage/components/RefundPolicy.tsx b/src/pages/shop/detailPage/components/RefundPolicy.tsx
new file mode 100644
index 0000000..86ed404
--- /dev/null
+++ b/src/pages/shop/detailPage/components/RefundPolicy.tsx
@@ -0,0 +1,85 @@
+const RefundPolicy = () => {
+ return (
+
+
+ 환불이 가능한 조건
+
+
+
+
+ 교환 및 반품이 가능한 경우
+
+
+ 상품을 공급 받으신 날로부터 7일 이내
+ 공급받으신 상품 및 용역의 내용이 표시·광고 내용과 다르거나 다르게 이행된 경우:
+ 공급받은 날로부터 3개월 이내
+ 그 사실을 알게 된 날로부터 30일 이내
+
+
+ 주의: 가전제품의 경우 포장을 개봉하였거나 포장이 훼손되어 상품가치가 상실된 경우에는
+ 교환/반품이 불가능합니다.
+
+
+ 참고: 고객님의 마음이 바뀌어 교환, 반품을 하실 경우 상품반송 비용은 고객님께서 부담하셔야
+ 합니다. (색상 교환, 사이즈 교환 등 포함)
+
+
+
+ 교환 및 반품이 불가능한 경우
+
+
+
+ 고객님의 책임 있는 사유로 상품 등이 멸실 또는 훼손된 경우. 단, 상품의 내용을 확인하기 위하여 포장 등을 훼손한
+ 경우는 제외
+
+
+ 포장을 개봉하였거나 포장이 훼손되어 상품가치가 상실된 경우 (예 : 가전제품, 식품, 음반 등, 단 액정화면이 부착된
+ 노트북, LCD모니터, 디지털 카메라 등의 불량화소에 따른 반품/교환은 제조사 기준에 따릅니다.)
+
+
+ 고객님의 사용 또는 일부 소비에 의하여 상품의 가치가 현저히 감소한 경우 단, 화장품등의 경우 시용제품을 제공한
+ 경우에 한 합니다.
+
+ 시간의 경과에 의하여 재판매가 곤란할 정도로 상품등의 가치가 현저히 감소한 경우
+ 복제가 가능한 상품등의 포장을 훼손한 경우 (자세한 내용은 고객센터에 문의를 해주시길 바랍니다)
+
+
+
+ 환불 요청 절차
+
+
+
+
+ 환불 방식
+
+
+ 결제 방식에 따른 환불
+
+ 신용카드로 결제하신 경우는 신용카드 승인을 취소하여 결제 대금이 청구되지 않게 합니다. (단, 신용카드 결제일자에
+ 맞추어 대금이 청구 될수 있으면 이경우 익월 신용카드 대금청구시 카드사에서 환급처리 됩니다.)
+
+
+
+
+ 환불 처리 기간
+
+
+
+
+ 환불 시 발생하는 수수료 설정 여부
+
+
+
+ );
+};
+
+export default RefundPolicy;
diff --git a/src/pages/shop/detailPage/components/ReviewCard.tsx b/src/pages/shop/detailPage/components/ReviewCard.tsx
new file mode 100644
index 0000000..451c5e1
--- /dev/null
+++ b/src/pages/shop/detailPage/components/ReviewCard.tsx
@@ -0,0 +1,39 @@
+import { reviewImgs } from '@/assets/dummys/reviewData';
+
+const ReviewCard = () => {
+ return (
+
+
+ ★★★★☆
+ smi****
+ 24.11.01
+
+
+
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut et massa mi. Aliquam in hendrerit urna.
+ Pellentesque sit amet sapien fringilla, mattis ligula consectetur, ultrices mauris. Maecenas vitae mattis
+ tellus. Nullam quis imperdiet augue. Vestibulum auctor ornare leo, non suscipit magna interdum eu.
+ Curabitur pellentesque nibh nibh, at maximus ante fermentum sit amet. Pellentesque commodo lacus at
+ sodales sodales. Quisque sagittis orci ut diam condimentum, vel euismod erat placerat. In iaculis arcu
+ eros, eget tempus orci facilisis id.
+
+
+
+
+
+
+
+
+
[답변] 감사합니다^^ 카키색 가방에 흰색 파우치 멋지십니다~!
+
+
+
+ );
+};
+
+export default ReviewCard;
diff --git a/src/pages/shop/detailPage/components/ReviewCarousel.tsx b/src/pages/shop/detailPage/components/ReviewCarousel.tsx
new file mode 100644
index 0000000..4f74aae
--- /dev/null
+++ b/src/pages/shop/detailPage/components/ReviewCarousel.tsx
@@ -0,0 +1,32 @@
+import Slider from 'react-slick';
+import 'slick-carousel/slick/slick.css';
+import 'slick-carousel/slick/slick-theme.css';
+import { reviewImgs } from '@/assets/dummys/reviewData';
+
+export default function ReviewCarousel() {
+ const settings = {
+ dots: false,
+ infinite: true,
+ speed: 500,
+ slidesToShow: 8,
+ slidesToScroll: 1,
+ };
+
+ return (
+
+
+ {reviewImgs.map((item) => (
+
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/pages/shop/detailPage/components/ReviewDropDown.tsx b/src/pages/shop/detailPage/components/ReviewDropDown.tsx
new file mode 100644
index 0000000..0d526f6
--- /dev/null
+++ b/src/pages/shop/detailPage/components/ReviewDropDown.tsx
@@ -0,0 +1,13 @@
+import dropDownIco from '@/assets/icons/dropDownIco.svg';
+
+const ReviewDropdown = () => {
+ return (
+
+
+ ★★★★☆ (2000)
+
+
+ );
+};
+
+export default ReviewDropdown;
diff --git a/src/pages/shop/detailPage/components/SizeBtns.tsx b/src/pages/shop/detailPage/components/SizeBtns.tsx
new file mode 100644
index 0000000..8b7a67e
--- /dev/null
+++ b/src/pages/shop/detailPage/components/SizeBtns.tsx
@@ -0,0 +1,111 @@
+import { useCallback, useEffect, useState } from 'react';
+import { OptionState } from '../types';
+import { ProductData, ProductSize } from '@/api/type';
+
+interface SizeBtnsProps {
+ data: ProductData;
+ selectedOption: OptionState;
+ onSelect: (prevOption: OptionState) => void;
+}
+
+const SizeBtns = ({ data, selectedOption, onSelect }: SizeBtnsProps) => {
+ const [selectedSizeName, setSelectedSizeName] = useState('');
+ const isColorSelected = !!selectedOption.selectedColor;
+
+ const sortSizes = useCallback(
+ (sizes: ProductSize[]) => {
+ // 사전 정의된 정렬 우선순위 배열
+ const predefinedOrder = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL', '2XL', '3XL'];
+
+ return sizes.sort((a, b) => {
+ const sizeA = a.size.toUpperCase();
+ const sizeB = b.size.toUpperCase();
+
+ const indexA = predefinedOrder.indexOf(sizeA);
+ const indexB = predefinedOrder.indexOf(sizeB);
+
+ const isANumber = !isNaN(Number(sizeA));
+ const isBNumber = !isNaN(Number(sizeB));
+
+ // 사전 정의된 순서가 있는 경우
+ if (indexA !== -1 && indexB !== -1) {
+ return indexA - indexB;
+ }
+
+ // 숫자 사이즈 처리
+ if (isANumber && isBNumber) {
+ return Number(sizeA) - Number(sizeB);
+ }
+
+ if (isANumber) {
+ return -1; // 숫자가 알파벳보다 앞
+ }
+
+ if (isBNumber) {
+ return 1; // 알파벳이 숫자보다 뒤
+ }
+
+ // 사전 정의된 순서가 없는 경우 알파벳 순서로 정렬
+ return sizeA.localeCompare(sizeB);
+ });
+ },
+ [data, selectedOption]
+ );
+
+ // 선택한 컬러에 해당하는 사이즈 데이터 필터링
+ const filteredSizes = isColorSelected
+ ? sortSizes(data.options.find((option) => option.color === selectedOption.selectedColor?.color)?.sizes || [])
+ : [];
+
+ const handleSizeChange = (size: ProductSize) => {
+ const isSelected = selectedSizeName === size.size;
+ setSelectedSizeName(isSelected ? '' : size.size);
+
+ onSelect({
+ ...selectedOption,
+ selectedSize: isSelected ? null : size,
+ amount: 1,
+ stock: isSelected ? 0 : size.stock,
+ });
+ };
+
+ useEffect(() => {
+ setSelectedSizeName('');
+ }, [selectedOption.selectedColor]); // 색상이 변경되면 사이즈 초기화
+
+ return (
+
+
사이즈
+
+ {!isColorSelected && (
+
색상을 선택해주세요!
+ )}
+ {isColorSelected &&
+ filteredSizes &&
+ filteredSizes.map((item) => (
+
+ handleSizeChange(item)}
+ className='sr-only'
+ aria-label={`사이즈: ${item.size}`}
+ />
+ {item.size}
+
+ ))}
+
+
+ );
+};
+
+export default SizeBtns;
diff --git a/src/pages/shop/detailPage/components/TextContent.tsx b/src/pages/shop/detailPage/components/TextContent.tsx
new file mode 100644
index 0000000..bc97452
--- /dev/null
+++ b/src/pages/shop/detailPage/components/TextContent.tsx
@@ -0,0 +1,7 @@
+import TextContentSkeleton from './skeletons/TextContentSkeleton';
+
+const TextContent = ({ content = '내용을 입력해주세요', isFetching }: { content: string; isFetching: boolean }) => {
+ return <>{isFetching ? : {content}
}>;
+};
+
+export default TextContent;
diff --git a/src/pages/shop/detailPage/components/TotalPrice.tsx b/src/pages/shop/detailPage/components/TotalPrice.tsx
new file mode 100644
index 0000000..ad8ad6c
--- /dev/null
+++ b/src/pages/shop/detailPage/components/TotalPrice.tsx
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react';
+
+interface TotalPriceProps {
+ count: number;
+ price: number;
+}
+
+const TotalPrice = ({ count, price }: TotalPriceProps) => {
+ const [totalPrice, setTotalPrice] = useState(price);
+
+ useEffect(() => {
+ setTotalPrice(count * price);
+ }, [count]);
+
+ return (
+
+
총 금액
+
₩{totalPrice.toLocaleString()}
+
+ );
+};
+
+export default TotalPrice;
diff --git a/src/pages/shop/detailPage/components/skeletons/DetailPageSkeleton.tsx b/src/pages/shop/detailPage/components/skeletons/DetailPageSkeleton.tsx
new file mode 100644
index 0000000..e89eea8
--- /dev/null
+++ b/src/pages/shop/detailPage/components/skeletons/DetailPageSkeleton.tsx
@@ -0,0 +1,84 @@
+import { useMediaQuery } from 'react-responsive';
+import DetailImageSkeleton from './ImageOnLoadSkeleton';
+import { AnimatePresence, motion } from 'framer-motion';
+import MobileModalToggler from '../MobileModalToggler';
+import { useState } from 'react';
+import OptionSelectBoxSkeleton from './OptionSelectBoxSkeleton';
+import TextContentSkeleton from './TextContentSkeleton';
+import LoadingSpinner from '@/components/LoadingSpinner';
+
+const DetailPageSkeleton = () => {
+ const [isOpen, setIsOpen] = useState(true);
+ const shouldResponsive = useMediaQuery({ maxWidth: 767 });
+ const handleOnClose = () => {
+ setIsOpen(false);
+ };
+ const detailBoxes = [
+ {
+ id: 1,
+ title: '제품 설명',
+ },
+ {
+ id: 2,
+ title: '제품 특징',
+ },
+ {
+ id: 3,
+ title: '환불 정책',
+ },
+ ];
+ return (
+ <>
+
+
+
+
+ {shouldResponsive ? (
+
+
+
+
+
+
+
+ {isOpen && (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+
+ {detailBoxes.map((item) => (
+
+ ))}
+
+ >
+ );
+};
+
+export default DetailPageSkeleton;
diff --git a/src/pages/shop/detailPage/components/skeletons/ImageOnLoadSkeleton.tsx b/src/pages/shop/detailPage/components/skeletons/ImageOnLoadSkeleton.tsx
new file mode 100644
index 0000000..fdd4964
--- /dev/null
+++ b/src/pages/shop/detailPage/components/skeletons/ImageOnLoadSkeleton.tsx
@@ -0,0 +1,26 @@
+import LogoWhite from '@/assets/imgs/LogoWhite';
+
+const ImageOnLoadSkeleton = ({ option = '디테일' }: { option?: '디테일' | '썸네일' }) => {
+ return (
+ <>
+ {option === '디테일' && (
+
+ )}
+ {option === '썸네일' && (
+
+ )}
+ >
+ );
+};
+
+export default ImageOnLoadSkeleton;
diff --git a/src/pages/shop/detailPage/components/skeletons/MobileOptionSelectBoxSkeleton.tsx b/src/pages/shop/detailPage/components/skeletons/MobileOptionSelectBoxSkeleton.tsx
new file mode 100644
index 0000000..9c1b00b
--- /dev/null
+++ b/src/pages/shop/detailPage/components/skeletons/MobileOptionSelectBoxSkeleton.tsx
@@ -0,0 +1,63 @@
+const MobileOptionSelectBoxSkeleton = () => {
+ return (
+ <>
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+
+
+
+ {/* 옵션 선택 영역 */}
+
+
+ {/* 컬러버튼 */}
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+
+
+ {/* 장바구니 & 네이버페이 버튼 영역 */}
+
+
+
+ >
+ );
+};
+
+export default MobileOptionSelectBoxSkeleton;
diff --git a/src/pages/shop/detailPage/components/skeletons/OptionSelectBoxSkeleton.tsx b/src/pages/shop/detailPage/components/skeletons/OptionSelectBoxSkeleton.tsx
new file mode 100644
index 0000000..c0a751d
--- /dev/null
+++ b/src/pages/shop/detailPage/components/skeletons/OptionSelectBoxSkeleton.tsx
@@ -0,0 +1,76 @@
+const OptionSelectBoxSkeleton = () => {
+ return (
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
+
+export default OptionSelectBoxSkeleton;
diff --git a/src/pages/shop/detailPage/components/skeletons/TextContentSkeleton.tsx b/src/pages/shop/detailPage/components/skeletons/TextContentSkeleton.tsx
new file mode 100644
index 0000000..08d6d2c
--- /dev/null
+++ b/src/pages/shop/detailPage/components/skeletons/TextContentSkeleton.tsx
@@ -0,0 +1,30 @@
+const TextContentSkeleton = () => {
+ return (
+
+ );
+};
+
+export default TextContentSkeleton;
diff --git a/src/pages/shop/detailPage/types.ts b/src/pages/shop/detailPage/types.ts
new file mode 100644
index 0000000..d2b2d67
--- /dev/null
+++ b/src/pages/shop/detailPage/types.ts
@@ -0,0 +1,19 @@
+import { ProductColor, ProductData, ProductDetail2, ProductOption, ProductSize } from '@/api/type';
+
+export interface ProductDetailViewProps {
+ data: ProductData;
+}
+
+export interface ProductDetailsProps extends ProductDetailViewProps {
+ isFetching: boolean;
+}
+
+export interface OptionState {
+ productCode: string;
+ productData: ProductDetail2;
+ optionData: ProductOption | null;
+ selectedColor?: ProductColor | null;
+ selectedSize?: ProductSize | null;
+ amount: number;
+ stock: number;
+}
diff --git a/src/pages/shop/shopPage/ShopPage.tsx b/src/pages/shop/shopPage/ShopPage.tsx
new file mode 100644
index 0000000..f20ab5f
--- /dev/null
+++ b/src/pages/shop/shopPage/ShopPage.tsx
@@ -0,0 +1,102 @@
+import ProductCard from '../components/ProductCard';
+import SortDropdown from '../components/SortDropdown';
+import useSort from '@/hooks/useSort';
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { productsApi } from '@/api';
+import ProductCardSkeleton from '../components/skeletons/ProductCardSkeleton';
+import { ProductDatas } from '@/api/type';
+import { useInView } from 'react-intersection-observer';
+import { handleApiError } from '@/utils/handleApiError';
+import { useEffect } from 'react';
+import LoadingSpinner from '@/components/LoadingSpinner';
+
+const ShopPage = () => {
+ const { currentSort, currentOrder, sortResult, setCurrentSort } = useSort();
+ const { ref, inView } = useInView({
+ threshold: 1,
+ });
+
+ const pageSize = 8;
+
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, isError, error } =
+ useInfiniteQuery({
+ queryKey: ['allProducts', sortResult, currentOrder],
+ queryFn: async ({ pageParam }) => {
+ try {
+ const response = await productsApi.getProductsData({
+ page: pageParam as number,
+ pageSize,
+ sort: sortResult,
+ order: currentOrder,
+ });
+ return response.data;
+ } catch (err) {
+ handleApiError(err);
+ }
+ },
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ const currentPage = allPages.length;
+ const totalPages = Math.ceil(lastPage.total_count / pageSize);
+ return currentPage < totalPages ? currentPage + 1 : undefined;
+ },
+ staleTime: 1000 * 5 * 60,
+ });
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage]);
+
+ const shopProductData = data?.pages.flatMap((page) => page.products);
+
+ if (isError) {
+ return (
+
+
{(error as Error).message}
+
+ );
+ }
+
+ if (shopProductData?.length === 0) {
+ return (
+
+ 상품이 없어요.
+
+ );
+ }
+
+ return (
+
+
+
+
+ {isPending && }
+ {shopProductData &&
+ shopProductData?.map((item, idx) => {
+ return (
+
+
+
+ );
+ })}
+
+ {isFetchingNextPage && (
+
+ )}
+
+
+ {(isFetchingNextPage || hasNextPage) && }
+
+
+ );
+};
+
+export default ShopPage;
diff --git a/src/routes/AdminRoutes.tsx b/src/routes/AdminRoutes.tsx
new file mode 100644
index 0000000..e95bcbe
--- /dev/null
+++ b/src/routes/AdminRoutes.tsx
@@ -0,0 +1,47 @@
+import { Route, Routes } from 'react-router-dom';
+import AdminLayout from '@/layouts/adminLayout/AdminLayout';
+import ProtectPrivateRoute from '@/routes/ProtectPrivateRoute';
+import AdminLoginPage from '@/pages/admin/AdminLoginPage';
+import AdminPage from '@/pages/admin/main/AdminPage';
+import ProductAdd from '@/pages/admin/product/ProductAdd';
+import ProductSearch from '@/pages/admin/product/ProductSearch';
+import SaleSearch from '@/pages/admin/sale/search/SaleSearch';
+import SalePayment from '@/pages/admin/sale/payment/SalePayment';
+import SaleOrdering from '@/pages/admin/sale/ordering/SaleOrdering';
+import SaleDelivery from '@/pages/admin/sale/delivery/SaleDelivery';
+import BannerPage from '@/pages/admin/store/BannerPage';
+import BestItemPage from '@/pages/admin/store/BestItemPage';
+import MdsChoicePage from '@/pages/admin/store/MdsChoicePage';
+import PromotionPage from '@/pages/admin/store/PromotionPage';
+
+const AdminRoutes = () => (
+
+ {/* Admin Login */}
+ } />
+
+ {/* Protected Admin Pages */}
+ }>
+ }>
+ } />
+
+ } />
+ } />
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+
+
+);
+
+export default AdminRoutes;
diff --git a/src/routes/AuthInitializer.tsx b/src/routes/AuthInitializer.tsx
new file mode 100644
index 0000000..c60d9c0
--- /dev/null
+++ b/src/routes/AuthInitializer.tsx
@@ -0,0 +1,59 @@
+import { client } from '@/api/client';
+import { useAuthStore } from '@/config/store';
+import { useEffect, useState } from 'react';
+import { Outlet, useLocation, useNavigate } from 'react-router-dom';
+
+const AuthInitializer = () => {
+ const [isInitialized, setIsInitialized] = useState(false);
+ const { setUser, clearUser } = useAuthStore();
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ useEffect(() => {
+ const initializeAuth = async () => {
+ // INFO: 첫 요청 이후 발생되는 훅 실행 막기 위한 장치
+ if (isInitialized) return;
+
+ const accessToken = localStorage.getItem('accessToken');
+
+ if (!accessToken) {
+ clearUser();
+ setIsInitialized(true);
+ return;
+ }
+
+ // INFO: 사용자 정보 조회, accessToken이 유효한지 체크하는 API
+ try {
+ const response = await client.get('/auth/protected', {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ if (response.data) {
+ setUser(response.data);
+ } else {
+ clearUser();
+
+ if (location.state === '/mypage') {
+ navigate('/auth/signin', { replace: true });
+ }
+ }
+ } catch (error) {
+ console.error(error);
+ clearUser();
+ if (location.state === '/mypage') {
+ navigate('/auth/signin', { replace: true });
+ }
+ } finally {
+ setIsInitialized(true);
+ }
+ };
+
+ initializeAuth();
+ }, [navigate, setUser, clearUser]);
+
+ if (!isInitialized) return;
+
+ return ;
+};
+
+export default AuthInitializer;
diff --git a/src/routes/PrivateRoute.tsx b/src/routes/PrivateRoute.tsx
deleted file mode 100644
index b9b4ace..0000000
--- a/src/routes/PrivateRoute.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Outlet } from 'react-router-dom';
-
-const PrivateRoute = () => {
- return ;
-};
-
-export default PrivateRoute;
diff --git a/src/routes/AdminRoute.tsx b/src/routes/ProtectAdminRoute.tsx
similarity index 50%
rename from src/routes/AdminRoute.tsx
rename to src/routes/ProtectAdminRoute.tsx
index 858af86..4c9d023 100644
--- a/src/routes/AdminRoute.tsx
+++ b/src/routes/ProtectAdminRoute.tsx
@@ -1,7 +1,7 @@
import { Outlet } from 'react-router-dom';
-const AdminRoute = () => {
+const ProtectAdminRoute = () => {
return ;
};
-export default AdminRoute;
+export default ProtectAdminRoute;
diff --git a/src/routes/ProtectPrivateRoute.tsx b/src/routes/ProtectPrivateRoute.tsx
new file mode 100644
index 0000000..bf1e1f1
--- /dev/null
+++ b/src/routes/ProtectPrivateRoute.tsx
@@ -0,0 +1,7 @@
+import { Outlet } from 'react-router-dom';
+
+const ProtectPrivateRoute = () => {
+ return ;
+};
+
+export default ProtectPrivateRoute;
diff --git a/src/routes/PublicRoutes.tsx b/src/routes/PublicRoutes.tsx
new file mode 100644
index 0000000..6e7416d
--- /dev/null
+++ b/src/routes/PublicRoutes.tsx
@@ -0,0 +1,74 @@
+import { Route, Routes } from 'react-router-dom';
+import PublicLayout from '@/layouts/publicLayout/PublicLayout';
+import ProtectPrivateRoute from '@/routes/ProtectPrivateRoute';
+import HomePage from '@/pages/home/HomePage';
+import ShopPage from '@/pages/shop/shopPage/ShopPage';
+import CategoryPage from '@/pages/shop/categoryPage/CategoryPage';
+import DetailPage from '@/pages/shop/detailPage/DetailPage';
+import EventMainPage from '@/pages/event/EventMainPage';
+import EventDetailPage from '@/pages/event/EventDetailPage';
+import SignInPage from '@/pages/auth/SignInPage';
+import SignUpPage from '@/pages/auth/SignUpPage';
+import MyPage from '@/pages/mypage/MyPage';
+import CartPage from '@/pages/cart/CartPage';
+import CheckoutPage from '@/pages/checkout/CheckoutPage';
+import CheckoutCompletePage from '@/pages/checkout/CheckoutCompletePage';
+import NoticePage from '@/pages/notice/NoticePage';
+import SignUpCompletePage from '@/pages/auth/SignUpCompletePage';
+import FindPwPage from '@/pages/auth/FindPwPage';
+import FindIdPage from '@/pages/auth/FindIdPage';
+import AuthInitializer from './AuthInitializer';
+import FindIdCompletePage from '@/pages/auth/FindIdCompletePage';
+import FindPwCompletePage from '@/pages/auth/FindPwCompletePage';
+import FindPwChangePage from '@/pages/auth/FindPwChangePage';
+import KakaoCallbackPage from '@/pages/auth/KakaoCallbackPage';
+import NaverCallbackPage from '@/pages/auth/NaverCallbackPage';
+
+const PublicRoutes = () => (
+
+ }>
+ }>
+ {/* 쇼핑 */}
+ } />
+ } />
+ } />
+ } />
+
+ {/* 이벤트 */}
+ } />
+ } />
+
+ {/* 인증 */}
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ {/* 장바구니 */}
+ } />
+
+ {/* 결제 */}
+ } />
+ } />
+
+ {/* 공지사항 */}
+ } />
+
+ {/* 메인 */}
+ } />
+
+ }>
+ } />
+
+
+
+
+);
+
+export default PublicRoutes;
diff --git a/src/utils/QueryClientProvider.tsx b/src/utils/QueryClientProvider.tsx
new file mode 100644
index 0000000..99f9a41
--- /dev/null
+++ b/src/utils/QueryClientProvider.tsx
@@ -0,0 +1,7 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+export const QueryClientBoundary = ({ children }: React.PropsWithChildren) => {
+ const queryClient = new QueryClient();
+
+ return {children} ;
+};
diff --git a/src/utils/decodeJwt.ts b/src/utils/decodeJwt.ts
new file mode 100644
index 0000000..c273cd7
--- /dev/null
+++ b/src/utils/decodeJwt.ts
@@ -0,0 +1,8 @@
+export const decodeJwt = (token: string) => {
+ const base64Url = token.split('.')[1]; // Payload Data
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+ const paddedBase64 = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=');
+ const jsonPayload = atob(paddedBase64);
+
+ return JSON.parse(jsonPayload);
+};
diff --git a/src/utils/handleApiError.ts b/src/utils/handleApiError.ts
new file mode 100644
index 0000000..0d696dd
--- /dev/null
+++ b/src/utils/handleApiError.ts
@@ -0,0 +1,35 @@
+import axios from 'axios';
+
+// 공통 에러 처리 함수
+export const handleApiError = (err: unknown) => {
+ // 'any'를 'unknown'으로 변경
+ if (axios.isAxiosError(err)) {
+ // Axios 에러인지 확인하는 조건 추가
+ const { response } = err;
+ if (response) {
+ const { status } = response;
+ switch (status) {
+ case 400:
+ throw new Error('잘못된 요청입니다. 요청 내용을 확인하세요.');
+ case 401:
+ case 403:
+ throw new Error('로그인 후 이용가능해요.');
+ case 404:
+ throw new Error('해당하는 상품이 없어요.');
+ case 500:
+ throw new Error('서버 오류가 발생했습니다. 잠시 후 다시 시도하세요.');
+ default:
+ throw new Error(response.data?.error || '알 수 없는 오류가 발생했습니다.');
+ }
+ } else if (err.request) {
+ throw new Error('서버와 연결할 수 없습니다. 인터넷 연결을 확인하세요.');
+ } else if (typeof err === 'object' && err !== null && 'message' in err) {
+ // 오류 메시지 처리 개선
+ throw new Error((err as any).message);
+ } else {
+ throw new Error('요청 중 오류가 발생했습니다.');
+ }
+ } else {
+ throw err;
+ }
+};
diff --git a/src/utils/testFn.ts b/src/utils/testFn.ts
deleted file mode 100644
index bc51dfb..0000000
--- a/src/utils/testFn.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const testFn = () => {
- return 'hi';
-};
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 11f02fe..553b16e 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1 +1,9 @@
///
+
+interface ImportMetaEnv {
+ readonly VITE_SWEETTRACKER_API_KEY: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index d37737f..1571eec 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -5,8 +5,57 @@ export default {
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
- extend: {},
+ extend: {
+ animation: {
+ 'fade-in': 'fadeIn 0.5s ease-in-out',
+ 'fade-out': 'fadeOut 0.5s ease-in-out',
+ shimmer: 'shimmer 1.3s linear infinite'
+ },
+ keyframes: {
+ fadeIn: {
+ '0%': { opacity: '0' },
+ '100%': { opacity: '1' },
+ },
+ fadeOut: {
+ '0%': { opacity: '1' },
+ '100%': { opacity: '0' },
+ },
+ shimmer: {
+ '100%': {
+ transform: 'translateX(100%)'
+ }
+ },
+ },
+ boxShadow: {
+ 'top': '0 0 10px rgba(0, 0, 0, 0.3)',
+ },
+ colors: {
+ primary: "#000000", // 메인 브랜드 색상
+ secondary: "#ffffff", // 배경에 사용하는 중립 색상
+ accent: "#F77830", // 포인트 색상
+ gray100: "#F1F1F1",
+ gray200: "#C5C5C5",
+ gray300: "#CBCAC7",
+ gray400: "#C7C7C7",
+ gray500: "#B2B2B2",
+ gray700: "#7F7F7F",
+ blue700: "#0000FF", // 링크
+ success: "#008541",
+ error: '#FF0E00',
+ naver: '#00DE5A',
+ transparentBlack: 'rgba(0, 0, 0, 0.5)'
+ },
+ fontFamily: {
+ sans: ["Noto Sans KR", "Arial", "sans-serif"],
+ roboto: ["Roboto", "sans-serif"]
+ }
+ },
},
- plugins: [],
-}
+ corePlugins: {
+ aspectRatio: false,
+ },
+ plugins: [
+ require('@tailwindcss/aspect-ratio'),
+ ],
+}