diff --git a/.github/ISSUE_TEMPLATE/bug-issue-template.md b/.github/ISSUE_TEMPLATE/bug-issue-template.md
new file mode 100644
index 0000000..770353a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-issue-template.md
@@ -0,0 +1,20 @@
+---
+name: Bug Issue Template
+about: 버그 관련 이슈 템플릿
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+## 에러 설명🚦
+
+
+## 환경⚙️
+
+
+## 재현 방법🧿
+
+
+## 에러 화면📸
+
diff --git a/.github/ISSUE_TEMPLATE/feature-issue-template.md b/.github/ISSUE_TEMPLATE/feature-issue-template.md
new file mode 100644
index 0000000..8800082
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature-issue-template.md
@@ -0,0 +1,17 @@
+---
+name: Feature Issue Template
+about: 기능 개발 시에 사용하는 이슈 템플릿
+title: 'feat:'
+labels: ''
+assignees: ''
+
+---
+
+## 이슈 설명☀️
+
+
+## TO-DO📒
+- [ ] 할 일 1
+
+## 기타🍀
+
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..79b7af5
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,15 @@
+## 1️⃣ 작업 내용 Summary📱💎
+
+- resolved #(issue_num)
+
+### 기존 코드에 영향을 미치지 않는 변경사항
+
+### 기존 코드에 영향을 미치는 변경사항
+
+### ✚ 작업 내용 스크린 샷📸
+
+## 2️⃣ 리뷰어에게 공유할 내용👥
+
+## 3️⃣ 추후 작업할 내용👋
+
+- [ ] `main` 브랜치의 최신 코드를 `pull` 받았나요?
diff --git a/.github/readme-assets/ai-flow.png b/.github/readme-assets/ai-flow.png
new file mode 100644
index 0000000..4a7cc6e
Binary files /dev/null and b/.github/readme-assets/ai-flow.png differ
diff --git a/.github/readme-assets/architecture.png b/.github/readme-assets/architecture.png
new file mode 100644
index 0000000..1da7077
Binary files /dev/null and b/.github/readme-assets/architecture.png differ
diff --git a/.github/readme-assets/cicd.png b/.github/readme-assets/cicd.png
new file mode 100644
index 0000000..6d3ed26
Binary files /dev/null and b/.github/readme-assets/cicd.png differ
diff --git a/.github/readme-assets/erd.png b/.github/readme-assets/erd.png
new file mode 100644
index 0000000..f8e626c
Binary files /dev/null and b/.github/readme-assets/erd.png differ
diff --git a/.github/readme-assets/server-test.png b/.github/readme-assets/server-test.png
new file mode 100644
index 0000000..e6934c5
Binary files /dev/null and b/.github/readme-assets/server-test.png differ
diff --git a/.github/readme-assets/test-coverage.png b/.github/readme-assets/test-coverage.png
new file mode 100644
index 0000000..0e5218f
Binary files /dev/null and b/.github/readme-assets/test-coverage.png differ
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..3081a05
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,30 @@
+name: git push into another repo to deploy to vercel
+
+on:
+ push:
+ branches: [main]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ container: pandoc/latex
+ steps:
+ - uses: actions/checkout@v2
+ - name: Install mustache (to update the date)
+ run: apk add ruby && gem install mustache
+ - name: creates output
+ run: sh ./build.sh
+ - name: Pushes to another repository
+ id: push_directory
+ uses: cpina/github-action-push-to-another-repository@main
+ env:
+ API_TOKEN_GITHUB: ${{ secrets.ACTIONS_TOKEN }}
+ with:
+ source-directory: 'output'
+ destination-github-username: dvp-tae
+ destination-repository-name: claco-client
+ user-email: ${{ secrets.EMAIL }}
+ commit-message: ${{ github.event.commits[0].message }}
+ target-branch: main
+ - name: Test get variable exported by push-to-another-repository
+ run: echo $DESTINATION_CLONED_DIRECTORY
diff --git a/README.md b/README.md
index 74872fd..575e634 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,211 @@
-# React + TypeScript + Vite
-
-This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
-
-Currently, two official plugins are available:
-
-- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
-- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
-
-## Expanding the ESLint configuration
-
-If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
-
-- Configure the top-level `parserOptions` property like this:
-
-```js
-export default tseslint.config({
- languageOptions: {
- // other options...
- parserOptions: {
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
- tsconfigRootDir: import.meta.dirname,
- },
- },
-})
-```
-
-- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
-- Optionally add `...tseslint.configs.stylisticTypeChecked`
-- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
-
-```js
-// eslint.config.js
-import react from 'eslint-plugin-react'
-
-export default tseslint.config({
- // Set the react version
- settings: { react: { version: '18.3' } },
- plugins: {
- // Add the react plugin
- react,
- },
- rules: {
- // other rules...
- // Enable its recommended rules
- ...react.configs.recommended.rules,
- ...react.configs['jsx-runtime'].rules,
- },
-})
-```
+# 사용자 맞춤 클래식 공연 큐레이션 서비스 Claco
+
+## 📱 서비스 소개
+- 클래식 공연 감상의 길잡이가 되는 서비스를 제공함으로써 클래식 공연 문화를 더 즐겁게 향유할 수 있도록 합니다.
+- 사용자의 취향에 맞는 공연을 추천해주고, 티켓을 만들어 추억을 간직하고, 공유합니다.
+
+### 📆 개발 기간 및 인원
+- ***2024.10.05 ~ 2024.11.24***
+- ```Frontend```: 2명
+- ```Backend```: 2명
+
+## 🧑💻 R&R
+| Profile | Name | Role |
+|:---------------------------------------------------------------------------------------------------------------------------:|:---------------------------------------:|:------------------------------------------------------------------------------------------------:|
+| | 김진희 **kimzini** FE | 소셜 로그인, 온보딩, 공연 상세, 마이 페이지, 티켓 등록 서비스 GUI 개발 및 API 연동
+| | 성태현 **dvp-tae** FE | 서비스 메인, 리뷰, 둘러보기, 클라코북 만들기 서비스 GUI 개발 및 API 연동
+| | \<개발 리드> 이건 **devkeon** BE | 아키텍처 설계, ERD 설계, 메인 서버 인프라 및 CI/CD 구축, 인증/인가, 모니터링 시스템 구축, 티켓/리뷰 기능, 클라코북 기능, 회원 관련 기능 |
+| | 정희찬 **anselmo** BE | ERD 설계, AI 및 배치 서버 인프라 및 CI/CD 구축, 추천 AI 모델 구현, 배치 기능(데이터 로드) 구축, 공연 기능, 공연 및 티켓 추천 기능 |
+
+
+## 🏛️ 아키텍처
+
+
+### PWA
+- 개발 및 유지보수 비용 절감
+- 사용자가 홈 화면에 추가하여 앱처럼 활용 가능
+- 네이티브 앱과 유사한 사용성과 빠른 로딩 속도 제공
+- 플랫폼 제약 없이 다양한 환경에서 동일한 사용자 경험 제공
+- 추후 웹 푸시 알림, 백그라운드 동작 등 기능 확장이 용이
+
+### 보안 고려 사항
+- JWT를 활용한 인증/인가
+ - SSL 보안 계층을 활용한 토큰 암호화 (HTTPS, ALB 설치)
+ - CSRF / XSS 공격에 대비한 토큰 저장 분리 (Local storage, HTTP-only Cookie)
+- Nginx를 활용한 actuator와 같은 민감 정보 deny
+- Spring Security를 활용한 철저한 Auth 검사 및 uri 접근 조정
+- Kakao OAuth2.0을 활용한 인증/인가 기능 간편화
+- docker 네트워크를 활용하여 spring 서버나, prometheus같은 인스턴스 포트 매핑x (Endpoint 단일화)
+
+### 추천 시스템 로직
+
+- Collaborative Filtering & Cosine Similarity 기반 추천시스템
+ 1. 각 Concert는 AI가 추출해준 키워드 값에 대해 0 ~ 1 사이의 값을 가짐
+ 2. 유저도 마찬가지로 온보딩에서 등록한 취향 정보로 부터 모든 키워드 값에 대해 0 ~ 1사이 값을 가짐
+ 3. Concerts, Users CSV파일을 통해서 Cosine Similarity와 Collaborative Filtering을 통한 유사도 계산 후 추천 진행
+
+### 메인 서버
+- 서비스의 주요 로직을 처리하는 서버
+- Grafana와 Prometheus에 기반한 모니터링 시스템 구축
+- Nginx를 통한 리버스 프록시 설정
+
+### AI 서버
+
+- 공연 성격 분석이나, 유저 성격 분석, OCR을 처리하는 서버
+- OCR 및 공연 성격 정보 추출은 NCP의 AI 서비스를 활용
+- 추천 시스템의 경우 직접 Collaborative Filtering Model 구현
+
+### 배치 서버
+
+- KOPIS 시스템으로부터 공연 정보를 주기적으로 업데이트하는 서버(한달에 1번)
+- KOPIS에서 데이터를 받아올때마다 AI서버에 학습 요청
+
+# ⭐️ Frontend
+
+#### 배포 URL https://claco-client.vercel.app/
+* * *
+
+## 💻 Technology
+*  
+*  
+*  
+*   
+
+
+## 🧸 기술 스택 선정 이유
+
+| 기술 스택 | 설명 |
+|-----------|------|
+| React | React는 가장 핵심 요소인 Virtual Dom을 이용하여 불필요한 화면 갱신을 최소화합니다. 이를 통해, 성능 향상을 시킬 수 있으며 빠른 렌더링을 지원합니다. React는 컴포넌트 기반 아키텍처를 채택하고 있으며, UI 요소들을 컴포넌트로 분리하여 개발하고 조합하는 방식으로 구성할 수 있습니다. 따라서, 컴포넌트의 재사용성을 용이하게 하며, 코드 수정 및 유지·보수에 효율적이기에 React를 사용하게 되었습니다. |
+| TypeScript | 정적 타입 언어로서 코드의 안정성을 높이고 협업을 용이하게 하며, 생산성 접근 파일 단계에서 오류를 사전에 발견하여 런타임 오류를 방지할 수 있습니다. 또한, 코드 힌트와 자동 완성을 제공해 개발 생산성을 향상시킬 수 있어 TypeScript를 선정했습니다. |
+| Zustand | 간결하고 직관적인 상태 관리 방식과 최소한의 보일러플레이트로 유연성을 제공하며, 상태 변경 시에만 컴포넌트를 렌더링하여 불필요한 렌더링을 최소화하고 성능 향상에 도움이 됩니다. |
+| Yarn | 빠른 속도와 높은 신뢰성을 바탕으로 안정적인 JavaScript 패키지 관리를 지원하며, 보안성 강화 기능을 제공하여 효율적인 프로젝트 관리가 가능합니다. |
+| TailwindCSS | 개발의 편의성 HTML과 CSS 파일을 별도로 개발 및 관리할 필요가 없기 때문에 개발하기에 편리하고, 팀핑하는 각 태그의 클래스명을 고민할 시간을 절약할 수 있어 빠른 개발이 가능합니다. |
+| shadcn/ui | TailwindCSS와의 긴밀한 통합으로 빠르고 일관된 스타일링이 가능하며, 높은 확장성과 유연한 커스터마이징 기능을 통해 프로젝트 요구사항에 맞는 UI를 효율적으로 구현할 수 있습니다. |
+| Tanstack-Query | 효율적인 데이터 페칭과 관리를 제공하며, 데이터 캐싱 기능을 통해 불필요한 요청을 줄여 성능 최적화와 네트워크 비용 절감이 가능합니다. |
+| Vercel | 깃 저장소와 통합되어 코드 변경 사항을 자동으로 감지하고, 푸시할 때마다 자동 배포를 지원하여 효율적이고 간편하게 웹사이트를 배포할 수 있습니다. |
+| PWA | 웹 기술 기반으로 다양한 플랫폼에서 동작하고, 앱 스토어 없이 홈 화면에 추가할 수 있어 접근성과 편의성이 뛰어나며, 개발 비용 절감과 빠른 배포가 가능합니다. |
+
+## 🗂️ Naming Rules
+* 폴더명 - `PascalCase`
+* 파일명 - `PascalCase`
+* 타입, 유틸함수 등 - `camelCase`
+* 상수 - `UpperCase`
+
+## 📄 Commit Convension
+커밋 메시지는 `태그: 커밋 메시지` 형식으로 작성 (ex. git commit -m "feat: 카카오 로그인 기능 구현")
+
+📌Type
+
+| 태그명 | commit 규칙 |
+|----------|--------------|
+| 🔗 feat | 새로운 기능 개발 |
+| 🛠 fix | UI,UX 및 코드 수정 |
+| 🎨 style | CSS 스타일링 및 퍼블리싱 작업 |
+| 📄 docs | 문서 작업(REANME.md 등) |
+| 📘 test | 배포 테스트, QA 테스팅 관련 |
+| 🧰 refactor | 코드 리팩토링 |
+| 🔧 rename | 폴더 혹은 파일명 변경 |
+| ✂️ remove | 파일 삭제 |
+
+## 🍀 Issue Template
+* `기능 개발 관련 Issue Template`
+ ### 이슈 설명☀️
+ 이슈에 관한 설명
+
+ ### TO-DO📒
+ - [ ] 할 일 1
+
+ ### 기타🍀
+
+* `버그 수정 관련 Issue Template`
+ ### 에러 설명🚦
+ 무슨 에러인지 설명!
+
+ ### 환경⚙️
+ 특정 기기에서만 발생하는 에러라면 디바이스 종류, 브라우저 종류 등!
+
+ ### 재현 방법🧿
+ 어떻게 재현하는지 설명!
+
+ ### 에러 화면📸
+ 스크린샷 or GIF 등..
+
+## 🍀 Pull Request Template
+* 관련 이슈
+* 기존 코드에 영향을 미치는 작업 사항
+* 기존 코드에 영향을 미치지 않는 작업 사항
+* 작업 내용 스크린 샷
+* 리뷰어에게 공유할 내용
+* 추후 작업할 내용
+* main (develop) 브랜치 pull 여부 확인
+
+## 🌊 Git Flow
+| 브랜치 명 | 역할 |
+|----------|--------------|
+| main | 최종 배포될 서비스의 브랜치 |
+| develop | 개발 브랜치, 해당 브랜치에서 분기를 파 작업 후 merge |
+| feature | 기능 개발 브랜치 |
+| hotfix | main 브랜치 배포 후 긴급 수정 사항 발생 시 사용하는 브랜치 |
+
+
+# 🔥 Backend
+
+## 💻 개발 환경
+> Language: ```Java 17```
+> Framework: ```Spring Boot 3.3.4```
+> Database: ```MySQL 8.x```
+> ORM: ```JPA(Hibernate)```
+> CI/CD: ```Github Actions```
+> Cloud Platform: ```AWS(EC2, ALB, ACM), GCP(SQL)```
+> Test DB: ```testcontainer```
+
+## ⚙️ 개발 프로세스
+- ```TDD (테스트 주도 개발)``` : 구문 커버지리 (Statement coverage) 기준 80%를 목표로 수행
+- ```Agile (애자일 프로세스)``` : 1주 단위 스프린트 수행
+- ```Github Flow 전략``` : 초기 개발 과정에서 불필요한 브랜치 관리를 피하고, 빠른 배포를 위한 전략 선택
+- ```CI/CD 파이프라인을 통한 배포 자동화``` : 서비스 개발이 50% 완료된 시점에서 구축하여 배포 자동화
+
+## 💫 TDD 결과
+- Service는 단위 테스트, Repository는 통합 테스트 진행
+- ```testcontainer```를 활용하여 데이터베이스 멱등성 보장
+- 테스트 코드 커버리지 측정 툴: ```IntelliJ```
+
+
+- summary
+ - statement coverage 기준: 88%
+ - branch coverage: 54.8%
+ - class coverage: 100%
+ - method coverage: 96.7%
+
+## 💫 부하 테스트 결과
+- 사용 인스턴스 유형: ```t2.large (ram 8GB)```
+- 부하 테스트 측정 툴: ```Jmeter```
+
+
+- summary
+ - 도메인별 주요 api 평균 50.3 Throughput
+
+
+
+
+
+## 🔄 FlowChart of AI & Batch Server
+
+
+## 📁 ERD
+
+
+- 카테고리에서 연관 관계 설정을 통해 관계형 데이터베이스 활용
+- AI 서비스 학습을 위한 soft delete 활용
+
+## 🛠️ CI/CD pipeline
+
+1. PR 이벤트 발생 시 CI 실행 (테스트 포함)
+2. approve 및 CI 성공 시 merge 가능
+3. merge 이벤트 발생 시 CI 스크립트 수행
+4. CI 스크립트 성공 시 CD 스크립트 수행
+5. Docker 이미지 docker hub에 push
+6. SSH로 AWS EC2 연결
+7. docker hub에서 이미지 pull
+8. dokcer-compose를 활용해 서비스 실행 및 도커 네트워크 구축
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..36891d7
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+cd ../
+mkdir output
+cp -R ./claco-client/* ./output
+cp -R ./output ./claco-client/
\ No newline at end of file
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..6d7f3f9
--- /dev/null
+++ b/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "src/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/index.html b/index.html
index aa695ff..1c6407a 100644
--- a/index.html
+++ b/index.html
@@ -2,9 +2,133 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Claco
+
diff --git a/package.json b/package.json
index 6b4ca0b..6efb722 100644
--- a/package.json
+++ b/package.json
@@ -4,28 +4,51 @@
"version": "0.0.0",
"type": "module",
"scripts": {
- "dev": "vite",
+ "dev": "vite --host",
"build": "tsc -b && vite build",
"lint": "eslint .",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "pwa": "pwa-assets-generator --preset minimal public/icons/logo.svg"
},
"dependencies": {
+ "@radix-ui/react-icons": "^1.3.0",
+ "@radix-ui/react-progress": "^1.1.0",
+ "@radix-ui/react-radio-group": "^1.2.1",
+ "@svgr/rollup": "^8.1.0",
"@tanstack/react-query": "^5.59.0",
+ "@tanstack/react-query-devtools": "^5.61.0",
+ "@types/html2canvas": "^1.0.0",
"@types/node": "^22.7.4",
"autoprefixer": "^10.4.20",
"axios": "^1.7.7",
+ "chart.js": "^4.4.5",
+ "chartjs-plugin-datalabels": "^2.2.0",
+ "class-variance-authority": "^0.7.0",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0",
+ "file-saver": "^2.0.5",
+ "html2canvas": "^1.4.1",
+ "lucide-react": "^0.452.0",
"postcss": "^8.4.47",
"react": "^18.3.1",
+ "react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
+ "react-lottie-player": "^2.1.0",
"react-router-dom": "^6.26.2",
- "recoil": "^0.7.7",
- "tailwindcss": "^3.4.13"
+ "swiper": "^11.1.14",
+ "tailwind-merge": "^2.5.4",
+ "tailwind-scrollbar-hide": "^1.1.7",
+ "tailwindcss": "^3.4.13",
+ "tailwindcss-animate": "^1.0.7",
+ "zustand": "^5.0.1"
},
"devDependencies": {
"@eslint/js": "^9.12.0",
+ "@types/file-saver": "^2.0.7",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/parser": "^8.8.0",
+ "@vite-pwa/assets-generator": "^0.2.6",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.12.0",
"eslint-config-prettier": "^9.1.0",
@@ -37,10 +60,9 @@
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.10.0",
"prettier": "^3.3.3",
- "typescript": "^5.5.3",
+ "typescript": "~5.4.2",
"typescript-eslint": "^8.8.0",
"vite": "^5.4.8",
- "vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.0.1"
}
}
diff --git a/public/assets/MagnifierBackground.png b/public/assets/MagnifierBackground.png
new file mode 100644
index 0000000..21017eb
Binary files /dev/null and b/public/assets/MagnifierBackground.png differ
diff --git a/public/icons/apple-touch-icon-114x114.png b/public/icons/apple-touch-icon-114x114.png
new file mode 100644
index 0000000..85c3960
Binary files /dev/null and b/public/icons/apple-touch-icon-114x114.png differ
diff --git a/public/icons/apple-touch-icon-152x152.png b/public/icons/apple-touch-icon-152x152.png
new file mode 100644
index 0000000..3256498
Binary files /dev/null and b/public/icons/apple-touch-icon-152x152.png differ
diff --git a/public/icons/apple-touch-icon-180x180.png b/public/icons/apple-touch-icon-180x180.png
new file mode 100644
index 0000000..9dee08d
Binary files /dev/null and b/public/icons/apple-touch-icon-180x180.png differ
diff --git a/public/icons/apple-touch-icon-60x60.png b/public/icons/apple-touch-icon-60x60.png
new file mode 100644
index 0000000..a57ff67
Binary files /dev/null and b/public/icons/apple-touch-icon-60x60.png differ
diff --git a/public/icons/favicon-16x16.png b/public/icons/favicon-16x16.png
new file mode 100644
index 0000000..e2ae6e7
Binary files /dev/null and b/public/icons/favicon-16x16.png differ
diff --git a/public/icons/favicon-196x196.png b/public/icons/favicon-196x196.png
new file mode 100644
index 0000000..4595616
Binary files /dev/null and b/public/icons/favicon-196x196.png differ
diff --git a/public/icons/favicon-32x32.png b/public/icons/favicon-32x32.png
new file mode 100644
index 0000000..cc1205e
Binary files /dev/null and b/public/icons/favicon-32x32.png differ
diff --git a/public/icons/favicon-96x96.png b/public/icons/favicon-96x96.png
new file mode 100644
index 0000000..5d29d68
Binary files /dev/null and b/public/icons/favicon-96x96.png differ
diff --git a/public/icons/favicon.ico b/public/icons/favicon.ico
new file mode 100644
index 0000000..9e5549b
Binary files /dev/null and b/public/icons/favicon.ico differ
diff --git a/public/icons/logo.svg b/public/icons/logo.svg
new file mode 100644
index 0000000..3acaae7
--- /dev/null
+++ b/public/icons/logo.svg
@@ -0,0 +1,60 @@
+
+
+
+
diff --git a/public/icons/maskable-icon-512x512.png b/public/icons/maskable-icon-512x512.png
new file mode 100644
index 0000000..a8d5b11
Binary files /dev/null and b/public/icons/maskable-icon-512x512.png differ
diff --git a/public/icons/mstile-150x150.png b/public/icons/mstile-150x150.png
new file mode 100644
index 0000000..66e45b6
Binary files /dev/null and b/public/icons/mstile-150x150.png differ
diff --git a/public/icons/mstile-310x310.png b/public/icons/mstile-310x310.png
new file mode 100644
index 0000000..9e5d09c
Binary files /dev/null and b/public/icons/mstile-310x310.png differ
diff --git a/public/icons/mstile-70x70.png b/public/icons/mstile-70x70.png
new file mode 100644
index 0000000..6debde0
Binary files /dev/null and b/public/icons/mstile-70x70.png differ
diff --git a/public/icons/pwa-192x192.png b/public/icons/pwa-192x192.png
new file mode 100644
index 0000000..e619df1
Binary files /dev/null and b/public/icons/pwa-192x192.png differ
diff --git a/public/icons/pwa-512x512.png b/public/icons/pwa-512x512.png
new file mode 100644
index 0000000..c416993
Binary files /dev/null and b/public/icons/pwa-512x512.png differ
diff --git a/public/icons/pwa-64x64.png b/public/icons/pwa-64x64.png
new file mode 100644
index 0000000..725185c
Binary files /dev/null and b/public/icons/pwa-64x64.png differ
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..c2f2903
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,31 @@
+{
+ "name": "Claco",
+ "short_name": "Claco",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#1C1C1C",
+ "theme_color": "#1C1C1C",
+ "icons": [
+ {
+ "src": "icons/pwa-64x64.png",
+ "sizes": "64x64",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/pwa-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/pwa-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/maskable-icon-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png b/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png
new file mode 100644
index 0000000..fcfd9db
Binary files /dev/null and b/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png differ
diff --git a/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png b/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png
new file mode 100644
index 0000000..44fd001
Binary files /dev/null and b/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png differ
diff --git a/public/splash_screens/iPhone_11__iPhone_XR_portrait.png b/public/splash_screens/iPhone_11__iPhone_XR_portrait.png
new file mode 100644
index 0000000..56ac6a8
Binary files /dev/null and b/public/splash_screens/iPhone_11__iPhone_XR_portrait.png differ
diff --git a/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png b/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png
new file mode 100644
index 0000000..e07d487
Binary files /dev/null and b/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png differ
diff --git a/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png b/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png
new file mode 100644
index 0000000..95fc44e
Binary files /dev/null and b/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png differ
diff --git a/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png b/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png
new file mode 100644
index 0000000..48cb7db
Binary files /dev/null and b/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png differ
diff --git a/public/splash_screens/iPhone_16_Plus__iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png b/public/splash_screens/iPhone_16_Plus__iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png
new file mode 100644
index 0000000..50ebea2
Binary files /dev/null and b/public/splash_screens/iPhone_16_Plus__iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png differ
diff --git a/public/splash_screens/iPhone_16_Pro_Max_portrait.png b/public/splash_screens/iPhone_16_Pro_Max_portrait.png
new file mode 100644
index 0000000..d6f6350
Binary files /dev/null and b/public/splash_screens/iPhone_16_Pro_Max_portrait.png differ
diff --git a/public/splash_screens/iPhone_16_Pro_portrait.png b/public/splash_screens/iPhone_16_Pro_portrait.png
new file mode 100644
index 0000000..9e80caa
Binary files /dev/null and b/public/splash_screens/iPhone_16_Pro_portrait.png differ
diff --git a/public/splash_screens/iPhone_16__iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png b/public/splash_screens/iPhone_16__iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png
new file mode 100644
index 0000000..fd8d275
Binary files /dev/null and b/public/splash_screens/iPhone_16__iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png differ
diff --git a/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png b/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png
new file mode 100644
index 0000000..c9d9e9c
Binary files /dev/null and b/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png differ
diff --git a/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png b/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png
new file mode 100644
index 0000000..72a4f53
Binary files /dev/null and b/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png differ
diff --git a/public/vite.svg b/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 0d63c3e..4cb8a29 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,11 +1,11 @@
-import { RouterProvider } from 'react-router-dom';
-import AppRegister from '@/libraries/index';
-import Router from '@/routes/router';
+import { RouterProvider } from "react-router-dom";
+import Router from "@/routes/router";
+import AppContainer from "@/libraries/index";
export default function App() {
return (
-
+
-
+
);
}
diff --git a/src/apis/client.ts b/src/apis/client.ts
new file mode 100644
index 0000000..8e37085
--- /dev/null
+++ b/src/apis/client.ts
@@ -0,0 +1,53 @@
+import axios, { InternalAxiosRequestConfig } from "axios";
+
+const client = axios.create({
+ baseURL: import.meta.env.VITE_SERVER_URL,
+ withCredentials: true,
+ headers: {
+ "Content-Type": "application/json",
+ },
+});
+
+client.interceptors.response.use(
+ (res) => {
+ if (res.data.refreshed) {
+ const new_accessToken = res.headers["authorization"];
+ localStorage.setItem("accessToken", new_accessToken);
+ window.location.replace("/main");
+ }
+
+ // /* 해당 에러 발생 시 재로그인 하도록 로그인 화면으로 리다이렉트 */
+ if (
+ res.data.code === "ACT-001" ||
+ res.data.code === "RFT-001" ||
+ res.data.code === "MSE-001" ||
+ res.data.code === "ATH-001"
+ ) {
+ localStorage.clear();
+ window.location.replace("/");
+ }
+ return res;
+ },
+ async (error) => {
+ console.error(error);
+ return Promise.reject(error);
+ },
+);
+
+client.interceptors.request.use(
+ (config: InternalAxiosRequestConfig) => {
+ config.withCredentials = true;
+ const accessToken = localStorage.getItem("accessToken");
+ if (accessToken) {
+ config.headers = config.headers || {};
+ config.headers.Authorization = `${accessToken}`;
+ } else {
+ return config;
+ }
+ return config;
+ },
+ (error) => {
+ return Promise.reject(error);
+ },
+);
+export default client;
diff --git a/src/apis/index.ts b/src/apis/index.ts
new file mode 100644
index 0000000..6742b42
--- /dev/null
+++ b/src/apis/index.ts
@@ -0,0 +1,5 @@
+export { default as client } from "./client";
+export { default as nickNameCheck } from "./nickNameCheck";
+export { default as submitUserInformation } from "./submitUserInformation";
+export { default as getPlaceCategories} from "./useGetPlaceCategories";
+export { default as getTagCategories } from "./useGetTagCategories";
\ No newline at end of file
diff --git a/src/apis/nickNameCheck.ts b/src/apis/nickNameCheck.ts
new file mode 100644
index 0000000..8578ccd
--- /dev/null
+++ b/src/apis/nickNameCheck.ts
@@ -0,0 +1,15 @@
+import client from "@/apis/client";
+
+export type NickNameCheckResponse = {
+ code: string;
+ message: string;
+};
+
+const nickNameCheck = async (nickName: string) => {
+ const response = await client.get(
+ `/members/check-nickname?nickname=${nickName}`
+ );
+ return response.data;
+};
+
+export default nickNameCheck;
diff --git a/src/apis/submitUserInformation.ts b/src/apis/submitUserInformation.ts
new file mode 100644
index 0000000..0107863
--- /dev/null
+++ b/src/apis/submitUserInformation.ts
@@ -0,0 +1,36 @@
+import client from "./client";
+
+export type UserInformationResponse = {
+ code: string;
+ message: string;
+};
+
+export type UserInformationRequest = {
+ nickname: string;
+ gender: string;
+ age: number;
+ minPrice: number;
+ maxPrice: number;
+ regionPreferences: { preferenceRegion: string }[];
+ typePreferences: { preferenceType: string }[];
+ categoryPreferences: { preferenceCategory: string }[];
+};
+
+const submitUserInformation = (userInfo: UserInformationRequest) => {
+ return client.post(`/members`, userInfo);
+};
+
+// const useUserInformationSubmit = async (userInfo: UserInformationRequest) => {
+// try {
+// const response = await client.post(
+// `/members`,
+// userInfo
+// );
+// return response.data;
+// } catch (error) {
+// console.error(error);
+// throw error;
+// }
+// };
+
+export default submitUserInformation;
diff --git a/src/apis/useGetPlaceCategories.ts b/src/apis/useGetPlaceCategories.ts
new file mode 100644
index 0000000..771aa2a
--- /dev/null
+++ b/src/apis/useGetPlaceCategories.ts
@@ -0,0 +1,10 @@
+import { client } from "@/apis";
+import { PlaceCategoriesResponse } from "@/types";
+
+const getPlaceCategories = async (): Promise => {
+ const response =
+ await client.get("/place-categories");
+ return response.data;
+};
+
+export default getPlaceCategories;
diff --git a/src/apis/useGetShowPosterUrl.ts b/src/apis/useGetShowPosterUrl.ts
new file mode 100644
index 0000000..90bbb02
--- /dev/null
+++ b/src/apis/useGetShowPosterUrl.ts
@@ -0,0 +1,24 @@
+import client from "./client";
+
+export type ShowPosterUrlResponse = {
+ code: string;
+ message: string;
+ result: string;
+ refreshed: boolean;
+};
+
+const useGetShowPoster = async (
+ KopisURL: string,
+): Promise => {
+ try {
+ const response = await client.get(
+ `/concerts/posters?KopisURL=${encodeURIComponent(KopisURL)}`,
+ );
+ return response.data;
+ } catch (error) {
+ console.error(error);
+ throw error;
+ }
+};
+
+export default useGetShowPoster;
diff --git a/src/apis/useGetTagCategories.ts b/src/apis/useGetTagCategories.ts
new file mode 100644
index 0000000..7a132ef
--- /dev/null
+++ b/src/apis/useGetTagCategories.ts
@@ -0,0 +1,9 @@
+import { client } from "@/apis";
+import { TagCategoriesResponse } from "@/types";
+
+const getTagCategories = async (): Promise => {
+ const response = await client.get("/tag-categories");
+ return response.data;
+};
+
+export default getTagCategories;
diff --git a/src/assets/fonts/Nonchalance-Medium.woff b/src/assets/fonts/Nonchalance-Medium.woff
new file mode 100644
index 0000000..63351aa
Binary files /dev/null and b/src/assets/fonts/Nonchalance-Medium.woff differ
diff --git a/src/assets/fonts/Nonchalance-Medium.woff2 b/src/assets/fonts/Nonchalance-Medium.woff2
new file mode 100644
index 0000000..8faa648
Binary files /dev/null and b/src/assets/fonts/Nonchalance-Medium.woff2 differ
diff --git a/src/assets/fonts/Pretendard-Bold.subset.woff b/src/assets/fonts/Pretendard-Bold.subset.woff
new file mode 100644
index 0000000..06ba102
Binary files /dev/null and b/src/assets/fonts/Pretendard-Bold.subset.woff differ
diff --git a/src/assets/fonts/Pretendard-Bold.subset.woff2 b/src/assets/fonts/Pretendard-Bold.subset.woff2
new file mode 100644
index 0000000..f80bdb7
Binary files /dev/null and b/src/assets/fonts/Pretendard-Bold.subset.woff2 differ
diff --git a/src/assets/fonts/Pretendard-Medium.subset.woff b/src/assets/fonts/Pretendard-Medium.subset.woff
new file mode 100644
index 0000000..f97a78f
Binary files /dev/null and b/src/assets/fonts/Pretendard-Medium.subset.woff differ
diff --git a/src/assets/fonts/Pretendard-Medium.subset.woff2 b/src/assets/fonts/Pretendard-Medium.subset.woff2
new file mode 100644
index 0000000..9419980
Binary files /dev/null and b/src/assets/fonts/Pretendard-Medium.subset.woff2 differ
diff --git a/src/assets/fonts/Pretendard-Regular.subset.woff b/src/assets/fonts/Pretendard-Regular.subset.woff
new file mode 100644
index 0000000..174736a
Binary files /dev/null and b/src/assets/fonts/Pretendard-Regular.subset.woff differ
diff --git a/src/assets/fonts/Pretendard-Regular.subset.woff2 b/src/assets/fonts/Pretendard-Regular.subset.woff2
new file mode 100644
index 0000000..6fc8ec4
Binary files /dev/null and b/src/assets/fonts/Pretendard-Regular.subset.woff2 differ
diff --git a/src/assets/fonts/Pretendard-SemiBold.subset.woff b/src/assets/fonts/Pretendard-SemiBold.subset.woff
new file mode 100644
index 0000000..ee2fa3d
Binary files /dev/null and b/src/assets/fonts/Pretendard-SemiBold.subset.woff differ
diff --git a/src/assets/fonts/Pretendard-SemiBold.subset.woff2 b/src/assets/fonts/Pretendard-SemiBold.subset.woff2
new file mode 100644
index 0000000..38175ff
Binary files /dev/null and b/src/assets/fonts/Pretendard-SemiBold.subset.woff2 differ
diff --git a/src/assets/images/ClacoBook/ClcaoBook_Gray.png b/src/assets/images/ClacoBook/ClcaoBook_Gray.png
new file mode 100644
index 0000000..5208a31
Binary files /dev/null and b/src/assets/images/ClacoBook/ClcaoBook_Gray.png differ
diff --git a/src/assets/images/ClacoBook/ClcaoBook_Orange.png b/src/assets/images/ClacoBook/ClcaoBook_Orange.png
new file mode 100644
index 0000000..667c193
Binary files /dev/null and b/src/assets/images/ClacoBook/ClcaoBook_Orange.png differ
diff --git a/src/assets/images/ClacoBook/ClcaoBook_Pink.png b/src/assets/images/ClacoBook/ClcaoBook_Pink.png
new file mode 100644
index 0000000..e4b9d26
Binary files /dev/null and b/src/assets/images/ClacoBook/ClcaoBook_Pink.png differ
diff --git a/src/assets/images/Genre/classical.png b/src/assets/images/Genre/classical.png
new file mode 100644
index 0000000..a891d3a
Binary files /dev/null and b/src/assets/images/Genre/classical.png differ
diff --git a/src/assets/images/Genre/delicate.png b/src/assets/images/Genre/delicate.png
new file mode 100644
index 0000000..26cf149
Binary files /dev/null and b/src/assets/images/Genre/delicate.png differ
diff --git a/src/assets/images/Genre/dynamic.png b/src/assets/images/Genre/dynamic.png
new file mode 100644
index 0000000..ef3e9ff
Binary files /dev/null and b/src/assets/images/Genre/dynamic.png differ
diff --git a/src/assets/images/Genre/familiar.png b/src/assets/images/Genre/familiar.png
new file mode 100644
index 0000000..e8a1893
Binary files /dev/null and b/src/assets/images/Genre/familiar.png differ
diff --git a/src/assets/images/Genre/grand.png b/src/assets/images/Genre/grand.png
new file mode 100644
index 0000000..4680c64
Binary files /dev/null and b/src/assets/images/Genre/grand.png differ
diff --git a/src/assets/images/Genre/lyrical.png b/src/assets/images/Genre/lyrical.png
new file mode 100644
index 0000000..a9fa9bb
Binary files /dev/null and b/src/assets/images/Genre/lyrical.png differ
diff --git a/src/assets/images/Genre/modern.png b/src/assets/images/Genre/modern.png
new file mode 100644
index 0000000..e714efe
Binary files /dev/null and b/src/assets/images/Genre/modern.png differ
diff --git a/src/assets/images/Genre/novel.png b/src/assets/images/Genre/novel.png
new file mode 100644
index 0000000..c0cc723
Binary files /dev/null and b/src/assets/images/Genre/novel.png differ
diff --git a/src/assets/images/Genre/romantic.png b/src/assets/images/Genre/romantic.png
new file mode 100644
index 0000000..3a930cd
Binary files /dev/null and b/src/assets/images/Genre/romantic.png differ
diff --git a/src/assets/images/Genre/tragic.png b/src/assets/images/Genre/tragic.png
new file mode 100644
index 0000000..e6036f8
Binary files /dev/null and b/src/assets/images/Genre/tragic.png differ
diff --git a/src/assets/images/kakao.png b/src/assets/images/kakao.png
new file mode 100644
index 0000000..5afd83a
Binary files /dev/null and b/src/assets/images/kakao.png differ
diff --git a/src/assets/images/loading.gif b/src/assets/images/loading.gif
new file mode 100644
index 0000000..c9d73bb
Binary files /dev/null and b/src/assets/images/loading.gif differ
diff --git a/src/assets/images/loginbackground.png b/src/assets/images/loginbackground.png
new file mode 100644
index 0000000..c1971d6
Binary files /dev/null and b/src/assets/images/loginbackground.png differ
diff --git a/src/assets/images/logo.png b/src/assets/images/logo.png
new file mode 100644
index 0000000..9342216
Binary files /dev/null and b/src/assets/images/logo.png differ
diff --git a/src/assets/images/showReview.png b/src/assets/images/showReview.png
new file mode 100644
index 0000000..9fd059a
Binary files /dev/null and b/src/assets/images/showReview.png differ
diff --git a/src/assets/lotties/onboarding-loading.json b/src/assets/lotties/onboarding-loading.json
new file mode 100644
index 0000000..660a90d
--- /dev/null
+++ b/src/assets/lotties/onboarding-loading.json
@@ -0,0 +1,2104 @@
+{
+ "assets": [
+ { "h": 339, "id": "0", "p": "MagnifierBackground.png", "u": "https://claco-client.vercel.app/assets/", "w": 344, "e": 0 },
+ { "h": 170, "id": "1", "p": "delicate-COgzgCmJ.png", "u": "https://claco-client.vercel.app/assets/", "w": 170, "e": 0 },
+ { "h": 170, "id": "2", "p": "dynamic-DyHv12pT.png", "u": "https://claco-client.vercel.app/assets/", "w": 170, "e": 0 },
+ { "h": 170, "id": "3", "p": "familiar-Dg6qKd33.png", "u": "https://claco-client.vercel.app/assets/", "w": 168, "e": 0 },
+ { "h": 170, "id": "4", "p": "grand-BYiR4a-o.png", "u": "https://claco-client.vercel.app/assets/", "w": 170, "e": 0 },
+ { "h": 170, "id": "5", "p": "lyrical-BgxdJF4r.png", "u": "https://claco-client.vercel.app/assets/", "w": 170, "e": 0 },
+ { "h": 170, "id": "6", "p": "modern-CpuftXSH.png", "u": "https://claco-client.vercel.app/assets/", "w": 170, "e": 0 },
+ { "h": 170, "id": "7", "p": "novel-DIk8ha5_.png", "u": "https://claco-client.vercel.app/assets/", "w": 168, "e": 0 },
+ { "h": 170, "id": "8", "p": "romantic-o0K20Lyg.png", "u": "https://claco-client.vercel.app/assets/", "w": 170, "e": 0 },
+ { "h": 170, "id": "9", "p": "tragic-5xq7Nqci.png", "u": "https://claco-client.vercel.app/assets/", "w": 170, "e": 0 },
+ { "h": 170, "id": "10", "p": "classical-BXnO5iTb.png", "u": "https://claco-client.vercel.app/assets/", "w": 170, "e": 0 },
+ {
+ "id": "17",
+ "layers": [
+ {
+ "ind": 16,
+ "ty": 4,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "shapes": [
+ {
+ "ty": "rc",
+ "p": { "a": 0, "k": [33, 31.5] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [66, 63] }
+ },
+ {
+ "ty": "fl",
+ "c": { "a": 0, "k": [0, 0, 0, 0] },
+ "o": { "a": 0, "k": 0 }
+ }
+ ]
+ },
+ {
+ "ind": 0,
+ "ty": 4,
+ "ks": { "s": { "a": 0, "k": [133.33, 133.33] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "ty": "sh",
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": false,
+ "i": [
+ [0, 0],
+ [0, 0]
+ ],
+ "o": [
+ [0, 0],
+ [0, 0]
+ ],
+ "v": [
+ [60.73, 58],
+ [5.47, 5.9]
+ ]
+ }
+ }
+ },
+ {
+ "ty": "st",
+ "c": { "a": 0, "k": [0.98, 0.82, 0.76, 1] },
+ "lc": 2,
+ "lj": 2,
+ "ml": 4,
+ "o": { "a": 0, "k": 100 },
+ "w": { "a": 0, "k": 10 }
+ },
+ {
+ "ty": "tr",
+ "o": { "a": 0, "k": 100 },
+ "s": { "a": 0, "k": [75, 75] }
+ }
+ ]
+ },
+ { "ty": "tr", "o": { "a": 0, "k": 100 } }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "23",
+ "layers": [
+ {
+ "ind": 22,
+ "ty": 4,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "shapes": [
+ {
+ "ty": "rc",
+ "p": { "a": 0, "k": [80.5, 78.5] },
+ "r": { "a": 0, "k": 0 },
+ "s": { "a": 0, "k": [161, 157] }
+ },
+ {
+ "ty": "fl",
+ "c": { "a": 0, "k": [0, 0, 0, 0] },
+ "o": { "a": 0, "k": 0 }
+ }
+ ]
+ },
+ {
+ "ind": 0,
+ "ty": 4,
+ "ks": { "s": { "a": 0, "k": [133.33, 133.33] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "shapes": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "ty": "gr",
+ "it": [
+ {
+ "ty": "sh",
+ "ks": {
+ "a": 0,
+ "k": {
+ "c": true,
+ "i": [
+ [0, 0],
+ [0, 40.55],
+ [41.42, 0],
+ [0, -40.55],
+ [-41.42, 0]
+ ],
+ "o": [
+ [41.42, 0],
+ [0, -40.55],
+ [-41.42, 0],
+ [0, 40.55],
+ [0, 0]
+ ],
+ "v": [
+ [80.58, 151.84],
+ [155.58, 78.42],
+ [80.58, 5],
+ [5.58, 78.42],
+ [80.58, 151.84]
+ ]
+ }
+ }
+ },
+ {
+ "ty": "st",
+ "c": { "a": 0, "k": [0.98, 0.82, 0.76, 1] },
+ "lc": 2,
+ "lj": 2,
+ "ml": 4,
+ "o": { "a": 0, "k": 100 },
+ "w": { "a": 0, "k": 10 }
+ },
+ {
+ "ty": "tr",
+ "o": { "a": 0, "k": 100 },
+ "s": { "a": 0, "k": [75, 75] }
+ }
+ ]
+ },
+ { "ty": "tr", "o": { "a": 0, "k": 100 } }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "34",
+ "layers": [
+ {
+ "ind": 33,
+ "ty": 2,
+ "parent": 32,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "1"
+ },
+ {
+ "ind": 32,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "40",
+ "layers": [
+ {
+ "ind": 39,
+ "ty": 2,
+ "parent": 38,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "2"
+ },
+ {
+ "ind": 38,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "46",
+ "layers": [
+ {
+ "ind": 45,
+ "ty": 2,
+ "parent": 44,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "3"
+ },
+ {
+ "ind": 44,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "52",
+ "layers": [
+ {
+ "ind": 51,
+ "ty": 2,
+ "parent": 50,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "4"
+ },
+ {
+ "ind": 50,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "58",
+ "layers": [
+ {
+ "ind": 57,
+ "ty": 2,
+ "parent": 56,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "5"
+ },
+ {
+ "ind": 56,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "64",
+ "layers": [
+ {
+ "ind": 63,
+ "ty": 2,
+ "parent": 62,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "6"
+ },
+ {
+ "ind": 62,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "70",
+ "layers": [
+ {
+ "ind": 69,
+ "ty": 2,
+ "parent": 68,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "7"
+ },
+ {
+ "ind": 68,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "76",
+ "layers": [
+ {
+ "ind": 75,
+ "ty": 2,
+ "parent": 74,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "8"
+ },
+ {
+ "ind": 74,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "82",
+ "layers": [
+ {
+ "ind": 81,
+ "ty": 2,
+ "parent": 80,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "9"
+ },
+ {
+ "ind": 80,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "88",
+ "layers": [
+ {
+ "ind": 87,
+ "ty": 2,
+ "parent": 86,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "10"
+ },
+ {
+ "ind": 86,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "95",
+ "layers": [
+ {
+ "ind": 94,
+ "ty": 2,
+ "parent": 93,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "1"
+ },
+ {
+ "ind": 93,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "101",
+ "layers": [
+ {
+ "ind": 100,
+ "ty": 2,
+ "parent": 99,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "2"
+ },
+ {
+ "ind": 99,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "107",
+ "layers": [
+ {
+ "ind": 106,
+ "ty": 2,
+ "parent": 105,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "3"
+ },
+ {
+ "ind": 105,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "113",
+ "layers": [
+ {
+ "ind": 112,
+ "ty": 2,
+ "parent": 111,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "4"
+ },
+ {
+ "ind": 111,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "119",
+ "layers": [
+ {
+ "ind": 118,
+ "ty": 2,
+ "parent": 117,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "5"
+ },
+ {
+ "ind": 117,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "125",
+ "layers": [
+ {
+ "ind": 124,
+ "ty": 2,
+ "parent": 123,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "6"
+ },
+ {
+ "ind": 123,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "131",
+ "layers": [
+ {
+ "ind": 130,
+ "ty": 2,
+ "parent": 129,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "7"
+ },
+ {
+ "ind": 129,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "137",
+ "layers": [
+ {
+ "ind": 136,
+ "ty": 2,
+ "parent": 135,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "8"
+ },
+ {
+ "ind": 135,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "143",
+ "layers": [
+ {
+ "ind": 142,
+ "ty": 2,
+ "parent": 141,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "9"
+ },
+ {
+ "ind": 141,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "149",
+ "layers": [
+ {
+ "ind": 148,
+ "ty": 2,
+ "parent": 147,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "10"
+ },
+ {
+ "ind": 147,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "157",
+ "layers": [
+ {
+ "ind": 156,
+ "ty": 2,
+ "parent": 155,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "1"
+ },
+ {
+ "ind": 155,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "163",
+ "layers": [
+ {
+ "ind": 162,
+ "ty": 2,
+ "parent": 161,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "2"
+ },
+ {
+ "ind": 161,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "169",
+ "layers": [
+ {
+ "ind": 168,
+ "ty": 2,
+ "parent": 167,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "3"
+ },
+ {
+ "ind": 167,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "175",
+ "layers": [
+ {
+ "ind": 174,
+ "ty": 2,
+ "parent": 173,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "4"
+ },
+ {
+ "ind": 173,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "181",
+ "layers": [
+ {
+ "ind": 180,
+ "ty": 2,
+ "parent": 179,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "5"
+ },
+ {
+ "ind": 179,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "187",
+ "layers": [
+ {
+ "ind": 186,
+ "ty": 2,
+ "parent": 185,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "6"
+ },
+ {
+ "ind": 185,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "193",
+ "layers": [
+ {
+ "ind": 192,
+ "ty": 2,
+ "parent": 191,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "7"
+ },
+ {
+ "ind": 191,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "199",
+ "layers": [
+ {
+ "ind": 198,
+ "ty": 2,
+ "parent": 197,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "8"
+ },
+ {
+ "ind": 197,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "205",
+ "layers": [
+ {
+ "ind": 204,
+ "ty": 2,
+ "parent": 203,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "9"
+ },
+ {
+ "ind": 203,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "211",
+ "layers": [
+ {
+ "ind": 210,
+ "ty": 2,
+ "parent": 209,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "10"
+ },
+ {
+ "ind": 209,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "218",
+ "layers": [
+ {
+ "ind": 217,
+ "ty": 2,
+ "parent": 216,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "1"
+ },
+ {
+ "ind": 216,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "224",
+ "layers": [
+ {
+ "ind": 223,
+ "ty": 2,
+ "parent": 222,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "2"
+ },
+ {
+ "ind": 222,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "230",
+ "layers": [
+ {
+ "ind": 229,
+ "ty": 2,
+ "parent": 228,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "3"
+ },
+ {
+ "ind": 228,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "236",
+ "layers": [
+ {
+ "ind": 235,
+ "ty": 2,
+ "parent": 234,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "4"
+ },
+ {
+ "ind": 234,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "242",
+ "layers": [
+ {
+ "ind": 241,
+ "ty": 2,
+ "parent": 240,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "5"
+ },
+ {
+ "ind": 240,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "248",
+ "layers": [
+ {
+ "ind": 247,
+ "ty": 2,
+ "parent": 246,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "6"
+ },
+ {
+ "ind": 246,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "254",
+ "layers": [
+ {
+ "ind": 253,
+ "ty": 2,
+ "parent": 252,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "7"
+ },
+ {
+ "ind": 252,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "260",
+ "layers": [
+ {
+ "ind": 259,
+ "ty": 2,
+ "parent": 258,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "8"
+ },
+ {
+ "ind": 258,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "266",
+ "layers": [
+ {
+ "ind": 265,
+ "ty": 2,
+ "parent": 264,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "9"
+ },
+ {
+ "ind": 264,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ },
+ {
+ "id": "272",
+ "layers": [
+ {
+ "ind": 271,
+ "ty": 2,
+ "parent": 270,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "10"
+ },
+ {
+ "ind": 270,
+ "ty": 3,
+ "ks": { "s": { "a": 0, "k": [50, 50] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ }
+ ]
+ }
+ ],
+ "fr": 60,
+ "h": 844,
+ "ip": 0,
+ "layers": [
+ {
+ "ind": 19,
+ "ty": 0,
+ "parent": 15,
+ "ks": {},
+ "w": 66,
+ "h": 63,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "17"
+ },
+ {
+ "ind": 15,
+ "ty": 3,
+ "parent": 14,
+ "ks": { "s": { "a": 0, "k": [76.32, 76.67] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 14,
+ "ty": 3,
+ "parent": 13,
+ "ks": { "p": { "a": 0, "k": [94.643, 93.533] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 25,
+ "ty": 0,
+ "parent": 21,
+ "ks": {},
+ "w": 161,
+ "h": 157,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "23"
+ },
+ {
+ "ind": 21,
+ "ty": 3,
+ "parent": 20,
+ "ks": { "s": { "a": 0, "k": [76.32, 76.67] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 20,
+ "ty": 3,
+ "parent": 13,
+ "ks": { "p": { "a": 0, "k": [-3.053, -3.833] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 28,
+ "ty": 2,
+ "parent": 27,
+ "ks": { "s": { "a": 0, "k": [43.6, 43.36] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "0"
+ },
+ {
+ "ind": 27,
+ "ty": 3,
+ "parent": 26,
+ "ks": { "s": { "a": 0, "k": [76.32, 76.67] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ { "ind": 26, "ty": 3, "parent": 13, "ks": {}, "ip": 0, "op": 481, "st": 0 },
+ {
+ "ind": 13,
+ "ty": 3,
+ "parent": 12,
+ "ks": { "p": { "a": 0, "k": [125, 387] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 36,
+ "ty": 0,
+ "parent": 31,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "34"
+ },
+ { "ind": 31, "ty": 3, "parent": 30, "ks": {}, "ip": 0, "op": 481, "st": 0 },
+ {
+ "ind": 42,
+ "ty": 0,
+ "parent": 37,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "40"
+ },
+ {
+ "ind": 37,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [105, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 48,
+ "ty": 0,
+ "parent": 43,
+ "ks": {},
+ "w": 84,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "46"
+ },
+ {
+ "ind": 43,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [944, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 54,
+ "ty": 0,
+ "parent": 49,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "52"
+ },
+ {
+ "ind": 49,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [210, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 60,
+ "ty": 0,
+ "parent": 55,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "58"
+ },
+ {
+ "ind": 55,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [315, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 66,
+ "ty": 0,
+ "parent": 61,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "64"
+ },
+ {
+ "ind": 61,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [420, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 72,
+ "ty": 0,
+ "parent": 67,
+ "ks": {},
+ "w": 84,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "70"
+ },
+ {
+ "ind": 67,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [840, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 78,
+ "ty": 0,
+ "parent": 73,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "76"
+ },
+ {
+ "ind": 73,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [735, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 84,
+ "ty": 0,
+ "parent": 79,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "82"
+ },
+ {
+ "ind": 79,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [630, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 90,
+ "ty": 0,
+ "parent": 85,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "88"
+ },
+ {
+ "ind": 85,
+ "ty": 3,
+ "parent": 30,
+ "ks": { "p": { "a": 0, "k": [525, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 30,
+ "ty": 3,
+ "parent": 29,
+ "ks": { "p": { "a": 0, "k": [1048, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 97,
+ "ty": 0,
+ "parent": 92,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "95"
+ },
+ { "ind": 92, "ty": 3, "parent": 91, "ks": {}, "ip": 0, "op": 481, "st": 0 },
+ {
+ "ind": 103,
+ "ty": 0,
+ "parent": 98,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "101"
+ },
+ {
+ "ind": 98,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [105, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 109,
+ "ty": 0,
+ "parent": 104,
+ "ks": {},
+ "w": 84,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "107"
+ },
+ {
+ "ind": 104,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [944, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 115,
+ "ty": 0,
+ "parent": 110,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "113"
+ },
+ {
+ "ind": 110,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [210, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 121,
+ "ty": 0,
+ "parent": 116,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "119"
+ },
+ {
+ "ind": 116,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [315, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 127,
+ "ty": 0,
+ "parent": 122,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "125"
+ },
+ {
+ "ind": 122,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [420, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 133,
+ "ty": 0,
+ "parent": 128,
+ "ks": {},
+ "w": 84,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "131"
+ },
+ {
+ "ind": 128,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [840, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 139,
+ "ty": 0,
+ "parent": 134,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "137"
+ },
+ {
+ "ind": 134,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [735, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 145,
+ "ty": 0,
+ "parent": 140,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "143"
+ },
+ {
+ "ind": 140,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [630, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 151,
+ "ty": 0,
+ "parent": 146,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "149"
+ },
+ {
+ "ind": 146,
+ "ty": 3,
+ "parent": 91,
+ "ks": { "p": { "a": 0, "k": [525, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ { "ind": 91, "ty": 3, "parent": 29, "ks": {}, "ip": 0, "op": 481, "st": 0 },
+ {
+ "ind": 29,
+ "ty": 3,
+ "parent": 12,
+ "ks": {
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "t": 0,
+ "s": [-1723, 456],
+ "i": { "x": [0, 1], "y": [1, 1] },
+ "o": { "x": [0.5, 0], "y": [0, 0] }
+ },
+ { "t": 480, "s": [20, 456], "h": 1 }
+ ]
+ }
+ },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 159,
+ "ty": 0,
+ "parent": 154,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "157"
+ },
+ {
+ "ind": 154,
+ "ty": 3,
+ "parent": 153,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 165,
+ "ty": 0,
+ "parent": 160,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "163"
+ },
+ {
+ "ind": 160,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [105, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 171,
+ "ty": 0,
+ "parent": 166,
+ "ks": {},
+ "w": 84,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "169"
+ },
+ {
+ "ind": 166,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [944, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 177,
+ "ty": 0,
+ "parent": 172,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "175"
+ },
+ {
+ "ind": 172,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [210, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 183,
+ "ty": 0,
+ "parent": 178,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "181"
+ },
+ {
+ "ind": 178,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [315, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 189,
+ "ty": 0,
+ "parent": 184,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "187"
+ },
+ {
+ "ind": 184,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [420, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 195,
+ "ty": 0,
+ "parent": 190,
+ "ks": {},
+ "w": 84,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "193"
+ },
+ {
+ "ind": 190,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [840, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 201,
+ "ty": 0,
+ "parent": 196,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "199"
+ },
+ {
+ "ind": 196,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [735, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 207,
+ "ty": 0,
+ "parent": 202,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "205"
+ },
+ {
+ "ind": 202,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [630, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 213,
+ "ty": 0,
+ "parent": 208,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "211"
+ },
+ {
+ "ind": 208,
+ "ty": 3,
+ "parent": 153,
+ "ks": { "p": { "a": 0, "k": [525, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 153,
+ "ty": 3,
+ "parent": 152,
+ "ks": { "p": { "a": 0, "k": [1048, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 220,
+ "ty": 0,
+ "parent": 215,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "218"
+ },
+ {
+ "ind": 215,
+ "ty": 3,
+ "parent": 214,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 226,
+ "ty": 0,
+ "parent": 221,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "224"
+ },
+ {
+ "ind": 221,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [105, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 232,
+ "ty": 0,
+ "parent": 227,
+ "ks": {},
+ "w": 84,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "230"
+ },
+ {
+ "ind": 227,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [944, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 238,
+ "ty": 0,
+ "parent": 233,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "236"
+ },
+ {
+ "ind": 233,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [210, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 244,
+ "ty": 0,
+ "parent": 239,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "242"
+ },
+ {
+ "ind": 239,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [315, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 250,
+ "ty": 0,
+ "parent": 245,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "248"
+ },
+ {
+ "ind": 245,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [420, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 256,
+ "ty": 0,
+ "parent": 251,
+ "ks": {},
+ "w": 84,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "254"
+ },
+ {
+ "ind": 251,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [840, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 262,
+ "ty": 0,
+ "parent": 257,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "260"
+ },
+ {
+ "ind": 257,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [735, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 268,
+ "ty": 0,
+ "parent": 263,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "266"
+ },
+ {
+ "ind": 263,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [630, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 274,
+ "ty": 0,
+ "parent": 269,
+ "ks": {},
+ "w": 85,
+ "h": 85,
+ "ip": 0,
+ "op": 481,
+ "st": 0,
+ "refId": "272"
+ },
+ {
+ "ind": 269,
+ "ty": 3,
+ "parent": 214,
+ "ks": { "p": { "a": 0, "k": [525, 0] } },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 214,
+ "ty": 3,
+ "parent": 152,
+ "ks": {},
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ {
+ "ind": 152,
+ "ty": 3,
+ "parent": 12,
+ "ks": {
+ "p": {
+ "a": 1,
+ "k": [
+ {
+ "t": 0,
+ "s": [27, 350],
+ "i": { "x": [0, 1], "y": [1, 1] },
+ "o": { "x": [0.5, 0], "y": [0, 0] }
+ },
+ { "t": 480, "s": [-1688, 350], "h": 1 }
+ ]
+ }
+ },
+ "ip": 0,
+ "op": 481,
+ "st": 0
+ },
+ { "ind": 12, "ty": 3, "parent": 11, "ks": {}, "ip": 0, "op": 481, "st": 0 },
+ { "ind": 11, "ty": 3, "ks": {}, "ip": 0, "op": 481, "st": 0 }
+ ],
+ "meta": { "g": "https://jitter.video" },
+ "op": 480,
+ "v": "5.7.4",
+ "w": 390
+}
diff --git a/src/assets/svgs/Arrow 2.svg b/src/assets/svgs/Arrow 2.svg
new file mode 100644
index 0000000..f4b3e42
--- /dev/null
+++ b/src/assets/svgs/Arrow 2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/svgs/BackArrow.svg b/src/assets/svgs/BackArrow.svg
new file mode 100644
index 0000000..5d942f2
--- /dev/null
+++ b/src/assets/svgs/BackArrow.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/svgs/Book.svg b/src/assets/svgs/Book.svg
new file mode 100644
index 0000000..16761a8
--- /dev/null
+++ b/src/assets/svgs/Book.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/BookMark.svg b/src/assets/svgs/BookMark.svg
new file mode 100644
index 0000000..779ded3
--- /dev/null
+++ b/src/assets/svgs/BookMark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/svgs/Calendar.svg b/src/assets/svgs/Calendar.svg
new file mode 100644
index 0000000..9f71a82
--- /dev/null
+++ b/src/assets/svgs/Calendar.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Claco_Main.svg b/src/assets/svgs/Claco_Main.svg
new file mode 100644
index 0000000..e6642da
--- /dev/null
+++ b/src/assets/svgs/Claco_Main.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Claco_Ticket.svg b/src/assets/svgs/Claco_Ticket.svg
new file mode 100644
index 0000000..f2fc16c
--- /dev/null
+++ b/src/assets/svgs/Claco_Ticket.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/CompletePreference.svg b/src/assets/svgs/CompletePreference.svg
new file mode 100644
index 0000000..36dffca
--- /dev/null
+++ b/src/assets/svgs/CompletePreference.svg
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/DownLoadBox.svg b/src/assets/svgs/DownLoadBox.svg
new file mode 100644
index 0000000..629a20d
--- /dev/null
+++ b/src/assets/svgs/DownLoadBox.svg
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Edit.svg b/src/assets/svgs/Edit.svg
new file mode 100644
index 0000000..42aebd0
--- /dev/null
+++ b/src/assets/svgs/Edit.svg
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/assets/svgs/Female.svg b/src/assets/svgs/Female.svg
new file mode 100644
index 0000000..6a24bc4
--- /dev/null
+++ b/src/assets/svgs/Female.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Heart.svg b/src/assets/svgs/Heart.svg
new file mode 100644
index 0000000..761f404
--- /dev/null
+++ b/src/assets/svgs/Heart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/svgs/Light.svg b/src/assets/svgs/Light.svg
new file mode 100644
index 0000000..971dd5c
--- /dev/null
+++ b/src/assets/svgs/Light.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Like.svg b/src/assets/svgs/Like.svg
new file mode 100644
index 0000000..ce8d153
--- /dev/null
+++ b/src/assets/svgs/Like.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/assets/svgs/Location.svg b/src/assets/svgs/Location.svg
new file mode 100644
index 0000000..2a90c2e
--- /dev/null
+++ b/src/assets/svgs/Location.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Location_gray.svg b/src/assets/svgs/Location_gray.svg
new file mode 100644
index 0000000..de6a792
--- /dev/null
+++ b/src/assets/svgs/Location_gray.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Male.svg b/src/assets/svgs/Male.svg
new file mode 100644
index 0000000..6bc3c8a
--- /dev/null
+++ b/src/assets/svgs/Male.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Megaphone.svg b/src/assets/svgs/Megaphone.svg
new file mode 100644
index 0000000..8a8606d
--- /dev/null
+++ b/src/assets/svgs/Megaphone.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/RightArrow.svg b/src/assets/svgs/RightArrow.svg
new file mode 100644
index 0000000..2434584
--- /dev/null
+++ b/src/assets/svgs/RightArrow.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/Romantic.svg b/src/assets/svgs/Romantic.svg
new file mode 100644
index 0000000..c05e347
--- /dev/null
+++ b/src/assets/svgs/Romantic.svg
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/StarRating.svg b/src/assets/svgs/StarRating.svg
new file mode 100644
index 0000000..f8c316c
--- /dev/null
+++ b/src/assets/svgs/StarRating.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/svgs/UserProfile.svg b/src/assets/svgs/UserProfile.svg
new file mode 100644
index 0000000..7f374d8
--- /dev/null
+++ b/src/assets/svgs/UserProfile.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/X-icon.svg b/src/assets/svgs/X-icon.svg
new file mode 100644
index 0000000..0b76aee
--- /dev/null
+++ b/src/assets/svgs/X-icon.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/svgs/agreeIcon.svg b/src/assets/svgs/agreeIcon.svg
new file mode 100644
index 0000000..4f9e47c
--- /dev/null
+++ b/src/assets/svgs/agreeIcon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/assets/svgs/chat.svg b/src/assets/svgs/chat.svg
new file mode 100644
index 0000000..582485b
--- /dev/null
+++ b/src/assets/svgs/chat.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/check.svg b/src/assets/svgs/check.svg
new file mode 100644
index 0000000..5bc9292
--- /dev/null
+++ b/src/assets/svgs/check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/svgs/checked.svg b/src/assets/svgs/checked.svg
new file mode 100644
index 0000000..586986f
--- /dev/null
+++ b/src/assets/svgs/checked.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/svgs/dotsthree.svg b/src/assets/svgs/dotsthree.svg
new file mode 100644
index 0000000..3aec909
--- /dev/null
+++ b/src/assets/svgs/dotsthree.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/assets/svgs/download.svg b/src/assets/svgs/download.svg
new file mode 100644
index 0000000..fd70fd0
--- /dev/null
+++ b/src/assets/svgs/download.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/errorIcon.svg b/src/assets/svgs/errorIcon.svg
new file mode 100644
index 0000000..fc20439
--- /dev/null
+++ b/src/assets/svgs/errorIcon.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/filter.svg b/src/assets/svgs/filter.svg
new file mode 100644
index 0000000..7c0c37f
--- /dev/null
+++ b/src/assets/svgs/filter.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/halfStarRating.svg b/src/assets/svgs/halfStarRating.svg
new file mode 100644
index 0000000..7d34f38
--- /dev/null
+++ b/src/assets/svgs/halfStarRating.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/assets/svgs/home.svg b/src/assets/svgs/home.svg
new file mode 100644
index 0000000..f545be8
--- /dev/null
+++ b/src/assets/svgs/home.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/svgs/listen.svg b/src/assets/svgs/listen.svg
new file mode 100644
index 0000000..08be7ac
--- /dev/null
+++ b/src/assets/svgs/listen.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/plus.svg b/src/assets/svgs/plus.svg
new file mode 100644
index 0000000..d58bd09
--- /dev/null
+++ b/src/assets/svgs/plus.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/refresh.svg b/src/assets/svgs/refresh.svg
new file mode 100644
index 0000000..ead17a3
--- /dev/null
+++ b/src/assets/svgs/refresh.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/remove.svg b/src/assets/svgs/remove.svg
new file mode 100644
index 0000000..5fba10a
--- /dev/null
+++ b/src/assets/svgs/remove.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/required.svg b/src/assets/svgs/required.svg
new file mode 100644
index 0000000..ad3ca57
--- /dev/null
+++ b/src/assets/svgs/required.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/search.svg b/src/assets/svgs/search.svg
new file mode 100644
index 0000000..1e3ff24
--- /dev/null
+++ b/src/assets/svgs/search.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/assets/svgs/settings.svg b/src/assets/svgs/settings.svg
new file mode 100644
index 0000000..3264547
--- /dev/null
+++ b/src/assets/svgs/settings.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/ticket.svg b/src/assets/svgs/ticket.svg
new file mode 100644
index 0000000..71aed3e
--- /dev/null
+++ b/src/assets/svgs/ticket.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/trash.svg b/src/assets/svgs/trash.svg
new file mode 100644
index 0000000..56bdba0
--- /dev/null
+++ b/src/assets/svgs/trash.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/svgs/user.svg b/src/assets/svgs/user.svg
new file mode 100644
index 0000000..19304c7
--- /dev/null
+++ b/src/assets/svgs/user.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/components/Browse/ClacoPick/ClacoPickShow/index.tsx b/src/components/Browse/ClacoPick/ClacoPickShow/index.tsx
new file mode 100644
index 0000000..c73ed43
--- /dev/null
+++ b/src/components/Browse/ClacoPick/ClacoPickShow/index.tsx
@@ -0,0 +1,16 @@
+import { ClacoPickShowProps } from "@/types";
+
+export const ClacoPickShow = ({ imageSrc, title }: ClacoPickShowProps) => {
+ return (
+
+
+
+ {title}
+
+
+ );
+};
diff --git a/src/components/Browse/ClacoPick/index.tsx b/src/components/Browse/ClacoPick/index.tsx
new file mode 100644
index 0000000..7089899
--- /dev/null
+++ b/src/components/Browse/ClacoPick/index.tsx
@@ -0,0 +1,47 @@
+import { ClacoPickShow } from "./ClacoPickShow";
+import { Swiper, SwiperSlide } from "swiper/react";
+import { useUserStore } from "@/libraries/store/user";
+import { ClacoPickProps } from "@/types";
+import { useNavigate } from "react-router-dom";
+
+export const ClacoPick = ({ pickData }: ClacoPickProps) => {
+ const { nickname } = useUserStore();
+ const navigate = useNavigate();
+
+ const gotoConcertDetail = (id: number) => {
+ navigate(`/show/${id}`);
+ };
+
+ return (
+
+
+
+ 이런 공연은 어떠세요?
+
+
+ {nickname}님이 좋아할 만한 공연이에요
+
+
+
+
+
+ {pickData.map((pick, index) => (
+ gotoConcertDetail(pick.id)}
+ >
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/Browse/RecentConcertResult/index.tsx b/src/components/Browse/RecentConcertResult/index.tsx
new file mode 100644
index 0000000..4f30dbc
--- /dev/null
+++ b/src/components/Browse/RecentConcertResult/index.tsx
@@ -0,0 +1,44 @@
+import { ShowSummaryCard } from "@/components/common/ShowSummaryCard";
+import { GetConcertInfiniteResponse } from "@/types";
+
+export type RecentConcertResultProps = {
+ concertData: GetConcertInfiniteResponse;
+ isFetchingNextPage: boolean;
+};
+
+export const RecentConcertResult = ({
+ concertData,
+ isFetchingNextPage,
+}: RecentConcertResultProps) => {
+ return (
+ <>
+
+ 총 {concertData?.pages[0].result.totalCount}개
+
+
+ {concertData &&
+ concertData.pages[0].result.listPageResponse.length === 0 ? (
+
검색결과없음
+ ) : (
+ <>
+ {concertData &&
+ concertData.pages.flatMap((page, pageIndex) =>
+ page.result.listPageResponse.map((show, index) => (
+
+ ))
+ )}
+ {/* 추가 데이터 로드 */}
+ {isFetchingNextPage && (
+
+ 로딩 중...
+
+ )}
+ >
+ )}
+
+ >
+ );
+};
diff --git a/src/components/Browse/SearchResult/index.tsx b/src/components/Browse/SearchResult/index.tsx
new file mode 100644
index 0000000..dac2062
--- /dev/null
+++ b/src/components/Browse/SearchResult/index.tsx
@@ -0,0 +1,60 @@
+import { GetConcertInfiniteResponse } from "@/types";
+import { ClacoPick } from "../ClacoPick";
+import { ShowSummaryCard } from "@/components/common/ShowSummaryCard";
+
+export type SearchReultProps = {
+ searchData: GetConcertInfiniteResponse;
+ isFetchingNextPage: boolean;
+};
+
+export const SearchResult = ({
+ searchData,
+ isFetchingNextPage,
+}: SearchReultProps) => {
+ return (
+ // 검색 이후 로직
+ <>
+
+ {searchData?.pages[0].result.totalCount === 0 ? (
+
+
+
+ 찾으시는 공연 정보가 없어요
+
+
+ 입력하신 단어가 정확한지 확인해주세요
+
+
+
+
+
+
+
+ ) : (
+ <>
+
+ 총 {searchData?.pages[0].result.totalCount}개의 공연
+
+ {searchData &&
+ searchData.pages.flatMap((page) =>
+ page.result.listPageResponse.map((show) => (
+
+ ))
+ )}
+ {/* 추가 데이터 로드 */}
+ {isFetchingNextPage && (
+
+ 로딩 중...
+
+ )}
+ >
+ )}
+
+ >
+ );
+};
diff --git a/src/components/Browse/ShowFilter/ShowFeatureFilter/index.tsx b/src/components/Browse/ShowFilter/ShowFeatureFilter/index.tsx
new file mode 100644
index 0000000..ade2baf
--- /dev/null
+++ b/src/components/Browse/ShowFilter/ShowFeatureFilter/index.tsx
@@ -0,0 +1,22 @@
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ isChecked: boolean;
+}
+
+export const ShowFeatureFilter = ({
+ isChecked,
+ children = "",
+ ...props
+}: ButtonProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/Browse/ShowFilter/index.tsx b/src/components/Browse/ShowFilter/index.tsx
new file mode 100644
index 0000000..638597f
--- /dev/null
+++ b/src/components/Browse/ShowFilter/index.tsx
@@ -0,0 +1,225 @@
+import { ReactComponent as Refresh } from "@/assets/svgs/refresh.svg";
+import { ReactComponent as X } from "@/assets/svgs/X-icon.svg";
+import { ConfirmButton } from "@/components/common/Button";
+import { Location } from "@/components/common/Location";
+import { Price } from "@/components/common/Price";
+import { useEffect, useState } from "react";
+import { ShowFeatureFilter } from "./ShowFeatureFilter";
+import { ShowFilterProps } from "@/types";
+import { Calendar } from "@/components/common/Calendar";
+
+const features = [
+ "웅장한",
+ "섬세한",
+ "친숙한",
+ "새로운",
+ "고전적인",
+ "현대적인",
+ "낭만적인",
+ "비극적인",
+ "서정적인",
+ "역동적인",
+];
+
+export const ShowFilter = ({ onClose, onApply }: ShowFilterProps) => {
+ const [minPrice, setMinPrice] = useState(0);
+ const [maxPrice, setMaxPrice] = useState(1000000);
+ const [selectedLocation, setSelectedLocation] = useState([]);
+ const [selectedFeatures, setSelectedFeatures] = useState([]);
+ const [rangeStart, setRangeStart] = useState(null);
+ const [rangeEnd, setRangeEnd] = useState(null);
+ const [isVisible, setIsVisible] = useState(false);
+
+ useEffect(() => {
+ const savedFilter = localStorage.getItem("filterObj");
+ if (savedFilter) {
+ const parsedFilter = JSON.parse(savedFilter);
+ setMinPrice(parsedFilter.minPrice || 0);
+ setMaxPrice(parsedFilter.maxPrice || 1000000);
+ setSelectedLocation(parsedFilter.selectedLocation || []);
+ setSelectedFeatures(parsedFilter.categories || []);
+ setRangeStart(
+ parsedFilter.startDate ? new Date(parsedFilter.startDate) : null
+ );
+ setRangeEnd(parsedFilter.endDate ? new Date(parsedFilter.endDate) : null);
+ }
+ }, []);
+
+ useEffect(() => {
+ const timer = setTimeout(() => setIsVisible(true), 50);
+ return () => clearTimeout(timer);
+ }, []);
+
+ useEffect(() => {
+ document.body.style.overflow = "hidden";
+
+ return () => {
+ document.body.style.overflow = "unset";
+ };
+ }, []);
+
+ const handleClose = () => {
+ setIsVisible(false);
+ setTimeout(onClose, 1000);
+ };
+
+ const handleApply = () => {
+ const priceRange = `${minPrice}~${maxPrice}원`;
+ const location = selectedLocation.join(", ");
+ let dateRange = "";
+ if (rangeStart && rangeEnd) {
+ const startYear = rangeStart.getFullYear();
+ const startMonth = String(rangeStart.getMonth() + 1).padStart(2, "0");
+ const startDate = String(rangeStart.getDate()).padStart(2, "0");
+
+ const endYear = rangeEnd.getFullYear();
+ const endMonth = String(rangeEnd.getMonth() + 1).padStart(2, "0");
+ const endDate = String(rangeEnd.getDate()).padStart(2, "0");
+
+ dateRange = `${startYear}.${startMonth}.${startDate}~${endYear}.${endMonth}.${endDate}`;
+ }
+
+ const feature = selectedFeatures.join(", ");
+ setIsVisible(false);
+ setTimeout(() => onApply(priceRange, location, dateRange, feature), 300);
+
+ const [startDate, endDate] = dateRange.split("~");
+ const filterObj = {
+ minPrice: minPrice,
+ maxPrice: maxPrice,
+ selectedLocation: selectedLocation,
+ startDate: startDate,
+ endDate: endDate,
+ categories: selectedFeatures,
+ };
+ // console.log(filterObj);
+
+ localStorage.setItem("filterObj", JSON.stringify(filterObj));
+ };
+
+ const handleLocationClick = (values: string[], label: string) => {
+ console.log(label);
+ if (values.every((value) => selectedLocation.includes(value))) {
+ setSelectedLocation(
+ selectedLocation.filter((loc) => !values.includes(loc))
+ );
+ } else {
+ setSelectedLocation([...selectedLocation, ...values]);
+ }
+ };
+
+ const handleFeatureClick = (feature: string) => {
+ if (selectedFeatures.includes(feature)) {
+ setSelectedFeatures(selectedFeatures.filter((item) => item !== feature));
+ } else if (selectedFeatures.length < 5) {
+ setSelectedFeatures([...selectedFeatures, feature]);
+ }
+ };
+
+ const handleRefreshClick = () => {
+ setMinPrice(0);
+ setMaxPrice(1000000);
+ setSelectedLocation([]);
+ setSelectedFeatures([]);
+ setRangeStart(null);
+ setRangeEnd(null);
+ };
+
+ return (
+
+
+
+
+
+ 필터 설정
+
+
+
+
+
+ 공연장 위치
+
+
+
+
+ 공연 날짜
+ {
+ setRangeStart(start);
+ setRangeEnd(end);
+ }}
+ />
+
+
+
+ 공연 특성
+
+ *최대 5개까지 선택 가능해요
+
+
+
+ {features.map((feature) => (
+ handleFeatureClick(feature)}
+ >
+ {feature}
+
+ ))}
+
+
+
+ 적용하기
+
+
+
+ );
+};
diff --git a/src/components/Layout/Footer/index.tsx b/src/components/Layout/Footer/index.tsx
new file mode 100644
index 0000000..b21ab06
--- /dev/null
+++ b/src/components/Layout/Footer/index.tsx
@@ -0,0 +1,76 @@
+import { ReactComponent as Home } from "@/assets/svgs/home.svg";
+import { ReactComponent as Search } from "@/assets/svgs/search.svg";
+import { ReactComponent as Ticket } from "@/assets/svgs/ticket.svg";
+import { ReactComponent as User } from "@/assets/svgs/user.svg";
+import { useLocation, useNavigate } from "react-router-dom";
+
+const CATEGORIES = [
+ {
+ text: "홈",
+ image: ,
+ path: "/main",
+ },
+ {
+ text: "둘러보기",
+ image: ,
+ path: "/browse",
+ },
+ {
+ text: "티켓북",
+ image: ,
+ path: "/ticketbook",
+ },
+ {
+ text: "마이페이지",
+ image: ,
+ path: "/mypage",
+ },
+];
+
+const Footer = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const getActiveCategory = () => {
+ const currentCategory = CATEGORIES.find(
+ (category) => category.path === location.pathname
+ );
+ return currentCategory?.text || CATEGORIES[0].text;
+ };
+
+ const handleFooter = (category: string) => {
+ const targetCategory = CATEGORIES.find((item) => item.text === category);
+ if (targetCategory) {
+ navigate(targetCategory.path);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
new file mode 100644
index 0000000..8ccd1b7
--- /dev/null
+++ b/src/components/Layout/Header/index.tsx
@@ -0,0 +1,5 @@
+const Header = () => {
+ return ;
+};
+
+export default Header;
diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx
new file mode 100644
index 0000000..ccad798
--- /dev/null
+++ b/src/components/Layout/index.tsx
@@ -0,0 +1,15 @@
+import { Outlet } from "react-router-dom";
+import Header from "./Header";
+import Footer from "./Footer";
+
+const Layout = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default Layout;
diff --git a/src/components/Main/Analysis/AIRecommend/index.tsx b/src/components/Main/Analysis/AIRecommend/index.tsx
new file mode 100644
index 0000000..e59fb05
--- /dev/null
+++ b/src/components/Main/Analysis/AIRecommend/index.tsx
@@ -0,0 +1,62 @@
+import { MainPosterCard } from "@/components/Main/MainPosterCard";
+import { Pagination } from "swiper/modules";
+import "swiper/css";
+import "swiper/css/pagination";
+import { Swiper, SwiperSlide } from "swiper/react";
+import { useUserStore } from "@/libraries/store/user";
+import { useEffect, useState } from "react";
+import { UserBased } from "@/types";
+import { useGetUserBased } from "@/hooks/queries";
+import { useDeferredLoading } from "@/hooks/utils";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export const AIRecommend = () => {
+ const [userBased, setUserBased] = useState([]);
+ const nickname = useUserStore((state) => state.nickname);
+ const { data, isLoading } = useGetUserBased();
+
+ useEffect(() => {
+ if (!isLoading && data?.result) {
+ setUserBased(data.result);
+ }
+ }, [isLoading, data]);
+
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+
+ if (shouldShowSkeleton) {
+ //skeleton UI 적용될 부분
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {nickname}님만의 취향을 담은
+
+ 공연을 준비했어요
+
+
+
+ {userBased.map((data) => (
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/Main/Analysis/SimilarKeyWordRecommend/index.tsx b/src/components/Main/Analysis/SimilarKeyWordRecommend/index.tsx
new file mode 100644
index 0000000..344cc6b
--- /dev/null
+++ b/src/components/Main/Analysis/SimilarKeyWordRecommend/index.tsx
@@ -0,0 +1,110 @@
+import { HorizontalInfoCard } from "@/components/Main/InformationCard";
+import { Genre } from "@/components/common/Genre";
+import { useEffect, useState } from "react";
+import dynamic from "@/assets/images/Genre/dynamic.png";
+import romantic from "@/assets/images/Genre/romantic.png";
+import tragic from "@/assets/images/Genre/tragic.png";
+import { useUserStore } from "@/libraries/store/user";
+import { useGetItemBased } from "@/hooks/queries";
+import { UserItemBased } from "@/types";
+
+const USER_GENRE = [
+ { imgUrl: dynamic, keyWord: "역동적인" },
+ { imgUrl: romantic, keyWord: "낭만적인" },
+ { imgUrl: tragic, keyWord: "비극적인" },
+];
+
+export const SimilarKeyWordRecommend = () => {
+ const [itemBased, setItemBased] = useState();
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const nickname = useUserStore((state) => state.nickname);
+ const { data, isLoading } = useGetItemBased();
+
+ useEffect(() => {
+ if (!isLoading && data?.result) {
+ // console.log(data.result);
+ setItemBased(data.result);
+ }
+ }, [isLoading, data]);
+
+ useEffect(() => {
+ const rotateGenre = () => {
+ setCurrentIndex((prev) => (prev + 1) % USER_GENRE.length);
+ };
+
+ const interval = setInterval(rotateGenre, 3500);
+ return () => clearInterval(interval);
+ }, []);
+
+ const getItemStyle = (index: number) => {
+ const baseStyle =
+ "absolute top-1/2 -translate-y-1/2 transition-all duration-500 ease-in-out";
+
+ const position = index - currentIndex;
+
+ const normalizedPosition =
+ position < -1 ? position + 3 : position > 1 ? position - 3 : position;
+
+ if (normalizedPosition === 0) {
+ return `${baseStyle} left-1/2 -translate-x-1/2 z-30 scale-100 opacity-100`;
+ } else if (normalizedPosition === 1) {
+ return `${baseStyle} left-[85%] -translate-x-1/2 z-20 scale-75 opacity-60`;
+ } else {
+ return `${baseStyle} left-[15%] -translate-x-1/2 z-10 scale-75 opacity-60`;
+ }
+ };
+
+ if (isLoading) {
+ //skeleton UI 적용될 부분
+ return 로딩 중..
;
+ }
+
+ return (
+
+
+
+
+ {itemBased?.likedHistory ? (
+ <>
+ {nickname}님이 좋아요한 공연과
+ 비슷한 느낌의 공연도 확인해보세요
+ >
+ ) : (
+ <>
+ 많은 사람들이 주목한
+ 클래식 공연도 확인해보세요
+ >
+ )}
+
+
+
+ {itemBased?.keywords.map((item, index) => (
+
+
+
+ ))}
+
+
+
+
+ {itemBased?.recommendationConcertsResponseV1s.map((concert) => (
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/Main/Analysis/TicketRecommend/Reviews/index.tsx b/src/components/Main/Analysis/TicketRecommend/Reviews/index.tsx
new file mode 100644
index 0000000..4c572c9
--- /dev/null
+++ b/src/components/Main/Analysis/TicketRecommend/Reviews/index.tsx
@@ -0,0 +1,28 @@
+import { useTruncateText } from "@/hooks/utils";
+import { ReactComponent as Chat } from "@/assets/svgs/chat.svg";
+import { TicketReviewSummary } from "@/types";
+
+export type ReviewsProps = {
+ data: TicketReviewSummary;
+};
+
+export const Reviews = ({ data }: ReviewsProps) => {
+ return (
+
+
+ {data.concertName}
+
+
+
+
+
+ {data.nickName}님의 리뷰
+
+
+
+ {useTruncateText(data.content, 64)}
+
+
+
+ );
+};
diff --git a/src/components/Main/Analysis/TicketRecommend/index.tsx b/src/components/Main/Analysis/TicketRecommend/index.tsx
new file mode 100644
index 0000000..53ee1b7
--- /dev/null
+++ b/src/components/Main/Analysis/TicketRecommend/index.tsx
@@ -0,0 +1,169 @@
+import { ReactComponent as Arrow } from "@/assets/svgs/Arrow 2.svg";
+import { TouchEvent, useEffect, useState } from "react";
+import { Reviews } from "./Reviews";
+import { TicketReviewSummary } from "@/types";
+import { useGetRecommendClacoTicket } from "@/hooks/queries";
+import { useNavigate } from "react-router-dom";
+export const TicketRecommend = () => {
+ const navigate = useNavigate();
+
+ const { data, isLoading, isError } = useGetRecommendClacoTicket();
+ const [error, setError] = useState(false);
+ const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(
+ null
+ );
+ const [touchEnd, setTouchEnd] = useState<{ x: number; y: number } | null>(
+ null
+ );
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [reviewContent, setReviewContent] = useState({
+ nickName: "",
+ concertId: 0,
+ concertName: "",
+ content: "",
+ createdAt: "",
+ });
+ const [isReviewVisible, setIsReviewVisible] = useState(true);
+
+ useEffect(() => {
+ if (data) {
+ if (data.code === "COM-000" && data.result.length > 0) {
+ setError(false);
+ setReviewContent(data.result[0].ticketReviewSummary);
+ } else if (data.code === "CLB-001") {
+ // 클라코북을 찾을 수 없는 오류
+ setError(true);
+ // window.confirm("알 수 없는 오류가 발생했습니다.");
+ // navigate("/");
+ } else {
+ navigate("/");
+ localStorage.clear();
+ }
+ }
+ }, [data, navigate]);
+
+ const handleTouchStart = (e: TouchEvent) => {
+ setTouchEnd(null);
+ setTouchStart({
+ x: e.targetTouches[0].clientX,
+ y: e.targetTouches[0].clientY,
+ });
+ };
+ const handleTouchMove = (e: TouchEvent) => {
+ setTouchEnd({
+ x: e.targetTouches[0].clientX,
+ y: e.targetTouches[0].clientY,
+ });
+ };
+ const handleTouchEnd = () => {
+ if (!touchStart || !touchEnd || !data) return;
+ const xDistance = touchStart.x - touchEnd.x;
+ const yDistance = Math.abs(touchStart.y - touchEnd.y);
+ if (yDistance > Math.abs(xDistance)) {
+ return;
+ }
+ const minSwipeDistance = 50;
+ const isLeftSwipe = xDistance > minSwipeDistance;
+ const isRightSwipe = xDistance < -minSwipeDistance;
+ if (!isLeftSwipe && !isRightSwipe) return;
+ setIsReviewVisible(false);
+ let nextIndex;
+ if (isLeftSwipe) {
+ nextIndex = (currentIndex + 1) % data.result.length;
+ } else if (isRightSwipe) {
+ nextIndex = (currentIndex - 1 + data.result.length) % data.result.length;
+ } else {
+ nextIndex = currentIndex;
+ }
+ setCurrentIndex(nextIndex);
+ setTimeout(() => {
+ setReviewContent(data.result[nextIndex].ticketReviewSummary);
+ setIsReviewVisible(true);
+ }, 400);
+ };
+ const getItemStyle = (index: number) => {
+ const baseStyle =
+ "w-[232px] absolute top-1/2 -translate-y-1/2 transition-all duration-500 ease-in-out";
+ const position = index - currentIndex;
+ const normalizedPosition =
+ position < -1 ? position + 3 : position > 1 ? position - 3 : position;
+ if (normalizedPosition === 0) {
+ return `${baseStyle} left-1/2 -translate-x-1/2 z-40 scale-100 opacity-100`;
+ } else if (normalizedPosition === 1) {
+ return `${baseStyle} left-[115%] -translate-x-1/2 z-20 scale-100 opacity-30`;
+ } else {
+ return `${baseStyle} left-[-15%] -translate-x-1/2 z-10 scale-100 opacity-30`;
+ }
+ };
+ const handleGoToTicket = () => {
+ const currentTicketId = data?.result[currentIndex].ticketInfoResponse.id;
+ navigate(`/ticket/${currentTicketId}`);
+ };
+
+ // 로딩, 에러, 데이터 없음 상태 처리
+ if (
+ isLoading ||
+ isError ||
+ !data ||
+ !data.result ||
+ data.result.length === 0
+ ) {
+ return (
+
+ 비슷한 취향을 가진 사람들의 정보를
+ 분석해주는 서비스를 준비중이에요..
+
+ );
+ }
+
+ // 클라코북을 찾을 수 없는 에러 발생 시
+ if (error) {
+ return (
+
+ 비슷한 취향을 가진 사람들의 정보를
+ 분석해주는 서비스를 준비중이에요..
+
+ );
+ }
+
+ return (
+
+
+ 비슷한 취향을 가진 사람들의 공연 후기로
+ 클래식 경험의 폭을 넓혀보세요
+
+
+ {data?.result.map((ticket, index) => (
+
+
+
+ ))}
+
+
+ {/* 리뷰 영역 */}
+
+
+
+
+
+ );
+};
diff --git a/src/components/Main/Analysis/index.tsx b/src/components/Main/Analysis/index.tsx
new file mode 100644
index 0000000..0cb8c7c
--- /dev/null
+++ b/src/components/Main/Analysis/index.tsx
@@ -0,0 +1,13 @@
+import { AIRecommend } from "./AIRecommend";
+import { SimilarKeyWordRecommend } from "./SimilarKeyWordRecommend";
+import { TicketRecommend } from "./TicketRecommend";
+
+export const Analysis = () => {
+ return (
+
+ );
+};
diff --git a/src/components/Main/ClassicalPalette/index.tsx b/src/components/Main/ClassicalPalette/index.tsx
new file mode 100644
index 0000000..5d87061
--- /dev/null
+++ b/src/components/Main/ClassicalPalette/index.tsx
@@ -0,0 +1,17 @@
+import { ReactComponent as ClacoMain } from "@/assets/svgs/Claco_Main.svg";
+import { ReactComponent as Light } from "@/assets/svgs/Light.svg";
+import { ClacoAnalysisCard } from "@/components/common/ClacoAnalysisCard";
+
+export const ClassicalPalette = () => {
+ return (
+
+ );
+};
diff --git a/src/components/Main/InformationCard/index.tsx b/src/components/Main/InformationCard/index.tsx
new file mode 100644
index 0000000..b02cf36
--- /dev/null
+++ b/src/components/Main/InformationCard/index.tsx
@@ -0,0 +1,111 @@
+import { ReactComponent as Location_Gray } from "@/assets/svgs/Location_gray.svg";
+import { ReactComponent as Calendar } from "@/assets/svgs/Calendar.svg";
+import { InfoCardProps } from "@/types/poster";
+import { formatDateYYYYMMDD } from "@/hooks/utils";
+import { CategoryTag } from "@/components/common/CategoryTag";
+import { useNavigate } from "react-router-dom";
+
+export const VerticalInfoCard = ({
+ id,
+ image,
+ title,
+ location,
+ dateFrom,
+ dateTo,
+ genrenm,
+}: InfoCardProps) => {
+ const _dateFrom = formatDateYYYYMMDD(dateFrom);
+ const _dateTo = formatDateYYYYMMDD(dateTo);
+ const navigate = useNavigate();
+ const gotoShowDetail = () => {
+ navigate(`/show/${id}`);
+ };
+
+ return (
+
+
+
+
+
+ {title}
+
+
+
+
+ {location}
+
+
+
+
+ {_dateFrom === _dateTo ? (
+ <>{_dateFrom}>
+ ) : (
+ <>
+ {_dateFrom}~{_dateTo}
+ >
+ )}
+
+
+
+
+
+ );
+};
+
+export const HorizontalInfoCard = ({
+ id,
+ image,
+ title,
+ location,
+ dateFrom,
+ dateTo,
+ genrenm,
+}: InfoCardProps) => {
+ const _dateFrom = formatDateYYYYMMDD(dateFrom);
+ const _dateTo = formatDateYYYYMMDD(dateTo);
+
+ const navigate = useNavigate();
+ const gotoShowDetail = () => {
+ navigate(`/show/${id}`);
+ };
+ return (
+
+
+
+
+
+
+ {title}
+
+
+
+
+ {location}
+
+
+
+
+ {_dateFrom === _dateTo ? (
+ <>{_dateFrom}>
+ ) : (
+ <>
+ {_dateFrom}~{_dateTo}
+ >
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Main/MainPosterCard/index.tsx b/src/components/Main/MainPosterCard/index.tsx
new file mode 100644
index 0000000..da1062a
--- /dev/null
+++ b/src/components/Main/MainPosterCard/index.tsx
@@ -0,0 +1,112 @@
+import { ReactComponent as Heart } from "@/assets/svgs/Heart.svg";
+import { CategoryTag } from "@/components/common/CategoryTag";
+import { UserBased } from "@/types";
+import { useNavigate } from "react-router-dom";
+import { useState, useEffect, useRef } from "react";
+import { usePostLike } from "@/hooks/mutation";
+
+export type MainPosterCardProps = {
+ data: UserBased;
+};
+
+export const MainPosterCard = ({ data }: MainPosterCardProps) => {
+ const navigate = useNavigate();
+ const [isLoading, setIsLoading] = useState(true);
+ const [shouldLoad, setShouldLoad] = useState(false);
+ const posterRef = useRef(null);
+
+ const { mutate: postLike } = usePostLike();
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting && !shouldLoad) {
+ setShouldLoad(true);
+ observer.unobserve(entry.target);
+ }
+ },
+ { threshold: 0.1 }
+ );
+
+ const currentElement = posterRef.current;
+ if (currentElement) {
+ observer.observe(currentElement);
+ }
+
+ return () => {
+ if (currentElement) {
+ observer.unobserve(currentElement);
+ }
+ };
+ }, [shouldLoad]);
+
+ const handleImageLoad = () => {
+ setIsLoading(false);
+ };
+
+ const gotoShowDetail = () => {
+ navigate(`/show/${data.id}`);
+ };
+
+ const handleLikeButton = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ postLike(data.id, {
+ onSuccess: () => {},
+ onError: (error) => console.error(error),
+ });
+ };
+
+ return (
+
+
handleLikeButton(e)}
+ >
+ {data.liked ? (
+
+ ) : (
+
+ )}
+
+
+ {isLoading && (
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Mypage/LikedShow/index.tsx b/src/components/Mypage/LikedShow/index.tsx
new file mode 100644
index 0000000..a3a3d04
--- /dev/null
+++ b/src/components/Mypage/LikedShow/index.tsx
@@ -0,0 +1,79 @@
+import { SearchBar } from "@/components/common/Search/Bar";
+import { ShowFilterTab } from "@/components/common/ShowFilterTab";
+import { ShowSummaryCard } from "@/components/common/ShowSummaryCard";
+import { useEffect, useState } from "react";
+import { useDebouncedState, useDeferredLoading } from "@/hooks/utils";
+import { TabMenu } from "@/types";
+import { useGetConcertLikes } from "@/hooks/queries";
+
+export const LikedShow = () => {
+ const [activeTab, setActiveTab] = useState(null);
+ const [query, setQuery] = useState("");
+ const debouncedQuery = useDebouncedState(query, 1000);
+
+ const { data, isLoading, refetch } = useGetConcertLikes({
+ query: debouncedQuery,
+ genre: activeTab || "",
+ });
+
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+
+ const handleSearchChange = (e: React.ChangeEvent) => {
+ setQuery(e.target.value.trim());
+ };
+
+ const handleTabClick = (tab: TabMenu) => {
+ setActiveTab(tab);
+ refetch();
+ };
+
+ useEffect(() => {
+ refetch();
+ }, [debouncedQuery, activeTab, refetch]);
+
+ if (shouldShowSkeleton) {
+ return (
+
+
+
+
+
+
+ {Array.from(Array(5).keys()).map((_, index) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {data?.result?.map((show) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Mypage/PreferenceAnalysis/index.tsx b/src/components/Mypage/PreferenceAnalysis/index.tsx
new file mode 100644
index 0000000..04313bc
--- /dev/null
+++ b/src/components/Mypage/PreferenceAnalysis/index.tsx
@@ -0,0 +1,24 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ClacoAnalysisCard } from "@/components/common/ClacoAnalysisCard";
+import { useNavigate } from "react-router-dom";
+
+export const PreferenceAnalysis = () => {
+ const navigate = useNavigate();
+
+ const gotoPreference = () => {
+ navigate("/mypage/preference");
+ };
+ return (
+
+
+
+
+ 나의 취향 정보 수정
+
+
+
+ );
+};
diff --git a/src/components/Onboarding/Registration/index.tsx b/src/components/Onboarding/Registration/index.tsx
new file mode 100644
index 0000000..08e39d7
--- /dev/null
+++ b/src/components/Onboarding/Registration/index.tsx
@@ -0,0 +1,72 @@
+import { cn } from "@/lib/utils";
+
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ isChecked: boolean;
+}
+
+export const TypeButton = ({
+ isChecked,
+ children,
+ className = "",
+ ...props
+}: ButtonProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const ConceptButton = ({
+ isChecked,
+ children,
+ className = "",
+ ...props
+}: ButtonProps) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const FeatureButton = ({
+ isChecked,
+ children,
+ className = "",
+ ...props
+}: ButtonProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/Review/ReviewCard/const/index.ts b/src/components/Review/ReviewCard/const/index.ts
new file mode 100644
index 0000000..7a8d5df
--- /dev/null
+++ b/src/components/Review/ReviewCard/const/index.ts
@@ -0,0 +1,146 @@
+import Profile from "@/assets/images/profile.png";
+import ReviewImage from "@/assets/images/review.png";
+import Review1 from "@/assets/images/poster1.gif";
+import Review2 from "@/assets/images/poster2.gif";
+import Review3 from "@/assets/images/poster3.webp";
+import Review4 from "@/assets/images/poster4.gif";
+import Review5 from "@/assets/images/poster5.gif";
+import Review6 from "@/assets/images/poster6.gif";
+import Review7 from "@/assets/images/poster7.gif";
+import { Review } from "@/types";
+
+export const REVIEW_MOCK_DATA: Review[] = [
+ {
+ ticketReviewId: 1,
+ userName: "울랄라",
+ profileImage: Profile,
+ createdDate: "2024.10.27",
+ watchSit: "9층 B열 14번",
+ starRate: 4.0,
+ content:
+ "크리스마스 밤, 클라라의 집에서 뭐가 벌어지는 것 같긴한데, 내용이 어떻게 될까요. 저는 모르겠습니다만 이렇게 두줄이 넘어가면 말줄임이 되어야 하지 않을까",
+ reviewImages: [{ imageUrl: Review6 }, { imageUrl: Review1 }],
+ placeReviews: [
+ { placeCategoryId: 1, categoryName: "음향이 좋아요" },
+ { placeCategoryId: 2, categoryName: "좌석 간격이 넓어요" },
+ { placeCategoryId: 3, categoryName: "좌석이 편안해요" },
+ { placeCategoryId: 4, categoryName: "시야가 탁 트여있어요" },
+ { placeCategoryId: 5, categoryName: "주차가 편리해요" },
+ ],
+ tagReviews: [
+ { tagCategoryId: 1, tagName: "웅장한", iconUrl: "https://..." },
+ { tagCategoryId: 2, tagName: "섬세한", iconUrl: "https://..." },
+ ],
+ },
+ {
+ ticketReviewId: 2,
+ userName: "발레러버",
+ profileImage: Profile,
+ createdDate: "2024.10.25",
+ watchSit: "1층 R열 23번",
+ starRate: 4.5,
+ content:
+ "발레단의 움직임이 정말 아름다웠어요. 특히 주인공의 독무 장면은 잊을 수 없을 것 같네요. 공연장의 분위기도 좋았고 객석에서 보이는 각도도 훌륭했습니다.",
+ reviewImages: [
+ { imageUrl: ReviewImage },
+ { imageUrl: Review4 },
+ { imageUrl: Review5 },
+ ],
+ placeReviews: [
+ { placeCategoryId: 4, categoryName: "시야가 탁 트여있어요" },
+ { placeCategoryId: 3, categoryName: "좌석이 편안해요" },
+ { placeCategoryId: 1, categoryName: "음향이 좋아요" },
+ { placeCategoryId: 5, categoryName: "주차가 편리해요" },
+ ],
+ tagReviews: [
+ { tagCategoryId: 2, tagName: "섬세한", iconUrl: "https://..." },
+ { tagCategoryId: 3, tagName: "고전적인", iconUrl: "https://..." },
+ ],
+ },
+ {
+ ticketReviewId: 3,
+ userName: "공연매니아",
+ profileImage: Profile,
+ createdDate: "2024.10.23",
+ watchSit: "2층 C열 7번",
+ starRate: 3.5,
+ content:
+ "무용수들의 실력은 훌륭했지만, 좌석이 좀 불편했네요. 앞좌석과의 간격이 좁아서 무릎이 불편했어요. 하지만 공연 자체는 매우 인상적이었습니다.무용수들의 실력은 훌륭했지만, 좌석이 좀 불편했네요. 앞좌석과의 간격이 좁아서 무릎이 불편했어요. 하지만 공연 자체는 매우 인상적이었습니다.무용수들의 실력은 훌륭했지만, 좌석이 좀 불편했네요. 앞좌석과의 간격이 좁아서 무릎이 불편했어요. 하지만 공연 자체는 매우 인상적이었습니다.무용수들의 실력은 훌륭했지만, 좌석이 좀 불편했네요. 앞좌석과의 간격이 좁아서 무릎이 불편했어요. 하지만 공연 자체는 매우 인상적이었습니다.",
+ reviewImages: [],
+ placeReviews: [
+ { placeCategoryId: 6, categoryName: "좌석 간격이 좁아요" },
+ { placeCategoryId: 7, categoryName: "시야 확보가 어려워요" },
+ { placeCategoryId: 1, categoryName: "음향이 좋아요" },
+ { placeCategoryId: 8, categoryName: "주차 공간이 부족해요" },
+ ],
+ tagReviews: [
+ { tagCategoryId: 1, tagName: "웅장한", iconUrl: "https://..." },
+ ],
+ },
+ {
+ ticketReviewId: 4,
+ userName: "클래식팬",
+ profileImage: Profile,
+ createdDate: "2024.10.20",
+ watchSit: "VIP석 A열 12번",
+ starRate: 5.0,
+ content:
+ "오케스트라의 연주가 정말 완벽했어요! 음향 시설이 잘 되어있어서 섬세한 소리까지 잘 들렸고, VIP석이라 그런지 시야도 정말 좋았습니다. 다음에도 또 오고 싶네요.",
+ reviewImages: [
+ { imageUrl: Review2 },
+ { imageUrl: Review3 },
+ { imageUrl: Review4 },
+ ],
+ placeReviews: [
+ { placeCategoryId: 1, categoryName: "음향이 좋아요" },
+ { placeCategoryId: 4, categoryName: "시야가 탁 트여있어요" },
+ { placeCategoryId: 3, categoryName: "좌석이 편안해요" },
+ { placeCategoryId: 2, categoryName: "좌석 간격이 넓어요" },
+ { placeCategoryId: 5, categoryName: "주차가 편리해요" },
+ ],
+ tagReviews: [
+ { tagCategoryId: 1, tagName: "웅장한", iconUrl: "https://..." },
+ { tagCategoryId: 2, tagName: "섬세한", iconUrl: "https://..." },
+ { tagCategoryId: 3, tagName: "고전적인", iconUrl: "https://..." },
+ ],
+ },
+ {
+ ticketReviewId: 5,
+ userName: "드라큘미",
+ profileImage: Profile,
+ createdDate: "2024.11.14",
+ watchSit: "VIP석 C열 8번",
+ starRate: 5.0,
+ content:
+ "크리스마스 밤, 클라라의 집에서 뭐가 벌어지는 것 같긴한데, 내용이 어떻게 될까요...(중략)...지금은 열시다",
+ reviewImages: [
+ { imageUrl: Review4 },
+ { imageUrl: Review2 },
+ { imageUrl: Review7 },
+ ],
+ placeReviews: [
+ { placeCategoryId: 1, categoryName: "음향이 좋아요" },
+ { placeCategoryId: 4, categoryName: "시야가 탁 트여있어요" },
+ { placeCategoryId: 3, categoryName: "좌석이 편안해요" },
+ { placeCategoryId: 2, categoryName: "좌석 간격이 넓어요" },
+ { placeCategoryId: 5, categoryName: "주차가 편리해요" },
+ ],
+ tagReviews: [
+ { tagCategoryId: 1, tagName: "웅장한", iconUrl: "https://..." },
+ { tagCategoryId: 5, tagName: "서정적인", iconUrl: "https://..." },
+ ],
+ },
+];
+
+/*
+음향이 좋아요
+음향이 나빠요
+좌석 간격이 넓어요
+좌석 간격이 좁아요
+좌석이 편안해요
+좌석이 불편해요
+시야가 탁 트여있어요
+시야 확보가 어려워요
+주차가 편리해요
+주차 공간이 부족해요
+*/
diff --git a/src/components/Review/ReviewCard/index.tsx b/src/components/Review/ReviewCard/index.tsx
new file mode 100644
index 0000000..ba4841a
--- /dev/null
+++ b/src/components/Review/ReviewCard/index.tsx
@@ -0,0 +1,94 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ReactComponent as Star } from "@/assets/svgs/StarRating.svg";
+import { useTruncateText } from "@/hooks/utils";
+import { ReviewTag } from "@/components/common/ReviewTag";
+import { useNavigate } from "react-router-dom";
+import { ReviewCardProps } from "@/types";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export const ReviewCard = ({ review, onClick }: ReviewCardProps) => {
+ const SHOW_LENGTH = review.reviewImages.length === 0 ? 90 : 60;
+
+ const navigate = useNavigate();
+ const gotoReviewDetail = (id: number) => {
+ navigate(`/show/1/reviews/${id}`);
+ };
+
+ return (
+
+
+
+
+
{review.userName}
+
+
+
+
+ {review.reviewImages.length === 0 ? null : (
+
+
+
+ {review.reviewImages.length}
+
+
+ )}
+
+
+
+
+
+ {review.starRate}
+
+
+
+ {useTruncateText(review.content, SHOW_LENGTH)}
+
+
+
+
+
+
공연장
+
+ {review.watchSit}
+
+
+
+
+ {review.placeReviews.map((lReview) => (
+
+ {lReview.categoryName}
+
+ ))}
+
+
+
+
+ {review.createdDate.replace(/-/g, ".")}
+
+
+
gotoReviewDetail(review.ticketReviewId)}>
+ 더 보기
+
+
+
+
+
+ );
+};
+
+ReviewCard.Skeleton = () => {
+ return ;
+};
diff --git a/src/components/ShowDetail/AudienceReviews/ReviewSummaryCard/index.tsx b/src/components/ShowDetail/AudienceReviews/ReviewSummaryCard/index.tsx
new file mode 100644
index 0000000..1d98076
--- /dev/null
+++ b/src/components/ShowDetail/AudienceReviews/ReviewSummaryCard/index.tsx
@@ -0,0 +1,41 @@
+import { ReactComponent as StarRating } from "@/assets/svgs/StarRating.svg";
+import { TicketSimpleReview } from "@/types";
+import { useNavigate, useParams } from "react-router-dom";
+
+export const ReviewSummaryCard = ({
+ ticketReviewId,
+ nickname,
+ starRate,
+ content,
+}: TicketSimpleReview) => {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const gotoShowReivewDetail = () => {
+ navigate(`/show/${id}/reviews/${ticketReviewId}`);
+ };
+ return (
+
+
+
+
+ {nickname}
+
+
+
+ {starRate.toFixed(1)}
+
+
+
+ 더 보기
+
+
+
+
+ {content}
+
+
+ );
+};
diff --git a/src/components/ShowDetail/AudienceReviews/index.tsx b/src/components/ShowDetail/AudienceReviews/index.tsx
new file mode 100644
index 0000000..2cd5e7c
--- /dev/null
+++ b/src/components/ShowDetail/AudienceReviews/index.tsx
@@ -0,0 +1,65 @@
+import { Swiper, SwiperSlide } from "swiper/react";
+import "swiper/css";
+import { forwardRef } from "react";
+import { ReviewSummaryCard } from "./ReviewSummaryCard";
+import { useNavigate, useParams } from "react-router-dom";
+import { TicketSimpleReview } from "@/types";
+
+export type AudienceReviewsProps = {
+ reviews: TicketSimpleReview[];
+};
+
+const AudienceReviews = forwardRef(
+ ({ reviews }, ref) => {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const gotoShowReview = () => {
+ navigate(`/show/${id}/reviews`);
+ };
+ return (
+
+
+
+
+ {reviews.length > 0 ? (
+
+ {reviews.map((review, index) => (
+
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ );
+ },
+);
+
+export default AudienceReviews;
diff --git a/src/components/ShowDetail/RelatedShowRecommend/index.tsx b/src/components/ShowDetail/RelatedShowRecommend/index.tsx
new file mode 100644
index 0000000..5c02002
--- /dev/null
+++ b/src/components/ShowDetail/RelatedShowRecommend/index.tsx
@@ -0,0 +1,56 @@
+import { Swiper, SwiperSlide } from "swiper/react";
+import { VerticalInfoCard } from "@/components/Main/InformationCard";
+import { forwardRef, useEffect, useState } from "react";
+import { ConcertBased } from "@/types";
+import { useGetConcertBased } from "@/hooks/queries";
+import { useParams } from "react-router-dom";
+
+const RelatedShowsRecommend = forwardRef((_, ref) => {
+ const [concertBased, setConcertBased] = useState([]);
+ const { id } = useParams<{ id: string }>();
+ const { data, isLoading } = useGetConcertBased(Number(id));
+
+ useEffect(() => {
+ if (!isLoading && data?.result) {
+ setConcertBased(data.result);
+ }
+ }, [isLoading, data]);
+
+ if (isLoading) {
+ return 로딩 중..
;
+ }
+ return (
+
+
+
+ 이 공연도 마음에 들 거예요!
+
+
+
+ {concertBased.map((concert, index) => (
+
+
+
+ ))}
+
+
+
+
+ );
+});
+
+export default RelatedShowsRecommend;
diff --git a/src/components/ShowDetail/ShowInformation/ShowEssentials/index.tsx b/src/components/ShowDetail/ShowInformation/ShowEssentials/index.tsx
new file mode 100644
index 0000000..278900b
--- /dev/null
+++ b/src/components/ShowDetail/ShowInformation/ShowEssentials/index.tsx
@@ -0,0 +1,87 @@
+import { forwardRef } from "react";
+
+export type PrfGuidance = {
+ day: string;
+ times: string[];
+};
+
+export type ShowEssentialsProps = {
+ fcltynm: string;
+ prfruntime: string;
+ prfdate: string;
+ dtguidance: PrfGuidance[];
+ seats: string[];
+ prices: string[];
+};
+
+const ShowEssentials = forwardRef(
+ ({ fcltynm, prfruntime, prfdate, dtguidance, seats, prices }, ref) => {
+ return (
+
+
+
공연 정보
+
+
+
+ 기간
+ {prfdate}
+
+
+
+ 공연장소
+ {fcltynm}
+
+
+
+ 러닝타임
+
+ {prfruntime}
+
+
+
+
+
공연시간
+
+
+ *해당하는 요일과 시간에만 공연이 진행돼요
+
+ {dtguidance.map(({ day, times }: PrfGuidance) => (
+
+
+ {day}
+
+
+
+ {times.map((time: string, index: number) => (
+ {time}
+ ))}
+
+
+ ))}
+
+
+
+
+
가격
+
+ {seats.map((seat, index) => (
+
+
+ {seat}
+
+
+
+ {prices[index]}
+
+
+ ))}
+
+
+
+
+
+ );
+ },
+);
+
+export default ShowEssentials;
diff --git a/src/components/ShowDetail/ShowInformation/ShowOverview/index.tsx b/src/components/ShowDetail/ShowInformation/ShowOverview/index.tsx
new file mode 100644
index 0000000..7e5744a
--- /dev/null
+++ b/src/components/ShowDetail/ShowInformation/ShowOverview/index.tsx
@@ -0,0 +1,190 @@
+import { ReactComponent as ClacoMain } from "@/assets/svgs/Claco_Main.svg";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { CategoryTag } from "@/components/common/CategoryTag";
+import { ReactComponent as Heart } from "@/assets/svgs/Heart.svg";
+import { ReactComponent as Megaphone } from "@/assets/svgs/Megaphone.svg";
+import { ReactComponent as Book } from "@/assets/svgs/Book.svg";
+import { Genre } from "@/components/common/Genre";
+import { useNavigate, useParams } from "react-router-dom";
+import { useEffect, useRef, useState } from "react";
+import { ShowCategory } from "@/types";
+import { usePostLike } from "@/hooks/mutation";
+import { useConcertInfoStore } from "@/libraries/store/concertInfo";
+
+export type ShowOverViewProps = {
+ prfstate: string;
+ prfprice: string;
+ genrenm: string;
+ prfnm: string;
+ poster: string;
+ area: string;
+ prfruntime: string;
+ prfage: string;
+ prfdate: string;
+ summary: string;
+ categories: ShowCategory[];
+ liked: boolean;
+};
+
+const ShowOverview = ({
+ prfstate,
+ prfage,
+ prfruntime,
+ genrenm,
+ prfnm,
+ poster,
+ area,
+ prfdate,
+ prfprice,
+ summary,
+ categories,
+ liked,
+}: ShowOverViewProps) => {
+ const { setConcertInfo } = useConcertInfoStore();
+ useEffect(() => {
+ setConcertInfo({
+ genrenm,
+ prfstate,
+ prfnm,
+ });
+ }, [genrenm, prfstate, prfnm, setConcertInfo]);
+
+ const navigate = useNavigate();
+ const [_liked, setLiked] = useState(liked);
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [showButton, setShowButton] = useState(false);
+ const textRef = useRef(null);
+ const mutation = usePostLike();
+ const { id } = useParams<{ id: string }>();
+
+ useEffect(() => {
+ if (textRef.current) {
+ const height = textRef.current.getBoundingClientRect().height;
+
+ setShowButton(height > 40);
+ }
+ }, [summary]);
+
+ const gotoBack = () => {
+ navigate(-1);
+ };
+
+ const handleLike = () => {
+ setLiked((prev) => !prev);
+ mutation.mutate(Number(id));
+ };
+
+ const toggleExpanded = () => {
+ setIsExpanded(!isExpanded);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {prfnm}
+
+
+
+
+
+
+
+
+
+
+
+
+ 장소
+ 기간
+ 시간
+ 연령
+ 가격
+
+
+ {area}
+ {prfdate}
+ {prfruntime}
+ {prfage}
+ {prfprice}
+
+
+
+ 예매하러가기
+
+
+
+
+
+
+
+ Claco 쉬운 공연 설명
+
+
+
+
+ {summary || ""}
+
+
+ {showButton && (
+
+
+
+ )}
+
+
+
+ 이런 느낌의 공연이에요
+
+
+ {categories?.map((item, index) => (
+
+ ))}
+
+
+
+ );
+};
+
+export default ShowOverview;
diff --git a/src/components/ShowDetail/ShowInformation/ShowPoster/index.tsx b/src/components/ShowDetail/ShowInformation/ShowPoster/index.tsx
new file mode 100644
index 0000000..fd78c38
--- /dev/null
+++ b/src/components/ShowDetail/ShowInformation/ShowPoster/index.tsx
@@ -0,0 +1,62 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { forwardRef, useRef, useState } from "react";
+
+export type DetailsInfoSectionProps = {
+ showFullImage: boolean;
+ setShowFullImage: (show: boolean) => void;
+ styurl: string;
+};
+
+const ShowPoster = forwardRef(
+ ({ showFullImage, setShowFullImage, styurl }, ref) => {
+ const [isImageOverflow, setIsImageOverflow] = useState(false);
+ const imageRef = useRef(null);
+
+ const checkImageHeight = () => {
+ if (imageRef.current) {
+ const imageHeight = imageRef.current.naturalHeight;
+ setIsImageOverflow(imageHeight > 4000);
+ }
+ };
+
+ const toggleFullImageView = () => {
+ setShowFullImage(!showFullImage);
+ };
+
+ return (
+
+
+
상세정보
+
+
+
+
+ {isImageOverflow && (
+
+ )}
+
+
+
+ );
+ },
+);
+
+export default ShowPoster;
diff --git a/src/components/Ticket/AudienceReview/KeywordTags/index.tsx b/src/components/Ticket/AudienceReview/KeywordTags/index.tsx
new file mode 100644
index 0000000..bf2dc4c
--- /dev/null
+++ b/src/components/Ticket/AudienceReview/KeywordTags/index.tsx
@@ -0,0 +1,40 @@
+import { ReviewTag } from "@/components/common/ReviewTag";
+import { KeywordTagProps } from "@/types";
+
+export const KeywordTags = ({
+ selectedTags,
+ onTagClick,
+ tagCategories,
+}: KeywordTagProps) => {
+ const handleClick = (tag: string) => {
+ if (selectedTags.includes(tag)) {
+ onTagClick(tag);
+ } else if (selectedTags.length < 5) {
+ onTagClick(tag);
+ }
+ };
+
+ return (
+
+
+ 공연 성격에 해당되는 키워드를 5개 선택해주세요
+
+
+ {[0, 4, 8].map((startIndex) => (
+
+ {tagCategories?.slice(startIndex, startIndex + 4).map((tag) => (
+ handleClick(tag.tagName)}
+ isSelected={selectedTags.includes(tag.tagName)}
+ isPlace={true}
+ >
+ {tag.tagName}
+
+ ))}
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Ticket/AudienceReview/ReviewContents/ImageReview/index.tsx b/src/components/Ticket/AudienceReview/ReviewContents/ImageReview/index.tsx
new file mode 100644
index 0000000..7f1570e
--- /dev/null
+++ b/src/components/Ticket/AudienceReview/ReviewContents/ImageReview/index.tsx
@@ -0,0 +1,51 @@
+import { ReactComponent as Plus } from "@/assets/svgs/plus.svg";
+import { ImageReviewProps } from "@/types";
+
+export const ImageReview = ({ files, onFileChange }: ImageReviewProps) => {
+ return (
+
+
+ 사진 또는 비디오를 첨부해
+ 보다 생생한 리뷰를 공유해주세요.
+
+
+ {files.length < 3 && (
+
+
+
+
+ {files.length}/3
+
+
+ )}
+
+ {files.map((file, index) => (
+
+ {file.type.startsWith("image") ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Ticket/AudienceReview/ReviewContents/TextReview/index.tsx b/src/components/Ticket/AudienceReview/ReviewContents/TextReview/index.tsx
new file mode 100644
index 0000000..3fef519
--- /dev/null
+++ b/src/components/Ticket/AudienceReview/ReviewContents/TextReview/index.tsx
@@ -0,0 +1,32 @@
+import { ReactComponent as Required } from "@/assets/svgs/required.svg";
+import { TextReviewProps } from "@/types";
+
+export const TextReview = ({ value, onChange }: TextReviewProps) => {
+ const handleChange = (e: React.ChangeEvent) => {
+ const text = e.target.value;
+ if (text.length <= 500) {
+ onChange(text);
+ }
+ };
+ return (
+
+
+
+ 감상평을 남겨주세요
+
+
+
+
+
+
+ {value.length}/500
+
+
+
+ );
+};
diff --git a/src/components/Ticket/AudienceReview/ReviewContents/index.tsx b/src/components/Ticket/AudienceReview/ReviewContents/index.tsx
new file mode 100644
index 0000000..6335a64
--- /dev/null
+++ b/src/components/Ticket/AudienceReview/ReviewContents/index.tsx
@@ -0,0 +1,17 @@
+import { TextReview } from "./TextReview";
+import { ImageReview } from "./ImageReview";
+import { ReviewContentProps } from "@/types";
+
+export const ReviewContents = ({
+ value,
+ onChange,
+ files,
+ onFileChange,
+}: ReviewContentProps) => {
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/src/components/Ticket/AudienceReview/ReviewQuestions/index.tsx b/src/components/Ticket/AudienceReview/ReviewQuestions/index.tsx
new file mode 100644
index 0000000..26fe56d
--- /dev/null
+++ b/src/components/Ticket/AudienceReview/ReviewQuestions/index.tsx
@@ -0,0 +1,147 @@
+import { ReactComponent as Required } from "@/assets/svgs/required.svg";
+import { ReviewTag } from "@/components/common/ReviewTag";
+import { ReviewQuestionProps } from "@/types";
+
+export const ReviewQuestions = ({
+ selectedSoundTag,
+ setSelectedSoundTag,
+ selectedSeatTag1,
+ setSelectedSeatTag1,
+ selectedSeatTag2,
+ setSelectedSeatTag2,
+ selectedSightTag,
+ setSelectedSightTag,
+ selectedAccessibilityTag,
+ setSelectedAccessibilityTag,
+ placeCategories,
+}: ReviewQuestionProps) => {
+ const soundPlaces = placeCategories.slice(0, 2);
+ const seatPlaces1 = placeCategories.slice(2, 4);
+ const seatPlaces2 = placeCategories.slice(4, 6);
+ const sightPlaces = placeCategories.slice(6, 8);
+ const accessibilityPlaces = placeCategories.slice(8, 10);
+
+ const handlePlaceClick = (
+ place: string,
+ setSelectedPlace: (value: string | null) => void,
+ ) => {
+ setSelectedPlace(place);
+ };
+
+ return (
+
+ {/* 음향 */}
+
+
+
+ Q. 음향은 어땠나요?
+
+
+
+
+ {soundPlaces.map((place) => (
+
+ handlePlaceClick(place.categoryName, setSelectedSoundTag)
+ }
+ isSelected={selectedSoundTag === place.categoryName}
+ isPlace={true}
+ >
+ {place.categoryName}
+
+ ))}
+
+
+
+ {/* 좌석 */}
+
+
+
+ Q. 좌석은 어땠나요?
+
+
+
+
+ {seatPlaces1.map((place) => (
+
+ handlePlaceClick(place.categoryName, setSelectedSeatTag1)
+ }
+ isSelected={selectedSeatTag1 === place.categoryName}
+ isPlace={true}
+ >
+ {place.categoryName}
+
+ ))}
+
+
+ {seatPlaces2.map((place) => (
+
+ handlePlaceClick(place.categoryName, setSelectedSeatTag2)
+ }
+ isSelected={selectedSeatTag2 === place.categoryName}
+ isPlace={true}
+ >
+ {place.categoryName}
+
+ ))}
+
+
+
+ {/* 시야 */}
+
+
+
+ Q. 시야는 어땠나요?
+
+
+
+
+ {sightPlaces.map((place) => (
+
+ handlePlaceClick(place.categoryName, setSelectedSightTag)
+ }
+ isSelected={selectedSightTag === place.categoryName}
+ isPlace={true}
+ >
+ {place.categoryName}
+
+ ))}
+
+
+
+ {/* 접근성 */}
+
+
+
+ Q. 접근성은 어땠나요?
+
+
+
+
+ {accessibilityPlaces.map((place) => (
+
+ handlePlaceClick(
+ place.categoryName,
+ setSelectedAccessibilityTag,
+ )
+ }
+ isSelected={selectedAccessibilityTag === place.categoryName}
+ isPlace={true}
+ >
+ {place.categoryName}
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/Ticket/AudienceReview/StarRating/index.tsx b/src/components/Ticket/AudienceReview/StarRating/index.tsx
new file mode 100644
index 0000000..136b3ed
--- /dev/null
+++ b/src/components/Ticket/AudienceReview/StarRating/index.tsx
@@ -0,0 +1,98 @@
+import { ReactComponent as Required } from "@/assets/svgs/required.svg";
+import { ReactComponent as FullStar } from "@/assets/svgs/StarRating.svg";
+import { ReactComponent as HalfStar } from "@/assets/svgs/halfStarRating.svg";
+
+import { StarRatingProps } from "@/types";
+import { ChangeEvent } from "react";
+
+export const StarRating = ({ rating = 0, onRatingChange }: StarRatingProps) => {
+ const STAR_IDX_ARR = ["first", "second", "third", "fourth", "last"];
+
+ const handleStarClick = (idx: number) => {
+ const newRating = idx + 1;
+ if (onRatingChange) {
+ onRatingChange(newRating);
+ }
+ };
+
+ const handleRangeChange = (e: ChangeEvent) => {
+ if (onRatingChange) {
+ onRatingChange(parseFloat(e.target.value));
+ }
+ };
+
+ return (
+
+
+
+ Q. 공연은 어떠셨나요?
+
+
+
+
+ {STAR_IDX_ARR.map((_, idx) => {
+ const currentRating = rating - idx;
+
+ let StarIcon;
+ if (currentRating >= 1) {
+ StarIcon = FullStar;
+ } else if (currentRating >= 0.5) {
+ StarIcon = HalfStar;
+ } else {
+ StarIcon = null;
+ }
+
+ return (
+
handleStarClick(idx)}
+ >
+ {StarIcon ? (
+ StarIcon === HalfStar ? (
+
+
+
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/src/components/Ticket/ClacoBook/index.tsx b/src/components/Ticket/ClacoBook/index.tsx
new file mode 100644
index 0000000..d049e73
--- /dev/null
+++ b/src/components/Ticket/ClacoBook/index.tsx
@@ -0,0 +1,43 @@
+import ClacoBook_Gray from "@/assets/images/ClacoBook/ClcaoBook_Gray.png";
+import ClacoBook_Orange from "@/assets/images/ClacoBook/ClcaoBook_Orange.png";
+import ClacoBook_Pink from "@/assets/images/ClacoBook/ClcaoBook_Pink.png";
+import { useEffect, useState } from "react";
+
+export type ClacoBookType = {
+ id: number | null;
+ title: string;
+ color: string;
+};
+
+export type ClacoBookProps = {
+ data: ClacoBookType;
+ isEditing: boolean;
+ children: React.ReactNode;
+};
+
+export const ClacoBook = ({ data, isEditing, children }: ClacoBookProps) => {
+ const [clacoBookImage, setClacoBookImage] = useState(ClacoBook_Gray);
+
+ useEffect(() => {
+ if (data.color === "#DD6339") setClacoBookImage(ClacoBook_Orange);
+ else if (data.color === "#D499B8") setClacoBookImage(ClacoBook_Pink);
+ else setClacoBookImage(ClacoBook_Gray);
+ }, [data.color]);
+
+ return (
+
+ {isEditing ? (
+
{children}
+ ) : null}
+
+ {data.title}
+
+
+
+ );
+};
diff --git a/src/components/Ticket/Modal/CreateEdit/index.tsx b/src/components/Ticket/Modal/CreateEdit/index.tsx
new file mode 100644
index 0000000..c228eae
--- /dev/null
+++ b/src/components/Ticket/Modal/CreateEdit/index.tsx
@@ -0,0 +1,89 @@
+import { Modal } from "@/components/common/Modal";
+import { ClacoBookType } from "../../ClacoBook";
+import { useEffect, useState } from "react";
+
+export type CreateEditModalProps = {
+ clacoBook?: ClacoBookType | null;
+ action: string;
+ onClose: () => void;
+ onConfirm: (newData: ClacoBookType) => void;
+};
+
+export const CreateEditModal = ({
+ clacoBook,
+ action,
+ onClose,
+ onConfirm,
+}: CreateEditModalProps) => {
+ const [id, setId] = useState();
+ const [title, setTitle] = useState("");
+ const [colorState, setColorState] = useState("");
+
+ useEffect(() => {
+ if (clacoBook) {
+ setId(clacoBook.id!);
+ setTitle(clacoBook.title);
+ setColorState(clacoBook.color);
+ }
+ }, [clacoBook]);
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setTitle(e.target.value);
+ };
+
+ return (
+
+ onConfirm({
+ id: id ?? null,
+ title: title,
+ color: colorState,
+ })
+ }
+ disabled={title.length === 0 || colorState.trim().length === 0}
+ >
+
+
+
+ 이름
+
+
+
+
+ {title.length}/15
+
+
+
+
+
+
+ 색상
+
+
+
setColorState("#DD6339")}
+ />
+
setColorState("#D499B8")}
+ />
+
setColorState("#9E8D8E")}
+ />
+
+
+
+
+ );
+};
diff --git a/src/components/Ticket/Modal/Delete/ClacoBook/index.tsx b/src/components/Ticket/Modal/Delete/ClacoBook/index.tsx
new file mode 100644
index 0000000..d39f916
--- /dev/null
+++ b/src/components/Ticket/Modal/Delete/ClacoBook/index.tsx
@@ -0,0 +1,33 @@
+import { Modal } from "@/components/common/Modal";
+import { ClacoBookType } from "../../../ClacoBook";
+
+export type DeleteModalProps = {
+ clacoBook: ClacoBookType;
+ onClose: () => void;
+ onConfirm: (clacoBookId: number) => void;
+};
+
+export const DeleteClacoBookModal = ({
+ clacoBook,
+ onClose,
+ onConfirm,
+}: DeleteModalProps) => {
+ return (
+
onConfirm(clacoBook.id!)}
+ >
+
+
+ 정말 이 티켓북을 삭제하시겠어요?
+
+
+ 지금 삭제하시면 기록된 모든 내용이 삭제되며
+ 삭제된 내용은 복구되지 않습니다.
+
+
+
+ );
+};
diff --git a/src/components/Ticket/Modal/Delete/ClacoTicket/index.tsx b/src/components/Ticket/Modal/Delete/ClacoTicket/index.tsx
new file mode 100644
index 0000000..d54513a
--- /dev/null
+++ b/src/components/Ticket/Modal/Delete/ClacoTicket/index.tsx
@@ -0,0 +1,30 @@
+import { Modal } from "@/components/common/Modal";
+
+export type DeleteModalProps = {
+ onClose: () => void;
+ onConfirm: () => void;
+};
+
+export const DeleteClacoTicketModal = ({
+ onClose,
+ onConfirm,
+}: DeleteModalProps) => {
+ return (
+
+
+
+ 정말 이 티켓을 삭제하시겠어요?
+
+
+ 지금 삭제하시면 기록된 모든 내용이 삭제되며
+ 삭제된 내용은 복구되지 않습니다.
+
+
+
+ );
+};
diff --git a/src/components/Ticket/Modal/DownLoad/index.tsx b/src/components/Ticket/Modal/DownLoad/index.tsx
new file mode 100644
index 0000000..8c6b00b
--- /dev/null
+++ b/src/components/Ticket/Modal/DownLoad/index.tsx
@@ -0,0 +1,21 @@
+import { Modal } from "@/components/common/Modal";
+
+export type DownLoadModalProps = {
+ onClose: () => void;
+ onConfirm: () => void;
+};
+
+export const DownLoadModal = ({ onClose, onConfirm }: DownLoadModalProps) => {
+ return (
+
+
+ 이 티켓을 다운받으시겠어요?
+
+
+ );
+};
diff --git a/src/components/Ticket/Modal/Move/index.tsx b/src/components/Ticket/Modal/Move/index.tsx
new file mode 100644
index 0000000..fbd17de
--- /dev/null
+++ b/src/components/Ticket/Modal/Move/index.tsx
@@ -0,0 +1,65 @@
+import { Modal } from "@/components/common/Modal";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { ClacoBookList } from "@/types";
+import { useState } from "react";
+
+export type MoveModalProps = {
+ clacoBookList: ClacoBookList[];
+ onClose: () => void;
+ onConfirm: () => void;
+ onSelect: React.Dispatch
>;
+};
+
+export const MoveModal = ({
+ clacoBookList,
+ onClose,
+ onConfirm,
+ onSelect,
+}: MoveModalProps) => {
+ const [isMove, setIsMove] = useState(false);
+ const [isSelect, setIsSelect] = useState(false);
+ const handleConfirm = () => {
+ if (isMove) {
+ onConfirm();
+ } else {
+ setIsMove(true);
+ }
+ };
+ return (
+
+ {isMove ? (
+
+
+ 선택하신 폴더로 티켓을 이동하시겠어요?
+
+
+ ) : (
+
+
+ {clacoBookList.map((item, index) => (
+
+ {
+ setIsSelect(true);
+ onSelect(item as ClacoBookList);
+ }}
+ className="mr-[10px] border-grayscale-50 text-grayscale-50"
+ />
+ {item.title}
+
+ ))}
+
+
+ )}
+
+ );
+};
diff --git a/src/components/Ticket/PerformanceAttributes/index.tsx b/src/components/Ticket/PerformanceAttributes/index.tsx
new file mode 100644
index 0000000..84a2854
--- /dev/null
+++ b/src/components/Ticket/PerformanceAttributes/index.tsx
@@ -0,0 +1,32 @@
+import { ReactComponent as Book } from "@/assets/svgs/Book.svg";
+import { Genre } from "@/components/common/Genre";
+import { TagCategory } from "@/types";
+
+export type PerformacneAttributesProps = {
+ categories: TagCategory[];
+ title: string;
+};
+
+export const PerformanceAttributes = ({
+ categories,
+ title,
+}: PerformacneAttributesProps) => {
+ return (
+
+
+
+ {title}
+
+
+ {categories.map((item) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/Ticket/const/index.ts b/src/components/Ticket/const/index.ts
new file mode 100644
index 0000000..faff47a
--- /dev/null
+++ b/src/components/Ticket/const/index.ts
@@ -0,0 +1,121 @@
+import { TicketReview } from "@/types";
+
+import Review1 from "@/assets/images/poster1.gif";
+import Review2 from "@/assets/images/poster2.gif";
+import Review3 from "@/assets/images/poster3.webp";
+import Review4 from "@/assets/images/poster4.gif";
+import Review5 from "@/assets/images/poster5.gif";
+import Review6 from "@/assets/images/poster6.gif";
+
+import classical from "@/assets/images/Genre/classical.png";
+import delicate from "@/assets/images/Genre/delicate.png";
+import dynamic from "@/assets/images/Genre/dynamic.png";
+import familiar from "@/assets/images/Genre/familiar.png";
+import grand from "@/assets/images/Genre/grand.png";
+import lyrical from "@/assets/images/Genre/lyrical.png";
+import modern from "@/assets/images/Genre/modern.png";
+import novel from "@/assets/images/Genre/novel.png";
+import romantic from "@/assets/images/Genre/romantic.png";
+import tragic from "@/assets/images/Genre/classical.png";
+
+export const TICKET_REVIEW_MOCK_DATA: TicketReview[] = [
+ {
+ ticketReviewId: 1,
+ concertName: "오페라의 유령",
+ nickname: "사용자1",
+ watchDate: "2024-11-14",
+ createdDate: "2024-11-14",
+ watchPlace: "예술의 전당",
+ watchRound: "17:00",
+ runningTime: "150분",
+ castings:
+ "홍향기, 한상이, 엄재용, 이혜민, 김현수, 서현재, 최윤희, 권태환, 이현준, 허요완, 강효형, 박세은, 이은혜, 이주희, 홍승연, 김승현",
+ watchSit: "1층 3열",
+ concertTags: [
+ { tagCategoryId: 1, tagName: "웅장한", iconUrl: grand },
+ { tagCategoryId: 2, tagName: "섬세한", iconUrl: delicate },
+ { tagCategoryId: 3, tagName: "고전적인", iconUrl: classical },
+ { tagCategoryId: 4, tagName: "현대적인", iconUrl: modern },
+ { tagCategoryId: 5, tagName: "서정적인", iconUrl: lyrical },
+ ],
+ starRate: 3.5,
+ content:
+ "공연이 재미있어요. 특히 주인공의 노래 실력이 인상적이었고 무대 연출도 훌륭했습니다.",
+ placeReviews: [
+ { placeCategoryId: 1, categoryName: "음향이 좋았어요" },
+ { placeCategoryId: 2, categoryName: "좌석 간격이 넓어요" },
+ { placeCategoryId: 3, categoryName: "좌석이 불편해요" },
+ { placeCategoryId: 4, categoryName: "시야가 탁 트여있어요" },
+ { placeCategoryId: 5, categoryName: "주차가 편리해요" },
+ ],
+ imageUrlS: [{ imageUrl: Review1 }, { imageUrl: Review2 }],
+ editor: true,
+ },
+ {
+ ticketReviewId: 2,
+ concertName: "지킬앤하이드",
+ nickname: "공연매니아",
+ watchDate: "2024-11-13",
+ createdDate: "2024-11-13",
+ watchPlace: "샤롯데씨어터",
+ watchRound: "19:30",
+ runningTime: "165분",
+ castings:
+ "서현재, 최윤희, 권태환, 이현준, 허요완, Marianela Nuñez, Vadim Muntagirov, Leo Dixon, Mayara Magri",
+ watchSit: "1층 R석 15번",
+ concertTags: [
+ { tagCategoryId: 6, tagName: "역동적인", iconUrl: dynamic },
+ { tagCategoryId: 7, tagName: "낭만적인", iconUrl: romantic },
+ { tagCategoryId: 8, tagName: "비극적인", iconUrl: tragic },
+ { tagCategoryId: 9, tagName: "친숙한", iconUrl: familiar },
+ { tagCategoryId: 10, tagName: "새로운", iconUrl: novel },
+ ],
+ starRate: 5.0,
+ content:
+ "조승우의 변신이 정말 놀라웠습니다. 한 명의 배우가 두 개의 캐릭터를 완벽하게 소화해내는 모습이 인상적이었어요.",
+ placeReviews: [
+ { placeCategoryId: 6, categoryName: "음향이 아쉬웠어요" },
+ { placeCategoryId: 7, categoryName: "좌석 간격이 좁아요" },
+ { placeCategoryId: 8, categoryName: "좌석이 편안해요" },
+ { placeCategoryId: 9, categoryName: "시야 확보가 어려워요" },
+ { placeCategoryId: 10, categoryName: "주차 공간이 부족해요" },
+ ],
+ imageUrlS: [
+ { imageUrl: Review3 },
+ { imageUrl: Review4 },
+ { imageUrl: Review5 },
+ ],
+ editor: false,
+ },
+ {
+ ticketReviewId: 3,
+ concertName: "라보엠",
+ nickname: "오페라덕후",
+ watchDate: "2024-11-12",
+ createdDate: "2024-11-12",
+ watchPlace: "세종문화회관",
+ watchRound: "14:00",
+ runningTime: "180분",
+ castings: "김수미, 이지연",
+ watchSit: "",
+ concertTags: [
+ { tagCategoryId: 3, tagName: "고전적인", iconUrl: classical },
+ { tagCategoryId: 4, tagName: "현대적인", iconUrl: modern },
+ { tagCategoryId: 6, tagName: "역동적인", iconUrl: dynamic },
+ { tagCategoryId: 7, tagName: "낭만적인", iconUrl: romantic },
+ { tagCategoryId: 8, tagName: "비극적인", iconUrl: tragic },
+ ],
+ starRate: 4.5,
+ content:
+ "파리의 겨울을 배경으로 한 무대 연출이 아름다웠고, 미미와 로돌포의 이중창이 특히 감동적이었습니다.",
+ placeReviews: [
+ { placeCategoryId: 1, categoryName: "음향이 좋았어요" },
+ { placeCategoryId: 2, categoryName: "좌석 간격이 넓어요" },
+ { placeCategoryId: 3, categoryName: "좌석이 불편해요" },
+ { placeCategoryId: 4, categoryName: "시야가 탁 트여있어요" },
+ { placeCategoryId: 5, categoryName: "주차가 편리해요" },
+ ],
+ imageUrlS: [{ imageUrl: Review6 }],
+ editor: true,
+ },
+];
diff --git a/src/components/common/Age/index.tsx b/src/components/common/Age/index.tsx
new file mode 100644
index 0000000..190472a
--- /dev/null
+++ b/src/components/common/Age/index.tsx
@@ -0,0 +1,27 @@
+import { TypeButton } from "@/components/Onboarding/Registration";
+import { AgeProps } from "@/types";
+
+export const Age = ({ selectedAge, onAgeSelect }: AgeProps) => {
+ const ageOptions = [
+ { label: "10대", value: 10 },
+ { label: "20대", value: 20 },
+ { label: "30대", value: 30 },
+ { label: "40대", value: 40 },
+ { label: "50대", value: 50 },
+ { label: "60대 이상", value: 60 },
+ ];
+
+ return (
+
+ {ageOptions.map((age) => (
+ onAgeSelect(age.value)}
+ >
+ {age.label}
+
+ ))}
+
+ );
+};
diff --git a/src/components/common/Button/index.tsx b/src/components/common/Button/index.tsx
new file mode 100644
index 0000000..4bec81d
--- /dev/null
+++ b/src/components/common/Button/index.tsx
@@ -0,0 +1,30 @@
+import { cn } from "@/lib/utils";
+
+interface ButtonProps extends React.ButtonHTMLAttributes {
+ isChecked: boolean;
+}
+
+export const ConfirmButton = ({
+ isChecked,
+ children,
+ className = "",
+ disabled,
+ ...props
+}: ButtonProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/common/Calendar/CalendarDay/index.tsx b/src/components/common/Calendar/CalendarDay/index.tsx
new file mode 100644
index 0000000..9fc1eff
--- /dev/null
+++ b/src/components/common/Calendar/CalendarDay/index.tsx
@@ -0,0 +1,49 @@
+import { CalendarDayProps } from "@/types";
+
+export const CalendarDay = ({
+ day,
+ isSelected,
+ isInRange,
+ isWithinRange,
+ isRangeStart,
+ isRangeEnd,
+ mode,
+ onClick,
+}: CalendarDayProps) => {
+ return (
+
+ {isWithinRange && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/common/Calendar/index.tsx b/src/components/common/Calendar/index.tsx
new file mode 100644
index 0000000..a94154c
--- /dev/null
+++ b/src/components/common/Calendar/index.tsx
@@ -0,0 +1,192 @@
+import { useEffect, useState } from "react";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ReactComponent as RightArrow } from "@/assets/svgs/RightArrow.svg";
+import { CalendarProps } from "@/types";
+import { CalendarDay } from "./CalendarDay";
+
+export const Calendar = ({
+ mode = "single",
+ selectedDate = null,
+ onDateSelect,
+ showTimesByDate,
+ startYear = new Date().getFullYear(),
+ startMonth = new Date().getMonth(),
+ rangeStart = null,
+ rangeEnd = null,
+ onRangeSelect,
+}: CalendarProps) => {
+ const [currentDate, setCurrentDate] = useState(
+ new Date(startYear || new Date().getFullYear(), (startMonth || 1) - 1, 1),
+ );
+
+ const [internalRangeStart, setInternalRangeStart] = useState(
+ rangeStart,
+ );
+ const [internalRangeEnd, setInternalRangeEnd] = useState(
+ rangeEnd,
+ );
+
+ const performanceDates = Object.keys(showTimesByDate || "");
+
+ const getYear = () => currentDate.getFullYear();
+ const getMonth = () => currentDate.getMonth();
+
+ const startOfMonth = new Date(getYear(), getMonth(), 1);
+ const endOfMonth = new Date(getYear(), getMonth() + 1, 0);
+ const startDay = startOfMonth.getDay();
+
+ useEffect(() => {
+ if (startYear && startMonth) {
+ setCurrentDate(new Date(startYear, startMonth, 1));
+ }
+ }, [startYear, startMonth]);
+
+ useEffect(() => {
+ setInternalRangeStart(rangeStart);
+ setInternalRangeEnd(rangeEnd);
+ }, [rangeStart, rangeEnd]);
+
+ // single mode: 티켓 등록, range mode: 둘러보기
+ const handleDateClick = (day: number) => {
+ const date = new Date(getYear(), getMonth(), day);
+
+ if (mode === "single") {
+ if (isDateInRange(date) && onDateSelect) {
+ onDateSelect(date);
+ }
+ } else if (mode === "range") {
+ if (!internalRangeStart || (internalRangeStart && internalRangeEnd)) {
+ setInternalRangeStart(date);
+ setInternalRangeEnd(null);
+ } else {
+ if (date < internalRangeStart) {
+ setInternalRangeStart(date);
+ } else {
+ setInternalRangeEnd(date);
+ if (onRangeSelect && internalRangeStart) {
+ onRangeSelect(internalRangeStart, date);
+ }
+ }
+ }
+ }
+ };
+
+ const getCalendarDays = () => {
+ const days: (number | null)[] = [];
+ for (let i = 0; i < startDay; i++) {
+ days.push(null);
+ }
+ for (let day = 1; day <= endOfMonth.getDate(); day++) {
+ days.push(day);
+ }
+ return days;
+ };
+
+ const changeMonth = (months: number) => {
+ setCurrentDate(new Date(getYear(), getMonth() + months, 1));
+ };
+
+ const isDateInRange = (date: Date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ const dateString = `${year}-${month}-${day}`;
+ return performanceDates.includes(dateString);
+ };
+
+ const isWithinSelectedRange = (date: Date) => {
+ return (
+ internalRangeStart &&
+ internalRangeEnd &&
+ date >= internalRangeStart &&
+ date <= internalRangeEnd
+ );
+ };
+
+ return (
+
+
+ changeMonth(-1)}
+ />
+
+ {`${getYear()}년 ${getMonth() + 1}월`}
+
+ changeMonth(1)}
+ />
+
+
+
+
+ {(() => {
+ const days = getCalendarDays();
+ const rows = [];
+ for (let i = 0; i < days.length; i += 7) {
+ const week = days.slice(i, i + 7);
+ rows.push(
+
+ {week.map((day, index) => {
+ const date = day
+ ? new Date(getYear(), getMonth(), day)
+ : null;
+ const isInRange =
+ mode === "single" && date && isDateInRange(date);
+ const isSelected =
+ mode === "single" &&
+ date &&
+ selectedDate &&
+ selectedDate.toDateString() === date.toDateString();
+ const isWithinRange =
+ mode === "range" && date && isWithinSelectedRange(date);
+ const isRangeStart =
+ mode === "range" &&
+ date &&
+ internalRangeStart &&
+ date.toDateString() === internalRangeStart.toDateString();
+ const isRangeEnd =
+ mode === "range" &&
+ date &&
+ internalRangeEnd &&
+ date.toDateString() === internalRangeEnd.toDateString();
+
+ return (
+
+ {day ? (
+ handleDateClick(day)}
+ />
+ ) : (
+
+ )}
+
+ );
+ })}
+ ,
+ );
+ }
+ return rows;
+ })()}
+
+
+
+ );
+};
diff --git a/src/components/common/CategoryTag/index.tsx b/src/components/common/CategoryTag/index.tsx
new file mode 100644
index 0000000..1d669a7
--- /dev/null
+++ b/src/components/common/CategoryTag/index.tsx
@@ -0,0 +1,36 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { CategoryTagProps } from "@/types";
+
+export const CategoryTag = ({ categoryType, className }: CategoryTagProps) => {
+ const tagStyle = () => {
+ switch (categoryType) {
+ case "서양음악(클래식)":
+ return "bg-primary-700";
+ case "무용":
+ return "bg-[#6370E4]";
+ case "공연중":
+ return "border-[1px] border-grayscale-70";
+ case "공연예정":
+ return "border-[1px] border-grayscale-70";
+ default:
+ return "border-[1px] border-grayscale-70";
+ }
+ };
+ return (
+
+ {categoryType === "서양음악(클래식)"
+ ? "클래식"
+ : categoryType === "무용"
+ ? "무용"
+ : categoryType === "공연중"
+ ? "공연 중"
+ : "공연 예정"}
+
+ );
+};
+
+CategoryTag.Skeleton = () => {
+ return ;
+};
diff --git a/src/components/common/ClacoAnalysisCard/index.tsx b/src/components/common/ClacoAnalysisCard/index.tsx
new file mode 100644
index 0000000..8ad04ab
--- /dev/null
+++ b/src/components/common/ClacoAnalysisCard/index.tsx
@@ -0,0 +1,136 @@
+import { useEffect, useState } from "react";
+import { ReactComponent as Listen } from "@/assets/svgs/listen.svg";
+import { Genre } from "@/components/common/Genre";
+import { useUserStore } from "@/libraries/store/user";
+import { useGetUserPreferences } from "@/hooks/queries";
+import { PreferCategory } from "@/types";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDeferredLoading } from "@/hooks/utils";
+
+export type ClacoAnalysisCardProps = {
+ type: string;
+};
+
+export const ClacoAnalysisCard = ({ type }: ClacoAnalysisCardProps) => {
+ const nickname = useUserStore((state) => state.nickname);
+ const initialGenreIndex = 0;
+ const [currentIndex, setCurrentIndex] = useState(initialGenreIndex);
+ const [isTransitioning, setIsTransitioning] = useState(false);
+ const [userPreference, setUserPreference] = useState([]);
+
+ const { data, isLoading } = useGetUserPreferences();
+
+ useEffect(() => {
+ if (!isLoading && data?.result?.preferCategories) {
+ // console.log(data);
+ setUserPreference(data.result.preferCategories);
+ }
+ }, [isLoading, data]);
+
+ useEffect(() => {
+ setCurrentIndex(initialGenreIndex);
+ }, []);
+
+ useEffect(() => {
+ if (!userPreference.length) return;
+
+ const rotateGenre = () => {
+ setIsTransitioning(true);
+ setTimeout(() => {
+ setCurrentIndex((prev) => (prev + 1) % userPreference.length);
+ setIsTransitioning(false);
+ }, 300);
+ };
+
+ const timer = setTimeout(() => {
+ const interval = setInterval(rotateGenre, 3500);
+ return () => clearInterval(interval);
+ }, 1000);
+
+ return () => clearTimeout(timer);
+ }, [userPreference]);
+
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+
+ if (shouldShowSkeleton) {
+ return (
+
+
+
+
+
+
+
+
+ {Array.from(Array(5).keys()).map((_, index) => (
+
+
+
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+ {type === "main" ? (
+ <>
+ 클라코가 분석한
+
+ {nickname}님의 클래식 취향이에요
+ >
+ ) : (
+ <>{nickname}님의 공연 취향>
+ )}
+
+
+
+
+
+ {type === "main"
+ ? "클라코 AI가 취향에 꼭 맞는 공연을 추천해드릴게요"
+ : "클라코 AI가 활동 내역을 분석해 클래식 취향을 알려드려요"}
+
+
+
+
+
+ {/* 배경 그라데이션 */}
+
+
+ {/* 장르 컴포넌트 컨테이너 */}
+
+ {userPreference.length > 0 && (
+
+ )}
+
+
+
+
+ {userPreference.map((item, index) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/common/ClacoTicket/index.tsx b/src/components/common/ClacoTicket/index.tsx
new file mode 100644
index 0000000..016bcfd
--- /dev/null
+++ b/src/components/common/ClacoTicket/index.tsx
@@ -0,0 +1,44 @@
+import { ReactComponent as ClacoTicketContainer } from "@/assets/svgs/Claco_Ticket.svg";
+import { Genre } from "@/components/common/Genre";
+import { ClacoTicketProps } from "@/types";
+
+export const ClacoTicket = ({
+ concertPoster,
+ watchDate,
+ concertName,
+ concertTags,
+}: ClacoTicketProps) => {
+ const formattedWatchDate = (date: string): string => date.replace(/-/g, ".");
+
+ return (
+
+
+
+
+
+ {formattedWatchDate(watchDate)}
+
+
+ {concertName}
+
+
+
+
+ {concertTags.map((item, index) => (
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/components/common/Concept/index.tsx b/src/components/common/Concept/index.tsx
new file mode 100644
index 0000000..b3171fc
--- /dev/null
+++ b/src/components/common/Concept/index.tsx
@@ -0,0 +1,37 @@
+import { ConceptButton } from "@/components/Onboarding/Registration";
+import { ConceptProps } from "@/types";
+
+export const Concept = ({ selectedConcept, onConceptClick }: ConceptProps) => {
+ return (
+
+
+ onConceptClick(
+ "악기나 성악, 오페라 등 음악 자체에 깊이 집중할 수 있는 클래식 공연이 좋아요.",
+ )
+ }
+ >
+ 악기나 성악, 오페라 등 음악 자체에
+
+ 깊이 집중할 수 있는 클래식 공연이 좋아요.
+
+
+ onConceptClick(
+ "무용, 발레 등 음악과 퍼포먼스를 모두 감상할 수 있는 공연이 좋아요.",
+ )
+ }
+ >
+ 무용, 발레 등 음악과 퍼포먼스를
+
+ 모두 감상할 수 있는 공연이 좋아요.
+
+
+ );
+};
diff --git a/src/components/common/Gender/index.tsx b/src/components/common/Gender/index.tsx
new file mode 100644
index 0000000..2a2d92e
--- /dev/null
+++ b/src/components/common/Gender/index.tsx
@@ -0,0 +1,23 @@
+import { TypeButton } from "@/components/Onboarding/Registration";
+import { GenderProps } from "@/types";
+
+export const Gender = ({ selectedGender, onGenderSelect }: GenderProps) => {
+ const genderOptions = [
+ { label: "남성", value: "MALE" },
+ { label: "여성", value: "FEMALE" },
+ ];
+
+ return (
+
+ {genderOptions.map((gender) => (
+ onGenderSelect(gender.value)}
+ >
+ {gender.label}
+
+ ))}
+
+ );
+};
diff --git a/src/components/common/Genre/index.tsx b/src/components/common/Genre/index.tsx
new file mode 100644
index 0000000..7528663
--- /dev/null
+++ b/src/components/common/Genre/index.tsx
@@ -0,0 +1,77 @@
+import { cn } from "@/lib/utils";
+import { GenreImageMap, GenreKeyword, GenreProps } from "@/types/genre";
+import { Suspense, useEffect, useState } from "react";
+
+const GENRE_IMAGE_MAP: GenreImageMap = {
+ 웅장한: () => import("@/assets/images/Genre/grand.png"),
+ 섬세한: () => import("@/assets/images/Genre/delicate.png"),
+ 고전적인: () => import("@/assets/images/Genre/classical.png"),
+ 현대적인: () => import("@/assets/images/Genre/modern.png"),
+ 서정적인: () => import("@/assets/images/Genre/lyrical.png"),
+ 역동적인: () => import("@/assets/images/Genre/dynamic.png"),
+ 낭만적인: () => import("@/assets/images/Genre/romantic.png"),
+ 비극적인: () => import("@/assets/images/Genre/tragic.png"),
+ 친숙한: () => import("@/assets/images/Genre/familiar.png"),
+ 새로운: () => import("@/assets/images/Genre/novel.png"),
+};
+
+const defaultImage = () => import("@/assets/images/Genre/classical.png");
+
+const isGenreKeyword = (keyword: string): keyword is GenreKeyword => {
+ return Object.keys(GENRE_IMAGE_MAP).includes(keyword);
+};
+
+export const Genre = ({
+ genreImgURL,
+ genreKeyword,
+ className,
+ isShow = false,
+}: GenreProps) => {
+ const [imageSrc, setImageSrc] = useState("");
+
+ useEffect(() => {
+ const loadImage = async () => {
+ try {
+ if (isGenreKeyword(genreKeyword)) {
+ const imageModule = await GENRE_IMAGE_MAP[genreKeyword]();
+ setImageSrc(imageModule.default);
+ } else {
+ const defaultImageModule = await defaultImage();
+ setImageSrc(defaultImageModule.default);
+ }
+ } catch (error) {
+ console.error("Error loading image:", error);
+ const defaultImageModule = await defaultImage();
+ setImageSrc(defaultImageModule.default);
+ }
+ };
+
+ loadImage();
+ }, [genreKeyword]);
+
+ return (
+
+
+ }
+ >
+ {imageSrc && (
+
+ )}
+
+ {!isShow && (
+
+ {genreKeyword}
+
+ )}
+
+ );
+};
diff --git a/src/components/common/Location/index.tsx b/src/components/common/Location/index.tsx
new file mode 100644
index 0000000..4889c80
--- /dev/null
+++ b/src/components/common/Location/index.tsx
@@ -0,0 +1,46 @@
+import { TypeButton } from "@/components/Onboarding/Registration";
+import { LocationProps } from "@/types";
+
+const AREA_LIST = [
+ { value: ["서울특별시"], label: "서울" },
+ { value: ["경기도"], label: "경기" },
+ { value: ["인천광역시"], label: "인천" },
+ { value: ["강원도"], label: "강원" },
+ { value: ["충청북도", "충청남도"], label: "충청" },
+ { value: ["전라북도", "전라남도"], label: "전라" },
+ { value: ["경상북도", "경상남도"], label: "경상" },
+ { value: ["제주특별자치도"], label: "제주" },
+];
+
+export const Location = ({
+ selectedLocation,
+ onLocationFilterClick,
+ onLocationClick,
+ isFilter = false,
+}: LocationProps) => {
+ const handleClick = (location: { value: string[]; label: string }) => {
+ if (isFilter) {
+ onLocationFilterClick?.(location.value, location.label);
+ } else {
+ onLocationClick?.(location.label);
+ }
+ };
+
+ return (
+
+ {AREA_LIST.map((location, index) => (
+ selectedLocation?.includes(v))
+ }
+ onClick={() => handleClick(location)}
+ >
+ {location.label}
+
+ ))}
+
+ );
+};
diff --git a/src/components/common/Modal/ThumbnailModal/index.tsx b/src/components/common/Modal/ThumbnailModal/index.tsx
new file mode 100644
index 0000000..3987da5
--- /dev/null
+++ b/src/components/common/Modal/ThumbnailModal/index.tsx
@@ -0,0 +1,91 @@
+import { Swiper, SwiperSlide } from "swiper/react";
+import { Swiper as SwiperType } from "swiper";
+import { FreeMode, Thumbs } from "swiper/modules";
+import { ReactComponent as X } from "@/assets/svgs/X-icon.svg";
+import "swiper/css";
+import "swiper/css/free-mode";
+import "swiper/css/thumbs";
+
+export type ThumbnailModalProps = {
+ isShow: boolean;
+ isAnimating: boolean;
+ thumbsSwiper: SwiperType | null;
+ selectIndex: number;
+ images: string[];
+ onClose: () => void;
+ setThumbsSwiper: (swiper: SwiperType | null) => void;
+};
+
+export const ThumbnailModal = ({
+ isShow,
+ isAnimating,
+ thumbsSwiper,
+ selectIndex,
+ images,
+ onClose,
+ setThumbsSwiper,
+}: ThumbnailModalProps) => {
+ if (!isShow && !isAnimating) return null;
+
+ return (
+ <>
+
+
+
+ {images.map((image, index) => (
+
+
+
+
+
+
+ ))}
+
+
+
+ {images.map((image, index) => (
+
+
+
+ ))}
+
+
+
+ >
+ );
+};
diff --git a/src/components/common/Modal/index.tsx b/src/components/common/Modal/index.tsx
new file mode 100644
index 0000000..66b9c8a
--- /dev/null
+++ b/src/components/common/Modal/index.tsx
@@ -0,0 +1,37 @@
+import { ModalProps } from "@/types";
+
+export const Modal = ({
+ title,
+ children,
+ positiveButtonText,
+ negativeButtonText,
+ onPositiveButtonClick,
+ onNegativeButtonClick,
+ disabled,
+}: ModalProps) => {
+ return (
+
+
+
+ {title}
+
+
{children && <>{children}>}
+
+
+ {negativeButtonText}
+
+
+ {positiveButtonText}
+
+
+
+
+ );
+};
diff --git a/src/components/common/Nickname/index.tsx b/src/components/common/Nickname/index.tsx
new file mode 100644
index 0000000..6f5a584
--- /dev/null
+++ b/src/components/common/Nickname/index.tsx
@@ -0,0 +1,122 @@
+import { useCallback, useEffect, useState } from "react";
+import { ReactComponent as ErrorIcon } from "@/assets/svgs/errorIcon.svg";
+import { ReactComponent as AgreeIcon } from "@/assets/svgs/agreeIcon.svg";
+import { NicknameProps } from "@/types";
+import { nickNameCheck } from "@/apis";
+import { useDebouncedState } from "@/hooks/utils";
+
+export const Nickname = ({
+ isChecked,
+ setIsChecked,
+ setNickname,
+}: NicknameProps) => {
+ const [nickname, setLocalNickname] = useState("");
+ const [errorMessage, setErrorMessage] = useState("");
+ const [hasStartedTyping, setHasStartedTyping] = useState(false);
+ const [isDuplicate, setIsDuplicate] = useState(false);
+ const debouncedNickname = useDebouncedState(nickname, 300);
+
+ const validateNickname = useCallback((name: string): string => {
+ const specialCharRegex = /[!@#$%^&*(),.?":{}|<>]/g;
+ if (name.length < 2) {
+ return "2글자 이상 작성해주세요";
+ } else if (specialCharRegex.test(name)) {
+ return "특수문자는 사용할 수 없어요";
+ }
+ return "";
+ }, []);
+
+ const validateDuplicatedNickname = useCallback(
+ async (name: string) => {
+ try {
+ const response = await nickNameCheck(name);
+ if (response.code === "MEM-009") {
+ setErrorMessage("이미 사용 중인 닉네임이에요");
+ setIsDuplicate(true);
+ setIsChecked(false);
+ } else {
+ setErrorMessage("");
+ setIsDuplicate(false);
+ setIsChecked(true);
+ setNickname(name);
+ }
+ } catch (error) {
+ console.error(error);
+ setIsChecked(false);
+ }
+ },
+ [setErrorMessage, setIsDuplicate, setIsChecked, setNickname]
+ );
+
+ useEffect(() => {
+ const error = validateNickname(nickname);
+ if (error) {
+ setErrorMessage(error);
+ setIsChecked(false);
+ setIsDuplicate(false);
+ return;
+ }
+
+ if (debouncedNickname) {
+ validateDuplicatedNickname(debouncedNickname);
+ }
+ }, [
+ debouncedNickname,
+ nickname,
+ validateNickname,
+ validateDuplicatedNickname,
+ setErrorMessage,
+ setIsChecked,
+ setIsDuplicate,
+ ]);
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const value = e.target.value;
+ setLocalNickname(value);
+ setNickname(value);
+ if (!hasStartedTyping) {
+ setHasStartedTyping(true);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {nickname.length}/10
+
+
+ {hasStartedTyping && errorMessage ? (
+
+
+ {errorMessage}
+
+ ) : hasStartedTyping && isChecked ? (
+
+ ) : null}
+
+ *최소 2글자 이상 사용할 수 있어요
+ *특수문자는 사용할 수 없어요
+
+ >
+ );
+};
diff --git a/src/components/common/Price/index.tsx b/src/components/common/Price/index.tsx
new file mode 100644
index 0000000..0ab9ed4
--- /dev/null
+++ b/src/components/common/Price/index.tsx
@@ -0,0 +1,68 @@
+import { PriceProps } from "@/types";
+
+export const Price = ({
+ minPrice,
+ maxPrice,
+ onMinPriceChange,
+ onMaxPriceChange,
+}: PriceProps) => {
+ const handleMinPriceChange = (e: React.ChangeEvent) => {
+ const value = Number(e.target.value);
+ if (value + 10000 <= maxPrice) {
+ onMinPriceChange(value);
+ }
+ };
+
+ const handleMaxPriceChange = (e: React.ChangeEvent) => {
+ const value = Number(e.target.value);
+ if (value - 10000 >= minPrice) {
+ onMaxPriceChange(value);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ {minPrice.toLocaleString()}원 ~ {maxPrice.toLocaleString()}원
+
+
+
+
+
+
+
+
+
+
+ 0원
+ 1,000,000원
+
+ >
+ );
+};
diff --git a/src/components/common/ReviewTag/index.tsx b/src/components/common/ReviewTag/index.tsx
new file mode 100644
index 0000000..a1f852d
--- /dev/null
+++ b/src/components/common/ReviewTag/index.tsx
@@ -0,0 +1,14 @@
+import { ReviewTagProps } from "@/types";
+
+export const ReviewTag = ({ isPlace = false, children, onClick, isSelected = false }: ReviewTagProps) => {
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/src/components/common/ScrollTop/index.tsx b/src/components/common/ScrollTop/index.tsx
new file mode 100644
index 0000000..03d7734
--- /dev/null
+++ b/src/components/common/ScrollTop/index.tsx
@@ -0,0 +1,12 @@
+import { useEffect } from "react";
+import { useLocation } from "react-router-dom";
+
+export default function ScrollTop() {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [pathname]);
+
+ return null;
+}
diff --git a/src/components/common/Search/Bar/index.tsx b/src/components/common/Search/Bar/index.tsx
new file mode 100644
index 0000000..c298210
--- /dev/null
+++ b/src/components/common/Search/Bar/index.tsx
@@ -0,0 +1,25 @@
+import { ReactComponent as Search } from "@/assets/svgs/search.svg";
+import { SearchBarProps } from "@/types";
+import { forwardRef } from "react";
+
+export const SearchBar = forwardRef(
+ ({ value, onChange, onKeyDown, onFocus, placeholder }, ref) => {
+ return (
+
+
+
+
+ );
+ }
+);
+
+SearchBar.displayName = "SearchBar";
diff --git a/src/components/common/Search/Card/index.tsx b/src/components/common/Search/Card/index.tsx
new file mode 100644
index 0000000..de57a10
--- /dev/null
+++ b/src/components/common/Search/Card/index.tsx
@@ -0,0 +1,31 @@
+import { CategoryTag } from "@/components/common/CategoryTag";
+import { HighlightText } from "../HighLight";
+import { extractDateRange } from "@/hooks/utils";
+import { SearchCardProps } from "@/types";
+
+export const SearchCard = ({
+ data,
+ searchKeyWord,
+ className = "",
+ onClick,
+}: SearchCardProps) => {
+ return (
+
+
+
+
+
+
+
+ {extractDateRange(data.prfpdfrom, data.prfpdto)}
+
+
+ );
+};
diff --git a/src/components/common/Search/HighLight/index.tsx b/src/components/common/Search/HighLight/index.tsx
new file mode 100644
index 0000000..a1b1a6b
--- /dev/null
+++ b/src/components/common/Search/HighLight/index.tsx
@@ -0,0 +1,24 @@
+export type HighlightTextProps = {
+ text: string;
+ highlight: string;
+};
+
+export const HighlightText = ({ text, highlight }: HighlightTextProps) => {
+ if (!highlight) return {text} ;
+
+ const parts = text.split(new RegExp(`(${highlight})`, "gi"));
+
+ return (
+
+ {parts.map((part, index) =>
+ part.toLowerCase() === highlight?.toLowerCase() ? (
+
+ {part}
+
+ ) : (
+ {part}
+ )
+ )}
+
+ );
+};
diff --git a/src/components/common/ShowFilterTab/index.tsx b/src/components/common/ShowFilterTab/index.tsx
new file mode 100644
index 0000000..019348e
--- /dev/null
+++ b/src/components/common/ShowFilterTab/index.tsx
@@ -0,0 +1,33 @@
+import { ShowFilterTabProps, TabMenuItem } from "@/types";
+
+const TEB_MENU: TabMenuItem[] = [
+ { value: null, label: "전체" },
+ { value: "서양음악(클래식)", label: "클래식" },
+ { value: "무용", label: "무용" },
+];
+
+export const ShowFilterTab = ({
+ activeTab,
+ onTabClick,
+ className = "",
+}: ShowFilterTabProps) => {
+ return (
+
+ {TEB_MENU.map((tab, index) => (
+ onTabClick(tab.value)}
+ >
+ {tab.label}
+
+ ))}
+
+ );
+};
diff --git a/src/components/common/ShowSummaryCard/const/index.ts b/src/components/common/ShowSummaryCard/const/index.ts
new file mode 100644
index 0000000..91aaf7f
--- /dev/null
+++ b/src/components/common/ShowSummaryCard/const/index.ts
@@ -0,0 +1,94 @@
+import { Show } from "@/types";
+import poster2 from "@/assets/images/poster2.gif";
+import poster10 from "@/assets/images/poster10.gif";
+import poster13 from "@/assets/images/poster13.png";
+
+export const initialShowData: Show[] = [
+ {
+ id: 1,
+ posterImage: poster10,
+ showType: "무용",
+ status: "공연중",
+ defaultLiked: true,
+ title: "유니버설발레단 (호두까기 인형) - 성남",
+ location: "예술의 전당 오페라 극장",
+ date: "2024.11.30",
+ keywords: ["섬세한", "역동적인", "서정적인", "웅장한", "새로운"],
+ isLiked: true,
+ },
+ {
+ id: 2,
+ posterImage: poster13,
+ showType: "서양음악(클래식)",
+ status: "공연중",
+ defaultLiked: false,
+ title: "대니 구 크리스마스 콘서트 ",
+ location: "예술의 전당 오페라 극장",
+ date: "2024.11.30",
+ keywords: ["섬세한", "역동적인", "서정적인", "웅장한", "새로운"],
+ isLiked: false,
+ },
+ {
+ id: 3,
+ posterImage: poster2,
+ showType: "무용",
+ status: "completed",
+ defaultLiked: false,
+ title: "라트라비아타",
+ location: "예술의 전당 오페라 극장",
+ date: "2024.11.30",
+ keywords: ["섬세한", "역동적인", "서정적인", "웅장한", "새로운"],
+ isLiked: false,
+ },
+ {
+ id: 4,
+ posterImage: poster2,
+ showType: "무용",
+ status: "공연예정",
+ defaultLiked: false,
+ title: "라트라비아타",
+ location: "예술의 전당 오페라 극장",
+ date: "2024.11.30",
+ keywords: ["섬세한", "역동적인", "서정적인", "웅장한", "새로운"],
+ isLiked: false,
+ },
+ {
+ id: 5,
+ posterImage: poster2,
+ showType: "무용",
+ status: "completed",
+ defaultLiked: false,
+ title: "라트라비아타",
+ location: "예술의 전당 오페라 극장",
+ date: "2024.11.30",
+ keywords: ["섬세한", "역동적인", "서정적인", "웅장한", "새로운"],
+ isLiked: false,
+ },
+ {
+ id: 6,
+ posterImage: poster2,
+ showType: "서양음악(클래식)",
+ status: "공연중",
+ defaultLiked: false,
+ title: "라트라비아타",
+ location: "예술의 전당 오페라 극장",
+ date: "2024.11.30",
+ keywords: ["섬세한", "역동적인", "서정적인", "웅장한", "새로운"],
+ isLiked: false,
+ },
+];
+
+export const searchResultData: Show[] = [
+ {
+ id: 1,
+ posterImage: poster10,
+ showType: "무용",
+ status: "공연예정",
+ defaultLiked: true,
+ title: "유니버설발레단 (호두까기 인형) - 성남",
+ location: "예술의 전당 오페라 극장",
+ date: "2024.11.30",
+ keywords: ["섬세한", "역동적인", "서정적인", "웅장한", "새로운"],
+ isLiked: true,
+ },
+];
diff --git a/src/components/common/ShowSummaryCard/index.tsx b/src/components/common/ShowSummaryCard/index.tsx
new file mode 100644
index 0000000..d435956
--- /dev/null
+++ b/src/components/common/ShowSummaryCard/index.tsx
@@ -0,0 +1,91 @@
+import { ReactComponent as Heart } from "@/assets/svgs/Heart.svg";
+import { CategoryTag } from "@/components/common/CategoryTag";
+import { Skeleton } from "@/components/ui/skeleton";
+import { usePostLike } from "@/hooks/mutation";
+import { extractDateRange } from "@/hooks/utils";
+import { ShowSummaryCardProps } from "@/types";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+export const ShowSummaryCard = ({ data }: ShowSummaryCardProps) => {
+ const navigate = useNavigate();
+ const [isLiked, setIsLiked] = useState(data.liked);
+ const mutation = usePostLike();
+
+ const gotoShowDetail = (id: number) => {
+ navigate(`/show/${id}`);
+ };
+
+ const handleLike = () => {
+ setIsLiked((prev) => !prev);
+ mutation.mutate(data.id, {
+ onError: () => {
+ setIsLiked((prev) => !prev);
+ },
+ });
+ };
+
+ return (
+
+
+
gotoShowDetail(data.id)}
+ />
+
+
+
+
+
+
+
+ {isLiked ? (
+
+ ) : (
+
+ )}
+
+
+
+
gotoShowDetail(data.id)}>
+
+
+
+
+
+ {data.prfnm}
+
+
+ {data.fcltynm}
+
+
+ {extractDateRange(data.prfpdfrom, data.prfpdto)}
+
+
+
+
+
+
+ {data.categories.map((category, index) => (
+
+ {category.category}
+
+ ))}
+
+
+
+
+ );
+};
+
+ShowSummaryCard.Skeleton = () => {
+ return ;
+};
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..54ad33c
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+import * as ProgressPrimitive from "@radix-ui/react-progress"
+
+import { cn } from "@/lib/utils"
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, value, ...props }, ref) => (
+
+
+
+))
+Progress.displayName = ProgressPrimitive.Root.displayName
+
+export { Progress }
diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..df330eb
--- /dev/null
+++ b/src/components/ui/radio-group.tsx
@@ -0,0 +1,40 @@
+import * as React from "react";
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import { cn } from "@/lib/utils";
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ );
+});
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
+
+export { RadioGroup, RadioGroupItem };
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..070d715
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from "@/lib/utils";
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/src/globals.css b/src/globals.css
index 7ce139b..e5eca7f 100644
--- a/src/globals.css
+++ b/src/globals.css
@@ -3,23 +3,121 @@
@tailwind utilities;
@layer base {
+ * {
+ @apply border-border;
+ }
+
+ @font-face {
+ font-family: Pretendard;
+ font-weight: 700;
+ font-display: swap;
+ src:
+ url("./assets/fonts/Pretendard-Bold.subset.woff2") format("woff2"),
+ url("./assets/fonts/Pretendard-Bold.subset.woff") format("woff");
+ }
+
+ @font-face {
+ font-family: Pretendard;
+ font-weight: 600;
+ font-display: swap;
+ src:
+ url("./assets/fonts/Pretendard-SemiBold.subset.woff2") format("woff2"),
+ url("./assets/fonts/Pretendard-SemiBold.subset.woff") format("woff");
+ }
+
+ @font-face {
+ font-family: Pretendard;
+ font-weight: 500;
+ font-display: swap;
+ src:
+ url("./assets/fonts/Pretendard-Medium.subset.woff2") format("woff2"),
+ url("./assets/fonts/Pretendard-Medium.subset.woff") format("woff");
+ }
+
+ @font-face {
+ font-family: Pretendard;
+ font-weight: 400;
+ font-display: swap;
+ src:
+ url("./assets/fonts/Pretendard-Regular.subset.woff2") format("woff2"),
+ url("./assets/fonts/Pretendard-Regular.subset.woff") format("woff");
+ }
+
@font-face {
- font-family: 'Pretendard';
- src: url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css');
+ font-family: Nonchalance;
+ font-weight: 400;
+ font-display: swap;
+ src:
+ url("./assets/fonts/Nonchalance-Medium.woff2") format("woff2"),
+ url("./assets/fonts/Nonchalance-Medium.woff") format("woff");
}
+
html {
- font-size: 62.5%;
- font-family: pretendard;
+ background-color: #1c1c1c;
+ @media (max-width: 450px) {
+ font-size: 15px;
+ }
+ @media (max-width: 375px) {
+ font-size: 12px;
+ }
}
- body {
- display: flex;
- justify-content: center;
- align-items: center;
- border: 1px solid #222;
+ body {
+ @apply bg-background text-foreground;
+ }
- max-width: 430px;
- min-height: 100vh;
+ :root {
+ --background: 0 0% 100%;
+ --foreground: 0 0% 3.9%;
+ --card: 0 0% 100%;
+ --card-foreground: 0 0% 3.9%;
+ --popover: 0 0% 100%;
+ --popover-foreground: 0 0% 3.9%;
+ --primary: 0 0% 9%;
+ --primary-foreground: 0 0% 98%;
+ --secondary: 0 0% 96.1%;
+ --secondary-foreground: 0 0% 9%;
+ --muted: 0 0% 96.1%;
+ --muted-foreground: 0 0% 45.1%;
+ --accent: 0 0% 96.1%;
+ --accent-foreground: 0 0% 9%;
+ --destructive: 0 84.2% 60.2%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 89.8%;
+ --input: 0 0% 89.8%;
+ --ring: 0 0% 3.9%;
+ --chart-1: 12 76% 61%;
+ --chart-2: 173 58% 39%;
+ --chart-3: 197 37% 24%;
+ --chart-4: 43 74% 66%;
+ --chart-5: 27 87% 67%;
+ --radius: 0.5rem;
+ }
+ .dark {
+ --background: 0 0% 3.9%;
+ --foreground: 0 0% 98%;
+ --card: 0 0% 3.9%;
+ --card-foreground: 0 0% 98%;
+ --popover: 0 0% 3.9%;
+ --popover-foreground: 0 0% 98%;
+ --primary: 0 0% 98%;
+ --primary-foreground: 0 0% 9%;
+ --secondary: 0 0% 14.9%;
+ --secondary-foreground: 0 0% 98%;
+ --muted: 0 0% 14.9%;
+ --muted-foreground: 0 0% 63.9%;
+ --accent: 0 0% 14.9%;
+ --accent-foreground: 0 0% 98%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 0 0% 98%;
+ --border: 0 0% 14.9%;
+ --input: 0 0% 14.9%;
+ --ring: 0 0% 83.1%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
}
}
@@ -61,12 +159,113 @@
@apply text-[1rem] font-medium leading-normal tracking-[-0.01em];
}
.body1-regular {
- @apply text-[1rem] font-regular leading-normal tracking-[-0.01em];
+ @apply text-[1rem] font-normal leading-normal tracking-[-0.01em];
+ }
+ .body2-semibold {
+ @apply text-[0.875rem] font-semibold leading-normal tracking-[-0.01em];
}
.body2-medium {
- @apply text-[0.9375rem] font-medium leading-normal tracking-[-0.01em];
+ @apply text-[0.875rem] font-medium leading-normal tracking-[-0.01em];
}
.body2-regular {
- @apply text-[0.9375rem] font-regular leading-normal tracking-[-0.01em];
+ @apply text-[0.875rem] font-normal leading-normal tracking-[-0.01em];
+ }
+ .caption-13 {
+ @apply text-[0.8125rem] font-normal leading-normal tracking-[-0.02em];
+ }
+ .caption-12 {
+ @apply text-[0.75rem] font-normal leading-normal tracking-[-0.02em];
+ }
+}
+
+@layer utilities {
+ .carousel-item-overlay::after {
+ content: "";
+ @apply absolute inset-0 bg-black rounded-lg bg-opacity-20;
}
}
+
+input[type="range"] {
+ pointer-events: none;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ height: 0.875rem;
+ width: 0.875rem;
+ border-radius: 50%;
+ background-color: #f1efef;
+ -webkit-appearance: none;
+ pointer-events: auto;
+}
+
+.swiper-pagination {
+ position: absolute;
+ bottom: 70px !important;
+ right: 10px;
+ width: auto !important;
+ margin: 0;
+}
+
+.clacobook .swiper-pagination {
+ position: absolute;
+ bottom: 0px !important;
+ right: 0px;
+ width: auto !important;
+ margin: 0;
+}
+
+.swiper-pagination-bullet {
+ padding-top: -20px;
+ border-radius: 50%;
+ width: 6px;
+ height: 6px;
+ text-align: center;
+ line-height: 30px;
+ font-size: 12px;
+ color: #000;
+ opacity: 1;
+ background: #626262;
+}
+
+.swiper-pagination-bullet-active {
+ color: #fff;
+ background: #d9d9d9;
+}
+
+.thumbnail .swiper-slide {
+ border-radius: 5px;
+ max-width: 90px;
+ margin-right: 20px;
+ img {
+ border-radius: 5px;
+ }
+}
+
+.thumbnail .swiper-slide-thumb-active {
+ max-width: 90px;
+ border: 1px solid #ecebe7;
+ border-radius: 5px;
+}
+
+.change-gender-fillcolor path {
+ fill: #e9663a;
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.slide-up {
+ animation: slideUp 1s ease-out;
+}
+
+.hide-scrollbar::-webkit-scrollbar {
+ display: none;
+}
\ No newline at end of file
diff --git a/src/hooks/mutation/index.ts b/src/hooks/mutation/index.ts
new file mode 100644
index 0000000..85f8a99
--- /dev/null
+++ b/src/hooks/mutation/index.ts
@@ -0,0 +1,9 @@
+export { default as usePostLike } from "./usePostLike";
+export { default as usePostTicketReview } from "./usePostTicketReview";
+export { default as usePutTicketImage } from "./usePutTicketImage";
+export { default as usePostCreateClacoBook } from "./usePostCreateClacoBook";
+export { default as useDeleteClacoBook } from "./useDeleteClacoBook";
+export { default as usePutEditClacoBook } from "./usePutEditClacoBook";
+export { default as usePutMoveClacoTicket } from "./usePutMoveClacoTicket";
+export { default as useDeleteClacoTicket } from "./useDeleteClacoTicket";
+export { default as usePutEditTicketReview } from "./usePutEditTicketReview";
diff --git a/src/hooks/mutation/useDeleteClacoBook.ts b/src/hooks/mutation/useDeleteClacoBook.ts
new file mode 100644
index 0000000..eda81a6
--- /dev/null
+++ b/src/hooks/mutation/useDeleteClacoBook.ts
@@ -0,0 +1,27 @@
+import { client } from "@/apis";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+const deleteClacoBook = async (bookId: number) => {
+ const res = await client.delete(`/claco-books/claco-book/${bookId}`);
+ return res.data;
+};
+
+const useDeleteClacoBook = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: deleteClacoBook,
+ onSuccess: (res) => {
+ if (res.code === "COM-000") {
+ console.log("클라코북 삭제 성공");
+ queryClient.invalidateQueries({ queryKey: ["clacoBookList"] });
+ } else {
+ console.log("클라코북을 삭제하는 도중 에러가 발생했습니다.");
+ }
+ },
+ onError: (error) => {
+ console.error("클라코북 생성 실패", error);
+ },
+ });
+};
+
+export default useDeleteClacoBook;
diff --git a/src/hooks/mutation/useDeleteClacoTicket.ts b/src/hooks/mutation/useDeleteClacoTicket.ts
new file mode 100644
index 0000000..4bca0a6
--- /dev/null
+++ b/src/hooks/mutation/useDeleteClacoTicket.ts
@@ -0,0 +1,31 @@
+import { client } from "@/apis";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+const deleteClacoTicket = async (ticketReviewId: number) => {
+ const res = await client.delete(`/ticket-reviews/${ticketReviewId}`);
+ return res.data;
+};
+
+const useDeleteClacoTicket = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: deleteClacoTicket,
+ onSuccess: (res) => {
+ if (res.code === "COM-000") {
+ console.log("클라코 티켓 삭제 성공");
+ queryClient.invalidateQueries({ queryKey: ["clacoTicketList"] });
+ } else if (res.code === "TCK-001") {
+ console.log("클라코 티켓을 찾을 수 없습니다.");
+ } else if (res.code === "MEM-999") {
+ console.log("해당 클라코 티켓의 소유주가 아니라 삭제할 수 없습니다.");
+ } else {
+ console.log("클라코 티켓을 삭제하는데 오류가 발생했습니다.");
+ }
+ },
+ onError: (error) => {
+ console.error("클라코 티켓 삭제 실패", error);
+ },
+ });
+};
+
+export default useDeleteClacoTicket;
diff --git a/src/hooks/mutation/usePostCreateClacoBook.ts b/src/hooks/mutation/usePostCreateClacoBook.ts
new file mode 100644
index 0000000..e401b80
--- /dev/null
+++ b/src/hooks/mutation/usePostCreateClacoBook.ts
@@ -0,0 +1,40 @@
+import { client } from "@/apis";
+import { ClacoBookList, ClacoBookListResponse } from "@/types";
+import {
+ useMutation,
+ UseMutationResult,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { AxiosError, AxiosResponse } from "axios";
+
+const createClacoBook = async (
+ clacobook: ClacoBookList
+): Promise => {
+ const res: AxiosResponse =
+ await client.post("/claco-books", clacobook);
+ return res.data;
+};
+
+const usePostCreateClacoBook = (): UseMutationResult<
+ ClacoBookListResponse,
+ AxiosError,
+ ClacoBookList
+> => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: createClacoBook,
+ onSuccess: (res) => {
+ if (res.code === "COM-000") {
+ console.log("클라코북 생성 성공");
+ queryClient.invalidateQueries({ queryKey: ["clacoBookList"] });
+ } else if (res.code === "CLB-010") {
+ console.log("클라코북은 최대 5개까지만 보유할 수 있습니다");
+ }
+ },
+ onError: (error) => {
+ console.error("클라코북 생성 실패", error);
+ },
+ });
+};
+
+export default usePostCreateClacoBook;
diff --git a/src/hooks/mutation/usePostLike.ts b/src/hooks/mutation/usePostLike.ts
new file mode 100644
index 0000000..0671371
--- /dev/null
+++ b/src/hooks/mutation/usePostLike.ts
@@ -0,0 +1,36 @@
+import { client } from "@/apis";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+export type Like = {
+ likeId: number;
+ liked: boolean;
+};
+
+const postLike = async (concertId: number) => {
+ const response = await client.post(`/concerts/likes/${concertId}`, {});
+ return response.data;
+};
+
+const usePostLike = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: postLike,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["userBased"] });
+ queryClient.invalidateQueries({ queryKey: ["itemBased"] });
+ queryClient.invalidateQueries({ queryKey: ["concert-data"] });
+ queryClient.invalidateQueries({
+ queryKey: ["search-concert-data"],
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["search-liked-concert-data"],
+ });
+ queryClient.invalidateQueries({ queryKey: ["showDetail"] });
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+};
+
+export default usePostLike;
diff --git a/src/hooks/mutation/usePostTicketReview.ts b/src/hooks/mutation/usePostTicketReview.ts
new file mode 100644
index 0000000..2a1cf59
--- /dev/null
+++ b/src/hooks/mutation/usePostTicketReview.ts
@@ -0,0 +1,63 @@
+import { client } from "@/apis";
+import { TicketReviewRequest, TicketReviewResponse } from "@/types";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+
+const postTicketReview = async (
+ request: TicketReviewRequest
+): Promise => {
+ const formData = new FormData();
+ formData.append(
+ "request",
+ new Blob([JSON.stringify(request)], {
+ type: "application/json",
+ })
+ );
+
+ if (request.files.length == 0) {
+ formData.append("files", "");
+ } else {
+ request.files.forEach((file: File) => {
+ formData.append("files", file);
+ });
+ }
+
+ const response = await client.post(
+ "/ticket-reviews",
+ formData,
+ {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ }
+ );
+
+ return response.data;
+};
+
+const usePostTicketReview = () => {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: postTicketReview,
+ onSuccess: (data) => {
+ console.log(data);
+ localStorage.removeItem("showDate");
+ localStorage.removeItem("showTime");
+ localStorage.removeItem("showPlace");
+ localStorage.removeItem("seat");
+ localStorage.removeItem("castingList");
+ const ticketReviewId = data.result?.ticketReviewId;
+ if (ticketReviewId) {
+ localStorage.setItem("ticketReviewId", ticketReviewId.toString());
+ }
+ queryClient.invalidateQueries({ queryKey: ["clacoTicketList"] });
+ navigate("/ticketcreate/download");
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+};
+
+export default usePostTicketReview;
diff --git a/src/hooks/mutation/usePutEditClacoBook.ts b/src/hooks/mutation/usePutEditClacoBook.ts
new file mode 100644
index 0000000..14cacd0
--- /dev/null
+++ b/src/hooks/mutation/usePutEditClacoBook.ts
@@ -0,0 +1,40 @@
+import { client } from "@/apis";
+import { ClacoBookList, ClacoBookListResponse } from "@/types";
+import {
+ useMutation,
+ UseMutationResult,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { AxiosError, AxiosResponse } from "axios";
+
+const editClacoBook = async (
+ clacobook: ClacoBookList
+): Promise => {
+ const res: AxiosResponse =
+ await client.put("/claco-books", clacobook);
+ return res.data;
+};
+
+const usePutEditClacoBook = (): UseMutationResult<
+ ClacoBookListResponse,
+ AxiosError,
+ ClacoBookList
+> => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: editClacoBook,
+ onSuccess: (res) => {
+ if (res.code === "COM-000") {
+ console.log("클라코북 수정 성공");
+ queryClient.invalidateQueries({ queryKey: ["clacoBookList"] });
+ } else if (res.code === "CLB-001") {
+ console.log("클라코북을 수정하는 중에 문제가 발생했어요");
+ }
+ },
+ onError: (error) => {
+ console.error("클라코북 수정 실패", error);
+ },
+ });
+};
+
+export default usePutEditClacoBook;
diff --git a/src/hooks/mutation/usePutEditTicketReview.ts b/src/hooks/mutation/usePutEditTicketReview.ts
new file mode 100644
index 0000000..8637ea9
--- /dev/null
+++ b/src/hooks/mutation/usePutEditTicketReview.ts
@@ -0,0 +1,54 @@
+import { client } from "@/apis";
+import {
+ EditClacoTicketReviewProps,
+ EditClacoTicketReviewResponse,
+} from "@/types";
+import {
+ useMutation,
+ UseMutationResult,
+ useQueryClient,
+} from "@tanstack/react-query";
+import { AxiosError, AxiosResponse } from "axios";
+
+const editClacoTicketReview = async (
+ editData: EditClacoTicketReviewProps
+): Promise => {
+ const res: AxiosResponse =
+ await client.put(
+ "/ticket-reviews",
+ editData
+ );
+ return res.data;
+};
+
+const usePutEditTicketReview = (): UseMutationResult<
+ EditClacoTicketReviewResponse,
+ AxiosError,
+ EditClacoTicketReviewProps
+> => {
+ const queryClient = useQueryClient();
+ return useMutation<
+ EditClacoTicketReviewResponse,
+ AxiosError,
+ EditClacoTicketReviewProps
+ >({
+ mutationFn: editClacoTicketReview,
+ onSuccess: (res) => {
+ if (res.code === "COM-000") {
+ console.log("클라코 티켓 리뷰 수정 성공");
+ queryClient.invalidateQueries({ queryKey: ["ticketReviewDetail"] });
+ } else if (res.code === "TCK-001") {
+ console.log("클라코 티켓 리뷰를 찾을 수 없습니다");
+ } else if (res.code === "MEM-999") {
+ console.log("해당 리뷰의 작성자가 아닙니다.");
+ } else {
+ console.log("클라코 티켓 리뷰 수정 중에 문제가 발생했습니다.");
+ }
+ },
+ onError: (error) => {
+ console.error("클라코 티켓 리뷰 수정 실패", error);
+ },
+ });
+};
+
+export default usePutEditTicketReview;
diff --git a/src/hooks/mutation/usePutMoveClacoTicket.ts b/src/hooks/mutation/usePutMoveClacoTicket.ts
new file mode 100644
index 0000000..745907d
--- /dev/null
+++ b/src/hooks/mutation/usePutMoveClacoTicket.ts
@@ -0,0 +1,36 @@
+import { client } from "@/apis";
+import { ClacoBookListResponse } from "@/types";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { AxiosResponse } from "axios";
+
+export type MoveClacoTicketProps = {
+ ticketReviewId: number;
+ clacoBookId: number;
+};
+
+const moveClacoTicket = async ({
+ ticketReviewId,
+ clacoBookId,
+}: MoveClacoTicketProps): Promise => {
+ const res: AxiosResponse =
+ await client.put(
+ `/ticket-reviews/${ticketReviewId}/claco-books/${clacoBookId}`
+ );
+ return res.data;
+};
+
+const usePutMoveClacoTicket = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: moveClacoTicket,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["clacoTicketList"] });
+ queryClient.invalidateQueries({ queryKey: ["clacoBookList"] });
+ },
+ onError: (error) => {
+ console.error("클라코 티켓 폴더 이동 실패", error);
+ },
+ });
+};
+
+export default usePutMoveClacoTicket;
diff --git a/src/hooks/mutation/usePutTicketImage.ts b/src/hooks/mutation/usePutTicketImage.ts
new file mode 100644
index 0000000..e9dd259
--- /dev/null
+++ b/src/hooks/mutation/usePutTicketImage.ts
@@ -0,0 +1,53 @@
+import { client } from "@/apis";
+import { useMutation } from "@tanstack/react-query";
+
+export type TicketImageRequest = {
+ id: number;
+ file: File;
+};
+
+export type TicketImageURL = {
+ imageUrl: string;
+};
+
+export type TicketImageResponse = {
+ code: string;
+ message: string;
+ result: TicketImageURL;
+ refreshed: boolean;
+};
+
+const putTicketImage = async (
+ request: TicketImageRequest
+): Promise => {
+ const formData = new FormData();
+ formData.append("file", request.file);
+
+ const response = await client.put(
+ `/ticket-reviews/ticket-images?id=${request.id}`,
+ formData,
+ {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ }
+ );
+
+ return response.data;
+};
+
+const usePutTicketImage = () => {
+ // const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: putTicketImage,
+ onSuccess: () => {
+ // console.log(data);
+ // queryClient.invalidateQueries({ queryKey: ["ticketReviewDetail"] });
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+};
+
+export default usePutTicketImage;
diff --git a/src/hooks/mutation/usePutUserInfo.ts b/src/hooks/mutation/usePutUserInfo.ts
new file mode 100644
index 0000000..b171c55
--- /dev/null
+++ b/src/hooks/mutation/usePutUserInfo.ts
@@ -0,0 +1,52 @@
+import { client } from "@/apis";
+import { UserInfoRequest, UserInfoResponse } from "@/types";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+
+const putUserInfo = async (
+ request: UserInfoRequest,
+): Promise => {
+ const formData = new FormData();
+ formData.append(
+ "request",
+ new Blob([JSON.stringify(request)], {
+ type: "application/json",
+ }),
+ );
+
+ formData.append("updateNickname", request.updateNickname || "");
+
+ if (request.updateImage) {
+ formData.append("updateImage", request.updateImage);
+ } else {
+ formData.append("files", "");
+ }
+
+ const response = await client.put(`/members`, formData, {
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ });
+
+ return response.data;
+};
+
+const usePutUserInfo = () => {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: putUserInfo,
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["userInfo"],
+ });
+ navigate("/mypage");
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+};
+
+export default usePutUserInfo;
diff --git a/src/hooks/mutation/usePutUserPreference.ts b/src/hooks/mutation/usePutUserPreference.ts
new file mode 100644
index 0000000..d6154f4
--- /dev/null
+++ b/src/hooks/mutation/usePutUserPreference.ts
@@ -0,0 +1,35 @@
+import { client } from "@/apis";
+import { UserPreferencesPutRequest, UserPreferencesPutResponse } from "@/types";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router-dom";
+
+const putUserPreference = async (
+ preferences: UserPreferencesPutRequest
+): Promise => {
+ const response = await client.put(
+ `/preferences`,
+ preferences
+ );
+ return response.data;
+};
+
+const usePutUserPreference = () => {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ return useMutation<
+ UserPreferencesPutResponse,
+ Error,
+ UserPreferencesPutRequest
+ >({
+ mutationFn: putUserPreference,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["userPreferences"] });
+ navigate("/mypage");
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+};
+
+export default usePutUserPreference;
diff --git a/src/hooks/queries/index.ts b/src/hooks/queries/index.ts
new file mode 100644
index 0000000..901529d
--- /dev/null
+++ b/src/hooks/queries/index.ts
@@ -0,0 +1,18 @@
+export { default as useGetUserPreferences } from "./useGetUserPreferences";
+export { default as useGetUserBased } from "./useGetUserBased";
+export { default as useGetItemBased } from "./useGetItemBased";
+export { default as useGetConcertBased } from "./useGetConcertBased";
+export { default as useGetShowDeatil } from "./useGetShowDetail";
+export { default as useGetRecommendClacoTicket } from "./useGetRecommendClacoTicket";
+export { default as useGetConcertList } from "./useGetConcertList";
+export { default as useGetAutoCompleteSearch } from "./useGetAutoCompleteSearch";
+export { default as useGetSearch } from "./useGetSearch";
+export { default as useGetConcertFilters } from "./useGetConcertFilters";
+export { default as useGetConcertReviewList } from "./useGetConcertReviewList";
+export { default as useGetConcertReviewSize } from "./useGetConcertReviewSize";
+export { default as useGetConcertReviewDetail } from "./useGetConcertReviewDetail";
+export { default as useGetClacoBookList } from "./useGetClacoBookList";
+export { default as useGetClacoTicketList } from "./useGetClacoTicketList";
+export { default as useGetTicketReviewDetail } from "./useGetTicketReviewDetail";
+export { default as useGetConcertLikes } from "./useGetConcertLikes";
+export { default as useGetReviewAutoCompleteSearch } from "./useGetReviewAutoCompleteSearch";
diff --git a/src/hooks/queries/useGetAutoCompleteSearch.ts b/src/hooks/queries/useGetAutoCompleteSearch.ts
new file mode 100644
index 0000000..ca494df
--- /dev/null
+++ b/src/hooks/queries/useGetAutoCompleteSearch.ts
@@ -0,0 +1,40 @@
+import { client } from "@/apis";
+import { AutoCompleteSearchResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getAutoCompleteSearch = async (
+ query: string
+): Promise => {
+ const response = await client.get(
+ "/concerts/search",
+ {
+ params: {
+ query: query,
+ },
+ }
+ );
+ return response.data;
+};
+
+const useGetAutoCompleteSearch = (
+ query: string
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["useAutoComplete", query],
+ queryFn: () => getAutoCompleteSearch(query),
+ enabled: query.trim().length !== 0,
+ });
+};
+
+export default useGetAutoCompleteSearch;
+
+/**
+ * No description
+ *
+ * @tags useGetAutoCompleteSearch
+ * @name concert-controller
+ * @summary 검색어 디바운스 쿼리에 따른 자동완성 목록 불러오는 API
+ * @request GET:/concerts/search
+ * @secure bearer
+ */
diff --git a/src/hooks/queries/useGetClacoBookList.ts b/src/hooks/queries/useGetClacoBookList.ts
new file mode 100644
index 0000000..59c1915
--- /dev/null
+++ b/src/hooks/queries/useGetClacoBookList.ts
@@ -0,0 +1,21 @@
+import { client } from "@/apis";
+import { ClacoBookListResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getClacoBookList = async (): Promise => {
+ const response = await client.get("/claco-books");
+ return response.data;
+};
+
+const useGetClacoBookList = (): UseQueryResult<
+ ClacoBookListResponse,
+ AxiosError
+> => {
+ return useQuery({
+ queryKey: ["clacoBookList"],
+ queryFn: () => getClacoBookList(),
+ });
+};
+
+export default useGetClacoBookList;
diff --git a/src/hooks/queries/useGetClacoTicketList.ts b/src/hooks/queries/useGetClacoTicketList.ts
new file mode 100644
index 0000000..491e601
--- /dev/null
+++ b/src/hooks/queries/useGetClacoTicketList.ts
@@ -0,0 +1,24 @@
+import { client } from "@/apis";
+import { ClacoTicketListResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getClacoTicketList = async (
+ clacoBookId: number
+): Promise => {
+ const response = await client.get(
+ `/ticket-reviews/claco-books/${clacoBookId}`
+ );
+ return response.data;
+};
+
+const useGetClacoTicketList = (
+ clacoBookId: number
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["clacoTicketList", clacoBookId],
+ queryFn: () => getClacoTicketList(clacoBookId),
+ });
+};
+
+export default useGetClacoTicketList;
diff --git a/src/hooks/queries/useGetConcertBased.ts b/src/hooks/queries/useGetConcertBased.ts
new file mode 100644
index 0000000..441c9ac
--- /dev/null
+++ b/src/hooks/queries/useGetConcertBased.ts
@@ -0,0 +1,24 @@
+import { client } from "@/apis";
+import { ConcertBasedResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getConcertBased = async (
+ concertId: number,
+): Promise => {
+ const response = await client.get(
+ `/recommendations/concertbased?concertId=${concertId}`,
+ );
+ return response.data;
+};
+
+const useGetConcertBased = (
+ concertId: number,
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["concertBased", concertId],
+ queryFn: () => getConcertBased(concertId),
+ });
+};
+
+export default useGetConcertBased;
diff --git a/src/hooks/queries/useGetConcertFilters.ts b/src/hooks/queries/useGetConcertFilters.ts
new file mode 100644
index 0000000..92e0b7d
--- /dev/null
+++ b/src/hooks/queries/useGetConcertFilters.ts
@@ -0,0 +1,87 @@
+import { client } from "@/apis";
+import {
+ GetConcertFiltersProps,
+ GetConcertInfiniteResponse,
+ GetConcertListResponse,
+} from "@/types";
+import {
+ useInfiniteQuery,
+ UseInfiniteQueryResult,
+} from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getConcertFilters = async ({
+ minPrice,
+ maxPrice,
+ area,
+ startDate,
+ endDate,
+ page,
+ size = 9,
+ categories,
+}: GetConcertFiltersProps) => {
+ const response = await client.get(
+ "/concerts/filters",
+ {
+ params: {
+ minPrice,
+ maxPrice,
+ area,
+ startDate,
+ endDate,
+ page,
+ size,
+ categories,
+ },
+ paramsSerializer: {
+ indexes: null,
+ },
+ }
+ );
+ return response.data;
+};
+
+const useGetConcertFilters = ({
+ minPrice,
+ maxPrice,
+ area,
+ startDate,
+ endDate,
+ size = 9,
+ categories,
+ enabled = false,
+}: Omit & {
+ enabled?: boolean;
+}): UseInfiniteQueryResult => {
+ return useInfiniteQuery({
+ queryKey: [
+ "concert-filter",
+ minPrice,
+ maxPrice,
+ area,
+ startDate,
+ endDate,
+ categories,
+ ],
+ queryFn: ({ pageParam }) =>
+ getConcertFilters({
+ minPrice,
+ maxPrice,
+ area,
+ startDate,
+ endDate,
+ page: pageParam,
+ size,
+ categories,
+ }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ return lastPage.result.currentPage !== allPages[0].result.totalPage
+ ? lastPage.result.currentPage + 1
+ : undefined;
+ },
+ enabled,
+ });
+};
+
+export default useGetConcertFilters;
diff --git a/src/hooks/queries/useGetConcertLikes.ts b/src/hooks/queries/useGetConcertLikes.ts
new file mode 100644
index 0000000..97543bb
--- /dev/null
+++ b/src/hooks/queries/useGetConcertLikes.ts
@@ -0,0 +1,33 @@
+import { client } from "@/apis";
+import { GetLikedConcertListResponse, GetSearchProps } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getConcertLikes = async ({
+ query,
+ genre,
+}: GetSearchProps): Promise => {
+ const response = await client.get(
+ "/concerts/likes",
+ {
+ params: {
+ query: query,
+ genre: genre,
+ },
+ },
+ );
+ return response.data;
+};
+
+const useGetConcertLikes = ({
+ query,
+ genre,
+}: GetSearchProps): UseQueryResult => {
+ return useQuery({
+ queryKey: ["search-liked-concert-data", query],
+ queryFn: () => getConcertLikes({ query, genre }),
+ enabled: query.trim().length !== 0,
+ });
+};
+
+export default useGetConcertLikes;
diff --git a/src/hooks/queries/useGetConcertList.ts b/src/hooks/queries/useGetConcertList.ts
new file mode 100644
index 0000000..d51c5b4
--- /dev/null
+++ b/src/hooks/queries/useGetConcertList.ts
@@ -0,0 +1,59 @@
+import { client } from "@/apis";
+import {
+ GetConcertInfiniteResponse,
+ GetConcertListProps,
+ GetConcertListResponse,
+} from "@/types";
+import {
+ useInfiniteQuery,
+ UseInfiniteQueryResult,
+} from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getConcertsList = async ({
+ genre,
+ page,
+ size = 9,
+}: GetConcertListProps): Promise => {
+ const response = await client.get(`/concerts/views`, {
+ params: {
+ genre,
+ page,
+ size,
+ },
+ });
+ return response.data;
+};
+
+const useGetConcertsList = ({
+ genre,
+ size = 9,
+ enabled = true,
+}: Omit & {
+ enabled?: boolean;
+}): UseInfiniteQueryResult => {
+ return useInfiniteQuery({
+ queryKey: ["concert-data", genre],
+ queryFn: ({ pageParam = 1 }) =>
+ getConcertsList({ genre, page: pageParam, size }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ return lastPage.result.currentPage !== allPages[0].result.totalPage
+ ? lastPage.result.currentPage + 1
+ : undefined;
+ },
+ enabled,
+ });
+};
+
+export default useGetConcertsList;
+
+/**
+ * No description
+ *
+ * @tags useGetInfiniteConcerts
+ * @name concert-controller
+ * @summary 둘러보기 공연 조회 API
+ * @request GET: /concerts/views
+ * @secure bearer
+ */
diff --git a/src/hooks/queries/useGetConcertReviewDetail.ts b/src/hooks/queries/useGetConcertReviewDetail.ts
new file mode 100644
index 0000000..ee1731a
--- /dev/null
+++ b/src/hooks/queries/useGetConcertReviewDetail.ts
@@ -0,0 +1,24 @@
+import { client } from "@/apis";
+import { GetConcertReviewDetailResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getConcertReviewDetail = async (
+ reviewId: number
+): Promise => {
+ const response = await client.get(
+ `/ticket-reviews/reviews/${reviewId}`
+ );
+ return response.data;
+};
+
+const useGetConcertReviewDetail = (
+ reviewId: number
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["concert-review-detail", reviewId],
+ queryFn: () => getConcertReviewDetail(reviewId),
+ });
+};
+
+export default useGetConcertReviewDetail;
diff --git a/src/hooks/queries/useGetConcertReviewList.ts b/src/hooks/queries/useGetConcertReviewList.ts
new file mode 100644
index 0000000..26dd2cc
--- /dev/null
+++ b/src/hooks/queries/useGetConcertReviewList.ts
@@ -0,0 +1,63 @@
+import { client } from "@/apis";
+import {
+ GetConcertReviewInfiniteResponse,
+ GetConcertReviewListProps,
+ GetConcertReviewListResponse,
+} from "@/types";
+import {
+ useInfiniteQuery,
+ UseInfiniteQueryResult,
+} from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getConcertReviewList = async ({
+ concertId,
+ page,
+ size = 9,
+ orderBy,
+}: GetConcertReviewListProps): Promise => {
+ const response = await client.get(
+ `/ticket-reviews/concerts/reviews/${concertId}`,
+ {
+ params: {
+ page,
+ size,
+ orderBy,
+ },
+ }
+ );
+ return response.data;
+};
+
+const useGetConcertReviewList = ({
+ concertId,
+ size = 9,
+ orderBy,
+}: Omit): UseInfiniteQueryResult<
+ GetConcertReviewInfiniteResponse,
+ AxiosError
+> => {
+ return useInfiniteQuery({
+ queryKey: ["concert-review-data", orderBy],
+ queryFn: ({ pageParam = 1 }) =>
+ getConcertReviewList({ concertId, page: pageParam, size, orderBy }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ return lastPage.result.currentPage !== allPages[0].result.totalPage
+ ? lastPage.result.currentPage + 1
+ : undefined;
+ },
+ });
+};
+
+export default useGetConcertReviewList;
+
+/**
+ * No description
+ *
+ * @tags useGetConcertReviewList
+ * @name ticket-review-controller
+ * @summary 공연에 작성된 리뷰 목록 조회 api
+ * @request GET: /ticket-reviews/concerts/reviews
+ * @secure bearer
+ */
diff --git a/src/hooks/queries/useGetConcertReviewSize.ts b/src/hooks/queries/useGetConcertReviewSize.ts
new file mode 100644
index 0000000..4b5a589
--- /dev/null
+++ b/src/hooks/queries/useGetConcertReviewSize.ts
@@ -0,0 +1,24 @@
+import { client } from "@/apis";
+import { GetConcertReviewSizeResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getConcertReviewSize = async (
+ concertId: number
+): Promise => {
+ const response = await client.get(
+ `/ticket-reviews/concerts/${concertId}/size`
+ );
+ return response.data;
+};
+
+const useGetConcertReviewSize = (
+ concertId: number
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["concert-review-size", concertId],
+ queryFn: () => getConcertReviewSize(concertId),
+ });
+};
+
+export default useGetConcertReviewSize;
diff --git a/src/hooks/queries/useGetItemBased.ts b/src/hooks/queries/useGetItemBased.ts
new file mode 100644
index 0000000..67b781d
--- /dev/null
+++ b/src/hooks/queries/useGetItemBased.ts
@@ -0,0 +1,24 @@
+import { client } from "@/apis";
+import { UserItemBasedResponse } from "@/types";
+
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getItemBased = async (): Promise => {
+ const response = await client.get(
+ "/recommendations/itembased"
+ );
+ return response.data;
+};
+
+const useGetItemBased = (): UseQueryResult<
+ UserItemBasedResponse,
+ AxiosError
+> => {
+ return useQuery({
+ queryKey: ["itemBased"],
+ queryFn: getItemBased,
+ });
+};
+
+export default useGetItemBased;
diff --git a/src/hooks/queries/useGetRecommendClacoTicket.ts b/src/hooks/queries/useGetRecommendClacoTicket.ts
new file mode 100644
index 0000000..be18ffa
--- /dev/null
+++ b/src/hooks/queries/useGetRecommendClacoTicket.ts
@@ -0,0 +1,25 @@
+import { client } from "@/apis";
+import { UserRecClacoTicketResponse } from "@/types";
+
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getRecommendClacoTicket =
+ async (): Promise => {
+ const response = await client.get(
+ "/recommendations/clacobooks"
+ );
+ return response.data;
+ };
+
+const useGetRecommendClacoTicket = (): UseQueryResult<
+ UserRecClacoTicketResponse,
+ AxiosError
+> => {
+ return useQuery({
+ queryKey: ["recommend-clacoticket"],
+ queryFn: getRecommendClacoTicket,
+ });
+};
+
+export default useGetRecommendClacoTicket;
diff --git a/src/hooks/queries/useGetReviewAutoCompleteSearch.ts b/src/hooks/queries/useGetReviewAutoCompleteSearch.ts
new file mode 100644
index 0000000..14c7179
--- /dev/null
+++ b/src/hooks/queries/useGetReviewAutoCompleteSearch.ts
@@ -0,0 +1,30 @@
+import { client } from "@/apis";
+import { AutoCompleteSearchResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getReviewAutoCompleteSearch = async (
+ query: string,
+): Promise => {
+ const response = await client.get(
+ "/concerts/reviews/search",
+ {
+ params: {
+ query: query,
+ },
+ },
+ );
+ return response.data;
+};
+
+const useGetReviewAutoCompleteSearch = (
+ query: string,
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["useReviewAutoComplete", query],
+ queryFn: () => getReviewAutoCompleteSearch(query),
+ enabled: query.trim().length !== 0,
+ });
+};
+
+export default useGetReviewAutoCompleteSearch;
diff --git a/src/hooks/queries/useGetSearch.ts b/src/hooks/queries/useGetSearch.ts
new file mode 100644
index 0000000..8e69772
--- /dev/null
+++ b/src/hooks/queries/useGetSearch.ts
@@ -0,0 +1,60 @@
+import { client } from "@/apis";
+import {
+ GetConcertInfiniteResponse,
+ GetConcertListResponse,
+ GetSearchProps,
+} from "@/types";
+import {
+ useInfiniteQuery,
+ UseInfiniteQueryResult,
+} from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getSearch = async ({
+ query,
+ page,
+ size = 9,
+}: GetSearchProps): Promise => {
+ const response = await client.get(
+ "/concerts/queries",
+ {
+ params: {
+ query: query,
+ page: page,
+ size: size,
+ },
+ }
+ );
+ return response.data;
+};
+
+const useGetSearch = ({
+ query,
+ size = 9,
+}: Omit): UseInfiniteQueryResult<
+ GetConcertInfiniteResponse,
+ AxiosError
+> => {
+ return useInfiniteQuery({
+ queryKey: ["search-concert-data", query],
+ queryFn: ({ pageParam }) => getSearch({ query, page: pageParam, size }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => {
+ return lastPage.result.currentPage !== allPages[0].result.totalPage
+ ? lastPage.result.currentPage + 1
+ : undefined;
+ },
+ });
+};
+
+export default useGetSearch;
+
+/**
+ * No description
+ *
+ * @tags useGetSearch
+ * @name concert-controller
+ * @summary 검색 API
+ * @request GET:/concerts/queries
+ * @secure bearer
+ */
diff --git a/src/hooks/queries/useGetShowDetail.ts b/src/hooks/queries/useGetShowDetail.ts
new file mode 100644
index 0000000..151ec00
--- /dev/null
+++ b/src/hooks/queries/useGetShowDetail.ts
@@ -0,0 +1,22 @@
+import { client } from "@/apis";
+import { ShowDetailCheckResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getShowDetail = async (showId: number) => {
+ const response = await client.get(
+ `/concerts/details/${showId}`,
+ );
+ return response.data;
+};
+
+const useGetShowDetail = (
+ showId: number,
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["showDetail", showId],
+ queryFn: () => getShowDetail(showId),
+ });
+};
+
+export default useGetShowDetail;
diff --git a/src/hooks/queries/useGetTicketReviewDetail.ts b/src/hooks/queries/useGetTicketReviewDetail.ts
new file mode 100644
index 0000000..8bdd68c
--- /dev/null
+++ b/src/hooks/queries/useGetTicketReviewDetail.ts
@@ -0,0 +1,22 @@
+import { client } from "@/apis";
+import { TicketReviewDetailResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getTicketReviewDetail = async (ticketReviewId: number) => {
+ const response = await client.get(
+ `/ticket-reviews/${ticketReviewId}`,
+ );
+ return response.data;
+};
+
+const useGetTicketReviewDetail = (
+ ticketReviewId: number,
+): UseQueryResult => {
+ return useQuery({
+ queryKey: ["ticketReviewDetail", ticketReviewId],
+ queryFn: () => getTicketReviewDetail(ticketReviewId),
+ });
+};
+
+export default useGetTicketReviewDetail;
diff --git a/src/hooks/queries/useGetUserBased.ts b/src/hooks/queries/useGetUserBased.ts
new file mode 100644
index 0000000..5f5ffa0
--- /dev/null
+++ b/src/hooks/queries/useGetUserBased.ts
@@ -0,0 +1,20 @@
+import { client } from "@/apis";
+import { UserBasedResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getUserBased = async (): Promise => {
+ const response = await client.get(
+ "/recommendations/userbased"
+ );
+ return response.data;
+};
+
+const useGetUserBased = (): UseQueryResult => {
+ return useQuery({
+ queryKey: ["userBased"],
+ queryFn: getUserBased,
+ });
+};
+
+export default useGetUserBased;
diff --git a/src/hooks/queries/useGetUserInfo.ts b/src/hooks/queries/useGetUserInfo.ts
new file mode 100644
index 0000000..a30c222
--- /dev/null
+++ b/src/hooks/queries/useGetUserInfo.ts
@@ -0,0 +1,18 @@
+import { client } from "@/apis";
+import { UserInfoResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getUserInfo = async (): Promise => {
+ const response = await client.get("/members");
+ return response.data;
+};
+
+const useGetUserInfo = (): UseQueryResult => {
+ return useQuery({
+ queryKey: ["userInfo"],
+ queryFn: getUserInfo,
+ });
+};
+
+export default useGetUserInfo;
diff --git a/src/hooks/queries/useGetUserPreferences.ts b/src/hooks/queries/useGetUserPreferences.ts
new file mode 100644
index 0000000..acc4503
--- /dev/null
+++ b/src/hooks/queries/useGetUserPreferences.ts
@@ -0,0 +1,21 @@
+import { client } from "@/apis";
+import { UserPreferencesResponse } from "@/types";
+import { useQuery, UseQueryResult } from "@tanstack/react-query";
+import { AxiosError } from "axios";
+
+const getUserPreferences = async (): Promise => {
+ const response = await client.get("/preferences");
+ return response.data;
+};
+
+const useGetUserPreferences = (): UseQueryResult<
+ UserPreferencesResponse,
+ AxiosError
+> => {
+ return useQuery({
+ queryKey: ["userPreferences"],
+ queryFn: getUserPreferences,
+ });
+};
+
+export default useGetUserPreferences;
diff --git a/src/hooks/utils/extractDateRange.ts b/src/hooks/utils/extractDateRange.ts
new file mode 100644
index 0000000..231902a
--- /dev/null
+++ b/src/hooks/utils/extractDateRange.ts
@@ -0,0 +1,19 @@
+const extractDateRange = (fromDate: string, toDate: string): string => {
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(
+ 2,
+ "0"
+ )}.${String(date.getDate()).padStart(2, "0")}`;
+ };
+ if (fromDate && toDate) {
+ if (fromDate === toDate) {
+ return formatDate(fromDate);
+ } else {
+ return `${formatDate(fromDate)} ~ ${formatDate(toDate)}`;
+ }
+ }
+ return "공연 기간 정보 없음";
+};
+
+export default extractDateRange;
diff --git a/src/hooks/utils/extractPricesWithSeats.ts b/src/hooks/utils/extractPricesWithSeats.ts
new file mode 100644
index 0000000..d32bac0
--- /dev/null
+++ b/src/hooks/utils/extractPricesWithSeats.ts
@@ -0,0 +1,49 @@
+import { PricesMapType } from "@/types";
+
+const extractPricesWithSeats = (priceString: string) => {
+ if (!priceString.includes("무료")) {
+ const priceEntries = priceString.split(", ");
+ const pricesMap: PricesMapType = {};
+
+ priceEntries.forEach((entry) => {
+ const match = entry.match(/(.*)\s(\d{1,3}(,\d{3})*)원/);
+ if (match) {
+ const seat = match[1].trim();
+ const price = parseInt(match[2].replace(/,/g, ""), 10);
+ pricesMap[seat] = price;
+ } else if (entry.includes("전석")) {
+ const matchFreeSeat = entry.match(/전석\s(\d{1,3}(,\d{3})*)원/);
+ if (matchFreeSeat) {
+ pricesMap["전석"] = parseInt(matchFreeSeat[1].replace(/,/g, ""), 10);
+ }
+ }
+ });
+
+ const cleanedPrices = Object.values(pricesMap).filter(
+ (price) => typeof price === "number",
+ ) as number[];
+
+ const minPrice =
+ cleanedPrices.length > 0 ? Math.min(...cleanedPrices) : null;
+ const maxPrice =
+ cleanedPrices.length > 0 ? Math.max(...cleanedPrices) : null;
+
+ return {
+ seats: Object.keys(pricesMap),
+ prices: Object.values(pricesMap).map((price) =>
+ typeof price === "number" ? `${price.toLocaleString()}원` : price,
+ ),
+ minPrice,
+ maxPrice,
+ };
+ }
+
+ return {
+ seats: ["전석"],
+ prices: ["무료"],
+ minPrice: "무료",
+ maxPrice: "무료",
+ };
+};
+
+export default extractPricesWithSeats;
\ No newline at end of file
diff --git a/src/hooks/utils/extractSchedule.ts b/src/hooks/utils/extractSchedule.ts
new file mode 100644
index 0000000..7077916
--- /dev/null
+++ b/src/hooks/utils/extractSchedule.ts
@@ -0,0 +1,103 @@
+import { PrfGuidance } from "@/components/ShowDetail/ShowInformation/ShowEssentials";
+import { DaysMapType } from "@/types";
+
+const daysMap: DaysMapType[] = [
+ { day: "일요일", dayIndex: 0 },
+ { day: "월요일", dayIndex: 1 },
+ { day: "화요일", dayIndex: 2 },
+ { day: "수요일", dayIndex: 3 },
+ { day: "목요일", dayIndex: 4 },
+ { day: "금요일", dayIndex: 5 },
+ { day: "토요일", dayIndex: 6 },
+];
+
+const extractSchedule = (dtguidance: string): PrfGuidance[] => {
+ if (!dtguidance) return [];
+
+ const scheduleEntries = dtguidance.split(", ");
+ const schedule: PrfGuidance[] = [];
+
+ const sortTimes = (times: string[]) => {
+ return times.sort((a, b) => {
+ const timeToMinutes = (time: string) => {
+ const [hours, minutes] = time.split(":").map(Number);
+ return hours * 60 + minutes;
+ };
+ return timeToMinutes(a) - timeToMinutes(b);
+ });
+ };
+
+ scheduleEntries.forEach((entry) => {
+ const match = entry.match(/(.*)\((.*)\)/);
+ if (match) {
+ let dayRange = match[1].trim();
+ const times = sortTimes(match[2].split(",").map((time) => time.trim()));
+
+ if (dayRange === "HOL") {
+ dayRange = "일요일";
+ }
+
+ if (dayRange.includes("~")) {
+ const [startDay, endDay] = dayRange
+ .split(" ~ ")
+ .map((day) => day.trim());
+ const startDayIndex = daysMap.find((d) => d.day === startDay)?.dayIndex;
+ const endDayIndex = daysMap.find((d) => d.day === endDay)?.dayIndex;
+
+ if (startDayIndex !== undefined && endDayIndex !== undefined) {
+ if (startDayIndex <= endDayIndex) {
+ for (let i = startDayIndex; i <= endDayIndex; i++) {
+ const fullDay = daysMap.find((d) => d.dayIndex === i)?.day;
+ const shortDay = fullDay ? fullDay.slice(0, 1) : null;
+ if (shortDay) {
+ const existing = schedule.find((item) => item.day === shortDay);
+ if (existing) {
+ existing.times = sortTimes([...existing.times, ...times]);
+ } else {
+ schedule.push({ day: shortDay, times });
+ }
+ }
+ }
+ } else {
+ for (let i = startDayIndex; i < daysMap.length; i++) {
+ const fullDay = daysMap.find((d) => d.dayIndex === i)?.day;
+ const shortDay = fullDay ? fullDay.slice(0, 1) : null;
+ if (shortDay) {
+ const existing = schedule.find((item) => item.day === shortDay);
+ if (existing) {
+ existing.times = sortTimes([...existing.times, ...times]);
+ } else {
+ schedule.push({ day: shortDay, times });
+ }
+ }
+ }
+ for (let i = 0; i <= endDayIndex; i++) {
+ const fullDay = daysMap.find((d) => d.dayIndex === i)?.day;
+ const shortDay = fullDay ? fullDay.slice(0, 1) : null;
+ if (shortDay) {
+ const existing = schedule.find((item) => item.day === shortDay);
+ if (existing) {
+ existing.times = sortTimes([...existing.times, ...times]);
+ } else {
+ schedule.push({ day: shortDay, times });
+ }
+ }
+ }
+ }
+ }
+ } else {
+ const shortDay = dayRange.slice(0, 1);
+ const existing = schedule.find((item) => item.day === shortDay);
+ if (existing) {
+ existing.times = sortTimes([...existing.times, ...times]);
+ } else {
+ schedule.push({ day: shortDay, times });
+ }
+ }
+ }
+ });
+
+ return schedule;
+};
+
+export default extractSchedule;
diff --git a/src/hooks/utils/extractShowTime.tsx b/src/hooks/utils/extractShowTime.tsx
new file mode 100644
index 0000000..8e56943
--- /dev/null
+++ b/src/hooks/utils/extractShowTime.tsx
@@ -0,0 +1,49 @@
+import extractSchedule from "./extractSchedule";
+
+export type ShowTimesByDate = {
+ [key: string]: { time: string }[];
+};
+
+export type ExtractShowTimeProps = {
+ prfpdfrom: string;
+ prfpdto: string;
+ dtguidance: string;
+};
+
+const extractShowTime = ({
+ prfpdfrom,
+ prfpdto,
+ dtguidance,
+}: ExtractShowTimeProps): ShowTimesByDate => {
+ const showTimesByDate: ShowTimesByDate = {};
+ const schedule = extractSchedule(dtguidance);
+
+ const daysOfWeek = ["일", "월", "화", "수", "목", "금", "토"];
+
+ const startDate = new Date(prfpdfrom);
+ const endDate = new Date(prfpdto);
+
+ for (
+ let currentDate = new Date(startDate);
+ currentDate <= endDate;
+ currentDate.setDate(currentDate.getDate() + 1)
+ ) {
+ const year = currentDate.getFullYear();
+ const month = String(currentDate.getMonth() + 1).padStart(2, "0");
+ const day = String(currentDate.getDate()).padStart(2, "0");
+ const dateString = `${year}-${month}-${day}`;
+
+ const dayOfWeek = daysOfWeek[currentDate.getDay()];
+ const matchingSchedule = schedule.find((item) => item.day === dayOfWeek);
+
+ if (matchingSchedule && matchingSchedule.times.length > 0) {
+ showTimesByDate[dateString] = matchingSchedule.times.map((time) => ({
+ time,
+ }));
+ }
+ }
+
+ return showTimesByDate;
+};
+
+export default extractShowTime;
diff --git a/src/hooks/utils/formatYYYYMMDD.ts b/src/hooks/utils/formatYYYYMMDD.ts
new file mode 100644
index 0000000..0fdabe1
--- /dev/null
+++ b/src/hooks/utils/formatYYYYMMDD.ts
@@ -0,0 +1,12 @@
+import { parse, format } from "date-fns";
+import { ko } from "date-fns/locale";
+
+const formatDateYYYYMMDD = (dateStr: string) => {
+ const date = parse(dateStr.split(" ")[0], "yyyy-MM-dd", new Date());
+ const formattedDate = format(date, "yyyy.MM.dd");
+ const shortDay = `(${format(date, "E", { locale: ko })})`;
+
+ return `${formattedDate} ${shortDay}`;
+};
+
+export default formatDateYYYYMMDD;
diff --git a/src/hooks/utils/index.ts b/src/hooks/utils/index.ts
new file mode 100644
index 0000000..5ca7294
--- /dev/null
+++ b/src/hooks/utils/index.ts
@@ -0,0 +1,11 @@
+export { default as useTruncateText } from "./useTruncateText";
+export { default as useThumbnailModal } from "./useThumbnailModal";
+export { default as useDebouncedState } from "./useDebouncedState";
+export { default as useShowFilter } from "./useShowFilter";
+export { default as formatDateYYYYMMDD } from "./formatYYYYMMDD";
+export { default as extractDateRange } from "./extractDateRange";
+export { default as extractPricesWithSeats } from "./extractPricesWithSeats";
+export { default as extractSchedule } from "./extractSchedule";
+export { default as timeToMinutes } from "./timeToMinutes";
+export { default as useRefFocusEffect } from "./useRefFocusEffect";
+export { default as useDeferredLoading } from "./useDeferredLoading";
diff --git a/src/hooks/utils/timeToMinutes.ts b/src/hooks/utils/timeToMinutes.ts
new file mode 100644
index 0000000..a73e96e
--- /dev/null
+++ b/src/hooks/utils/timeToMinutes.ts
@@ -0,0 +1,17 @@
+const timeToMinutes = (runtime: string): string => {
+ if (!runtime) return "공연 시간 정보 없음";
+
+ const hourMatch = runtime.match(/(\d+)시간/);
+ const minuteMatch = runtime.match(/(\d+)분/);
+
+ const hours = hourMatch ? parseInt(hourMatch[1], 10) : 0;
+ const minutes = minuteMatch ? parseInt(minuteMatch[1], 10) : 0;
+
+ const result = hours * 60 + minutes;
+
+ if (result === 0) return "공연 시간 정보 없음";
+
+ return `${result}분`;
+};
+
+export default timeToMinutes;
diff --git a/src/hooks/utils/useDebouncedState.ts b/src/hooks/utils/useDebouncedState.ts
new file mode 100644
index 0000000..8910cab
--- /dev/null
+++ b/src/hooks/utils/useDebouncedState.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from "react";
+
+export default function useDebouncedState(
+ value: T,
+ delay: number = 500,
+ immediate: boolean = false
+): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ if (immediate) {
+ setDebouncedValue(value);
+ return;
+ }
+ const timeout = setTimeout(() => setDebouncedValue(value), delay);
+ return () => clearTimeout(timeout);
+ }, [value, delay, immediate]);
+
+ return debouncedValue;
+}
diff --git a/src/hooks/utils/useDeferredLoading.ts b/src/hooks/utils/useDeferredLoading.ts
new file mode 100644
index 0000000..543d2f0
--- /dev/null
+++ b/src/hooks/utils/useDeferredLoading.ts
@@ -0,0 +1,26 @@
+import { useState, useEffect } from "react";
+
+//200ms보다 적은 응답 시간이 걸리는 경우 스캘레톤 컴포넌트 UI를 보여주지 않기 위한 커스텀 훅 (사용성 개선을 위함)
+const useDeferredLoading = (isLoading: boolean, delay: number = 200) => {
+ const [showSkeleton, setShowSkeleton] = useState(false);
+
+ useEffect(() => {
+ let timeoutId: NodeJS.Timeout;
+
+ if (isLoading) {
+ timeoutId = setTimeout(() => {
+ setShowSkeleton(true);
+ }, delay);
+ } else {
+ setShowSkeleton(false);
+ }
+
+ return () => clearTimeout(timeoutId);
+ }, [isLoading, delay]);
+
+ return {
+ shouldShowSkeleton: isLoading && showSkeleton,
+ };
+};
+
+export default useDeferredLoading;
diff --git a/src/hooks/utils/useRefFocusEffect.ts b/src/hooks/utils/useRefFocusEffect.ts
new file mode 100644
index 0000000..bca0bd1
--- /dev/null
+++ b/src/hooks/utils/useRefFocusEffect.ts
@@ -0,0 +1,38 @@
+import { useEffect, useRef } from "react";
+
+const useRefFocusEffect = (
+ onFocusCallback: () => void,
+
+ deps: React.DependencyList = []
+) => {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const currentElement = ref.current;
+
+ if (currentElement) {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ onFocusCallback();
+ }
+ });
+ },
+ {
+ threshold: 0.5,
+ }
+ );
+
+ observer.observe(currentElement);
+
+ return () => {
+ observer.unobserve(currentElement);
+ };
+ }
+ }, [...deps]);
+
+ return { elementRef: ref };
+};
+
+export default useRefFocusEffect;
diff --git a/src/hooks/utils/useShowFilter.ts b/src/hooks/utils/useShowFilter.ts
new file mode 100644
index 0000000..de3e1b5
--- /dev/null
+++ b/src/hooks/utils/useShowFilter.ts
@@ -0,0 +1,83 @@
+import { useState } from "react";
+
+export type FilterState = {
+ priceRange: string;
+ selectedLocation: string;
+ dateRange: string;
+ selectedFeature: string;
+};
+
+export type UseShowFilterReturn = {
+ filterState: FilterState;
+ showFilter: boolean;
+ hasActiveFilters: boolean;
+ setShowFilter: (show: boolean) => void;
+ handleFilterClick: () => void;
+ handleRefreshClick: () => void;
+ applyFilter: (
+ price: string,
+ location: string,
+ date: string,
+ feature: string
+ ) => void;
+ closeFilter: () => void;
+};
+
+const useShowFilter = (): UseShowFilterReturn => {
+ const [showFilter, setShowFilter] = useState(false);
+ const [filterState, setFilterState] = useState({
+ priceRange: "",
+ selectedLocation: "",
+ dateRange: "",
+ selectedFeature: "",
+ });
+
+ const hasActiveFilters = Object.values(filterState).some(
+ (value) => value !== ""
+ );
+
+ const handleFilterClick = () => {
+ setShowFilter(true);
+ };
+
+ const handleRefreshClick = () => {
+ setFilterState({
+ priceRange: "",
+ selectedLocation: "",
+ dateRange: "",
+ selectedFeature: "",
+ });
+ };
+
+ const closeFilter = () => {
+ setShowFilter(false);
+ };
+
+ const applyFilter = (
+ price: string,
+ location: string,
+ date: string,
+ feature: string
+ ) => {
+ setFilterState({
+ priceRange: price,
+ selectedLocation: location,
+ dateRange: date,
+ selectedFeature: feature,
+ });
+ closeFilter();
+ };
+
+ return {
+ filterState,
+ showFilter,
+ hasActiveFilters,
+ setShowFilter,
+ handleFilterClick,
+ handleRefreshClick,
+ applyFilter,
+ closeFilter,
+ };
+};
+
+export default useShowFilter;
diff --git a/src/hooks/utils/useThumbnailModal.ts b/src/hooks/utils/useThumbnailModal.ts
new file mode 100644
index 0000000..6a939f4
--- /dev/null
+++ b/src/hooks/utils/useThumbnailModal.ts
@@ -0,0 +1,52 @@
+import { SelectThumbnail, UseThumbnailModalReturn } from "@/types";
+import { Swiper as SwiperType } from "swiper";
+import { useState, useEffect } from "react";
+
+const useThumbnailModal = (): UseThumbnailModalReturn => {
+ const [thumbsSwiper, setThumbsSwiper] = useState(null);
+ const [isThumbnailShow, setIsThumbnailShow] = useState(false);
+ const [isAnimating, setIsAnimating] = useState(false);
+ const [selectIndex, setSelectIndex] = useState({
+ page: 1,
+ index: 0,
+ });
+
+ useEffect(() => {
+ if (isThumbnailShow) {
+ document.body.style.overflow = "hidden";
+ setTimeout(() => {
+ setIsAnimating(true);
+ }, 50);
+ } else {
+ document.body.style.overflow = "unset";
+ setIsAnimating(false);
+ }
+
+ return () => {
+ document.body.style.overflow = "unset";
+ };
+ }, [isThumbnailShow]);
+
+ const handleImageClick = () => {
+ if (isThumbnailShow) {
+ setIsAnimating(false);
+ setTimeout(() => {
+ setIsThumbnailShow(false);
+ }, 300);
+ } else {
+ setIsThumbnailShow(true);
+ }
+ };
+
+ return {
+ thumbsSwiper,
+ isThumbnailShow,
+ isAnimating,
+ selectIndex,
+ setThumbsSwiper,
+ setSelectIndex,
+ handleImageClick,
+ };
+};
+
+export default useThumbnailModal;
diff --git a/src/hooks/utils/useTruncateText.ts b/src/hooks/utils/useTruncateText.ts
new file mode 100644
index 0000000..a12708a
--- /dev/null
+++ b/src/hooks/utils/useTruncateText.ts
@@ -0,0 +1,22 @@
+import { useState, useEffect } from "react";
+
+const useTruncateText = (
+ text: string | undefined | null,
+ maxLength: number
+): string => {
+ const [truncatedText, setTruncatedText] = useState("");
+
+ useEffect(() => {
+ if (!text) {
+ setTruncatedText("");
+ } else if (text.length <= maxLength) {
+ setTruncatedText(text);
+ } else {
+ setTruncatedText(text.slice(0, maxLength).trim() + "...");
+ }
+ }, [text, maxLength]);
+
+ return truncatedText;
+};
+
+export default useTruncateText;
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/libraries/index.tsx b/src/libraries/index.tsx
index 0a5e99a..c24fc9d 100644
--- a/src/libraries/index.tsx
+++ b/src/libraries/index.tsx
@@ -1,11 +1,23 @@
-import { PropsWithChildren } from 'react';
-import { RecoilRoot } from 'recoil';
-import ReactQuerySetting from '@/libraries/reactQuery/ReactQuerySetting';
+import { PropsWithChildren, useEffect } from "react";
+
+import ReactQuerySetting from "@/libraries/reactQuery/ReactQuerySetting";
+
+const setScreenHeight = () => {
+ const vh = window.innerHeight * 0.01;
+ document.documentElement.style.setProperty("--vh", `${vh}px`);
+};
+
+export default function AppContainer({ children }: PropsWithChildren) {
+ useEffect(() => {
+ setScreenHeight();
+
+ window.addEventListener("resize", setScreenHeight);
+ return () => window.removeEventListener("resize", setScreenHeight);
+ }, []);
-export default function AppRegister({ children }: PropsWithChildren) {
return (
-
- {children}
-
+
+ {children}
+
);
}
diff --git a/src/libraries/reactQuery/ReactQuerySetting.tsx b/src/libraries/reactQuery/ReactQuerySetting.tsx
index d5fa3df..bb770f1 100644
--- a/src/libraries/reactQuery/ReactQuerySetting.tsx
+++ b/src/libraries/reactQuery/ReactQuerySetting.tsx
@@ -1,13 +1,14 @@
-import { PropsWithChildren } from 'react';
+import { PropsWithChildren } from "react";
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const MINUTES = 1000 * 60;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- retry: 1,
+ retry: false,
retryDelay: 1000,
staleTime: 5 * MINUTES,
},
@@ -15,5 +16,10 @@ const queryClient = new QueryClient({
});
export default function ReactQuerySetting({ children }: PropsWithChildren) {
- return {children} ;
+ return (
+
+
+ {children}
+
+ );
}
diff --git a/src/libraries/store/concertInfo.ts b/src/libraries/store/concertInfo.ts
new file mode 100644
index 0000000..b4177f5
--- /dev/null
+++ b/src/libraries/store/concertInfo.ts
@@ -0,0 +1,30 @@
+import { create } from "zustand";
+
+export type ConcertInfo = {
+ genrenm: string;
+ prfstate: string;
+ prfnm: string;
+};
+
+export type ConcertInfoState = {
+ data: ConcertInfo;
+ setConcertInfo: (data: ConcertInfo) => void;
+ clearConcertInfo: () => void;
+};
+
+export const useConcertInfoStore = create()((set) => ({
+ data: {
+ genrenm: "",
+ prfstate: "",
+ prfnm: "",
+ },
+ setConcertInfo: (data) => set({ data }),
+ clearConcertInfo: () =>
+ set({
+ data: {
+ genrenm: "",
+ prfstate: "",
+ prfnm: "",
+ },
+ }),
+}));
diff --git a/src/libraries/store/index.ts b/src/libraries/store/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/libraries/store/onboarding.ts b/src/libraries/store/onboarding.ts
new file mode 100644
index 0000000..1fe0648
--- /dev/null
+++ b/src/libraries/store/onboarding.ts
@@ -0,0 +1,97 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export type OnboardingState = {
+ gender: string;
+ setGender: (gender: string) => void;
+ clearGender: () => void;
+
+ age: number;
+ setAge: (age: number) => void;
+ clearAge: () => void;
+
+ minPrice: number;
+ setMinPrice: (minPrice: number) => void;
+ clearMinPrice: () => void;
+
+ maxPrice: number;
+ setMaxPrice: (maxPrice: number) => void;
+ clearMaxPrice: () => void;
+
+ regionPreferences: string[];
+ setRegionPreferences: (regions: string[]) => void;
+ addRegionPreference: (region: string) => void;
+ clearRegionPreferences: () => void;
+
+ typePreferences: string[];
+ setTypePreferences: (types: string[]) => void;
+ addTypePreference: (type: string) => void;
+ clearTypePreferences: () => void;
+
+ categoryPreferences: string[];
+ setCategoryPreferences: (categories: string[]) => void;
+ addCategoryPreference: (category: string) => void;
+ removeLastCategoryPreference: () => void;
+ removeSpecificCategoryPreference: (category: string) => void;
+ clearCategoryPreferences: () => void;
+};
+
+export const useOnboardingStore = create()(
+ persist(
+ (set) => ({
+ gender: "",
+ setGender: (gender) => set({ gender }),
+ clearGender: () => set({ gender: "" }),
+
+ age: 0,
+ setAge: (age) => set({ age }),
+ clearAge: () => set({ age: 0 }),
+
+ minPrice: 0,
+ setMinPrice: (minPrice) => set({ minPrice }),
+ clearMinPrice: () => set({ minPrice: 0 }),
+
+ maxPrice: 1000000,
+ setMaxPrice: (maxPrice) => set({ maxPrice }),
+ clearMaxPrice: () => set({ maxPrice: 1000000 }),
+
+ regionPreferences: [],
+ setRegionPreferences: (regions) => set({ regionPreferences: regions }),
+ addRegionPreference: (region) =>
+ set((state) => ({
+ regionPreferences: [...state.regionPreferences, region],
+ })),
+ clearRegionPreferences: () => set({ regionPreferences: [] }),
+
+ typePreferences: [],
+ setTypePreferences: (types) => set({ typePreferences: types }),
+ addTypePreference: (type) =>
+ set((state) => ({
+ typePreferences: [...state.typePreferences, type],
+ })),
+ clearTypePreferences: () => set({ typePreferences: [] }),
+
+ categoryPreferences: [],
+ setCategoryPreferences: (categories) =>
+ set({ categoryPreferences: categories }),
+ addCategoryPreference: (category) =>
+ set((state) => ({
+ categoryPreferences: [...state.categoryPreferences, category],
+ })),
+ removeLastCategoryPreference: () =>
+ set((state) => ({
+ categoryPreferences: state.categoryPreferences.slice(0, -1),
+ })),
+ removeSpecificCategoryPreference: (category: string) =>
+ set((state) => ({
+ categoryPreferences: state.categoryPreferences.filter(
+ (item) => item !== category,
+ ),
+ })),
+ clearCategoryPreferences: () => set({ categoryPreferences: [] }),
+ }),
+ {
+ name: "user-onboarding-storage",
+ },
+ ),
+);
diff --git a/src/libraries/store/reviewInfo.ts b/src/libraries/store/reviewInfo.ts
new file mode 100644
index 0000000..9cf8d30
--- /dev/null
+++ b/src/libraries/store/reviewInfo.ts
@@ -0,0 +1,27 @@
+import { create } from "zustand";
+
+export type ReviewInfo = {
+ starRate: number;
+ content: string;
+};
+
+export type ReviewInfoState = {
+ data: ReviewInfo;
+ setReviewInfo: (data: ReviewInfo) => void;
+ clearReviewInfo: () => void;
+};
+
+export const useReviewInfoStore = create()((set) => ({
+ data: {
+ starRate: 0,
+ content: "",
+ },
+ setReviewInfo: (data) => set({ data }),
+ clearReviewInfo: () =>
+ set({
+ data: {
+ starRate: 0,
+ content: "",
+ },
+ }),
+}));
diff --git a/src/libraries/store/user.ts b/src/libraries/store/user.ts
new file mode 100644
index 0000000..dbe8d5c
--- /dev/null
+++ b/src/libraries/store/user.ts
@@ -0,0 +1,21 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export type UserState = {
+ nickname: string;
+ setNickname: (nickname: string) => void;
+ clearNickname: () => void;
+};
+
+export const useUserStore = create()(
+ persist(
+ (set) => ({
+ nickname: "",
+ setNickname: (nickname) => set({ nickname }),
+ clearNickname: () => set({ nickname: "" }),
+ }),
+ {
+ name: "user-storage",
+ }
+ )
+);
diff --git a/src/libraries/toast/Toast.tsx b/src/libraries/toast/Toast.tsx
new file mode 100644
index 0000000..f766252
--- /dev/null
+++ b/src/libraries/toast/Toast.tsx
@@ -0,0 +1,34 @@
+import { useEffect, useState } from "react";
+
+export type ToastProps = {
+ message: string;
+ setToast: React.Dispatch>;
+};
+
+export const Toast = ({ message, setToast }: ToastProps) => {
+ const [isVisible, setIsVisible] = useState(true);
+
+ useEffect(() => {
+ const fadeOutTimer = setTimeout(() => {
+ setIsVisible(false);
+ }, 2500);
+
+ const removeTimer = setTimeout(() => {
+ setToast(false);
+ }, 3000);
+
+ return () => {
+ clearTimeout(fadeOutTimer);
+ clearTimeout(removeTimer);
+ };
+ }, [setToast]);
+
+ return (
+
+ );
+};
diff --git a/src/libraries/toast/index.ts b/src/libraries/toast/index.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/main.tsx b/src/main.tsx
index 8931e81..bc1a62a 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,10 @@
-import { StrictMode } from 'react';
-import { createRoot } from 'react-dom/client';
-import App from './App.tsx';
-import './globals.css';
+// import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App.tsx";
+import "./globals.css";
-createRoot(document.getElementById('root')!).render(
-
-
-
+createRoot(document.getElementById("root")!).render(
+ //
+
+ //
);
diff --git a/src/pages/Browse/page.tsx b/src/pages/Browse/page.tsx
new file mode 100644
index 0000000..db34d3c
--- /dev/null
+++ b/src/pages/Browse/page.tsx
@@ -0,0 +1,400 @@
+import { SearchBar } from "@/components/common/Search/Bar";
+import { ShowFilterTab } from "@/components/common/ShowFilterTab";
+import { useEffect, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { ReactComponent as Filter } from "@/assets/svgs/filter.svg";
+import { ReactComponent as Refresh } from "@/assets/svgs/refresh.svg";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { SearchCard } from "@/components/common/Search/Card";
+import { ShowFilter } from "@/components/Browse/ShowFilter";
+import { ShowSummaryCard } from "@/components/common/ShowSummaryCard";
+import {
+ useDebouncedState,
+ useDeferredLoading,
+ useRefFocusEffect,
+ useShowFilter,
+} from "@/hooks/utils";
+import {
+ useGetAutoCompleteSearch,
+ useGetConcertFilters,
+ useGetConcertList,
+ useGetSearch,
+} from "@/hooks/queries";
+import { AutoCompleteSearchCard, FilterValue, TabMenu } from "@/types";
+import { Skeleton } from "@/components/ui/skeleton";
+import { SearchResult } from "@/components/Browse/SearchResult";
+import { RecentConcertResult } from "@/components/Browse/RecentConcertResult";
+
+export const BrowsePage = () => {
+ const [query, setQuery] = useState("");
+ const [activeTab, setActiveTab] = useState(null);
+ const [skipDebounce, setSkipDebounce] = useState(false);
+ const debouncedQuery = useDebouncedState(query, 500, skipDebounce);
+ const [filterValue, setFilterValue] = useState(null);
+ const [isFilterOn, setIsFilterOn] = useState(false);
+ const [isSearch, setIsSearch] = useState(false);
+ const [isNoSearchResult, setIsNoSearchResult] = useState(false);
+ const [isNoFilterResult, setIsNoFilterResult] = useState(false);
+ const [showSearchResult, setShowSearchResult] = useState(false);
+ const [autoCompleteList, setAutoCompleteList] = useState<
+ AutoCompleteSearchCard[]
+ >([]);
+
+ const {
+ data: concertData,
+ fetchNextPage,
+ isFetchingNextPage,
+ isLoading,
+ } = useGetConcertList({
+ genre: activeTab,
+ size: 9,
+ // enabled: !isFilterOn,
+ });
+
+ const { data: autoCompleteData, isLoading: autoCompleteDataLoading } =
+ useGetAutoCompleteSearch(debouncedQuery);
+
+ const {
+ data: searchData,
+ fetchNextPage: searchFetchNextPage,
+ isFetchingNextPage: isFetchingSearchNext,
+ isLoading: searchLoading,
+ } = useGetSearch({
+ query: debouncedQuery,
+ size: 9,
+ });
+
+ const searchInputRef = useRef(null);
+ const {
+ filterState,
+ showFilter,
+ hasActiveFilters,
+ handleFilterClick,
+ handleRefreshClick,
+ applyFilter,
+ closeFilter,
+ } = useShowFilter();
+
+ useEffect(() => {
+ const savedFilter = localStorage.getItem("filterObj");
+ if (savedFilter) {
+ const parsedFilter = JSON.parse(savedFilter);
+ setFilterValue(parsedFilter);
+ setIsFilterOn(true);
+ }
+ }, [showFilter]);
+
+ const {
+ data: filterConcertData,
+ fetchNextPage: filterFetchNextPage,
+ isFetchingNextPage: isFilterFetchingNextPage,
+ isLoading: isFilterSearchLoading,
+ } = useGetConcertFilters({
+ minPrice: filterValue?.minPrice,
+ maxPrice: filterValue?.maxPrice,
+ area: filterValue?.selectedLocation,
+ startDate: filterValue?.startDate,
+ endDate: filterValue?.endDate,
+ categories: filterValue?.categories,
+ size: 9,
+ enabled: isFilterOn,
+ });
+
+ const navigate = useNavigate();
+ const gotoShowDetail = (id: number) => {
+ navigate(`/show/${id}`);
+ };
+
+ const handleRefreshButton = () => {
+ setIsFilterOn(false);
+ setFilterValue(null);
+ setIsNoFilterResult(false);
+ localStorage.removeItem("filterObj");
+ handleRefreshClick();
+ };
+
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+
+ const { elementRef: recentRef } = useRefFocusEffect(
+ fetchNextPage,
+ [concertData, isSearch, isFilterOn]
+ );
+ const { elementRef: filterRef } = useRefFocusEffect(
+ filterFetchNextPage,
+ [filterConcertData, isFilterOn]
+ );
+ const { elementRef: searchRef } = useRefFocusEffect(
+ searchFetchNextPage,
+ [searchData, isSearch]
+ );
+
+ const handleTabClick = (tab: TabMenu) => {
+ setActiveTab(tab);
+ setIsFilterOn(false);
+ setFilterValue(null);
+ localStorage.removeItem("filterObj");
+ handleRefreshClick();
+ };
+
+ const handleSearchChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ setSkipDebounce(newValue === "");
+ setQuery(newValue);
+ if (newValue.trim().length === 0) setAutoCompleteList([]);
+ };
+
+ const isProcessing = useRef(false);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && !isProcessing.current) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ isProcessing.current = true;
+
+ setIsSearch(false);
+ setShowSearchResult(true);
+ setSkipDebounce(true);
+ setActiveTab(null);
+
+ setTimeout(() => {
+ isProcessing.current = false;
+ searchInputRef.current?.blur();
+ }, 300);
+ }
+ };
+
+ // useEffect(() => {
+ // return () => {
+ // localStorage.removeItem("filterObj");
+ // };
+ // }, []);
+
+ useEffect(() => {
+ if (autoCompleteData && !autoCompleteDataLoading) {
+ setAutoCompleteList(autoCompleteData.result);
+ }
+ }, [
+ debouncedQuery,
+ autoCompleteDataLoading,
+ setAutoCompleteList,
+ autoCompleteData,
+ ]);
+
+ useEffect(() => {
+ if (searchData && !searchLoading) {
+ if (
+ searchData.pages[0].result.listPageResponse[0]
+ .recommendationConcertsResponseV1s
+ ) {
+ setIsNoSearchResult(true);
+ }
+ }
+
+ if (filterConcertData && !isFilterSearchLoading) {
+ if (filterConcertData.pages[0].result.listPageResponse.length === 0) {
+ setIsNoFilterResult(true);
+ }
+ }
+ }, [searchData, searchLoading, filterConcertData, isFilterSearchLoading]);
+
+ useEffect(() => {
+ if (skipDebounce) {
+ const timer = setTimeout(() => {
+ setSkipDebounce(false);
+ }, 100);
+
+ return () => clearTimeout(timer);
+ }
+ }, [skipDebounce]);
+
+ if (shouldShowSkeleton) {
+ return (
+
+
+
+ 둘러보기
+
+
+
setIsSearch(true)}
+ onChange={handleSearchChange}
+ onKeyDown={handleKeyDown}
+ placeholder={"공연명, 출연자, 극단 등을 검색하세요."}
+ />
+
+
+
+
+
+
+ {Array.from(Array(5).keys()).map((_, index) => (
+
+ ))}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {isSearch ? (
+ {
+ setIsSearch(false);
+ setShowSearchResult(false);
+ setIsNoSearchResult(false);
+ }}
+ />
+ ) : null}
+
+ 둘러보기
+
+
+
setIsSearch(true)}
+ onChange={handleSearchChange}
+ onKeyDown={handleKeyDown}
+ placeholder={"공연명, 출연자, 극단 등을 검색하세요."}
+ />
+
+ {/* 검색어 자동 완성 영역 */}
+ {isSearch ? (
+
+ {debouncedQuery.trim().length !== 0 ? (
+ <>
+ {autoCompleteList.length !== 0 ? (
+ <>
+ {" "}
+ {autoCompleteList?.map((show) => (
+ gotoShowDetail(show.id)}
+ />
+ ))}
+ >
+ ) : null}
+ >
+ ) : null}
+
+ ) : (
+ <>
+ {debouncedQuery.trim().length === 0 ? (
+
+ ) : null}
+
+ {showFilter && (
+
+ )}
+
+
+ {hasActiveFilters ? (
+
+ {filterState.priceRange && (
+
+ {filterState.priceRange}
+
+ )}
+ {filterState.selectedLocation && (
+
+ {filterState.selectedLocation}
+
+ )}
+ {filterState.dateRange && (
+
+ {filterState.dateRange}
+
+ )}
+ {filterState.selectedFeature && (
+
+ {filterState.selectedFeature}
+
+ )}
+
+ ) : (
+
+ {debouncedQuery.trim().length === 0 ? "최근 공연" : null}
+
+ )}
+ {debouncedQuery.trim().length === 0 ? (
+
+
+
+
+ ) : null}
+
+
+ {hasActiveFilters && (
+
+ {debouncedQuery.trim().length === 0 ? "최근 공연" : null}
+
+ )}
+
+ {debouncedQuery.trim().length === 0 ? (
+ <>
+ {isFilterOn && filterConcertData ? (
+
+ ) : (
+ concertData && (
+
+ )
+ )}
+ >
+ ) : (
+ <>
+ {searchData && (
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ {isNoSearchResult || isNoFilterResult ? null : (
+ <>
+ {!isSearch ? (
+
+ ) : null}
+ >
+ )}
+
+
+ );
+};
diff --git a/src/pages/Login/Kakao/AfterOnBoarding/page.tsx b/src/pages/Login/Kakao/AfterOnBoarding/page.tsx
new file mode 100644
index 0000000..748074d
--- /dev/null
+++ b/src/pages/Login/Kakao/AfterOnBoarding/page.tsx
@@ -0,0 +1,21 @@
+import { useUserStore } from "@/libraries/store/user";
+import { useEffect } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+
+export const AfterOnBoardingPage = () => {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const accessToken = searchParams.get("token");
+ const nickname = searchParams.get("nickname")!;
+ const setNickname = useUserStore((state) => state.setNickname);
+
+ useEffect(() => {
+ if (accessToken) {
+ localStorage.setItem("accessToken", accessToken);
+ setNickname(nickname);
+ navigate("/main");
+ }
+ }, [accessToken, nickname, setNickname, navigate]);
+
+ return <>온보딩 진행 완료된 사용자>;
+};
diff --git a/src/pages/Login/Kakao/BeforeOnBoarding/page.tsx b/src/pages/Login/Kakao/BeforeOnBoarding/page.tsx
new file mode 100644
index 0000000..0d6cfc2
--- /dev/null
+++ b/src/pages/Login/Kakao/BeforeOnBoarding/page.tsx
@@ -0,0 +1,19 @@
+import { useEffect } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+
+export const BeforeOnBoardingPage = () => {
+ const navigate = useNavigate();
+ //온보딩 로그인 시 사용할 토큰 url에서 받아오기
+ const [searchParams] = useSearchParams();
+ const accessToken = searchParams.get("token");
+
+ //온보딩 과정에서 사용할 액세스 토큰 로컬 스토리지에 저장
+ useEffect(() => {
+ if (accessToken) {
+ localStorage.setItem("accessToken", accessToken);
+ navigate("/tos");
+ }
+ }, [accessToken, navigate]);
+
+ return <>온보딩 진행 중...>;
+};
diff --git a/src/pages/Login/page.tsx b/src/pages/Login/page.tsx
index f4461fa..f6b72b0 100644
--- a/src/pages/Login/page.tsx
+++ b/src/pages/Login/page.tsx
@@ -1,33 +1,45 @@
+import backgroundImage from "../../assets/images/loginbackground.png";
+import { ReactComponent as ClacoMain } from "@/assets/svgs/Claco_Main.svg";
+import KakaoLogo from "@/assets/images/kakao.png";
+
export const LoginPage = () => {
+ // 카카오 인증 서버로 리다이렉트
+ const handleLogin = () => {
+ window.location.replace(`${import.meta.env.VITE_LOGIN_SERVER_URL}`);
+ };
+
return (
-
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
-
글자 크기
+
+
+
+
+
+
+ 에
+
+
+
+ 오신 것을 환영해요!
+
+
+
+
+
+
+ 카카오로 시작하기
+
+
+
);
};
diff --git a/src/pages/Main/page.tsx b/src/pages/Main/page.tsx
new file mode 100644
index 0000000..70b8b80
--- /dev/null
+++ b/src/pages/Main/page.tsx
@@ -0,0 +1,11 @@
+import { ClassicalPalette } from "@/components/Main/ClassicalPalette";
+import { Analysis } from "@/components/Main/Analysis";
+
+export const MainPage = () => {
+ return (
+
+ );
+};
diff --git a/src/pages/Mypage/PreferenceEdit/page.tsx b/src/pages/Mypage/PreferenceEdit/page.tsx
new file mode 100644
index 0000000..d87579d
--- /dev/null
+++ b/src/pages/Mypage/PreferenceEdit/page.tsx
@@ -0,0 +1,167 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { Age } from "@/components/common/Age";
+import { ConfirmButton } from "@/components/common/Button";
+import { Concept } from "@/components/common/Concept";
+import { Gender } from "@/components/common/Gender";
+import { Location } from "@/components/common/Location";
+import { Price } from "@/components/common/Price";
+import usePutUserPreference from "@/hooks/mutation/usePutUserPreference";
+import { useGetUserPreferences } from "@/hooks/queries";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+export const PreferenceEditPage = () => {
+ const [selectedGender, setSelectedGender] = useState
("");
+ const [selectedAge, setSelectedAge] = useState(null);
+ const [minPrice, setMinPrice] = useState(0);
+ const [maxPrice, setMaxPrice] = useState(1000000);
+ const [selectedLocation, setSelectedLocation] = useState([]);
+ const [selectedConcepts, setSelectedConcepts] = useState([]);
+ const { data, isLoading } = useGetUserPreferences();
+ const preferenceData = data?.result;
+ const { mutate: uploadPreference } = usePutUserPreference();
+
+ useEffect(() => {
+ if (!isLoading && preferenceData) {
+ setSelectedGender(preferenceData.gender);
+ setSelectedAge(preferenceData.age);
+ setMinPrice(preferenceData.minPrice);
+ setMaxPrice(preferenceData.maxPrice);
+ setSelectedLocation(
+ preferenceData.preferRegions.map((region) => region.preferenceRegion)
+ );
+ setSelectedConcepts(
+ preferenceData.preferTypes.map((type) => type.preferenceType)
+ );
+ }
+ }, [isLoading, data]);
+
+ const navigate = useNavigate();
+
+ const handleGenderClick = (gender: string) => {
+ setSelectedGender(gender);
+ };
+
+ const handleAgeClick = (age: number) => {
+ setSelectedAge(age);
+ };
+
+ const handleLocationClick = (location: string) => {
+ setSelectedLocation((prev) => {
+ if (prev.includes(location)) {
+ return prev.filter((loc) => loc !== location);
+ }
+ return [...prev, location];
+ });
+ };
+
+ const handleConceptClick = (concept: string) => {
+ if (selectedConcepts.includes(concept)) {
+ setSelectedConcepts(selectedConcepts.filter((item) => item !== concept));
+ } else if (selectedConcepts.length < 5) {
+ setSelectedConcepts([...selectedConcepts, concept]);
+ }
+ };
+
+ const handleConfirmClick = () => {
+ const requestPayload = {
+ gender: selectedGender || "",
+ age: selectedAge || 10,
+ minPrice: minPrice,
+ maxPrice: maxPrice,
+ regionPreferences: selectedLocation.map((location) => ({
+ preferenceRegion: location,
+ })),
+ typePreferences: selectedConcepts.map((concept) => ({
+ preferenceType: concept,
+ })),
+ };
+
+ try {
+ uploadPreference(requestPayload);
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const gotoBack = () => {
+ navigate("/mypage");
+ };
+
+ return (
+
+
+
+
+ 나의 취향 정보 수정
+
+
+
+
+
+
+ 공연장 위치
+
+
+
+
+
+ 공연 유형
+
+
+
+
{
+ handleConfirmClick();
+ }}
+ disabled={
+ !(
+ selectedGender &&
+ selectedAge &&
+ selectedLocation &&
+ selectedConcepts
+ )
+ }
+ className="w-full"
+ >
+ 적용하기
+
+
+ );
+};
diff --git a/src/pages/Mypage/UserEdit/page.tsx b/src/pages/Mypage/UserEdit/page.tsx
new file mode 100644
index 0000000..d824349
--- /dev/null
+++ b/src/pages/Mypage/UserEdit/page.tsx
@@ -0,0 +1,136 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ConfirmButton } from "@/components/common/Button";
+import { Nickname } from "@/components/common/Nickname";
+import usePutUserInfo from "@/hooks/mutation/usePutUserInfo";
+import useGetUserInfo from "@/hooks/queries/useGetUserInfo";
+import { useUserStore } from "@/libraries/store/user";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+export const UserEditPage = () => {
+ const setNickname = useUserStore((state) => state.setNickname);
+ const [nickname, setLocalNickname] = useState("");
+ const [isChecked, setIsChecked] = useState(false);
+ const [initialProfileImage, setInitialProfileImage] = useState("");
+ const [profileImage, setProfileImage] = useState(null);
+ const { data: userInfo, isLoading: isUserInfoLoading } = useGetUserInfo();
+ const userInfoData = userInfo?.result;
+ const { mutate: uploadUserInfo } = usePutUserInfo();
+
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (userInfoData) {
+ setLocalNickname(userInfoData.nickname);
+ setInitialProfileImage(userInfoData.imageUrl);
+ }
+ }, [userInfoData, isUserInfoLoading]);
+
+ const handleConfirmClick = () => {
+ try {
+ uploadUserInfo(
+ {
+ updateNickname: nickname,
+ updateImage: profileImage,
+ },
+ {
+ onSuccess: (data) => {
+ setInitialProfileImage(
+ `${data.result.imageUrl}?timestamp=${Date.now()}`,
+ );
+ setNickname(nickname);
+ },
+ },
+ );
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ const handleImageUpload = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file && file.size <= 10 * 1024 * 1024) {
+ setProfileImage(file);
+ }
+ };
+
+ const gotoBack = () => {
+ navigate("/mypage");
+ };
+
+ if (isUserInfoLoading) {
+ return 로딩중
;
+ }
+
+ return (
+
+
+
+
+ 프로필 설정
+
+
+
내 정보
+
+
+ {profileImage ? (
+
+ ) : (
+
+ )}
+
+
+
+ {userInfoData?.nickname}
+
+
+
+ 프로필 이미지 업로드
+
+
+
+
+ *10MB 이내의 이미지 파일을 업로드 해주세요.
+
+
+
+
+
+ 닉네임 설정
+
+
+
+
+ 적용하기
+
+
+ );
+};
diff --git a/src/pages/Mypage/page.tsx b/src/pages/Mypage/page.tsx
new file mode 100644
index 0000000..54cb657
--- /dev/null
+++ b/src/pages/Mypage/page.tsx
@@ -0,0 +1,132 @@
+import { ReactComponent as Edit } from "@/assets/svgs/Edit.svg";
+import { ReactComponent as Settings } from "@/assets/svgs/settings.svg";
+import { LikedShow } from "@/components/Mypage/LikedShow";
+import { PreferenceAnalysis } from "@/components/Mypage/PreferenceAnalysis";
+import { Skeleton } from "@/components/ui/skeleton";
+import useGetUserInfo from "@/hooks/queries/useGetUserInfo";
+import { useDeferredLoading } from "@/hooks/utils";
+import { useEffect, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+export const MyPage = () => {
+ const [selectedTab, setSelectedTab] = useState("나의 취향 분석");
+ const navigate = useNavigate();
+ const { data, isLoading } = useGetUserInfo();
+ const userInfoData = data?.result;
+ const [updatedImageUrl, setUpdatedImageUrl] = useState(null);
+ const tabs = ["나의 취향 분석", "좋아요한 공연"];
+
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+
+ useEffect(() => {
+ if (!isLoading && userInfoData) {
+ console.log(userInfoData);
+ }
+ }, [userInfoData, isLoading]);
+
+ useEffect(() => {
+ if (userInfoData?.imageUrl) {
+ const newImageUrl = `${userInfoData.imageUrl}?timestamp=${Date.now()}`;
+ if (newImageUrl !== updatedImageUrl) {
+ setUpdatedImageUrl(newImageUrl);
+ }
+ }
+ }, [userInfoData?.imageUrl, updatedImageUrl]);
+
+ const handleTabClick = (tab: string) => {
+ setSelectedTab(tab);
+ };
+
+ const gotoUserSettings = () => {
+ window.scrollTo({ top: 0 });
+ navigate("/mypage/user");
+ };
+
+ if (isLoading || shouldShowSkeleton) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+
+ {Array.from({ length: 5 }).map((_, index) => (
+
+ ))}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ 마이페이지
+
+
+
+
+
+
+
+
+
+
+
+ {userInfoData?.nickname}
+
+
+
+
+
+ {tabs.map((tab) => (
+
handleTabClick(tab)}
+ className={`text-center cursor-pointer pb-2 ${
+ selectedTab === tab
+ ? "headline2-bold text-white px-[17px] border-b-2 border-grayscale-80 z-10"
+ : "headline2-bold text-grayscale-60 px-[17px] border-b-2 border-grayscale-30"
+ }`}
+ >
+ {tab}
+
+ ))}
+
+
+
+ {selectedTab === "나의 취향 분석" ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/src/pages/Onboarding/CompleteRegistration/page.tsx b/src/pages/Onboarding/CompleteRegistration/page.tsx
new file mode 100644
index 0000000..197b2a2
--- /dev/null
+++ b/src/pages/Onboarding/CompleteRegistration/page.tsx
@@ -0,0 +1,58 @@
+import { ReactComponent as CompletePreference } from "@/assets/svgs/CompletePreference.svg";
+import { useEffect, useState } from "react";
+import { ConfirmButton } from "@/components/common/Button";
+import LottieData from "@/assets/lotties/onboarding-loading.json";
+import Lottie from "react-lottie-player";
+import { useUserStore } from "@/libraries/store/user";
+
+export const CompleteRegistrationPage = () => {
+ const [showLoading, setShowLoading] = useState(true);
+ const nickname = useUserStore((state) => state.nickname);
+
+ const handleLogin = () => {
+ localStorage.removeItem("user-onboarding-storage");
+ window.location.replace(`${import.meta.env.VITE_LOGIN_SERVER_URL}`);
+ };
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setShowLoading(false);
+ }, 5000);
+
+ return () => clearTimeout(timer);
+ }, []);
+
+ return (
+
+ {showLoading ? (
+
+
+ 클라코가 {nickname}님의
+
+ 클래식 공연 취향을 분석중이에요
+
+
+
+ ) : (
+
+
+ 클라코와 함께 클래식 경험을 넓혀줄
+
+ 여정을 시작해보세요!
+
+
+
+
+
+ 맞춤 공연 보러가기
+
+
+ )}
+
+ );
+};
diff --git a/src/pages/Onboarding/UserRegistration/Concept/page.tsx b/src/pages/Onboarding/UserRegistration/Concept/page.tsx
new file mode 100644
index 0000000..e212cab
--- /dev/null
+++ b/src/pages/Onboarding/UserRegistration/Concept/page.tsx
@@ -0,0 +1,70 @@
+import { Progress } from "@/components/ui/progress";
+import { useNavigate } from "react-router-dom";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ConfirmButton } from "@/components/common/Button";
+import { Concept } from "@/components/common/Concept";
+import { useState } from "react";
+import { useUserStore } from "@/libraries/store/user";
+import { useOnboardingStore } from "@/libraries/store/onboarding";
+
+export const SelectConceptPage = () => {
+ const navigate = useNavigate();
+ const nickname = useUserStore((state) => state.nickname);
+ const setConcept = useOnboardingStore((state) => state.setTypePreferences);
+ const [selectedConcept, setSelectedConcept] = useState([]);
+
+ const handleBackClick = () => {
+ navigate("/create/price");
+ };
+
+ const handleConfirmClick = () => {
+ if (selectedConcept) setConcept(selectedConcept);
+ navigate("/create/feature");
+ };
+
+ const handleConceptClick = (concept: string) => {
+ setSelectedConcept((prevSelectedConcept) =>
+ prevSelectedConcept.includes(concept)
+ ? prevSelectedConcept.filter((con) => con !== concept)
+ : [...prevSelectedConcept, concept],
+ );
+ };
+
+ return (
+
+
+
+
+
+ {nickname}님 취향에 맞는
+
+ 클래식 공연을 추천드릴게요
+
+
+
+
+
+ 선호하는 클래식 공연 유형을 모두 선택해주세요.
+
+
+
+
+
+
0}
+ onClick={handleConfirmClick}
+ disabled={selectedConcept.length === 0}
+ >
+ 다음
+
+
+
+
+ );
+};
diff --git a/src/pages/Onboarding/UserRegistration/Feature/const/index.ts b/src/pages/Onboarding/UserRegistration/Feature/const/index.ts
new file mode 100644
index 0000000..4c1ca65
--- /dev/null
+++ b/src/pages/Onboarding/UserRegistration/Feature/const/index.ts
@@ -0,0 +1,79 @@
+import { FeatureType } from "@/types";
+import grand from "@/assets/images/Genre/grand.png";
+import delicate from "@/assets/images/Genre/delicate.png";
+import classical from "@/assets/images/Genre/classical.png";
+import modern from "@/assets/images/Genre/modern.png";
+import lyrical from "@/assets/images/Genre/lyrical.png";
+import dynamic from "@/assets/images/Genre/dynamic.png";
+import romantic from "@/assets/images/Genre/romantic.png";
+import tragic from "@/assets/images/Genre/tragic.png";
+import familiar from "@/assets/images/Genre/familiar.png";
+import novel from "@/assets/images/Genre/novel.png";
+
+export const features: FeatureType[] = [
+ {
+ title: "웅장한",
+ description: [
+ "큰 규모의 오케스트라나 무대 장치,",
+ "강렬한 감정이 느껴지는 공연",
+ ],
+ image: grand,
+ },
+ {
+ title: "섬세한",
+ description: [
+ "작은 규모의 연주나 무대",
+ "미세한 감정의 변화와 정교함이",
+ "돋보이는 공연",
+ ],
+ image: delicate,
+ },
+ {
+ title: "고전적인",
+ description: [
+ "고전적인 형식과 규칙을 따르는",
+ "클래식 공연",
+ "예) 고전 교향곡",
+ ],
+ image: classical,
+ },
+ {
+ title: "현대적인",
+ description: ["혁신적이고 새로운 형식의 공연", "예) 현대 무용"],
+ image: modern,
+ },
+ {
+ title: "역동적인",
+ description: ["강한 움직임과 템포가 특징인", "빠르고 에너지 넘치는 공연"],
+ image: dynamic,
+ },
+ {
+ title: "서정적인",
+ description: ["감정적으로 부드럽고 서정적인", "음악과 무대"],
+ image: lyrical,
+ },
+ {
+ title: "낭만적인",
+ description: ["따뜻하고 감미로운 분위기로", "사랑과 감성을 주제로 한 공연"],
+ image: romantic,
+ },
+ {
+ title: "비극적인",
+ description: ["슬프고 어두운 감정을 전달하는 공연"],
+ image: tragic,
+ },
+ {
+ title: "친숙한",
+ description: [
+ "대중들에게 친숙한 곡이나",
+ "춤을 기반으로 한 공연",
+ "예) 지브리 OST 공연, 비발디 ‘사계’ ",
+ ],
+ image: familiar,
+ },
+ {
+ title: "새로운",
+ description: ["평소 자주 들어보지 못했던", "새로운 곡을 기반으로 한 공연"],
+ image: novel,
+ },
+];
diff --git a/src/pages/Onboarding/UserRegistration/Feature/page.tsx b/src/pages/Onboarding/UserRegistration/Feature/page.tsx
new file mode 100644
index 0000000..22fe1e1
--- /dev/null
+++ b/src/pages/Onboarding/UserRegistration/Feature/page.tsx
@@ -0,0 +1,175 @@
+import { FeatureButton } from "@/components/Onboarding/Registration";
+import { Progress } from "@/components/ui/progress";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ConfirmButton } from "@/components/common/Button";
+import { features } from "./const";
+import { useUserStore } from "@/libraries/store/user";
+import { useOnboardingStore } from "@/libraries/store/onboarding";
+import { submitUserInformation } from "@/apis";
+
+export const SelectFeaturePage = () => {
+ const [currentStep, setCurrentStep] = useState(0);
+ const [progressValue, setProgressValue] = useState(55.55);
+ const nickname = useUserStore((state) => state.nickname);
+ const addFeature = useOnboardingStore((state) => state.addCategoryPreference);
+ const removeLastCategoryPreference = useOnboardingStore(
+ (state) => state.removeLastCategoryPreference
+ );
+ const removeSpecificCategoryPreference = useOnboardingStore(
+ (state) => state.removeSpecificCategoryPreference
+ );
+ const [selectedFeature, setSelectedFeature] = useState(null);
+ const [isWaiting, setIsWaiting] = useState(false);
+
+ const {
+ gender,
+ age,
+ minPrice,
+ maxPrice,
+ regionPreferences,
+ typePreferences,
+ categoryPreferences,
+ } = useOnboardingStore();
+
+ const navigate = useNavigate();
+
+ const handleBackClick = () => {
+ if (currentStep === 0) {
+ navigate("/create/concept");
+ } else {
+ removeLastCategoryPreference();
+ setCurrentStep(currentStep - 2);
+ }
+ setProgressValue((prevValue) => Math.min(prevValue - 11.11, 100));
+ };
+
+ const handleFeatureClick = (feature: string) => {
+ setSelectedFeature(feature);
+
+ if (progressValue !== 99.99) {
+ setIsWaiting(true);
+ setTimeout(() => {
+ if (currentStep < features.length - 2) {
+ setCurrentStep((prevStep) => prevStep + 2);
+ addFeature(feature);
+ setSelectedFeature(null);
+ setProgressValue((prevValue) => Math.min(prevValue + 11.11, 100));
+ }
+ setIsWaiting(false);
+ }, 500);
+ } else {
+ if (
+ !categoryPreferences.includes("친숙한") &&
+ !categoryPreferences.includes("새로운")
+ ) {
+ addFeature(feature);
+ } else {
+ if (feature === "친숙한" || feature === "새로운") {
+ if (categoryPreferences.includes("친숙한") && feature !== "친숙한") {
+ removeSpecificCategoryPreference("친숙한");
+ }
+ if (categoryPreferences.includes("새로운") && feature !== "새로운") {
+ removeSpecificCategoryPreference("새로운");
+ }
+ }
+
+ if (!categoryPreferences.includes(feature)) {
+ addFeature(feature);
+ }
+ }
+ }
+ };
+
+ const handleNextClick = async () => {
+ const onboardingRequest = {
+ nickname: nickname,
+ gender,
+ age,
+ minPrice,
+ maxPrice,
+ regionPreferences: regionPreferences.map((region) => ({
+ preferenceRegion: region,
+ })),
+ typePreferences: typePreferences.map((type) => ({
+ preferenceType: type,
+ })),
+ categoryPreferences: categoryPreferences.map((category) => ({
+ preferenceCategory: category,
+ })),
+ };
+
+ try {
+ const response = await submitUserInformation(onboardingRequest);
+ console.log(response);
+ navigate("/create/complete");
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {nickname}님 취향에 맞는
+
+ 클래식 공연을 추천드릴게요
+
+
+
+
+
+
+ 선호하는 클래식 공연의 특징을 선택해주세요.
+
+
+ {features.slice(currentStep, currentStep + 2).map((feature) => (
+
handleFeatureClick(feature.title)}
+ className="flex items-center p-[19px]"
+ >
+
+
+ {feature.title}
+
+ {feature.description.map((line, index) => (
+
+ {line}
+
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+ 다음
+
+
+
+
+ );
+};
diff --git a/src/pages/Onboarding/UserRegistration/Location/page.tsx b/src/pages/Onboarding/UserRegistration/Location/page.tsx
new file mode 100644
index 0000000..3fed461
--- /dev/null
+++ b/src/pages/Onboarding/UserRegistration/Location/page.tsx
@@ -0,0 +1,70 @@
+import { Progress } from "@/components/ui/progress";
+import { useNavigate } from "react-router-dom";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ConfirmButton } from "@/components/common/Button";
+import { Location } from "@/components/common/Location";
+import { useState } from "react";
+import { useOnboardingStore } from "@/libraries/store/onboarding";
+import { useUserStore } from "@/libraries/store/user";
+
+export const SelectLocationPage = () => {
+ const navigate = useNavigate();
+ const nickname = useUserStore((state) => state.nickname);
+ const setLocation = useOnboardingStore((state) => state.setRegionPreferences);
+ const [selectedLocation, setSelectedLocation] = useState([]);
+
+ const handleBackClick = () => {
+ navigate("/create/profile");
+ };
+
+ const handleConfirmClick = () => {
+ setLocation(selectedLocation);
+ navigate("/create/price");
+ };
+
+ const handleLocationClick = (location: string) => {
+ if (selectedLocation.includes(location)) {
+ setSelectedLocation(selectedLocation.filter((loc) => loc !== location));
+ } else {
+ setSelectedLocation([...selectedLocation, location]);
+ }
+ };
+
+ return (
+
+
+
+
+
+ 맞춤 공연 추천 전,
+
+ {nickname}님에 대해 알려주세요
+
+
+
+
+
+ 선호하는 공연장 위치를 모두 알려주세요.
+
+
+
+
+
+
0}
+ onClick={handleConfirmClick}
+ disabled={selectedLocation.length === 0}
+ >
+ 다음
+
+
+
+
+ );
+};
diff --git a/src/pages/Onboarding/UserRegistration/Nickname/page.tsx b/src/pages/Onboarding/UserRegistration/Nickname/page.tsx
new file mode 100644
index 0000000..b845761
--- /dev/null
+++ b/src/pages/Onboarding/UserRegistration/Nickname/page.tsx
@@ -0,0 +1,49 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { ConfirmButton } from "@/components/common/Button";
+import { Nickname } from "@/components/common/Nickname";
+import { useUserStore } from "@/libraries/store/user";
+
+export const NicknameCreatePage = () => {
+ const setNickname = useUserStore((state) => state.setNickname);
+ const [localNickname, setLocalNickname] = useState("");
+ const [isChecked, setIsChecked] = useState(false);
+ const navigate = useNavigate();
+
+ const handleConfirmClick = () => {
+ if (isChecked) {
+ setNickname(localNickname);
+ navigate("/create/profile");
+ }
+ };
+ const handleBackClick = () => {
+ navigate("/tos");
+ };
+ return (
+
+
+
+
+
+ 사용하실 닉네임을 알려주세요
+
+
+
+
+
+
+ 확인
+
+
+
+ );
+};
diff --git a/src/pages/Onboarding/UserRegistration/Price/page.tsx b/src/pages/Onboarding/UserRegistration/Price/page.tsx
new file mode 100644
index 0000000..05b4d6c
--- /dev/null
+++ b/src/pages/Onboarding/UserRegistration/Price/page.tsx
@@ -0,0 +1,63 @@
+import { Progress } from "@/components/ui/progress";
+import { useNavigate } from "react-router-dom";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ConfirmButton } from "@/components/common/Button";
+import { Price } from "@/components/common/Price";
+import { useState } from "react";
+import { useUserStore } from "@/libraries/store/user";
+import { useOnboardingStore } from "@/libraries/store/onboarding";
+
+export const SelectPricePage = () => {
+ const navigate = useNavigate();
+ const nickname = useUserStore((state) => state.nickname);
+ const setMinPrice = useOnboardingStore((state) => state.setMinPrice);
+ const setMaxPrice = useOnboardingStore((state) => state.setMaxPrice);
+ const [selectedMinPrice, setSelectedMinPrice] = useState(0);
+ const [selectedMaxPrice, setSelectedMaxPrice] = useState(1000000);
+
+ const handleBackClick = () => {
+ navigate("/create/location");
+ };
+
+ const handleConfirmClick = () => {
+ setMinPrice(selectedMinPrice);
+ setMaxPrice(selectedMaxPrice);
+ navigate("/create/concept");
+ };
+
+ return (
+
+
+
+
+
+ 맞춤 공연 추천 전,
+
+ {nickname}님에 대해 알려주세요
+
+
+
+
+
+ 선호하는 티켓 가격 범위를 알려주세요.
+
+
+
+
+ 다음
+
+
+
+
+ );
+};
diff --git a/src/pages/Onboarding/UserRegistration/Profile/page.tsx b/src/pages/Onboarding/UserRegistration/Profile/page.tsx
new file mode 100644
index 0000000..52fa8c2
--- /dev/null
+++ b/src/pages/Onboarding/UserRegistration/Profile/page.tsx
@@ -0,0 +1,74 @@
+import { Progress } from "@/components/ui/progress";
+import { useNavigate } from "react-router-dom";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ConfirmButton } from "@/components/common/Button";
+import { useState } from "react";
+import { Gender } from "@/components/common/Gender";
+import { Age } from "@/components/common/Age";
+import { useOnboardingStore } from "@/libraries/store/onboarding";
+import { useUserStore } from "@/libraries/store/user";
+
+export const SelectProfilePage = () => {
+ const navigate = useNavigate();
+ const nickname = useUserStore((state) => state.nickname);
+ const setGender = useOnboardingStore((state) => state.setGender);
+ const setAge = useOnboardingStore((state) => state.setAge);
+ const [selectedGender, setSelectedGender] = useState(null);
+ const [selectedAge, setSelectedAge] = useState(null);
+
+ const handleBackClick = () => {
+ navigate("/create");
+ };
+
+ const handleConfirmClick = () => {
+ if (selectedGender) setGender(selectedGender);
+ if (selectedAge) setAge(selectedAge);
+ navigate("/create/location");
+ };
+
+ return (
+
+
+
+
+
+ 맞춤 공연 추천 전,
+
+ {nickname}님에 대해 알려주세요
+
+
+
+
+
+ 성별과 연령대를 알려주세요.
+
+
+
+
+
+ 다음
+
+
+
+
+ );
+};
diff --git a/src/pages/Review/[id]/page.tsx b/src/pages/Review/[id]/page.tsx
new file mode 100644
index 0000000..1bf8cbd
--- /dev/null
+++ b/src/pages/Review/[id]/page.tsx
@@ -0,0 +1,114 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ReactComponent as Star } from "@/assets/svgs/StarRating.svg";
+import { ReviewTag } from "@/components/common/ReviewTag";
+import { useNavigate, useParams } from "react-router-dom";
+import { useThumbnailModal } from "@/hooks/utils";
+import { ThumbnailModal } from "@/components/common/Modal/ThumbnailModal";
+import { useGetConcertReviewDetail } from "@/hooks/queries";
+
+export const ReviewDetailPage = () => {
+ const { reviewId } = useParams<{ reviewId: string }>();
+ const reviewId_ = Number(reviewId);
+ const { data: reviewList } = useGetConcertReviewDetail(reviewId_);
+
+ const {
+ thumbsSwiper,
+ isThumbnailShow,
+ isAnimating,
+ selectIndex,
+ setThumbsSwiper,
+ setSelectIndex,
+ handleImageClick,
+ } = useThumbnailModal();
+
+ const navigate = useNavigate();
+
+ const gotoBack = () => {
+ navigate(-1);
+ };
+
+ return (
+
+
img.imageUrl) ?? []
+ }
+ onClose={handleImageClick}
+ setThumbsSwiper={setThumbsSwiper}
+ />
+
+
+
+
+
+
+
{reviewList?.result.userName}
+
+
+
+ {reviewList?.result.starRate}
+
+
+
+
+
+ {reviewList?.result.createdDate.replace(/-/g, ".")}
+
+
+
+
{reviewList?.result.content}
+
+ {reviewList?.result.reviewImages?.map((image, index) => (
+
{
+ handleImageClick();
+ setSelectIndex({
+ page: 1,
+ index: 0,
+ });
+ }}
+ key={index}
+ src={image.imageUrl}
+ alt="리뷰 이미지"
+ className="min-w-[90px] max-w-[90px] max-h-[90px] object-contain rounded-[5px]"
+ />
+ ))}
+
+
+
공연 특징
+
+ {reviewList?.result.tagReviews.map((tag) => (
+ {tag.tagName}
+ ))}
+
+
+
+
+
공연장
+
+ {reviewList?.result.watchSit}
+
+
+
+ {reviewList?.result.placeReviews.map((lReview) => (
+
+ {lReview.categoryName}
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/pages/Review/page.tsx b/src/pages/Review/page.tsx
new file mode 100644
index 0000000..82e793e
--- /dev/null
+++ b/src/pages/Review/page.tsx
@@ -0,0 +1,256 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { CategoryTag } from "@/components/common/CategoryTag";
+import { ReviewCard } from "@/components/Review/ReviewCard";
+import { useEffect, useRef, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import {
+ useDeferredLoading,
+ useRefFocusEffect,
+ useThumbnailModal,
+} from "@/hooks/utils";
+import { ThumbnailModal } from "@/components/common/Modal/ThumbnailModal";
+import { OrederByType, SelectThumbnail } from "@/types";
+import {
+ useGetConcertReviewList,
+ useGetConcertReviewSize,
+} from "@/hooks/queries";
+import { useConcertInfoStore } from "@/libraries/store/concertInfo";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export type OptionType = {
+ value: OrederByType;
+ label: string;
+};
+
+const options: OptionType[] = [
+ {
+ value: "RECENT",
+ label: "최신 순",
+ },
+ {
+ value: "HIGH_RATE",
+ label: "별점 높은 순",
+ },
+ {
+ value: "LOW_RATE",
+ label: "별점 낮은 순",
+ },
+];
+
+export const ReviewPage = () => {
+ const { id } = useParams();
+ const { data, clearConcertInfo } = useConcertInfoStore();
+
+ // useEffect(() => {
+ // return () => {
+ // clearConcertInfo();
+ // };
+ // }, [clearConcertInfo]);
+
+ const [selectOption, setSelectOption] = useState({
+ value: options[0].value,
+ label: options[0].label,
+ });
+
+ const {
+ data: reviewList,
+ fetchNextPage,
+ isFetchingNextPage,
+ isLoading,
+ } = useGetConcertReviewList({
+ concertId: Number(id),
+ size: 9,
+ orderBy: selectOption.value,
+ });
+
+ const { data: reviewTotalCount } = useGetConcertReviewSize(Number(id));
+
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+ const {
+ thumbsSwiper,
+ isThumbnailShow,
+ isAnimating,
+ selectIndex,
+ setThumbsSwiper,
+ setSelectIndex,
+ handleImageClick,
+ } = useThumbnailModal();
+
+ const navigate = useNavigate();
+
+ const gotoBack = () => {
+ navigate(-1);
+ clearConcertInfo();
+ };
+
+ const handlePreviewImage = (index: SelectThumbnail) => {
+ setSelectIndex(index);
+ handleImageClick();
+ };
+
+ const { elementRef } = useRefFocusEffect(fetchNextPage, [
+ reviewList,
+ ]);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+ if (shouldShowSkeleton) {
+ return (
+
+
+
+
+
+
+ {Array.from(Array(2).keys()).map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {Array.from(Array(5).keys()).map((_, index) => (
+
+ ))}
+
+
+
+
+ );
+ }
+
+ return (
+
+ {reviewList && reviewList?.pages[0].result.reviewList.length > 0 && (
+
img.imageUrl)) ||
+ []
+ }
+ onClose={handleImageClick}
+ setThumbsSwiper={setThumbsSwiper}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
{data.prfnm}
+
+ 리뷰 {reviewTotalCount?.result.total}개
+
+
+
+
+
+ setIsOpen((prev) => !prev)}
+ >
+
{selectOption.label}
+
+
+ {isOpen && (
+
+ {options.map((option, index) => (
+
+ setSelectOption({
+ value: option.value,
+ label: option.label,
+ })
+ }
+ className={`${selectOption.value === option.value ? "text-common-white" : "text-grayscale-60"}`}
+ >
+ {option.label}
+
+ ))}
+
+ )}
+
+
+
+ {reviewList &&
+ reviewList?.pages.flatMap((page) =>
+ page.result.reviewList.map((review, index) => (
+
+ review.reviewImages.length &&
+ handlePreviewImage({
+ page: page.result.currentPage,
+ index: index,
+ })
+ }
+ />
+ ))
+ )}
+ {/* 추가 데이터 로드 */}
+ {isFetchingNextPage && (
+
+ 로딩 중...
+
+ )}
+
+
+
+ {reviewList?.pages[0].result.totalPage !== 0 && (
+
+ )}
+
+ );
+};
diff --git a/src/pages/ShowDetail/page.tsx b/src/pages/ShowDetail/page.tsx
new file mode 100644
index 0000000..d65000a
--- /dev/null
+++ b/src/pages/ShowDetail/page.tsx
@@ -0,0 +1,228 @@
+import AudienceReviews from "@/components/ShowDetail/AudienceReviews";
+import { ReactComponent as ClacoMain } from "@/assets/svgs/Claco_Main.svg";
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import RelatedShowsRecommend from "@/components/ShowDetail/RelatedShowRecommend";
+import ShowEssentials from "@/components/ShowDetail/ShowInformation/ShowEssentials";
+import ShowOverview from "@/components/ShowDetail/ShowInformation/ShowOverview";
+import ShowPoster from "@/components/ShowDetail/ShowInformation/ShowPoster";
+import { Skeleton } from "@/components/ui/skeleton";
+import useGetShowDetail from "@/hooks/queries/useGetShowDetail";
+import {
+ extractDateRange,
+ extractPricesWithSeats,
+ extractSchedule,
+ timeToMinutes,
+ useDeferredLoading,
+} from "@/hooks/utils";
+import { useRef, useState, useEffect } from "react";
+import { useParams } from "react-router-dom";
+
+export const ShowDetailPage = () => {
+ const [selectedTab, setSelectedTab] = useState("공연 정보");
+ const [isSticky, setIsSticky] = useState(true);
+ const [showFullImage, setShowFullImage] = useState(false);
+
+ const tabs = ["공연 정보", "상세정보", "감상 리뷰"];
+
+ const showInfoRef = useRef(null);
+ const detailsInfoRef = useRef(null);
+ const reviewRef = useRef(null);
+ const relatedShowsRef = useRef(null);
+
+ const { id } = useParams<{ id: string }>();
+ const { data, isLoading } = useGetShowDetail(Number(id));
+ const showDetail = data?.result;
+ const { seats, prices, minPrice, maxPrice } = extractPricesWithSeats(
+ showDetail?.pcseguidance || "",
+ );
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+
+ useEffect(() => {
+ const sectionRefs = {
+ "공연 정보": showInfoRef,
+ 상세정보: detailsInfoRef,
+ "감상 리뷰": reviewRef,
+ };
+
+ Object.entries(sectionRefs).forEach(([sectionId, ref]) => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setSelectedTab(sectionId);
+ }
+ });
+ },
+ {
+ threshold: sectionId === "상세정보" && showFullImage ? 0.2 : 0.7,
+ },
+ );
+
+ if (ref.current) {
+ observer.observe(ref.current);
+ }
+
+ return () => {
+ if (ref.current) {
+ observer.unobserve(ref.current);
+ }
+ };
+ });
+
+ const currentRelatedShowsRef = relatedShowsRef.current;
+ const endObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setIsSticky(false);
+ } else {
+ setIsSticky(true);
+ }
+ });
+ },
+ { threshold: 0.8 },
+ );
+
+ if (currentRelatedShowsRef) {
+ endObserver.observe(currentRelatedShowsRef);
+ }
+
+ return () => {
+ if (currentRelatedShowsRef) {
+ endObserver.unobserve(currentRelatedShowsRef);
+ }
+ };
+ }, [showFullImage]);
+
+ const scrollToSection = (ref: React.RefObject) => {
+ const yOffset = -100;
+ if (ref.current) {
+ const elementPosition =
+ ref.current.getBoundingClientRect().top + window.scrollY + yOffset;
+ window.scrollTo({ top: elementPosition, behavior: "smooth" });
+ }
+ };
+
+ const handleTabClick = (tab: string) => {
+ setSelectedTab(tab);
+ if (tab === "공연 정보") {
+ scrollToSection(showInfoRef);
+ } else if (tab === "상세정보") {
+ scrollToSection(detailsInfoRef);
+ } else if (tab === "감상 리뷰") {
+ scrollToSection(reviewRef);
+ }
+ };
+
+ const displayedPrice = (
+ minPrice: number | string | null,
+ maxPrice: number | string | null,
+ ): string => {
+ if (minPrice === "무료" && maxPrice === "무료") {
+ return "무료";
+ }
+ if (minPrice !== null && maxPrice !== null) {
+ return minPrice === maxPrice
+ ? `${minPrice.toLocaleString()}원`
+ : `${minPrice.toLocaleString()}원-${maxPrice.toLocaleString()}원`;
+ }
+ return "";
+ };
+
+ if (isLoading || shouldShowSkeleton) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+ return (
+
+
+
+
+
+
+ {tabs.map((tab) => (
+
handleTabClick(tab)}
+ className={`text-center cursor-pointer pb-2 ${
+ selectedTab === tab
+ ? "body2-semibold text-grayscale-80 border-b-2 border-grayscale-80 z-10"
+ : "body2-medium text-grayscale-60 border-b-2 border-grayscale-30"
+ }`}
+ >
+ {tab}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/pages/Ticket/[id]/edit/page.tsx b/src/pages/Ticket/[id]/edit/page.tsx
new file mode 100644
index 0000000..ba6a51a
--- /dev/null
+++ b/src/pages/Ticket/[id]/edit/page.tsx
@@ -0,0 +1,64 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { TextReview } from "@/components/Ticket/AudienceReview/ReviewContents/TextReview";
+import { StarRating } from "@/components/Ticket/AudienceReview/StarRating";
+import { usePutEditTicketReview } from "@/hooks/mutation";
+import { useReviewInfoStore } from "@/libraries/store/reviewInfo";
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+
+export const ClacoTicketReviewEditPage = () => {
+ const { data } = useReviewInfoStore();
+ const { id } = useParams();
+ const ticketId = Number(id);
+ const [rating, setRating] = useState(0);
+ const [reviewText, setReviewText] = useState("");
+ const navigate = useNavigate();
+ const { mutate: editClacoTicketReview } = usePutEditTicketReview();
+
+ useEffect(() => {
+ // console.log(data);
+ setRating(data.starRate);
+ setReviewText(data.content);
+ }, [setRating, setReviewText, data]);
+
+ const gotoBack = () => {
+ navigate(-1);
+ };
+
+ const handleConfirm = () => {
+ const editData = {
+ ticketReviewId: ticketId,
+ watchSit: null,
+ starRate: rating,
+ content: reviewText,
+ };
+ editClacoTicketReview(editData, {
+ onSuccess: () => {
+ navigate(`/ticket/${ticketId}`);
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+ };
+ return (
+
+ );
+};
diff --git a/src/pages/Ticket/[id]/page.tsx b/src/pages/Ticket/[id]/page.tsx
new file mode 100644
index 0000000..5150d95
--- /dev/null
+++ b/src/pages/Ticket/[id]/page.tsx
@@ -0,0 +1,322 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ReactComponent as Trash } from "@/assets/svgs/trash.svg";
+import { ReactComponent as Edit } from "@/assets/svgs/Edit.svg";
+import { ReactComponent as Star } from "@/assets/svgs/StarRating.svg";
+import { useEffect, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { useDeferredLoading, useThumbnailModal } from "@/hooks/utils";
+import { DeleteClacoTicketModal } from "@/components/Ticket/Modal/Delete/ClacoTicket";
+import { CategoryTag } from "@/components/common/CategoryTag";
+import { PerformanceAttributes } from "@/components/Ticket/PerformanceAttributes";
+import { ReviewTag } from "@/components/common/ReviewTag";
+import { Modal } from "@/components/common/Modal";
+import { ThumbnailModal } from "@/components/common/Modal/ThumbnailModal";
+import { TicketReviewDetailRequest } from "@/types";
+import { useGetTicketReviewDetail } from "@/hooks/queries";
+import { useDeleteClacoTicket, usePutEditTicketReview } from "@/hooks/mutation";
+import { useReviewInfoStore } from "@/libraries/store/reviewInfo";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export const ClacoTicketDetailPage = () => {
+ const { id } = useParams();
+ const ticketId = Number(id);
+ const { setReviewInfo } = useReviewInfoStore();
+
+ const { data, isLoading } = useGetTicketReviewDetail(ticketId);
+ const { mutate: editClacoTicketReview } = usePutEditTicketReview();
+ const { mutate: deleteClacoTicket } = useDeleteClacoTicket();
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
+ const [viewingSeat, setViewingSeat] = useState("");
+ const [reviewData, setReviewData] = useState();
+ const [imageLoaded, setImageLoaded] = useState(false);
+ const {
+ thumbsSwiper,
+ isThumbnailShow,
+ isAnimating,
+ selectIndex,
+ setThumbsSwiper,
+ setSelectIndex,
+ handleImageClick,
+ } = useThumbnailModal();
+
+ useEffect(() => {
+ if (data && !isLoading) {
+ setReviewData(data.result);
+ setReviewInfo({
+ starRate: data.result.starRate,
+ content: data.result.content,
+ });
+ }
+ }, [data, isLoading, setReviewInfo]);
+
+ const navigate = useNavigate();
+
+ const gotoBack = () => {
+ const id = localStorage.getItem("prevTicketBookId");
+ const title = localStorage.getItem("prevClacoBookTitle");
+ if (id || title) {
+ navigate(`/ticketbook/${id}?title=${title}`);
+ } else {
+ navigate(-1);
+ }
+ };
+
+ const gotoTicketReviewEdit = () => {
+ navigate(`/ticket/${Number(id)}/edit`);
+ };
+
+ const handleEditSeat = () => {
+ const editData = {
+ ticketReviewId: ticketId,
+ watchSit: JSON.stringify(viewingSeat),
+ starRate: null,
+ content: null,
+ };
+ editClacoTicketReview(editData, {
+ onSuccess: () => {
+ setIsEditModalOpen(false);
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+ };
+
+ const handleDeleteTicketReview = () => {
+ deleteClacoTicket(ticketId, {
+ onSuccess: () => {
+ setIsModalOpen(false);
+ // navigate("/ticketbook");
+ navigate(-1);
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+ };
+
+ useEffect(() => {
+ if (data?.result?.ticketImage) {
+ const img = new Image();
+ img.src = data.result.ticketImage;
+ img.onload = () => setImageLoaded(true);
+ }
+ }, [data]);
+
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+
+ if (shouldShowSkeleton) {
+ return (
+
+ );
+ }
+
+ return (
+
+
image.imageUrl) ?? []}
+ onClose={handleImageClick}
+ setThumbsSwiper={setThumbsSwiper}
+ />
+
+
+ {reviewData?.editor && setIsModalOpen(true)} />}
+
+
+ {reviewData && (
+
+
+
+ )}
+
+
{reviewData?.concertName}
+
+
+ {reviewData?.ticketImage && (
+
setImageLoaded(true)}
+ />
+ )}
+
+
+ {reviewData && (
+
+ )}
+
+
+
+
나의 공연 감상
+ {reviewData?.editor && (
+
+ )}
+
+
+
+
+
+ {reviewData?.starRate?.toFixed(1)}
+
+
+
+ {reviewData?.createdDate.replace(/-/g, ".")} 작성
+
+
+
{reviewData?.content}
+
+ {reviewData?.imageUrlS?.map((image, index) => (
+
{
+ handleImageClick();
+ setSelectIndex({
+ page: 1,
+ index: 0,
+ });
+ }}
+ key={index}
+ src={image.imageUrl}
+ alt="리뷰 이미지"
+ className="min-w-[90px] max-h-[90px] rounded-[5px]"
+ />
+ ))}
+
+
+
공연장
+
+ {reviewData?.placeReviews.map((lReview) => (
+
+ {lReview.categoryName}
+
+ ))}
+
+
+
+
관람 정보
+
+
관람 날짜
+
+ {reviewData?.watchDate.replace(/-/g, ".")}
+
+
+
공연장소
+
+ {reviewData?.watchPlace}
+
+
+
회차
+
+ {reviewData && JSON.parse(reviewData.watchRound)}
+
+
+
캐스팅
+
+ {reviewData &&
+ JSON.parse(reviewData.castings).map(
+ (casting: string, index: number, array: string[]) => (
+
+ {casting.trim()}
+
+ )
+ )}
+
+
+
+ 좌석
+ {reviewData &&
+ JSON.parse(reviewData.watchSit).trim().length === 0 ? (
+ <>
+ {reviewData?.editor && (
+ setIsEditModalOpen(true)}
+ />
+ )}
+ >
+ ) : null}
+
+
+ {reviewData &&
+ JSON.parse(reviewData.watchSit).trim().length === 0 ? null : (
+ <>
+ {reviewData && JSON.parse(reviewData.watchSit)}
+ {reviewData?.editor && (
+ setIsEditModalOpen(true)}
+ className="ml-2"
+ />
+ )}
+ >
+ )}
+
+
+
+
+
+ {/* 모달 컴포넌트 영역 */}
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ onConfirm={handleDeleteTicketReview}
+ />
+ )}
+ {isEditModalOpen && (
+ setIsEditModalOpen(false)}
+ >
+
+
+ 관람 좌석을 입력해주세요
+
+ setViewingSeat(e.target.value)}
+ />
+
+
+ )}
+
+ );
+};
diff --git a/src/pages/TicketBook/[id]/page.tsx b/src/pages/TicketBook/[id]/page.tsx
new file mode 100644
index 0000000..0e98c97
--- /dev/null
+++ b/src/pages/TicketBook/[id]/page.tsx
@@ -0,0 +1,336 @@
+import { ReactComponent as BackArrow } from "@/assets/svgs/BackArrow.svg";
+import { ReactComponent as DownLoad } from "@/assets/svgs/DownLoadBox.svg";
+import { ReactComponent as DotsThree } from "@/assets/svgs/dotsthree.svg";
+import { ReactComponent as Plus } from "@/assets/svgs/plus.svg";
+import { Pagination } from "swiper/modules";
+import "swiper/css";
+import "swiper/css/pagination";
+import { Swiper, SwiperSlide } from "swiper/react";
+import type { Swiper as SwiperType } from "swiper";
+import html2canvas from "html2canvas";
+import { saveAs } from "file-saver";
+import { createRef, useEffect, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { Toast } from "@/libraries/toast/Toast";
+import { MoveModal } from "@/components/Ticket/Modal/Move";
+import { DeleteClacoTicketModal } from "@/components/Ticket/Modal/Delete/ClacoTicket";
+import { DownLoadModal } from "@/components/Ticket/Modal/DownLoad";
+import { useGetClacoBookList, useGetClacoTicketList } from "@/hooks/queries";
+import { ClacoBookList, ClacoTicketListResult } from "@/types";
+import showReview from "@/assets/images/showReview.png";
+import { useDeleteClacoTicket, usePutMoveClacoTicket } from "@/hooks/mutation";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDeferredLoading } from "@/hooks/utils";
+import { useQueryClient } from "@tanstack/react-query";
+
+export const ClacoBookDetailPage = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const queryClient = useQueryClient();
+ const queryParams = new URLSearchParams(location.search);
+ const value = queryParams.get("title");
+ const { id } = useParams();
+ const [ticketRefs, setTicketRefs] = useState<
+ React.RefObject[]
+ >([]);
+ const [clacoTicket, setClacoTicket] = useState();
+ const [currentClacoBook, setCurrentClacoBook] = useState("");
+ const [selectTicketIndex, setSelectTicketIndex] = useState(0);
+ const [selectClacoBook, setSelectClacoBook] = useState({
+ id: 0,
+ title: "",
+ color: "",
+ });
+ const [isSetting, setIsSetting] = useState(false);
+ const [actionState, setActionState] = useState<
+ "move" | "delete" | "download"
+ >();
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [toast, setToast] = useState(false);
+ const [message, setMessage] = useState("");
+
+ const { data: clacoBookData } = useGetClacoBookList();
+ const { data: clacoTicketData, isLoading } = useGetClacoTicketList(
+ Number(id)
+ );
+
+ const { mutate: moveClacoTicket } = usePutMoveClacoTicket();
+ const { mutate: deleteClacoTicket } = useDeleteClacoTicket();
+
+ useEffect(() => {
+ if (clacoTicketData?.result.ticketList && !isLoading) {
+ setClacoTicket(clacoTicketData.result.ticketList);
+
+ setTicketRefs(
+ clacoTicketData.result.ticketList.map(() =>
+ createRef()
+ )
+ );
+ }
+ }, [clacoTicketData, isLoading]);
+
+ useEffect(() => {
+ setCurrentClacoBook(value as string);
+ }, [value]);
+
+ const gotoBack = () => {
+ localStorage.removeItem("prevTicketBookId");
+ localStorage.removeItem("prevClacoBookTitle");
+ navigate("/ticketbook");
+ };
+
+ const gotoTicketDetail = (tId: number) => {
+ localStorage.setItem("prevTicketBookId", String(id));
+ localStorage.setItem("prevClacoBookTitle", currentClacoBook);
+ queryClient.invalidateQueries({ queryKey: ["ticketReviewDetail", tId] });
+ navigate(`/ticket/${tId}`);
+ };
+
+ const gotoTicketCreate = () => {
+ localStorage.setItem("clacoBookId", id?.toString() || "");
+ navigate("/ticketcreate/search");
+ };
+
+ const onDownloadBtn = async () => {
+ if (!ticketRefs[selectTicketIndex]?.current) return;
+
+ try {
+ const canvas = await html2canvas(ticketRefs[selectTicketIndex].current!, {
+ scale: 2,
+ backgroundColor: "#1C1C1C",
+ useCORS: true,
+ allowTaint: true,
+ });
+ canvas.toBlob((blob) => {
+ if (blob !== null) {
+ console.log(ticketRefs[selectTicketIndex].current);
+ saveAs(blob, "MyClacoTicket.png");
+ }
+ });
+ } catch (error) {
+ console.error("Error converting div to image:", error);
+ }
+ };
+
+ const handleModalOpen = (action: "move" | "delete" | "download") => {
+ const condition =
+ clacoBookData?.result.clacoBookList
+ .filter((book) => book.title !== value)
+ .map((book) => book.title).length === 0;
+
+ if (action === "move" && condition) {
+ setToast(true);
+ setMessage("티켓을 이동할 클라코북이 없어요");
+ setIsSetting(false);
+ return;
+ }
+ setActionState(action);
+ setIsSetting(false);
+ setIsModalOpen(true);
+ };
+
+ const handleCloseModal = () => {
+ setIsModalOpen(false);
+ };
+
+ const handleConfirm = async () => {
+ if (actionState === "move") {
+ const clacoBookId = selectClacoBook.id;
+ const ticketReviewId = clacoTicket && clacoTicket[selectTicketIndex].id;
+ moveClacoTicket(
+ {
+ clacoBookId: clacoBookId as number,
+ ticketReviewId: ticketReviewId as number,
+ },
+ {
+ onSuccess: () => {
+ setMessage("티켓이 이동이 완료되었어요");
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ }
+ );
+ } else if (actionState === "delete") {
+ const ticketReviewId = clacoTicket && clacoTicket[selectTicketIndex].id;
+ deleteClacoTicket(ticketReviewId as number, {
+ onSuccess: () => {
+ setMessage("티켓이 삭제되었어요");
+ },
+ onError: (error) => {
+ console.error(error);
+ },
+ });
+ } else {
+ onDownloadBtn();
+ }
+ setIsModalOpen(false);
+ setToast(true);
+ };
+
+ const handleSlideChange = (swiper: SwiperType) => {
+ setSelectTicketIndex(swiper.activeIndex);
+ };
+
+ const { shouldShowSkeleton } = useDeferredLoading(isLoading);
+
+ if (shouldShowSkeleton) {
+ return (
+
+ );
+ }
+
+ if (!isLoading && clacoTicket?.length === 0) {
+ return (
+ <>
+
+
+ navigate(-1)}
+ />
+
+ {currentClacoBook}
+
+
+
+
+ 공연은 즐겁게 관람하셨나요?
+
+
+
+
+