diff --git a/.eslintrc.cjs b/.eslintrc.cjs
new file mode 100644
index 00000000..f5e75171
--- /dev/null
+++ b/.eslintrc.cjs
@@ -0,0 +1,16 @@
+module.exports = {
+ env: { browser: true, es2020: true, node: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended'
+ ],
+ parser: '@typescript-eslint/parser',
+ parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
+ plugins: ['react-refresh', 'prettier'], //추가사항 - prettier
+ rules: {
+ 'react-refresh/only-export-components': 'warn',
+ 'no-extra-semi': 'off',
+ '@typescript-eslint/no-extra-semi': 'error'
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..8617331e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,28 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+package-lock.json
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+#env
+.env
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..755a1f74
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,9 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "endOfLine": "lf",
+ "singleAttributePerLine": true,
+ "bracketSameLine": true,
+ "trailingComma": "none",
+ "arrowParens": "avoid"
+}
diff --git a/README.md b/README.md
index c81804bf..7f375a37 100644
--- a/README.md
+++ b/README.md
@@ -1,1319 +1,77 @@
-# 🤝 거래 API 활용, 팀 프로젝트
-
-주어진 API를 분석해 어떤 프로젝트를 진행/완성할 것인지 팀 단위로 자유롭게 결정하고 만들어보세요.
-TypeScript를 필수로 사용해야 합니다.
-과제 수행 및 리뷰 기간은 별도 공지를 참고하세요!
-
-## 과제 수행 및 제출 방법
-
-```
-KDT기수번호_이름
-
-E.g, KDT0_ParkYoungWoong
-```
-
-1. 현재 저장소를 로컬에 클론(Clone)합니다.
-1. 자신의 본명으로 브랜치를 생성합니다.(구분 가능하도록 본명을 꼭 파스칼케이스로 표시하세요, `git branch KDTX_ParkYoungWoong`)
-1. 자신의 본명 브랜치에서 과제를 수행합니다.
-1. 과제 수행이 완료되면, 자신의 본명 브랜치를 원격 저장소에 푸시(Push)합니다.(`main` 브랜치에 푸시하지 않도록 꼭 주의하세요, `git push origin KDTX_ParkYoungWoong`)
-1. 저장소에서 `main` 브랜치를 대상으로 Pull Request 생성하면, 과제 제출이 완료됩니다!(E.g, `main` <== `KDTX_ParkYoungWoong`)
-
-- `main` 혹은 다른 사람의 브랜치로 절대 병합하지 않도록 주의하세요!
-- Pull Request에서 보이는 설명을 다른 사람들이 이해하기 쉽도록 꼼꼼하게 작성하세요!
-- Pull Request에서 과제 제출 후 절대 병합(Merge)하지 않도록 주의하세요!
-- 과제 수행 및 제출 과정에서 문제가 발생한 경우, 바로 담당 멘토나 강사에서 얘기하세요!
-
-## API 사용법
-
-모든 API 요청(Request) `headers`에 아래 정보가 꼭 포함돼야 합니다!
-`username`은 `KDT5_TeamX`와 같이 본명 혹은 팀 이름을 포함해야 합니다!
-확인할 수 없는 사용자나 팀의 DB 정보는 임의로 삭제될 수 있습니다!
-
-```json
-{
- "content-type": "application/json",
- "apikey": "KDT5_nREmPe9B",
- "username": "KDT5_TeamX"
-}
-```
-
-
-
-## 인증
-
-'인증' 관련 API는 모두 일반 사용자 전용입니다.
-
-### 회원가입
-
-사용자가 `username`에 종속되어 회원가입합니다.
-
-- 사용자 비밀번호는 암호화해 저장합니다.(관리자는 확인할 수 없습니다!)
-- 프로필 이미지는 1MB 이하여야 합니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/auth/signup
- \ -X 'POST'
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- email: string // 사용자 아이디 (필수!)
- password: string // 사용자 비밀번호, 8자 이상 (필수!)
- displayName: string // 사용자 이름, 20자 이하 (필수!)
- profileImgBase64?: string // 사용자 프로필 이미지(base64) - jpg, jpeg, webp, png, gif, svg
-}
-```
-
-```json
-{
- "email": "thesecon@gmail.com",
- "password": "********",
- "displayName": "ParkYoungWoong",
- "profileImgBase64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAf...(생략)"
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue {
- user: { // 회원가입한 사용자 정보
- email: string // 사용자 아이디
- displayName: string // 사용자 표시 이름
- profileImg: string | null // 사용자 프로필 이미지(URL)
- }
- accessToken: string // 사용자 접근 토큰
-}
-```
-
-```json
-{
- "user": {
- "email": "thesecon@gmail.com",
- "displayName": "ParkYoungWoong",
- "profileImg": "https://storage.googleapis.com/heropy-api/vjbtIrh5dGv163442.png"
- },
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IlM3WDhpQ...(생략)"
-}
-```
-
-### 로그인
-
-- 발급된 `accessToken`은 24시간 후 만료됩니다.(만료 후 다시 로그인 필요)
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/auth/login
- \ -X 'POST'
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- email: string // 사용자 아이디 (필수!)
- password: string // 사용자 비밀번호 (필수!)
-}
-```
-
-```json
-{
- "email": "thesecon@gmail.com",
- "password": "********"
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue {
- user: { // 회원가입한 사용자 정보
- email: string // 사용자 아이디
- displayName: string // 사용자 표시 이름
- profileImg: string | null // 사용자 프로필 이미지(URL)
- }
- accessToken: string // 사용자 접근 토큰
-}
-```
-
-```json
-{
- "user": {
- "email": "thesecon@gmail.com",
- "displayName": "ParkYoungWoong",
- "profileImg": "https://storage.googleapis.com/heropy-api/vAKjlJ-Gx5v163442.png"
- },
- "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjlQS3I...(생략)"
-}
-```
-
-### 인증 확인
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/auth/me
- \ -X 'POST'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue {
- email: string // 사용자 아이디
- displayName: string // 사용자 표시 이름
- profileImg: string | null // 사용자 프로필 이미지(URL)
-}
-```
-
-```json
-{
- "email": "thesecon@gmail.com",
- "displayName": "ParkYoungWoong",
- "profileImg": "https://storage.googleapis.com/heropy-api/vAKjlJ-Gx5v163442.png"
-}
-```
-
-### 로그아웃
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/auth/logout
- \ -X 'POST'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = true // 로그아웃 처리 상태
-```
-
-### 사용자 정보 수정
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/auth/user
- \ -X 'PUT'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- displayName?: string // 새로운 표시 이름
- profileImgBase64?: string // 사용자 프로필 이미지(base64) - jpg, jpeg, webp, png, gif, svg
- oldPassword?: string // 기존 비밀번호
- newPassword?: string // 새로운 비밀번호
-}
-```
-
-```json
-{
- "oldPassword": "********",
- "newPassword": "**********"
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue {
- email: string // 사용자 아이디
- displayName: string // 사용자 표시 이름
- profileImg: string | null // 사용자 프로필 이미지(URL)
-}
-```
-
-```json
-{
- "email": "thesecon@gmail.com",
- "displayName": "ParkYoungWoong",
- "profileImg": "https://storage.googleapis.com/heropy-api/vAKjlJ-Gx5v163442.png"
-}
-```
-
-### 사용자 목록 조회
-
-- 관리자 전용 API입니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/auth/users
- \ -X 'GET'
- \ -H 'masterKey: true'
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = User[]
-
-interface User {
- email: string // 사용자 아이디
- displayName: string // 사용자 표시 이름
- profileImg: string // 사용자 프로필 이미지 URL
-}
-```
-
-```json
-[
- {
- "email": "thesecon@gmail.com",
- "displayName": "HEROPY",
- "profileImg": null
- },
- {
- "email": "neo@zillinks.com",
- "displayName": "박영웅",
- "profileImg": "https://storage.googleapis.com/heropy-api/Z_una7lyijv074804.png"
- },
- {
- "email": "test@test.com",
- "displayName": "관리자",
- "profileImg": "https://storage.googleapis.com/heropy-api/ZXcXjwsB7nv121507.png"
- }
-]
-```
-
-
-
-## 계좌
-
-'계좌' 관련 API는 모두 일반 사용자 전용입니다.
-
-### 선택 가능한 은행 목록 조회
-
-- 은행 당 하나의 계좌만 허용됩니다.
-- 사용자가 계좌를 추가하면, 해당 은행 정보 `disabled` 속성이 `true`로 변경됩니다.
-- 은행 정보 `digits` 속성의 숫자를 모두 더하면 각 은행의 유효한 계좌번호 길이가 됩니다.
-- `[3, 2, 4, 3]` => 123-12-1234-123
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/account/banks
- \ -X 'GET'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = Bank[] // 선택 가능한 은행 정보 목록
-
-interface Bank { // 선택 가능한 은행 정보
- name: string // 은행 이름
- code: string // 은행 코드
- digits: number[] // 은행 계좌 자릿수
- disabled: boolean // 사용자가 추가한 계좌 여부
-}
-```
-
-```json
-[
- {
- "name": "KB국민은행",
- "code": "004",
- "digits": [3, 2, 4, 3],
- "disabled": false
- },
- {
- "name": "신한은행",
- "code": "088",
- "digits": [3, 3, 6],
- "disabled": true
- },
- {
- "name": "우리은행",
- "code": "020",
- "digits": [4, 3, 6],
- "disabled": true
- },
- {
- "name": "하나은행",
- "code": "081",
- "digits": [3, 6, 5],
- "disabled": false
- },
- {
- "name": "케이뱅크",
- "code": "089",
- "digits": [3, 3, 6],
- "disabled": false
- },
- {
- "name": "카카오뱅크",
- "code": "090",
- "digits": [4, 2, 7],
- "disabled": false
- },
- {
- "name": "NH농협은행",
- "code": "011",
- "digits": [3, 4, 4, 2],
- "disabled": false
- }
-]
-```
-
-### 계좌 목록 및 잔액 조회
-
-- 계좌번호는 일부만 노출됩니다. E.g. `"123-XXXX-XXXX-XX"`
-- 잔액의 단위는 '원화(₩)'입니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/account
- \ -X 'GET'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue {
- totalBalance: number // 사용자 계좌 잔액 총합
- accounts: Bank[] // 사용자 계좌 정보 목록
-}
-
-interface Bank { // 사용자 계좌 정보
- id: string // 계좌 ID
- bankName: string // 은행 이름
- bankCode: string // 은행 코드
- accountNumber: string // 계좌 번호
- balance: number // 계좌 잔액
-}
-```
-
-```json
-{
- "totalBalance": 5999900,
- "accounts": [
- {
- "id": "jQMfKla8vOIFELA3mAXv",
- "bankName": "NH농협은행",
- "bankCode": "011",
- "accountNumber": "356-XXXX-XXXX-XX",
- "balance": 2999900
- },
- {
- "id": "wiPgsXvMAmcLw8AuRHIi",
- "bankName": "KB국민은행",
- "bankCode": "004",
- "accountNumber": "123-XX-XXXX-XXX",
- "balance": 3000000
- }
- ]
-}
-```
-
-### 계좌 연결
-
-- 연결된 계좌 잔액에는 자동으로 기본 '3백만원'이 추가됩니다.
-- 요청하는 계좌번호와 전화번호에는 `-` 구분이 없어야 합니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/account
- \ -X 'POST'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- bankCode: string // 연결할 은행 코드 (필수!)
- accountNumber: string // 연결할 계좌번호 (필수!)
- phoneNumber: string // 사용자 전화번호 (필수!)
- signature: boolean // 사용자 서명 (필수!)
-}
-```
-
-```json
-{
- "bankCode": "088",
- "accountNumber": "123456789012",
- "phoneNumber": "01012345678",
- "signature": true
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue { // 연결된 계좌 정보
- id: string // 계좌 ID
- bankName: string // 은행 이름
- bankCode: string // 은행 코드
- accountNumber: string // 계좌 번호
- balance: number // 계좌 잔액
-}
-```
-
-```json
-{
- "id": "1qRFC6Ey5VkSu6nyj5Ba",
- "bankName": "신한은행",
- "bankCode": "088",
- "accountNumber": "123-XXX-XXXXXX",
- "balance": 3000000
-}
-```
-
-### 계좌 해지
-
-- 해지한 계좌는 다시 연결해도 잔액이 반영되지 않습니다.(기본 금액으로 추가됩니다)
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/account
- \ -X 'DELETE'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- accountId: string // 계좌 ID (필수!)
- signature: boolean // 사용자 서명 (필수!)
-}
-```
-
-```json
-{
- "accountId": "jQMfKla8vOIFELA3mAXv",
- "signature": true
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = true // 계좌 해지 처리 상태
-```
-
-
-
-## 제품
-
-'제품' 관련 API는 관리자 전용과 일반 사용자 전용으로 구분됩니다.
-공용 API도 있으니 주의하세요!
-
-### 모든 제품 조회
-
-- 관리자 전용 API입니다.
-- 상세 정보가 아닌 기본 정보의 제품 설명은 100자까지만 포함됩니다.
-- 상세 정보가 아닌 기본 정보의 제품 상세 사진은 포함되지 않습니다.
-- 제품 할인율(`discountRate`)은 제품 가격과 직접 관계가 없는 단순 메모 속성입니다.
-- 제품 할인율이 없는 경우, `0`으로 표시됩니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products
- \ -X 'GET'
- \ -H 'masterKey: true'
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = Product[] // 관리하는 모든 제품의 목록
-
-interface Product { // 제품 정보
- id: string // 제품 ID
- title: string // 제품 이름
- price: number // 제품 가격
- description: string // 제품 설명(최대 100자)
- tags: string[] // 제품 태그
- thumbnail: string | null // 제품 썸네일 이미지(URL)
- isSoldOut: boolean // 제품 매진 여부
- discountRate: number // 제품 할인율
-}
-```
-
-```json
-[
- {
- "id": "cFmeC7aY5KjZbBAdJE9y",
- "title": "삼성전자 스마트모니터 M7 S43AM700",
- "price": 639000,
- "description": "107.9cm(43인치) / 와이드(16:9) / 평면 / VA / 3840 x 2160(4K UHD) / 픽셀피치: 0.2451mm / 8ms(GTG) / 300cd / 5,00",
- "tags": [
- "가전",
- "모니터",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vBAK4MQdH5v195712.png",
- "isSoldOut": false,
- "discountRate": 20
- },
- {
- "id": "nbqtQvEivYwEXTDet7YM",
- "title": "MacBook Pro 16",
- "price": 3360000,
- "description": "역대 가장 강력한 MacBook Pro가 등장했습니다. 최초의 프로용 Apple Silicon인 M1 Pro 또는 M1 Max 칩을 탑재해 쏜살같이 빠른 속도는 물론, 획기적인 성",
- "tags": [
- "가전",
- "노트북",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vIKMk_jy4Yv195256.png",
- "isSoldOut": false,
- "discountRate": 0
- }
-]
-```
-
-### 전체 거래(판매) 내역
-
-- 관리자 전용 API입니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/transactions/all
- \ -X 'GET'
- \ -H 'masterKey: true'
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-type RequestValue = TransactionDetail[] // 모든 거래 내역의 목록
-
-interface TransactionDetail { // 거래 내역 정보
- detailId: string // 거래 내역 ID
- user: { // 거래한 사용자 정보
- email: string
- displayName: string
- profileImg: string | null
- }
- account: { // 거래한 사용자의 계좌 정보
- bankName: string
- bankCode: string
- accountNumber: string
- }
- product: { // 거래한 제품 정보
- productId: string
- title: string
- price: number
- description: string
- tags: string[]
- thumbnail: string | null
- discountRate: number
- }
- reservation: Reservation | null // 거래한 제품의 예약 정보
- timePaid: string // 제품을 거래한 시간
- isCanceled: boolean // 거래 취소 여부
- done: boolean // 거래 완료 여부
-}
-
-interface Reservation {
- start: string // 예약 시작 시간
- end: string // 예약 종료 시간
- isCanceled: boolean // 예약 취소 여부
- isExpired: boolean // 예약 만료 여부
-}
-```
-
-```json
-[
- {
- "detailId": "dMhfxyrAupQP18OYmywy",
- "user": {
- "email": "thesecon@gmail.com",
- "displayName": "ParkYoungWoong",
- "profileImg": "https://storage.googleapis.com/heropy-api/vsLRqTlPO5v200111.png"
- },
- "account": {
- "bankName": "KB국민은행",
- "bankCode": "004",
- "accountNumber": "123-XX-XXXX-XXX"
- },
- "product": {
- "productId": "cFmeC7aY5KjZbBAdJE9y",
- "title": "삼성전자 스마트모니터 M7 S43AM700",
- "price": 639000,
- "description": "107.9cm(43인치) / 와이드(16:9) / 평면 / VA / 3840 x 2160(4K UHD) / 픽셀피치: 0.2451mm / 8ms(GTG) / 300cd / 5,00",
- "tags": [
- "가전",
- "모니터",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vBAK4MQdH5v195712.png",
- "discountRate": 0
- },
- "reservation": null,
- "timePaid": "2021-11-07T20:01:49.100Z",
- "isCanceled": false,
- "done": false
- }
-]
-```
-
-예약 정보(`reservation`)가 있는 경우:
-
-```json
-[
- {
- "reservation": {
- "start": "2021-11-12T06:00:00.000Z",
- "end": "2021-11-12T07:00:00.000Z",
- "isCanceled": false,
- "isExpired": true
- }
- }
-]
-```
-
-### 거래(판매) 내역 완료/취소 및 해제
-
-- 관리자 전용 API입니다.
-- 거래 내역을 취소하면, 예약도 같이 취소됩니다.
-- 거래 내역을 취소 해제하면, 예약도 같이 취소가 해제됩니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/transactions/:detailId
- \ -X 'PUT'
- \ -H 'masterKey: true'
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- isCanceled?: boolean // 거래 취소 여부 (사용자의 '제품 거래(구매) 취소' 상태와 같습니다)
- done?: boolean // 거래 완료 여부 (사용자의 '제품 거래(구매) 확정' 상태와 같습니다)
-}
-```
-
-```json
-{
- "isCanceled": true
-}
-```
-
-응답 데이터 타입 및 예시:
-
-
-```ts
-type ResponseValue = true // 거래 내역 완료/취소 및 해제 처리 상태
-```
-
-### 제품 추가
-
-- 관리자 전용 API입니다.
-- 파일(사진)은 Base64로 요청해야 합니다.
-- 제품 썸네일 사진은 1MB 이하여야 합니다.
-- 제품 상세 사진은 4MB 이하여야 합니다.
-- 제품 할인율(`discountRate`)은 제품 가격과 직접 관계가 없는 단순 메모 속성입니다.
-- 제품 할인율은 `0`~`99` 사이 숫자를 입력하세요. 만약 할인율이 '20%'인 경우, `20`으로 입력해야 합니다.
-- 제품 할인율을 입력하지 않으면, `0`으로 적용됩니다.
-
-```js
-// 할인 전 가격을 계산!
-const priceBeforeDiscount = price * 100 / (100 - discountRate)
-```
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products
- \ -X 'POST'
- \ -H 'masterKey: true'
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- title: string // 제품 이름 (필수!)
- price: number // 제품 가격 (필수!)
- description: string // 제품 상세 설명 (필수!)
- tags?: string[] // 제품 태그
- thumbnailBase64?: string // 제품 썸네일(대표) 사진(base64) - jpg, jpeg, webp, png, gif, svg
- photoBase64?: string // 제품 상세 사진(base64) - jpg, jpeg, webp, png, gif, svg
- discountRate?: number // 제품 할인율
-}
-```
-
-```json
-{
- "title": "MacBook Pro 16",
- "price": 3360000,
- "description": "역대 가장 강력한 MacBook Pro가 등장했습니다. 최초의 프로용 Apple Silicon인 M1 Pro 또는 M1 Max 칩을 탑재해 쏜살같이 빠른 속도는 물론, 획기적인 성능과 놀라운 배터리 사용 시간을 자랑하죠. 여기에 시선을 사로잡는 Liquid Retina XDR 디스플레이, Mac 노트북 사상 최고의 카메라 및 오디오 그리고 더할 나위 없이 다양한 포트까지. 기존 그 어떤 카테고리에도 속하지 않는 노트북. 새로운 MacBook Pro는 그야말로 야수입니다.",
- "tags": [
- "가전",
- "노트북",
- "컴퓨터"
- ],
- "thumbnailBase64": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...(생략)"
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue { // 추가한 제품의 상세 내용
- id: string // 제품 ID
- title: string // 제품 이름
- price: number // 제품 가격
- description: string // 제품 상세 설명
- tags: string[] // 제품 태그
- thumbnail: string | null // 제품 썸네일 이미지(URL)
- photo: string | null // 제품 상세 이미지(URL)
- isSoldOut: boolean // 제품 매진 여부
- discountRate: number // 제품 할인율
-}
-```
-
-```json
-{
- "id": "nbqtQvEivYwEXTDet7YM",
- "title": "MacBook Pro 16",
- "price": 3360000,
- "description": "역대 가장 강력한 MacBook Pro가 등장했습니다. 최초의 프로용 Apple Silicon인 M1 Pro 또는 M1 Max 칩을 탑재해 쏜살같이 빠른 속도는 물론, 획기적인 성능과 놀라운 배터리 사용 시간을 자랑하죠. 여기에 시선을 사로잡는 Liquid Retina XDR 디스플레이, Mac 노트북 사상 최고의 카메라 및 오디오 그리고 더할 나위 없이 다양한 포트까지. 기존 그 어떤 카테고리에도 속하지 않는 노트북. 새로운 MacBook Pro는 그야말로 야수입니다.",
- "tags": [
- "가전",
- "노트북",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vIKMk_jy4Yv195256.png",
- "photo": "https://storage.googleapis.com/heropy-api/voihKb3NLGcv195257.png",
- "isSoldOut": false,
- "discountRate": 0
-}
-```
-
-### 제품 수정
-
-- 관리자 전용 API입니다.
-- 사용자의 구매 내역 확인을 위해, 제품을 실제로는 삭제하지 않고 매진(Sold Out) 처리해야 합니다.
-- 매진은 다시 해제할 수 있습니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/:productId
- \ -X 'PUT'
- \ -H 'masterKey: true'
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- title?: string // 제품 이름
- price?: number // 제품 가격
- description?: string // 제품 상세 설명
- tags?: string[] // 제품 태그
- thumbnailBase64?: string // 제품 썸네일(대표) 사진(base64) - jpg, jpeg, webp, png, gif, svg
- photoBase64?: string // 제품 상세 사진(base64) - jpg, jpeg, webp, png, gif, svg
- isSoldOut?: boolean // 제품 매진 여부
- discountRate?: number // 제품 할인율
-}
-```
-
-```json
-{
- "price": 1500
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue { // 수정한 제품의 상세 내용
- id: string // 제품 ID
- title: string // 제품 이름
- price: number // 제품 가격
- description: string // 제품 상세 설명
- tags: string[] // 제품 태그
- thumbnail: string | null // 제품 썸네일 이미지(URL)
- photo: string | null // 제품 상세 이미지(URL)
- isSoldOut: boolean // 제품 매진 여부
- discountRate: number // 제품 할인율
-}
-```
-
-```json
-{
- "id": "nbqtQvEivYwEXTDet7YM",
- "title": "MacBook Pro 16",
- "price": 1500,
- "description": "역대 가장 강력한 MacBook Pro가 등장했습니다. 최초의 프로용 Apple Silicon인 M1 Pro 또는 M1 Max 칩을 탑재해 쏜살같이 빠른 속도는 물론, 획기적인 성능과 놀라운 배터리 사용 시간을 자랑하죠. 여기에 시선을 사로잡는 Liquid Retina XDR 디스플레이, Mac 노트북 사상 최고의 카메라 및 오디오 그리고 더할 나위 없이 다양한 포트까지. 기존 그 어떤 카테고리에도 속하지 않는 노트북. 새로운 MacBook Pro는 그야말로 야수입니다.",
- "tags": [
- "가전",
- "노트북",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vIKMk_jy4Yv195256.png",
- "photo": "https://storage.googleapis.com/heropy-api/voihKb3NLGcv195257.png",
- "isSoldOut": false,
- "discountRate": 0
-}
-```
-
-### 제품 삭제
-
-- 관리자 전용 API입니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/:productId
- \ -X 'DELETE'
- \ -H 'masterKey: true'
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = true // 제품 삭제 처리 상태
-```
-
-### 단일 제품 상세 조회
-
-- 공용 API입니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/:productId
- \ -X 'GET'
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface ResponseValue { // 제품의 상세 내용
- id: string // 제품 ID
- title: string // 제품 이름
- price: number // 제품 가격
- description: string // 제품 상세 설명
- tags: string[] // 제품 태그
- thumbnail: string | null // 제품 썸네일 이미지(URL)
- photo: string | null // 제품 상세 이미지(URL)
- isSoldOut: boolean // 제품 매진 여부
- reservations: Reservation[] // 제품의 모든 예약 정보 목록
- discountRate: number // 제품 할인율
-}
-
-interface Reservation {
- start: string // 예약 시작 시간
- end: string // 예약 종료 시간
- isCanceled: boolean // 예약 취소 여부
- isExpired: boolean // 예약 만료 여부
-}
-```
-
-```json
-{
- "id": "nbqtQvEivYwEXTDet7YM",
- "title": "MacBook Pro 16",
- "price": 3360000,
- "description": "역대 가장 강력한 MacBook Pro가 등장했습니다. 최초의 프로용 Apple Silicon인 M1 Pro 또는 M1 Max 칩을 탑재해 쏜살같이 빠른 속도는 물론, 획기적인 성능과 놀라운 배터리 사용 시간을 자랑하죠. 여기에 시선을 사로잡는 Liquid Retina XDR 디스플레이, Mac 노트북 사상 최고의 카메라 및 오디오 그리고 더할 나위 없이 다양한 포트까지. 기존 그 어떤 카테고리에도 속하지 않는 노트북. 새로운 MacBook Pro는 그야말로 야수입니다.",
- "tags": [
- "가전",
- "노트북",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vIKMk_jy4Yv195256.png",
- "photo": "https://storage.googleapis.com/heropy-api/voihKb3NLGcv195257.png",
- "isSoldOut": false,
- "reservations": [],
- "discountRate": 0
-}
-```
-
-예약 정보(`reservation`)가 있는 경우:
-
-```json
-{
- "reservations": [
- {
- "reservation": {
- "start": "2021-11-12T06:00:00.000Z",
- "end": "2021-11-12T07:00:00.000Z",
- "isCanceled": false,
- "isExpired": true
- }
- }
- ]
-}
-```
-
-### 제품 검색
-
-- 사용자 전용 API입니다.
-- 제품 이름과 태그를 동시에 검색할 수 있고, 'And'(검색한 이름과 태그 모두 포함된 제품) 조건으로 결과를 반환합니다.
-- 제품 이름과 태그 모두 포함하지 않으면, 모든 제품의 결과를 반환합니다.
-- 제품의 기본 정보만 반환합니다.
-- 매진된 제품은 검색되지 않습니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/search
- \ -X 'POST'
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- searchText?: string // 검색할 제품 이름
- searchTags?: string[] // 검색할 제품 태그
-}
-```
-
-```json
-{
- "searchText": "삼성전자",
- "searchTags": ["가전"]
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = Product[] // 관리하는 모든 제품의 목록
-
-interface Product { // 제품 정보
- id: string // 제품 ID
- title: string // 제품 이름
- price: number // 제품 가격
- description: string // 제품 설명(최대 100자)
- tags: string[] // 제품 태그
- thumbnail: string | null // 제품 썸네일 이미지(URL)
- discountRate: number // 제품 할인율
-}
-```
-
-```json
-[
- {
- "id": "cFmeC7aY5KjZbBAdJE9y",
- "title": "삼성전자 스마트모니터 M7 S43AM700",
- "price": 639000,
- "description": "107.9cm(43인치) / 와이드(16:9) / 평면 / VA / 3840 x 2160(4K UHD) / 픽셀피치: 0.2451mm / 8ms(GTG) / 300cd / 5,00",
- "tags": [
- "가전",
- "모니터",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vBAK4MQdH5v195712.png",
- "discountRate": 0
- }
-]
-```
-
-### 제품 거래(구매) 신청
-
-- 사용자 전용 API입니다.
-- 거래(구매) 신청시 연결된 계좌에서 결제됩니다.
-- 결제할 계좌(ID)를 꼭 선택해야 합니다.(`계좌 목록 및 잔액 조회` API를 사용하세요)
-- 선택한 계좌의 잔액보다 결제 금액이 크면 결제가 처리되지 않습니다.(에러 반환)
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/buy
- \ -X 'POST'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- productId: string // 거래할 제품 ID (필수!)
- accountId: string // 결제할 사용자 계좌 ID (필수!)
- reservation?: { // 예약 정보(예약 시스템을 사용하는 경우만 필요)
- start: string // 예약 시작 시간(ISO)
- end: string // 예약 종료 시간(ISO)
- }
-}
-```
-
-```js
-const isoString = new Date().toISOString()
-```
-
-```json
-{
- "productId": "nbqtQvEivYwEXTDet7YM",
- "accountId": "Mq2KKHk8vlmr6Xkg58Fa",
- "reservation": {
- "start": "2021-11-12T06:00:00.000Z",
- "end": "2021-11-12T07:00:00.000Z"
- }
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = true // 거래 신청 처리 여부
-```
-
-### 제품 거래(구매) 취소
-
-- 사용자 전용 API입니다.
-- '거래 취소'시 결제한 사용자 계좌로 금액이 환불됩니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/cancel
- \ -X 'POST'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- detailId: string // 취소할 제품의 거래 내역 ID
-}
-```
-
-```json
-{
- "detailId": "dMhfxyrAupQP18OYmywy"
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = true // 거래 취소 처리 여부
-```
-
-### 제품 거래(구매) 확정
-
-- 사용자 전용 API입니다.
-- '거래(구매) 확정' 후에는 '거래 취소'를 할 수 없습니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/ok
- \ -X 'POST'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- detailId: string // 거래(구매) 확정할 제품의 거래 내역 ID
-}
-```
-
-```json
-{
- "detailId": "dMhfxyrAupQP18OYmywy"
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-type ResponseValue = true // 거래(구매) 확정 처리 여부
-```
-
-### 제품 전체 거래(구매) 내역
-
-- 사용자 전용 API입니다.
-- 거래 내역의 기본 정보만 포함됩니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/transactions/details
- \ -X 'GET'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-- 없음
-
-응답 데이터 타입 및 예시:
-
-```ts
-type RequestValue = TransactionDetail[] // 모든 거래 내역의 목록
-
-interface TransactionDetail { // 거래 내역 정보
- detailId: string // 거래 내역 ID
- product: { // 거래한 제품 정보
- productId: string
- title: string
- price: number
- description: string
- tags: string[]
- thumbnail: string | null
- discountRate: number // 제품 할인율
- }
- reservation: Reservation | null // 거래한 제품의 예약 정보
- timePaid: string // 제품을 거래한 시간
- isCanceled: boolean // 거래 취소 여부
- done: boolean // 거래 완료 여부
-}
-
-interface Reservation {
- start: string // 예약 시작 시간
- end: string // 예약 종료 시간
- isCanceled: boolean // 예약 취소 여부
- isExpired: boolean // 예약 만료 여부
-}
-```
-
-```json
-[
- {
- "detailId": "9jAoagzrZBkSWI5NctEB",
- "product": {
- "productId": "nbqtQvEivYwEXTDet7YM",
- "title": "MacBook Pro 16",
- "price": 3360000,
- "description": "역대 가장 강력한 MacBook Pro가 등장했습니다. 최초의 프로용 Apple Silicon인 M1 Pro 또는 M1 Max 칩을 탑재해 쏜살같이 빠른 속도는 물론, 획기적인 성",
- "tags": [
- "가전",
- "노트북",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vIKMk_jy4Yv195256.png",
- "discountRate": 0
- },
- "reservation": null,
- "timePaid": "2021-11-07T20:17:32.112Z",
- "isCanceled": true,
- "done": false
- },
- {
- "detailId": "dMhfxyrAupQP18OYmywy",
- "product": {
- "productId": "cFmeC7aY5KjZbBAdJE9y",
- "title": "삼성전자 스마트모니터 M7 S43AM700",
- "price": 639000,
- "description": "107.9cm(43인치) / 와이드(16:9) / 평면 / VA / 3840 x 2160(4K UHD) / 픽셀피치: 0.2451mm / 8ms(GTG) / 300cd / 5,00",
- "tags": [
- "가전",
- "모니터",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vBAK4MQdH5v195712.png",
- "discountRate": 0
- },
- "reservation": {
- "start": "2021-11-12T06:00:00.000Z",
- "end": "2021-11-12T07:00:00.000Z",
- "isCanceled": false,
- "isExpired": true
- },
- "timePaid": "2021-11-07T20:01:49.100Z",
- "isCanceled": false,
- "done": true
- }
-]
-```
-
-### 단일 제품 상세 거래(구매) 내역
-
-- 사용자 전용 API입니다.
-
-```curl
-curl https://asia-northeast3-heropy-api.cloudfunctions.net/api/products/transactions/detail
- \ -X 'POST'
- \ -H 'Authorization: Bearer '
-```
-
-요청 데이터 타입 및 예시:
-
-```ts
-interface RequestBody {
- detailId: string // 상세 내용을 확인할 거래(구매) 내역 ID
-}
-```
-
-```json
-{
- "detailId": "dMhfxyrAupQP18OYmywy"
-}
-```
-
-응답 데이터 타입 및 예시:
-
-```ts
-interface TransactionDetail { // 상세 거래 정보
- detailId: string // 거래 내역 ID
- account: { // 거래한 사용자의 계좌 정보
- bankName: string
- bankCode: string
- accountNumber: string
- }
- product: { // 거래한 제품 정보
- productId: string
- title: string
- price: number
- description: string
- tags: string[]
- thumbnail: string | null
- photo: string | null
- discountRate: number // 제품 할인율
- }
- reservation: Reservation | null // 거래한 제품의 예약 정보
- timePaid: string // 제품을 거래한 시간
- isCanceled: boolean // 거래 취소 여부
- done: boolean // 거래 완료 여부
-}
-
-interface Reservation {
- start: string // 예약 시작 시간
- end: string // 예약 종료 시간
- isCanceled: boolean // 예약 취소 여부
- isExpired: boolean // 예약 만료 여부
-}
-```
-
-```json
-{
- "detailId": "dMhfxyrAupQP18OYmywy",
- "account": {
- "bankName": "KB국민은행",
- "bankCode": "004",
- "accountNumber": "123-XX-XXXX-XXX"
- },
- "product": {
- "productId": "cFmeC7aY5KjZbBAdJE9y",
- "title": "삼성전자 스마트모니터 M7 S43AM700",
- "price": 639000,
- "description": "107.9cm(43인치) / 와이드(16:9) / 평면 / VA / 3840 x 2160(4K UHD) / 픽셀피치: 0.2451mm / 8ms(GTG) / 300cd / 5,000:1 / 최대 주사율: 60Hz / HDMI 2.0 / USB Type-C / 플리커 프리 / 블루라이트 차단 / 게임모드 지원 / 스피커 / 리모컨 / USB허브 / Wi-Fi(무선) / 스마트TV / 블루투스 / 틸트(상하) / 200 x 200mm / HDR / HDR10 / 10.6kg 기획전 차세대 게임 라이프 PS5 매력분석 관련기사 큐소닉, 43인치 4K UHD 스마트 모니터 ‘삼성전자 M7 S43AM700’ 출시 및 할인 행사 사용기 삼성 스마트모니터 m7 s43am700",
- "tags": [
- "가전",
- "모니터",
- "컴퓨터"
- ],
- "thumbnail": "https://storage.googleapis.com/heropy-api/vBAK4MQdH5v195712.png",
- "photo": "https://storage.googleapis.com/heropy-api/vVLP-ox_zSDv195712.jpg",
- "discountRate": 0
- },
- "reservation": null,
- "timePaid": "2021-11-07T20:01:49.100Z",
- "isCanceled": false,
- "done": true
-}
-```
+
+
+# Colley
+### 🔗 배포주소
+[Colley](https://team2colley.netlify.app)
+
+### 📄 프로젝트 소개
+거래 API를 활용한 온라인 쇼핑몰입니다.
+
+### 🗓 프로젝트 기간
+2023.05.30 ~ 2023.07.02
+
+### 🧑💻 기술 스택
+
+
+### 🤝 개발팀 소개
+
+|**[이은비](https://github.com/)**|**[안중후](https://github.com/)**|**[문현수](https://github.com/)**|**[방미선](https://github.com/)**|**[김동해](https://github.com/THEEASTSEA)**
+| :--------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------: |
+프로젝트 팀장,
관리자 페이지,
위시리스트,
마이페이지 - 구매 |Github 관리,
구매 페이지, 계좌,
장바구니 | 로그인, 회원가입,
장바구니,
디자인 가이드,
마이페이지 - 조회,수정 | 디자인 가이드 | 메인 페이지,
상품 상세,
상품 카테고리별 페이지|
+
+### 📂 폴더구조
+```
+📦src
+┣ 📂api
+┣ 📂assets
+┣ 📂components
+┃ ┣ 📂admin
+┃ ┣ 📂cart
+┃ ┣ 📂common
+┃ ┣ 📂main
+┃ ┣ 📂mypage
+┃ ┣ 📂payment
+┃ ┣ 📂product
+┃ ┣ 📜App.tsx
+┃ ┗ 📜index.ts
+┣ 📂constants
+┣ 📂contexts
+┣ 📂hooks
+┣ 📂pages
+┃ ┣ 📂admin
+┃ ┣ 📂login
+┃ ┣ 📂mypage
+┃ ┣ 📂payment
+┃ ┣ 📂product
+┃ ┣ 📜Home.tsx
+┃ ┣ 📜Router.tsx
+┃ ┗ 📜index.ts
+┣ 📂services
+┣ 📂styles
+┃ ┣ 📂abstracts
+┃ ┣ 📂components
+┃ ┃ ┣ 📂admin
+┃ ┃ ┣ 📂cart
+┃ ┃ ┣ 📂main
+┃ ┃ ┣ 📂mypage
+┃ ┃ ┣ 📂payment
+┃ ┃ ┣ 📂product
+┃ ┃ ┣ 📜Modal.module.scss
+┃ ┃ ┣ 📜badge.module.scss
+┃ ┃ ┗ 📜errorComponent.module.scss
+┃ ┣ 📂layout
+┃ ┣ 📂pages
+┃ ┗ 📜common.scss
+┣ 📂types
+┃ ┣ 📂swiper
+┣ 📂utils
+┣ 📜main.tsx
+┗ 📜vite-env.d.ts
+```
\ No newline at end of file
diff --git a/index.html b/index.html
new file mode 100644
index 00000000..57b6ac95
--- /dev/null
+++ b/index.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Colley
+
+
+
+
+
+
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
index 00000000..f50d6ef1
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,4 @@
+[[redirects]]
+ from = "/*"
+ to = "/index.html"
+ status = 200
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..325ec19b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "colley",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview",
+ "format": "prettier --write --cache ."
+ },
+ "dependencies": {
+ "@tosspayments/payment-widget-sdk": "^0.8.0",
+ "@types/react-js-pagination": "^3.0.4",
+ "axios": "^1.4.0",
+ "moment": "^2.29.4",
+ "path": "^0.12.7",
+ "react": "^18.2.0",
+ "react-daum-postcode": "^3.1.1",
+ "react-dom": "^18.2.0",
+ "react-js-pagination": "^3.0.3",
+ "react-router-dom": "^6.13.0",
+ "sass": "^1.62.1",
+ "swiper": "^9.4.1"
+ },
+ "devDependencies": {
+ "@swc/cli": "^0.1.62",
+ "@swc/core": "^1.3.66",
+ "@types/node": "^20.2.5",
+ "@types/react": "^18.0.37",
+ "@types/react-dom": "^18.0.11",
+ "@types/swiper": "^6.0.0",
+ "@typescript-eslint/eslint-plugin": "^5.59.0",
+ "@typescript-eslint/parser": "^5.59.0",
+ "@vitejs/plugin-react-swc": "^3.0.0",
+ "eslint": "^8.41.0",
+ "eslint-config-prettier": "^8.8.0",
+ "eslint-plugin-prettier": "^4.2.1",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.3.4",
+ "prettier": "^2.8.8",
+ "react-intersection-observer": "^9.5.1",
+ "swiper": "^9.4.1",
+ "tsc-alias": "^1.8.6",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "^5.0.2",
+ "vite": "^4.3.9",
+ "vite-plugin-remove-console": "^2.1.1",
+ "vite-tsconfig-paths": "^4.2.0"
+ }
+}
diff --git a/public/ico_coupon.png b/public/ico_coupon.png
new file mode 100644
index 00000000..ef3a5bec
Binary files /dev/null and b/public/ico_coupon.png differ
diff --git a/public/ico_orders.png b/public/ico_orders.png
new file mode 100644
index 00000000..0dbc664b
Binary files /dev/null and b/public/ico_orders.png differ
diff --git a/public/ico_won.png b/public/ico_won.png
new file mode 100644
index 00000000..84d5fc72
Binary files /dev/null and b/public/ico_won.png differ
diff --git a/public/instagramIcon.svg b/public/instagramIcon.svg
new file mode 100644
index 00000000..57b3f7c3
--- /dev/null
+++ b/public/instagramIcon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/no-photo.png b/public/no-photo.png
new file mode 100644
index 00000000..4506248b
Binary files /dev/null and b/public/no-photo.png differ
diff --git a/src/api/adminRequests.ts b/src/api/adminRequests.ts
new file mode 100644
index 00000000..29ad3a42
--- /dev/null
+++ b/src/api/adminRequests.ts
@@ -0,0 +1,130 @@
+import { adminInstance, authInstance, baseInstance } from 'api/index'
+import {
+ ProductAddBody,
+ Customer,
+ TransactionDetail,
+ CustomerInfo
+} from 'types/index'
+import axios from 'axios'
+
+// 관리자 - 상품 추가
+export const adminInsertProduct = async (product: ProductAddBody) => {
+ const response = await adminInstance.post('/products', product)
+ return response.data
+}
+
+// 관리자 - 상품 조회
+export const adminFetchProducts = async () => {
+ const response = await adminInstance.get('/products')
+ return response.data
+}
+
+// 관리자 - 상품 삭제
+export const adminDeleteProduct = async (productId: string) => {
+ const response = await adminInstance.delete(`/products/${productId}`)
+ return response.data
+}
+
+// 관리자 - 상품 상세 조회
+export const adminGetProductDetail = async (productId: string) => {
+ const response = await baseInstance.get(`/products/${productId}`)
+ return response.data
+}
+
+// 관리자 - 상품 판매 상태 변경
+export const adminChangeProductSaleStatus = async (
+ productId: string,
+ isSoldOut: boolean
+) => {
+ const response = await adminInstance.put(`/products/${productId}`, {
+ isSoldOut: isSoldOut
+ })
+ return response.data
+}
+
+// 관리자 - 상품 수정
+export const adminEditProduct = async (product: ProductAddBody) => {
+ const response = await adminInstance.put(`/products/${product.id}`, product)
+ return response.data
+}
+
+// 관리자 - 사용자 목록 조회
+export const adminFetchCustomers = async () => {
+ const response = await axios
+ .all([
+ await adminInstance.get('auth/users'),
+ await adminInstance.get('products/transactions/all')
+ ])
+ .then(
+ axios.spread((res1, res2) => {
+ const customers = res1.data as Customer[]
+ const orders = res2.data as TransactionDetail[]
+
+ const customerInfos: CustomerInfo[] = customers.map(
+ (customer: Customer) => {
+ const customerTransactions = orders.filter(
+ (order: TransactionDetail) =>
+ order.user.email === customer.email &&
+ (order.done || !order.isCanceled)
+ )
+ return {
+ user: customer,
+ totalTransaction: customerTransactions.length,
+ totalTransactionPrice: customerTransactions.reduce(
+ (acc, current) => {
+ if (current.product.discountRate) {
+ return (acc +=
+ current.product.price -
+ (current.product.price * current.product.discountRate) /
+ 100)
+ }
+ return (acc += current.product.price)
+ },
+ 0
+ )
+ }
+ }
+ )
+
+ return customerInfos
+ })
+ )
+ return response
+}
+
+// 관리자 - 대시보드 거래내역 조회
+export const fetchAdminTransactions = async () => {
+ const response = await adminInstance.get('products/transactions/all')
+ return response.data
+}
+
+// 사용자 확인
+export const checkIsAdmin = async () => {
+ const response = await authInstance.post('/auth/me')
+ return response.data
+}
+
+// 주문 취소/취소 철회
+export const changeIsCanceled = async (
+ detailId: string,
+ isCanceled: boolean
+) => {
+ const response = await adminInstance.put(
+ `/products/transactions/${detailId}`,
+ {
+ isCanceled: isCanceled
+ }
+ )
+ return response.data
+}
+
+// 구매 확정 처리
+export const adminOrderConfirm = async (detailId: string) => {
+ const response = await adminInstance.put(
+ `/products/transactions/${detailId}`,
+ {
+ done: true
+ }
+ )
+ return response.data
+}
diff --git a/src/api/axios.ts b/src/api/axios.ts
new file mode 100644
index 00000000..1114b84f
--- /dev/null
+++ b/src/api/axios.ts
@@ -0,0 +1,16 @@
+import axios, { AxiosInstance } from 'axios'
+
+axios.defaults.baseURL = import.meta.env.VITE_BASE_URL
+axios.defaults.headers.common['apikey'] = import.meta.env.VITE_APIKEY
+axios.defaults.headers.common['username'] = import.meta.env.VITE_USERNAME
+
+// Authorization 설정이 없는 일반 사용자 API용 Instance
+export const baseInstance: AxiosInstance = axios.create()
+
+// Authorization 설정이 추가된 로그인한 사용자 API용 Instance
+export const authInstance: AxiosInstance = baseInstance
+
+// 관리자 API용 Instance
+export const adminInstance: AxiosInstance = axios.create({
+ headers: { masterKey: true }
+})
diff --git a/src/api/index.ts b/src/api/index.ts
new file mode 100644
index 00000000..325e340e
--- /dev/null
+++ b/src/api/index.ts
@@ -0,0 +1,6 @@
+export * from 'api/axios'
+export * from 'api/signApi'
+export * from 'api/adminRequests'
+export * from 'api/mypageRequests'
+export * from 'api/paymentRequests'
+export * from 'api/productRequests'
diff --git a/src/api/mypageRequests.ts b/src/api/mypageRequests.ts
new file mode 100644
index 00000000..a8ba66ee
--- /dev/null
+++ b/src/api/mypageRequests.ts
@@ -0,0 +1,20 @@
+import { authInstance } from 'api/index'
+
+export const featchUserOrders = async () => {
+ const res = await authInstance.get('products/transactions/details')
+ return res.data
+}
+
+export const confirmOrder = async (orderId: string) => {
+ const res = await authInstance.post('products/ok', {
+ detailId: orderId
+ })
+ return res.data
+}
+
+export const cancelOrder = async (orderId: string) => {
+ const res = await authInstance.post('products/cancel', {
+ detailId: orderId
+ })
+ return res.data
+}
diff --git a/src/api/paymentRequests.ts b/src/api/paymentRequests.ts
new file mode 100644
index 00000000..116db754
--- /dev/null
+++ b/src/api/paymentRequests.ts
@@ -0,0 +1,47 @@
+import { authInstance } from '@/api'
+import { CreateRequest, RemoveRequest, Transaction } from 'types/index'
+
+//선택 가능한 은행 목록 조회
+export const getBankLists = async () => {
+ const res = await authInstance.get('/account/banks')
+ return res.data
+}
+
+//계좌 목록 및 잔액 조회
+export const getAccounts = async () => {
+ try {
+ const res = await authInstance.get('/account')
+ return res.data.accounts
+ } catch (error) {
+ console.error()
+ }
+}
+
+//계좌 연결
+export const createAccount = async (request: CreateRequest) => {
+ try {
+ const res = await authInstance.post('/account', request)
+ return res.data
+ } catch (error) {
+ console.error()
+ }
+}
+
+//계좌 해지
+export const removeAccount = async (req: RemoveRequest) => {
+ try {
+ const res = await authInstance.delete('/account', { data: req })
+ return res.data
+ } catch (error) {
+ console.error()
+ }
+}
+//결제
+export const transactPayment = async (req: Transaction): Promise => {
+ try {
+ const res = await authInstance.post('/products/buy', req)
+ return res
+ } catch (error) {
+ console.error()
+ }
+}
diff --git a/src/api/productRequests.ts b/src/api/productRequests.ts
new file mode 100644
index 00000000..8fea7b18
--- /dev/null
+++ b/src/api/productRequests.ts
@@ -0,0 +1,14 @@
+import { baseInstance } from 'api/axios'
+
+export const fetchAllProducts = async () => {
+ const res = await baseInstance.post('/products/search', {
+ searchText: '',
+ searchTags: []
+ })
+ return res.data
+}
+
+export const getPorductDetail = async (id: string) => {
+ const res = await baseInstance.get(`/products/${id}`)
+ return res.data
+}
diff --git a/src/api/signApi.ts b/src/api/signApi.ts
new file mode 100644
index 00000000..4c949338
--- /dev/null
+++ b/src/api/signApi.ts
@@ -0,0 +1,22 @@
+import { baseInstance, authInstance } from 'api/axios'
+import { BodyInfo, IdInfo, RequestBody } from 'types/index'
+
+export const postInfo = async (bodyInfo: BodyInfo) => {
+ const res = await baseInstance.post('/auth/signup', bodyInfo)
+ return res.data
+}
+
+export const getId = async (idInfo: IdInfo) => {
+ const res = await baseInstance.post('/auth/login', idInfo)
+ return res.data
+}
+
+export const logOut = async () => {
+ const res = await authInstance.post('/auth/logout')
+ return res.data
+}
+
+export const InfoModify = async (RequestBody: RequestBody) => {
+ const res = await authInstance.put('/auth/user', RequestBody)
+ return res.data
+}
diff --git a/src/components/App.tsx b/src/components/App.tsx
new file mode 100644
index 00000000..55a263ae
--- /dev/null
+++ b/src/components/App.tsx
@@ -0,0 +1,126 @@
+import { Outlet, useLocation, useNavigate } from 'react-router-dom'
+import { Header, Badge, MyPageNav, Modal } from 'components/index'
+import { CommonError, Product, ModalProps, CartProduct } from 'types/index'
+import { useState, useEffect } from 'react'
+
+import {
+ LoginContext,
+ RecentlyContext,
+ LoginedUserContext,
+ WishListContext,
+ CartContext
+} from 'contexts/index'
+import {
+ useLocalStorage,
+ useSessionStorage,
+ useCartLocalStorage,
+ useAxiosInterceptor
+} from 'hooks/index'
+import styles from 'src/styles/components/mypage/mypage.module.scss'
+
+//App은 Outlet을 통해 슬래시로 페이지 경로 이동시의 최상위 컴포넌트로 설정했습니다
+export const App = () => {
+ const navigate = useNavigate()
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [modalProps, setModalProps] = useState(null)
+ const [isLogined, setIsLogined] = useLocalStorage('isLogined', false)
+ const [userEmail, setUserEmail] = useLocalStorage('ColleyUser', '')
+ const [userCart, setUserCart] = useCartLocalStorage(userEmail, [])
+ const [recentlyViewedList, setRecentlyViewedList] = useSessionStorage<
+ Product[]
+ >('RecentlyViewed', [])
+ const [wishList, setWishList] = useLocalStorage(
+ `wish-${userEmail}`,
+ isLogined
+ ? JSON.parse(localStorage.getItem('wish-${userEmail}') as string)
+ : [],
+ isLogined
+ )
+
+ userCart.sort((a: CartProduct, b: CartProduct) =>
+ a.product.id.toLowerCase() < b.product.id.toLowerCase() ? -1 : 1
+ )
+
+ const path: string = useLocation().pathname
+ useEffect(() => {
+ if (
+ path === '/mypage' ||
+ path === '/mypage/order' ||
+ path === '/mypage/wishlist'
+ ) {
+ if (isLogined === false) {
+ setIsModalShow(true)
+ setModalProps({
+ title: '로그인',
+ content: '로그인이 필요한 서비스입니다.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ navigate('/signin')
+ }
+ })
+ }
+ }
+ }, [path])
+
+ const handleLogout = () => {
+ setIsLogined(false)
+ setUserEmail('')
+ setUserCart([])
+ localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN)
+ }
+
+ const handleErrorModal = (error: CommonError) => {
+ setIsModalShow(true)
+ setModalProps({
+ title: '오류',
+ content: error.message,
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ }
+ })
+ }
+
+ useAxiosInterceptor(handleLogout, handleErrorModal)
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {path.includes('/mypage') && isLogined ? (
+
+
+
+
+ ) : (
+
+ )}
+ {isModalShow && modalProps ? (
+
+ ) : null}
+
+
+
+
+
+ {/* 결제 페이지/회원가입 페이지 등은 footer미적용일 것 같아서 header만 기본으로 outlet과 함께 배치시켰습니다 */}
+ >
+ )
+}
diff --git a/src/components/admin/AdminCustomerItem.tsx b/src/components/admin/AdminCustomerItem.tsx
new file mode 100644
index 00000000..2f920589
--- /dev/null
+++ b/src/components/admin/AdminCustomerItem.tsx
@@ -0,0 +1,69 @@
+import React, { useMemo } from 'react'
+import { CustomerInfo } from 'types/index'
+import styled from 'styles/components/admin/customerItem.module.scss'
+
+export const AdminCustomerItem = React.memo(
+ ({ user, totalTransaction, totalTransactionPrice }: CustomerInfo) => {
+ const customerGrade = useMemo(() => {
+ // 회원등급 (누적금액)
+ // 브론즈(0~10만) > 실버(10만~20만) > 골드(20만~50만) > 플래티넘(50만~100만) > 다이아몬드(100만~)
+ if (totalTransactionPrice < 100000) {
+ return '브론즈'
+ } else if (
+ totalTransactionPrice >= 100000 &&
+ totalTransactionPrice < 200000
+ ) {
+ return '실버'
+ } else if (
+ totalTransactionPrice >= 200000 &&
+ totalTransactionPrice < 500000
+ ) {
+ return '골드'
+ } else if (
+ totalTransactionPrice >= 500000 &&
+ totalTransactionPrice < 1000000
+ ) {
+ return '플래티넘'
+ } else if (totalTransactionPrice >= 1000000) {
+ return '다이아몬드'
+ }
+ return '-'
+ }, [totalTransactionPrice])
+
+ const gradeColor = useMemo(() => {
+ switch (customerGrade) {
+ case '실버':
+ return 'grade--silver'
+ break
+ case '골드':
+ return 'grade--gold'
+ break
+ case '플래티넘':
+ return 'grade--platinum'
+ break
+ case '다이아몬드':
+ return 'grade--diamond'
+ break
+ default:
+ return 'grade--bronze'
+ break
+ }
+ }, [customerGrade])
+
+ return (
+
+ {user.email}
+ {user.displayName}
+
+ {customerGrade}
+
+
+ {totalTransaction.toLocaleString()}건
+
+
+ {totalTransactionPrice.toLocaleString()}원
+
+
+ )
+ }
+)
diff --git a/src/components/admin/AdminCustomerSkeleton.tsx b/src/components/admin/AdminCustomerSkeleton.tsx
new file mode 100644
index 00000000..9970b68c
--- /dev/null
+++ b/src/components/admin/AdminCustomerSkeleton.tsx
@@ -0,0 +1,18 @@
+import styled from 'src/styles/components/admin/adminSkeleton.module.scss'
+
+export const AdminCustomerSkeleton = () => {
+ return (
+
+ )
+}
diff --git a/src/components/admin/AdminDashboardCard.tsx b/src/components/admin/AdminDashboardCard.tsx
new file mode 100644
index 00000000..380d5b41
--- /dev/null
+++ b/src/components/admin/AdminDashboardCard.tsx
@@ -0,0 +1,22 @@
+import { AdminDashboardCardProps } from 'types/index'
+import React from 'react'
+import styled from 'styles/components/admin/adminCard.module.scss'
+
+export const AdminDashboardCard = React.memo(
+ ({ title, value, unitStr, isLoading }: AdminDashboardCardProps) => {
+ return (
+
+
{title}
+
+ {isLoading ? (
+
+ ) : (
+
{value}
+ )}
+
+
{unitStr}
+
+
+ )
+ }
+)
diff --git a/src/components/admin/AdminLoading.tsx b/src/components/admin/AdminLoading.tsx
new file mode 100644
index 00000000..7ba25714
--- /dev/null
+++ b/src/components/admin/AdminLoading.tsx
@@ -0,0 +1,19 @@
+import styled from 'styles/pages/admin.module.scss'
+
+export const AdminLoading = () => {
+ return (
+
+ )
+}
diff --git a/src/components/admin/AdminMoreButton.tsx b/src/components/admin/AdminMoreButton.tsx
new file mode 100644
index 00000000..09e811e7
--- /dev/null
+++ b/src/components/admin/AdminMoreButton.tsx
@@ -0,0 +1,48 @@
+import { AdminMoreButtonProps } from 'types/index'
+import styled from 'styles/components/admin/productItem.module.scss'
+
+export const AdminMoreButton = ({
+ isShow,
+ onToggleMenu,
+ onClickEdit,
+ onClickDelete,
+ product,
+ onClickChangeStatus
+}: AdminMoreButtonProps) => {
+ const handleClickMenu = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onToggleMenu()
+ }
+
+ const handleClickEdit = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onClickEdit()
+ }
+
+ const handleClickDelete = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ onClickDelete(product)
+ }
+
+ return (
+
+
+ more_vert
+
+
+
+ - 상품 수정
+ - 상품 삭제
+ -
+ {product.isSoldOut ? '판매처리' : '품절처리'}
+
+
+
+
+ )
+}
diff --git a/src/components/admin/AdminNav.tsx b/src/components/admin/AdminNav.tsx
new file mode 100644
index 00000000..9d91c958
--- /dev/null
+++ b/src/components/admin/AdminNav.tsx
@@ -0,0 +1,34 @@
+import { Link, useLocation } from 'react-router-dom'
+import styled from 'styles/pages/admin.module.scss'
+
+export const AdminNav = () => {
+ const path: string = useLocation().pathname
+
+ return (
+
+ )
+}
diff --git a/src/components/admin/AdminPrivateRoute.tsx b/src/components/admin/AdminPrivateRoute.tsx
new file mode 100644
index 00000000..b297419d
--- /dev/null
+++ b/src/components/admin/AdminPrivateRoute.tsx
@@ -0,0 +1,84 @@
+import { useCallback, useEffect, useState, useContext } from 'react'
+import { Outlet, useNavigate } from 'react-router-dom'
+import { checkIsAdmin } from 'api/index'
+import { AdminNav, Modal } from 'components/index'
+import { CommonError, ModalProps } from 'types/index'
+import { LoginContext, LoginedUserContext, CartContext } from 'contexts/index'
+import { useAxiosInterceptor } from 'hooks/index'
+import styled from 'styles/pages/admin.module.scss'
+
+export const AdminPrivateRoute = () => {
+ const [isAdmin, setIsAdmin] = useState(false)
+ const navigate = useNavigate()
+
+ const moveSignIn = useCallback(() => {
+ alert('관리자만 접근할 수 있습니다.')
+ navigate('/signin')
+ }, [navigate])
+
+ const moveHome = useCallback(() => {
+ alert('관리자만 접근할 수 있습니다.')
+ navigate('/')
+ }, [navigate])
+
+ useEffect(() => {
+ checkIsAdmin()
+ .then(user => {
+ if (user.email !== import.meta.env.VITE_ADMIN_EMAIL) {
+ moveHome()
+ } else {
+ setIsAdmin(true)
+ }
+ })
+ .catch(error => {
+ console.log(error)
+ moveSignIn()
+ })
+ }, [moveSignIn, moveHome])
+
+ const { setIsLogined } = useContext(LoginContext)
+ const { setUserEmail } = useContext(LoginedUserContext)
+ const { setUserCart } = useContext(CartContext)
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [modalProps, setModalProps] = useState(null)
+
+ const handleLogout = () => {
+ setIsLogined(false)
+ setUserEmail('')
+ setUserCart([])
+ localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN)
+ }
+
+ const handleErrorModal = (error: CommonError) => {
+ setIsModalShow(true)
+ setModalProps({
+ title: '오류',
+ content: error.message,
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ }
+ })
+ }
+
+ useAxiosInterceptor(handleLogout, handleErrorModal)
+
+ return isAdmin ? (
+
+
+
+ {isModalShow && modalProps ? (
+
+ ) : null}
+
+ ) : null
+}
diff --git a/src/components/admin/AdminProductItem.tsx b/src/components/admin/AdminProductItem.tsx
new file mode 100644
index 00000000..b88db0a9
--- /dev/null
+++ b/src/components/admin/AdminProductItem.tsx
@@ -0,0 +1,84 @@
+import React from 'react'
+import { useNavigate } from 'react-router-dom'
+import { ProductItemProps } from 'types/index'
+import { convertTagColor } from 'utils/index'
+import { AdminMoreButton } from 'components/index'
+
+import styled from 'styles/components/admin/productItem.module.scss'
+
+export const AdminProductItem = React.memo(
+ ({
+ product,
+ isMenuShow,
+ showMenu,
+ hideMenu,
+ onClickDelete,
+ onChangeSaleStatus
+ }: ProductItemProps) => {
+ const handleToogleMenu = React.useCallback(() => {
+ if (isMenuShow) {
+ hideMenu()
+ } else {
+ showMenu(product.id ?? '')
+ }
+ }, [hideMenu, showMenu, isMenuShow, product])
+
+ const navigate = useNavigate()
+
+ const onClickProductEdit = React.useCallback(() => {
+ if (isMenuShow) {
+ hideMenu()
+ }
+ navigate('/admin/product-add', { state: product })
+ }, [isMenuShow, hideMenu])
+
+ const onClickChangeStatus = React.useCallback(() => {
+ if (product.isSoldOut) {
+ onChangeSaleStatus(product.id, false)
+ } else {
+ onChangeSaleStatus(product.id, true)
+ }
+ }, [])
+
+ return (
+
+
{product.title}
+
+ {product.tags.map(tag => {
+ return (
+
+ {tag}
+
+ )
+ })}
+
+
{product.price.toLocaleString()}원
+
+ {product.discountRate === 0 || !product.discountRate
+ ? '-'
+ : `${product.discountRate}%`}
+
+
+ {product.isSoldOut ? (
+ 품절
+ ) : (
+ 판매중
+ )}
+
+
+
+ )
+ }
+)
diff --git a/src/components/admin/AdminProductItemHeader.tsx b/src/components/admin/AdminProductItemHeader.tsx
new file mode 100644
index 00000000..28323784
--- /dev/null
+++ b/src/components/admin/AdminProductItemHeader.tsx
@@ -0,0 +1,14 @@
+import React from 'react'
+import styled from 'styles/components/admin/productItemHeader.module.scss'
+
+export const AdminProductItemHeader = React.memo(() => {
+ return (
+
+
상품명
+
태그
+
가격
+
할인율
+
판매상태
+
+ )
+})
diff --git a/src/components/admin/AdminProductsSkeleton.tsx b/src/components/admin/AdminProductsSkeleton.tsx
new file mode 100644
index 00000000..53903bb1
--- /dev/null
+++ b/src/components/admin/AdminProductsSkeleton.tsx
@@ -0,0 +1,18 @@
+import styled from 'styles/components/admin/adminSkeleton.module.scss'
+
+export const AdminProductsSkeleton = () => {
+ return (
+
+ )
+}
diff --git a/src/components/admin/AdminSalesItem.tsx b/src/components/admin/AdminSalesItem.tsx
new file mode 100644
index 00000000..10854783
--- /dev/null
+++ b/src/components/admin/AdminSalesItem.tsx
@@ -0,0 +1,74 @@
+import React, { useMemo } from 'react'
+import moment from 'moment'
+import { AdminSalesItemProps } from 'types/index'
+import styled from 'styles/components/admin/adminSalesItem.module.scss'
+
+export const AdminSalesItem = React.memo(
+ ({
+ detail,
+ onChangeOrderIsCanceled,
+ onClickOrderConfirm
+ }: AdminSalesItemProps) => {
+ const paidDate = useMemo(() => {
+ const date = moment(detail.timePaid).format('YYYY-MM-DD HH:mm')
+ return date
+ }, [detail])
+
+ const paidPrice = useMemo(() => {
+ if (detail.product.discountRate) {
+ return (
+ (detail.product.price * (100 - detail.product.discountRate)) / 100
+ )
+ } else {
+ return detail.product.price
+ }
+ }, [detail])
+
+ const handleOrderCancelClick = () => {
+ onChangeOrderIsCanceled(detail.detailId, true)
+ }
+
+ const handleOrderCancelBackClick = () => {
+ onChangeOrderIsCanceled(detail.detailId, false)
+ }
+
+ const handleOrderConfirmClick = () => {
+ onClickOrderConfirm(detail.detailId)
+ }
+
+ return (
+
+ {paidDate.toString()}
+ {detail.user.email}
+ {detail.product.title}
+
+ {paidPrice.toLocaleString()}원
+
+
+ {!detail.done && detail.isCanceled ? (
+
+ ) : !detail.done ? (
+
+ ) : null}
+ {detail.done ? (
+ 판매완료
+ ) : (
+
+ )}
+
+
+ )
+ }
+)
diff --git a/src/components/admin/AdminSalesSkeleton.tsx b/src/components/admin/AdminSalesSkeleton.tsx
new file mode 100644
index 00000000..1c32fe6b
--- /dev/null
+++ b/src/components/admin/AdminSalesSkeleton.tsx
@@ -0,0 +1,18 @@
+import styled from 'styles/components/admin/adminSkeleton.module.scss'
+
+export const AdminSalesSkeleton = () => {
+ return (
+
+ )
+}
diff --git a/src/components/admin/ProductAddForm.tsx b/src/components/admin/ProductAddForm.tsx
new file mode 100644
index 00000000..68cdc657
--- /dev/null
+++ b/src/components/admin/ProductAddForm.tsx
@@ -0,0 +1,246 @@
+import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
+import styled from 'styles/components/admin/productAddForm.module.scss'
+import { ProductAddBody, ProductAddFormProps } from 'types/index'
+
+export const ProductAddForm = ({ product, onSubmit }: ProductAddFormProps) => {
+ const [title, setTitle] = useState('')
+ const [description, setDescription] = useState('')
+ const [tagStr, setTagStr] = useState('')
+ const [price, setPrice] = useState('')
+ const [discountRate, setDiscountRate] = useState('')
+ const [isValid, setIsValid] = useState(false)
+ const isAddMode = useMemo(() => !product, [product])
+
+ useEffect(() => {
+ if (product) {
+ const {
+ title,
+ description,
+ tags,
+ price,
+ discountRate,
+ thumbnail,
+ photo
+ } = product
+ console.log(thumbnail)
+ setTitle(title)
+ setDescription(description)
+ setTagStr(tags.join(', '))
+ setPrice(price.toString())
+ setDiscountRate(discountRate ? discountRate.toString() : '')
+ setThumbnailImage(thumbnail ? thumbnail : '')
+ setDetailImage(photo ? photo : '')
+ }
+ }, [product])
+
+ // 유효성 검사
+ useEffect(() => {
+ if (
+ title &&
+ description &&
+ price &&
+ tagStr &&
+ !isNaN(parseInt(price)) &&
+ !isNaN(parseInt(discountRate === '' ? '0' : discountRate)) &&
+ parseInt(price) > 0
+ ) {
+ setIsValid(true)
+ } else {
+ setIsValid(false)
+ }
+ }, [title, description, price, tagStr, discountRate])
+
+ // 대표이미지
+ const fileInputRef = useRef(null)
+ const [thumbnailImage, setThumbnailImage] = useState(null)
+
+ // 상세이미지
+ const detailInputRef = useRef(null)
+ const [detailImage, setDetailImage] = useState(null)
+
+ // 이미지 프리뷰 설정
+ const handleUploadImage = (e: React.ChangeEvent) => {
+ const id = e.target.id
+ onChangeImage(e, id === 'thumbnail-upload')
+ }
+
+ // 이미지 convert base64
+ const onChangeImage = useCallback(
+ (e: React.ChangeEvent, isThumb: boolean) => {
+ if (!e.target.files?.length) {
+ return
+ }
+ const reader = new FileReader()
+
+ reader.onloadend = () => {
+ const base64 = reader.result
+ if (base64) {
+ isThumb
+ ? setThumbnailImage(base64.toString())
+ : setDetailImage(base64.toString())
+ }
+ }
+
+ reader.readAsDataURL(e.target.files[0])
+ },
+ []
+ )
+
+ const onClickThumbnailImageUpload = useCallback((e: React.MouseEvent) => {
+ e.preventDefault()
+ if (!fileInputRef.current) {
+ return
+ }
+ fileInputRef.current.click()
+ }, [])
+
+ const onClickDetailImageUpload = useCallback((e: React.MouseEvent) => {
+ e.preventDefault()
+ if (!detailInputRef.current) {
+ return
+ }
+ detailInputRef.current.click()
+ }, [])
+
+ const handleSumitAddForm = (e: React.ChangeEvent) => {
+ e.preventDefault()
+ if (!isValid) return
+ const tags: string[] = tagStr.toUpperCase().split(', ')
+ let newProduct: ProductAddBody
+ if (isAddMode) {
+ newProduct = {
+ title,
+ description,
+ tags,
+ price: parseInt(price),
+ discountRate: parseInt(discountRate === '' ? '0' : discountRate),
+ thumbnailBase64: thumbnailImage,
+ photoBase64: detailImage
+ }
+ } else {
+ newProduct = {
+ id: product?.id,
+ title,
+ description,
+ tags,
+ price: parseInt(price),
+ discountRate: parseInt(discountRate === '' ? '0' : discountRate),
+ ...(thumbnailImage !== product?.thumbnail
+ ? { thumbnailBase64: thumbnailImage }
+ : {}),
+ ...(detailImage !== product?.photo ? { photoBase64: detailImage } : {})
+ }
+ }
+
+ onSubmit(newProduct)
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts
new file mode 100644
index 00000000..4d7f626e
--- /dev/null
+++ b/src/components/admin/index.ts
@@ -0,0 +1,13 @@
+export * from 'components/admin/AdminNav'
+export * from 'components/admin/AdminProductItemHeader'
+export * from 'components/admin/ProductAddForm'
+export * from 'components/admin/AdminLoading'
+export * from 'components/admin/AdminProductItem'
+export * from 'components/admin/AdminProductsSkeleton'
+export * from 'components/admin/AdminMoreButton'
+export * from 'components/admin/AdminCustomerItem'
+export * from 'components/admin/AdminCustomerSkeleton'
+export * from 'components/admin/AdminDashboardCard'
+export * from 'components/admin/AdminPrivateRoute'
+export * from 'components/admin/AdminSalesSkeleton'
+export * from 'components/admin/AdminSalesItem'
diff --git a/src/components/cart/CartFooter.tsx b/src/components/cart/CartFooter.tsx
new file mode 100644
index 00000000..c0151f5c
--- /dev/null
+++ b/src/components/cart/CartFooter.tsx
@@ -0,0 +1,57 @@
+import styles from 'styles/components/cart/cartFooter.module.scss'
+
+export const CartFooter = () => {
+ return (
+
+
이용 안내
+
+
+ 장바구니 이용안내
+ -
+ 해외배송 상품과 국내배송 상품은 함께 결제하실 수 없으니 장바구니
+ 별로 따로 결제해 주시기 바랍니다.
+
+ -
+ 해외배송 가능 상품의 경우 국내배송 장바구니에 담았다가 해외배송
+ 장바구니로 이동하여 결제하실 수 있습니다.
+
+ -
+ 선택하신 상품의 수량을 변경하시려면 수량변경 후 [변경] 버튼을
+ 누르시면 됩니다.
+
+ - [쇼핑계속하기] 버튼을 누르시면 쇼핑을 계속 하실 수 있습니다.
+ -
+ 장바구니와 관심상품을 이용하여 원하시는 상품만 주문하거나
+ 관심상품으로 등록하실 수 있습니다.
+
+ -
+ 파일첨부 옵션은 동일상품을 장바구니에 추가할 경우 마지막에 업로드 한
+ 파일로 교체됩니다.
+
+
+
+ 무이자할부 이용안내
+ -
+ 상품별 무이자할부 혜택을 받으시려면 무이자할부 상품만 선택하여
+ [주문하기] 버튼을 눌러 주문/결제 하시면 됩니다.
+
+ -
+ [전체 상품 주문] 버튼을 누르시면 장바구니의 구분없이 선택된 모든
+ 상품에 대한 주문/결제가 이루어집니다.
+
+ -
+ 단, 전체 상품을 주문/결제하실 경우, 상품별 무이자할부 혜택을 받으실
+ 수 없습니다.
+
+ -
+ 무이자할부 상품은 장바구니에서 별도 무이자할부 상품 영역에 표시되어,
+ 무이자할부 상품 기준으로 배송비가 표시됩니다.
+
+ 실제 배송비는 함께 주문하는 상품에 따라 적용되오니 주문서 하단의
+ 배송비 정보를 참고해주시기 바랍니다.
+
+
+
+
+ )
+}
diff --git a/src/components/cart/CartHeader.tsx b/src/components/cart/CartHeader.tsx
new file mode 100644
index 00000000..ccddedc3
--- /dev/null
+++ b/src/components/cart/CartHeader.tsx
@@ -0,0 +1,32 @@
+import { Link } from 'react-router-dom'
+import styles from 'styles/components/cart/cartHeader.module.scss'
+import { CartContext } from 'contexts/CartContext'
+import { useContext } from 'react'
+
+export const CartHeader = () => {
+ const { userCart } = useContext(CartContext)
+ return (
+
+
+ 홈 /
+ 장바구니
+
+
장바구니
+
+ - 장바구니
+ - 주문서작성
+ - 주문완료
+
+
+ {/* 국내배송상품 주문숫자 연동 */}
+
+ 국내배송상품 ({userCart.length})
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/cart/CartItem.tsx b/src/components/cart/CartItem.tsx
new file mode 100644
index 00000000..009c56c8
--- /dev/null
+++ b/src/components/cart/CartItem.tsx
@@ -0,0 +1,97 @@
+import styles from 'styles/components/cart/cartItem.module.scss'
+import { useState, useContext } from 'react'
+import { useNavigate } from 'react-router'
+import { CartContext } from 'contexts/index'
+import { calculateDiscountedPrice } from 'utils/index'
+import { CartProduct } from 'types/index'
+
+export const CartItem = ({
+ product,
+ quantity,
+ checkedItemHandler,
+ isChecked
+}: CartProduct) => {
+ const [number, setNumber] = useState(quantity)
+ const { userCart, setUserCart } = useContext(CartContext)
+
+ // checkHandler - 개별 상품에서 체크 상태관리
+ // checkedItemHandler - 상위컴포넌트(=CartProducts)에서 개별 상품 체크 상태관리
+ const checkHandler = ({ target }: React.ChangeEvent) => {
+ checkedItemHandler?.(product.id as string, target.checked as boolean)
+ }
+
+ const navigate = useNavigate()
+
+ const naviagteDetail = () => {
+ event?.preventDefault()
+ navigate(`/products/${product.id}`)
+ }
+
+ const filter = userCart.filter(item => item.product.id !== product.id)
+
+ const plus = () => {
+ setNumber(number + 1)
+ // 로컬스토리지 동기화
+ setUserCart([...filter, { product: product, quantity: number + 1 }])
+ }
+
+ const minus = () => {
+ if (number === 1) {
+ return
+ } else {
+ setNumber(number - 1)
+ setUserCart([...filter, { product: product, quantity: number - 1 }])
+ }
+ }
+ const discounted = calculateDiscountedPrice(
+ product.price,
+ product.discountRate
+ )
+ const sumPrice = number * discounted
+
+ const deleteList = () => {
+ setUserCart(userCart.filter(p => p.product.id !== product.id))
+ }
+
+ return (
+
+
checkHandler(e)}
+ />
+
+
+
+ {product.title}
+
+
{discounted.toLocaleString()}원
+
+
+ -
+
+
{number}
+
+ +
+
+
+
+
{sumPrice.toLocaleString()}원
+
+ close
+
+
+ )
+}
diff --git a/src/components/cart/CartProducts.tsx b/src/components/cart/CartProducts.tsx
new file mode 100644
index 00000000..d06bc4ef
--- /dev/null
+++ b/src/components/cart/CartProducts.tsx
@@ -0,0 +1,96 @@
+import { CartItem, CartSummary } from 'components/cart/index'
+import styles from 'styles/components/cart/cartProducts.module.scss'
+import { CartContext, CheckedContext } from 'contexts/index'
+import { useCallback, useContext, useState, useEffect, useMemo } from 'react'
+import { CartProduct } from 'types/index'
+import { calculateDiscountedPrice } from 'utils/index'
+
+export const CartProducts = () => {
+ const { userCart } = useContext(CartContext)
+ // 하나의 요소가 체크됐을 경우, 요소를 Set에 추가
+ const [checkedItems, setCheckedItems] = useState(new Set())
+ const [filtered, setFiltered] = useState([])
+
+ // 하나의 요소를 선택할 때의 상태관리 함수 => props(CartItem)
+ const checkedItemHandler = (id: string, isChecked: boolean): void => {
+ if (isChecked) {
+ setCheckedItems(new Set([...checkedItems, id]))
+ // 선택됐던 것이 해제된경우
+ } else if (!isChecked && checkedItems.has(id)) {
+ const newCheckedItems = Array.from(checkedItems).filter(
+ checkedItem => checkedItem !== id
+ )
+ setCheckedItems(new Set([...newCheckedItems]))
+ }
+ }
+
+ //전체선택 기능
+ const allCheckedHandler = useCallback(() => {
+ // 현재 선택된 상품 set size와 원래 장바구니 목록 사이즈 비교
+ if (checkedItems.size !== userCart.length) {
+ setCheckedItems(new Set(userCart.map(({ product }) => product.id)))
+ } else {
+ setCheckedItems(new Set()) // 전체선택 해제 시 빈 set
+ }
+ }, [checkedItems, userCart])
+
+ useEffect(() => {
+ setFiltered(userCart.filter(item => checkedItems.has(item.product.id)))
+ }, [userCart, checkedItems])
+
+ //총액 계산
+ const total = useMemo(() => {
+ return filtered.reduce((acc: number, cur: CartProduct) => {
+ const discounted = calculateDiscountedPrice(
+ cur.product.price,
+ cur.product.discountRate
+ )
+ return acc + discounted * cur.quantity
+ }, 0)
+ }, [filtered])
+
+ const delivery = 3000
+
+ return (
+ <>
+
+
+
+
장바구니 상품
+
일반상품 ({userCart.length})
+ {/* 장바구니 내 개별 아이템 */}
+ {userCart.map(item => (
+
+ ))}
+
+
[기본배송]
+
+ 상품구매금액 {total.toLocaleString()} + 배송비{' '}
+ {delivery.toLocaleString()}
+
+
합계 : {(total + delivery).toLocaleString()}원
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/cart/CartSummary.tsx b/src/components/cart/CartSummary.tsx
new file mode 100644
index 00000000..e52df2fa
--- /dev/null
+++ b/src/components/cart/CartSummary.tsx
@@ -0,0 +1,97 @@
+import styles from 'styles/components/cart/cartSummary.module.scss'
+import { CartProduct } from 'types/index'
+import { useNavigate } from 'react-router-dom'
+import { CartContext, LoginContext } from 'contexts/index'
+import { useContext, useCallback } from 'react'
+
+export const CartSummary = ({
+ total,
+ filtered
+}: {
+ total: number
+ filtered: CartProduct[]
+}) => {
+ const navigate = useNavigate()
+ const { userCart } = useContext(CartContext)
+ const { isLogined } = useContext(LoginContext)
+ console.log(isLogined)
+ const orderAllHandler = () => {
+ if (!isLogined) {
+ alert('로그인이 필요한 서비스입니다. 로그인 페이지로 이동합니다.')
+ navigate('/signin')
+ }
+
+ const itemsInCart = userCart.length !== 0
+
+ if (isLogined && itemsInCart) {
+ navigate('/payment', {
+ state: {
+ //장바구니 내 상품정보 데이터
+ products: [...userCart]
+ }
+ })
+ }
+ if (isLogined && !itemsInCart) {
+ alert('장바구니에 상품을 추가 후 다시 시도해주세요.')
+ }
+ }
+
+ const orderSelectedHandler = useCallback(() => {
+ if (!isLogined) {
+ alert('로그인이 필요한 서비스입니다. 로그인 페이지로 이동합니다.')
+ navigate('/signin')
+ }
+
+ const filteredItemsInCart = filtered.length !== 0
+
+ if (isLogined && filteredItemsInCart) {
+ navigate('/payment', {
+ state: {
+ //상품정보 데이터
+ products: [...filtered]
+ }
+ })
+ }
+ if (isLogined && !filteredItemsInCart) {
+ alert('장바구니에 상품을 선택 후 다시 시도해주세요.')
+ }
+ }, [filtered, isLogined, navigate])
+
+ const delivery = 3000
+
+ return (
+
+
+
+
선택상품
+
+
+
총 상품금액
+ {total.toLocaleString()}원
+
+
+
총 배송비
+ {delivery.toLocaleString()}원
+
+
+
+
결제예정금액
+ {(total + delivery).toLocaleString()}원
+
+
+
+
+
+ )
+}
diff --git a/src/components/cart/index.ts b/src/components/cart/index.ts
new file mode 100644
index 00000000..b7767742
--- /dev/null
+++ b/src/components/cart/index.ts
@@ -0,0 +1,5 @@
+export * from 'components/cart/CartHeader'
+export * from 'components/cart/CartFooter'
+export * from 'components/cart/CartSummary'
+export * from 'components/cart/CartProducts'
+export * from 'components/cart/CartItem'
diff --git a/src/components/common/ErrorComponent.tsx b/src/components/common/ErrorComponent.tsx
new file mode 100644
index 00000000..5b741fa5
--- /dev/null
+++ b/src/components/common/ErrorComponent.tsx
@@ -0,0 +1,11 @@
+import styles from 'styles/components/errorComponent.module.scss'
+export const ErrorComponent = () => {
+ return (
+
+
페이지 오류 안내
+
홈페이지 이용에 불편을 드려 죄송합니다.
+
선택한 페이지를 찾을 수 없습니다.
+
메인 페이지로 이동
+
+ )
+}
diff --git a/src/components/common/Footer.tsx b/src/components/common/Footer.tsx
new file mode 100644
index 00000000..03b8f72b
--- /dev/null
+++ b/src/components/common/Footer.tsx
@@ -0,0 +1,93 @@
+import styles from 'src/styles/layout/footer.module.scss'
+import icon from '/public/instagramIcon.svg'
+
+export const Footer = () => {
+ return (
+
+
+
+
+ 02 543 1218
+
+ 통화량이 많을 때는 Q&A 게시판을 이용해주세요.
+ MON-FRI 10:00~18:00, LUNCH 13:00~14:00 / SAT-SUN·HOLIDAY OFF
+
+
+
+
주식회사 콜리
+
+ 서울특별시 강남구 논현로 709 (논현동) 로얄토토 논현동사옥 5층 콜리
+ I 사업자번호 : 371-81-01287
+
+ [사업자정보확인]
+
+ I 통신판매업신고 : 2016-서울마포-1355
+
+
+ 대표자(성명) : 양승철 I CPO : 김남혁 I MAIL : pay@colley.kr
+
+
CALL CENTER : 02-543-1218 I FAX : 02-3144-7780
+
입금계좌: 기업은행 063-088821-04-058
+
+
+
+ Copyright © 콜리. All rights reserved. Hosting by cafe24
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx
new file mode 100644
index 00000000..9fa52097
--- /dev/null
+++ b/src/components/common/Header.tsx
@@ -0,0 +1,267 @@
+import { useNavigate } from 'react-router-dom'
+import { checkIsAdmin } from 'utils/index'
+import { LoginedUserContext, LoginContext, CartContext } from 'contexts/index'
+import React, { useState, useRef, useEffect, useContext } from 'react'
+import { logOut } from 'api/index'
+import styles from 'styles/layout/header.module.scss'
+import { CommonError } from 'types/index'
+import { useInView } from 'react-intersection-observer'
+
+export const Header: React.FC = () => {
+ const { isLogined, setIsLogined } = useContext(LoginContext)
+ const { userEmail, setUserEmail } = useContext(LoginedUserContext)
+ const { setUserCart } = useContext(CartContext)
+ const [hideInput, setHideInput] = useState(true)
+ const navigate = useNavigate()
+ const [searchKeyword, setSearchKeyword] = useState('')
+ const [scrollActive, setScrollActive] = useState(false)
+ const searchRef = useRef(null)
+ const { ref, inView } = useInView()
+
+ const onClickSearch = () => {
+ setHideInput(false)
+ }
+
+ useEffect(() => {
+ if (inView || scrollY < 100) {
+ setScrollActive(false)
+ } else {
+ setScrollActive(true)
+ }
+ }, [inView])
+
+ useEffect(() => {
+ function handleOutside(e: Event) {
+ if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
+ if (!hideInput) {
+ setHideInput(true)
+ }
+ }
+ }
+ document.addEventListener('mousedown', handleOutside)
+ return () => {
+ document.removeEventListener('mousedown', handleOutside)
+ }
+ }, [searchRef, hideInput])
+
+ const handleLogout = () => {
+ setUserEmail('')
+ setUserCart([])
+ localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN)
+ setIsLogined(false)
+ navigate('/')
+ }
+
+ const logOutId = () => {
+ logOut()
+ .then(isSuccess => {
+ if (isSuccess) {
+ handleLogout()
+ alert('로그아웃되었습니다.')
+ }
+ })
+ .catch((error: CommonError) => {
+ if (error.status === 401) {
+ navigate('/')
+ }
+ })
+ }
+
+ const onSearchEnter = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ if (searchKeyword.trim() !== '') {
+ // 검색어를 `ProductList` 컴포넌트로 전달합니다.
+ window.location.href = `/productlist?category=SEARCH&keyword=${encodeURIComponent(
+ searchKeyword
+ )}`
+ }
+ }
+ }
+
+ const navigateDetail = (path: string) => {
+ event?.preventDefault()
+ navigate(`/productlist?category=${path}`)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {isLogined ? (
+
+ {checkIsAdmin(userEmail) ? (
+
+ ADMIN
+
+ ) : (
+
+ MYPAGE
+
+ )}
+
LOGOUT
+
+ ) : (
+
+ )}
+
+
+
+
+
setSearchKeyword(e.target.value)}
+ onKeyPress={onSearchEnter}
+ />
+
+ search
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx
new file mode 100644
index 00000000..c5876b36
--- /dev/null
+++ b/src/components/common/Modal.tsx
@@ -0,0 +1,34 @@
+import { ModalProps } from 'types/index'
+import styles from 'src/styles/components/Modal.module.scss'
+
+export const Modal = (props: ModalProps) => {
+ return (
+
+
+
+
+ {props.title}
+
+
+ {props.content}
+
+ {props.children}
+
+
+ {props.isTwoButton ? (
+
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/src/components/common/NotFound.tsx b/src/components/common/NotFound.tsx
new file mode 100644
index 00000000..c07fd4db
--- /dev/null
+++ b/src/components/common/NotFound.tsx
@@ -0,0 +1,3 @@
+export const NotFound = () => {
+ return NotFound
;
+};
diff --git a/src/components/common/index.ts b/src/components/common/index.ts
new file mode 100644
index 00000000..4e124264
--- /dev/null
+++ b/src/components/common/index.ts
@@ -0,0 +1,5 @@
+export * from 'components/common/Footer'
+export * from 'components/common/Header'
+export * from 'components/common/ErrorComponent'
+export * from 'components/common/Modal'
+export * from 'components/common/NotFound'
diff --git a/src/components/index.ts b/src/components/index.ts
new file mode 100644
index 00000000..e88f91f3
--- /dev/null
+++ b/src/components/index.ts
@@ -0,0 +1,6 @@
+export * from 'components/App'
+export * from 'components/admin'
+export * from 'components/mypage'
+export * from 'components/product'
+export * from 'components/main'
+export * from 'components/common'
diff --git a/src/components/main/Badge.tsx b/src/components/main/Badge.tsx
new file mode 100644
index 00000000..ecba92f6
--- /dev/null
+++ b/src/components/main/Badge.tsx
@@ -0,0 +1,34 @@
+import React, { useCallback, useContext } from 'react'
+import { RecentlyList } from 'components/index'
+import { RecentlyContext, CartContext } from 'contexts/index'
+import styled from 'styles/components/badge.module.scss'
+
+export const Badge = React.memo(() => {
+ const { recentlyViewedList } = useContext(RecentlyContext)
+ const { userCart } = useContext(CartContext)
+
+ const scrollTop = useCallback(() => {
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ })
+ }, [])
+
+ return (
+
+
+
CART
+
{userCart.length}
+
+ {recentlyViewedList.length !== 0 ? (
+
+ ) : null}
+
+ expand_less
+ TOP
+
+
+ )
+})
diff --git a/src/components/main/BestSeller.tsx b/src/components/main/BestSeller.tsx
new file mode 100644
index 00000000..575558cc
--- /dev/null
+++ b/src/components/main/BestSeller.tsx
@@ -0,0 +1,110 @@
+import React, { useState, useContext, useMemo } from 'react'
+import { BestProduct } from 'components/index'
+import { Product } from 'types/index'
+import { RecentlyContext } from 'contexts/index'
+import { tags } from 'constants/index'
+import 'styles/layout/BestSeller.scss'
+
+export const BestSeller = React.memo(
+ ({ products }: { products: Product[] }) => {
+ const bestProducts = useMemo(
+ () => products.filter(p => p.tags.includes(tags.CONST_TAG_BEST)),
+ [products]
+ )
+
+ const [activeTab, setActiveTab] = useState('Home')
+
+ const handleTabClick = (tab: string) => {
+ if (activeTab !== tab) {
+ setActiveTab(tab)
+ }
+ }
+
+ // 최근 본 상품 세션 저장 처리
+ const { recentlyViewedList, setRecentlyViewedList } =
+ useContext(RecentlyContext)
+
+ const onSaveProductRecently = (product: Product) => {
+ const isExist = recentlyViewedList.find(p => p.id === product.id)
+ if (!isExist) {
+ setRecentlyViewedList([...recentlyViewedList, product])
+ } else {
+ const removeList = recentlyViewedList.filter(p => p.id !== product.id)
+ setRecentlyViewedList([...removeList, product])
+ }
+ }
+
+ return (
+
+
+
BEST SELLER
+
+
+
+
+
+
+ {activeTab === 'Home' && (
+
+ {bestProducts.slice(0, 4).map(product => (
+
+ ))}
+
+ )}
+ {activeTab === 'Stationery' && (
+
+ {bestProducts
+ .filter(p => p.tags.includes(tags.CONST_TAG_STATIONERY))
+ .slice(0, 4)
+ .map(product => (
+
+ ))}
+
+ )}
+ {activeTab === 'Baby/Kids' && (
+
+ {bestProducts
+ .filter(p => p.tags.includes(tags.CONST_TAG_BABYKIDS))
+ .slice(0, 4)
+ .map(product => (
+
+ ))}
+
+ )}
+
+
+
+ )
+ }
+)
diff --git a/src/components/main/ColleyNews.tsx b/src/components/main/ColleyNews.tsx
new file mode 100644
index 00000000..7a374cf3
--- /dev/null
+++ b/src/components/main/ColleyNews.tsx
@@ -0,0 +1,68 @@
+import 'styles/layout/ColleyNews.scss'
+
+const ColleyNews = () => {
+ const newsItem1 =
+ 'https://colley.market/web/upload/news/issue_1/(%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF)%E1%84%8F%E1%85%A2%E1%84%85%E1%85%B5%E1%86%A8%E1%84%90%E1%85%A5-%E1%84%8B%E1%85%B3%E1%86%B7%E1%84%89%E1%85%B5%E1%86%A8.jpg'
+ const newsItem2 =
+ 'https://colley.market/web/upload/news/news_15/(%E1%84%8A%E1%85%A5%E1%86%B7%E1%84%82%E1%85%A6%E1%84%8B%E1%85%B5%E1%86%AF)%E1%84%82%E1%85%B2%E1%84%89%E1%85%B3-%E1%84%8B%E1%85%A6%E1%86%AF%E1%84%86%E1%85%A9.jpg'
+ const newsItem3 =
+ 'https://colley.market/web/upload/category/editor/2022/06/09/10c76d1d1b214a3b14c861863abc6a36.jpg'
+
+ return (
+
+
+
Colley News
+
+
+
+
+
+ ISSUE
+ NOW
+
+
안 먹어도 배불러~😋
+
저장 필수✔️캐릭터 음식.ZIP
+
+
+
+
+
+
+ NEWS
+ NOW
+
+
세서미 스트리트 넨도로이드 발매 💥
+
+ 엘모&쿠키몬스터 3월 초까지 사전 예약 판매 ❗
+
+
+
+
+
+
+
+ GOODS
+ NOW
+
+
월레스와 그로밋 네컷 포토 앨범📸
+
+ 저귀여운 그로밋과 맥그로우 2종 디자인🎈
+
+
+
+
+
+
+ )
+}
+
+export { ColleyNews }
diff --git a/src/components/main/ImageSlider.tsx b/src/components/main/ImageSlider.tsx
new file mode 100644
index 00000000..a25c95e0
--- /dev/null
+++ b/src/components/main/ImageSlider.tsx
@@ -0,0 +1,52 @@
+import React, { useState } from 'react'
+import 'styles/layout/ImageSlider.scss'
+
+interface SliderProps {
+ sliderImages: string[]
+}
+
+const ImageSlider: React.FC = ({ sliderImages = [] }) => {
+ const [currentImage, setCurrentImage] = useState(0)
+
+ const goToPreviousImage = () => {
+ if (currentImage === 0) {
+ setCurrentImage(sliderImages.length - 1)
+ } else {
+ setCurrentImage(currentImage - 1)
+ }
+ }
+
+ const goToNextImage = () => {
+ if (currentImage === sliderImages.length - 1) {
+ setCurrentImage(0)
+ } else {
+ setCurrentImage(currentImage + 1)
+ }
+ }
+
+ return (
+
+
+ {sliderImages.length > 0 && (
+
+ )}
+
+
+
+
+ )
+}
+
+export { ImageSlider }
diff --git a/src/components/main/NewArrival.tsx b/src/components/main/NewArrival.tsx
new file mode 100644
index 00000000..113859f1
--- /dev/null
+++ b/src/components/main/NewArrival.tsx
@@ -0,0 +1,104 @@
+import { useEffect, useState, useContext } from 'react'
+import { adminInstance } from 'api/index'
+import 'styles/layout/NewArrival.scss'
+import { Link } from 'react-router-dom'
+import { RecentlyContext } from 'contexts/index'
+import { Product } from 'types/index'
+import noImage from 'public/no-photo.png'
+
+const NewArrival = () => {
+ const [newProducts, setNewProducts] = useState([])
+
+ useEffect(() => {
+ const fetchProducts = async () => {
+ try {
+ const response = await adminInstance.get('/products')
+
+ const filteredProducts = response.data.filter(
+ (product: { tags: string | string[] }) => product.tags.includes('NEW')
+ )
+ setNewProducts(filteredProducts)
+ } catch (error) {
+ console.error('Error fetching products', error)
+ }
+ }
+
+ fetchProducts()
+ }, [])
+
+ const calculateDiscountedPrice = (price: number, discountRate?: number) => {
+ if (discountRate) {
+ const discountAmount = price * (discountRate / 100)
+ return price - discountAmount
+ }
+ return price
+ }
+
+ // 최근 본 상품 세션 저장 처리
+ const { recentlyViewedList, setRecentlyViewedList } =
+ useContext(RecentlyContext)
+
+ const onSaveProductRecently = (product: Product) => {
+ const isExist = recentlyViewedList.find(p => p.id === product.id)
+ if (!isExist) {
+ setRecentlyViewedList([...recentlyViewedList, product])
+ } else {
+ const removeList = recentlyViewedList.filter(p => p.id !== product.id)
+ setRecentlyViewedList([...removeList, product])
+ }
+ }
+
+ return (
+
+
+
NEW ARRIVAL
+
콜리에 새롭게 들어온 제품을 소개합니다.
+
+ {newProducts.map(product => (
+
+
onSaveProductRecently(product)}>
+
+
+
+
{product.title}
+
+
+ {product.discountRate ? (
+ <>
+
+ {product.price.toLocaleString()}원
+ {' '}
+
+ {calculateDiscountedPrice(
+ product.price,
+ product.discountRate
+ ).toLocaleString()}
+ 원
+
+ >
+ ) : (
+ <>{product.price.toLocaleString()}원>
+ )}
+
+
+ {product.tags.includes('NEW') ? (
+
NEW
+ ) : null}
+ {product.tags.includes('BEST') ? (
+
HOT
+ ) : null}
+
+
+ ))}
+
+
+
+ )
+}
+
+export { NewArrival }
diff --git a/src/components/main/PromotionSlider.tsx b/src/components/main/PromotionSlider.tsx
new file mode 100644
index 00000000..fc93ca1e
--- /dev/null
+++ b/src/components/main/PromotionSlider.tsx
@@ -0,0 +1,52 @@
+import React, { useState } from 'react'
+import 'styles/layout/PromotionSlider.scss'
+
+interface SliderProps {
+ promotionImages: string[]
+}
+
+const PromotionSlider: React.FC = ({ promotionImages = [] }) => {
+ const [currentImage, setCurrentImage] = useState(0)
+
+ const goToPreviousImage = () => {
+ if (currentImage === 0) {
+ setCurrentImage(promotionImages.length - 1)
+ } else {
+ setCurrentImage(currentImage - 1)
+ }
+ }
+
+ const goToNextImage = () => {
+ if (currentImage === promotionImages.length - 1) {
+ setCurrentImage(0)
+ } else {
+ setCurrentImage(currentImage + 1)
+ }
+ }
+
+ return (
+
+
+ {promotionImages.length > 0 && (
+
+ )}
+
+
+
+
+ )
+}
+
+export { PromotionSlider }
diff --git a/src/components/main/RecentlyList.tsx b/src/components/main/RecentlyList.tsx
new file mode 100644
index 00000000..f24d8f32
--- /dev/null
+++ b/src/components/main/RecentlyList.tsx
@@ -0,0 +1,64 @@
+import React, { useEffect, useState } from 'react'
+import { Product } from 'types/index'
+import SwiperCore, { Navigation } from 'swiper'
+import { Swiper, SwiperSlide } from 'swiper/react'
+import { Link } from 'react-router-dom'
+import styled from 'styles/components/main/recentlyList.module.scss'
+import 'swiper/scss'
+import 'swiper/scss/navigation'
+
+export const RecentlyList = React.memo(
+ ({ products }: { products: Product[] }) => {
+ const [isObserver, setIsObserver] = useState(false)
+ const navigationPrevRef = React.useRef(null)
+ const navigationNextRef = React.useRef(null)
+ useEffect(() => {
+ if (navigationNextRef.current && navigationPrevRef.current) {
+ setIsObserver(true)
+ } else {
+ setIsObserver(false)
+ }
+ }, [navigationPrevRef, navigationNextRef])
+
+ SwiperCore.use([Navigation])
+
+ return (
+
+
{`RECENTLY\nVIEWED`}
+ {isObserver ? (
+
+ {products.map(product => {
+ return (
+
+
+
+
+
+ )
+ })}
+
+ ) : null}
+
+
+ )
+ }
+)
diff --git a/src/components/main/index.ts b/src/components/main/index.ts
new file mode 100644
index 00000000..993765f1
--- /dev/null
+++ b/src/components/main/index.ts
@@ -0,0 +1,7 @@
+export * from 'components/main/BestSeller'
+export * from 'components/main/ColleyNews'
+export * from 'components/main/ImageSlider'
+export * from 'components/main/NewArrival'
+export * from 'components/main/PromotionSlider'
+export * from 'components/main/RecentlyList'
+export * from 'components/main/Badge'
diff --git a/src/components/mypage/MyOrderItem.tsx b/src/components/mypage/MyOrderItem.tsx
new file mode 100644
index 00000000..375b9533
--- /dev/null
+++ b/src/components/mypage/MyOrderItem.tsx
@@ -0,0 +1,67 @@
+import React, { useMemo } from 'react'
+import moment from 'moment'
+import { MyOrderItemProps } from 'types/index'
+import { calculateDiscountedPrice } from 'utils/index'
+import styled from 'styles/components/mypage/myOrderItem.module.scss'
+import { Link } from 'react-router-dom'
+import noImage from 'public/no-photo.png'
+
+export const MyOrderItem = React.memo(
+ ({ detail, isLast, onClickConfirm, onClickCancel }: MyOrderItemProps) => {
+ const paidDate = useMemo(() => {
+ const date = moment(detail.timePaid).format('YYYY-MM-DD HH:mm')
+ return date
+ }, [detail])
+
+ const paidPrice = useMemo(
+ () =>
+ calculateDiscountedPrice(
+ detail.product.price,
+ detail.product.discountRate
+ ),
+ [detail]
+ )
+
+ const handleClickConfirmOrder = () => {
+ onClickConfirm(detail.detailId)
+ }
+
+ const handleClickCancelOrder = () => {
+ onClickCancel(detail.detailId)
+ }
+
+ return (
+
+
+ {paidDate}
+
+ {detail.product.title}
+ {paidPrice.toLocaleString()}원
+
+
+ {detail.done && !detail.isCanceled ? (
+ 구매확정 완료
+ ) : detail.isCanceled ? (
+ 주문취소 완료
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ )
+ }
+)
diff --git a/src/components/mypage/MyOrderItemSkeleton.tsx b/src/components/mypage/MyOrderItemSkeleton.tsx
new file mode 100644
index 00000000..ecc11e23
--- /dev/null
+++ b/src/components/mypage/MyOrderItemSkeleton.tsx
@@ -0,0 +1,17 @@
+import React from 'react'
+import styled from 'styles/components/mypage/myOrderItem.module.scss'
+
+export const MyOrderItemSkeleton = React.memo(
+ ({ isLast }: { isLast: boolean }) => {
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+)
diff --git a/src/components/mypage/MyOrderList.tsx b/src/components/mypage/MyOrderList.tsx
new file mode 100644
index 00000000..ef151225
--- /dev/null
+++ b/src/components/mypage/MyOrderList.tsx
@@ -0,0 +1,150 @@
+import React, { useCallback, useMemo, useState } from 'react'
+import { Modal, MyOrderItem, MyOrderItemSkeleton } from 'components/index'
+import Pagination from 'react-js-pagination'
+import { TransactionDetail, ModalProps } from 'types/index'
+import { confirmOrder, cancelOrder } from 'api/index'
+import styled from 'styles/components/mypage/myOrderList.module.scss'
+
+export const MyOrderList = React.memo(
+ ({
+ title,
+ orders,
+ onFetch,
+ isLoading
+ }: {
+ title: string
+ orders: TransactionDetail[]
+ onFetch: () => void
+ isLoading: boolean
+ }) => {
+ const [page, setPage] = useState(1)
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [modalProps, setModalProps] = useState(null)
+
+ const pagedOrders = useMemo(() => {
+ if (orders.length === 0) {
+ return []
+ }
+
+ const list = orders.sort((a, b) => {
+ if (a.timePaid < b.timePaid) {
+ return 1
+ }
+ if (a.timePaid > b.timePaid) {
+ return -1
+ }
+ return 0
+ })
+ const indexOfLast = page * 5
+ const indexOfFirst = indexOfLast - 5
+ return list.slice(indexOfFirst, indexOfLast)
+ }, [orders, page])
+
+ const onClickConfirm = useCallback(
+ (id: string) => {
+ setIsModalShow(true)
+ setModalProps({
+ title: '구매확정',
+ content: '상품을 구매확정 하시겠습니까?',
+ isTwoButton: true,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ confirmOrder(id).then(isSccess => {
+ if (isSccess) {
+ onFetch()
+ setIsModalShow(false)
+ }
+ })
+ },
+ cancelButtonText: '취소',
+ onClickCancelButton: () => {
+ setIsModalShow(false)
+ }
+ })
+ },
+ [onFetch]
+ )
+
+ const onClickCancel = useCallback(
+ (id: string) => {
+ setIsModalShow(true)
+ setModalProps({
+ title: '주문취소',
+ content:
+ '상품 주문을 취소하시겠습니까? \n주문 취소 시 주문 금액은 주문하신 계좌로 환불됩니다.',
+ isTwoButton: true,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ cancelOrder(id).then(isSccess => {
+ if (isSccess) {
+ onFetch()
+ setIsModalShow(false)
+ }
+ })
+ },
+ cancelButtonText: '취소',
+ onClickCancelButton: () => {
+ setIsModalShow(false)
+ }
+ })
+ },
+ [onFetch]
+ )
+
+ return (
+
+
{title}
+
+ {isLoading ? (
+ <>
+
+
+
+
+
+ >
+ ) : orders.length === 0 ? (
+ - 주문 내역이 없습니다.
+ ) : (
+ pagedOrders.map((order, index) => (
+
+ ))
+ )}
+
+ {orders.length !== 0 ? (
+
+ ) : null}
+
+ {isModalShow && modalProps ? (
+
+ ) : null}
+
+ )
+ }
+)
diff --git a/src/components/mypage/MyOrderStatus.tsx b/src/components/mypage/MyOrderStatus.tsx
new file mode 100644
index 00000000..a1bd09cd
--- /dev/null
+++ b/src/components/mypage/MyOrderStatus.tsx
@@ -0,0 +1,57 @@
+import React, { useMemo } from 'react'
+import { TransactionDetail } from 'types/index'
+import styled from 'styles/components/mypage/myOrderStatus.module.scss'
+
+export const MyOrderStatus = React.memo(
+ ({
+ orders,
+ isLoading
+ }: {
+ orders: TransactionDetail[]
+ isLoading: boolean
+ }) => {
+ const doneCount = useMemo(() => {
+ return orders.filter(order => order.done).length
+ }, [orders])
+
+ const shippingCount = useMemo(() => {
+ return orders.filter(order => !order.done && !order.isCanceled).length
+ }, [orders])
+
+ return (
+
+
나의 주문처리 현황
+
+
+ {isLoading ? (
+
+ ) : (
+
0
+ )}
+
입금전
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {shippingCount.toLocaleString()}
+
+ )}
+
배송중
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {doneCount.toLocaleString()}
+
+ )}
+
구매완료
+
+
+
+ )
+ }
+)
diff --git a/src/components/mypage/MyOrderSummary.tsx b/src/components/mypage/MyOrderSummary.tsx
new file mode 100644
index 00000000..ce809438
--- /dev/null
+++ b/src/components/mypage/MyOrderSummary.tsx
@@ -0,0 +1,85 @@
+import React, { useMemo } from 'react'
+import { TransactionDetail } from 'types/index'
+import { calculateDiscountedPrice } from 'utils/index'
+import styled from 'styles/components/mypage/myOrderSummary.module.scss'
+import iconWon from '/public/ico_won.png'
+import iconCoupon from '/public/ico_coupon.png'
+import iconOrder from '/public/ico_orders.png'
+
+export const MyOrderSummary = React.memo(
+ ({
+ orders,
+ isLoading
+ }: {
+ orders: TransactionDetail[]
+ isLoading: boolean
+ }) => {
+ // 총 주문 금액
+ const totalOrderPrice = useMemo(() => {
+ const totalPrice = orders
+ .filter(order => !order.isCanceled)
+ .reduce((total, order) => {
+ total += calculateDiscountedPrice(
+ order.product.price,
+ order.product.discountRate
+ )
+ return total
+ }, 0)
+ return totalPrice
+ }, [orders])
+
+ // 적립금 (주문금액의 1%)
+ const point = useMemo(() => {
+ return Math.floor(totalOrderPrice * 0.01)
+ }, [totalOrderPrice])
+
+ return (
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {point.toLocaleString()}원
+
+ )}
+
총적립금
+
+
+
+ {isLoading ? (
+
+ ) : (
+
0개
+ )}
+
쿠폰
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ {totalOrderPrice.toLocaleString()}원 (
+ {orders.filter(order => !order.isCanceled).length}회)
+
+ )}
+
총주문
+
+
+ )
+ }
+)
diff --git a/src/components/mypage/MyPageNav.tsx b/src/components/mypage/MyPageNav.tsx
new file mode 100644
index 00000000..f22bd5fd
--- /dev/null
+++ b/src/components/mypage/MyPageNav.tsx
@@ -0,0 +1,71 @@
+import { useNavigate, Link } from 'react-router-dom'
+import { logOut } from 'api/index'
+import { useContext } from 'react'
+import styles from 'styles/components/mypage/mypageNav.module.scss'
+import { LoginContext, LoginedUserContext, CartContext } from 'contexts/index'
+import { CommonError } from 'types/index'
+
+export const MyPageNav = () => {
+ const { setIsLogined } = useContext(LoginContext)
+ const { setUserEmail } = useContext(LoginedUserContext)
+ const { setUserCart } = useContext(CartContext)
+ const navigate = useNavigate()
+
+ const handleLogout = () => {
+ setUserEmail('')
+ setUserCart([])
+ localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN)
+ setIsLogined(false)
+ navigate('/')
+ }
+
+ const logOutId = () => {
+ logOut()
+ .then(isSuccess => {
+ if (isSuccess) {
+ handleLogout()
+ alert('로그아웃되었습니다.')
+ }
+ })
+ .catch((error: CommonError) => {
+ if (error.status === 401) {
+ navigate('/')
+ }
+ })
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/mypage/MyWishItem.tsx b/src/components/mypage/MyWishItem.tsx
new file mode 100644
index 00000000..7aff80b5
--- /dev/null
+++ b/src/components/mypage/MyWishItem.tsx
@@ -0,0 +1,54 @@
+import React, { useMemo, useContext } from 'react'
+import { MyWishItemProps } from 'types/index'
+import { calculateDiscountedPrice } from 'utils/index'
+import styled from 'styles/components/mypage/myWishItem.module.scss'
+import { Link } from 'react-router-dom'
+import { WishListContext } from 'contexts/index'
+import noImage from 'public/no-photo.png'
+
+export const MyWishrItem = React.memo(
+ ({ product, isLast, isChecked, onChange }: MyWishItemProps) => {
+ const { wishList, setWishList } = useContext(WishListContext)
+
+ const productPrice = useMemo(
+ () =>
+ calculateDiscountedPrice(product.price, product.discountRate ?? null),
+ [product]
+ )
+
+ const handleDeleteWishItem = () => {
+ setWishList(wishList.filter(item => item.id !== product.id))
+ }
+
+ const handleCheckedChange = () => {
+ onChange(product.id)
+ }
+
+ return (
+
+
+
+
+
+
{product.title}
+
+ {productPrice.toLocaleString()}원
+
+
+
+
+ close
+
+
+ )
+ }
+)
diff --git a/src/components/mypage/MyWishList.tsx b/src/components/mypage/MyWishList.tsx
new file mode 100644
index 00000000..4ce3a736
--- /dev/null
+++ b/src/components/mypage/MyWishList.tsx
@@ -0,0 +1,95 @@
+import React, { useCallback, useMemo, useState, useContext } from 'react'
+import { MyWishrItem } from 'components/index'
+import Pagination from 'react-js-pagination'
+import { Product } from 'types/index'
+import { WishListContext } from 'contexts/index'
+import styled from 'styles/components/mypage/myWishList.module.scss'
+
+export const MyWishList = React.memo(
+ ({ wishList }: { wishList: Product[] }) => {
+ const { setWishList } = useContext(WishListContext)
+ const [page, setPage] = useState(1)
+ const [checkedList, setCheckedList] = useState([])
+
+ const pagedWishList = useMemo(() => {
+ if (wishList.length === 0) {
+ return []
+ }
+
+ const indexOfLast = page * 10
+ const indexOfFirst = indexOfLast - 10
+ return wishList.slice(indexOfFirst, indexOfLast)
+ }, [wishList, page])
+
+ const onChangeCheckedList = useCallback(
+ (id: string) => {
+ if (checkedList.includes(id)) {
+ setCheckedList(crr => crr.filter(checkedId => checkedId !== id))
+ } else {
+ setCheckedList(crr => [...crr, id])
+ }
+ },
+ [checkedList]
+ )
+
+ const onDeleteAllWishList = () => {
+ setWishList([])
+ }
+
+ const onDeleteCheckedWishList = () => {
+ const list = wishList.filter(product => !checkedList.includes(product.id))
+ setWishList(list)
+ }
+
+ return (
+
+
+ {wishList.length === 0 ? (
+ - 관심상품 내역이 없습니다.
+ ) : (
+ pagedWishList.map((product, index) => (
+
+ ))
+ )}
+
+
+ {wishList.length !== 0 ? (
+ <>
+
+
+
+
+
+
+ >
+ ) : null}
+
+ )
+ }
+)
diff --git a/src/components/mypage/index.ts b/src/components/mypage/index.ts
new file mode 100644
index 00000000..d957acae
--- /dev/null
+++ b/src/components/mypage/index.ts
@@ -0,0 +1,8 @@
+export * from 'components/mypage/MyPageNav'
+export * from 'components/mypage/MyOrderSummary'
+export * from 'components/mypage/MyOrderStatus'
+export * from 'components/mypage/MyOrderList'
+export * from 'components/mypage/MyOrderItem'
+export * from 'components/mypage/MyOrderItemSkeleton'
+export * from 'components/mypage/MyWishList'
+export * from 'components/mypage/MyWishItem'
diff --git a/src/components/payment/BankSelection.tsx b/src/components/payment/BankSelection.tsx
new file mode 100644
index 00000000..abe54fd5
--- /dev/null
+++ b/src/components/payment/BankSelection.tsx
@@ -0,0 +1,93 @@
+import { AccountNumberContext, BankContext } from 'contexts/index'
+import { useEffect, useRef } from 'react'
+import { useContext, useReducer, Reducer } from 'react'
+
+import { childProps } from 'types/index'
+
+import styles from 'styles/components/payment/BankSelection.module.scss'
+
+export const BankSelection = ({ setValid }: childProps) => {
+ const { bank, setBank } = useContext(BankContext)
+ const { accountNumber, setAccountNumber } = useContext(AccountNumberContext)
+ const accountMax = useRef(null)
+
+ const accountNumberHandler = (e: React.ChangeEvent) => {
+ const acc = e.currentTarget.value.toString()
+ if (acc === '' || /^[0-9\b]+$/.test(acc)) {
+ setAccountNumber(e.currentTarget.value)
+ }
+ }
+ const bankSelectionHandler = async (
+ e: React.ChangeEvent
+ ) => {
+ setBank(e.currentTarget.value)
+ setAccountNumber('')
+ }
+
+ const reducer: Reducer = (
+ state: number,
+ action: string
+ ): number => {
+ const actionHandlers = new Map()
+ .set('004', 12)
+ .set('088', 12)
+ .set('020', 13)
+ .set('081', 14)
+ .set('089', 12)
+ .set('090', 13)
+ .set('011', 13)
+
+ if (actionHandlers.has(action)) {
+ return actionHandlers.get(action) as number
+ }
+
+ return state
+ }
+
+ const [max, dispatch] = useReducer(reducer, 12)
+
+ useEffect(() => {
+ dispatch(bank)
+ }, [bank])
+
+ useEffect(() => {
+ accountMax.current?.maxLength === accountNumber.length
+ ? setValid(true)
+ : setValid(false)
+ }, [setValid, bank, accountMax, accountNumber])
+
+ return (
+
+
+ {/* ACCOUNT NUMBER */}
+
+
+ )
+}
diff --git a/src/components/payment/Confirmation.tsx b/src/components/payment/Confirmation.tsx
new file mode 100644
index 00000000..174a31dc
--- /dev/null
+++ b/src/components/payment/Confirmation.tsx
@@ -0,0 +1,133 @@
+import styles from 'src/styles/components/payment/Confirmation.module.scss'
+import { useLocation, useNavigate } from 'react-router-dom'
+import { CartProduct } from 'types/index'
+import { calculateDiscountedPrice } from 'utils/index'
+import { transactPayment } from 'api/paymentRequests'
+import {
+ CartContext,
+ UseremailContext,
+ UsernameContext,
+ UserAddressContext,
+ PhoneNumberContext,
+ AddressDetailContext
+} from 'contexts/index'
+import { useState, useContext, useEffect } from 'react'
+import { Modal } from 'components/index'
+import { ModalProps } from '@/types'
+import { Bank } from 'types/index'
+
+export const Confirmation = ({
+ accountData,
+ selected
+}: {
+ accountData: Bank[]
+ selected: string
+}) => {
+ const navigate = useNavigate()
+ const { setUserCart } = useContext(CartContext)
+ const { address } = useContext(UserAddressContext)
+ const { name } = useContext(UsernameContext)
+ const { phoneNumber } = useContext(PhoneNumberContext)
+ const { email } = useContext(UseremailContext)
+ const { addressDetail } = useContext(AddressDetailContext)
+
+ const [modalProps, setModalProps] = useState(null)
+ const [isModalShow, setIsModalShow] = useState(false)
+ //receipt - 개별 상품 or 장바구니 상품 정보
+ const receipt = useLocation().state.products
+ const total = receipt.reduce((acc: number, cur: CartProduct) => {
+ return acc + cur.product.price * cur.quantity
+ }, 0)
+ const discountedPrice = receipt.reduce((acc: number, cur: CartProduct) => {
+ const discounted = calculateDiscountedPrice(
+ cur.product.price,
+ cur.product.discountRate
+ )
+ return acc + discounted * cur.quantity
+ }, 0)
+ const delivery = 3000
+
+ const paymentHandler = (pro: string, acc: string) => {
+ transactPayment({ productId: pro, accountId: acc })
+ }
+ // console.log(receipt)
+ useEffect(() => {
+ if (isModalShow) {
+ setModalProps({
+ title: '결제완료',
+ isTwoButton: false,
+ content: '결제가 완료되었습니다.',
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ navigate('/success', {
+ state: {
+ //상품정보 데이터
+ products: [...receipt],
+ address: address,
+ name: name,
+ email: email,
+ phoneNumber: phoneNumber,
+ addressDetail: addressDetail
+ }
+ })
+ }
+ })
+ }
+ }, [
+ isModalShow,
+ navigate,
+ receipt,
+ address,
+ addressDetail,
+ phoneNumber,
+ name,
+ email
+ ])
+ const validateAndPay = () => {
+ //계좌정보 / 사용자명 / 이메일 / 주소 / 휴대전화
+ if (selected && name && email && address && phoneNumber) {
+ // 계좌 목록 중 선택된 계좌를 찾아 '상품가격' 과 '선택된 계좌의 자산'을 비교
+ const accountBalance = accountData.find(
+ account => account.id === selected
+ )?.balance
+ if ((accountBalance as number) > total) {
+ receipt.map((item: CartProduct) => {
+ paymentHandler(item.product.id, selected)
+ })
+ setIsModalShow(true)
+ setUserCart([])
+ }
+ if ((accountBalance as number) < total) {
+ alert('계좌의 잔액이 부족합니다.')
+ }
+ } else {
+ alert('필수입력정보를 다시 확인해주세요.')
+ }
+ }
+ return (
+
+
구매조건 확인 및 결제진행 동의
+
+ 주문 내용을 확인하였으며 약관에 동의합니다.
+
+
+ {/* MODAL */}
+ {isModalShow && modalProps ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/payment/DaumPostCode.tsx b/src/components/payment/DaumPostCode.tsx
new file mode 100644
index 00000000..3bf1915e
--- /dev/null
+++ b/src/components/payment/DaumPostCode.tsx
@@ -0,0 +1,74 @@
+import { useState, useContext } from 'react'
+import { UserAddressContext, AddressDetailContext } from 'contexts/index'
+import styles from 'src/styles/components/payment/DaumPostCode.module.scss'
+import { useDaumPostcodePopup } from 'react-daum-postcode'
+
+export const DaumPostCode = () => {
+ const { address, setAddress } = useContext(UserAddressContext)
+ const { setAddressDetail } = useContext(AddressDetailContext)
+
+ const [zoneCode, setZoneCode] = useState('')
+
+ const open = useDaumPostcodePopup() //미입력시 기본값 => 우편번호 스크립트 주소
+ const handleComplete = (data: any) => {
+ console.log(data)
+ let fullAddress = data.address
+ let extraAddress = ''
+
+ if (data.addressType === 'R') {
+ //R(도로명), J(지번)
+ if (data.bname !== '') {
+ //법정동/법정리 이름
+ extraAddress += data.bname
+ }
+ if (data.buildingName !== '') {
+ extraAddress +=
+ extraAddress !== '' ? `, ${data.buildingName}` : data.buildingName
+ }
+ fullAddress += extraAddress !== '' ? ` (${extraAddress})` : ''
+ }
+ setAddress(fullAddress)
+ setZoneCode(data.zonecode)
+ }
+
+ const handleClick = () => {
+ open({ onComplete: handleComplete }) //onComplete: 사용자 선택정보 수신 콜백
+ }
+ const handleAddressDetail = (e: React.ChangeEvent) => {
+ setAddressDetail(e.target.value)
+ }
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/components/payment/PaymentMethods.tsx b/src/components/payment/PaymentMethods.tsx
new file mode 100644
index 00000000..306935d5
--- /dev/null
+++ b/src/components/payment/PaymentMethods.tsx
@@ -0,0 +1,199 @@
+import { useEffect, useRef, useState, useContext, useCallback } from 'react'
+import {
+ loadPaymentWidget,
+ PaymentWidgetInstance
+} from '@tosspayments/payment-widget-sdk'
+import { Modal } from 'components/index'
+import { Confirmation, BankSelection } from 'components/payment/index'
+import styles from 'src/styles/components/payment/PaymentMethods.module.scss'
+import {
+ PhoneNumberContext,
+ BankContext,
+ AccountNumberContext
+} from 'contexts/index'
+import { ModalProps } from 'types/index'
+import { Swiper, SwiperSlide } from 'swiper/react'
+import SwiperCore, { Navigation } from 'swiper'
+import 'swiper/scss'
+import 'swiper/scss/navigation'
+import { Bank } from 'types/index'
+import { removeAccount, getAccounts, createAccount } from 'api/index'
+
+export const PaymentMethods = () => {
+ // USER INFO
+ const { phoneNumber } = useContext(PhoneNumberContext)
+ const { bank } = useContext(BankContext)
+ const { accountNumber } = useContext(AccountNumberContext)
+ // MODAL
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [modalProps, setModalProps] = useState(null)
+ // ACCOUNT
+ const [selected, setSelected] = useState('')
+ const [accountData, setAccountData] = useState([])
+ const [valid, setValid] = useState(false)
+
+ SwiperCore.use([Navigation])
+
+ //MODAL HANDLERS
+ const modalCancelHandler = () => {
+ setIsModalShow(false)
+ }
+ const modalOpenHandler = useCallback(() => {
+ if (phoneNumber.length === 11) {
+ setIsModalShow(true)
+ } else {
+ alert('휴대전화번호를 정확히 입력해주세요.')
+ }
+ }, [phoneNumber])
+
+ // ACCOUNTS FUNCTIONS
+ const createAndRender = useCallback(async () => {
+ await createAccount({
+ bankCode: bank,
+ accountNumber: accountNumber,
+ phoneNumber: phoneNumber,
+ signature: true
+ })
+ }, [bank, accountNumber, phoneNumber])
+
+ const removeAndAlert = async (val: string) => {
+ await removeAccount({
+ accountId: val,
+ signature: true
+ })
+ alert('계좌가 삭제되었습니다.')
+ }
+
+ const addAccountHandler = useCallback(() => {
+ if (!valid) {
+ alert('계좌 추가에 실패했습니다. 계좌번호를 끝까지 입력해주세요.')
+ }
+ const accountExists = accountData.some(account =>
+ account.bankCode.includes(bank)
+ )
+ if (valid && accountExists) {
+ alert('이미 존재하는 계좌입니다.')
+ }
+
+ if (valid && !accountExists) {
+ createAndRender().then(() => {
+ alert('계좌가 추가되었습니다.')
+ setIsModalShow(false)
+ })
+ }
+ }, [createAndRender, valid, accountData, bank])
+
+ // ######TOSS PAYMENTS WIDGET
+ const clientKey = 'test_ck_P24xLea5zVAxXyyGMxb3QAMYNwW6'
+ const customerKey = 'YbX2HuSlsC9uVJW6NMRMj'
+
+ const paymentWidgetRef = useRef(null) //인스턴스 저장 - useRef
+ const price = 50_000
+
+ useEffect(() => {
+ // eslint-disable-next-line @typescript-eslint/no-extra-semi
+ ;(async () => {
+ const paymentWidget = await loadPaymentWidget(clientKey, customerKey) //인스턴스 생성
+
+ paymentWidget.renderPaymentMethods('#payment-widget', price) //결제위젯 렌더링
+
+ paymentWidgetRef.current = paymentWidget
+ })()
+ }, [])
+ // INITIAL ACCOUNTS RENDER
+ useEffect(() => {
+ getAccounts().then(response => {
+ setAccountData(response)
+ })
+ }, [accountData])
+
+ useEffect(() => {
+ if (phoneNumber.length === 11) {
+ setModalProps({
+ title: '계좌 추가',
+ isTwoButton: true,
+ okButtonText: '추가',
+ onClickOkButton: addAccountHandler,
+ cancelButtonText: '취소',
+ onClickCancelButton: modalCancelHandler
+ })
+ }
+ }, [
+ bank,
+ accountNumber,
+ phoneNumber,
+ createAndRender,
+ valid,
+ addAccountHandler
+ ])
+
+ return (
+
+
+
+
+
+ +
+ 계좌추가
+
+
+ 계좌를 추가하지 않을 시 결제가 진행되지 않습니다.
+
+
+ {/* 생성된 계좌 */}
+ {accountData.map(item => (
+
+
+
+ 더블클릭으로 결제를 진행할 계좌를 선택해주세요.
+
+
+ ))}
+
+
+ {/* PRODUCTS ID => PROPS */}
+
+ {/* MODAL */}
+ {phoneNumber.length === 11 && isModalShow && modalProps ? (
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/payment/PriceInfo.tsx b/src/components/payment/PriceInfo.tsx
new file mode 100644
index 00000000..b05ac2dd
--- /dev/null
+++ b/src/components/payment/PriceInfo.tsx
@@ -0,0 +1,50 @@
+import styles from 'src/styles/components/payment/PriceInfo.module.scss'
+import { useLocation } from 'react-router-dom'
+import { CartProduct } from 'types/index'
+import { calculateDiscountedPrice } from 'utils/index'
+
+export const PriceInfo = () => {
+ const receipt = useLocation().state.products
+ const total = receipt.reduce((acc: number, cur: CartProduct) => {
+ return acc + cur.product.price * cur.quantity
+ }, 0)
+ const discountedPrice = receipt.reduce((acc: number, cur: CartProduct) => {
+ const discounted = calculateDiscountedPrice(
+ cur.product.price,
+ cur.product.discountRate
+ )
+ return acc + discounted * cur.quantity
+ }, 0)
+ const delivery = 3000
+
+ return (
+ <>
+
+
결제 정보
+
+
+ 주문상품
+ {total.toLocaleString()}원
+
+
+ 배송비
+ +3,000원
+
+
+ 할인/부가결제
+
+ {(total - discountedPrice).toLocaleString()}
+ 원
+
+
+
+
+
+ 최종 결제 금액
+
+ {(total - (total - discountedPrice) + delivery).toLocaleString()}원
+
+
+ >
+ )
+}
diff --git a/src/components/payment/ProductInfo.tsx b/src/components/payment/ProductInfo.tsx
new file mode 100644
index 00000000..e3c08039
--- /dev/null
+++ b/src/components/payment/ProductInfo.tsx
@@ -0,0 +1,35 @@
+import styles from 'src/styles/components/payment/ProductInfo.module.scss'
+import { useLocation } from 'react-router-dom'
+import { CartProduct } from 'types/index'
+
+export const ProductInfo = () => {
+ const chosenProduct = useLocation().state.products
+ return (
+
+ )
+}
diff --git a/src/components/payment/UserAddress.tsx b/src/components/payment/UserAddress.tsx
new file mode 100644
index 00000000..11273656
--- /dev/null
+++ b/src/components/payment/UserAddress.tsx
@@ -0,0 +1,90 @@
+import styles from 'src/styles/components/payment/UserAddress.module.scss'
+import { useContext, useState } from 'react'
+import { UsernameContext } from 'contexts/UsernameContext'
+import { UseremailContext } from 'contexts/UseremailContext'
+import { PhoneNumberContext } from 'contexts/PhoneNumberContext'
+import { DaumPostCode } from 'components/payment/index'
+
+export const UserAddress = () => {
+ const { name, setName } = useContext(UsernameContext)
+ const { setEmail } = useContext(UseremailContext)
+ const { phoneNumber, setPhoneNumber } = useContext(PhoneNumberContext)
+ const [isValidEmail, setIsValidEmail] = useState(false)
+ const [isValidPhoneNumber, setIsValidPhoneNumber] = useState(false)
+ const [isValidName, setIsValidName] = useState(false)
+
+ //USERNAME
+ const nameCheckHandler = (e: React.ChangeEvent) => {
+ setName(e.target.value)
+ if (!(name.length < 2 || name.length > 5)) {
+ setIsValidName(true)
+ }
+ }
+
+ // PHONE NUMBER
+ const numberCheckHandler = (e: React.ChangeEvent) => {
+ const phone = e.currentTarget.value.toString()
+ if (phone === '' || /^[0-9\b]+$/.test(phone)) {
+ setPhoneNumber(phone)
+ setIsValidPhoneNumber(false)
+ }
+ if (phone.length === 11) {
+ setIsValidPhoneNumber(true)
+ }
+ }
+ // EMAIL
+ const emailCheckHandler = (e: React.ChangeEvent) => {
+ const emailRegEx =
+ /^[A-Za-z0-9]([-_.]?[A-Za-z0-9])*@[A-Za-z0-9]([-_.]?[A-Za-z0-9])*\.[A-Za-z]{2,3}$/i
+ const input = e.target.value
+ const emailCheck = (username: string) => {
+ return emailRegEx.test(username) //형식에 맞을 경우, true 리턴
+ }
+ if (emailCheck(input)) {
+ setEmail(input)
+ setIsValidEmail(true)
+ } else {
+ setIsValidEmail(false)
+ }
+ }
+
+ return (
+
+
배송지
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/payment/index.ts b/src/components/payment/index.ts
new file mode 100644
index 00000000..e81b096e
--- /dev/null
+++ b/src/components/payment/index.ts
@@ -0,0 +1,8 @@
+//PAYMENT PAGE
+export * from 'components/payment/ProductInfo'
+export * from 'components/payment/UserAddress'
+export * from 'components/payment/PriceInfo'
+export * from 'components/payment/PaymentMethods'
+export * from 'components/payment/Confirmation'
+export * from 'components/payment/DaumPostCode'
+export * from 'components/payment/BankSelection'
diff --git a/src/components/product/BestProduct.tsx b/src/components/product/BestProduct.tsx
new file mode 100644
index 00000000..afad8f1a
--- /dev/null
+++ b/src/components/product/BestProduct.tsx
@@ -0,0 +1,56 @@
+import React from 'react'
+import { Link } from 'react-router-dom'
+import { BestProductProps } from 'types/index'
+import { calculateDiscountedPrice } from 'utils/index'
+import 'styles/layout/BestSeller.scss'
+import noImage from 'public/no-photo.png'
+
+export const BestProduct = React.memo(
+ ({ product, onSaveProductRecently }: BestProductProps) => {
+ return (
+
+
{
+ onSaveProductRecently(product)
+ }}>
+
+
+
+
{product.title}
+
+ {product.discountRate ? (
+ <>
+
+ {product.price.toLocaleString()}원
+
+
+ {calculateDiscountedPrice(
+ product.price,
+ product.discountRate
+ ).toLocaleString()}
+ 원
+
+ >
+ ) : (
+ <>{product.price.toLocaleString()}원>
+ )}
+
+
+ {product.tags.includes('NEW') ? (
+
NEW
+ ) : null}
+ {product.tags.includes('BEST') ? (
+
HOT
+ ) : null}
+
+
+
+ )
+ }
+)
diff --git a/src/components/product/ProductDetailInfoTab.tsx b/src/components/product/ProductDetailInfoTab.tsx
new file mode 100644
index 00000000..6f360fe9
--- /dev/null
+++ b/src/components/product/ProductDetailInfoTab.tsx
@@ -0,0 +1,56 @@
+import { useMemo, useState } from 'react'
+import { tabItems } from 'constants/index'
+import styled from 'styles/components/product/productDetailInfoTab.module.scss'
+
+export const ProductDetailInfoTab = ({ child }: { child: JSX.Element }) => {
+ const [isSelectedTab, setIsSelectedTab] = useState(0)
+ const tabItemMap = new Map()
+ .set(1, tabItems.PAYMENT_INFO)
+ .set(2, tabItems.PRODUCT_REVIEW)
+ .set(3, tabItems.PRODUCT_QaA)
+
+ const tabItem = useMemo(() => {
+ return tabItemMap.get(isSelectedTab) || null
+ }, [isSelectedTab, tabItemMap])
+
+ return (
+ <>
+
+ - setIsSelectedTab(0)}>
+ 상품상세정보
+
+ - setIsSelectedTab(1)}>
+ 상품구매안내
+
+ - setIsSelectedTab(2)}>
+ 상품사용후기
+
+ - setIsSelectedTab(3)}>
+ 상품Q&A
+
+
+ {isSelectedTab === 0 && child}
+ {isSelectedTab !== 0 && (
+
+ {tabItem &&
+ tabItem.datas.map(data => {
+ return (
+
+
{data.title}
+
{data.content}
+
+ )
+ })}
+
+ )}
+ >
+ )
+}
diff --git a/src/components/product/Products.tsx b/src/components/product/Products.tsx
new file mode 100644
index 00000000..a9f14911
--- /dev/null
+++ b/src/components/product/Products.tsx
@@ -0,0 +1,170 @@
+import React, { useEffect, useState, useContext } from 'react'
+import { useLocation } from 'react-router-dom'
+import { adminInstance } from 'api/index'
+import 'styles/layout/NewArrival.scss'
+import { ProductsProps, Product } from 'types/index'
+import { Link } from 'react-router-dom'
+import { RecentlyContext } from 'contexts/index'
+import noImage from 'public/no-photo.png'
+
+const API_ENDPOINT = '/products'
+
+const Products = React.memo(
+ ({
+ tagFilter = [],
+ limit,
+ sortOption,
+ getProductCount,
+ keyword
+ }: ProductsProps) => {
+ const [products, setProducts] = useState([])
+ const location = useLocation()
+
+ useEffect(() => {
+ const fetchProducts = async () => {
+ try {
+ const query = API_ENDPOINT + location.search
+ const response = await adminInstance.get(query)
+ let filteredProducts: Product[] = response.data
+
+ if (tagFilter.length > 0) {
+ filteredProducts = filteredProducts.filter((product: Product) =>
+ tagFilter.every(tag => product.tags.includes(tag))
+ )
+ }
+
+ filteredProducts = filteredProducts.filter(
+ (product: Product) => !product.isSoldOut
+ )
+
+ if (sortOption) {
+ filteredProducts.sort((a: Product, b: Product) => {
+ if (sortOption === 'priceLow') {
+ return a.price - b.price
+ } else if (sortOption === 'priceHigh') {
+ return b.price - a.price
+ } else if (sortOption === 'name') {
+ return a.title.localeCompare(b.title, 'ko', {
+ sensitivity: 'base'
+ })
+ } else {
+ return 0
+ }
+ })
+ }
+
+ let searchedProducts: Product[] = filteredProducts
+
+ if (keyword) {
+ const keywordLowerCase = keyword.toLowerCase() // 키워드를 소문자로 변환
+ searchedProducts = response.data.filter((product: Product) =>
+ product.title.toLowerCase().includes(keywordLowerCase)
+ )
+ }
+
+ if (limit) {
+ searchedProducts = searchedProducts.slice(0, limit)
+ }
+
+ setProducts(searchedProducts)
+ if (getProductCount) getProductCount(searchedProducts.length)
+ } catch (error) {
+ console.error('상품을 가져오는 중 오류 발생', error)
+ }
+ }
+
+ fetchProducts()
+ }, [
+ limit,
+ sortOption,
+ getProductCount,
+ location.search,
+ keyword,
+ getProductCount
+ ])
+
+ const calculateDiscountedPrice = (
+ price: number,
+ discountRate?: number
+ ): number => {
+ if (discountRate) {
+ const discountAmount = price * (discountRate / 100)
+ return price - discountAmount
+ }
+ return price
+ }
+
+ // 최근 본 상품 세션 저장 처리
+ const { recentlyViewedList, setRecentlyViewedList } =
+ useContext(RecentlyContext)
+
+ const onSaveProductRecently = (product: Product) => {
+ const isExist = recentlyViewedList.find(p => p.id === product.id)
+ if (!isExist) {
+ setRecentlyViewedList([...recentlyViewedList, product])
+ } else {
+ const removeList = recentlyViewedList.filter(p => p.id !== product.id)
+ setRecentlyViewedList([...removeList, product])
+ }
+ }
+
+ return (
+
+
+
+ {products.length > 0 ? (
+ products.map(product => (
+
+
{
+ onSaveProductRecently(product)
+ }}>
+
+
+
+
{product.title}
+
+ {product.discountRate ? (
+ <>
+
+ {product.price.toLocaleString()}원
+ {' '}
+
+ {calculateDiscountedPrice(
+ product.price,
+ product.discountRate
+ ).toLocaleString()}
+ 원
+
+ >
+ ) : (
+ <>{product.price.toLocaleString()}원>
+ )}
+
+
+ {product.tags.includes('NEW') ? (
+
NEW
+ ) : null}
+ {product.tags.includes('BEST') ? (
+
HOT
+ ) : null}
+
+
+
+ ))
+ ) : (
+
상품이 없습니다.
+ )}
+
+
+
+ )
+ }
+)
+
+export { Products }
+export type { ProductsProps }
diff --git a/src/components/product/index.ts b/src/components/product/index.ts
new file mode 100644
index 00000000..0a47aede
--- /dev/null
+++ b/src/components/product/index.ts
@@ -0,0 +1,3 @@
+export * from 'components/product/ProductDetailInfoTab'
+export * from 'components/product/BestProduct'
+export * from 'components/product/Products'
diff --git a/src/constants/detailTabItems.ts b/src/constants/detailTabItems.ts
new file mode 100644
index 00000000..ac0288a5
--- /dev/null
+++ b/src/constants/detailTabItems.ts
@@ -0,0 +1,65 @@
+export const tabItems = {
+ PAYMENT_INFO: {
+ datas: [
+ {
+ title: '상품 결제정보',
+ content:
+ '고액결제의 경우 안전을 위해 카드사에서 확인전화를 드릴 수도 있습니다. 확인과정에서 도난 카드의 사용이나 타인 명의의 주문등 정상적인 주문이 아니라고 판단될 경우 임의로 주문을 보류 또는 취소할 수 있습니다.\n' +
+ '\n무통장 입금은 상품 구매 대금은 PC뱅킹, 인터넷뱅킹, 텔레뱅킹 혹은 가까운 은행에서 직접 입금하시면 됩니다.' +
+ '\n주문시 입력한 입금자명과 실제입금자의 성명이 반드시 일치하여야 하며, 7일 이내로 입금을 하셔야 하며 입금되지 않은 주문은 자동취소 됩니다.\n\n\n'
+ },
+ {
+ title: '배송정보',
+ content:
+ '배송 방법 : 택배\n' +
+ '배송 지역 : 전국지역\n' +
+ '배송 비용 : 3,000원\n' +
+ '배송 기간 : 2일 ~ 3일\n' +
+ '배송 안내 : - 산간벽지나 도서지방은 별도의 추가금액을 지불하셔야 하는 경우가 있습니다.\n' +
+ '고객님께서 주문하신 상품은 입금 확인후 배송해 드립니다. 다만, 상품종류에 따라서 상품의 배송이 다소 지연될 수 있습니다.\n\n\n'
+ },
+ {
+ title: '교환 및 반품정보',
+ content: ''
+ },
+ {
+ title: '교환 및 반품 주소',
+ content:
+ ' - [06151] 서울특별시 강남구 선릉로93길 54 (역삼동) 일환빌딩 7층'
+ },
+ {
+ title: '교환 및 반품이 가능한 경우',
+ content:
+ '- 계약내용에 관한 서면을 받은 날부터 7일. 단, 그 서면을 받은 때보다 재화등의 공급이 늦게 이루어진 경우에는 재화등을 공급받거나 재화등의 공급이 시작된 날부터 7일 이내\n' +
+ '- 공급받으신 상품 및 용역의 내용이 표시.광고 내용과 다르거나 계약내용과 다르게 이행된 때에는 당해 재화 등을 공급받은 날 부터 3월이내, 그사실을 알게 된 날 또는 알 수 있었던 날부터 30일이내\n'
+ },
+ {
+ title: '교환 및 반품이 불가능한 경우',
+ content:
+ '- 이용자에게 책임 있는 사유로 재화 등이 멸실 또는 훼손된 경우(다만, 재화 등의 내용을 확인하기 위하여 포장 등을 훼손한 경우에는 청약철회를 할 수 있습니다)\n' +
+ '- 이용자의 사용 또는 일부 소비에 의하여 재화 등의 가치가 현저히 감소한 경우\n' +
+ '- 시간의 경과에 의하여 재판매가 곤란할 정도로 재화등의 가치가 현저히 감소한 경우\n' +
+ '- 복제가 가능한 재화등의 포장을 훼손한 경우\n' +
+ '- 개별 주문 생산되는 재화 등 청약철회시 판매자에게 회복할 수 없는 피해가 예상되어 소비자의 사전 동의를 얻은 경우\n' +
+ '- 디지털 콘텐츠의 제공이 개시된 경우, (다만, 가분적 용역 또는 가분적 디지털콘텐츠로 구성된 계약의 경우 제공이 개시되지 아니한 부분은 청약철회를 할 수 있습니다.)\n' +
+ '※ 고객님의 마음이 바뀌어 교환, 반품을 하실 경우 상품반송 비용은 고객님께서 부담하셔야 합니다. (색상 교환, 사이즈 교환 등 포함)'
+ }
+ ]
+ },
+ PRODUCT_REVIEW: {
+ datas: [
+ {
+ title: '상품 사용후기',
+ content: '상품의 사용후기를 적어주세요'
+ }
+ ]
+ },
+ PRODUCT_QaA: {
+ datas: [
+ {
+ title: '상품 Q&A',
+ content: '상품에 대한 궁금한 점을 해결해 드립니다.'
+ }
+ ]
+ }
+}
diff --git a/src/constants/errors.ts b/src/constants/errors.ts
new file mode 100644
index 00000000..71682cb7
--- /dev/null
+++ b/src/constants/errors.ts
@@ -0,0 +1,12 @@
+export const networkErrors = {
+ EXPIRE_TOKEN: {
+ status: 401,
+ message: 'AccessToken 만료',
+ isShowModal: false
+ },
+ SERVER_ERROR: {
+ status: 500,
+ message: '서버에 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
+ isShowModal: true
+ }
+}
diff --git a/src/constants/imageLinks.ts b/src/constants/imageLinks.ts
new file mode 100644
index 00000000..3e8bfe8b
--- /dev/null
+++ b/src/constants/imageLinks.ts
@@ -0,0 +1,11 @@
+export const sliderImages = [
+ 'https://colley.market/web/upload/category/editor/2023/02/17/b020ca816a613a9b0aa0c68a5b9fee67.jpg',
+ 'https://colley.market/web/upload/category/editor/2023/02/17/8938f65bbf94293194e031791ad24c72.jpg',
+ 'https://colley.market/web/upload/category/editor/2023/02/17/e45876abccdcad4195c603c85f968e90.jpg'
+]
+
+export const promotionImages = [
+ 'https://colley.market/web/upload/category/editor/2022/04/07/e09d8657162931682be6630f7169773a.jpg',
+ 'https://colley.market/web/upload/category/editor/2022/04/07/1c29e4f56f062ae87478ec5a7b60f20e.jpg',
+ 'https://colley.market/web/upload/category/editor/2021/11/26/779187e2830b4321951a352952c73861.jpg'
+]
diff --git a/src/constants/index.ts b/src/constants/index.ts
new file mode 100644
index 00000000..fefe0356
--- /dev/null
+++ b/src/constants/index.ts
@@ -0,0 +1,4 @@
+export * from 'constants/tag'
+export * from 'constants/errors'
+export * from 'constants/detailTabItems'
+export * from 'constants/imageLinks'
diff --git a/src/constants/tag.ts b/src/constants/tag.ts
new file mode 100644
index 00000000..bdfa7a0d
--- /dev/null
+++ b/src/constants/tag.ts
@@ -0,0 +1,17 @@
+export const tags = {
+ CONST_TAG_NEW: 'NEW',
+ CONST_TAG_BEST: 'BEST',
+ CONST_TAG_LIVING: 'LIVING',
+ CONST_TAG_KITCHEN: 'KITCHEN',
+ CONST_TAG_STATIONERY: 'STATIONERY',
+ CONST_TAG_BABYKIDS: 'BABY/KIDS',
+ CONST_TAG_TABLE: 'TABLE',
+ CONST_TAG_ROOM: 'ROOM',
+ CONST_TAG_LIGHT: 'LIGHT',
+ CONST_TAG_BED: 'BED',
+ CONST_TAG_CUP: 'CUP',
+ CONST_TAG_DISHES: 'DISHES',
+ CONST_TAG_PLATE: 'PLATE',
+ CONST_TAG_GOODS: 'GOODS',
+ CONST_TAG_CUTTING_BOARD: 'CUTTING_BOARD'
+}
diff --git a/src/contexts/AccountContext.tsx b/src/contexts/AccountContext.tsx
new file mode 100644
index 00000000..14bba6fb
--- /dev/null
+++ b/src/contexts/AccountContext.tsx
@@ -0,0 +1,17 @@
+import { createContext } from 'react'
+
+type AccountNumberState = {
+ accountNumber: string
+ setAccountNumber: (value: string) => void
+}
+
+export const AccountNumberContext = createContext(
+ {} as AccountNumberState
+)
+
+type BankState = {
+ bank: string
+ setBank: (value: string) => void
+}
+
+export const BankContext = createContext({} as BankState)
diff --git a/src/contexts/BankContext.tsx b/src/contexts/BankContext.tsx
new file mode 100644
index 00000000..1a53a2c7
--- /dev/null
+++ b/src/contexts/BankContext.tsx
@@ -0,0 +1,10 @@
+import { createContext } from 'react'
+
+type BankState = {
+ bank: string
+ setBank: (value: string) => void
+}
+
+export const BankContext = createContext(
+ {} as BankState
+)
diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx
new file mode 100644
index 00000000..db95fe4b
--- /dev/null
+++ b/src/contexts/CartContext.tsx
@@ -0,0 +1,16 @@
+import { createContext } from 'react'
+import { CartProduct } from 'types/index'
+
+type CartState = {
+ userCart: CartProduct[]
+ setUserCart: (products: CartProduct[]) => void
+}
+
+export const CartContext = createContext({} as CartState)
+
+type CheckedState = {
+ checkedItems: Set
+ setCheckedItems: (products: Set) => void
+}
+
+export const CheckedContext = createContext({} as CheckedState)
diff --git a/src/contexts/ClickedContext.tsx b/src/contexts/ClickedContext.tsx
new file mode 100644
index 00000000..986543e9
--- /dev/null
+++ b/src/contexts/ClickedContext.tsx
@@ -0,0 +1,8 @@
+import { createContext } from 'react'
+
+type ClickedState = {
+ isClicked: boolean
+ setIsClicked: (value: boolean) => void
+}
+
+export const ClickedContext = createContext({} as ClickedState)
diff --git a/src/contexts/LoginContext.tsx b/src/contexts/LoginContext.tsx
new file mode 100644
index 00000000..600d03df
--- /dev/null
+++ b/src/contexts/LoginContext.tsx
@@ -0,0 +1,8 @@
+import { createContext } from 'react'
+
+type LoginState = {
+ isLogined: boolean
+ setIsLogined: (value: boolean) => void
+}
+
+export const LoginContext = createContext({} as LoginState)
diff --git a/src/contexts/LoginedUserContext.tsx b/src/contexts/LoginedUserContext.tsx
new file mode 100644
index 00000000..22c82381
--- /dev/null
+++ b/src/contexts/LoginedUserContext.tsx
@@ -0,0 +1,10 @@
+import { createContext } from 'react'
+
+type LoginedUserState = {
+ userEmail: string
+ setUserEmail: (value: string) => void
+}
+
+export const LoginedUserContext = createContext(
+ {} as LoginedUserState
+)
diff --git a/src/contexts/PhoneNumberContext.tsx b/src/contexts/PhoneNumberContext.tsx
new file mode 100644
index 00000000..92a26d60
--- /dev/null
+++ b/src/contexts/PhoneNumberContext.tsx
@@ -0,0 +1,10 @@
+import { createContext } from 'react'
+
+type PhoneNumberState = {
+ phoneNumber: string
+ setPhoneNumber: (value: string) => void
+}
+
+export const PhoneNumberContext = createContext(
+ {} as PhoneNumberState
+)
diff --git a/src/contexts/RecentlyContext.tsx b/src/contexts/RecentlyContext.tsx
new file mode 100644
index 00000000..8789007b
--- /dev/null
+++ b/src/contexts/RecentlyContext.tsx
@@ -0,0 +1,9 @@
+import { createContext } from 'react'
+import { Product } from 'types/index'
+
+type RecentlyState = {
+ recentlyViewedList: Product[]
+ setRecentlyViewedList: (products: Product[]) => void
+}
+
+export const RecentlyContext = createContext({} as RecentlyState)
diff --git a/src/contexts/UserAddressContext.tsx b/src/contexts/UserAddressContext.tsx
new file mode 100644
index 00000000..4bca37c7
--- /dev/null
+++ b/src/contexts/UserAddressContext.tsx
@@ -0,0 +1,18 @@
+import { createContext } from 'react'
+
+type UserAddressState = {
+ address: string
+ setAddress: (value: string) => void
+}
+
+export const UserAddressContext = createContext(
+ {} as UserAddressState
+)
+type AddressDetailState = {
+ addressDetail: string
+ setAddressDetail: (value: string) => void
+}
+
+export const AddressDetailContext = createContext(
+ {} as AddressDetailState
+)
diff --git a/src/contexts/UseremailContext.tsx b/src/contexts/UseremailContext.tsx
new file mode 100644
index 00000000..9f16ca3b
--- /dev/null
+++ b/src/contexts/UseremailContext.tsx
@@ -0,0 +1,10 @@
+import { createContext } from 'react'
+
+type UseremailState = {
+ email: string
+ setEmail: (value: string) => void
+}
+
+export const UseremailContext = createContext(
+ {} as UseremailState
+)
diff --git a/src/contexts/UsernameContext.tsx b/src/contexts/UsernameContext.tsx
new file mode 100644
index 00000000..77264191
--- /dev/null
+++ b/src/contexts/UsernameContext.tsx
@@ -0,0 +1,8 @@
+import { createContext } from 'react'
+
+type UsernameState = {
+ name: string
+ setName: (value: string) => void
+}
+
+export const UsernameContext = createContext({} as UsernameState)
diff --git a/src/contexts/WishListContext.tsx b/src/contexts/WishListContext.tsx
new file mode 100644
index 00000000..83a8e46b
--- /dev/null
+++ b/src/contexts/WishListContext.tsx
@@ -0,0 +1,9 @@
+import { createContext } from 'react'
+import { Product } from 'types/index'
+
+type WishListState = {
+ wishList: Product[]
+ setWishList: (products: Product[]) => void
+}
+
+export const WishListContext = createContext({} as WishListState)
diff --git a/src/contexts/index.ts b/src/contexts/index.ts
new file mode 100644
index 00000000..3c71d6e0
--- /dev/null
+++ b/src/contexts/index.ts
@@ -0,0 +1,11 @@
+export * from 'contexts/LoginContext'
+export * from 'contexts/AccountContext'
+export * from 'contexts/PhoneNumberContext'
+export * from 'contexts/UseremailContext'
+export * from 'contexts/UsernameContext'
+export * from 'contexts/RecentlyContext'
+export * from 'contexts/LoginedUserContext'
+export * from 'contexts/CartContext'
+export * from 'contexts/WishListContext'
+export * from 'contexts/ClickedContext'
+export * from 'contexts/UserAddressContext'
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
new file mode 100644
index 00000000..6ad8453d
--- /dev/null
+++ b/src/hooks/index.ts
@@ -0,0 +1,6 @@
+export * from 'hooks/useLocalStorage'
+export * from 'hooks/useOnClickOutside'
+export * from 'hooks/useLocalStorage'
+export * from 'hooks/useSessionStorage'
+export * from 'hooks/useCartLocalStorage'
+export * from 'hooks/useAxiosInterceptor'
diff --git a/src/hooks/useAxiosInterceptor.ts b/src/hooks/useAxiosInterceptor.ts
new file mode 100644
index 00000000..843c0f16
--- /dev/null
+++ b/src/hooks/useAxiosInterceptor.ts
@@ -0,0 +1,128 @@
+import { useEffect } from 'react'
+import { AxiosError, InternalAxiosRequestConfig } from 'axios'
+import { baseInstance, adminInstance } from 'api/index'
+import { CommonError } from 'types/index'
+import { networkErrors } from 'constants/index'
+
+export const useAxiosInterceptor = (
+ handleLogout: () => void,
+ setErrorModal: (error: CommonError) => void
+) => {
+ const accessToken = localStorage.getItem(
+ import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN
+ )
+
+ const apiErrorInterceptor = (error: AxiosError): Promise => {
+ const errorObj = {
+ status: error?.response?.status,
+ message: (error?.response?.data as string) ?? '',
+ isShowModal: false
+ }
+ setErrorModal(errorObj)
+ return Promise.reject(errorObj)
+ }
+
+ const adminConfig = (config: InternalAxiosRequestConfig) => {
+ config.headers['masterKey'] = true
+ return config
+ }
+
+ const authConfig = (config: InternalAxiosRequestConfig) => {
+ if (config.headers && accessToken) {
+ // AccessToken이 정상적으로 저장되어 있으면 headers에 Authorization에 값을 추가해준다.
+ config.headers.Authorization = `Bearer ${accessToken}`
+ }
+ // authorization을 추가한 config 반환
+ return config
+ }
+
+ const responseErrorInterceptor = (
+ error: AxiosError
+ ): Promise | void => {
+ if (!accessToken) {
+ if (error?.response?.status === 500) {
+ return Promise.reject(networkErrors.SERVER_ERROR)
+ } else {
+ const errorObj = {
+ status: error?.response?.status,
+ message: (error?.response?.data as string) ?? '',
+ isShowModal: false
+ }
+ if (errorObj.isShowModal) {
+ setErrorModal(errorObj)
+ }
+ return Promise.reject(errorObj)
+ }
+ } else {
+ const url = error.request?.responseURL ?? ''
+ const showModal =
+ !url.includes('/auth/login') &&
+ !url.includes('/auth/signup') &&
+ !url.includes('/products/transactions/details')
+
+ if (
+ error.request?.responseURL &&
+ error.request?.responseURL.includes('logout') &&
+ error?.response?.status === 401
+ ) {
+ handleLogout()
+ return Promise.reject(networkErrors.EXPIRE_TOKEN)
+ } else if (error?.response?.status === 401) {
+ localStorage.removeItem(import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN)
+ alert('로그인 세션이 만료되었습니다. 다시 로그인해주세요.')
+ location.replace('/signin')
+ handleLogout()
+ return
+ } else if (error?.response?.status === 500) {
+ setErrorModal(networkErrors.SERVER_ERROR)
+ return
+ } else {
+ const errorObj = {
+ status: error?.response?.status,
+ message: (error?.response?.data as string) ?? '',
+ isShowModal: showModal
+ }
+ if (errorObj.isShowModal) {
+ setErrorModal(errorObj)
+ }
+
+ return Promise.reject(errorObj)
+ }
+ }
+ }
+
+ const adminRequestInterceptor = adminInstance.interceptors.request.use(
+ adminConfig,
+ apiErrorInterceptor
+ )
+
+ const requestInterceptor = baseInstance.interceptors.request.use(
+ authConfig,
+ apiErrorInterceptor
+ )
+
+ const adminResponseInterceptor = adminInstance.interceptors.response.use(
+ response => response,
+ responseErrorInterceptor
+ )
+
+ const responseInterceptor = baseInstance.interceptors.response.use(
+ response => response,
+ responseErrorInterceptor
+ )
+
+ useEffect(() => {
+ return () => {
+ // interceptor 해제
+ baseInstance.interceptors.request.eject(requestInterceptor)
+ baseInstance.interceptors.response.eject(responseInterceptor)
+ adminInstance.interceptors.request.eject(adminRequestInterceptor)
+ adminInstance.interceptors.response.eject(adminResponseInterceptor)
+ }
+ }, [
+ responseInterceptor,
+ requestInterceptor,
+ adminRequestInterceptor,
+ adminResponseInterceptor
+ ])
+}
diff --git a/src/hooks/useCartLocalStorage.ts b/src/hooks/useCartLocalStorage.ts
new file mode 100644
index 00000000..a64efad5
--- /dev/null
+++ b/src/hooks/useCartLocalStorage.ts
@@ -0,0 +1,48 @@
+import { useState, useEffect } from 'react'
+import { CartProduct } from 'types/index'
+
+export function useCartLocalStorage(
+ email: string,
+ initialState: CartProduct[]
+) {
+ const [state, setState] = useState(
+ () =>
+ JSON.parse(
+ window.localStorage.getItem(
+ `${email !== '' ? `cart-${email}` : 'cart-guest'}`
+ ) as string
+ ) || initialState
+ )
+
+ useEffect(() => {
+ const guestItems = localStorage.getItem('cart-guest')
+ const userCartList = localStorage.getItem(`cart-${email}`)
+ if (email === '' && guestItems != null) {
+ // 로그인 X + 장바구니에 상품이 있는데 또 추가하는 경우
+ localStorage.setItem(`cart-guest`, JSON.stringify(state))
+ } else if (email === '') {
+ // 로그인 X + guest 장바구니 초기화
+ localStorage.setItem(`cart-guest`, JSON.stringify([]))
+ } else {
+ if (guestItems !== null && guestItems.length > 0) {
+ // 로그인 직후 guest 상태에서 장바구니에 상품이 있는 경우
+ // 이전 장바구니와 합쳐서 저장
+ userCartList !== null
+ ? localStorage.setItem(
+ `cart-${email}`,
+ JSON.stringify([
+ ...JSON.parse(userCartList),
+ ...JSON.parse(guestItems)
+ ])
+ )
+ : localStorage.setItem(`cart-${email}`, guestItems)
+ delete localStorage['cart-guest']
+ } else {
+ // 로그인 후 사용자가 장바구니에 상품을 추가하는 경우
+ localStorage.setItem(`cart-${email}`, JSON.stringify(state))
+ }
+ }
+ }, [email, state])
+
+ return [state, setState]
+}
diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts
new file mode 100644
index 00000000..879b4420
--- /dev/null
+++ b/src/hooks/useLocalStorage.ts
@@ -0,0 +1,35 @@
+import { useState, useEffect } from 'react'
+
+export function useLocalStorage(
+ key: string,
+ initialState: T,
+ isAccessible = true
+) {
+ const [state, setState] = useState(
+ () => JSON.parse(window.localStorage.getItem(key) as string) || initialState
+ )
+
+ useEffect(() => {
+ // 빈 위시리스트 생성 금지
+ if (!isAccessible) {
+ return
+ }
+
+ // 위시리스트 재로그인 예외처리
+ if (key.includes('wish') && state) {
+ const wishList = JSON.parse(localStorage.getItem(key) as string)
+ if (wishList && wishList.length > 0 && state.length === 0) {
+ localStorage.setItem(key, JSON.stringify([...wishList]))
+ return
+ }
+ }
+
+ if (state === '') {
+ delete localStorage[key]
+ } else {
+ window.localStorage.setItem(key, JSON.stringify(state))
+ }
+ }, [key, state, isAccessible])
+
+ return [state, setState]
+}
diff --git a/src/hooks/useOnClickOutside.ts b/src/hooks/useOnClickOutside.ts
new file mode 100644
index 00000000..a00f56f5
--- /dev/null
+++ b/src/hooks/useOnClickOutside.ts
@@ -0,0 +1,30 @@
+import { RefObject, useEffect, useCallback } from 'react'
+
+export const useOutsideClick = (
+ ref: RefObject,
+ callback: () => void
+) => {
+ const handleClick = useCallback(
+ (event: MouseEvent) => {
+ event.stopPropagation()
+
+ const el = ref?.current
+
+ // Do nothing if clicking ref's element or descendent elements
+ if (!el || el.contains(event.target as Node)) {
+ return
+ }
+
+ callback()
+ },
+ [callback, ref]
+ )
+
+ useEffect(() => {
+ document.addEventListener('click', handleClick)
+
+ return () => {
+ document.removeEventListener('click', handleClick)
+ }
+ }, [handleClick])
+}
diff --git a/src/hooks/useSessionStorage.ts b/src/hooks/useSessionStorage.ts
new file mode 100644
index 00000000..49e139ff
--- /dev/null
+++ b/src/hooks/useSessionStorage.ts
@@ -0,0 +1,14 @@
+import { useState, useEffect } from 'react'
+
+export function useSessionStorage(key: string, initialState: T) {
+ const [state, setState] = useState(
+ () =>
+ JSON.parse(window.sessionStorage.getItem(key) as string) || initialState
+ )
+
+ useEffect(() => {
+ window.sessionStorage.setItem(key, JSON.stringify(state))
+ }, [key, state])
+
+ return [state, setState]
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 00000000..9bbb768c
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,7 @@
+import ReactDOM from 'react-dom/client'
+import { RouterProvider } from 'react-router-dom'
+import { router } from 'pages/Router'
+
+ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
+
+)
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
new file mode 100644
index 00000000..3618884b
--- /dev/null
+++ b/src/pages/Home.tsx
@@ -0,0 +1,32 @@
+import {
+ Footer,
+ PromotionSlider,
+ NewArrival,
+ ImageSlider,
+ ColleyNews,
+ BestSeller
+} from 'components/index'
+import { fetchAllProducts } from 'api/index'
+import { Product } from 'types/index'
+import { useEffect, useState } from 'react'
+import { sliderImages, promotionImages } from 'constants/index'
+
+export const Home = () => {
+ const [prodcuts, setProdcuts] = useState([])
+ useEffect(() => {
+ fetchAllProducts().then(res => {
+ setProdcuts(res)
+ })
+ }, [])
+
+ return (
+
+ )
+}
diff --git a/src/pages/Router.tsx b/src/pages/Router.tsx
new file mode 100644
index 00000000..17411567
--- /dev/null
+++ b/src/pages/Router.tsx
@@ -0,0 +1,128 @@
+import { createBrowserRouter } from 'react-router-dom'
+import {
+ App,
+ ErrorComponent,
+ NotFound,
+ AdminPrivateRoute
+} from 'components/index'
+import {
+ Home,
+ AdminProducts,
+ AdminProductAdd,
+ ProductList,
+ ProductDetail,
+ AdminCustomers,
+ AdminDashboard,
+ SignInPage,
+ SignUpPage,
+ Payment,
+ AdminSales,
+ MyOrders,
+ Order,
+ Cart,
+ Success,
+ WishList,
+ ModifyPassword
+} from 'pages/index'
+
+export const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ errorElement: ,
+ children: [
+ {
+ path: '',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'mypage',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'mypage/order',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'mypage/password',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'mypage/wishlist',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'payment',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'productlist',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'products/:id',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'signup',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'signin',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'cart',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'success',
+ element: ,
+ errorElement:
+ }
+ ]
+ },
+ {
+ path: '/admin',
+ element: ,
+ errorElement: ,
+ children: [
+ {
+ path: '',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'customers',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'products',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'product-add',
+ element: ,
+ errorElement:
+ },
+ {
+ path: 'sales',
+ element: ,
+ errorElement:
+ }
+ ]
+ }
+])
diff --git a/src/pages/admin/AdminCustomers.tsx b/src/pages/admin/AdminCustomers.tsx
new file mode 100644
index 00000000..c23283a6
--- /dev/null
+++ b/src/pages/admin/AdminCustomers.tsx
@@ -0,0 +1,137 @@
+import { useCallback, useEffect, useState, useMemo } from 'react'
+import { adminFetchCustomers } from 'api/index'
+import { CustomerInfo } from 'types/index'
+import { AdminCustomerItem, AdminCustomerSkeleton } from 'components/index'
+import styled from 'styles/pages/adminCustomers.module.scss'
+import Pagination from 'react-js-pagination'
+
+export const AdminCustomers = () => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [customers, setCustomers] = useState([])
+ const [search, setSearch] = useState('')
+ const [page, setPage] = useState(1)
+
+ const filteredCustomers = useMemo(() => {
+ if (customers.length === 0) {
+ return []
+ }
+
+ const list = customers
+ .filter(customer => customer.user.displayName.includes(search))
+ .sort((a, b) => {
+ if (a.user.displayName < b.user.displayName) {
+ return 1
+ }
+ if (a.user.displayName > b.user.displayName) {
+ return -1
+ }
+ return 0
+ })
+ const indexOfLast = page * 10
+ const indexOfFirst = indexOfLast - 10
+ return list.slice(indexOfFirst, indexOfLast)
+ }, [customers, search, page])
+
+ const totalCustomerCount = useMemo(() => {
+ return customers.filter(customer =>
+ customer.user.displayName.includes(search)
+ ).length
+ }, [customers, search])
+
+ const onChangeSearch = useCallback(
+ (event: React.ChangeEvent) => {
+ setSearch(event.target.value.trim())
+ },
+ []
+ )
+
+ const fetchCustomers = useCallback(() => {
+ adminFetchCustomers()
+ .then(
+ customers => {
+ setCustomers(customers)
+ },
+ error => {
+ console.error(error)
+ }
+ )
+ .finally(() => {
+ const hideSkeletons = setTimeout(() => {
+ setIsLoading(false)
+ clearTimeout(hideSkeletons)
+ }, 500)
+ })
+ }, [])
+
+ useEffect(() => {
+ setIsLoading(true)
+ fetchCustomers()
+ }, [fetchCustomers])
+
+ return (
+
+ 고객 관리
+
+
+
이메일
+
고객명
+
등급
+
누적 주문수
+
누적 주문금액
+
+
+ {
+ // 고객 리스트
+ !isLoading && (
+
+ {filteredCustomers.map(customer => (
+
+ ))}
+
+ )
+ }
+
+ {
+ // 스켈레톤 로딩
+ isLoading && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }
+
+ {filteredCustomers.length > 0 ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/pages/admin/AdminDashboard.tsx b/src/pages/admin/AdminDashboard.tsx
new file mode 100644
index 00000000..f072f2d0
--- /dev/null
+++ b/src/pages/admin/AdminDashboard.tsx
@@ -0,0 +1,112 @@
+import styled from 'styles/pages/adminDashboard.module.scss'
+import { fetchAdminTransactions } from 'api/index'
+import { TransactionDetail } from 'types/index'
+import { AdminDashboardCard } from 'components/index'
+import { useCallback, useEffect, useState, useMemo } from 'react'
+
+export const AdminDashboard = () => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [today] = useState(new Date())
+ const [transactions, setTransactions] = useState([])
+
+ // 월 판매량
+ const transactionsByMonth = useMemo(() => {
+ return transactions.filter(transactions => {
+ const date = new Date(transactions.timePaid)
+ return (
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() == today.getFullYear()
+ )
+ })
+ }, [transactions, today])
+
+ // 월 매출
+ const totalSalesByMonth = useMemo(() => {
+ return transactionsByMonth.reduce((total, transaction) => {
+ if (transaction.product.discountRate) {
+ total +=
+ transaction.product.price -
+ (transaction.product.price * transaction.product.discountRate) / 100
+ } else {
+ total += transaction.product.price
+ }
+ return total
+ }, 0)
+ }, [transactionsByMonth])
+
+ // 일 판매량
+ const transactionsByDate = useMemo(() => {
+ return transactionsByMonth.filter(transactions => {
+ const date = new Date(transactions.timePaid)
+ return date.getDate() === today.getDate()
+ })
+ }, [transactionsByMonth, today])
+
+ // 일 매출
+ const totalSalesByDate = useMemo(() => {
+ const totalPrice = transactionsByDate.reduce((total, transaction) => {
+ if (transaction.product.discountRate) {
+ total +=
+ transaction.product.price -
+ (transaction.product.price * transaction.product.discountRate) / 100
+ } else {
+ total += transaction.product.price
+ }
+ return total
+ }, 0)
+ // 로딩 해제
+ setIsLoading(false)
+ return totalPrice
+ }, [transactionsByDate])
+
+ useEffect(() => {
+ setIsLoading(true)
+ fetchTransactions()
+ }, [today])
+
+ const fetchTransactions = useCallback(() => {
+ fetchAdminTransactions().then(res => {
+ setTransactions(res)
+ })
+ }, [])
+
+ return (
+
+ Dashboard
+
+ {today.getMonth() + 1}월 매출 통계
+
+
+
+ {today.getMonth() + 1}월 {today.getDate()}일 매출 통계
+
+
+
+ )
+}
diff --git a/src/pages/admin/AdminProductAdd.tsx b/src/pages/admin/AdminProductAdd.tsx
new file mode 100644
index 00000000..0217d3a7
--- /dev/null
+++ b/src/pages/admin/AdminProductAdd.tsx
@@ -0,0 +1,72 @@
+import { useState, useCallback, useEffect } from 'react'
+import { useLocation } from 'react-router-dom'
+import { ProductAddForm, AdminLoading } from 'components/index'
+import styled from 'styles/pages/adminProductAdd.module.scss'
+import { ProductAddBody, ProductResponse } from 'types/index'
+import {
+ adminInsertProduct,
+ adminGetProductDetail,
+ adminEditProduct
+} from 'api/index'
+
+export const AdminProductAdd = () => {
+ const [isLoading, setLoading] = useState(false)
+ const product = useLocation().state as ProductResponse
+
+ const [isAddMode] = useState(!product)
+ const [editProduct, setEditProduct] = useState(null)
+
+ useEffect(() => {
+ if (product) {
+ setLoading(true)
+ adminGetProductDetail(product.id)
+ .then(
+ res => {
+ setEditProduct(res)
+ },
+ error => {
+ console.log('error', error)
+ }
+ )
+ .finally(() => {
+ setLoading(false)
+ })
+ }
+ }, [product])
+
+ const onSubmitAddForm = useCallback((product: ProductAddBody) => {
+ setLoading(true)
+ handleInsertProduct(product)
+ }, [])
+
+ const handleInsertProduct = (product: ProductAddBody) => {
+ if (isAddMode) {
+ adminInsertProduct(product).then(res => {
+ console.log(res)
+
+ setLoading(false)
+ history.back()
+ })
+ } else {
+ adminEditProduct(product).then(res => {
+ console.log(res)
+
+ setLoading(false)
+ history.back()
+ })
+ }
+ }
+
+ return (
+
+
+ {isAddMode ? '상품 추가' : '상품 수정'}
+
+
+ {isLoading && }
+
+ )
+}
diff --git a/src/pages/admin/AdminProducts.tsx b/src/pages/admin/AdminProducts.tsx
new file mode 100644
index 00000000..8bb8c068
--- /dev/null
+++ b/src/pages/admin/AdminProducts.tsx
@@ -0,0 +1,264 @@
+import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
+import {
+ AdminProductItem,
+ AdminProductItemHeader,
+ Modal,
+ AdminProductsSkeleton
+} from 'components/index'
+import styled from 'styles/pages/adminProducts.module.scss'
+import { Link } from 'react-router-dom'
+import {
+ adminFetchProducts,
+ adminDeleteProduct,
+ adminChangeProductSaleStatus
+} from 'api/index'
+import { ProductResponse, ModalProps } from 'types/index'
+import { useOutsideClick } from 'hooks/index'
+import Pagination from 'react-js-pagination'
+import 'styles/common.scss'
+
+export const AdminProducts = () => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [search, setSearch] = useState('')
+ const [products, setProducts] = useState>([])
+ const [shownMenuId, setShownMenuId] = useState(null)
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [deleteProduct, setDeleteProduct] = useState(
+ null
+ )
+ const [modalProps, setModalProps] = useState(null)
+ const [isError, setError] = useState(false)
+ const [page, setPage] = useState(1)
+
+ const filteredProducts = useMemo(() => {
+ if (products.length === 0) {
+ return []
+ }
+ const list = products
+ .filter(product => product.title.includes(search))
+ .sort((a, b) => {
+ if (a.title < b.title) {
+ return 1
+ }
+ if (a.title > b.title) {
+ return -1
+ }
+ return 0
+ })
+
+ const indexOfLast = page * 10
+ const indexOfFirst = indexOfLast - 10
+ return list.slice(indexOfFirst, indexOfLast)
+ }, [products, search, page])
+
+ const totalProductsCount = useMemo(() => {
+ return products.filter(product => product.title.includes(search)).length
+ }, [products, search])
+
+ const addButtonRef = useRef(null)
+ useEffect(() => {
+ setIsLoading(true)
+ fetchProducts()
+ }, [])
+
+ const fetchProducts = useCallback(() => {
+ adminFetchProducts()
+ .then(res => {
+ setProducts(res)
+ })
+ .finally(() => {
+ setIsLoading(false)
+ })
+ }, [])
+
+ // 바깥쪽 클릭 시 메뉴 hidden 처리
+ useOutsideClick(addButtonRef, () => {
+ setShownMenuId(null)
+ })
+
+ const handleShow = useCallback((id: string) => {
+ setShownMenuId(id)
+ }, [])
+
+ const handleHide = useCallback(() => {
+ setShownMenuId('')
+ }, [])
+
+ const onChangeSearch = useCallback(
+ (event: React.ChangeEvent) => {
+ setSearch(event.target.value.trim())
+ },
+ []
+ )
+
+ // 상품 삭제 클릭 이벤트
+ useEffect(() => {
+ if (deleteProduct) {
+ setIsModalShow(true)
+ setModalProps({
+ title: '상품 삭제',
+ content: `${deleteProduct.title}상품을 삭제하시겠습니까?`,
+ isTwoButton: true,
+ okButtonText: '삭제',
+ onClickOkButton: () => {
+ onClickDeleteModalOk(deleteProduct.id)
+ },
+ cancelButtonText: '취소',
+ onClickCancelButton: onClickDeleteModalCancel
+ })
+ }
+
+ if (isError) {
+ setIsModalShow(true)
+ setModalProps({
+ title: '상품 삭제 오류',
+ content: '상품 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: onClickDeleteModalCancel
+ })
+ }
+ }, [deleteProduct, isError])
+
+ const onClickDelete = useCallback((product: ProductResponse) => {
+ setDeleteProduct(product)
+ }, [])
+
+ const onDeleteProduct = useCallback((id: string) => {
+ adminDeleteProduct(id).then(
+ isSuccess => {
+ if (isSuccess) {
+ fetchProducts()
+ }
+ },
+ error => {
+ console.log(error)
+ setError(true)
+ }
+ )
+ }, [])
+
+ // 삭제 확인 모달
+ const onClickDeleteModalOk = (id: string | undefined) => {
+ setIsModalShow(false)
+ // 삭제 API 호출
+ if (id) {
+ onDeleteProduct(id)
+ }
+ }
+
+ const onClickDeleteModalCancel = () => {
+ setIsModalShow(false)
+ }
+
+ // 상품 품절, 판매 처리
+ const changeStatusById = useCallback(
+ (id: string, isSoldOut: boolean) => {
+ const newProducts = products.map(product => {
+ if (product.id === id) {
+ product.isSoldOut = isSoldOut
+ }
+ return product
+ })
+ setProducts(newProducts)
+ },
+ [products]
+ )
+
+ const onChangeSaleStatus = useCallback(
+ (id: string, isChangedSoldout: boolean) => {
+ adminChangeProductSaleStatus(id, isChangedSoldout).then(
+ isSuccess => {
+ if (isSuccess) {
+ changeStatusById(id, isChangedSoldout)
+ }
+ },
+ error => {
+ console.log(error)
+ }
+ )
+ },
+ [changeStatusById]
+ )
+
+ return (
+
+ )
+}
diff --git a/src/pages/admin/AdminSales.tsx b/src/pages/admin/AdminSales.tsx
new file mode 100644
index 00000000..c01de19d
--- /dev/null
+++ b/src/pages/admin/AdminSales.tsx
@@ -0,0 +1,138 @@
+import { useState, useCallback, useEffect, useMemo } from 'react'
+import { AdminSalesSkeleton, AdminSalesItem } from 'components/index'
+import {
+ fetchAdminTransactions,
+ changeIsCanceled,
+ adminOrderConfirm
+} from 'api/index'
+import { TransactionDetail } from 'types/index'
+import styled from 'styles/pages/adminSales.module.scss'
+import Pagination from 'react-js-pagination'
+
+export const AdminSales = () => {
+ const [isLoading, setIsLoading] = useState(false)
+ const [search, setSearch] = useState('')
+ const [page, setPage] = useState(1)
+ const [sales, setSales] = useState([])
+ const onChangeSearch = useCallback(
+ (event: React.ChangeEvent) => {
+ setSearch(event.target.value.trim())
+ },
+ []
+ )
+
+ const filteredSales = useMemo(() => {
+ if (sales.length === 0) {
+ return []
+ }
+
+ const list = sales
+ .filter(sale => sale.user.email.includes(search))
+ .sort((a, b) => {
+ if (a.timePaid < b.timePaid) {
+ return 1
+ }
+ if (a.timePaid > b.timePaid) {
+ return -1
+ }
+ return 0
+ })
+ const indexOfLast = page * 10
+ const indexOfFirst = indexOfLast - 10
+ return list.slice(indexOfFirst, indexOfLast)
+ }, [sales, search, page])
+
+ const totalSalesCount = useMemo(() => {
+ return sales.filter(sale => sale.user.email.includes(search)).length
+ }, [sales, search])
+
+ useEffect(() => {
+ fetchTransactions()
+ }, [])
+
+ const fetchTransactions = () => {
+ setIsLoading(true)
+ fetchAdminTransactions()
+ .then(res => setSales(res))
+ .finally(() => setIsLoading(false))
+ }
+
+ const onChangeOrderIsCanceled = useCallback(
+ (id: string, isCanceled: boolean) => {
+ changeIsCanceled(id, isCanceled).then(isSuccess => {
+ if (isSuccess) {
+ fetchTransactions()
+ }
+ })
+ },
+ []
+ )
+
+ const onChangeOrderConfirm = useCallback((id: string) => {
+ adminOrderConfirm(id).then(isSuccess => {
+ if (isSuccess) {
+ fetchTransactions()
+ }
+ })
+ }, [])
+
+ return (
+
+ 주문 관리
+
+
+
+
주문일
+
고객 아이디
+
주문 상품
+
주문 금액
+
주문 상태
+
+
+ {isLoading && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ {!isLoading &&
+ filteredSales.length > 0 &&
+ filteredSales.map(sale => (
+
+ ))}
+ {filteredSales.length > 0 ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts
new file mode 100644
index 00000000..1efde7a8
--- /dev/null
+++ b/src/pages/admin/index.ts
@@ -0,0 +1,5 @@
+export * from 'pages/admin/AdminDashboard'
+export * from 'pages/admin/AdminProducts'
+export * from 'pages/admin/AdminProductAdd'
+export * from 'pages/admin/AdminCustomers'
+export * from 'pages/admin/AdminSales'
diff --git a/src/pages/index.ts b/src/pages/index.ts
new file mode 100644
index 00000000..9fa71b98
--- /dev/null
+++ b/src/pages/index.ts
@@ -0,0 +1,6 @@
+export * from 'pages/login'
+export * from 'pages/product'
+export * from 'pages/mypage'
+export * from 'pages/payment'
+export * from 'pages/admin'
+export * from 'pages/Home'
diff --git a/src/pages/login/SignInPage.tsx b/src/pages/login/SignInPage.tsx
new file mode 100644
index 00000000..36dbd7cc
--- /dev/null
+++ b/src/pages/login/SignInPage.tsx
@@ -0,0 +1,151 @@
+import { useState, FormEvent, useContext, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { getId } from 'api/index'
+import { LoginContext, LoginedUserContext } from 'contexts/index'
+import { useEffect } from 'react'
+import { Modal } from 'components/index'
+import { ModalProps } from 'types/index'
+import styles from 'styles/pages/signin.module.scss'
+
+export const SignInPage = () => {
+ const navigate = useNavigate()
+ const { setIsLogined } = useContext(LoginContext)
+ const { setUserEmail } = useContext(LoginedUserContext)
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [isValid, setIsValid] = useState(false)
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [modalProps, setModalProps] = useState(null)
+
+ //유효성 검사
+ useEffect(() => {
+ if (email && password) {
+ setIsValid(true)
+ } else {
+ setIsValid(false)
+ }
+ }, [email, password])
+
+ const inputRef = useRef(null)
+ useEffect(() => {
+ function handleOutside(e: Event) {
+ // current.contains(e.target) : 컴포넌트 특정 영역 외 클릭 감지를 위해 사용
+ if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
+ inputRef.current.blur()
+ }
+ }
+ document.addEventListener('mousedown', handleOutside)
+ return () => {
+ document.removeEventListener('mousedown', handleOutside)
+ }
+ }, [inputRef])
+
+ useEffect(() => {
+ const token = localStorage.getItem(
+ import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN
+ )
+ if (token) {
+ navigate('/') // Redirect to the main page if the user is already logged in
+ }
+ }, [])
+
+ const emailSpacingCheck = (e: React.ChangeEvent) => {
+ const emailValue = e.target.value
+ if (emailValue.includes(' ')) {
+ alert('띄어쓰기는 사용할 수 없습니다.')
+ } else {
+ setEmail(emailValue)
+ }
+ }
+
+ const passwordSpacingCheck = (e: React.ChangeEvent) => {
+ const passwordValue = e.target.value
+ if (passwordValue.includes(' ')) {
+ alert('띄어쓰기는 사용할 수 없습니다.')
+ } else {
+ setPassword(passwordValue)
+ }
+ }
+
+ const submitId = (event: FormEvent) => {
+ event.preventDefault()
+ const idInfo = {
+ email,
+ password
+ }
+ getId(idInfo).then(
+ res => {
+ setUserEmail(res.user.email)
+ localStorage.setItem(
+ import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN,
+ res.accessToken
+ )
+ const adminEmail = res.user.email
+ if (adminEmail === import.meta.env.VITE_ADMIN_EMAIL) {
+ location.replace('/admin')
+ setIsLogined(true)
+ } else {
+ location.replace(document.referrer)
+ setIsLogined(true)
+ }
+ },
+ error => {
+ const errorMessage = error.message
+ if (
+ errorMessage === '유효한 사용자가 아닙니다.' ||
+ errorMessage === '이메일 혹은 비밀번호가 일치하지 않습니다.'
+ ) {
+ setIsModalShow(true)
+ setModalProps({
+ title: '로그인 오류',
+ content: errorMessage,
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ }
+ })
+ }
+ }
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/pages/login/SignUpPage.tsx b/src/pages/login/SignUpPage.tsx
new file mode 100644
index 00000000..e6b1c3a2
--- /dev/null
+++ b/src/pages/login/SignUpPage.tsx
@@ -0,0 +1,205 @@
+import { useState, FormEvent, useContext, useEffect, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { postInfo } from 'api/signApi'
+import { LoginContext, LoginedUserContext } from 'contexts/index'
+import { Modal } from 'components/index'
+import { ModalProps } from 'types/ModalProps.type'
+import styles from 'styles/pages/signup.module.scss'
+
+export const SignUpPage = () => {
+ const navigate = useNavigate()
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [isValid, setIsValid] = useState(false)
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [modalProps, setModalProps] = useState(null)
+ const { setIsLogined } = useContext(LoginContext)
+ const { setUserEmail } = useContext(LoginedUserContext)
+
+ useEffect(() => {
+ const token = localStorage.getItem(
+ import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN
+ )
+ if (token) {
+ navigate('/') // Redirect to the main page if the user is already logged in
+ }
+ }, [])
+
+ //유효성 검사
+ useEffect(() => {
+ if (email && password && displayName) {
+ setIsValid(true)
+ } else {
+ setIsValid(false)
+ }
+ }, [email, password, displayName])
+
+ const inputRef = useRef(null)
+ useEffect(() => {
+ function handleOutside(e: Event) {
+ // current.contains(e.target) : 컴포넌트 특정 영역 외 클릭 감지를 위해 사용
+ if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
+ inputRef.current.blur()
+ }
+ }
+ document.addEventListener('mousedown', handleOutside)
+ return () => {
+ document.removeEventListener('mousedown', handleOutside)
+ }
+ }, [inputRef])
+
+ const emailSpacingCheck = (e: React.ChangeEvent) => {
+ const emailValue = e.target.value
+ if (emailValue.includes(' ')) {
+ alert('띄어쓰기는 사용할 수 없습니다.')
+ } else {
+ setEmail(emailValue)
+ }
+ }
+
+ const emailValidCheck = () => {
+ const emailRegEx = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
+ const testEmail = emailRegEx.test(email)
+ if (testEmail) {
+ return true
+ } else {
+ setIsModalShow(true)
+ setModalProps({
+ title: '로그인 오류',
+ content: '유효한 형식의 이메일이 아닙니다.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ }
+ })
+ return false
+ }
+ }
+
+ const passwordSpacingCheck = (e: React.ChangeEvent) => {
+ const passwordValue = e.target.value
+ if (passwordValue.includes(' ')) {
+ alert('띄어쓰기는 사용할 수 없습니다.')
+ } else {
+ setPassword(passwordValue)
+ }
+ }
+
+ const nameSpacingCheck = (e: React.ChangeEvent) => {
+ const nameValue = e.target.value
+ if (nameValue.includes(' ')) {
+ alert('띄어쓰기는 사용할 수 없습니다.')
+ } else {
+ setDisplayName(nameValue)
+ }
+ }
+
+ const submitBodyInfo = (event: FormEvent) => {
+ event.preventDefault()
+ const bodyInfo = {
+ email,
+ password,
+ displayName
+ }
+ if (emailValidCheck()) {
+ postInfo(bodyInfo).then(
+ res => {
+ localStorage.setItem(
+ import.meta.env.VITE_STORAGE_KEY_ACCESSTOKEN,
+ res.accessToken
+ )
+ setUserEmail(res.user.email)
+ setIsModalShow(true)
+ setModalProps({
+ title: '회원가입',
+ content: '회원가입을 축하합니다.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ setIsLogined(true)
+ setUserEmail(res.user.email)
+ navigate('/', { replace: true })
+ }
+ })
+ },
+ error => {
+ const errorMessage = error.message
+ if (
+ errorMessage === '유효한 이메일이 아닙니다.' ||
+ errorMessage === '유효한 사용자 이름이 아닙니다.' ||
+ errorMessage === '유효한 비밀번호가 아닙니다.' ||
+ errorMessage === '이미 존재하는 사용자입니다.'
+ ) {
+ setIsModalShow(true)
+ setModalProps({
+ title: '회원가입 오류',
+ content: errorMessage,
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ }
+ })
+ }
+ console.log(errorMessage)
+ }
+ )
+ }
+ }
+
+ return (
+
+
회원가입
+
+
+ {isModalShow && modalProps ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/pages/login/index.ts b/src/pages/login/index.ts
new file mode 100644
index 00000000..94ceac92
--- /dev/null
+++ b/src/pages/login/index.ts
@@ -0,0 +1,2 @@
+export * from 'pages/login/SignInPage'
+export * from 'pages/login/SignUpPage'
diff --git a/src/pages/mypage/Cart.tsx b/src/pages/mypage/Cart.tsx
new file mode 100644
index 00000000..fc846f8f
--- /dev/null
+++ b/src/pages/mypage/Cart.tsx
@@ -0,0 +1,14 @@
+import { CartHeader, CartFooter, CartProducts } from 'components/cart/index'
+import styles from 'styles/components/cart/cart.module.scss'
+
+export const Cart = () => {
+ return (
+ <>
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/pages/mypage/ModifyPassword.tsx b/src/pages/mypage/ModifyPassword.tsx
new file mode 100644
index 00000000..f647b7d1
--- /dev/null
+++ b/src/pages/mypage/ModifyPassword.tsx
@@ -0,0 +1,126 @@
+import styles from 'styles/pages/modifyPassword.module.scss'
+import { InfoModify } from 'api/index'
+import { useState, useEffect, useRef } from 'react'
+import { useNavigate } from 'react-router'
+import { Modal } from 'components/index'
+import { ModalProps } from 'types/index'
+
+export const ModifyPassword = () => {
+ const navigate = useNavigate()
+ const [newPassword, setNewPassword] = useState('')
+ const [oldPassword, setOldPassword] = useState('')
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [modalProps, setModalProps] = useState(null)
+ const [isValid, setIsValid] = useState(false)
+
+ //유효성 검사
+ useEffect(() => {
+ if (newPassword && oldPassword) {
+ setIsValid(true)
+ } else {
+ setIsValid(false)
+ }
+ }, [newPassword, oldPassword])
+
+ const inputRef = useRef(null)
+ useEffect(() => {
+ function handleOutside(e: Event) {
+ // current.contains(e.target) : 컴포넌트 특정 영역 외 클릭 감지를 위해 사용
+ if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
+ inputRef.current.blur()
+ }
+ }
+ document.addEventListener('mousedown', handleOutside)
+ return () => {
+ document.removeEventListener('mousedown', handleOutside)
+ }
+ }, [inputRef])
+
+ const oldValidCheck = (e: React.ChangeEvent) => {
+ const passwordValue = e.target.value
+ if (passwordValue.includes(' ')) {
+ alert('띄어쓰기는 사용할 수 없습니다.')
+ } else {
+ setOldPassword(passwordValue)
+ }
+ }
+
+ const newValidCheck = (e: React.ChangeEvent) => {
+ const passwordValue = e.target.value
+ if (passwordValue.includes(' ')) {
+ alert('띄어쓰기는 사용할 수 없습니다.')
+ } else {
+ setNewPassword(passwordValue)
+ }
+ }
+
+ const Modify = () => {
+ event?.preventDefault()
+ const password = {
+ newPassword,
+ oldPassword
+ }
+ InfoModify(password).then(() => {
+ setIsModalShow(true)
+ setModalProps({
+ title: '비밀번호 변경',
+ content: '비밀번호 변경이 완료되었습니다.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ navigate('/mypage')
+ }
+ })
+ })
+ }
+
+ return (
+
+
+
+ {isModalShow && modalProps ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/pages/mypage/MyOrders.tsx b/src/pages/mypage/MyOrders.tsx
new file mode 100644
index 00000000..217bb742
--- /dev/null
+++ b/src/pages/mypage/MyOrders.tsx
@@ -0,0 +1,57 @@
+import { useState, useEffect, useContext } from 'react'
+import { Link } from 'react-router-dom'
+import { TransactionDetail } from 'types/index'
+import { featchUserOrders } from 'api/index'
+import { MyOrderSummary, MyOrderStatus, MyOrderList } from 'components/index'
+import { LoginContext } from 'contexts/index'
+import styled from 'styles/pages/myOrders.module.scss'
+
+export const MyOrders = () => {
+ const { isLogined } = useContext(LoginContext)
+ const [isLoading, setIsLoading] = useState(false)
+ const [orders, setOrders] = useState([])
+
+ const fetchOrders = () => {
+ setIsLoading(true)
+ featchUserOrders()
+ .then(res => {
+ setOrders(res)
+ })
+ .finally(() => {
+ setIsLoading(false)
+ })
+ }
+
+ useEffect(() => {
+ fetchOrders()
+ }, [])
+
+ return (
+ <>
+ {isLogined ? (
+
+
+ 홈
+ / MY PAGE
+
+
+
+
+
+ ) : (
+
+ )}
+ >
+ )
+}
diff --git a/src/pages/mypage/Order.tsx b/src/pages/mypage/Order.tsx
new file mode 100644
index 00000000..cb6fd205
--- /dev/null
+++ b/src/pages/mypage/Order.tsx
@@ -0,0 +1,58 @@
+import { useState, useEffect, useContext } from 'react'
+import { TransactionDetail } from 'types/index'
+import { featchUserOrders } from 'api/index'
+import { MyOrderList } from 'components/index'
+import { Link } from 'react-router-dom'
+import { LoginContext } from 'contexts/index'
+import styled from 'styles/pages/myOrders.module.scss'
+
+export const Order = () => {
+ const { isLogined } = useContext(LoginContext)
+ const [isLoading, setIsLoading] = useState(false)
+ const [orders, setOrders] = useState([])
+
+ const fetchOrders = () => {
+ setIsLoading(true)
+ featchUserOrders()
+ .then(res => {
+ setOrders(res)
+ })
+ .finally(() => {
+ setIsLoading(false)
+ })
+ }
+
+ useEffect(() => {
+ fetchOrders()
+ }, [])
+
+ return (
+ <>
+ {isLogined ? (
+
+
+ 홈
+ /
+ 마이쇼핑
+ / 주문조회
+
+
주문 조회
+
+
+ 주문내역조회 ({orders.length.toLocaleString()})
+
+
+
+
+
+ ) : (
+
+ )}
+ >
+ )
+}
diff --git a/src/pages/mypage/WishList.tsx b/src/pages/mypage/WishList.tsx
new file mode 100644
index 00000000..bd43ada8
--- /dev/null
+++ b/src/pages/mypage/WishList.tsx
@@ -0,0 +1,30 @@
+import { useContext } from 'react'
+import { Link } from 'react-router-dom'
+import { MyWishList } from 'components/index'
+import { WishListContext } from 'contexts/index'
+import { LoginContext } from 'contexts/index'
+import styled from 'styles/pages/myWishList.module.scss'
+
+export const WishList = () => {
+ const { isLogined } = useContext(LoginContext)
+ const { wishList } = useContext(WishListContext)
+
+ return (
+ <>
+ {isLogined ? (
+
+
+ 홈
+ /
+ 마이쇼핑
+ / 나의 위시리스트
+
+
나의 위시리스트
+
+
+ ) : (
+
+ )}
+ >
+ )
+}
diff --git a/src/pages/mypage/index.ts b/src/pages/mypage/index.ts
new file mode 100644
index 00000000..44393215
--- /dev/null
+++ b/src/pages/mypage/index.ts
@@ -0,0 +1,5 @@
+export * from 'pages/mypage/ModifyPassword'
+export * from 'pages/mypage/MyOrders'
+export * from 'pages/mypage/Order'
+export * from 'pages/mypage/WishList'
+export * from 'pages/mypage/Cart'
diff --git a/src/pages/payment/Payment.tsx b/src/pages/payment/Payment.tsx
new file mode 100644
index 00000000..294b193c
--- /dev/null
+++ b/src/pages/payment/Payment.tsx
@@ -0,0 +1,52 @@
+import {
+ ProductInfo,
+ UserAddress,
+ PriceInfo,
+ PaymentMethods
+} from 'components/payment/index'
+import {
+ UsernameContext,
+ PhoneNumberContext,
+ UseremailContext,
+ BankContext,
+ AccountNumberContext,
+ UserAddressContext,
+ AddressDetailContext
+} from 'contexts/index'
+
+import { useState } from 'react'
+import styles from 'styles/components/payment/Payment.module.scss'
+
+export const Payment = () => {
+ const [name, setName] = useState('')
+ const [email, setEmail] = useState('')
+ const [phoneNumber, setPhoneNumber] = useState('010')
+ const [bank, setBank] = useState('004')
+ const [accountNumber, setAccountNumber] = useState('')
+ const [address, setAddress] = useState('')
+ const [addressDetail, setAddressDetail] = useState('')
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/payment/Success.tsx b/src/pages/payment/Success.tsx
new file mode 100644
index 00000000..ed87d50c
--- /dev/null
+++ b/src/pages/payment/Success.tsx
@@ -0,0 +1,53 @@
+import styles from 'styles/pages/success.module.scss'
+import { ProductInfo, PriceInfo } from 'components/payment/index'
+import { useLocation } from 'react-router-dom'
+
+export const Success = () => {
+ const info = useLocation().state
+
+ return (
+
+
결제 상세
+
+
+
배송정보
+
+
+ 받는사람
+ {info.name}
+
+
+ 주소
+
+ {info.address} / {info.addressDetail}
+
+
+
+ 휴대전화
+ {info.phoneNumber}
+
+
+ 이메일
+ {info.email}
+
+
+ {/* BUTTON */}
+
+
+ )
+}
diff --git a/src/pages/payment/index.ts b/src/pages/payment/index.ts
new file mode 100644
index 00000000..5b878316
--- /dev/null
+++ b/src/pages/payment/index.ts
@@ -0,0 +1,2 @@
+export * from 'pages/payment/Payment'
+export * from 'pages/payment/Success'
diff --git a/src/pages/product/ProductDetail.tsx b/src/pages/product/ProductDetail.tsx
new file mode 100644
index 00000000..cc6e66d1
--- /dev/null
+++ b/src/pages/product/ProductDetail.tsx
@@ -0,0 +1,293 @@
+import React, { useContext, useEffect, useState } from 'react'
+import { useParams, useNavigate } from 'react-router-dom'
+import { getPorductDetail } from 'api/index'
+import { Footer, Products, Modal, ProductDetailInfoTab } from 'components/index'
+import 'styles/layout/ProductDetail.scss'
+import { Product, RouteParams, ModalProps } from 'types/index'
+import { LoginContext, WishListContext, CartContext } from 'contexts/index'
+import noImage from 'public/no-photo.png'
+
+export const ProductDetail = () => {
+ const { id } = useParams()
+ const [product, setProduct] = useState(null)
+ const [quantity, setQuantity] = useState(1)
+ const [isLoading, setIsLoading] = useState(false)
+ const { userCart, setUserCart } = useContext(CartContext)
+ const navigate = useNavigate()
+ const { wishList, setWishList } = useContext(WishListContext)
+ const { isLogined } = useContext(LoginContext)
+ const [isModalShow, setIsModalShow] = useState(false)
+ const [modalProps, setModalProps] = useState(null)
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true)
+ getPorductDetail(id)
+ .then(res => setProduct(res))
+ .finally(() => {
+ const onLoaded = setTimeout(() => {
+ setIsLoading(false)
+ clearTimeout(onLoaded)
+ }, 500)
+ })
+ }
+ }, [id])
+
+ if (!product) {
+ return <>>
+ }
+
+ const handleQuantityChange = (event: React.ChangeEvent) => {
+ const value = parseInt(event.target.value)
+ const nonNegativeValue = value < 1 ? 1 : value
+ setQuantity(nonNegativeValue)
+ }
+
+ const handleIncreaseQuantity = () => {
+ setQuantity(prevQuantity => prevQuantity + 1)
+ }
+
+ const handleDecreaseQuantity = () => {
+ if (quantity > 1) {
+ setQuantity(prevQuantity => prevQuantity - 1)
+ }
+ }
+
+ const calculateDiscountPrice = () => {
+ if (product && product.discountRate) {
+ const discountRate = product.discountRate / 100
+ return product.price * (1 - discountRate)
+ }
+ return product?.price ?? 0
+ }
+
+ const calculateTotalPrice = () => {
+ const discountPrice = calculateDiscountPrice()
+ const price = discountPrice !== null ? discountPrice : product.price
+ return price * quantity
+ }
+
+ const handleBuyNow = () => {
+ if (isLogined) {
+ navigate('/payment', {
+ state: {
+ //상품정보 데이터
+ products: [
+ {
+ product: product,
+ quantity: quantity
+ }
+ ]
+ }
+ })
+ } else {
+ alert('로그인이 필요한 서비스입니다. 로그인 페이지로 이동합니다.')
+ navigate('/signin')
+ }
+ }
+
+ const handleAddToCart = () => {
+ // 장바구니 기능
+ const findProduct = userCart.find(item => item.product.id === product.id)
+ if (findProduct) {
+ findProduct.quantity += quantity
+ const filter = userCart.filter(item => item.product.id !== product.id)
+ setUserCart([...filter, findProduct])
+ } else {
+ setUserCart([...userCart, { product, quantity: quantity }])
+ }
+ setIsModalShow(true)
+ setModalProps({
+ title: '장바구니',
+ content: '장바구니에 추가가 완료되었습니다.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => setIsModalShow(false)
+ })
+ }
+
+ // 위시 리스트 저장 처리
+ const onSaveWishList = (product: Product) => {
+ const isExist = wishList.find(p => p.id === product.id)
+ if (!isExist) {
+ setWishList([...wishList, product])
+ setIsModalShow(true)
+ setModalProps({
+ title: '관심상품 등록',
+ content: '관심상품으로 등록되었습니다.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => setIsModalShow(false)
+ })
+ } else {
+ setIsModalShow(true)
+ setModalProps({
+ title: '오류',
+ content: '이미 관심상품으로 등록된 상품입니다.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => setIsModalShow(false)
+ })
+ }
+ }
+
+ const handleAddToWishlist = () => {
+ if (isLogined) {
+ onSaveWishList(product)
+ } else {
+ // 로그인 안내
+ setIsModalShow(true)
+ setModalProps({
+ title: '관심상품 등록',
+ content: '로그인이 필요한 서비스입니다.',
+ isTwoButton: false,
+ okButtonText: '확인',
+ onClickOkButton: () => {
+ setIsModalShow(false)
+ }
+ })
+ }
+ }
+
+ return (
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+ {isLoading ?
: product.title}
+
+
+
+
+ 소비자가
+
+ {isLoading ? (
+
+ ) : (
+
{product.price.toLocaleString()}원
+ )}
+
+
+
+ 판매가
+
+ {isLoading ? (
+
+ ) : (
+
{calculateDiscountPrice().toLocaleString()}원
+ )}
+
+
+
+ 배송방법
+
+
+ {isLoading ? (
+
+ ) : (
+
국내 배송
+ )}
+
+
+
+
+ 배송비
+
+
+ {isLoading ? (
+
+ ) : (
+
3,000원
+ )}
+
+
+
+ (최소주문수량 1개 이상)
+
+
+
+
+
TOTAL:
+ {isLoading ? (
+
+ ) : (
+
{calculateTotalPrice().toLocaleString()}원
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {!isLoading && (
+
+
+
+ {product.photo && (
+
+ )}
+
+
+
+
YOU MAY ALSO LIKE
+ 함께 구매하면 좋을 관련 상품
+
+
+
+ >
+ }
+ />
+
+
+ )}
+
+
+
+ {isModalShow && modalProps ? (
+
+ ) : null}
+
+ )
+}
diff --git a/src/pages/product/ProductList.tsx b/src/pages/product/ProductList.tsx
new file mode 100644
index 00000000..7e0cf8ff
--- /dev/null
+++ b/src/pages/product/ProductList.tsx
@@ -0,0 +1,99 @@
+import { useEffect, useState } from 'react'
+import { useLocation } from 'react-router-dom'
+import { Products, Footer } from 'components/index'
+import 'styles/layout/ProductList.scss'
+
+const ProductList = () => {
+ const location = useLocation()
+ const queryParams = new URLSearchParams(location.search)
+ const category = queryParams.get('category')
+ const sortOption = queryParams.get('sortOption')
+ const keyword = queryParams.get('keyword')
+
+ const [selectedSortOption, setSelectedSortOption] = useState(
+ sortOption || null
+ )
+ const [productCount, setProductCount] = useState(0)
+
+ useEffect(() => {
+ setSelectedSortOption(sortOption)
+ }, [sortOption])
+
+ const handleGetProductCount = (count: number) => {
+ setProductCount(count)
+ }
+
+ const handleTabClick = (option: string) => {
+ setSelectedSortOption(option)
+
+ const searchParams = new URLSearchParams(location.search)
+ searchParams.set('sortOption', option)
+
+ if (keyword) {
+ searchParams.set('keyword', keyword)
+ }
+
+ const newPath = `${location.pathname}?${searchParams.toString()}`
+ window.history.pushState(null, '', newPath)
+ }
+
+ return (
+
+ {/*
*/}
+
+
+
등록 제품: {productCount}개
+
+
+
+
+
+
+
+ )
+}
+
+export { ProductList }
diff --git a/src/pages/product/index.ts b/src/pages/product/index.ts
new file mode 100644
index 00000000..a8854fa5
--- /dev/null
+++ b/src/pages/product/index.ts
@@ -0,0 +1,2 @@
+export * from 'pages/product/ProductDetail'
+export * from 'pages/product/ProductList'
diff --git a/src/styles/abstracts/mixins.scss b/src/styles/abstracts/mixins.scss
new file mode 100644
index 00000000..9417f95b
--- /dev/null
+++ b/src/styles/abstracts/mixins.scss
@@ -0,0 +1,67 @@
+@mixin size($w, $h) {
+ width: $w;
+ height: $h;
+}
+
+@mixin inputBox {
+ min-width: 100px;
+ height: 40px;
+ border: 1px solid #e0e0e0;
+ padding: 0px 10px;
+ outline: none;
+
+ &:hover,
+ &:focus {
+ border: 1px solid #1e1e1e;
+ }
+}
+
+@mixin skeleton {
+ border-radius: 8px;
+ height: 20px;
+ background: linear-gradient(
+ 120deg,
+ #e5e5e5 30%,
+ #f0f0f0 38%,
+ #f0f0f0 40%,
+ #e5e5e5 48%
+ );
+ background-size: 200% 100%;
+ background-position: 100% 0;
+ animation: skeleton-loading 1s ease-in-out infinite;
+}
+
+@keyframes skeleton-loading {
+ 0% {
+ background-position: 100% 0;
+ }
+ 100% {
+ background-position: -100% 0;
+ }
+}
+
+@mixin colley-checkbox {
+ -webkit-appearance: none;
+ width: 15px;
+ height: 15px;
+ border: 1px solid $color-black-0;
+ cursor: pointer;
+ &::after {
+ border: solid #fff;
+ border-width: 0 2px 2px 0;
+ content: '';
+ display: none;
+ height: 40%;
+ left: 40%;
+ position: relative;
+ top: 20%;
+ transform: rotate(45deg);
+ width: 15%;
+ }
+ &:checked {
+ background-color: $color-black-0;
+ }
+ &:checked::after {
+ display: block;
+ }
+}
diff --git a/src/styles/abstracts/variables.scss b/src/styles/abstracts/variables.scss
new file mode 100644
index 00000000..5396db24
--- /dev/null
+++ b/src/styles/abstracts/variables.scss
@@ -0,0 +1,63 @@
+// WHITE-TONES
+$color-primary: #fff;
+$color-sub: #f7f7f7;
+$color-mypage-background: #f6f6f6;
+$color-white-F9: #f9f9f9;
+$color-white-F6: #f6f6f6;
+$color-white-ed: #ededed;
+//BLACK-TONES
+$color-black-0: #000;
+$color-black-2: #222;
+$color-black-8: #888;
+$color-black-5: #555;
+$color-black-7: #777;
+$color-black-6: #666;
+$color-black-86: #868686;
+$color-black-6D: #6d6d6d;
+$color-black-D9: #d9d9d9;
+$color-carttext: #2e2e2e;
+$color-loginlink: #262626;
+$color-border: #e5e5e5;
+$color-link: #7d7d7d;
+$color-gray-E1: #e1e1e1; //PAYMENT BORDER
+$color-gray-D7: #d7d5d5;
+$color-gray-ED: #ededed;
+$color-gray-EA: #eaeaea;
+$color-gray-F8: #f8f9fa;
+$color-gray-EB: #ebebeb;
+$color-gray-FA: #fafafd;
+$color-gray-A7: #a7a7a7;
+$color-boundary: #f1f1f1;
+$color-payment-titles: #333d4b;
+$color-steps: #939393;
+$color-home: #7d7d7d;
+$color-tab: #e5e5e5;
+$color-gray-BC: #bcbcbc;
+
+//COLOURED
+$color-now: #f94724;
+$color-error: #ec2a1d;
+$color-subvisual-yellow: #fce8ad;
+$color-subvisual-blue: #e6f9ff;
+$color-subvisual-gray: #dddbdc;
+$color-price: #008bcc;
+$color-possible: #0985df;
+$color-confirm: #3971ff;
+$color-sum: #f4f7ff;
+
+//PRODUCTS-COLOURS
+$color-item-red: #cc0c0c;
+$color-item-orange: #ff5a00;
+$color-item-yellow: #f49f00;
+$color-item-green: #007543;
+$color-item-blue: #0000fd;
+$color-item-pink: #fec0cb;
+$color-item-whtie: #fefefe;
+
+// ADMIN-COLORS
+$color-admin-primary: #0b63f8;
+$color-admin-main: #f8f8f8;
+$color-admin-border: #f2f2f2;
+$color-admin-soldout: #ec2a1d;
+$color-admin-sale: #12b100;
+$color-admin-back: #302867;
diff --git a/src/styles/common.scss b/src/styles/common.scss
new file mode 100644
index 00000000..6974c754
--- /dev/null
+++ b/src/styles/common.scss
@@ -0,0 +1,172 @@
+@import 'abstracts/mixins';
+@import 'abstracts/variables';
+
+button {
+ display: block;
+}
+
+.inputBlur {
+ border: none;
+ background-color: #edeff2;
+}
+.inputFocus {
+ border: 1px solid #d0d4d9;
+ outline: none;
+}
+
+.black {
+ background-color: $color-black-0;
+ border: 1px solid $color-black-0;
+ color: $color-primary;
+ &.buy {
+ @include size(580px, 50px);
+ }
+ &.loadMore {
+ @include size(300px, 550x);
+ }
+ &.login {
+ @include size(420px, 50px);
+ }
+ &.next {
+ @include size(135px, 40px);
+ }
+ &.add {
+ @include size(100px, 40px);
+ }
+ &:disabled {
+ border-color: $color-black-D9;
+ background-color: $color-black-D9;
+ }
+}
+
+.white {
+ background-color: $color-primary;
+ border: 1px solid $color-gray-BC;
+ color: $color-black-0;
+ //장바구니와 위시리스트가 텍스트 제외 스타일이 같아서 우선 이렇게 명명해뒀습니다
+ &.toLIst {
+ @include size(285px, 50px);
+ }
+ &.cancel {
+ @include size(135px, 40px);
+ }
+ &.loadMore {
+ @include size(300px, 550x);
+ }
+
+ &:disabled {
+ border-color: $color-black-D9;
+ background-color: $color-black-D9;
+ }
+}
+
+// Admin에서 사용하는 common styles
+.admin-title {
+ color: $color-black-0;
+ font-size: 24px;
+ font-weight: 500;
+}
+
+.admin-content-wrapper {
+ min-width: 1000px;
+ width: 100%;
+ position: relative;
+ padding: 40px 50px;
+ flex-grow: 1;
+ background-color: $color-admin-main;
+
+ input {
+ @include inputBox;
+ }
+}
+
+.pagination-wrapper {
+ .pagination {
+ display: flex;
+ justify-content: center;
+ margin-top: 20px;
+ }
+ ul {
+ list-style: none;
+ padding: 0;
+ }
+
+ ul.pagination li {
+ display: inline-block;
+ width: 30px;
+ height: 30px;
+ border: 1px solid $color-black-D9;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 12px;
+ }
+ ul.pagination li:first-child {
+ border-radius: 5px 0 0 5px;
+ }
+ ul.pagination li:last-child {
+ border-radius: 0 5px 5px 0;
+ }
+ ul.pagination li a {
+ text-decoration: none;
+ color: $color-admin-primary;
+ font-size: 1rem;
+ }
+ ul.pagination li.active a {
+ color: $color-primary;
+ }
+ ul.pagination li.active {
+ background-color: $color-admin-primary;
+ }
+ ul.pagination li a:hover,
+ ul.pagination li a.active {
+ color: $color-item-blue;
+ }
+}
+
+.mypage-pagination {
+ .pagination {
+ display: flex;
+ justify-content: center;
+ margin-top: 40px;
+ }
+ ul {
+ list-style: none;
+ padding: 0;
+ }
+
+ ul.pagination li {
+ display: inline-block;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 14px;
+ line-height: 40px;
+
+ &:first-child,
+ &:nth-child(2),
+ &:nth-last-child(2),
+ &:last-child {
+ font-size: 16px;
+ font-weight: 500;
+ line-height: 40px;
+ }
+ }
+ ul.pagination li a {
+ text-decoration: none;
+ color: $color-black-0;
+ }
+ ul.pagination li.active a {
+ color: $color-black-0;
+ }
+ ul.pagination li.active {
+ border: 1px solid $color-black-0;
+ }
+ ul.pagination li a:hover,
+ ul.pagination li a.active {
+ color: $color-black-0;
+ font-weight: 500;
+ }
+}
diff --git a/src/styles/components/Modal.module.scss b/src/styles/components/Modal.module.scss
new file mode 100644
index 00000000..85568cf8
--- /dev/null
+++ b/src/styles/components/Modal.module.scss
@@ -0,0 +1,68 @@
+@import 'src/styles/abstracts/variables';
+
+.modalbackground {
+ width: 100vw;
+ height: 100vh;
+ background-color: rgba(0, 0, 0, 0.4);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index:3;
+}
+
+.modal {
+ background-color: $color-primary;
+ width: 500px;
+ height: 250px;
+ font-weight: 400;
+ display: flex;
+ flex-direction: column;
+ border-radius: 10px;
+ position:relative;
+ z-index:4;
+
+ .title {
+ background-color: $color-black-0;
+ width: 500px;
+ height: 60px;
+ border-radius: 10px 10px 0 0;
+ text-align: center;
+ span {
+ color: $color-primary;
+ font-size: 20px;
+ font-weight: 400;
+ text-align: left;
+ line-height: 60px;
+ }
+ }
+
+ .content {
+ margin-top: 40px;
+ font-size: 20px;
+ flex-grow: 1;
+ white-space: pre-line;
+ text-align: center;
+ }
+
+ .buttons {
+ display: flex;
+ align-items: center;
+ justify-content: end;
+ gap: 20px;
+ padding: 0 20px 20px 0;
+ }
+
+ .button {
+ background-color: $color-black-D9;
+ width: 70px;
+ height: 35px;
+ text-align: center;
+ line-height: 35px;
+ border: none;
+ border-radius: 10px;
+ cursor: pointer;
+ }
+}
diff --git a/src/styles/components/admin/adminCard.module.scss b/src/styles/components/admin/adminCard.module.scss
new file mode 100644
index 00000000..7d57f3aa
--- /dev/null
+++ b/src/styles/components/admin/adminCard.module.scss
@@ -0,0 +1,45 @@
+@import 'src/styles/common.scss';
+
+.card {
+ border-radius: 20px;
+ background-color: $color-primary;
+ min-width: 300px;
+ height: 150px;
+ padding: 20px;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: space-between;
+ box-shadow: 0 3px 6px rgba(0, 0, 0, 0.05), 0 2px 4px rgba(0, 0, 0, 0.15);
+ transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
+ color: $color-black-2;
+
+ &:hover {
+ box-shadow: 0 7px 14px rgba(0, 0, 0, 0.1), 0 10px 10px rgba(0, 0, 0, 0.1);
+ }
+ h4 {
+ font-size: 20px;
+ }
+}
+
+.card__content {
+ display: flex;
+ justify-content: end;
+ align-items: end;
+ gap: 4px;
+
+ span.value {
+ font-size: 18px;
+ font-weight: 500;
+ color: $color-admin-primary;
+ }
+ span.unit {
+ font-size: 16px;
+ color: $color-black-5;
+ }
+}
+
+.skeleton {
+ width: 80px;
+ height: 18px;
+ @include skeleton;
+}
diff --git a/src/styles/components/admin/adminSalesItem.module.scss b/src/styles/components/admin/adminSalesItem.module.scss
new file mode 100644
index 00000000..7ab02eab
--- /dev/null
+++ b/src/styles/components/admin/adminSalesItem.module.scss
@@ -0,0 +1,93 @@
+@import 'src/styles/common.scss';
+
+.sale-item {
+ min-height: 40px;
+ padding: 5px 0;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid $color-admin-border;
+ background-color: $color-primary;
+ text-align: start;
+ gap: 10px;
+ .sale__date {
+ width: 17%;
+ flex-grow: 1;
+ margin-left: 40px;
+ }
+
+ .sale__email {
+ width: 20%;
+ flex-grow: 1;
+ }
+
+ .sale__product {
+ width: 30%;
+ flex-grow: 1;
+ }
+
+ .sale__price {
+ width: calc(15% - 40px);
+ flex-grow: 1;
+ text-align: right;
+ padding: 0 20px;
+ }
+
+ .sale__status {
+ width: 18%;
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 10px;
+
+ button {
+ height: 30px;
+ outline: none;
+ border: none;
+ padding: 0 8px;
+ cursor: pointer;
+ border: 2px solid;
+ background-color: $color-primary;
+ font-weight: 500;
+
+ &.back {
+ border-color: $color-admin-back;
+ color: $color-admin-back;
+
+ &:hover {
+ background-color: $color-admin-back;
+ color: $color-primary;
+ }
+ }
+
+ &.cancel {
+ border-color: $color-error;
+ color: $color-error;
+
+ &:hover {
+ background-color: $color-error;
+ color: $color-primary;
+ }
+ }
+
+ &.confirm {
+ border-color: $color-admin-sale;
+ color: $color-admin-sale;
+ font-weight: 500;
+
+ &:hover {
+ background-color: $color-admin-sale;
+ color: $color-primary;
+ }
+ }
+
+ &.hide {
+ display: none;
+ }
+ }
+
+ span {
+ color: $color-admin-sale;
+ }
+ }
+}
diff --git a/src/styles/components/admin/adminSkeleton.module.scss b/src/styles/components/admin/adminSkeleton.module.scss
new file mode 100644
index 00000000..47adfb82
--- /dev/null
+++ b/src/styles/components/admin/adminSkeleton.module.scss
@@ -0,0 +1,104 @@
+@import 'src/styles/common.scss';
+
+.skeleton-wrapper {
+ min-height: 40px;
+ padding: 5px 0;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid $color-admin-border;
+ background-color: $color-primary;
+ text-align: start;
+ justify-content: space-between;
+ gap: 10px;
+
+ .skeleton {
+ @include skeleton;
+
+ &.skeleton__email {
+ width: 40%;
+ margin-right: 10%;
+ margin-left: 40px;
+ }
+
+ &.skeleton__name {
+ width: 20%;
+ margin-right: 10%;
+ }
+
+ &.skeleton__grade {
+ width: 15%;
+ margin-right: 7.5%;
+ }
+
+ &.skeleton__total-order {
+ width: 15%;
+ text-align: right;
+ margin-left: 7.5%;
+ }
+
+ &.skeleton__total-price {
+ width: 25%;
+ text-align: right;
+ margin-right: 40px;
+ margin-left: 7.5%;
+ }
+
+ &.skeleton__sales-date {
+ width: 17%;
+ margin-right: 4.25%;
+ margin-left: 40px;
+ }
+
+ &.skeleton__sales-email {
+ width: 20%;
+ margin-right: 5%;
+ }
+
+ &.skeleton__sales-product {
+ width: 30%;
+ margin-right: 7.5%;
+ }
+
+ &.skeleton__sales-price {
+ width: 15%;
+ margin-left: calc(3.75% - 20px);
+ margin-right: 20px;
+ }
+
+ &.skeleton__sales-status {
+ width: 15%;
+ margin-left: 1.875%;
+ margin-right: 1.875%;
+ }
+
+ // Product Item Skeleton Class
+ &.skeleton__product-title {
+ width: 20%;
+ margin-left: 40px;
+ margin-right: 10%;
+ }
+
+ &.skeleton__product-tag {
+ width: 25%;
+ margin-right: 5%;
+ }
+
+ &.skeleton__product-price {
+ width: 10%;
+ margin-right: 2.5%;
+ margin-left: 2.5%;
+ }
+
+ &.skeleton__product-discount {
+ width: 4%;
+ margin-right: 2.5%;
+ margin-left: 3.5%;
+ }
+
+ &.skeleton__product-status {
+ width: 5%;
+ margin-right: calc(2.5% + 65px);
+ margin-left: 2.5%;
+ }
+ }
+}
diff --git a/src/styles/components/admin/customerItem.module.scss b/src/styles/components/admin/customerItem.module.scss
new file mode 100644
index 00000000..8ffe08b2
--- /dev/null
+++ b/src/styles/components/admin/customerItem.module.scss
@@ -0,0 +1,71 @@
+@import 'src/styles/common.scss';
+
+.customer {
+ min-height: 40px;
+ padding: 5px 0;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid $color-admin-border;
+ background-color: $color-primary;
+ text-align: start;
+ gap: 10px;
+
+ .customer__email {
+ width: 30%;
+ flex-grow: 1;
+ margin-left: 40px;
+ min-width: 250px;
+ }
+
+ .customer__name {
+ width: 20%;
+ flex-grow: 1;
+ }
+
+ .customer__grade {
+ width: 15%;
+ flex-grow: 1;
+
+ span {
+ padding: 4px 8px;
+ border-radius: 20px;
+ font-size: 14px;
+ color: #000;
+ }
+ }
+
+ .customer__total-order {
+ width: 15%;
+ flex-grow: 1;
+ text-align: right;
+ }
+
+ .customer__total-price {
+ width: 20%;
+ flex-grow: 1;
+ text-align: right;
+ margin-right: 40px;
+ }
+}
+
+.grade--bronze {
+ background-color: #cd7f32;
+ color: #fff !important;
+}
+
+.grade--silver {
+ background-color: #c0c0c0;
+}
+
+.grade--gold {
+ background-color: #ffd700;
+}
+
+.grade--platinum {
+ background-color: #797979;
+ color: #fff !important;
+}
+
+.grade--diamond {
+ background-color: #b9f2ff;
+}
diff --git a/src/styles/components/admin/productAddForm.module.scss b/src/styles/components/admin/productAddForm.module.scss
new file mode 100644
index 00000000..02c68dbb
--- /dev/null
+++ b/src/styles/components/admin/productAddForm.module.scss
@@ -0,0 +1,74 @@
+@import 'src/styles/common.scss';
+
+form.form-wrapper {
+ margin-top: 60px;
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+
+ label {
+ color: $color-black-5;
+ font-size: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ input {
+ box-sizing: border-box;
+ margin-left: 2px;
+ display: inline-block;
+ max-width: 620px;
+ height: 40px;
+
+ &::-webkit-inner-spin-button {
+ appearance: none;
+ -moz-appearance: none;
+ -webkit-appearance: none;
+ }
+
+ &[type='number'] {
+ width: 300px;
+ }
+
+ &[type='file'] {
+ display: none;
+
+ + button {
+ @include size(120px, 30px);
+ }
+ }
+ }
+
+ textarea {
+ @include inputBox;
+ box-sizing: border-box;
+ max-width: 620px;
+ height: 100px;
+ padding: 10px;
+ }
+
+ button {
+ @include size(80px, 40px);
+ }
+}
+
+.input-row {
+ display: flex;
+ align-items: start;
+ gap: 20px;
+}
+
+.thumbnail,
+.detail {
+ width: 300px;
+ height: 0px;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ margin-left: 2px;
+
+ &.show {
+ height: 300px;
+ }
+}
diff --git a/src/styles/components/admin/productItem.module.scss b/src/styles/components/admin/productItem.module.scss
new file mode 100644
index 00000000..dec1ae38
--- /dev/null
+++ b/src/styles/components/admin/productItem.module.scss
@@ -0,0 +1,121 @@
+@import 'src/styles/common.scss';
+
+div.wrapper {
+ min-height: 40px;
+ padding: 5px 0;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid $color-admin-border;
+ background-color: $color-primary;
+ gap: 10px;
+
+ div {
+ color: $color-black-0;
+ font-size: 16px;
+ font-weight: 500;
+ }
+
+ div.name {
+ width: 30%;
+ margin-left: 40px;
+ }
+
+ div.tags {
+ width: calc(30% - 8px);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ div.price {
+ width: 10%;
+ text-align: right;
+ margin-left: 2.5%;
+ margin-right: 2.5%;
+ }
+
+ div.discount {
+ width: 5%;
+ text-align: right;
+ margin-right: 2.5%;
+ margin-left: 2.5%;
+ }
+
+ div.sale-status {
+ width: calc(10% - 40px);
+ text-align: center;
+ padding: 0 20px;
+
+ span {
+ font-size: 12px;
+
+ &.soldout {
+ color: $color-admin-soldout;
+ }
+
+ &.sale {
+ color: $color-admin-sale;
+ }
+ }
+ }
+
+ div.more {
+ width: 40px;
+ height: 40px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 15px;
+ cursor: pointer;
+ }
+}
+
+.tag {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 2px 10px 0;
+ font-size: 12px;
+ border-radius: 20px;
+ color: $color-primary;
+ cursor: pointer;
+ line-height: 22px;
+}
+
+.menu-wrapper {
+ position: relative;
+}
+
+.dropdown-menu {
+ position: absolute;
+ right: 0;
+ color: $color-black-8;
+ width: 200px;
+ padding: 10px;
+ margin: auto;
+ text-align: center;
+ border-radius: 4px;
+ background: $color-primary;
+ box-shadow: 0 2px 20px rgba(0, 0, 0, 0.15);
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.4s;
+ z-index: 1;
+
+ li {
+ height: 30px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+
+ &:hover {
+ background-color: $color-black-D9;
+ }
+ }
+}
+
+.dropdown-menu.show {
+ opacity: 1;
+ visibility: visible;
+}
diff --git a/src/styles/components/admin/productItemHeader.module.scss b/src/styles/components/admin/productItemHeader.module.scss
new file mode 100644
index 00000000..c0eebe09
--- /dev/null
+++ b/src/styles/components/admin/productItemHeader.module.scss
@@ -0,0 +1,45 @@
+@import 'src/styles/common.scss';
+
+div.wrapper {
+ height: 60px;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid $color-admin-border;
+ background-color: $color-primary;
+ margin-top: 40px; // test
+ gap: 10px;
+
+ div {
+ color: $color-black-0;
+ font-size: 16px;
+ font-weight: 500;
+ }
+
+ div.name {
+ width: 30%;
+ margin-left: 40px;
+ }
+
+ div.tag {
+ width: 30%;
+ }
+
+ div.price {
+ width: 10%;
+ text-align: right;
+ margin-right: 5%;
+ }
+
+ div.discount {
+ width: 5%;
+ text-align: right;
+ margin-right: 2.5%;
+ margin-left: 2.5%;
+ }
+
+ div.saleStatus {
+ width: 10%;
+ margin-right: 65px;
+ text-align: center;
+ }
+}
diff --git a/src/styles/components/badge.module.scss b/src/styles/components/badge.module.scss
new file mode 100644
index 00000000..87e0378e
--- /dev/null
+++ b/src/styles/components/badge.module.scss
@@ -0,0 +1,63 @@
+@import 'src/styles/common.scss';
+.badge {
+ z-index: 10;
+ position: fixed;
+ top: 285px;
+ right: 50px;
+ min-width: 93px;
+ background-color: #fff;
+ box-shadow: 0 0 10px 0 rgba(#000, 0.25);
+}
+
+.cart {
+ cursor: pointer;
+ padding: 12px;
+ border-bottom: 1px solid #eee;
+ display: flex;
+
+ font-size: 12px;
+ align-items: center;
+ justify-content: space-evenly;
+
+ a {
+ line-height: 24px;
+ text-decoration: none;
+ color: $color-black-0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .cart__count {
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ border: 1px solid #000;
+ font-size: 14px;
+ text-align: center;
+ box-sizing: border-box;
+ line-height: 24px;
+ }
+}
+
+.top {
+ cursor: pointer;
+ display: flex;
+ justify-content: space-evenly;
+ align-items: center;
+ padding: 12px;
+ font-size: 14px;
+ line-height: 24px;
+
+ i {
+ font-weight: 100;
+ font-size: 20px;
+ }
+
+ span {
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
diff --git a/src/styles/components/cart/cart.module.scss b/src/styles/components/cart/cart.module.scss
new file mode 100644
index 00000000..f7dc8572
--- /dev/null
+++ b/src/styles/components/cart/cart.module.scss
@@ -0,0 +1,12 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ min-width: 1040px;
+ max-width: 1240px;
+ min-height: 700px;
+ padding: 0 50px 200px;
+ padding-bottom: 200px;
+ margin: 0 auto;
+}
+.container {
+}
diff --git a/src/styles/components/cart/cartFooter.module.scss b/src/styles/components/cart/cartFooter.module.scss
new file mode 100644
index 00000000..fe02775e
--- /dev/null
+++ b/src/styles/components/cart/cartFooter.module.scss
@@ -0,0 +1,37 @@
+@import 'src/styles/abstracts/variables';
+
+.title{
+ font-weight:bold;
+ margin:20px 0;
+}
+.container{
+ width:1240px;
+ margin:auto;
+ border-top:2px solid $color-black-0;
+ border-left:1px solid $color-gray-A7;
+ border-right:1px solid $color-gray-A7;
+ border-bottom:1px solid $color-gray-A7;
+ padding:10px 0 20px;
+ ul{
+ .notice{
+ font-size:14px;
+ margin:15px 0;
+ padding-left:15px;
+ }
+ li{
+ font-size:12px;
+ padding:2px 30px;
+ line-height:1.6;
+ color:$color-black-6D;
+ &::before{
+ content:"";
+ display:inline-block;
+ width:3px;
+ height:3px;
+ margin:7px 9px 3px -12px;
+ border-radius: 50%;
+ background-color: $color-black-6D;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/styles/components/cart/cartHeader.module.scss b/src/styles/components/cart/cartHeader.module.scss
new file mode 100644
index 00000000..801bc355
--- /dev/null
+++ b/src/styles/components/cart/cartHeader.module.scss
@@ -0,0 +1,86 @@
+@import 'src/styles/common.scss';
+
+.path {
+ width: 100%;
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ margin-top: 30px;
+ gap: 4px;
+ a {
+ font-size: 12px;
+ color: $color-home;
+ text-decoration: none;
+ }
+ strong {
+ font-size: 13px;
+ font-weight: bold;
+ color: $color-black-0;
+ }
+}
+
+h2 {
+ display: flex;
+ justify-content: center;
+ font-size: 30px;
+ margin-top: 55px;
+ margin-bottom: 50px;
+}
+
+.step {
+ list-style: decimal;
+ vertical-align: baseline;
+ text-align: center;
+ display: flex;
+ justify-content: center;
+ color: $color-steps;
+ margin-bottom: 50px;
+ :first-child {
+ color: $color-black-0;
+ }
+ li {
+ font-size: 14px;
+ margin: -2px 30px 0;
+ }
+ .selected {
+ &::after {
+ content: '';
+ display: inline-block;
+ width: 8px;
+ margin-left: 40px;
+ height: 8px;
+ vertical-align: middle;
+ border-left: 1px solid #e0e0e0;
+ border-bottom: 1px solid #e0e0e0;
+ -webkit-transform: rotate(-135deg);
+ transform: rotate(-135deg);
+ }
+ }
+}
+.tabs {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 40px;
+
+ .tab {
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 60px;
+ font-size: 13px;
+ line-height: 26px;
+ font-weight: 700;
+ box-sizing: border-box;
+
+ &.active {
+ border: 1px solid $color-black-86;
+ border-bottom: none;
+ }
+
+ &.inactive {
+ border-bottom: 1px solid $color-black-86;
+ }
+ }
+}
diff --git a/src/styles/components/cart/cartItem.module.scss b/src/styles/components/cart/cartItem.module.scss
new file mode 100644
index 00000000..7bbc1891
--- /dev/null
+++ b/src/styles/components/cart/cartItem.module.scss
@@ -0,0 +1,86 @@
+@import 'src/styles/abstracts/variables';
+
+.itemBox{
+ //border:1px solid red;
+ display:flex;
+ padding-top:30px;
+ border-bottom:1px solid $color-gray-A7;
+ input{
+ -webkit-appearance: none;
+ width:15px;
+ height:15px;
+ border:1px solid $color-black-0;
+ margin-right:20px;
+ cursor: pointer;
+ &::after{
+ border: solid #fff;
+ border-width: 0 2px 2px 0;
+ content: "";
+ display: none;
+ height: 40%;
+ left: 40%;
+ position: relative;
+ top: 20%;
+ transform: rotate(45deg);
+ width: 15%;
+ }
+ &:checked{
+ background-color: $color-black-0;
+ }
+ &:checked::after{
+ display:block;
+ }
+ }
+ .thumbnail{
+ width:90px;
+ height:90px;
+ margin-right:20px;
+ cursor: pointer;
+ }
+ .productInfo{
+ flex-grow:5;
+ height:150px;
+ .name{
+ font-size:13px;
+ padding-bottom:10px;
+ cursor: pointer;
+ text-decoration: underline;
+ }
+ .price{
+ font-size:13px;
+ padding-bottom:10px;
+ }
+ .amount{
+ display:flex;
+ text-align: center;
+ line-height: 30px;
+ padding-top:12px;
+ .down{
+ width:30px;
+ height:30px;
+ cursor:pointer;
+ border:1px solid $color-tab;
+ }
+ .number{
+ width:45px;
+ height:30px;
+ border:1px solid $color-tab;
+ }
+ .up{
+ width:30px;
+ height:30px;
+ cursor: pointer;
+ border:1px solid $color-tab;
+ }
+ }
+ }
+ .sumPrice{
+ flex-grow:1;
+ text-align:center;
+ padding-top:5px;
+ font-weight:bold;
+ }
+ .delete{
+ cursor:pointer;
+ }
+}
diff --git a/src/styles/components/cart/cartProducts.module.scss b/src/styles/components/cart/cartProducts.module.scss
new file mode 100644
index 00000000..4127287d
--- /dev/null
+++ b/src/styles/components/cart/cartProducts.module.scss
@@ -0,0 +1,51 @@
+@import 'src/styles/common.scss';
+.container {
+ display: flex;
+ .wrapper {
+ flex-grow: 1;
+ border-top: 1px solid $color-black-0;
+ margin-bottom: 50px;
+
+ .heading {
+ position: relative;
+ font-size: 13px;
+ padding: 20px 0;
+ font-weight: bold;
+ line-height: 1.3;
+ }
+ .selected {
+ padding-left: 15px;
+ line-height: 55px;
+ background-color: $color-mypage-background;
+ font-size: 13px;
+ }
+ .summary {
+ background-color: $color-mypage-background;
+ font-size: 13px;
+ padding: 25px 20px;
+ line-height: 20px;
+ margin: 40px 0;
+ border-bottom: 1px solid $color-tab;
+ h5 {
+ margin: 0 0 10px;
+ }
+ }
+ .pseudo {
+ padding: 10px 20px;
+ height: 32px;
+ font-size: 12px;
+ box-sizing: border-box;
+ border: 1px solid $color-gray-BC;
+ cursor: pointer;
+ text-decoration: none;
+ vertical-align: middle;
+ word-spacing: -0.5px;
+ &:hover {
+ border: 1px solid $color-black-0;
+ }
+ input {
+ display: none;
+ }
+ }
+ }
+}
diff --git a/src/styles/components/cart/cartSummary.module.scss b/src/styles/components/cart/cartSummary.module.scss
new file mode 100644
index 00000000..b66b3937
--- /dev/null
+++ b/src/styles/components/cart/cartSummary.module.scss
@@ -0,0 +1,73 @@
+@import 'src/styles/common.scss';
+.container {
+ margin-left: 47px;
+ position: relative;
+ .total {
+ width: 300px;
+ position: sticky;
+ .totalSummary {
+ border: 1px solid $color-black-0;
+ padding: 20px;
+ h3 {
+ font-size: 14px;
+ font-weight: bold;
+ margin: 0 0 20px;
+ padding: 0 0 19px;
+ border-bottom: 1px solid $color-tab;
+ }
+ .contents {
+ border-bottom: 1px solid $color-tab;
+ margin: 0 0 20px;
+ padding: 0 0 19px;
+ .content {
+ display: flex;
+ justify-content: space-between;
+ font-size: 13px;
+ &:first-child {
+ margin: 0 0 15px;
+ }
+ }
+ }
+ }
+ .totalPrice {
+ display: flex;
+ justify-content: space-between;
+ h4 {
+ font-size: 14px;
+ font-weight: bold;
+ }
+ span {
+ font-size: 13px;
+ font-weight: bold;
+ }
+ }
+ }
+ .buttonArea {
+ margin: 20px 0;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ a {
+ vertical-align: middle;
+ padding: 14px 26px;
+ height: 50px;
+ min-width: 80px;
+ font-size: 14px;
+ line-height: 18px;
+ font-weight: bold;
+ box-sizing: border-box;
+ cursor: pointer;
+ }
+ }
+ .orderAll {
+ background-color: $color-black-0;
+ color: $color-primary;
+ }
+ .orderSelected {
+ margin: 10px 0 0;
+ border: 1px solid $color-gray-BC;
+ &:hover {
+ border: 1px solid $color-black-0;
+ }
+ }
+}
diff --git a/src/styles/components/errorComponent.module.scss b/src/styles/components/errorComponent.module.scss
new file mode 100644
index 00000000..f4b4c364
--- /dev/null
+++ b/src/styles/components/errorComponent.module.scss
@@ -0,0 +1,29 @@
+@import 'src/styles/abstracts/variables';
+.container {
+ margin-top: 100px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ h1 {
+ font-size: 50px;
+ margin-bottom: 50px;
+ }
+ p {
+ font-size: 20px;
+ }
+ a {
+ margin-top: 50px;
+ text-decoration: none;
+ padding: 15px;
+ border: 1px solid $color-black-0;
+ background-color: $color-primary;
+ &:visited {
+ color: $color-black-0;
+ }
+ &:hover {
+ transition: 0.2s ease;
+ color: $color-primary;
+ background-color: $color-black-0;
+ }
+ }
+}
diff --git a/src/styles/components/main/recentlyList.module.scss b/src/styles/components/main/recentlyList.module.scss
new file mode 100644
index 00000000..8faf07db
--- /dev/null
+++ b/src/styles/components/main/recentlyList.module.scss
@@ -0,0 +1,49 @@
+.recently {
+ cursor: pointer;
+ border-bottom: 1px solid #eee;
+ font-size: 12px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+
+ p {
+ text-align: center;
+ line-height: 14px;
+ white-space: pre-line;
+ margin-top: 12px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
+
+.thumb {
+ width: 70px;
+ height: 70px;
+}
+
+.swiper {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin-top: 10px;
+ // max-height: 320px;
+ // overflow: hidden;
+ max-height: 310px !important;
+ padding: 0 12px;
+}
+
+.navigation {
+ display: flex;
+ z-index: 99;
+ div {
+ flex-grow: 1;
+ height: 40px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ font-size: 20px;
+ color: #7d7d7d;
+ }
+}
diff --git a/src/styles/components/mypage/myOrderItem.module.scss b/src/styles/components/mypage/myOrderItem.module.scss
new file mode 100644
index 00000000..4537f58a
--- /dev/null
+++ b/src/styles/components/mypage/myOrderItem.module.scss
@@ -0,0 +1,105 @@
+@import 'src/styles/common.scss';
+
+.order {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ padding: 10px 20px;
+ gap: 20px;
+ justify-content: space-between;
+ box-sizing: border-box;
+ border-bottom: 1px solid $color-border;
+
+ &.last {
+ border: none;
+ }
+
+ .timestamp {
+ font-size: 14px;
+ flex-shrink: 0;
+ min-width: 120px;
+
+ &.skeleton {
+ @include size(110px, 14px);
+ }
+ }
+
+ .thumb.skeleton {
+ @include size(80px, 80px);
+ }
+
+ a {
+ flex-grow: 1;
+ text-decoration: none;
+ color: $color-black-0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 20px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ img {
+ width: 80px;
+ height: 80px;
+ border-radius: 4px;
+ overflow: hidden;
+ background-color: $color-black-D9;
+ flex-shrink: 0;
+ }
+
+ p {
+ font-size: 16px;
+ flex-grow: 1;
+ white-space: pre-wrap;
+ min-width: 200px;
+
+ &.skeleton {
+ @include size(250px, 16px);
+ }
+ }
+
+ .price {
+ font-size: 16px;
+ flex-shrink: 0;
+
+ &.skeleton {
+ @include size(120px, 16px);
+ }
+ }
+
+ .status {
+ min-width: 150px;
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+
+ button {
+ padding: 8px 10px;
+ cursor: pointer;
+ }
+
+ .cancel {
+ color: $color-now;
+ font-size: 16px;
+ font-weight: 500;
+ }
+
+ .confirm {
+ color: $color-confirm;
+ font-size: 16px;
+ font-weight: 500;
+ }
+
+ &.skeleton {
+ @include size(80px, 20px);
+ }
+ }
+}
+
+.skeleton {
+ @include skeleton;
+}
diff --git a/src/styles/components/mypage/myOrderList.module.scss b/src/styles/components/mypage/myOrderList.module.scss
new file mode 100644
index 00000000..058b7c29
--- /dev/null
+++ b/src/styles/components/mypage/myOrderList.module.scss
@@ -0,0 +1,23 @@
+@import 'src/styles/common.scss';
+
+.orders {
+ width: 100%;
+
+ h4 {
+ color: $color-black-0;
+ font-size: 16px;
+ margin: 50px 0 20px;
+ }
+
+ .list {
+ border-top: 1px solid $color-black-0;
+ border-bottom: 1px solid $color-border;
+
+ li.none {
+ color: $color-black-6D;
+ font-size: 13px;
+ text-align: center;
+ padding: 56px 0;
+ }
+ }
+}
diff --git a/src/styles/components/mypage/myOrderStatus.module.scss b/src/styles/components/mypage/myOrderStatus.module.scss
new file mode 100644
index 00000000..e64647d6
--- /dev/null
+++ b/src/styles/components/mypage/myOrderStatus.module.scss
@@ -0,0 +1,65 @@
+@import 'src/styles/common.scss';
+
+.status {
+ width: 100%;
+
+ h4 {
+ color: $color-black-0;
+ font-size: 16px;
+ margin: 50px 0 20px;
+ }
+
+ .status__step {
+ border-top: 1px solid $color-black-0;
+ border-bottom: 1px solid $color-border;
+ display: flex;
+
+ .status__item {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ padding: 35px 0 33px;
+ position: relative;
+ gap: 10px;
+
+ &::after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 50%;
+ right: 0;
+ width: 13px;
+ height: 13px;
+ margin: -10px auto 0;
+ border-left: 1px solid $color-black-D9;
+ border-bottom: 1px solid $color-black-D9;
+ -webkit-transform: rotate(-135deg);
+ transform: rotate(-135deg);
+ }
+
+ h6 {
+ color: $color-black-0;
+ font-size: 13px;
+ }
+
+ .status__content {
+ color: $color-black-0;
+ font-weight: 700;
+ font-size: 30px;
+ }
+ }
+
+ div:last-child::after {
+ content: '';
+ display: none;
+ }
+ }
+}
+
+.skeleton {
+ @include skeleton;
+ width: 40px;
+ height: 30px;
+}
diff --git a/src/styles/components/mypage/myOrderSummary.module.scss b/src/styles/components/mypage/myOrderSummary.module.scss
new file mode 100644
index 00000000..4047cd12
--- /dev/null
+++ b/src/styles/components/mypage/myOrderSummary.module.scss
@@ -0,0 +1,63 @@
+@import 'src/styles/common.scss';
+
+.summary {
+ width: 100%;
+ display: flex;
+ border-top: 1px solid $color-black-0;
+ border-bottom: 1px solid $color-border;
+ min-height: 116px;
+ margin-top: 39px;
+
+ .summary__item {
+ flex-grow: 1;
+ padding: 26px 7px 25px;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 5px;
+
+ &::after {
+ content: '';
+ display: inline-block;
+ position: absolute;
+ right: 0;
+ width: 1px;
+ height: 48px;
+ top: 50%;
+ margin-top: -24px;
+ vertical-align: middle;
+ background-color: $color-border;
+ }
+
+ .summary__icon {
+ width: 24px;
+ height: 24px;
+ margin: 6px;
+ }
+
+ .summary__content {
+ color: $color-black-0;
+ font-weight: 700;
+ font-size: 15px;
+ line-height: 30px;
+ }
+
+ .summary__title {
+ color: #6d6d6d;
+ font-size: 13px;
+ }
+ }
+
+ div:last-child::after {
+ content: '';
+ display: none;
+ }
+}
+
+.skeleton {
+ @include skeleton;
+ width: 40px;
+ height: 16px;
+}
diff --git a/src/styles/components/mypage/myWishItem.module.scss b/src/styles/components/mypage/myWishItem.module.scss
new file mode 100644
index 00000000..ba2f7e06
--- /dev/null
+++ b/src/styles/components/mypage/myWishItem.module.scss
@@ -0,0 +1,60 @@
+@import 'src/styles/common.scss';
+
+.product {
+ width: 100%;
+ display: flex;
+ align-items: start;
+ padding: 25px 20px;
+ gap: 20px;
+ justify-content: space-between;
+ box-sizing: border-box;
+ border-bottom: 1px solid $color-border;
+
+ input[type='checkbox'] {
+ @include colley-checkbox;
+ }
+
+ &.last {
+ border: none;
+ }
+
+ a {
+ flex-grow: 1;
+ text-decoration: none;
+ color: $color-black-0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 20px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ img {
+ width: 80px;
+ height: 80px;
+ border-radius: 4px;
+ overflow: hidden;
+ background-color: $color-black-D9;
+ }
+}
+
+.product__info {
+ flex-grow: 1;
+ align-self: start;
+ font-size: 13px;
+
+ span {
+ display: inline-block;
+ margin-top: 8px;
+ }
+}
+
+.delete {
+ align-self: start;
+ font-size: 40px;
+ color: $color-gray-BC;
+ cursor: pointer;
+}
diff --git a/src/styles/components/mypage/myWishList.module.scss b/src/styles/components/mypage/myWishList.module.scss
new file mode 100644
index 00000000..725d2a79
--- /dev/null
+++ b/src/styles/components/mypage/myWishList.module.scss
@@ -0,0 +1,34 @@
+@import 'src/styles/common.scss';
+
+.wishList {
+ width: 100%;
+
+ h4 {
+ color: $color-black-0;
+ font-size: 16px;
+ margin: 50px 0 20px;
+ }
+
+ .list {
+ border-top: 1px solid $color-black-0;
+ border-bottom: 1px solid $color-border;
+
+ li.none {
+ color: $color-black-6D;
+ font-size: 13px;
+ text-align: center;
+ padding: 56px 0;
+ }
+ }
+
+ .delete-buttons {
+ margin-top: 20px;
+ display: flex;
+ gap: 12px;
+ justify-content: end;
+
+ button {
+ padding: 10px 20px;
+ }
+ }
+}
diff --git a/src/styles/components/mypage/mypage.module.scss b/src/styles/components/mypage/mypage.module.scss
new file mode 100644
index 00000000..886f843a
--- /dev/null
+++ b/src/styles/components/mypage/mypage.module.scss
@@ -0,0 +1,7 @@
+*{
+ font-family:"Arial","Noto Sans Regular","맑은 고딕","malgun gothic","돋움","dotum",sans-serif;
+}
+.wrapper{
+ display:flex;
+ justify-content: center;
+}
diff --git a/src/styles/components/mypage/mypageNav.module.scss b/src/styles/components/mypage/mypageNav.module.scss
new file mode 100644
index 00000000..696c073d
--- /dev/null
+++ b/src/styles/components/mypage/mypageNav.module.scss
@@ -0,0 +1,45 @@
+@import 'src/styles/abstracts/variables.scss';;
+
+nav{
+ padding-top:100px;
+ width:200px;
+ .title{
+ padding:100px 0;
+ font-size:30px;
+ font-weight:bold;
+ }
+
+ >ul{
+ margin-top:40px;
+ .subtitle{
+ font-size:18px;
+ font-weight:bold;
+ padding:20px 0;
+ div{
+ padding-top:15px;
+ a{
+ font-size:15px;
+ text-decoration:none;
+ color:$color-black-0;
+ font-weight:normal;
+ padding:10px 0;
+ }
+ }
+ }
+ .myInfo{
+ font-size:18px;
+ font-weight:bold;
+ padding:20px 0;
+ li{
+ padding:12px 0;
+ a{
+ font-size:15px;
+ text-decoration:none;
+ color:$color-black-0;
+ font-weight:normal;
+ cursor:pointer;
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/styles/components/mypage/mypageroute.module.scss b/src/styles/components/mypage/mypageroute.module.scss
new file mode 100644
index 00000000..355b4996
--- /dev/null
+++ b/src/styles/components/mypage/mypageroute.module.scss
@@ -0,0 +1,4 @@
+.wrapper{
+ display:flex;
+ justify-content: center;
+}
\ No newline at end of file
diff --git a/src/styles/components/payment/BankSelection.module.scss b/src/styles/components/payment/BankSelection.module.scss
new file mode 100644
index 00000000..7dffa80e
--- /dev/null
+++ b/src/styles/components/payment/BankSelection.module.scss
@@ -0,0 +1,12 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ margin-top: 20px;
+ margin-left: 40px;
+ span {
+ display: block;
+ font-weight: 700;
+ padding: 10px 0;
+ }
+}
diff --git a/src/styles/components/payment/Confirmation.module.scss b/src/styles/components/payment/Confirmation.module.scss
new file mode 100644
index 00000000..1b1474e9
--- /dev/null
+++ b/src/styles/components/payment/Confirmation.module.scss
@@ -0,0 +1,36 @@
+@import 'src/styles/abstracts/variables';
+
+.container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ margin: 5px 0 0;
+ // width: 900px;
+ background-color: $color-primary;
+
+ .agree {
+ border: 1px solid $color-gray-EA;
+ background-color: $color-gray-FA;
+ margin: 30px;
+ padding: 20px;
+ }
+ .check {
+ display: flex;
+ justify-content: center;
+ margin-bottom: 30px;
+ font-weight: 700;
+ }
+ .confirm {
+ display: block;
+ border: none;
+ padding: 15px;
+ margin: 30px;
+ font-size: 18px;
+ background-color: $color-confirm;
+ color: $color-primary;
+ cursor: pointer;
+ span {
+ font-weight: 700;
+ }
+ }
+}
diff --git a/src/styles/components/payment/DaumPostCode.module.scss b/src/styles/components/payment/DaumPostCode.module.scss
new file mode 100644
index 00000000..40bc6f3f
--- /dev/null
+++ b/src/styles/components/payment/DaumPostCode.module.scss
@@ -0,0 +1,11 @@
+@import 'src/styles/abstracts/variables';
+
+.zonecode {
+ width: 20% !important;
+}
+.address {
+ border: 1px solid $color-admin-sale !important;
+}
+.empty {
+ border: 1px solid $color-admin-primary !important;
+}
diff --git a/src/styles/components/payment/Payment.module.scss b/src/styles/components/payment/Payment.module.scss
new file mode 100644
index 00000000..c8b99d35
--- /dev/null
+++ b/src/styles/components/payment/Payment.module.scss
@@ -0,0 +1,14 @@
+@import 'src/styles/abstracts/variables.scss';
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ right: 0;
+ left: 0;
+ border-left: 1px solid $color-gray-D7;
+ border-right: 1px solid $color-gray-D7;
+ border-top: 1px solid $color-gray-D7;
+ margin: 30px auto;
+ max-width: 900px;
+ background-color: $color-gray-D7;
+}
diff --git a/src/styles/components/payment/PaymentMethods.module.scss b/src/styles/components/payment/PaymentMethods.module.scss
new file mode 100644
index 00000000..abddfb7e
--- /dev/null
+++ b/src/styles/components/payment/PaymentMethods.module.scss
@@ -0,0 +1,76 @@
+@import 'src/styles/abstracts/variables';
+
+.container {
+ background-color: $color-primary;
+ margin: 0 0 5px;
+ width: 900px;
+ box-sizing: border-box;
+ padding: 8px;
+ h3 {
+ font-weight: 700;
+ padding-bottom: 20px;
+ }
+ .addAccount {
+ box-shadow: 0px 0px 26px 0px rgba(208, 208, 208, 0.75);
+ left: 0;
+ right: 0;
+ margin: 5px auto;
+ width: 500px;
+ padding: 20px 0;
+ line-height: 50px;
+ border: 1px dashed black;
+ border-radius: 8px;
+ transition: 0.3s linear;
+
+ cursor: pointer;
+ span {
+ font-weight: 700;
+ display: flex;
+ justify-content: center;
+ position: relative;
+ a {
+ text-decoration: none;
+ cursor: crosshair;
+ top: -20px;
+ right: 15px;
+ position: absolute;
+ }
+ }
+ }
+
+ .addAccountSelected {
+ box-shadow: 0px 0px 26px 0px rgba(208, 208, 208, 0.75);
+ left: 0;
+ right: 0;
+ margin: 5px auto;
+ width: 500px;
+ padding: 20px 0;
+ line-height: 50px;
+ border: 1px dashed black;
+ background-color: $color-confirm;
+ color: $color-primary;
+ transition: 0.3s linear;
+ border-radius: 8px;
+ cursor: pointer;
+ span {
+ font-weight: 700;
+ display: flex;
+ justify-content: center;
+ position: relative;
+ a {
+ text-decoration: none;
+ cursor: crosshair;
+ top: -20px;
+ right: 15px;
+ position: absolute;
+ }
+ }
+ }
+ .addAccoutText {
+ display: flex;
+ justify-content: center;
+ color: $color-black-8;
+ padding: 10px;
+ font-size: 13px;
+ }
+}
diff --git a/src/styles/components/payment/PriceInfo.module.scss b/src/styles/components/payment/PriceInfo.module.scss
new file mode 100644
index 00000000..11ad9313
--- /dev/null
+++ b/src/styles/components/payment/PriceInfo.module.scss
@@ -0,0 +1,53 @@
+@import 'src/styles/abstracts/variables';
+.container {
+ background-color: $color-primary;
+ padding: 32px;
+ width: 900px;
+ box-sizing: border-box;
+ margin: 5px 0 0;
+
+ h3 {
+ font-weight: 600;
+ padding-bottom: 20px;
+ font-size: 19px;
+ color: $color-payment-titles;
+ letter-spacing: -1px;
+ }
+ .wrapper {
+ display: flex;
+ flex-direction: column;
+ .block {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 15px;
+ .numbers {
+ font-weight: 700;
+ }
+ .discount {
+ font-weight: 700;
+ color: $color-now;
+ > span {
+ color: $color-black-0;
+ }
+ }
+ }
+ }
+}
+.sum {
+ display: flex;
+ width: 900px;
+ justify-content: space-between;
+ background-color: $color-sum;
+ margin: 0 0 5px;
+ padding: 15px 32px 15px;
+ box-sizing: border-box;
+ .total {
+ font-size: 18px;
+ font-weight: 700;
+ }
+ .totalprice {
+ font-size: 22px;
+ font-weight: 700;
+ color: $color-confirm;
+ }
+}
diff --git a/src/styles/components/payment/ProductInfo.module.scss b/src/styles/components/payment/ProductInfo.module.scss
new file mode 100644
index 00000000..07799039
--- /dev/null
+++ b/src/styles/components/payment/ProductInfo.module.scss
@@ -0,0 +1,48 @@
+@import 'src/styles/abstracts/variables';
+
+.container {
+ margin: 0 0 5px;
+ padding: 32px;
+ background-color: $color-primary;
+ width: 900px;
+ box-sizing: border-box;
+
+ h3 {
+ font-weight: 600;
+ padding-bottom: 20px;
+ color: $color-payment-titles;
+ font-size: 19px;
+ letter-spacing: -1px;
+ }
+ .wrapper {
+ display: flex;
+ margin-bottom: 5px;
+ img {
+ width: 90px;
+ aspect-ratio: 1 / 1;
+ border: 2px solid $color-border;
+ }
+
+ .product {
+ display: flex;
+ flex-direction: column;
+ margin-left: 20px;
+ .title {
+ padding-top: 5px;
+ height: 25px;
+ font-size: 14px;
+ }
+ .quantity {
+ padding-top: 5px;
+ height: 25px;
+ font-size: 14px;
+ color: $color-black-6;
+ }
+ .price {
+ padding-top: 5px;
+ height: 25px;
+ font-size: 15px;
+ }
+ }
+ }
+}
diff --git a/src/styles/components/payment/UserAddress.module.scss b/src/styles/components/payment/UserAddress.module.scss
new file mode 100644
index 00000000..97d27337
--- /dev/null
+++ b/src/styles/components/payment/UserAddress.module.scss
@@ -0,0 +1,42 @@
+@import 'src/styles/abstracts/variables';
+.container {
+ background-color: $color-primary;
+ padding: 32px;
+ width: 900px;
+ box-sizing: border-box;
+
+ h3 {
+ font-weight: 600;
+ padding-bottom: 19px;
+ color: $color-payment-titles;
+ font-size: 19px;
+ letter-spacing: -1px;
+ }
+ label {
+ display: flex;
+ margin: 10px;
+
+ span {
+ width: 100px;
+ font-size: 14px;
+ padding: 10px 0 10px 10px;
+ }
+ input {
+ display: block;
+ height: 32px;
+ border: 1px solid $color-gray-E1;
+ width: 80%;
+ padding-left: 10px;
+
+ &:focus {
+ outline: none;
+ }
+ }
+ .valid {
+ border: 1px solid $color-admin-sale;
+ }
+ .invalid {
+ border: 1px solid $color-admin-primary;
+ }
+ }
+}
diff --git a/src/styles/components/product/productDetailInfoTab.module.scss b/src/styles/components/product/productDetailInfoTab.module.scss
new file mode 100644
index 00000000..1ff208a1
--- /dev/null
+++ b/src/styles/components/product/productDetailInfoTab.module.scss
@@ -0,0 +1,46 @@
+@import 'src/styles/abstracts/variables';
+
+.tabs {
+ width: 100%;
+ display: flex;
+
+ li {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-grow: 1;
+ height: 40px;
+ border-bottom: 1px solid $color-border;
+ color: $color-gray-BC;
+ margin-bottom: 30px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+
+ &.selected {
+ border-bottom: 2px solid $color-black-0;
+ color: $color-black-0;
+ }
+ }
+}
+
+.tab__content {
+ color: $color-black-0;
+ min-height: 300px;
+
+ .tab__data {
+ margin-bottom: 20px;
+ font-size: 12px;
+
+ .data__title {
+ font-weight: 700;
+ margin-bottom: 20px;
+ }
+
+ .data__content {
+ white-space: break-spaces;
+ line-height: 1.4;
+ padding-left: 4px;
+ }
+ }
+}
diff --git a/src/styles/layout/BestSeller.scss b/src/styles/layout/BestSeller.scss
new file mode 100644
index 00000000..dc1c993e
--- /dev/null
+++ b/src/styles/layout/BestSeller.scss
@@ -0,0 +1,107 @@
+.bestseller {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ a {
+ text-decoration: none;
+ color: black;
+ }
+
+ .inner {
+ padding-top: 50px;
+ padding-bottom: 50px;
+ padding-right: 10px;
+ width: 1240px;
+ box-sizing: border-box;
+
+ h2 {
+ display: block;
+ margin-top: 20px;
+ margin-bottom: 70px;
+ font-size: 26px;
+ font-weight: 700;
+ text-align: center;
+ }
+
+ .tabs {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ .tab {
+ width: 200px;
+ border: none;
+ background-color: transparent;
+ font-size: 15px;
+ font-weight: 700;
+ padding-left: 20px;
+ padding-right: 20px;
+ padding-bottom: 10px;
+ box-sizing: border-box;
+ cursor: pointer;
+ transition: border-bottom-color 0.3s ease;
+
+ &:hover {
+ border-bottom-color: black;
+ }
+ }
+
+ .active {
+ border-bottom: 2px solid black;
+ }
+ }
+
+ .tab-container {
+ .tabPane {
+ display: flex;
+ padding-top: 50px;
+ padding-bottom: 50px;
+ padding-right: 10px;
+ width: 1240px;
+ box-sizing: border-box;
+ gap: 20px;
+ }
+
+ .product {
+ cursor: pointer;
+ display: flex;
+ gap: 20px;
+ // animation: fade-in 1s ease;
+
+ .image {
+ img {
+ width: 230px;
+ height: 230px;
+ }
+ }
+
+ .title {
+ padding: 10px 0px 0px 0px;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 230px;
+ }
+
+ .price {
+ margin: 10px 0px 4px;
+ font-size: 14px;
+ text-decoration: none;
+ }
+ }
+ }
+ }
+}
+
+@keyframes fade-in {
+ 0% {
+ opacity: 0;
+ transform: translateX(-10px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
diff --git a/src/styles/layout/ColleyNews.scss b/src/styles/layout/ColleyNews.scss
new file mode 100644
index 00000000..ddda57a2
--- /dev/null
+++ b/src/styles/layout/ColleyNews.scss
@@ -0,0 +1,55 @@
+.ColleyNews {
+ background-color: #f7f7f7;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ .Inner {
+ padding-top: 50px;
+ padding-bottom: 50px;
+ padding-left: 10px;
+ padding-right: 10px;
+ width: 1240px;
+ box-sizing: border-box;
+ h2 {
+ display: block;
+ margin-bottom: 50px;
+ font-size: 26px;
+ font-weight: 700;
+ text-align: center;
+ }
+ .NewsList {
+ display: flex;
+ gap: 40px;
+ .NewsItem {
+ cursor: pointer;
+ font-size: 20px;
+ img {
+ width: 380px;
+ height: 220px;
+ }
+ .NewsContent {
+ background-color: white;
+ padding: 10px 20px 20px 20px;
+ .Header {
+ span {
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 2.5;
+ }
+ }
+ .Title {
+ font-size: 16px;
+ font-weight: 700;
+ line-height: 2;
+ }
+ .Content {
+ font-size: 13px;
+ }
+ }
+ }
+ }
+ }
+}
+
+
+
diff --git a/src/styles/layout/ImageSlider.scss b/src/styles/layout/ImageSlider.scss
new file mode 100644
index 00000000..5819890e
--- /dev/null
+++ b/src/styles/layout/ImageSlider.scss
@@ -0,0 +1,45 @@
+.ImageSlider {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ .Inner {
+ position: relative;
+ width: 1240px;
+ height: 780px;
+ overflow: hidden;
+ img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ transition: opacity 0.5s ease;
+ }
+ img.active {
+ opacity: 1;
+ z-index: 1;
+ }
+ .ImageSlider_Button_Left,
+ .ImageSlider_Button_Right {
+ position: absolute;
+ width: 80px;
+ height: 600px;
+ font-size: 10px;
+ background: none;
+ border: none;
+ color: #fff;
+ cursor: pointer;
+ z-index: 2;
+ transition: 0.5s;
+ }
+ .ImageSlider_Button_Left:hover,
+ .ImageSlider_Button_Right:hover {
+ background-color: rgba(255, 255, 255, 0.2);
+ transform: scale(1.2);
+ }
+ .ImageSlider_Button_Right {
+ right: 0px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/styles/layout/NewArrival.scss b/src/styles/layout/NewArrival.scss
new file mode 100644
index 00000000..1e871621
--- /dev/null
+++ b/src/styles/layout/NewArrival.scss
@@ -0,0 +1,91 @@
+@import 'src/styles/abstracts/variables';
+
+.new-arrival {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ a {
+ text-decoration: none;
+ color: $color-black-0;
+ }
+}
+
+.inner {
+ padding-top: 50px;
+ padding-bottom: 50px;
+ padding-left: 10px;
+ padding-right: 10px;
+ width: 1240px;
+ box-sizing: border-box;
+
+ h2.title {
+ display: block;
+ margin-bottom: 10px;
+ font-size: 26px;
+ font-weight: 700;
+ text-align: center;
+ }
+
+ h3.sub-title {
+ display: block;
+ margin-bottom: 50px;
+ font-size: 14px;
+ font-weight: 200;
+ text-align: center;
+ }
+
+ div.products {
+ display: flex;
+ gap: 20px;
+ flex-wrap: nowrap;
+ cursor: pointer;
+
+ .image {
+ img {
+ width: 230px;
+ height: 230px;
+ }
+ }
+
+ div.title {
+ padding: 10px 0px 0px 0px;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 230px;
+ }
+
+ .price {
+ margin: 10px 0px 4px 0px;
+ font-size: 14px;
+ }
+ }
+}
+
+.product__tags {
+ display: flex;
+ gap: 4px;
+ max-width: 200px;
+ height: 16px;
+ font-size: 10px;
+ line-height: 16px;
+ font-weight: 500;
+
+ .product__new {
+ color: $color-primary;
+ background-color: $color-item-yellow;
+ text-align: center;
+ padding: 0 4px;
+ box-sizing: border-box;
+ }
+
+ .product__best {
+ color: $color-item-red;
+ border: 1px solid $color-item-red;
+ text-align: center;
+ padding: 0 4px;
+ box-sizing: border-box;
+ }
+}
diff --git a/src/styles/layout/ProductDetail.scss b/src/styles/layout/ProductDetail.scss
new file mode 100644
index 00000000..6657c5fe
--- /dev/null
+++ b/src/styles/layout/ProductDetail.scss
@@ -0,0 +1,239 @@
+@import 'src/styles/common.scss';
+
+.product-detail {
+ a {
+ text-decoration: none;
+ color: $color-black-0;
+ }
+
+ .detailInner {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 30px;
+ gap: 50px;
+
+ .image {
+ .skeleton {
+ @include skeleton;
+ @include size(580px, 580px);
+ }
+
+ img {
+ width: 580px;
+ height: 580px;
+ }
+ }
+
+ .info {
+ width: 580px;
+ height: 580px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ flex-shrink: 0;
+
+ .title {
+ border-top: 2px solid $color-black-0;
+ border-bottom: 1px solid $color-border;
+ display: block;
+ font-size: 24px;
+ font-weight: 600;
+ line-height: 1.5;
+ padding: 20px 0;
+
+ .skeleton {
+ @include skeleton;
+ @include size(250px, 24px);
+ }
+ }
+
+ .price {
+ font-size: 12px;
+ margin: 10px 0;
+ display: block;
+ line-height: 3;
+
+ .discount-price {
+ font-size: 13px;
+ font-weight: 800;
+ color: $color-price;
+ }
+
+ .infoInner {
+ display: flex;
+
+ .infoleft {
+ text-align: left;
+ width: 15%;
+ }
+
+ .skeleton {
+ @include skeleton;
+ @include size(100px, 16px);
+ margin: 10px 0;
+ }
+ }
+ }
+
+ .quantity {
+ border-top: 1px solid $color-border;
+ border-bottom: 1px solid $color-border;
+ padding: 10px;
+ font-size: 13px;
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ gap: 20px;
+
+ .quantityControl {
+ display: flex;
+ }
+
+ input {
+ width: 35px;
+ height: 35px;
+ box-sizing: border-box;
+ font-size: 12px;
+ text-align: center;
+ border: 1px solid gray;
+
+ &:disabled {
+ background-color: $color-primary;
+ color: $color-black-0;
+ }
+ }
+
+ button {
+ width: 35px;
+ height: 35px;
+ box-sizing: border-box;
+ background-color: $color-primary;
+ border: 1px solid gray;
+ font-size: 18px;
+ font-weight: 300;
+ }
+ }
+
+ .totalPrice {
+ display: flex;
+ justify-content: space-between;
+ margin: 30px 0;
+ font-size: 18px;
+ font-weight: 800;
+
+ .skeleton {
+ @include skeleton;
+ @include size(150px, 18px);
+ }
+ }
+
+ .button--now {
+ margin: 20px 0 10px;
+ display: flex;
+ flex-direction: column;
+
+ button,
+ a {
+ height: 50px;
+ background-color: $color-black-0;
+ border: none;
+ color: $color-primary;
+ font-weight: 600;
+ cursor: pointer;
+ }
+ }
+
+ .button--cart {
+ display: flex;
+ gap: 10px;
+
+ button {
+ width: 100%;
+ height: 50px;
+ background-color: $color-primary;
+ border: 1px solid rgb(177, 177, 177);
+ color: $color-black-0;
+ font-weight: 600;
+ cursor: pointer;
+ }
+ button:hover {
+ border: 1px solid $color-black-0;
+ }
+ }
+ }
+ }
+
+ .details {
+ margin-top: 50px;
+ margin-bottom: 50px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ .inner {
+ width: 1240px;
+
+ img {
+ width: 100%;
+ text-align: center;
+ }
+ }
+ }
+ .etcProducts {
+ margin-top: 100px;
+ margin-bottom: 50px;
+ text-align: center;
+ line-height: 1.5;
+ h2 {
+ font-size: 40px;
+ font-weight: 300;
+ }
+ h3 {
+ font-size: 18px;
+ font-weight: 300;
+ color: gray;
+ }
+ }
+
+ .products {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 50px;
+
+ .inner {
+ padding-top: 50px;
+ padding-bottom: 50px;
+ padding-right: 10px;
+ box-sizing: border-box;
+
+ .product {
+ width: 1240px;
+ display: flex;
+ cursor: pointer;
+ flex-wrap: wrap;
+ gap: 20px;
+ animation: fade-in 1s ease;
+ .image {
+ img {
+ width: 295px;
+ height: 295px;
+ }
+ }
+ .title {
+ padding: 10px 0px 0px 0px;
+ font-size: 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 295px;
+ }
+ .price {
+ margin: 10px 0px 4px;
+ font-size: 14px;
+ }
+ }
+ }
+ }
+}
diff --git a/src/styles/layout/ProductList.scss b/src/styles/layout/ProductList.scss
new file mode 100644
index 00000000..8ac79835
--- /dev/null
+++ b/src/styles/layout/ProductList.scss
@@ -0,0 +1,110 @@
+.product-list {
+ a {
+ text-decoration: none;
+ color: black;
+ }
+
+ .category {
+ display: flex;
+ justify-content: center;
+
+ .title {
+ padding-top: 50px;
+ padding-bottom: 100px;
+ width: 1240px;
+ font-size: 16px;
+ font-weight: 400;
+ }
+ }
+
+ .inner {
+ padding: 50px 0 !important;
+ }
+
+ .list-inner {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 30px;
+
+ .product-info {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 1240px;
+ padding-top: 20px;
+ padding-bottom: 20px;
+ box-sizing: border-box;
+ font-size: 12px;
+ color: #777;
+
+ .product-count {
+ display: flex;
+ flex-grow: 1;
+ }
+
+ .product-sort {
+ ul {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 10px;
+ li a {
+ text-decoration: none;
+ color: #777;
+ }
+ }
+ }
+ }
+ }
+
+ .products {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ .inner {
+ padding-top: 50px;
+ padding-bottom: 50px;
+ padding-right: 10px;
+ box-sizing: border-box;
+
+ .product {
+ width: 1240px;
+ display: flex;
+ cursor: pointer;
+ flex-wrap: wrap;
+ gap: 20px;
+ animation: fade-in 1s ease;
+ .image {
+ img {
+ width: 295px;
+ height: 295px;
+ }
+ }
+
+ .title {
+ padding: 10px 0px 0px;
+ font-size: 12px;
+ max-width: 295px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .price {
+ margin: 10px 0px 4px 0px;
+ font-size: 14px;
+
+ .originalPrice {
+ /* Original price styles here */
+ }
+
+ .discountedPrice {
+ /* Discounted price styles here */
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/styles/layout/PromotionSlider.scss b/src/styles/layout/PromotionSlider.scss
new file mode 100644
index 00000000..6c74f888
--- /dev/null
+++ b/src/styles/layout/PromotionSlider.scss
@@ -0,0 +1,45 @@
+.PromotionSlider {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ .Inner {
+ position: relative;
+ width: 1240px;
+ height: 450px;
+ overflow: hidden;
+ img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ transition: opacity 0.5s ease;
+ }
+ img.active {
+ opacity: 1;
+ z-index: 1;
+ }
+ .PromotionSlider_Button_Left,
+ .PromotionSlider_Button_Right {
+ position: absolute;
+ width: 80px;
+ height: 450px;
+ font-size: 10px;
+ background: none;
+ border: none;
+ color: #fff;
+ cursor: pointer;
+ z-index: 2;
+ transition: 0.5s;
+ }
+ .PromotionSlider_Button_Left:hover,
+ .PromotionSlider_Button_Right:hover {
+ background-color: rgba(255, 255, 255, 0.2);
+ transform: scale(1.2);
+ }
+ .PromotionSlider_Button_Right {
+ right: 0px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/styles/layout/footer.module.scss b/src/styles/layout/footer.module.scss
new file mode 100644
index 00000000..30a264ba
--- /dev/null
+++ b/src/styles/layout/footer.module.scss
@@ -0,0 +1,111 @@
+@import 'src/styles/abstracts/variables.scss';
+.sideSpace {
+ display: flex;
+ justify-content: center;
+ background-color: $color-mypage-background;
+ .container {
+ display: flex;
+ justify-content: space-between;
+ width: 1240px;
+ padding: 45px 0;
+ background-color: $color-mypage-background;
+ .sideLeft {
+ display: flex;
+ flex-direction: column;
+ .csinfo {
+ .tel {
+ font-size: 32px;
+ font-weight: 700;
+ }
+ .call {
+ font-size: 12px;
+ padding: 25px 0 0;
+ white-space: pre-line;
+ line-height: 17px;
+ }
+ span {
+ display: block;
+ }
+ }
+ .companyinfo {
+ display: flex;
+ flex-direction: column;
+ font-size: 11px;
+ padding: 30px 0 0;
+ span:first-child {
+ font-weight: 700;
+ color: $color-black-8;
+ }
+ span {
+ color: $color-black-5;
+ line-height: 21px;
+ }
+ .businessInfo {
+ text-decoration: none;
+ outline: none;
+ color: $color-black-0;
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+ .bottom {
+ margin: 84px 0 0;
+
+ .copyright {
+ color: $color-black-6D;
+ font-size: 13px;
+ text-decoration: none;
+ outline: none;
+ }
+ ul {
+ display: flex;
+ a {
+ text-decoration: none;
+ outline: none;
+ &:visited {
+ color: $color-black-0;
+ }
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ li:nth-child(3) {
+ font-weight: 700;
+ }
+ li {
+ font-size: 15px;
+ margin: 0 30px 0 0;
+ }
+ }
+ }
+ }
+ .sideRight {
+ display: flex;
+ justify-content: space-between;
+ ul {
+ margin-right: 70px;
+ li:first-child {
+ font-size: 12px;
+ font-weight: 600;
+ margin: 0 0 18px;
+ }
+ a {
+ text-decoration: none;
+ outline: none;
+ color: $color-black-7;
+ &:visited {
+ color: $color-black-7;
+ }
+ &:hover {
+ color: $color-black-0;
+ }
+ }
+ li {
+ font-size: 11px;
+ margin-bottom: 13px;
+ }
+ }
+ }
+ }
+}
diff --git a/src/styles/layout/header.module.scss b/src/styles/layout/header.module.scss
new file mode 100644
index 00000000..fed9b536
--- /dev/null
+++ b/src/styles/layout/header.module.scss
@@ -0,0 +1,147 @@
+@import '../abstracts/variables.scss';
+@import 'src/styles/common.scss';
+
+.header {
+ font-family: 'Montserrat', sans-serif;
+ .headerTop {
+ height: 180px;
+ display: flex;
+ justify-content: space-between;
+ .inner {
+ width: 1240px;
+ margin: auto;
+ > a {
+ position:absolute;
+ top:50px;
+ left:50%;
+ transform:translate(-50%,0);
+ .logo {
+ width: 162px;
+ height: 90px;
+ }
+ }
+ .loginTop {
+ height: 80px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ overflow: hidden;
+ padding-bottom:20px;
+ .inputBox {
+ display: flex;
+ align-items: center;
+ margin-top: 20px;
+ position: relative;
+ input {
+ margin-right: 10px;
+ font-size: 15px;
+ outline: none;
+ padding: 5px 5px 5px 10px;
+ border-radius: 4px;
+ transition: all 1s;
+ &.show {
+ position: absolute;
+ top: 0px;
+ right: 0px;
+ border: 1px solid #d0d4d9;
+ background-color: $color-primary;
+ opacity: 1;
+ }
+ &.hide {
+ position: absolute;
+ top: 0;
+ right: -300px;
+ opacity: 0;
+ }
+ }
+ .icon {
+ font-size: 30px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 1s;
+ &.show {
+ display: block;
+ }
+ &.hide {
+ //color:red;
+ display: none;
+ }
+ }
+ }
+ .loginLink {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-start;
+ span {
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0 10px;
+ color: $color-black-0;
+ cursor: pointer;
+ a {
+ text-decoration: none;
+ color: $color-black-0;
+ }
+ }
+ }
+ }
+ }
+ }
+ .navigation{
+ border-top:2px solid $color-white-ed;
+ border-bottom:2px solid $color-white-ed;
+ background-color: $color-primary;
+ width:100%;
+ height:60px;
+ position:relative;
+ z-index:2;
+ &.fixed{
+ position:fixed;
+ top:0;
+ }
+ .navInner{
+ max-width:1240px;
+ height:60px;
+ margin:auto;
+ background-color: white;
+ line-height:20px;
+ height:20px;
+ display:flex;
+ justify-content: space-evenly;
+ font-weight:800;
+ padding:20px 100px;
+ >li{
+ width:130px;
+ text-align: center;
+ height:40px;
+ >a{
+ text-decoration: none;
+ color: $color-black-0;
+ .dropdown{
+ display:none;
+ width:130px;
+ margin-top:20px;
+ border:1px solid $color-white-ed;
+ z-index: 10;
+ li{
+ height:35px;
+ background-color:$color-primary;
+ border:1px solid $color-white-ed;
+ text-align: center;
+ line-height:35px;
+ div{
+ font-size:13px;
+ &:hover{
+ background-color: $color-white-ed;
+ }
+ }
+ }
+ }
+ }
+ &:hover .dropdown{
+ display:block;
+ }
+ }
+ }
+ }
+}
diff --git a/src/styles/pages/admin.module.scss b/src/styles/pages/admin.module.scss
new file mode 100644
index 00000000..0a1cd23e
--- /dev/null
+++ b/src/styles/pages/admin.module.scss
@@ -0,0 +1,189 @@
+@import 'src/styles/common.scss';
+
+.admin {
+ min-height: 100vh;
+ height: 100%;
+ display: flex;
+ background-color: $color-admin-main;
+ color: $color-black-0;
+
+ nav {
+ min-width: 240px;
+ min-height: 100%;
+ padding-top: 50px;
+ background-color: $color-primary;
+ position: relative;
+
+ h1 {
+ color: $color-admin-primary;
+ font-size: 24px;
+ font-weight: 700;
+ padding-left: 20px;
+ }
+
+ ul {
+ display: flex;
+ flex-direction: column;
+ gap: 30px;
+ margin-top: 50px;
+
+ li {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ height: 40px;
+ border-left: 3px solid $color-primary;
+
+ a {
+ padding-left: 36px;
+ text-decoration: none;
+ color: $color-black-2;
+ font-size: 16px;
+ }
+
+ &.active {
+ border-left: 3px solid $color-admin-primary;
+
+ a {
+ color: $color-admin-primary;
+ }
+ }
+
+ &.home {
+ a {
+ color: $color-admin-primary;
+ font-weight: 500;
+ }
+
+ position: absolute;
+ bottom: 100px;
+ }
+ }
+ }
+ }
+}
+
+// Admin Loading
+.loading {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ width: 100%;
+ height: 100%;
+
+ .loading__overlay {
+ background-color: rgba($color-black-2, 0.4);
+ width: 100%;
+ height: 100%;
+ }
+
+ .loading__message {
+ text-align: center;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 20px;
+ color: #fff;
+
+ span {
+ color: #56f0ed;
+ font-size: 40px;
+ }
+ p {
+ font-weight: 700;
+ font-size: 24px;
+ }
+
+ @keyframes loading-anim-1 {
+ 0% {
+ top: 18px;
+ height: 64px;
+ }
+ 50% {
+ top: 30px;
+ height: 40px;
+ }
+ 100% {
+ top: 30px;
+ height: 40px;
+ }
+ }
+ @keyframes loading-anim-2 {
+ 0% {
+ top: 22px;
+ height: 58px;
+ }
+ 50% {
+ top: 30px;
+ height: 40px;
+ }
+ 100% {
+ top: 30px;
+ height: 40px;
+ }
+ }
+ @keyframes loading-anim-3 {
+ 0% {
+ top: 24px;
+ height: 52px;
+ }
+ 50% {
+ top: 30px;
+ height: 40px;
+ }
+ 100% {
+ top: 30px;
+ height: 40px;
+ }
+ }
+ .loading__icon div {
+ position: absolute;
+ width: 10px;
+ }
+
+ .loading__icon div:nth-child(1) {
+ left: 10px;
+ background: #1d3f72;
+ animation: loading-anim-1 1s cubic-bezier(0, 0.5, 0.5, 1) infinite;
+ animation-delay: -0.2s;
+ }
+
+ .loading__icon div:nth-child(2) {
+ left: 30px;
+ background: #5699d2;
+ animation: loading-anim-2 1s cubic-bezier(0, 0.5, 0.5, 1) infinite;
+ animation-delay: -0.1s;
+ }
+ .loading__icon div:nth-child(3) {
+ left: 50px;
+ background: #d8ebf9;
+ animation: loading-anim-3 1s cubic-bezier(0, 0.5, 0.5, 1) infinite;
+ animation-delay: undefineds;
+ }
+
+ .loading__icon-container {
+ width: 70px;
+ height: 80px;
+ display: inline-block;
+ overflow: hidden;
+ }
+
+ .loading__icon {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ transform: translateZ(0) scale(1);
+ backface-visibility: hidden;
+ transform-origin: 0 0; /* see note above */
+ }
+
+ .loading__icon div {
+ box-sizing: content-box;
+ }
+ }
+}
diff --git a/src/styles/pages/adminCustomers.module.scss b/src/styles/pages/adminCustomers.module.scss
new file mode 100644
index 00000000..46b2463b
--- /dev/null
+++ b/src/styles/pages/adminCustomers.module.scss
@@ -0,0 +1,50 @@
+@import 'src/styles/common.scss';
+
+// 검색 Input
+input.search {
+ margin-top: 80px;
+ width: 200px;
+ height: 40px;
+}
+
+.customers {
+ margin-top: 40px;
+ height: 60px;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid $color-admin-border;
+ background-color: $color-primary;
+ width: 100%;
+ text-align: start;
+ font-weight: 700;
+ gap: 10px;
+
+ .customers__email {
+ width: 30%;
+ flex-grow: 1;
+ margin-left: 40px;
+ min-width: 250px;
+ }
+
+ .customers__name {
+ width: 20%;
+ flex-grow: 1;
+ }
+
+ .customers__grade {
+ width: 15%;
+ flex-grow: 1;
+ }
+ .customers__total-order {
+ width: 15%;
+ flex-grow: 1;
+ text-align: right;
+ }
+
+ .customers__total-price {
+ width: 20%;
+ flex-grow: 1;
+ text-align: right;
+ margin-right: 40px;
+ }
+}
diff --git a/src/styles/pages/adminDashboard.module.scss b/src/styles/pages/adminDashboard.module.scss
new file mode 100644
index 00000000..68207ef3
--- /dev/null
+++ b/src/styles/pages/adminDashboard.module.scss
@@ -0,0 +1,17 @@
+@import 'src/styles/common.scss';
+
+.row-title {
+ margin: 50px 0 0;
+ font-size: 20px;
+ font-weight: 500;
+ color: $color-loginlink;
+ display: block;
+}
+
+.row {
+ margin-top: 10px;
+ display: flex;
+ justify-content: start;
+ align-items: center;
+ gap: 40px;
+}
diff --git a/src/styles/pages/adminProductAdd.module.scss b/src/styles/pages/adminProductAdd.module.scss
new file mode 100644
index 00000000..88dbf9d7
--- /dev/null
+++ b/src/styles/pages/adminProductAdd.module.scss
@@ -0,0 +1 @@
+@import 'src/styles/common.scss';
diff --git a/src/styles/pages/adminProducts.module.scss b/src/styles/pages/adminProducts.module.scss
new file mode 100644
index 00000000..f2226f3d
--- /dev/null
+++ b/src/styles/pages/adminProducts.module.scss
@@ -0,0 +1,19 @@
+@import 'src/styles/common.scss';
+
+// 상품 추가 버튼
+button.right {
+ position: absolute;
+ right: 50px;
+
+ a {
+ color: $color-primary;
+ text-decoration: none;
+ }
+}
+
+// 검색 Input
+input.search {
+ margin-top: 80px;
+ width: 200px;
+ height: 40px;
+}
diff --git a/src/styles/pages/adminSales.module.scss b/src/styles/pages/adminSales.module.scss
new file mode 100644
index 00000000..e85f097f
--- /dev/null
+++ b/src/styles/pages/adminSales.module.scss
@@ -0,0 +1,48 @@
+@import 'src/styles/common.scss';
+
+// 검색 Input
+input.search {
+ margin-top: 80px;
+ width: 200px;
+}
+
+.sales {
+ margin-top: 40px;
+ height: 60px;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid $color-admin-border;
+ background-color: $color-primary;
+ width: 100%;
+ text-align: start;
+ font-weight: 700;
+ gap: 10px;
+
+ .sales__date {
+ width: 17%;
+ flex-grow: 1;
+ margin-left: 40px;
+ }
+
+ .sales__email {
+ width: 20%;
+ flex-grow: 1;
+ }
+
+ .sales__product {
+ width: 30%;
+ flex-grow: 1;
+ }
+
+ .sales__price {
+ width: 15%;
+ flex-grow: 1;
+ text-align: center;
+ }
+
+ .sales__status {
+ width: 18%;
+ flex-grow: 1;
+ text-align: center;
+ }
+}
diff --git a/src/styles/pages/modifyPassword.module.scss b/src/styles/pages/modifyPassword.module.scss
new file mode 100644
index 00000000..97faac6a
--- /dev/null
+++ b/src/styles/pages/modifyPassword.module.scss
@@ -0,0 +1,63 @@
+@import 'src/styles/common.scss';
+@import 'src/styles/abstracts/variables.scss';
+
+.container {
+ width: 1040px;
+ height:400px;
+ padding-bottom: 200px;
+ display:flex;
+ justify-content:center;
+ align-items:center;
+ form{
+ border-top:1px solid $color-black-0;
+ border-bottom:1px solid $color-black-0;
+ text-align:center;
+ padding:100px;
+ margin-top:300px;
+ h2{
+ font-size:30px;
+ margin-bottom:50px;
+ font-weight:bold;
+ }
+ .box{
+ display:flex;
+ justify-content: center;
+ width:500px;
+ height:60px;
+ border:1px solid $color-black-0;
+ box-sizing: border-box;
+ margin:15px 0;
+ .content{
+ width:150px;
+ line-height:60px;
+ color:$color-primary;
+ background-color: $color-black-0;
+ display:flex;
+ justify-content: center;
+ align-items: center;
+ font-size:14px;
+ }
+ .inputBox{
+ width:350px;
+ line-height:60px;
+ display:flex;
+ justify-content: center;
+ align-items: center;
+ input{
+ width:260px;
+ height:25px;
+ outline:none;
+ padding-left:15px;
+ font-size:20px;
+ }
+ }
+ }
+ button{
+ width:140px;
+ height:50px;
+ font-size:16px;
+ margin:30px auto 0;
+ cursor: pointer;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/styles/pages/myOrders.module.scss b/src/styles/pages/myOrders.module.scss
new file mode 100644
index 00000000..7fbd2d6b
--- /dev/null
+++ b/src/styles/pages/myOrders.module.scss
@@ -0,0 +1,60 @@
+@import 'src/styles/common.scss';
+
+.container {
+ //min-width: 1040px;
+ width: 1040px;
+ min-height: 700px;
+ //padding: 0 50px 200px;
+ padding-bottom: 200px;
+
+ h4 {
+ color: $color-black-0;
+ font-size: 16px;
+ margin: 50px 0 20px;
+ }
+}
+
+.path {
+ width: 100%;
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ margin-top: 30px;
+ gap: 4px;
+ a {
+ color: $color-link;
+ text-decoration: none;
+ font-size: 12px;
+ }
+ color: $color-black-0;
+ font-size: 13px;
+ line-height: 30px;
+}
+
+.tabs {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 40px;
+
+ .tab {
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 60px;
+ font-size: 13px;
+ line-height: 26px;
+ font-weight: 700;
+ box-sizing: border-box;
+
+ &.active {
+ border: 1px solid $color-black-86;
+ border-bottom: none;
+ }
+
+ &.inactive {
+ border-bottom: 1px solid $color-black-86;
+ }
+ }
+}
diff --git a/src/styles/pages/myWishList.module.scss b/src/styles/pages/myWishList.module.scss
new file mode 100644
index 00000000..02cc6726
--- /dev/null
+++ b/src/styles/pages/myWishList.module.scss
@@ -0,0 +1,61 @@
+@import 'src/styles/common.scss';
+
+.container {
+ //min-width: 1040px;
+ width: 1040px;
+ min-height: 700px;
+ //padding: 0 50px 200px;
+ padding-bottom: 200px;
+
+ h4 {
+ color: $color-black-0;
+ font-size: 16px;
+ margin: 50px 0 20px;
+ }
+}
+
+.path {
+ width: 100%;
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ margin-top: 30px;
+ gap: 4px;
+
+ a {
+ color: $color-link;
+ text-decoration: none;
+ font-size: 12px;
+ }
+ color: $color-black-0;
+ font-size: 13px;
+ line-height: 30px;
+}
+
+.tabs {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-bottom: 40px;
+
+ .tab {
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 60px;
+ font-size: 13px;
+ line-height: 26px;
+ font-weight: 700;
+ box-sizing: border-box;
+
+ &.active {
+ border: 1px solid $color-black-86;
+ border-bottom: none;
+ }
+
+ &.inactive {
+ border-bottom: 1px solid $color-black-86;
+ }
+ }
+}
diff --git a/src/styles/pages/signin.module.scss b/src/styles/pages/signin.module.scss
new file mode 100644
index 00000000..a772e5fb
--- /dev/null
+++ b/src/styles/pages/signin.module.scss
@@ -0,0 +1,47 @@
+@import 'src/styles/common.scss';
+
+.loginForm {
+ width: 600px;
+ height: 400px;
+ margin: 70px auto;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ .title {
+ margin-top: 50px;
+ font-weight: bold;
+ }
+ form {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-around;
+ align-items: center;
+ width: 360px;
+ height: 200px;
+ margin: auto;
+ input {
+ width: 338px;
+ height: 50px;
+ outline: none;
+ padding-left: 10px;
+ border: none;
+ background-color: #edeff2;
+ font-size: 16px;
+ &:focus {
+ width: 350px;
+ background-color: $color-primary;
+ border: 1px solid #d0d4d9;
+ box-sizing: border-box;
+ }
+ }
+ button {
+ width: 180px;
+ height: 45px;
+ font-size: 16px;
+ }
+ }
+ .title {
+ font-size: 30px;
+ }
+}
diff --git a/src/styles/pages/signup.module.scss b/src/styles/pages/signup.module.scss
new file mode 100644
index 00000000..c681eded
--- /dev/null
+++ b/src/styles/pages/signup.module.scss
@@ -0,0 +1,60 @@
+@import 'src/styles/abstracts/variables.scss';
+
+.signupform{
+ width:600px;
+ height:350px;
+ padding:50px;
+ margin:auto;
+ margin-top:70px;
+ border-top:3px solid $color-black-5;
+ border-bottom:3px solid $color-black-5;
+ text-align:center;
+ .title{
+ font-size:30px;
+ font-weight: bold;
+ }
+ .inputbox{
+ width:500px;
+ height:60px;
+ margin:auto;
+ border:1px solid $color-black-7;
+ display:flex;
+ justify-content: space-evenly;
+ align-items: center;
+ &:first-child{
+ margin-top:40px;
+ }
+ .text{
+ width:100px;
+ height:60px;
+ border-right:1px solid $color-black-7;
+ flex-grow:0;
+ line-height:60px;
+ padding-right:45px;
+ font-weight:bold;
+ }
+ input{
+ width:188px;
+ height:30px;
+ outline:none;
+ padding-left:10px;
+ border:none;
+ background-color:#EDEFF2;
+ &:focus{
+ width:200px;
+ height:30px;
+ background-color: $color-primary;
+ border:1px solid #D0d4d9;
+ box-sizing:border-box;
+ }
+ }
+ }
+ button{
+ width:150px;
+ height:40px;
+ margin:auto;
+ margin-top:40px;
+ border:1px solid #D0d4d9;
+ outline:none;
+ }
+}
diff --git a/src/styles/pages/success.module.scss b/src/styles/pages/success.module.scss
new file mode 100644
index 00000000..d4709d39
--- /dev/null
+++ b/src/styles/pages/success.module.scss
@@ -0,0 +1,90 @@
+@import 'src/styles/common.scss';
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ right: 0;
+ left: 0;
+ border: 1px solid $color-gray-D7;
+ margin: 30px auto;
+ max-width: 900px;
+ font-weight: 700;
+
+ h2 {
+ border-bottom: 3px solid $color-gray-D7;
+ padding-bottom: 60px;
+ width: 100%;
+ }
+ .product {
+ padding-bottom: 40px;
+ border-bottom: 3px solid $color-gray-D7;
+ }
+ .price {
+ padding: 50px 0 80px;
+ border-bottom: 3px solid $color-gray-D7;
+ }
+ .heading {
+ align-self: flex-start;
+ margin-left: 32px;
+ margin-top: 30px;
+ margin-bottom: 30px;
+ font-weight: 600;
+ padding-bottom: 20px;
+ color: $color-payment-titles;
+ font-size: 19px;
+ letter-spacing: -1px;
+ }
+ .delivery {
+ width: 100%;
+ padding: 0 32px;
+ box-sizing: border-box;
+ .wrapper {
+ font-weight: 400;
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 12px;
+ .title {
+ text-decoration: underline;
+ }
+ .info {
+ letter-spacing: 0.15em;
+ }
+ }
+ }
+
+ .buttonArea {
+ margin: 20px 0;
+ text-align: center;
+ display: flex;
+ width: 100%;
+ justify-content: end;
+ padding-right: 20px;
+ box-sizing: border-box;
+ a {
+ text-decoration: none;
+ vertical-align: middle;
+ padding: 14px 26px;
+ height: 50px;
+ min-width: 80px;
+ font-size: 14px;
+ line-height: 18px;
+ font-weight: bold;
+ box-sizing: border-box;
+ cursor: pointer;
+ &:visited {
+ color: $color-black-0;
+ }
+ }
+ }
+ .home {
+ background-color: $color-black-0;
+ color: $color-primary !important;
+ }
+ .orders {
+ border: 1px solid $color-gray-BC;
+ color: $color-black-0;
+ &:hover {
+ border: 1px solid $color-black-0;
+ }
+ }
+}
diff --git a/src/types/Address.interface.ts b/src/types/Address.interface.ts
new file mode 100644
index 00000000..212cf312
--- /dev/null
+++ b/src/types/Address.interface.ts
@@ -0,0 +1,41 @@
+export interface Address {
+ postcode: string
+ postcode1: string
+ postcode2: string
+ postcodeSeq: string
+ zonecode: string
+ address: string
+ addressEnglish: string
+ addressType: string
+ bcode: string
+ bname: string
+ bnameEnglish: string
+ bname1: string
+ bname1English: string
+ bname2: string
+ bname2English: string
+ sido: string
+ sidoEnglish: string
+ sigungu: string
+ sigunguEnglish: string
+ sigunguCode: string
+ userLanguageType: string
+ query: string
+ buildingName: string
+ buildingCode: string
+ apartment: string
+ jibunAddress: string
+ jibunAddressEnglish: string
+ roadAddress: string
+ roadAddressEnglish: string
+ autoRoadAddress: string
+ autoRoadAddressEnglish: string
+ autoJibunAddress: string
+ autoJibunAddressEnglish: string
+ userSelectedType: string
+ noSelected: string
+ hname: string
+ roadnameCode: string
+ roadname: string
+ roadnameEnglish: string
+}
diff --git a/src/types/AdminDashboardCardProps.type.ts b/src/types/AdminDashboardCardProps.type.ts
new file mode 100644
index 00000000..2ab8a381
--- /dev/null
+++ b/src/types/AdminDashboardCardProps.type.ts
@@ -0,0 +1,6 @@
+export type AdminDashboardCardProps = {
+ title: string
+ value: string
+ unitStr: string
+ isLoading: boolean
+}
diff --git a/src/types/AdminMoreButtonProps.type.ts b/src/types/AdminMoreButtonProps.type.ts
new file mode 100644
index 00000000..8d756db2
--- /dev/null
+++ b/src/types/AdminMoreButtonProps.type.ts
@@ -0,0 +1,10 @@
+import { ProductResponse } from 'types/index'
+
+export type AdminMoreButtonProps = {
+ isShow: boolean
+ onToggleMenu: () => void
+ onClickEdit: () => void
+ onClickDelete: (product: ProductResponse) => void
+ product: ProductResponse
+ onClickChangeStatus: () => void
+}
diff --git a/src/types/AdminSalesItemProps.type.ts b/src/types/AdminSalesItemProps.type.ts
new file mode 100644
index 00000000..bb3f3859
--- /dev/null
+++ b/src/types/AdminSalesItemProps.type.ts
@@ -0,0 +1,7 @@
+import { TransactionDetail } from 'types/index'
+
+export type AdminSalesItemProps = {
+ detail: TransactionDetail
+ onChangeOrderIsCanceled: (id: string, isCanceled: boolean) => void
+ onClickOrderConfirm: (id: string) => void
+}
diff --git a/src/types/BankAccounts.interface.ts b/src/types/BankAccounts.interface.ts
new file mode 100644
index 00000000..f955f553
--- /dev/null
+++ b/src/types/BankAccounts.interface.ts
@@ -0,0 +1,34 @@
+export interface RemoveRequest {
+ accountId: string // 계좌 ID (필수!)
+ signature: boolean // 사용자 서명 (필수!)
+}
+
+export interface CreateRequest {
+ bankCode: string // 연결할 은행 코드 (필수!)
+ accountNumber: string // 연결할 계좌번호 (필수!)
+ phoneNumber: string // 사용자 전화번호 (필수!)
+ signature: boolean // 사용자 서명 (필수!)
+}
+
+export interface AccountsRequest {
+ totalBalance: number // 사용자 계좌 잔액 총합
+ accounts: Bank[] // 사용자 계좌 정보 목록
+}
+
+export interface Bank {
+ // 사용자 계좌 정보
+ id: string // 계좌 ID
+ bankName: string // 은행 이름
+ bankCode: string // 은행 코드
+ accountNumber: string // 계좌 번호
+ balance: number // 계좌 잔액
+}
+
+export interface Transaction {
+ productId: string // 거래할 제품 ID (필수!)
+ accountId: string // 결제할 사용자 계좌 ID (필수!)
+}
+
+export interface childProps {
+ setValid: React.Dispatch>
+}
diff --git a/src/types/BestProductProps.type.ts b/src/types/BestProductProps.type.ts
new file mode 100644
index 00000000..491976b4
--- /dev/null
+++ b/src/types/BestProductProps.type.ts
@@ -0,0 +1,6 @@
+import { Product } from 'types/index'
+
+export type BestProductProps = {
+ product: Product
+ onSaveProductRecently: (product: Product) => void
+}
diff --git a/src/types/BodyInfo.interface.ts b/src/types/BodyInfo.interface.ts
new file mode 100644
index 00000000..0f271deb
--- /dev/null
+++ b/src/types/BodyInfo.interface.ts
@@ -0,0 +1,5 @@
+export interface BodyInfo {
+ email: string
+ password: string
+ displayName: string
+}
diff --git a/src/types/Cart.interface.ts b/src/types/Cart.interface.ts
new file mode 100644
index 00000000..3b638e6d
--- /dev/null
+++ b/src/types/Cart.interface.ts
@@ -0,0 +1,13 @@
+import { Product } from 'types/index'
+// export interface Cart {
+// total: string
+// delivery: string
+// products: string
+// }
+
+export interface CartProduct {
+ product: Product
+ quantity: number // 저장되어있는 수량
+ checkedItemHandler?: (id: string, isChecked: boolean) => void
+ isChecked?: boolean
+}
diff --git a/src/types/CommonError.interface.ts b/src/types/CommonError.interface.ts
new file mode 100644
index 00000000..d8d23eee
--- /dev/null
+++ b/src/types/CommonError.interface.ts
@@ -0,0 +1,5 @@
+export interface CommonError {
+ status: number | undefined // Error status code
+ message: string // Error message
+ isShowModal: boolean
+}
diff --git a/src/types/Customer.interface.ts b/src/types/Customer.interface.ts
new file mode 100644
index 00000000..e7982954
--- /dev/null
+++ b/src/types/Customer.interface.ts
@@ -0,0 +1,11 @@
+export interface Customer {
+ email: string // 사용자 아이디
+ displayName: string // 사용자 표시 이름
+ profileImg: string // 사용자 프로필 이미지 URL
+}
+
+export interface CustomerInfo {
+ user: Customer
+ totalTransaction: number
+ totalTransactionPrice: number
+}
diff --git a/src/types/IdInfo.interface.ts b/src/types/IdInfo.interface.ts
new file mode 100644
index 00000000..59208f9a
--- /dev/null
+++ b/src/types/IdInfo.interface.ts
@@ -0,0 +1,11 @@
+export interface IdInfo {
+ email: string
+ password: string
+}
+
+export interface RequestBody {
+ displayName?: string // 새로운 표시 이름
+ profileImgBase64?: string // 사용자 프로필 이미지(base64) - jpg, jpeg, webp, png, gif, svg
+ oldPassword?: string // 기존 비밀번호
+ newPassword?: string // 새로운 비밀번호
+}
diff --git a/src/types/ModalProps.type.ts b/src/types/ModalProps.type.ts
new file mode 100644
index 00000000..a03f561b
--- /dev/null
+++ b/src/types/ModalProps.type.ts
@@ -0,0 +1,15 @@
+export type ModalProps = {
+ isTwoButton: boolean // * true면 ok, cancel 버튼 2개
+ title: string // * Modal title
+ content?: string // * Modal content message
+ okButtonText: string // * 기본 확인 버튼명(필수) 확인(닫기)
+ children?: JSX.Element
+ cancelButtonText?: string // * 취소 버튼명(isTwoButton이 true인 경우 필수 작성)
+ onClickOkButton: () => void // * 확인 버튼 클릭 이벤트
+ onClickCancelButton?: () => void // * 취소 버튼 클릭 이벤트
+}
+
+export const defaultModalProps = {
+ isTwoButton: false,
+ okButtonText: '확인'
+}
diff --git a/src/types/MyOrderItemProps.type.ts b/src/types/MyOrderItemProps.type.ts
new file mode 100644
index 00000000..3b0b9a1f
--- /dev/null
+++ b/src/types/MyOrderItemProps.type.ts
@@ -0,0 +1,8 @@
+import { TransactionDetail } from 'types/index'
+
+export type MyOrderItemProps = {
+ detail: TransactionDetail
+ isLast: boolean
+ onClickConfirm: (id: string) => void
+ onClickCancel: (id: string) => void
+}
diff --git a/src/types/MyWishItemProps.type.ts b/src/types/MyWishItemProps.type.ts
new file mode 100644
index 00000000..f158ca49
--- /dev/null
+++ b/src/types/MyWishItemProps.type.ts
@@ -0,0 +1,8 @@
+import { Product } from 'types/index'
+
+export type MyWishItemProps = {
+ product: Product
+ isLast: boolean
+ isChecked: boolean
+ onChange: (id: string) => void
+}
diff --git a/src/types/Product.interface.ts b/src/types/Product.interface.ts
new file mode 100644
index 00000000..e36561e7
--- /dev/null
+++ b/src/types/Product.interface.ts
@@ -0,0 +1,23 @@
+export interface ProductAddBody {
+ id?: string
+ title: string
+ price: number
+ description: string
+ tags: string[]
+ thumbnailBase64?: string | null
+ photoBase64?: string | null
+ discountRate?: number
+}
+
+export interface ProductResponse {
+ id: string // 제품 ID
+ title: string // 제품 이름
+ price: number // 제품 가격
+ description: string // 제품 상세 설명
+ tags: string[] // 제품 태그
+ thumbnail: string | null // 제품 썸네일 이미지(URL)
+ photo: string | null // 제품 상세 이미지(URL)
+ isSoldOut: boolean // 제품 매진 여부
+ // reservations: Reservation[] // 제품의 모든 예약 정보 목록
+ discountRate: number // 제품 할인율
+}
diff --git a/src/types/ProductAddFormProps.type.ts b/src/types/ProductAddFormProps.type.ts
new file mode 100644
index 00000000..3c87d18b
--- /dev/null
+++ b/src/types/ProductAddFormProps.type.ts
@@ -0,0 +1,6 @@
+import { ProductAddBody, ProductResponse } from 'types/index'
+
+export type ProductAddFormProps = {
+ product: ProductResponse | null
+ onSubmit: (product: ProductAddBody) => void
+}
diff --git a/src/types/ProductItemProps.type.ts b/src/types/ProductItemProps.type.ts
new file mode 100644
index 00000000..f5652150
--- /dev/null
+++ b/src/types/ProductItemProps.type.ts
@@ -0,0 +1,10 @@
+import { ProductResponse } from 'types/index'
+
+export type ProductItemProps = {
+ product: ProductResponse
+ isMenuShow: boolean
+ showMenu: (id: string) => void
+ hideMenu: () => void
+ onClickDelete: (product: ProductResponse) => void
+ onChangeSaleStatus: (id: string, isChangedSoldout: boolean) => void
+}
diff --git a/src/types/Products.interface.ts b/src/types/Products.interface.ts
new file mode 100644
index 00000000..6a620fff
--- /dev/null
+++ b/src/types/Products.interface.ts
@@ -0,0 +1,24 @@
+export interface Product {
+ isSoldOut: any
+ id: string
+ thumbnail: string
+ title: string
+ price: number
+ discountRate?: number | undefined
+ tags: string[]
+ photo: string
+}
+
+export interface ProductsProps {
+ tagFilter?: string[]
+ limit?: number
+ sortOption?: string | null
+ keyword?: string
+ getProductCount?: ((count: number) => void) | undefined
+ excludeProductIds?: string[]
+}
+
+export interface RouteParams {
+ id: string
+ [key: string]: string | undefined
+}
diff --git a/src/types/TransactionDetail.interface.ts b/src/types/TransactionDetail.interface.ts
new file mode 100644
index 00000000..62e11549
--- /dev/null
+++ b/src/types/TransactionDetail.interface.ts
@@ -0,0 +1,37 @@
+export interface TransactionDetail {
+ // 거래 내역 정보
+ detailId: string // 거래 내역 ID
+ user: {
+ // 거래한 사용자 정보
+ email: string
+ displayName: string
+ profileImg: string | null
+ }
+ account: {
+ // 거래한 사용자의 계좌 정보
+ bankName: string
+ bankCode: string
+ accountNumber: string
+ }
+ product: {
+ // 거래한 제품 정보
+ productId: string
+ title: string
+ price: number
+ description: string
+ tags: string[]
+ thumbnail: string | null
+ discountRate: number
+ }
+ reservation: Reservation | null // 거래한 제품의 예약 정보
+ timePaid: string // 제품을 거래한 시간
+ isCanceled: boolean // 거래 취소 여부
+ done: boolean // 거래 완료 여부
+}
+
+interface Reservation {
+ start: string // 예약 시작 시간
+ end: string // 예약 종료 시간
+ isCanceled: boolean // 예약 취소 여부
+ isExpired: boolean // 예약 만료 여부
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 00000000..b9e46382
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,19 @@
+export * from 'types/BodyInfo.interface'
+export * from 'types/IdInfo.interface'
+export * from 'types/Product.interface'
+export * from 'types/ProductAddFormProps.type'
+export * from 'types/ProductItemProps.type'
+export * from 'types/AdminMoreButtonProps.type'
+export * from 'types/ModalProps.type'
+export * from 'types/Customer.interface'
+export * from 'types/TransactionDetail.interface'
+export * from 'types/AdminDashboardCardProps.type'
+export * from 'types/AdminSalesItemProps.type'
+export * from 'types/Products.interface'
+export * from 'types/MyOrderItemProps.type'
+export * from 'types/Cart.interface'
+export * from 'types/MyWishItemProps.type'
+export * from 'types/BankAccounts.interface'
+export * from 'types/CommonError.interface'
+export * from 'types/BestProductProps.type'
+export * from 'types/Address.interface'
diff --git a/src/types/swiper/index.d.ts b/src/types/swiper/index.d.ts
new file mode 100644
index 00000000..b0276635
--- /dev/null
+++ b/src/types/swiper/index.d.ts
@@ -0,0 +1,5 @@
+declare module 'swiper/core' {
+ import Swiper from 'swiper'
+
+ export default Swiper
+}
diff --git a/src/utils/CalculateDiscountedPrice.ts b/src/utils/CalculateDiscountedPrice.ts
new file mode 100644
index 00000000..e0a88ebf
--- /dev/null
+++ b/src/utils/CalculateDiscountedPrice.ts
@@ -0,0 +1,10 @@
+export const calculateDiscountedPrice = (
+ price: number,
+ discountRate: number | null | undefined
+) => {
+ if (discountRate && discountRate != 0) {
+ const discountAmount = price * (discountRate / 100)
+ return price - discountAmount
+ }
+ return price
+}
diff --git a/src/utils/CheckIsAdmin.ts b/src/utils/CheckIsAdmin.ts
new file mode 100644
index 00000000..39f3b19f
--- /dev/null
+++ b/src/utils/CheckIsAdmin.ts
@@ -0,0 +1,4 @@
+export const checkIsAdmin = (email: string) => {
+ const isAdmin = email === import.meta.env.VITE_ADMIN_EMAIL ? true : false
+ return isAdmin
+}
diff --git a/src/utils/convertTagColor.ts b/src/utils/convertTagColor.ts
new file mode 100644
index 00000000..7cffe27b
--- /dev/null
+++ b/src/utils/convertTagColor.ts
@@ -0,0 +1,38 @@
+import { tags } from 'constants/index'
+
+export const convertTagColor = (tag: string): string => {
+ switch (tag) {
+ case tags.CONST_TAG_NEW:
+ return '#068E52'
+ case tags.CONST_TAG_BEST:
+ return '#FFC602'
+ case tags.CONST_TAG_LIVING:
+ return '#2596be'
+ case tags.CONST_TAG_KITCHEN:
+ return '#F39570'
+ case tags.CONST_TAG_STATIONERY:
+ return '#D094f7'
+ case tags.CONST_TAG_BABYKIDS:
+ return '#2872FB'
+ case tags.CONST_TAG_TABLE:
+ return '#1d7898'
+ case tags.CONST_TAG_ROOM:
+ return '#165a72'
+ case tags.CONST_TAG_LIGHT:
+ return '#0e3c4c'
+ case tags.CONST_TAG_BED:
+ return '#071e26'
+ case tags.CONST_TAG_CUP:
+ return '#da8664'
+ case tags.CONST_TAG_DISHES:
+ return '#f39570'
+ case tags.CONST_TAG_PLATE:
+ return '#f18458'
+ case tags.CONST_TAG_GOODS:
+ return '#ef7241'
+ case tags.CONST_TAG_CUTTING_BOARD:
+ return '#ed6129'
+ default:
+ return '#F77291'
+ }
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
new file mode 100644
index 00000000..3c1a0054
--- /dev/null
+++ b/src/utils/index.ts
@@ -0,0 +1,3 @@
+export * from 'utils/convertTagColor'
+export * from 'utils/CheckIsAdmin'
+export * from 'utils/CalculateDiscountedPrice'
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 00000000..11f02fe2
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..201fda73
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,60 @@
+{
+ "compilerOptions": {
+ "types": ["node"],
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "declaration": true, //컴파일시 .d.ts 파일도 자동으로 함께생성 (현재쓰는 모든 타입이 정의된 파일)
+ "allowImportingTsExtensions": true,
+ "allowSyntheticDefaultImports": true, // default export가 없는 모듈에서 default imports를 허용합니다. 코드 방출에는 영향을 주지 않으며, 타입 검사만 수행합니다
+ "typeRoots": ["./@types", "./node_modules/@types"],
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true, //기본값 - false, CommonJS에서 존재하지 않는 기본 내보내기 설정
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "removeComments": true, //컴파일시 주석제거
+ "noImplicitAny": false,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noImplicitReturns": true, //함수에서 return 빼먹으면 에러내기
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": "./", // baseUrl을 기준으로 관련된 위치에 모듈 이름의 경로 매핑 목록을 나열합니다.
+ "paths": {
+ "@/*": ["src/*"],
+ "pages/*": ["src/pages/*"],
+ "components/*": ["src/components/*"],
+ "services/*": ["src/services/*"],
+ "hooks/*": ["src/hooks/*"],
+ "contexts/*": ["src/contexts/*"],
+ "styles/*": ["src/styles/*"],
+ "types/*": ["src/types/*"],
+ "utils/*": ["src/utils/*"],
+ "api/*": ["src/api/*"],
+ "constants/*": ["src/constants/*"]
+ }
+ },
+ "include": [
+ "src",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "src/**/*.js",
+ "src/**/*.d.ts"
+ ], //컴파일할 파일 경로 목록
+ "exclude": [
+ //컴파일에서 제외할 파일 경로 목록
+ "node_modules"
+ ],
+ "ts-node": {
+ "require": ["tsconfig-paths/register"]
+ },
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 00000000..42872c59
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 00000000..d90e82d0
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react-swc'
+import tsconfigPaths from 'vite-tsconfig-paths'
+import removeConsole from 'vite-plugin-remove-console'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react(), tsconfigPaths(), removeConsole()]
+})