diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..644cc165 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(xargs grep -l \"scaleValue\")", + "Bash(xargs grep -h \"scaleValue\")", + "Bash(pnpm build *)", + "Bash(pnpm lint *)" + ] + } +} diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..d86609d7 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,19 @@ +language: ko-KR + +early_access: false + +reviews: + profile: chill + + high_level_summary: true + review_status: true + review_details: true + + auto_review: + enabled: true + drafts: false + base_branches: + - develop + +chat: + auto_reply: true \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..a402d3da --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..1898ac4e --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +VITE_API_BASE_URL=https://api.yeogido.kr/api/v1 +VITE_KAKAO_MAP_API_KEY=your_kakao_javascript_key +KAKAO_REST_API_KEY=your_kakao_rest_api_key +VITE_NAVER_CLIENT_ID=your_naver_client_id +GOOGLE_MAPS_API_KEY=발급받은_키 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5e2adf0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +.env +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +.claude/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..349cc3a8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +pnpm-lock.yaml \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..967b102d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"] +} \ No newline at end of file diff --git a/README.md b/README.md index e4ac2709..af83874b 100644 --- a/README.md +++ b/README.md @@ -1 +1,506 @@ -# frontend \ No newline at end of file +# 여기도 Frontend + +## 프로젝트 소개 + +**여기도**는 사용자가 지역별 장소와 코스를 탐색하고, 추천 코스와 방문 기록을 작성할 수 있는 웹 서비스입니다. + +주요 기능: + +- 장소 탐색 및 검색 +- 지역/카테고리 필터 +- 지도 기반 장소 확인 +- 장소 및 코스 상세 조회 +- 코스 등록 +- 방문 기록 작성 +- 로그인/회원가입 +- 마이페이지 + +--- + +## 팀원 및 역할 분담 + +| 이름 | 역할 | 담당 | +| --- | --- | --- | +| 김태혁 | A | 공통 기반 + 홈 | +| 윤선민 | B | 인증 + 유저 + 기록 | +| 이경준 | C | 장소 탐색 + 검색 + 필터 + 지도 | +| 이준혁 | D | 코스/장소 상세 + 코스 등록 | + +### A. 공통 기반 + 홈 + +담당자: 김태혁 + +담당 업무: + +- 프로젝트 기본 구조 관리 +- 라우팅 설정 +- 공통 레이아웃 구현 +- 공통 UI 컴포넌트 구현 +- 홈 화면 구현 +- 전역 UI 상태 관리 + +담당 파일: + +```txt +components/common +components/layout +components/ui +components/home +pages/HomePage +router +styles +store/ui.store.ts +``` + +### B. 인증 + 유저 + 기록 + +담당자: 윤선민 + +담당 업무: + +- 로그인 +- 회원가입 +- 프로필 설정 +- 마이페이지 +- 방문 기록 목록 +- 방문 기록 작성 + +담당 파일: + +```txt +components/auth +components/record +pages/auth +pages/mypage +pages/records +store/auth.store.ts +apis/auth.api.ts +apis/user.api.ts +apis/record.api.ts +``` + +### C. 장소 탐색 + 검색 + 필터 + 지도 + +담당자: 이경준 + +담당 업무: + +- 장소 목록 +- 장소 검색 +- 지역 필터 +- 카테고리 필터 +- 지도 화면 +- 장소 좋아요 + +담당 파일: + +```txt +components/place +pages/places +store/placeFilter.store.ts +apis/place.api.ts +hooks/usePlaces.ts +types/place.type.ts +``` + +### D. 코스/장소 상세 + 코스 등록 + +담당자: 이준혁 + +담당 업무: + +- 장소 상세 +- 코스 목록 +- 코스 상세 +- 코스 생성 +- 코스에 장소 추가 +- 사진 및 해시태그 등록 + +담당 파일: + +```txt +components/course +pages/courses +pages/places/PlaceDetailPage +store/courseCreate.store.ts +apis/course.api.ts +apis/image.api.ts +hooks/useCourses.ts +types/course.type.ts +``` + +--- + +## 기술 스택 + +- React +- TypeScript +- Vite +- Tailwind CSS +- React Router DOM +- TanStack React Query +- Zustand +- React Hook Form +- Zod +- Axios +- ESLint +- Prettier +- pnpm + +--- + +## 폴더 구조 + +```text + +📂 폴더 구조 (Directory Structure) +📦 src +┣ 📂 apis # API 통신 관련 설정 및 도메인별 API 함수 +┃ ┣ 📜 axios.ts # Axios 인스턴스 및 인터셉터 설정 +┃ ┣ 📜 auth.api.ts # 인증 관련 API +┃ ┣ 📜 user.api.ts # 사용자 정보 관련 API +┃ ┣ 📜 place.api.ts # 장소 관련 API +┃ ┣ 📜 course.api.ts # 로컬 코스 관련 API +┃ ┣ 📜 record.api.ts # 방문 기록 관련 API +┃ ┗ 📜 image.api.ts # 이미지 업로드 및 처리 API +┃ +┣ 📂 pages # 페이지 단위 (라우트 기준) +┃ ┣ 📂 home # 메인(Home) 페이지 +┃ ┃ ┣ 📜 index.tsx # 메인 페이지 진입점 +┃ ┃ ┣ 📂 components # 메인 페이지에서만 사용하는 컴포넌트 +┃ ┃ ┗ 📂 hooks # 메인 페이지 전용 커스텀 훅 +┃ ┃ +┃ ┣ 📂 auth # 인증(로그인, 회원가입) 페이지 +┃ ┃ ┣ 📜 index.tsx # 인증 페이지 진입점 +┃ ┃ ┣ 📂 components # 로그인/회원가입 UI 컴포넌트 +┃ ┃ ┗ 📂 hooks # 인증 관련 커스텀 훅 +┃ ┃ +┃ ┣ 📂 local-course # 로컬 코스 탐색 및 생성 페이지 +┃ ┃ ┣ 📜 index.tsx # 로컬 코스 페이지 진입점 +┃ ┃ ┣ 📂 components # 로컬 코스 전용 컴포넌트 +┃ ┃ ┗ 📂 hooks # 로컬 코스 관련 커스텀 훅 +┃ ┃ +┃ ┗ 📂 not-found # 404(Not Found) 페이지 +┃ ┗ 📜 index.tsx # 존재하지 않는 경로 접근 시 표시되는 페이지 +┃ +┣ 📂 components # 여러 페이지에서 공통으로 사용하는 UI 컴포넌트 +┃ ┣ 📂 common # Button, Input, Card 등 범용 컴포넌트 +┃ ┣ 📂 layout # Header, Footer, Navigation 등 레이아웃 컴포넌트 +┃ ┣ 📂 ui # Modal, Toast, Spinner 등 기본 UI 요소 +┃ ┗ 📂 icons # SVG 및 아이콘 컴포넌트 +┃ +┣ 📂 hooks # 여러 페이지에서 재사용되는 공통 커스텀 훅 +┃ ┣ 📜 useAuth.ts # 인증 관련 로직 +┃ ┣ 📜 useLocalStorage.ts # LocalStorage 관리 +┃ ┣ 📜 useModal.ts # 모달 상태 관리 +┃ ┗ 📜 useDebounce.ts # 디바운싱 처리 +┃ +┣ 📂 store # 전역 상태 관리 (Zustand) +┃ ┣ 📜 auth.store.ts # 로그인 및 사용자 상태 관리 +┃ ┣ 📜 course.store.ts # 로컬 코스 관련 상태 관리 +┃ ┗ 📜 ui.store.ts # 모달, 토스트 등 UI 상태 관리 +┃ +┣ 📂 types # 공통 TypeScript 타입 및 인터페이스 정의 +┃ ┣ 📜 auth.type.ts +┃ ┣ 📜 user.type.ts +┃ ┣ 📜 course.type.ts +┃ ┣ 📜 place.type.ts +┃ ┣ 📜 record.type.ts +┃ ┗ 📜 api.type.ts # 공통 API 응답 및 에러 타입 +┃ +┗ 📂 utils # 공통 유틸리티 함수 + ┣ 📜 constants.ts # 상수 정의 + ┣ 📜 formatDate.ts # 날짜 포맷 함수 + ┗ 📜 validation.ts # 입력값 검증 함수 +``` + +--- + +## 브랜치 컨벤션 + +브랜치 구조: + +```txt +main +develop +feature/* +fix/* +refactor/* +docs/* +chore/* +``` + +브랜치 역할: + +```txt +main 배포/시연 가능한 안정 버전 +develop 개발 통합 브랜치 +feature 기능 개발 +fix 버그 수정 +refactor 리팩토링 +docs 문서 수정 +chore 설정 및 기타 작업 +``` + +브랜치 생성: + +```bash +git checkout develop +git pull origin develop +git checkout -b feature/기능명 +``` + +PR 방향: + +```txt +feature/* -> develop +fix/* -> develop +refactor/* -> develop +docs/* -> develop +chore/* -> develop + +develop -> main +``` + +브랜치 예시: + +```txt +feature/home-page +feature/auth-login +feature/place-search +feature/course-create +fix/login-validation +refactor/common-button +docs/readme +``` + +규칙: + +- `main` 직접 push 금지 +- 개인 이름 브랜치 사용 금지 +- 기능 단위로 브랜치 생성 +- PR 전 `main` 최신 반영 + +--- + +## 커밋 컨벤션 + +형식: + +```txt +type: 작업 내용 +``` + +타입: + +| 타입 | 설명 | +| :---: | :--- | +| feat | 새로운 기능 추가 | +| fix | 버그 수정 | +| docs | 문서 수정 (README 등) | +| style | 코드 스타일 변경 (포맷팅, 세미콜론 누락 등) | +| design | 사용자 UI 디자인 변경 (CSS 등) | +| refactor | 코드 리팩토링 | +| test | 테스트 코드 작성 | +| build | 빌드 파일 수정 | +| ci | CI 설정 파일 수정 | +| perf | 성능 개선 | +| chore | 패키지 매니저 설정, 기타 운영 코드 변경 | +| rename | 파일 혹은 폴더명 수정 | +| remove | 파일 삭제 | + +예시: + +```txt +feat: 로그인 페이지 구현 +feat: 장소 검색 필터 구현 +fix: 로그인 유효성 검사 오류 수정 +refactor: 공통 Button 컴포넌트 분리 +style: 홈 화면 카드 간격 수정 +chore: React Query Provider 설정 +docs: README 작성 +``` + +규칙: + +- 한글 사용 가능 +- 끝에 마침표 사용 안 함 +- 한 커밋에는 하나의 의도만 포함 +- `수정`, `작업`, `최종` 같은 모호한 메시지 금지 + +--- + +## PR 컨벤션 + +PR 제목: + +```txt +[타입] 작업 요약 +``` + +타입: + +```txt +[FEAT] 기능 추가 +[FIX] 버그 수정 +[REFACTOR] 리팩토링 +[STYLE] UI/CSS 수정 +[CHORE] 설정 및 기타 작업 +[DOCS] 문서 수정 +``` + +예시: + +```txt +[FEAT] 로그인 페이지 구현 +[FEAT] 장소 검색 및 필터 기능 구현 +[FIX] 로그인 유효성 검사 오류 수정 +[REFACTOR] 공통 Button 컴포넌트 분리 +``` + +PR 템플릿: + +```md +## 작업 내용 + +- + +## 변경 화면 + +- + +## 확인 방법 + +1. + +## 체크리스트 + +- [ ] 로컬 실행 확인 +- [ ] 콘솔 에러 없음 +- [ ] 반응형 확인 +- [ ] 불필요한 console.log 제거 +- [ ] develop 최신 반영 완료 +- [ ] 관련 없는 파일 변경 없음 + +## 스크린샷 + + + +## 참고 사항 + + +``` + +PR 규칙: + +- 하나의 PR에는 하나의 기능 또는 목적만 포함 +- 리뷰어 최소 1명 지정 +- UI 변경 시 스크린샷 첨부 +- 가능하면 `pnpm lint`, `pnpm build` 확인 후 PR 생성 +- Merge 방식은 `Squash and merge` + +--- + +## 실행 방법 + +패키지 설치: + +```bash +pnpm install +``` + +개발 서버 실행: + +```bash +pnpm dev +``` + +빌드: + +```bash +pnpm build +``` + +린트: + +```bash +pnpm lint +``` + +프리뷰: + +```bash +pnpm preview +``` + +--- + +## 화면 목록 및 플로우 + +라우트: + +```txt +/ 홈 +/login 로그인 +/signup 회원가입 +/profile 프로필 설정 +/places 장소 목록 +/places/search 장소 검색 +/places/map 지도 탐색 +/places/:placeId 장소 상세 +/courses 코스 목록 +/courses/:courseId 코스 상세 +/courses/new 코스 등록 +/records 방문 기록 목록 +/records/new 방문 기록 작성 +/mypage 마이페이지 +``` + +플로우: + +```txt +인증: +홈 -> 로그인 -> 회원가입/소셜 로그인 -> 프로필 설정 -> 홈 + +장소 탐색: +홈 -> 장소 목록 -> 검색/필터 -> 장소 상세 + +지도 탐색: +홈 -> 지도 화면 -> 주변 장소 확인 -> 장소 상세 + +코스 조회: +홈 -> 코스 목록 -> 코스 상세 + +코스 등록: +코스 등록 -> 장소 검색 -> 장소 선택 -> 코스 정보 입력 -> 사진/해시태그 추가 -> 등록 완료 + +방문 기록: +마이페이지 -> 방문 기록 -> 기록 작성 -> 장소 선택 -> 날짜 선택 -> 사진 추가 -> 기록 완료 +``` + +--- + +## 상태 관리 기준 + +```txt +Zustand + 클라이언트 전역 상태 관리 + auth.store.ts + placeFilter.store.ts + courseCreate.store.ts + ui.store.ts + +React Query + 서버 상태 관리 + 장소 목록 + 장소 상세 + 코스 목록 + 코스 상세 + 방문 기록 + 사용자 정보 +``` + +```txt +Zustand = 클라이언트 UI 상태 +React Query = 서버에서 받아오는 데이터 +``` diff --git a/api/google-places/fetchWithTimeout.ts b/api/google-places/fetchWithTimeout.ts new file mode 100644 index 00000000..b599fdb1 --- /dev/null +++ b/api/google-places/fetchWithTimeout.ts @@ -0,0 +1,27 @@ +const DEFAULT_TIMEOUT_MS = 8000; + +function isTimeoutError(error: unknown): boolean { + return ( + error instanceof DOMException && + (error.name === 'AbortError' || error.name === 'TimeoutError') + ); +} + +export async function fetchWithTimeout( + url: string, + init: RequestInit = {}, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise { + try { + return await fetch(url, { + ...init, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + if (isTimeoutError(error)) { + throw new Error('Google Places request timed out.', { cause: error }); + } + + throw error; + } +} diff --git a/api/google-places/hours.ts b/api/google-places/hours.ts new file mode 100644 index 00000000..093b2960 --- /dev/null +++ b/api/google-places/hours.ts @@ -0,0 +1,88 @@ +import { + InvalidPlaceHoursRequestError, + lookupPlaceHours, + type PlaceHoursRequest, +} from './placeHours'; + +export const config = { runtime: 'edge' }; + +const CACHE_TTL_MS = 1000 * 60 * 5; +const RATE_LIMIT_WINDOW_MS = 1000 * 60; +const RATE_LIMIT_MAX_REQUESTS = 30; + +const responseCache = new Map< + string, + { readonly expiresAt: number; readonly body: Awaited> } +>(); +const rateLimitEntries = new Map(); + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +} + +function getClientId(request: Request) { + return ( + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + 'unknown' + ); +} + +function isRateLimited(clientId: string, now: number) { + const entry = rateLimitEntries.get(clientId); + + if (!entry || entry.resetAt <= now) { + rateLimitEntries.set(clientId, { + count: 1, + resetAt: now + RATE_LIMIT_WINDOW_MS, + }); + return false; + } + + entry.count += 1; + return entry.count > RATE_LIMIT_MAX_REQUESTS; +} + +export default async function handler(request: Request): Promise { + if (request.method !== 'POST') { + return json({ message: 'Method not allowed.' }, 405); + } + + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) { + return json({ message: 'Google Places API is not configured.' }, 503); + } + + let requestBody: PlaceHoursRequest; + try { + requestBody = (await request.json()) as PlaceHoursRequest; + } catch { + return json({ message: 'Invalid request body.' }, 400); + } + + const now = Date.now(); + const cacheKey = JSON.stringify(requestBody); + const cached = responseCache.get(cacheKey); + if (cached && cached.expiresAt > now) { + return json(cached.body); + } + + if (isRateLimited(getClientId(request), now)) { + return json({ message: 'Too many requests.' }, 429); + } + + try { + const body = await lookupPlaceHours(requestBody, apiKey); + responseCache.set(cacheKey, { body, expiresAt: now + CACHE_TTL_MS }); + return json(body); + } catch (error) { + if (error instanceof InvalidPlaceHoursRequestError) { + return json({ message: error.message }, 400); + } + + return json({ message: 'Google Places request failed.' }, 502); + } +} diff --git a/api/google-places/image.ts b/api/google-places/image.ts new file mode 100644 index 00000000..6d2bb74e --- /dev/null +++ b/api/google-places/image.ts @@ -0,0 +1,26 @@ +import { fetchGoogleImage } from './imageProxy'; + +export const config = { runtime: 'edge' }; + +export default async function handler(request: Request): Promise { + if (request.method !== 'GET') { + return new Response(null, { status: 405 }); + } + + const targetUrl = new URL(request.url).searchParams.get('url'); + if (!targetUrl) { + return new Response(null, { status: 400 }); + } + + const result = await fetchGoogleImage(targetUrl); + + return new Response(result.body, { + status: result.status, + headers: { + 'content-type': result.contentType, + ...(result.status === 200 + ? { 'cache-control': 'public, max-age=3600' } + : {}), + }, + }); +} diff --git a/api/google-places/imageProxy.ts b/api/google-places/imageProxy.ts new file mode 100644 index 00000000..34db5267 --- /dev/null +++ b/api/google-places/imageProxy.ts @@ -0,0 +1,57 @@ +// admin course-registration에서 장소 사진을 직접 올리지 않고 구글 이미지로 +// 넘어가면, 클라이언트가 googleusercontent.com을 크로스오리진으로 직접 +// fetch해서 File로 만들어야 했다 — CORS에 기대는 취약한 방식이라, 우리 +// 서버가 대신 내려받아 같은 오리진으로 돌려주는 프록시를 둔다. photoUri +// 자체는 공개 URL이라 API 키는 필요 없지만, 임의 URL을 그대로 fetch하면 +// SSRF가 되므로 구글 이미지 호스트만 허용한다. +const ALLOWED_HOSTNAME_SUFFIXES = ['.googleusercontent.com']; +const IMAGE_PROXY_TIMEOUT_MS = 8000; + +function isAllowedGoogleImageUrl(rawUrl: string): URL | null { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return null; + } + + const isAllowed = + url.protocol === 'https:' && + ALLOWED_HOSTNAME_SUFFIXES.some((suffix) => url.hostname.endsWith(suffix)); + + return isAllowed ? url : null; +} + +export interface ProxiedImage { + readonly status: number; + readonly contentType: string; + readonly body: ArrayBuffer | null; +} + +export async function fetchGoogleImage( + targetUrl: string +): Promise { + const url = isAllowedGoogleImageUrl(targetUrl); + if (!url) { + return { status: 400, contentType: 'text/plain', body: null }; + } + + let upstream: Response; + try { + upstream = await fetch(url.toString(), { + signal: AbortSignal.timeout(IMAGE_PROXY_TIMEOUT_MS), + }); + } catch { + return { status: 502, contentType: 'text/plain', body: null }; + } + + if (!upstream.ok) { + return { status: 502, contentType: 'text/plain', body: null }; + } + + return { + status: 200, + contentType: upstream.headers.get('content-type') ?? 'image/jpeg', + body: await upstream.arrayBuffer(), + }; +} diff --git a/api/google-places/parseRequest.ts b/api/google-places/parseRequest.ts new file mode 100644 index 00000000..27135f4d --- /dev/null +++ b/api/google-places/parseRequest.ts @@ -0,0 +1,9 @@ +export function toOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +export function toOptionalCoordinate(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} diff --git a/api/google-places/photo.ts b/api/google-places/photo.ts new file mode 100644 index 00000000..34e7c258 --- /dev/null +++ b/api/google-places/photo.ts @@ -0,0 +1,139 @@ +import { + InvalidPlacePhotoRequestError, + lookupPlacePhoto, + type PlacePhotoRequest, +} from './placePhoto'; +import { toOptionalCoordinate, toOptionalString } from './parseRequest.js'; + +export const config = { runtime: 'edge' }; + +const CACHE_TTL_MS = 1000 * 60 * 30; +const CACHE_MAX_ENTRIES = 500; +const RATE_LIMIT_WINDOW_MS = 1000 * 60; +const RATE_LIMIT_MAX_REQUESTS = 30; +const RATE_LIMIT_MAX_ENTRIES = 1000; + +const responseCache = new Map< + string, + { + readonly expiresAt: number; + readonly body: Awaited>; + } +>(); +const rateLimitEntries = new Map(); + +// 클라이언트가 requestBody에 여분 필드를 섞어 보내도 같은 장소로 취급하도록 +// 실제로 조회에 쓰이는 필드만으로 정규화한 키를 쓴다. 그냥 JSON.stringify한 +// 원본 바디를 키로 쓰면 무관한 필드 차이로 캐시가 쪼개진다. +function buildCacheKey(requestBody: PlacePhotoRequest): string { + const name = toOptionalString(requestBody.name) ?? ''; + const address = toOptionalString(requestBody.address) ?? ''; + const latitude = toOptionalCoordinate(requestBody.latitude) ?? ''; + const longitude = toOptionalCoordinate(requestBody.longitude) ?? ''; + + return `${name}|${address}|${latitude}|${longitude}`; +} + +// 엣지 함수 인스턴스는 재사용되는 동안 계속 살아있으므로, 만료된 항목을 지워 +// 주지 않으면 두 맵 모두 무한정 커진다. 요청마다 만료분을 걷어내고, 그래도 +// 상한을 넘으면(자연 만료가 안 되는 rate-limit 카운터 등) 가장 오래된 항목부터 +// 지워 상한을 지킨다. +function pruneExpired( + entries: Map, + isExpired: (value: V) => boolean, + maxEntries: number +) { + for (const [key, value] of entries) { + if (isExpired(value)) { + entries.delete(key); + } + } + + while (entries.size > maxEntries) { + const oldestKey = entries.keys().next().value; + if (oldestKey === undefined) break; + entries.delete(oldestKey); + } +} + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +} + +function getClientId(request: Request) { + return ( + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + 'unknown' + ); +} + +function isRateLimited(clientId: string, now: number) { + const entry = rateLimitEntries.get(clientId); + + if (!entry || entry.resetAt <= now) { + rateLimitEntries.set(clientId, { + count: 1, + resetAt: now + RATE_LIMIT_WINDOW_MS, + }); + return false; + } + + entry.count += 1; + return entry.count > RATE_LIMIT_MAX_REQUESTS; +} + +export default async function handler(request: Request): Promise { + if (request.method !== 'POST') { + return json({ message: 'Method not allowed.' }, 405); + } + + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) { + return json({ message: 'Google Places API is not configured.' }, 503); + } + + let requestBody: PlacePhotoRequest; + try { + requestBody = (await request.json()) as PlacePhotoRequest; + } catch { + return json({ message: 'Invalid request body.' }, 400); + } + + const now = Date.now(); + pruneExpired( + responseCache, + (entry) => entry.expiresAt <= now, + CACHE_MAX_ENTRIES + ); + pruneExpired( + rateLimitEntries, + (entry) => entry.resetAt <= now, + RATE_LIMIT_MAX_ENTRIES + ); + + const cacheKey = buildCacheKey(requestBody); + const cached = responseCache.get(cacheKey); + if (cached && cached.expiresAt > now) { + return json(cached.body); + } + + if (isRateLimited(getClientId(request), now)) { + return json({ message: 'Too many requests.' }, 429); + } + + try { + const body = await lookupPlacePhoto(requestBody, apiKey); + responseCache.set(cacheKey, { body, expiresAt: now + CACHE_TTL_MS }); + return json(body); + } catch (error) { + if (error instanceof InvalidPlacePhotoRequestError) { + return json({ message: error.message }, 400); + } + + return json({ message: 'Google Places request failed.' }, 502); + } +} diff --git a/api/google-places/placeHours.ts b/api/google-places/placeHours.ts new file mode 100644 index 00000000..95bfab8a --- /dev/null +++ b/api/google-places/placeHours.ts @@ -0,0 +1,117 @@ +import { fetchWithTimeout } from './fetchWithTimeout.js'; +import { toOptionalCoordinate, toOptionalString } from './parseRequest.js'; + +export interface PlaceHoursRequest { + readonly name?: unknown; + readonly address?: unknown; + readonly latitude?: unknown; + readonly longitude?: unknown; +} +export interface PlaceHoursResponse { + readonly currentWeekdayDescriptions: readonly string[]; + readonly regularWeekdayDescriptions: readonly string[]; + readonly openNow?: boolean; + readonly nextOpenTime?: string; + readonly nextCloseTime?: string; +} + +interface GoogleOpeningHours { + readonly weekdayDescriptions?: readonly string[]; + readonly openNow?: boolean; + readonly nextOpenTime?: string; + readonly nextCloseTime?: string; +} + +interface GooglePlaceDetails { + readonly currentOpeningHours?: GoogleOpeningHours; + readonly regularOpeningHours?: GoogleOpeningHours; +} + +interface GoogleTextSearchResponse { + readonly places?: readonly { readonly id?: string }[]; +} + +export class InvalidPlaceHoursRequestError extends Error {} + +export async function lookupPlaceHours( + requestBody: PlaceHoursRequest, + apiKey: string +): Promise { + const name = toOptionalString(requestBody.name); + const address = toOptionalString(requestBody.address); + const latitude = toOptionalCoordinate(requestBody.latitude); + const longitude = toOptionalCoordinate(requestBody.longitude); + + if (!name) { + throw new InvalidPlaceHoursRequestError('Place name is required.'); + } + + const textSearchBody: Record = { + textQuery: [name, address].filter(Boolean).join(' '), + languageCode: 'ko', + }; + + if (latitude !== undefined && longitude !== undefined) { + textSearchBody.locationBias = { + circle: { + center: { latitude, longitude }, + radius: 1_000, + }, + }; + } + + const searchResponse = await fetchWithTimeout( + 'https://places.googleapis.com/v1/places:searchText', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'places.id', + }, + body: JSON.stringify(textSearchBody), + } + ); + + if (!searchResponse.ok) { + throw new Error('Google place search failed.'); + } + + const searchResult = + (await searchResponse.json()) as GoogleTextSearchResponse; + const placeId = searchResult.places?.[0]?.id; + + if (!placeId) { + return null; + } + + const detailsResponse = await fetchWithTimeout( + `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}?languageCode=ko`, + { + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'currentOpeningHours,regularOpeningHours', + }, + } + ); + + if (!detailsResponse.ok) { + throw new Error('Google place details failed.'); + } + + const details = (await detailsResponse.json()) as GooglePlaceDetails; + const currentHours = details.currentOpeningHours; + const regularHours = details.regularOpeningHours; + + if (!currentHours && !regularHours) { + return null; + } + + return { + currentWeekdayDescriptions: currentHours?.weekdayDescriptions ?? [], + regularWeekdayDescriptions: regularHours?.weekdayDescriptions ?? [], + openNow: currentHours?.openNow, + nextOpenTime: currentHours?.nextOpenTime, + nextCloseTime: currentHours?.nextCloseTime, + }; +} diff --git a/api/google-places/placePhoto.ts b/api/google-places/placePhoto.ts new file mode 100644 index 00000000..3c02559f --- /dev/null +++ b/api/google-places/placePhoto.ts @@ -0,0 +1,141 @@ +import { fetchWithTimeout } from './fetchWithTimeout.js'; +import { toOptionalCoordinate, toOptionalString } from './parseRequest.js'; + +export interface PlacePhotoRequest { + readonly name?: unknown; + readonly address?: unknown; + readonly latitude?: unknown; + readonly longitude?: unknown; +} +export interface PlacePhotoResponse { + readonly photoUri: string; +} + +interface GooglePlacePhoto { + readonly name?: string; +} + +interface GooglePlaceDetails { + readonly photos?: readonly GooglePlacePhoto[]; +} + +interface GoogleTextSearchResponse { + readonly places?: readonly { readonly id?: string }[]; +} + +interface GooglePhotoMedia { + readonly photoUri?: string; +} + +const PHOTO_MAX_WIDTH_PX = 800; + +export class InvalidPlacePhotoRequestError extends Error {} + +function fetchTextSearch( + textSearchBody: Record, + apiKey: string +): Promise { + return fetchWithTimeout( + 'https://places.googleapis.com/v1/places:searchText', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'places.id', + }, + body: JSON.stringify(textSearchBody), + } + ); +} + +function fetchPlaceDetails(placeId: string, apiKey: string): Promise { + return fetchWithTimeout( + `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}?languageCode=ko`, + { + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'photos', + }, + } + ); +} + +function fetchMedia(photoName: string, apiKey: string): Promise { + return fetchWithTimeout( + `https://places.googleapis.com/v1/${photoName}/media?maxWidthPx=${PHOTO_MAX_WIDTH_PX}&skipHttpRedirect=true`, + { + headers: { 'X-Goog-Api-Key': apiKey }, + } + ); +} + +export async function lookupPlacePhoto( + requestBody: unknown, + apiKey: string +): Promise { + if (typeof requestBody !== 'object' || requestBody === null) { + throw new InvalidPlacePhotoRequestError('Request body must be an object.'); + } + + const body = requestBody as PlacePhotoRequest; + const name = toOptionalString(body.name); + const address = toOptionalString(body.address); + const latitude = toOptionalCoordinate(body.latitude); + const longitude = toOptionalCoordinate(body.longitude); + + if (!name) { + throw new InvalidPlacePhotoRequestError('Place name is required.'); + } + + const textSearchBody: Record = { + textQuery: [name, address].filter(Boolean).join(' '), + languageCode: 'ko', + }; + + if (latitude !== undefined && longitude !== undefined) { + textSearchBody.locationBias = { + circle: { + center: { latitude, longitude }, + radius: 1_000, + }, + }; + } + + const searchResponse = await fetchTextSearch(textSearchBody, apiKey); + + if (!searchResponse.ok) { + throw new Error('Google place search failed.'); + } + + const searchResult = + (await searchResponse.json()) as GoogleTextSearchResponse; + const placeId = searchResult.places?.[0]?.id; + + if (!placeId) { + return null; + } + + const detailsResponse = await fetchPlaceDetails(placeId, apiKey); + + if (!detailsResponse.ok) { + throw new Error('Google place details failed.'); + } + + const details = (await detailsResponse.json()) as GooglePlaceDetails; + const photoName = details.photos?.[0]?.name; + + if (!photoName) { + return null; + } + + const mediaResponse = await fetchMedia(photoName, apiKey); + + if (!mediaResponse.ok) { + throw new Error('Google place photo media failed.'); + } + + const media = (await mediaResponse.json()) as GooglePhotoMedia; + + return media.photoUri ? { photoUri: media.photoUri } : null; +} diff --git a/api/kakao-routing/walk.ts b/api/kakao-routing/walk.ts new file mode 100644 index 00000000..5449aeed --- /dev/null +++ b/api/kakao-routing/walk.ts @@ -0,0 +1,22 @@ +export const config = { runtime: 'edge' }; + +export default async function handler(request: Request): Promise { + const { search } = new URL(request.url); + + const kakaoResponse = await fetch( + `https://dapi.kakao.com/v2/routing/walk${search}`, + { + headers: { + Authorization: `KakaoAK ${process.env.KAKAO_REST_API_KEY}`, + }, + } + ); + + return new Response(kakaoResponse.body, { + status: kakaoResponse.status, + headers: { + 'content-type': + kakaoResponse.headers.get('content-type') ?? 'application/json', + }, + }); +} diff --git a/docs/superpowers/specs/2026-07-22-event-selection-stability-design.md b/docs/superpowers/specs/2026-07-22-event-selection-stability-design.md new file mode 100644 index 00000000..891ab13f --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-event-selection-stability-design.md @@ -0,0 +1,40 @@ +# 행사 선택 화면 안정화 설계 + +## 목적 + +행사를 선택한 뒤에도 새 검색어에 맞는 결과를 즉시 표시하고, 선택한 행사는 바텀 시트 목록에 유지한다. 모바일에서 바텀 시트가 유효하지 않은 0% 상태로 내려가 다시 열 수 없게 되는 상황을 막는다. + +## 범위 + +- 검색 입력값이 바뀔 때 검색 결과를 즉시 다시 필터링한다. +- 선택한 행사는 검색어 변경과 무관하게 선택 목록에 유지한다. +- 이미 선택한 행사는 검색 결과에서 중복 추가를 막는다. +- 바텀 시트가 `null` 또는 스냅 포인트에 없는 값을 받으면 최소 스냅(30%)으로 복구한다. + +검색 아이콘 클릭 및 Enter 제출 동작은 이번 변경 범위에 포함하지 않는다. + +## 설계 + +### 검색 상태 흐름 + +`SearchBar`는 입력값 변경과 제출을 구분해 콜백으로 알린다. 행사 선택 화면은 입력 변경 콜백을 사용해 `query`를 갱신하고, 그 값을 기준으로 검색 결과를 계산한다. 제출 동작은 기존대로 유지하며 이 변경이 검색 아이콘이나 Enter 처리 방식에 영향을 주지 않는다. + +### 선택 상태 흐름 + +`selectedFestivals`는 검색어 상태와 독립적으로 유지한다. 검색 결과는 이 목록의 ID 집합을 받아 이미 선택한 항목의 추가 버튼만 비활성화한다. 따라서 새 검색을 해도 바텀 시트의 선택 목록은 바뀌지 않는다. + +### 바텀 시트 상태 흐름 + +Vaul이 전달하는 활성 스냅 값을 검증하는 전용 정규화 함수를 둔다. 값이 `null`이거나 선언된 스냅 포인트(30%, 60%, 90%) 중 하나가 아니면 최소 스냅 30%를 반환한다. 컴포넌트는 이 정규화된 값만 상태에 저장해 0% 또는 닫힌 상태가 지속되지 않게 한다. + +## 오류 처리 + +- 빈 검색어는 현재와 같이 검색 결과를 비운다. +- 유효하지 않은 바텀 시트 스냅 값은 오류를 표시하지 않고 최소 높이로 안전하게 복구한다. + +## 테스트 + +- 검색 입력 변경 알림과 행사 화면의 실시간 결과 갱신을 검증한다. +- 검색어를 바꾼 뒤에도 선택 ID 목록이 유지되는 동작을 검증한다. +- `null`, `0`, 임의 수치가 최소 스냅으로 정규화되는지 검증한다. +- 기존 스냅 계산 및 검색 필터 테스트도 계속 통과해야 한다. diff --git a/docs/superpowers/specs/2026-07-29-local-recommendation-draft-design.md b/docs/superpowers/specs/2026-07-29-local-recommendation-draft-design.md new file mode 100644 index 00000000..1271e51d --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-local-recommendation-draft-design.md @@ -0,0 +1,40 @@ +# Local Recommendation Draft Persistence Design + +## Goal + +Preserve every in-progress local recommendation field across route changes and browser refreshes, then submit it once from the visit-order screen. + +## Storage Boundaries + +- A new Zustand store owns the draft state shared by all local-recommendation routes. +- Zustand `persist` stores JSON-compatible values in `localStorage`: course basics, region, tag IDs, selected festival IDs, selected place metadata, image keys, and visit order. +- The browser's existing IndexedDB style is reused for actual `File` objects: the cover image and one image per selected place. +- The store never persists `File`, `Blob`, object URLs, React state, or UI-only fields. + +## Data Flow + +1. Each step initializes from the persisted draft and writes its completed values before navigating forward or backward. +2. Image selection writes the file to IndexedDB and writes only a stable image key to the Zustand draft. +3. The visit-order page reads its sortable items from the draft, and records every completed reorder. +4. The final submit operation builds one JSON request for `POST /api/v1/courses` and prevents concurrent submission. +5. A successful POST clears both the Zustand draft and its IndexedDB records, then navigates to the success destination. A failed POST leaves both intact for retry. + +## API Boundary + +`apis/localRecommendations.ts` exposes `createLocalRecommendation(payload)`. It posts JSON through the existing Axios client to `/api/v1/courses` using the backend fields `title`, `regionId`, `description`, enum values, months, `thumbnailKey`, `hashtagIds`, and ordered `courseItems`. Local IndexedDB file keys remain separate from remote storage keys. + +## Error Handling + +- Missing required persisted fields or missing indexed files block final submission and present an actionable error. +- IndexedDB read/write failures do not clear the current draft. +- A failed POST does not invoke either cleanup operation. + +## Testing + +- Unit-test JSON draft merging, reset behavior, and request-data construction. +- Unit-test the IndexedDB draft adapter's save, read, and clear behavior with a browser database mock. +- Verify final registration calls cleanup only after a successful request. + +## Scope + +This change does not add cross-device drafts, a server-side draft API, or automatic cleanup of abandoned browser drafts. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..ef614d25 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 00000000..0fca6f04 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 00000000..29dd6f21 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "test": "node --test tests/*.test.ts", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.4.0", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query": "^5.101.2", + "@tanstack/react-query-devtools": "^5.101.2", + "axios": "^1.18.1", + "d3-geo": "^3.1.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "immer": "^11.1.9", + "jwt-decode": "^4.0.0", + "motion": "12.42.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-hook-form": "^7.80.0", + "react-icons": "^5.7.0", + "react-modal-sheet": "4.4.0", + "react-router-dom": "^7.18.1", + "react-zoom-pan-pinch": "^4.0.3", + "tailwindcss": "^4.3.2", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/d3-geo": "^3.1.0", + "@types/d3-selection": "^3.0.11", + "@types/d3-zoom": "^3.0.8", + "@types/geojson": "^7946.0.16", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "eslint": "^10.6.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.3", + "globals": "^17.7.0", + "prettier": "^3.9.4", + "prettier-plugin-tailwindcss": "^0.8.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.62.0", + "vite": "^8.1.1" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..85980dcc --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2671 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.7) + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.80.0(react@19.2.7)) + '@tailwindcss/vite': + specifier: ^4.3.2 + version: 4.3.2(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)) + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@19.2.7) + '@tanstack/react-query-devtools': + specifier: ^5.101.2 + version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) + axios: + specifier: ^1.18.1 + version: 1.18.1 + d3-geo: + specifier: ^3.1.1 + version: 3.1.1 + d3-selection: + specifier: ^3.0.0 + version: 3.0.0 + d3-zoom: + specifier: ^3.0.0 + version: 3.0.0 + immer: + specifier: ^11.1.9 + version: 11.1.9 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 + motion: + specifier: 12.42.2 + version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + react-hook-form: + specifier: ^7.80.0 + version: 7.80.0(react@19.2.7) + react-icons: + specifier: ^5.7.0 + version: 5.7.0(react@19.2.7) + react-modal-sheet: + specifier: 4.4.0 + version: 4.4.0(motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + react-router-dom: + specifier: ^7.18.1 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-zoom-pan-pinch: + specifier: ^4.0.3 + version: 4.0.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tailwindcss: + specifier: ^4.3.2 + version: 4.3.2 + zod: + specifier: ^4.4.3 + version: 4.4.3 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.17)(immer@11.1.9)(react@19.2.7) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) + '@types/d3-geo': + specifier: ^3.1.0 + version: 3.1.0 + '@types/d3-selection': + specifier: ^3.0.11 + version: 3.0.11 + '@types/d3-zoom': + specifier: ^3.0.8 + version: 3.0.8 + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 + '@types/node': + specifier: ^24.13.2 + version: 24.13.2 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)) + eslint: + specifier: ^10.6.0 + version: 10.6.0(jiti@2.7.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.6.0(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.3 + version: 0.5.3(eslint@10.6.0(jiti@2.7.0)) + globals: + specifier: ^17.7.0 + version: 17.7.0 + prettier: + specifier: ^3.9.4 + version: 3.9.4 + prettier-plugin-tailwindcss: + specifier: ^0.8.0 + version: 0.8.0(prettier@3.9.4) + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.62.0 + version: 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + vite: + specifier: ^8.1.1 + version: 8.1.3(@types/node@24.13.2)(jiti@2.7.0) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.2': + resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/query-devtools@5.101.2': + resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==} + + '@tanstack/react-query-devtools@5.101.2': + resolution: {integrity: sha512-eU7HctdA9gDjqoERoEdzLbw9DiqnBDfh5+Hu0u26gjqoHJezOpQAuiesDL2VvkU+2cPV76zgv0tMZsOrI4LjnQ==} + peerDependencies: + '@tanstack/react-query': ^5.101.2 + react: ^18 || ^19 + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@typescript-eslint/eslint-plugin@8.62.1': + resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.62.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.62.1': + resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.1': + resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.1': + resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.62.1': + resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.62.1': + resolution: {integrity: sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.62.1': + resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.62.1': + resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.1': + resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.62.1': + resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.41: + resolution: {integrity: sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001800: + resolution: {integrity: sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.384: + resolution: {integrity: sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.3: + resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.6.0: + resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + framer-motion@12.42.2: + resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immer@11.1.9: + resolution: {integrity: sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + motion-dom@12.42.2: + resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.42.2: + resolution: {integrity: sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.8.0: + resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-hook-form@7.80.0: + resolution: {integrity: sha512-4P+fk6oXsxY+6xSj7Euhc2sumQD8zQqCuVHoJwoyp9EchP+IUW9OESB7uHFJOKsIBQ4MQqYE84INJFqUCYNoOg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-icons@5.7.0: + resolution: {integrity: sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==} + peerDependencies: + react: '*' + + react-modal-sheet@4.4.0: + resolution: {integrity: sha512-ub42vR7iwjdM/2Zl6uZoH5M5Dcb6KTuVaOQ+uQCIlo10SdlYAhksb6u3eIFSLohFrX5q03rgFnR6Y0PKhjjl/Q==} + engines: {node: '>=18'} + peerDependencies: + motion: '>=11' + react: '>=16' + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-zoom-pan-pinch@4.0.3: + resolution: {integrity: sha512-N2Hi6L78fFmhRra+ORpFSW7WST5x6kxpOPplIvtB0b7b+U2anpo1z1wLgaWRPS2kUSqcraRG+JgBCIlDJnqqAg==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: '*' + react-dom: '*' + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.62.1: + resolution: {integrity: sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.1.3: + resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))': + dependencies: + eslint: 10.6.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.6.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@hookform/resolvers@5.4.0(react-hook-form@7.80.0(react@19.2.7))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.80.0(react@19.2.7) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.138.0': {} + + '@rolldown/binding-android-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-x64@1.1.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.4': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.4': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.2 + + '@tailwindcss/oxide-android-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + + '@tailwindcss/vite@4.3.2(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + vite: 8.1.3(@types/node@24.13.2)(jiti@2.7.0) + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/query-devtools@5.101.2': {} + + '@tanstack/react-query-devtools@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/query-devtools': 5.101.2 + '@tanstack/react-query': 5.101.2(react@19.2.7) + react: 19.2.7 + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/d3-color@3.1.3': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/geojson@7946.0.16': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.2': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/type-utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.62.1 + eslint: 10.6.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + eslint: 10.6.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.62.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) + '@typescript-eslint/types': 8.62.1 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + + '@typescript-eslint/tsconfig-utils@8.62.1(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.6.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.62.1': {} + + '@typescript-eslint/typescript-estree@8.62.1(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.1(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@6.0.3) + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + eslint: 10.6.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.0.3(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.3(@types/node@24.13.2)(jiti@2.7.0) + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + asynckit@0.4.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.41: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.41 + caniuse-lite: 1.0.30001800 + electron-to-chromium: 1.5.384 + node-releases: 2.0.50 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + caniuse-lite@1.0.30001800: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.384: {} + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.6.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.3(eslint@10.6.0(jiti@2.7.0)): + dependencies: + eslint: 10.6.0(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.6.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.42.2 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.7.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immer@11.1.9: {} + + imurmurhash@0.1.4: {} + + internmap@2.0.3: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jwt-decode@4.0.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + motion-dom@12.42.2: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + framer-motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.50: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.8.0(prettier@3.9.4): + dependencies: + prettier: 3.9.4 + + prettier@3.9.4: {} + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-hook-form@7.80.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-icons@5.7.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-modal-sheet@4.4.0(motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + dependencies: + motion: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react-zoom-pan-pinch@4.0.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + rolldown@1.1.4: + dependencies: + '@oxc-project/types': 0.138.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + tailwindcss@4.3.2: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.62.1(typescript@6.0.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 10.6.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.16 + rolldown: 1.1.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + fsevents: 2.3.3 + jiti: 2.7.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + + zustand@5.0.14(@types/react@19.2.17)(immer@11.1.9)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + immer: 11.1.9 + react: 19.2.7 diff --git a/public/fonts/PretendardVariable.woff2 b/public/fonts/PretendardVariable.woff2 new file mode 100644 index 00000000..49c54b51 Binary files /dev/null and b/public/fonts/PretendardVariable.woff2 differ diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..a55add83 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,26 @@ +import AppRouter from './router/AppRouter'; +import ScrollToTop from './router/ScrollToTop'; +import { ToastProvider } from './components/toast'; + +import { APP_MAX_WIDTH } from './constants/layout'; +import { LoginModalProvider } from './contexts/LoginModalProvider'; + +function App() { + return ( + + +
+
+ + +
+
+
+
+ ); +} + +export default App; diff --git a/src/apis/auth.api.ts b/src/apis/auth.api.ts new file mode 100644 index 00000000..9179dc83 --- /dev/null +++ b/src/apis/auth.api.ts @@ -0,0 +1,106 @@ +import { apiClient } from './common'; + +import type { + CheckEmailResult, + LoginRequest, + LoginResult, + ResetPasswordRequest, + SignupRequest, + SignupResult, + SocialLoginRequest, + SocialLoginResult, + SocialSignupCompleteRequest, + VerifyEmailCodeResult, + VerifyPasswordResetCodeResult, +} from '../types/auth.type'; + +export async function login(data: LoginRequest): Promise { + const { data: result } = await apiClient.post( + '/auth/login', + data + ); + + return result; +} + +export async function logout(): Promise { + await apiClient.post('/auth/logout'); +} + +export async function checkEmail(email: string): Promise { + const { data: result } = await apiClient.get( + '/auth/check-email', + { params: { email } } + ); + + return result; +} + +export async function signup(data: SignupRequest): Promise { + const { data: result } = await apiClient.post( + '/auth/signup', + data + ); + + return result; +} + +export async function sendEmailCode(email: string): Promise { + await apiClient.post('/auth/email/send-code', { email }); +} + +export async function verifyEmailCode( + email: string, + authCode: string +): Promise { + const { data: result } = await apiClient.post( + '/auth/email/verify-code', + { email, authCode } + ); + + return result; +} + +export async function socialLogin( + data: SocialLoginRequest +): Promise { + const { data: result } = await apiClient.post( + '/auth/social-login', + data + ); + + return result; +} + +export async function completeSocialSignup( + data: SocialSignupCompleteRequest +): Promise { + const { data: result } = await apiClient.post( + '/auth/social-signup/complete', + data + ); + + return result; +} + +export async function sendPasswordResetCode(email: string): Promise { + await apiClient.post('/auth/password/send-code', { email }); +} + +export async function verifyPasswordResetCode( + email: string, + authCode: string +): Promise { + const { data: result } = await apiClient.post( + '/auth/password/verify-code', + { email, authCode } + ); + + return result; +} + +export async function resetPassword( + data: ResetPasswordRequest +): Promise { + await apiClient.patch('/auth/password/reset', data); +} diff --git a/src/apis/business-promotions.api.ts b/src/apis/business-promotions.api.ts new file mode 100644 index 00000000..4e6ccb6d --- /dev/null +++ b/src/apis/business-promotions.api.ts @@ -0,0 +1,30 @@ +import { apiClient } from './common'; + +import type { + BusinessPromotionDetailResponse, + BusinessPromotionListParams, + BusinessPromotionListResponse, +} from '../types/businessPromotion.type'; + +export async function getBusinessPromotions( + params: BusinessPromotionListParams = {} +): Promise { + // apiClient's response interceptor already unwraps the envelope and + // normalizes rejected errors, so no local try/catch is needed here. + const { data } = await apiClient.get( + '/business-promotions', + { params } + ); + + return data; +} + +export async function getBusinessPromotionDetail( + promotionId: number +): Promise { + const { data } = await apiClient.get( + `/business-promotions/${promotionId}` + ); + + return data; +} diff --git a/src/apis/common/apiClient.ts b/src/apis/common/apiClient.ts new file mode 100644 index 00000000..35c2e68b --- /dev/null +++ b/src/apis/common/apiClient.ts @@ -0,0 +1,141 @@ +import axios from 'axios'; +import type { AxiosError, InternalAxiosRequestConfig } from 'axios'; + +import { useAuthStore } from '../../store/auth.store'; +import type { LoginResult } from '../../types/auth.type'; + +import { normalizeApiError } from './apiError'; +import type { ApiResponse } from './apiTypes'; + +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_BASE_URL, + timeout: 10_000, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 인증 없이 호출되는 엔드포인트. 로그인 요청 등에 이전 세션의 토큰이 +// 그대로 붙어 나가는 것을 막기 위해 예외 처리한다. /auth/reissue도 +// 포함해야 만료된 accessToken이 재발급 요청 자체에 붙거나, 재발급 +// 요청의 401이 또 재발급을 트리거하는 무한 루프를 막을 수 있다. +// /auth/social-login, /auth/social-signup/complete, /auth/check-email, +// /auth/signup, /auth/email/send-code, /auth/email/verify-code도 로그인 +// 전(또는 temporaryToken 기반) 요청이라 이전 세션 토큰과 무관해야 한다. +// /auth/password/* (비밀번호 찾기)도 마찬가지로 로그인 전 흐름이다. +const AUTH_EXEMPT_PATHS = [ + '/auth/login', + '/auth/reissue', + '/auth/social-login', + '/auth/social-signup/complete', + '/auth/check-email', + '/auth/signup', + '/auth/email/send-code', + '/auth/email/verify-code', + '/auth/password/send-code', + '/auth/password/verify-code', + '/auth/password/reset', +]; + +interface RetryableRequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean; +} + +function isAuthExemptPath(url?: string): boolean { + if (!url) return false; + return AUTH_EXEMPT_PATHS.includes(url); +} + +apiClient.interceptors.request.use((config) => { + if (isAuthExemptPath(config.url)) { + config.headers.delete('Authorization'); + return config; + } + + const accessToken = useAuthStore.getState().accessToken; + + if (accessToken) { + config.headers.set('Authorization', `Bearer ${accessToken}`); + } else { + config.headers.delete('Authorization'); + } + + return config; +}); + +let reissuePromise: Promise | null = null; + +// 여러 요청이 동시에 401을 받아도 /auth/reissue는 한 번만 호출되도록, +// 진행 중인 재발급 Promise를 공유한다 (kakaoMap.ts의 sdkPromise와 동일한 패턴). +function reissueAccessToken(): Promise { + if (reissuePromise) { + return reissuePromise; + } + + const { refreshToken } = useAuthStore.getState(); + + reissuePromise = apiClient + .post('/auth/reissue', { refreshToken }) + .then(({ data }) => { + useAuthStore.getState().setTokens(data.accessToken, data.refreshToken); + return data.accessToken; + }) + .finally(() => { + reissuePromise = null; + }); + + return reissuePromise; +} + +// 서버 응답은 항상 { isSuccess, code, message, result } 형태로 감싸져 있다. +// +// - 성공(2xx) 응답만 여기서 result를 벗겨서 각 API 함수가 실제 데이터 타입을 +// 그대로 받도록 한다. +// - 실패 응답은 이 wrapper를 절대 벗기지 않는다. error.response.data(= +// { isSuccess: false, code, message })를 그대로 normalizeApiError에 넘겨서, +// 그 안에서 code/message를 읽어 NormalizedApiError로 변환한다. +apiClient.interceptors.response.use( + (response) => { + const body = response.data as ApiResponse; + + if (body && typeof body === 'object' && 'result' in body) { + response.data = body.result; + } + + return response; + }, + async (error: AxiosError) => { + const originalRequest = error.config as RetryableRequestConfig | undefined; + + const shouldReissue = + error.response?.status === 401 && + originalRequest && + !isAuthExemptPath(originalRequest.url) && + !originalRequest._retry; + + if (!shouldReissue) { + return Promise.reject(normalizeApiError(error)); + } + + originalRequest._retry = true; + + try { + await reissueAccessToken(); + } catch { + // refreshToken도 만료/무효(AUTH4013 등) — 로컬 세션을 정리하고 + // 로그인 페이지로 보낸다. 인터셉터는 컴포넌트 트리 밖이라 + // useNavigate를 쓸 수 없어 풀 리로드로 이동한다. + useAuthStore.getState().clearAuth(); + window.location.href = '/login'; + return Promise.reject(normalizeApiError(error)); + } + + // 재발급 자체는 성공했으므로, 재시도가 실패하더라도(재발급과 무관한 + // 별개 오류) 로그아웃 처리하지 않는다. 재시도도 이 인터셉터를 다시 + // 타므로, 실패 시 그 호출에서 이미 정규화되어 reject된다. + return apiClient(originalRequest); + } +); + +export default apiClient; diff --git a/src/apis/common/apiError.ts b/src/apis/common/apiError.ts new file mode 100644 index 00000000..5b14f1f5 --- /dev/null +++ b/src/apis/common/apiError.ts @@ -0,0 +1,117 @@ +import axios from 'axios'; + +import type { + ApiErrorResponse, + NormalizedApiError, +} from './apiTypes'; + +const NETWORK_ERROR_CODE = 'NETWORK_ERROR'; +const UNKNOWN_ERROR_CODE = 'UNKNOWN_ERROR'; +const UNKNOWN_ERROR_MESSAGE = '알 수 없는 오류가 발생했습니다.'; + +// 서버 응답이 아니라 클라이언트에서 만들어 낸 code들. 이 경우의 message는 +// axios/스크립트의 기술적인 문구라 사용자에게 그대로 보여줄 수 없다. +const CLIENT_ERROR_CODES = new Set([ + NETWORK_ERROR_CODE, + UNKNOWN_ERROR_CODE, + 'ERR_CANCELED', + 'ECONNABORTED', + 'ETIMEDOUT', +]); + +export function normalizeApiError(error: unknown): NormalizedApiError { + if (axios.isAxiosError(error)) { + const { response } = error; + + if (response) { + const responseCode = response.data?.code; + const responseMessage = response.data?.message; + + return { + code: + typeof responseCode === 'string' + ? responseCode + : `HTTP_${response.status}`, + message: + typeof responseMessage === 'string' + ? responseMessage + : error.message, + status: response.status, + }; + } + + if ( + error.code === 'ERR_CANCELED' || + error.code === 'ECONNABORTED' || + error.code === 'ETIMEDOUT' + ) { + return { + code: error.code, + message: error.message, + }; + } + + return { + code: NETWORK_ERROR_CODE, + message: error.message, + }; + } + + // throw data; 형태의 ApiErrorResponse 처리. + // + // apiClient 인터셉터가 이미 정규화한 에러가 각 API 함수의 catch를 거쳐 + // 다시 들어오는 경로이기도 하다. 그때 status를 떨어뜨리면 호출부에서 + // HTTP 상태로 분기할 수 없으므로 그대로 옮긴다. + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + 'message' in error && + typeof error.code === 'string' && + typeof error.message === 'string' + ) { + const status = + 'status' in error && typeof error.status === 'number' + ? error.status + : undefined; + + return { + code: error.code, + message: error.message, + ...(status === undefined ? {} : { status }), + }; + } + + if (error instanceof Error) { + return { + code: UNKNOWN_ERROR_CODE, + message: error.message, + }; + } + + return { + code: UNKNOWN_ERROR_CODE, + message: UNKNOWN_ERROR_MESSAGE, + }; +} + +/** + * 사용자에게 보여줄 에러 문구를 고른다. + * + * 서버가 내려준 message는 원인별로 다르게 안내되어 있으므로 그대로 쓰고, + * 네트워크 오류처럼 클라이언트가 만든 문구일 때만 fallback으로 대체한다. + */ +export function getApiErrorMessage( + error: unknown, + fallbackMessage: string, +): string { + const { code, message } = normalizeApiError(error); + + // HTTP_xxx는 서버가 응답했지만 본문에 code가 없던 경우라 message가 + // 'Request failed with status code 500' 같은 문구다. + if (CLIENT_ERROR_CODES.has(code) || code.startsWith('HTTP_')) { + return fallbackMessage; + } + + return message || fallbackMessage; +} \ No newline at end of file diff --git a/src/apis/common/apiTypes.ts b/src/apis/common/apiTypes.ts new file mode 100644 index 00000000..0f6c96cb --- /dev/null +++ b/src/apis/common/apiTypes.ts @@ -0,0 +1,28 @@ +interface ApiSuccessResponse { + isSuccess: true; + code: string; + message: string; + result: T; +} + +export interface ApiErrorResponse { + isSuccess: false; + code: string; + message: string; + result: null; +} + +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +export interface CursorResponse { + items: T[]; + cursorValue?: TCursorValue | null; + cursorId?: number | null; + hasNext: boolean; +} + +export interface NormalizedApiError { + code: string; + message: string; + status?: number; +} diff --git a/src/apis/common/index.ts b/src/apis/common/index.ts new file mode 100644 index 00000000..b3a5af5c --- /dev/null +++ b/src/apis/common/index.ts @@ -0,0 +1,8 @@ +export { default as apiClient } from './apiClient'; +export { getApiErrorMessage, normalizeApiError } from './apiError'; +export type { + ApiErrorResponse, + ApiResponse, + CursorResponse, + NormalizedApiError, +} from './apiTypes'; diff --git a/src/apis/contents.api.ts b/src/apis/contents.api.ts new file mode 100644 index 00000000..c165bdc6 --- /dev/null +++ b/src/apis/contents.api.ts @@ -0,0 +1,107 @@ +import { + apiClient, + normalizeApiError, +} from './common'; + +import type { + ContentCreateRequest, + ContentCreateResult, + CultureContentBanner, + CultureContentDetail, + GetCultureContentsParams, + GetCultureContentsResponse, + OngoingContent, +} from '../types/content.type'; + +export async function getOngoingContents(): Promise { + try { + const { data } = await apiClient.get('/contents/ongoing'); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function getCultureContents( + params: GetCultureContentsParams = {}, + signal?: AbortSignal, +): Promise { + try { + const { data } = await apiClient.get( + '/contents', + { params, signal }, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function getCultureContentDetail( + contentId: number, +): Promise { + try { + const { data } = await apiClient.get( + `/contents/${contentId}`, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function getCultureContentBanners(): Promise< + CultureContentBanner[] +> { + try { + const { data } = await apiClient.get( + '/contents/banner', + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function createCultureContent( + payload: ContentCreateRequest, +): Promise { + try { + const { data } = await apiClient.post( + '/contents', + payload, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function updateCultureContent( + contentId: number, + payload: ContentCreateRequest, +): Promise { + try { + const { data } = await apiClient.patch( + `/contents/${contentId}`, + payload, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function deleteCultureContent(contentId: number): Promise { + try { + await apiClient.delete(`/contents/${contentId}`); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/apis/courses.api.ts b/src/apis/courses.api.ts new file mode 100644 index 00000000..d9a08949 --- /dev/null +++ b/src/apis/courses.api.ts @@ -0,0 +1,49 @@ +import { apiClient, normalizeApiError } from './common'; + +import type { + Course, + GetCoursesParams, + GetCoursesResponse, + GetPopularCoursesParams, + RecommendedCourse, +} from '../types/course.type'; + +export async function getCourses( + params: GetCoursesParams, +): Promise { + try { + const { data } = await apiClient.get('/courses', { + params, + }); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function getPopularCourses( + params: GetPopularCoursesParams, +): Promise { + try { + const { data } = await apiClient.get('/courses/popular', { + params, + }); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function getRecommendedCourses(): Promise { + try { + const { data } = await apiClient.get( + '/courses/recommended', + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/apis/courses.ts b/src/apis/courses.ts new file mode 100644 index 00000000..cfc922f5 --- /dev/null +++ b/src/apis/courses.ts @@ -0,0 +1,189 @@ +import { apiClient, normalizeApiError } from './common'; + +export type CourseDetailItem = + | { + order: number; + type: 'PLACE'; + placeId: number; + isLiked: boolean; + source: string; + externalPlaceId: string; + name: string; + roadAddress: string; + lotAddress: string; + latitude: number; + longitude: number; + imageUrl: string; + imageKey?: string; + } + | { + order: number; + type: 'CONTENT'; + contentId: number; + isLiked: boolean; + contentStatus: string; + source: string; + externalPlaceId: string; + name: string; + roadAddress: string; + lotAddress: string; + latitude: number; + longitude: number; + imageUrl: string; + }; + +export interface CourseDetailAuthor { + nickname: string; + profileImageUrl: string | null; +} + +export interface CourseDetailResult { + courseId: number; + courseType: string; + title: string; + thumbnailUrl: string; + description: string; + tags: string[]; + durationType: string; + transportType: string; + startMonth: number; + endMonth: number; + companionType: string; + isLiked: boolean; + courseItems: CourseDetailItem[]; + author?: CourseDetailAuthor; +} + +export interface CourseLikeResult { + isLiked: boolean; + likeCount: number; +} + +export async function getCourseDetail( + courseId: number +): Promise { + const { data } = await apiClient.get( + `/courses/${courseId}` + ); + + return data; +} + +export async function deleteCourse(courseId: number): Promise { + try { + await apiClient.delete(`/courses/${courseId}`); + } catch (error) { + throw normalizeApiError(error); + } +} + +export type UpdateCourseItem = + | { + order: number; + type: 'PLACE'; + externalPlaceId: string; + /** 라이브 스펙에서 PLACE의 선택 필드 — 모르면 아예 보내지 않는다(빈 문자열 금지). */ + categoryGroupCode?: string; + name: string; + roadAddress: string; + lotAddress: string; + latitude: number; + longitude: number; + imageKey: string | null; + } + | { order: number; type: 'CONTENT'; contentId: number }; + +// regionId는 여기 없다 — 라이브 스펙(CourseUpdateRequest)에 아예 필드가 +// 없어 지역은 수정 대상이 아니다. +export interface UpdateCourseRequest { + title: string; + description: string; + durationType: 'DAY_TRIP' | 'ONE_NIGHT' | 'TWO_NIGHT' | 'THREE_PLUS'; + transportType: 'WALK' | 'PUBLIC' | 'CAR'; + companionType: 'SOLO' | 'FRIEND' | 'COUPLE' | 'FAMILY' | 'PET'; + monthStart: number; + monthEnd: number; + thumbnailKey: string; + hashtagIds: number[]; + courseItems: UpdateCourseItem[]; +} + +export interface UpdateCourseResult { + courseId: number; +} + +export async function updateCourse( + courseId: number, + payload: UpdateCourseRequest +): Promise { + try { + const { data } = await apiClient.patch( + `/courses/${courseId}`, + payload + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +// 코스·문화콘텐츠·장소 좋아요 등록은 모두 PUT이다. 여러 번 눌러도 같은 +// 결과가 되도록 백엔드가 바꿨다. +export async function addCourseLike( + courseId: number +): Promise { + const { data } = await apiClient.put( + `/courses/${courseId}/likes` + ); + + return data; +} + +export async function removeCourseLike( + courseId: number +): Promise { + const { data } = await apiClient.delete( + `/courses/${courseId}/likes` + ); + + return data; +} + +export async function addPlaceLike(placeId: number): Promise { + const { data } = await apiClient.put( + `/places/${placeId}/likes` + ); + + return data; +} + +export async function removePlaceLike( + placeId: number +): Promise { + const { data } = await apiClient.delete( + `/places/${placeId}/likes` + ); + + return data; +} + +export async function addContentLike( + contentId: number +): Promise { + const { data } = await apiClient.put( + `/contents/${contentId}/likes` + ); + + return data; +} + +export async function removeContentLike( + contentId: number +): Promise { + const { data } = await apiClient.delete( + `/contents/${contentId}/likes` + ); + + return data; +} diff --git a/src/apis/files.api.ts b/src/apis/files.api.ts new file mode 100644 index 00000000..610d18cb --- /dev/null +++ b/src/apis/files.api.ts @@ -0,0 +1,43 @@ +import { normalizeApiError } from './common'; +import { apiClient } from './common'; + +import type { + PresignedUrlRequest, + PresignedUrlResponse, +} from '../types/file.type'; + +export async function createPresignedUrl( + request: PresignedUrlRequest, +): Promise { + try { + const { data } = await apiClient.post( + '/files/presigned-url', + request, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function uploadFileToPresignedUrl( + uploadUrl: string, + file: File, + // presigned URL 서명에 content-type이 포함돼 있어, 발급 요청에 보낸 값과 + // 여기서 보내는 값이 다르면 S3가 서명 불일치로 거부한다. 클립보드에서 온 + // File처럼 type이 비어 있을 수 있는 경우 호출부가 값을 고정해서 넘긴다. + contentType?: string, +): Promise { + const response = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': contentType || file.type || 'application/octet-stream', + }, + body: file, + }); + + if (!response.ok) { + throw new Error(`Failed to upload file: ${response.status}`); + } +} diff --git a/src/apis/files.ts b/src/apis/files.ts new file mode 100644 index 00000000..329ecdfe --- /dev/null +++ b/src/apis/files.ts @@ -0,0 +1,100 @@ +import { apiClient } from './common'; + +export type FileDirectory = 'COURSE'; + +export interface PresignedUrlRequest { + directory: FileDirectory; + fileName: string; + contentType: string; +} + +export interface PresignedUrlResult { + uploadUrl: string; + objectKey: string; +} + +export async function getPresignedUrl( + payload: PresignedUrlRequest +): Promise { + // apiClient's response interceptor already unwraps the { isSuccess, + // result } envelope and rejects with a NormalizedApiError on failure. + const { data } = await apiClient.post( + '/files/presigned-url', + payload + ); + + return data; +} + +async function putFileToPresignedUrl( + uploadUrl: string, + file: File +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30_000); + + try { + const response = await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error('이미지 업로드에 실패했습니다.'); + } + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error( + '이미지 업로드 시간이 초과되었습니다. 네트워크를 확인한 뒤 다시 시도해 주세요.', + { + cause: error, + } + ); + } + if ( + error instanceof Error && + error.message === '이미지 업로드에 실패했습니다.' + ) { + throw error; + } + throw new Error('이미지 업로드에 실패했습니다.', { cause: error }); + } finally { + clearTimeout(timeoutId); + } +} + +const createUniqueFileName = (fileName: string) => { + const suffix = + typeof globalThis.crypto?.randomUUID === 'function' + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + + return `${suffix}-${fileName}`; +}; + +export async function uploadCourseImage(file: File): Promise { + // 모바일 브라우저의 카메라 촬영 입력은 매 촬영마다 동일한 파일명 + // (예: image.jpg)을 주는 경우가 많다. presigned URL 요청의 fileName이 + // 곧 저장 키로 이어질 수 있어, 같은 등록 요청 안의 여러 업로드가 같은 + // 파일명을 갖더라도 서로 다른 objectKey를 받도록 항상 유일한 파일명을 + // 붙여 보낸다. + const { uploadUrl, objectKey } = await getPresignedUrl({ + directory: 'COURSE', + fileName: createUniqueFileName(file.name), + contentType: file.type, + }); + + await putFileToPresignedUrl(uploadUrl, file); + + return objectKey; +} + +// Files are compressed when users select them. Upload all prepared files in +// parallel and retain input ordering in the returned object keys. +export async function uploadCourseImages( + files: readonly File[] +): Promise { + return Promise.all(files.map(uploadCourseImage)); +} diff --git a/src/apis/googlePlacesHours.ts b/src/apis/googlePlacesHours.ts new file mode 100644 index 00000000..f1b57abc --- /dev/null +++ b/src/apis/googlePlacesHours.ts @@ -0,0 +1,31 @@ +export interface PlaceHoursLookup { + readonly name: string; + readonly address?: string; + readonly latitude?: number; + readonly longitude?: number; +} +export interface PlaceHours { + readonly currentWeekdayDescriptions: readonly string[]; + readonly regularWeekdayDescriptions: readonly string[]; + readonly openNow?: boolean; + readonly nextOpenTime?: string; + readonly nextCloseTime?: string; +} + +export async function getPlaceHours( + lookup: PlaceHoursLookup, + signal?: AbortSignal +): Promise { + const response = await fetch('/google-places/hours', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(lookup), + signal, + }); + + if (!response.ok) { + throw new Error('Failed to retrieve place opening hours.'); + } + + return (await response.json()) as PlaceHours | null; +} diff --git a/src/apis/googlePlacesPhoto.ts b/src/apis/googlePlacesPhoto.ts new file mode 100644 index 00000000..5187a432 --- /dev/null +++ b/src/apis/googlePlacesPhoto.ts @@ -0,0 +1,27 @@ +export interface PlacePhotoLookup { + readonly name: string; + readonly address?: string; + readonly latitude?: number; + readonly longitude?: number; +} +export interface PlacePhoto { + readonly photoUri: string; +} + +export async function getPlacePhoto( + lookup: PlacePhotoLookup, + signal?: AbortSignal +): Promise { + const response = await fetch('/google-places/photo', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(lookup), + signal, + }); + + if (!response.ok) { + throw new Error('Failed to retrieve place photo.'); + } + + return (await response.json()) as PlacePhoto | null; +} diff --git a/src/apis/hashtags.ts b/src/apis/hashtags.ts new file mode 100644 index 00000000..2c7a7bdf --- /dev/null +++ b/src/apis/hashtags.ts @@ -0,0 +1,13 @@ +import { apiClient } from './common'; + +export interface Hashtag { + id: number; + name: string; +} + +export async function fetchHashtags(): Promise { + // apiClient's response interceptor already unwraps the envelope. + const { data } = await apiClient.get('/hashtags'); + + return data; +} diff --git a/src/apis/kakaoWalkingRoute.ts b/src/apis/kakaoWalkingRoute.ts new file mode 100644 index 00000000..4a3a09cf --- /dev/null +++ b/src/apis/kakaoWalkingRoute.ts @@ -0,0 +1,110 @@ +import type { GeoPoint } from '../components/kakaomap/types'; + +const KAKAO_WALKING_ROUTE_URL = '/kakao-routing/walk'; +const MAX_WAYPOINTS = 5; +const MAX_POINTS_PER_REQUEST = MAX_WAYPOINTS + 2; + +interface KakaoWalkingRouteResponse { + readonly status: string; + readonly route?: { + readonly legs?: readonly { + readonly steps?: readonly { + readonly path?: { + readonly points?: readonly (readonly [number, number])[]; + }; + }[]; + }[]; + }; +} + +function splitIntoRouteRequests(points: readonly GeoPoint[]) { + const requests: GeoPoint[][] = []; + + for (let start = 0; start < points.length - 1;) { + const requestPoints = points.slice(start, start + MAX_POINTS_PER_REQUEST); + requests.push(requestPoints); + start += requestPoints.length - 1; + } + + return requests; +} + +function toWalkingRoutePoints(response: KakaoWalkingRouteResponse): GeoPoint[] { + if (response.status !== 'OK' || !response.route?.legs) { + throw new Error( + `Kakao walking route request failed with status: ${response.status}` + ); + } + + return response.route.legs.flatMap((leg) => + (leg.steps ?? []).flatMap((step) => + (step.path?.points ?? []).map(([longitude, latitude]) => ({ + latitude, + longitude, + })) + ) + ); +} + +function removeAdjacentDuplicates(points: readonly GeoPoint[]): GeoPoint[] { + return points.filter( + (point, index) => + index === 0 || + point.latitude !== points[index - 1].latitude || + point.longitude !== points[index - 1].longitude + ); +} + +async function requestWalkingRoute( + points: readonly GeoPoint[], + signal?: AbortSignal +): Promise { + const [start, ...remainingPoints] = points; + const end = remainingPoints.at(-1); + + if (!start || !end) { + return []; + } + + const viaPoints = remainingPoints.slice(0, -1); + const params = new URLSearchParams({ + start_x: String(start.longitude), + start_y: String(start.latitude), + end_x: String(end.longitude), + end_y: String(end.latitude), + }); + + if (viaPoints.length > 0) { + params.set('via_x', viaPoints.map((point) => point.longitude).join(',')); + params.set('via_y', viaPoints.map((point) => point.latitude).join(',')); + } + + const response = await fetch(`${KAKAO_WALKING_ROUTE_URL}?${params}`, { + signal, + }); + + if (!response.ok) { + throw new Error('Failed to request Kakao walking route.'); + } + + return toWalkingRoutePoints( + (await response.json()) as KakaoWalkingRouteResponse + ); +} + +export async function fetchKakaoWalkingRoute( + points: readonly GeoPoint[], + signal?: AbortSignal +): Promise { + if (points.length < 2) { + return []; + } + + const routeSegments = await Promise.all( + splitIntoRouteRequests(points).map((requestPoints) => + requestWalkingRoute(requestPoints, signal) + ) + ); + + return removeAdjacentDuplicates(routeSegments.flat()); +} diff --git a/src/apis/likes.api.ts b/src/apis/likes.api.ts new file mode 100644 index 00000000..ac23661e --- /dev/null +++ b/src/apis/likes.api.ts @@ -0,0 +1,114 @@ +import { apiClient, normalizeApiError } from './common'; + +import type { + LikedItemCategory, + LikedItemQueryCategory, +} from '../pages/likes/types'; + +export interface LikedItemsParams { + category: LikedItemQueryCategory; + keyword?: string; + sort?: 'LATEST' | 'OLDEST'; + cursorCreatedAt?: string; + cursorId?: number; + size?: number; + latitude?: number; + longitude?: number; +} + +/** 서버 쿼리 파라미터 표기. EVENT는 서버에서 CONTENT로 받는다. */ +const LIKED_ITEM_QUERY_CATEGORY_PARAM: Record = + { + ALL: 'ALL', + COURSE: 'COURSE', + EVENT: 'CONTENT', + PLACE: 'PLACE', + }; + +/** 서버 응답 category 표기. CONTENT는 앱에서 EVENT로 다룬다. */ +type LikedItemResponseCategory = 'COURSE' | 'CONTENT' | 'PLACE'; + +const LIKED_ITEM_RESPONSE_CATEGORY: Record< + LikedItemResponseCategory, + LikedItemCategory +> = { + COURSE: 'COURSE', + CONTENT: 'EVENT', + PLACE: 'PLACE', +}; + +interface RawLikedItemResponse { + id: number; + category: LikedItemResponseCategory; + title: string; + externalPlaceId?: string; + thumbnailImage: string | null; + duration: string | null; + startDate: string | null; + endDate: string | null; + location: string; + transportType?: string | null; + companionType?: string | null; + distance: number | null; + hashtags: string[]; + likedAt: string; +} + +interface RawGetLikedItemsResponse { + items: RawLikedItemResponse[]; + cursorValue: string | null; + cursorId: number | null; + hasNext: boolean; +} + +export interface LikedItemResponse { + id: number; + category: LikedItemCategory; + title: string; + externalPlaceId?: string; + thumbnailImage: string | null; + duration: string | null; + startDate: string | null; + endDate: string | null; + location: string; + transportType?: string | null; + companionType?: string | null; + distance: number | null; + hashtags: string[]; + likedAt: string; +} + +export interface GetLikedItemsResponse { + items: LikedItemResponse[]; + cursorValue: string | null; + cursorId: number | null; + hasNext: boolean; +} + +export async function getLikedItems( + params: LikedItemsParams, + signal?: AbortSignal +): Promise { + try { + const { data } = await apiClient.get( + '/users/me/likes', + { + params: { + ...params, + category: LIKED_ITEM_QUERY_CATEGORY_PARAM[params.category], + }, + signal, + } + ); + + return { + ...data, + items: data.items.map((item) => ({ + ...item, + category: LIKED_ITEM_RESPONSE_CATEGORY[item.category], + })), + }; + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/apis/localRecommendations.ts b/src/apis/localRecommendations.ts new file mode 100644 index 00000000..fcc7491e --- /dev/null +++ b/src/apis/localRecommendations.ts @@ -0,0 +1,67 @@ +import { apiClient } from './common'; + +export type CourseItem = + | { + order: number; + type: 'PLACE'; + externalPlaceId: string; + /** 라이브 스펙에서 PLACE의 선택 필드 — 모르면 아예 보내지 않는다(빈 문자열 금지). */ + categoryGroupCode?: string; + name: string; + roadAddress: string; + lotAddress: string; + latitude: number; + longitude: number; + imageKey: string | null; + } + | { order: number; type: 'CONTENT'; contentId: number }; + +export interface CreateLocalRecommendationRequest { + title: string; + regionId: number; + description: string; + durationType: + | 'DAY_TRIP' + | 'ONE_NIGHT' + | 'TWO_NIGHT' + | 'THREE_PLUS'; + transportType: 'WALK' | 'PUBLIC' | 'CAR'; + companionType: 'SOLO' | 'FRIEND' | 'COUPLE' | 'FAMILY' | 'PET'; + monthStart: number; + monthEnd: number; + thumbnailKey: string; + hashtagIds: number[]; + courseItems: CourseItem[]; +} + +export interface CreateLocalRecommendationResult { + courseId: number; +} + +interface CourseClient { + post( + path: string, + payload: CreateLocalRecommendationRequest + ): Promise<{ data: Result }>; +} + +// apiClient's response interceptor already unwraps the { isSuccess, result } +// envelope on success and rejects with a NormalizedApiError on failure, so +// this just passes the (already-unwrapped) response data through. +export async function createLocalRecommendationWithClient( + client: CourseClient, + payload: CreateLocalRecommendationRequest +): Promise { + const response = await client.post('/courses', payload); + + return response.data; +} + +export async function createLocalRecommendation( + payload: CreateLocalRecommendationRequest +): Promise { + return createLocalRecommendationWithClient( + apiClient, + payload + ); +} diff --git a/src/apis/regions.api.ts b/src/apis/regions.api.ts new file mode 100644 index 00000000..6b745699 --- /dev/null +++ b/src/apis/regions.api.ts @@ -0,0 +1,69 @@ +import { apiClient } from './common'; + +import type { + GetRegionsResponse, + GetSubRegionsResponse, + PopularRegionResponse, + RegionDetailResponse, + RegionSearchResponse, + SubRegion, +} from '../types/region.type'; + +// apiClient's response interceptor already unwraps the { isSuccess, result } +// envelope and rejects with a NormalizedApiError on failure. +export async function getRegions(): Promise { + const { data } = await apiClient.get('/regions'); + + return data; +} + +export async function getRegion( + regionId: number, +): Promise { + const { data } = await apiClient.get( + `/regions/${regionId}`, + ); + + return data; +} + +export async function getSubRegions(regionId: number): Promise { + try { + const { data } = await apiClient.get( + `/regions/${regionId}/sub-regions`, + ); + + return data.subRegions; + } catch (error) { + // A leaf region has no sub-regions; the backend represents that as 404. + if ( + error && + typeof error === 'object' && + (error as { status?: number; code?: string }).status === 404 && + (error as { status?: number; code?: string }).code === 'REGION4041' + ) { + return []; + } + + throw error; + } +} + +export async function searchRegions( + keyword: string, +): Promise { + const { data } = await apiClient.get( + '/regions/search', + { params: { keyword } }, + ); + + return data; +} + +export async function getPopularRegions(): Promise { + const { data } = await apiClient.get( + '/regions/popular', + ); + + return data; +} diff --git a/src/apis/reviews.api.ts b/src/apis/reviews.api.ts new file mode 100644 index 00000000..30fb9e0d --- /dev/null +++ b/src/apis/reviews.api.ts @@ -0,0 +1,90 @@ +import { apiClient, normalizeApiError } from './common'; + +import type { + CreateCourseReviewRequest, + CreateCourseReviewResponse, + GetCourseReviewsParams, + GetCourseReviewsResponse, + GetReviewsParams, + GetReviewsResponse, + UpdateReviewRequest, + UpdateReviewResponse, +} from '../types/review.type'; + +export async function getReviews( + params: GetReviewsParams = {}, +): Promise { + try { + const { data } = await apiClient.get('/reviews', { + params, + }); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +/** + * 코스별 리뷰 목록. + * + * 전체 후기 목록(GET /reviews)이 cursor 하나만 받는 것과 달리, 여기는 + * cursorValue와 cursorId를 함께 받는다. RATING 정렬에서 별점이 같은 리뷰가 + * 이어지도록 reviewId를 보조 기준으로 쓰기 때문이다. + */ +export async function getCourseReviews( + courseId: number, + params: GetCourseReviewsParams = {}, +): Promise { + try { + const { data } = await apiClient.get( + `/courses/${courseId}/reviews`, + { params }, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function createCourseReview( + courseId: number, + request: CreateCourseReviewRequest, +): Promise { + try { + const { data } = await apiClient.post( + `/courses/${courseId}/reviews`, + request, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +/** 수정 화면이 아직 없다. 사유는 types/review.type.ts 참고. */ +export async function updateReview( + reviewId: number, + request: UpdateReviewRequest, +): Promise { + try { + const { data } = await apiClient.patch( + `/reviews/${reviewId}`, + request, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function deleteReview(reviewId: number): Promise { + try { + await apiClient.delete(`/reviews/${reviewId}`); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/apis/stickers.api.ts b/src/apis/stickers.api.ts new file mode 100644 index 00000000..212a45a3 --- /dev/null +++ b/src/apis/stickers.api.ts @@ -0,0 +1,40 @@ +import { apiClient, normalizeApiError } from './common'; + +import type { + CustomStickerCreateRequest, + CustomStickerCreateResponse, + StickerListResponse, +} from '../types/sticker.type'; + +export async function getStickers(): Promise { + try { + const { data } = await apiClient.get('/stickers'); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function createCustomSticker( + request: CustomStickerCreateRequest, +): Promise { + try { + const { data } = await apiClient.post( + '/stickers', + request, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function deleteCustomSticker(stickerId: number): Promise { + try { + await apiClient.delete(`/stickers/${stickerId}`); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/apis/travelRecords.api.ts b/src/apis/travelRecords.api.ts new file mode 100644 index 00000000..777eb05f --- /dev/null +++ b/src/apis/travelRecords.api.ts @@ -0,0 +1,98 @@ +import { + apiClient, + normalizeApiError, +} from './common'; +import { deleteTravelRecordWithClient } from './travelRecordsClient.ts'; + +import type { + TravelRecordCreateRequest, + TravelRecordCreateResponse, + TravelRecordDetailResponse, + TravelRecordListParams, + TravelRecordListResponse, + TravelRecordUpdateRequest, + TravelRecordUpdateResponse, + TravelRecordYearListResponse, +} from '../types/travelRecord.type'; + +export async function getTravelRecords( + params: TravelRecordListParams = {}, +): Promise { + try { + const { data } = await apiClient.get( + '/travel-records', + { params }, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function getTravelRecordYears(): Promise { + try { + const { data } = await apiClient.get( + '/travel-records/years', + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function getTravelRecordDetail( + travelRecordId: number, +): Promise { + try { + const { data } = await apiClient.get( + `/travel-records/${travelRecordId}`, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function createTravelRecord( + request: TravelRecordCreateRequest, +): Promise { + try { + const { data } = await apiClient.post( + '/travel-records', + request, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function updateTravelRecord( + travelRecordId: number, + request: TravelRecordUpdateRequest, +): Promise { + try { + const { data } = await apiClient.patch( + `/travel-records/${travelRecordId}`, + request, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function deleteTravelRecordById( + travelRecordId: number, +): Promise { + try { + await deleteTravelRecordWithClient(apiClient, travelRecordId); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/apis/travelRecordsClient.ts b/src/apis/travelRecordsClient.ts new file mode 100644 index 00000000..6ca3eb5e --- /dev/null +++ b/src/apis/travelRecordsClient.ts @@ -0,0 +1,15 @@ +// Kept separate from travelRecords.api.ts: apiClient.ts reads +// import.meta.env at module load time, which the plain Node test +// runner (npm test) can't provide. Importing anything from +// travelRecords.api.ts pulls in that whole chain via ./common, so this +// file stays free of that import to keep the delete call unit-testable. +interface TravelRecordDeleteClient { + delete(path: string): Promise<{ data: Result }>; +} + +export async function deleteTravelRecordWithClient( + client: TravelRecordDeleteClient, + travelRecordId: number, +): Promise { + await client.delete(`/travel-records/${travelRecordId}`); +} diff --git a/src/apis/users.api.ts b/src/apis/users.api.ts new file mode 100644 index 00000000..1534eaac --- /dev/null +++ b/src/apis/users.api.ts @@ -0,0 +1,95 @@ +import { apiClient, normalizeApiError } from './common'; + +import type { + BusinessInfoResponse, + BusinessVerifyRequest, + BusinessVerifyResult, +} from '../types/business.type'; +import type { + GetMyPostsParams, + GetMyPostsResponse, + UpdateMyProfileRequest, + UpdateMyProfileResponse, + UserProfileResponse, +} from '../types/user.type'; + +export async function getMyProfile(): Promise { + try { + const { data } = await apiClient.get('/users/me'); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function getMyPosts( + params: GetMyPostsParams = {}, +): Promise { + try { + const { data } = await apiClient.get( + '/users/me/posts', + { params }, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function updateMyProfile( + payload: UpdateMyProfileRequest, +): Promise { + try { + const { data } = await apiClient.patch( + '/users/me', + payload, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +// 백엔드가 국세청 오픈 API로 사업자 상태를 검증하고, 성공하면 유저 권한을 +// BUSINESS로 승급한다. 국세청 연동 결과는 BUSINESS_VERIFY4002(정보 불일치), +// BUSINESS_VERIFY4041(폐업/미존재), BUSINESS_VERIFY5001(연동 오류)로 돌아온다. +export async function verifyBusiness( + request: BusinessVerifyRequest, +): Promise { + try { + const { data } = await apiClient.post( + '/users/business-verify', + request, + ); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +export async function deleteMyAccount(): Promise { + try { + const { data } = await apiClient.delete('/users/me'); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} + +// 인증 상태가 APPROVED인 사업장만, businessInfoId 최신순으로 돌아온다. +// 커서 봉투 없이 배열 그대로이며, 인증 사업장이 없으면 빈 배열이다(404 아님). +export async function getMyBusinesses(): Promise { + try { + const { data } = + await apiClient.get('/users/me/businesses'); + + return data; + } catch (error) { + throw normalizeApiError(error); + } +} \ No newline at end of file diff --git a/src/assets/badges/calendar.svg b/src/assets/badges/calendar.svg new file mode 100644 index 00000000..ca09d086 --- /dev/null +++ b/src/assets/badges/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/badges/car.svg b/src/assets/badges/car.svg new file mode 100644 index 00000000..e984d9b2 --- /dev/null +++ b/src/assets/badges/car.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/badges/child.svg b/src/assets/badges/child.svg new file mode 100644 index 00000000..aedb5c9c --- /dev/null +++ b/src/assets/badges/child.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/badges/favorite.svg b/src/assets/badges/favorite.svg new file mode 100644 index 00000000..3bcdf89f --- /dev/null +++ b/src/assets/badges/favorite.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/badges/group.svg b/src/assets/badges/group.svg new file mode 100644 index 00000000..88f04c3b --- /dev/null +++ b/src/assets/badges/group.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/badges/people.svg b/src/assets/badges/people.svg new file mode 100644 index 00000000..fcaf5fba --- /dev/null +++ b/src/assets/badges/people.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/badges/solo.svg b/src/assets/badges/solo.svg new file mode 100644 index 00000000..ffba82be --- /dev/null +++ b/src/assets/badges/solo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/badges/walk.svg b/src/assets/badges/walk.svg new file mode 100644 index 00000000..bacb97c2 --- /dev/null +++ b/src/assets/badges/walk.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/card-tag-chip/inactive/autumn.svg b/src/assets/card-tag-chip/inactive/autumn.svg new file mode 100644 index 00000000..b01aba4e --- /dev/null +++ b/src/assets/card-tag-chip/inactive/autumn.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/bakery.svg b/src/assets/card-tag-chip/inactive/bakery.svg new file mode 100644 index 00000000..98457cd9 --- /dev/null +++ b/src/assets/card-tag-chip/inactive/bakery.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/cafe.svg b/src/assets/card-tag-chip/inactive/cafe.svg new file mode 100644 index 00000000..a4e38dff --- /dev/null +++ b/src/assets/card-tag-chip/inactive/cafe.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/event.svg b/src/assets/card-tag-chip/inactive/event.svg new file mode 100644 index 00000000..efb6e89a --- /dev/null +++ b/src/assets/card-tag-chip/inactive/event.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/experience.svg b/src/assets/card-tag-chip/inactive/experience.svg new file mode 100644 index 00000000..112b451b --- /dev/null +++ b/src/assets/card-tag-chip/inactive/experience.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/local-attraction.svg b/src/assets/card-tag-chip/inactive/local-attraction.svg new file mode 100644 index 00000000..88d126f0 --- /dev/null +++ b/src/assets/card-tag-chip/inactive/local-attraction.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/mountain.svg b/src/assets/card-tag-chip/inactive/mountain.svg new file mode 100644 index 00000000..8e00f7dd --- /dev/null +++ b/src/assets/card-tag-chip/inactive/mountain.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/nature.svg b/src/assets/card-tag-chip/inactive/nature.svg new file mode 100644 index 00000000..c33aeb55 --- /dev/null +++ b/src/assets/card-tag-chip/inactive/nature.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/restaurant.svg b/src/assets/card-tag-chip/inactive/restaurant.svg new file mode 100644 index 00000000..c3953df6 --- /dev/null +++ b/src/assets/card-tag-chip/inactive/restaurant.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/sea.svg b/src/assets/card-tag-chip/inactive/sea.svg new file mode 100644 index 00000000..9a2d580a --- /dev/null +++ b/src/assets/card-tag-chip/inactive/sea.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/spring.svg b/src/assets/card-tag-chip/inactive/spring.svg new file mode 100644 index 00000000..b7607849 --- /dev/null +++ b/src/assets/card-tag-chip/inactive/spring.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/inactive/summer.svg b/src/assets/card-tag-chip/inactive/summer.svg new file mode 100644 index 00000000..ebb93d39 --- /dev/null +++ b/src/assets/card-tag-chip/inactive/summer.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/card-tag-chip/inactive/winter.svg b/src/assets/card-tag-chip/inactive/winter.svg new file mode 100644 index 00000000..5fe60bc3 --- /dev/null +++ b/src/assets/card-tag-chip/inactive/winter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/autumn.svg b/src/assets/card-tag-chip/selected/autumn.svg new file mode 100644 index 00000000..3e4e6cc5 --- /dev/null +++ b/src/assets/card-tag-chip/selected/autumn.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/bakery.svg b/src/assets/card-tag-chip/selected/bakery.svg new file mode 100644 index 00000000..86e67d3c --- /dev/null +++ b/src/assets/card-tag-chip/selected/bakery.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/cafe.svg b/src/assets/card-tag-chip/selected/cafe.svg new file mode 100644 index 00000000..d4d7cbfc --- /dev/null +++ b/src/assets/card-tag-chip/selected/cafe.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/drink.svg b/src/assets/card-tag-chip/selected/drink.svg new file mode 100644 index 00000000..3a6a5923 --- /dev/null +++ b/src/assets/card-tag-chip/selected/drink.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/event.svg b/src/assets/card-tag-chip/selected/event.svg new file mode 100644 index 00000000..59e25ddc --- /dev/null +++ b/src/assets/card-tag-chip/selected/event.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/experience.svg b/src/assets/card-tag-chip/selected/experience.svg new file mode 100644 index 00000000..922831dd --- /dev/null +++ b/src/assets/card-tag-chip/selected/experience.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/game.svg b/src/assets/card-tag-chip/selected/game.svg new file mode 100644 index 00000000..2871ceab --- /dev/null +++ b/src/assets/card-tag-chip/selected/game.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/local-attraction.svg b/src/assets/card-tag-chip/selected/local-attraction.svg new file mode 100644 index 00000000..9ef2826f --- /dev/null +++ b/src/assets/card-tag-chip/selected/local-attraction.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/mountain.svg b/src/assets/card-tag-chip/selected/mountain.svg new file mode 100644 index 00000000..c85a1461 --- /dev/null +++ b/src/assets/card-tag-chip/selected/mountain.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/music.svg b/src/assets/card-tag-chip/selected/music.svg new file mode 100644 index 00000000..1f18abaf --- /dev/null +++ b/src/assets/card-tag-chip/selected/music.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/nature.svg b/src/assets/card-tag-chip/selected/nature.svg new file mode 100644 index 00000000..50d4126b --- /dev/null +++ b/src/assets/card-tag-chip/selected/nature.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/restaurant.svg b/src/assets/card-tag-chip/selected/restaurant.svg new file mode 100644 index 00000000..ced57e34 --- /dev/null +++ b/src/assets/card-tag-chip/selected/restaurant.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/sea.svg b/src/assets/card-tag-chip/selected/sea.svg new file mode 100644 index 00000000..5e72e0a6 --- /dev/null +++ b/src/assets/card-tag-chip/selected/sea.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/sport.svg b/src/assets/card-tag-chip/selected/sport.svg new file mode 100644 index 00000000..85af988a --- /dev/null +++ b/src/assets/card-tag-chip/selected/sport.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/spring.svg b/src/assets/card-tag-chip/selected/spring.svg new file mode 100644 index 00000000..b1723c7d --- /dev/null +++ b/src/assets/card-tag-chip/selected/spring.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/card-tag-chip/selected/summer.svg b/src/assets/card-tag-chip/selected/summer.svg new file mode 100644 index 00000000..6e499093 --- /dev/null +++ b/src/assets/card-tag-chip/selected/summer.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/card-tag-chip/selected/winter.svg b/src/assets/card-tag-chip/selected/winter.svg new file mode 100644 index 00000000..388d39a0 --- /dev/null +++ b/src/assets/card-tag-chip/selected/winter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/category-tag-chip/cardview.svg b/src/assets/category-tag-chip/cardview.svg new file mode 100644 index 00000000..976600f7 --- /dev/null +++ b/src/assets/category-tag-chip/cardview.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/category-tag-chip/gridview.svg b/src/assets/category-tag-chip/gridview.svg new file mode 100644 index 00000000..10ffd9cf --- /dev/null +++ b/src/assets/category-tag-chip/gridview.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/fonts/.gitkeep b/src/assets/fonts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/assets/icons/back.svg b/src/assets/icons/back.svg new file mode 100644 index 00000000..7c5f27ce --- /dev/null +++ b/src/assets/icons/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/cake.svg b/src/assets/icons/cake.svg new file mode 100644 index 00000000..d4e0e6d0 --- /dev/null +++ b/src/assets/icons/cake.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/calendar.svg b/src/assets/icons/calendar.svg new file mode 100644 index 00000000..17d70323 --- /dev/null +++ b/src/assets/icons/calendar.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/call.svg b/src/assets/icons/call.svg new file mode 100644 index 00000000..dbe2c647 --- /dev/null +++ b/src/assets/icons/call.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/camera.svg b/src/assets/icons/camera.svg new file mode 100644 index 00000000..a2827473 --- /dev/null +++ b/src/assets/icons/camera.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/chevron-right.svg b/src/assets/icons/chevron-right.svg new file mode 100644 index 00000000..8db73890 --- /dev/null +++ b/src/assets/icons/chevron-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/close-rounded.svg b/src/assets/icons/close-rounded.svg new file mode 100644 index 00000000..6aab6c70 --- /dev/null +++ b/src/assets/icons/close-rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/close.svg b/src/assets/icons/close.svg new file mode 100644 index 00000000..c235cfe3 --- /dev/null +++ b/src/assets/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/dark star.svg b/src/assets/icons/dark star.svg new file mode 100644 index 00000000..52c9670e --- /dev/null +++ b/src/assets/icons/dark star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/fix.svg b/src/assets/icons/fix.svg new file mode 100644 index 00000000..b9a20049 --- /dev/null +++ b/src/assets/icons/fix.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/heart.svg b/src/assets/icons/heart.svg new file mode 100644 index 00000000..1062baee --- /dev/null +++ b/src/assets/icons/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/language.svg b/src/assets/icons/language.svg new file mode 100644 index 00000000..430207ea --- /dev/null +++ b/src/assets/icons/language.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/loading.svg b/src/assets/icons/loading.svg new file mode 100644 index 00000000..941ab840 --- /dev/null +++ b/src/assets/icons/loading.svg @@ -0,0 +1,5 @@ + +
+ + +
diff --git a/src/assets/icons/location-pin.svg b/src/assets/icons/location-pin.svg new file mode 100644 index 00000000..a429d099 --- /dev/null +++ b/src/assets/icons/location-pin.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/location.svg b/src/assets/icons/location.svg new file mode 100644 index 00000000..046bdc3b --- /dev/null +++ b/src/assets/icons/location.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/logo.svg b/src/assets/icons/logo.svg new file mode 100644 index 00000000..811b6eaf --- /dev/null +++ b/src/assets/icons/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/mail.svg b/src/assets/icons/mail.svg new file mode 100644 index 00000000..8c21542e --- /dev/null +++ b/src/assets/icons/mail.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/material-symbols_add-2-rounded.svg b/src/assets/icons/material-symbols_add-2-rounded.svg new file mode 100644 index 00000000..bf25ec67 --- /dev/null +++ b/src/assets/icons/material-symbols_add-2-rounded.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/menu.svg b/src/assets/icons/menu.svg new file mode 100644 index 00000000..c274bad8 --- /dev/null +++ b/src/assets/icons/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/more.svg b/src/assets/icons/more.svg new file mode 100644 index 00000000..8feb67da --- /dev/null +++ b/src/assets/icons/more.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/near.svg b/src/assets/icons/near.svg new file mode 100644 index 00000000..be4c584d --- /dev/null +++ b/src/assets/icons/near.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/oheart.svg b/src/assets/icons/oheart.svg new file mode 100644 index 00000000..1dbe4818 --- /dev/null +++ b/src/assets/icons/oheart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/out.svg b/src/assets/icons/out.svg new file mode 100644 index 00000000..7d0f53f3 --- /dev/null +++ b/src/assets/icons/out.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/pen.svg b/src/assets/icons/pen.svg new file mode 100644 index 00000000..5fa872b5 --- /dev/null +++ b/src/assets/icons/pen.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/people.svg b/src/assets/icons/people.svg new file mode 100644 index 00000000..46637c61 --- /dev/null +++ b/src/assets/icons/people.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/profile.svg b/src/assets/icons/profile.svg new file mode 100644 index 00000000..03dd0cec --- /dev/null +++ b/src/assets/icons/profile.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/schedule.svg b/src/assets/icons/schedule.svg new file mode 100644 index 00000000..181821e2 --- /dev/null +++ b/src/assets/icons/schedule.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg new file mode 100644 index 00000000..c11f6266 --- /dev/null +++ b/src/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/star.svg b/src/assets/icons/star.svg new file mode 100644 index 00000000..29c7b691 --- /dev/null +++ b/src/assets/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/upload.svg b/src/assets/icons/upload.svg new file mode 100644 index 00000000..90a56d61 --- /dev/null +++ b/src/assets/icons/upload.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/vector.svg b/src/assets/icons/vector.svg new file mode 100644 index 00000000..dddcd296 --- /dev/null +++ b/src/assets/icons/vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/yeogido.svg b/src/assets/icons/yeogido.svg new file mode 100644 index 00000000..2a252381 --- /dev/null +++ b/src/assets/icons/yeogido.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/.gitkeep b/src/assets/images/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/assets/images/regions/busan.webp b/src/assets/images/regions/busan.webp new file mode 100644 index 00000000..8b32e8cc Binary files /dev/null and b/src/assets/images/regions/busan.webp differ diff --git a/src/assets/images/regions/chungbuk.webp b/src/assets/images/regions/chungbuk.webp new file mode 100644 index 00000000..77e83d26 Binary files /dev/null and b/src/assets/images/regions/chungbuk.webp differ diff --git a/src/assets/images/regions/chungnam.webp b/src/assets/images/regions/chungnam.webp new file mode 100644 index 00000000..4e756c2e Binary files /dev/null and b/src/assets/images/regions/chungnam.webp differ diff --git a/src/assets/images/regions/daegu.webp b/src/assets/images/regions/daegu.webp new file mode 100644 index 00000000..c8bbc23e Binary files /dev/null and b/src/assets/images/regions/daegu.webp differ diff --git a/src/assets/images/regions/daejeon.webp b/src/assets/images/regions/daejeon.webp new file mode 100644 index 00000000..edc28b92 Binary files /dev/null and b/src/assets/images/regions/daejeon.webp differ diff --git a/src/assets/images/regions/gangwon.webp b/src/assets/images/regions/gangwon.webp new file mode 100644 index 00000000..5aac0ff3 Binary files /dev/null and b/src/assets/images/regions/gangwon.webp differ diff --git a/src/assets/images/regions/gwangju.webp b/src/assets/images/regions/gwangju.webp new file mode 100644 index 00000000..f4a73526 Binary files /dev/null and b/src/assets/images/regions/gwangju.webp differ diff --git a/src/assets/images/regions/gyeongbuk.webp b/src/assets/images/regions/gyeongbuk.webp new file mode 100644 index 00000000..a01ab0dc Binary files /dev/null and b/src/assets/images/regions/gyeongbuk.webp differ diff --git a/src/assets/images/regions/gyeonggi.webp b/src/assets/images/regions/gyeonggi.webp new file mode 100644 index 00000000..7894e7e6 Binary files /dev/null and b/src/assets/images/regions/gyeonggi.webp differ diff --git a/src/assets/images/regions/gyeongnam.webp b/src/assets/images/regions/gyeongnam.webp new file mode 100644 index 00000000..a20d154b Binary files /dev/null and b/src/assets/images/regions/gyeongnam.webp differ diff --git a/src/assets/images/regions/incheon.webp b/src/assets/images/regions/incheon.webp new file mode 100644 index 00000000..98d951b8 Binary files /dev/null and b/src/assets/images/regions/incheon.webp differ diff --git a/src/assets/images/regions/jeju.webp b/src/assets/images/regions/jeju.webp new file mode 100644 index 00000000..bdd4054d Binary files /dev/null and b/src/assets/images/regions/jeju.webp differ diff --git a/src/assets/images/regions/jeonbuk.webp b/src/assets/images/regions/jeonbuk.webp new file mode 100644 index 00000000..54da8ef3 Binary files /dev/null and b/src/assets/images/regions/jeonbuk.webp differ diff --git a/src/assets/images/regions/jeonnam.webp b/src/assets/images/regions/jeonnam.webp new file mode 100644 index 00000000..bcb19d27 Binary files /dev/null and b/src/assets/images/regions/jeonnam.webp differ diff --git a/src/assets/images/regions/sejong.webp b/src/assets/images/regions/sejong.webp new file mode 100644 index 00000000..26617040 Binary files /dev/null and b/src/assets/images/regions/sejong.webp differ diff --git a/src/assets/images/regions/seoul.webp b/src/assets/images/regions/seoul.webp new file mode 100644 index 00000000..c4f3498b Binary files /dev/null and b/src/assets/images/regions/seoul.webp differ diff --git a/src/assets/images/regions/ulsan.webp b/src/assets/images/regions/ulsan.webp new file mode 100644 index 00000000..e954f4dc Binary files /dev/null and b/src/assets/images/regions/ulsan.webp differ diff --git a/src/assets/tag-chip/inactive/autumn.svg b/src/assets/tag-chip/inactive/autumn.svg new file mode 100644 index 00000000..48de1c6e --- /dev/null +++ b/src/assets/tag-chip/inactive/autumn.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/bakery.svg b/src/assets/tag-chip/inactive/bakery.svg new file mode 100644 index 00000000..cc6a70bc --- /dev/null +++ b/src/assets/tag-chip/inactive/bakery.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/cafe.svg b/src/assets/tag-chip/inactive/cafe.svg new file mode 100644 index 00000000..7955782f --- /dev/null +++ b/src/assets/tag-chip/inactive/cafe.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/drink.svg b/src/assets/tag-chip/inactive/drink.svg new file mode 100644 index 00000000..bd729b9b --- /dev/null +++ b/src/assets/tag-chip/inactive/drink.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/event.svg b/src/assets/tag-chip/inactive/event.svg new file mode 100644 index 00000000..379503ff --- /dev/null +++ b/src/assets/tag-chip/inactive/event.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/experience.svg b/src/assets/tag-chip/inactive/experience.svg new file mode 100644 index 00000000..15cda405 --- /dev/null +++ b/src/assets/tag-chip/inactive/experience.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/game.svg b/src/assets/tag-chip/inactive/game.svg new file mode 100644 index 00000000..9565a17a --- /dev/null +++ b/src/assets/tag-chip/inactive/game.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/local-attraction.svg b/src/assets/tag-chip/inactive/local-attraction.svg new file mode 100644 index 00000000..b6bdcec4 --- /dev/null +++ b/src/assets/tag-chip/inactive/local-attraction.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/mountain.svg b/src/assets/tag-chip/inactive/mountain.svg new file mode 100644 index 00000000..a192c7c0 --- /dev/null +++ b/src/assets/tag-chip/inactive/mountain.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/music.svg b/src/assets/tag-chip/inactive/music.svg new file mode 100644 index 00000000..24942a9e --- /dev/null +++ b/src/assets/tag-chip/inactive/music.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/nature.svg b/src/assets/tag-chip/inactive/nature.svg new file mode 100644 index 00000000..cd735e4a --- /dev/null +++ b/src/assets/tag-chip/inactive/nature.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/restaurant.svg b/src/assets/tag-chip/inactive/restaurant.svg new file mode 100644 index 00000000..4f4eff05 --- /dev/null +++ b/src/assets/tag-chip/inactive/restaurant.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/sea.svg b/src/assets/tag-chip/inactive/sea.svg new file mode 100644 index 00000000..c2d74017 --- /dev/null +++ b/src/assets/tag-chip/inactive/sea.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/sport.svg b/src/assets/tag-chip/inactive/sport.svg new file mode 100644 index 00000000..0dae12da --- /dev/null +++ b/src/assets/tag-chip/inactive/sport.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/spring.svg b/src/assets/tag-chip/inactive/spring.svg new file mode 100644 index 00000000..83f6c09b --- /dev/null +++ b/src/assets/tag-chip/inactive/spring.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/summer.svg b/src/assets/tag-chip/inactive/summer.svg new file mode 100644 index 00000000..cec13b49 --- /dev/null +++ b/src/assets/tag-chip/inactive/summer.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/inactive/winter.svg b/src/assets/tag-chip/inactive/winter.svg new file mode 100644 index 00000000..cf0a7045 --- /dev/null +++ b/src/assets/tag-chip/inactive/winter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/autumn.svg b/src/assets/tag-chip/selected/autumn.svg new file mode 100644 index 00000000..d5a94daa --- /dev/null +++ b/src/assets/tag-chip/selected/autumn.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/bakery.svg b/src/assets/tag-chip/selected/bakery.svg new file mode 100644 index 00000000..cd94acce --- /dev/null +++ b/src/assets/tag-chip/selected/bakery.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/cafe.svg b/src/assets/tag-chip/selected/cafe.svg new file mode 100644 index 00000000..45db1c12 --- /dev/null +++ b/src/assets/tag-chip/selected/cafe.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/drink.svg b/src/assets/tag-chip/selected/drink.svg new file mode 100644 index 00000000..8955ba11 --- /dev/null +++ b/src/assets/tag-chip/selected/drink.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/event.svg b/src/assets/tag-chip/selected/event.svg new file mode 100644 index 00000000..c467303d --- /dev/null +++ b/src/assets/tag-chip/selected/event.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/experience.svg b/src/assets/tag-chip/selected/experience.svg new file mode 100644 index 00000000..c9c2d3b3 --- /dev/null +++ b/src/assets/tag-chip/selected/experience.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/game.svg b/src/assets/tag-chip/selected/game.svg new file mode 100644 index 00000000..2ddfc129 --- /dev/null +++ b/src/assets/tag-chip/selected/game.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/local-attraction.svg b/src/assets/tag-chip/selected/local-attraction.svg new file mode 100644 index 00000000..4868ec00 --- /dev/null +++ b/src/assets/tag-chip/selected/local-attraction.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/mountain.svg b/src/assets/tag-chip/selected/mountain.svg new file mode 100644 index 00000000..e40e72a6 --- /dev/null +++ b/src/assets/tag-chip/selected/mountain.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/music.svg b/src/assets/tag-chip/selected/music.svg new file mode 100644 index 00000000..73e26ef6 --- /dev/null +++ b/src/assets/tag-chip/selected/music.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/nature.svg b/src/assets/tag-chip/selected/nature.svg new file mode 100644 index 00000000..384fcaa5 --- /dev/null +++ b/src/assets/tag-chip/selected/nature.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/restaurant.svg b/src/assets/tag-chip/selected/restaurant.svg new file mode 100644 index 00000000..4dd3b8d6 --- /dev/null +++ b/src/assets/tag-chip/selected/restaurant.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/sea.svg b/src/assets/tag-chip/selected/sea.svg new file mode 100644 index 00000000..870962a8 --- /dev/null +++ b/src/assets/tag-chip/selected/sea.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/sport.svg b/src/assets/tag-chip/selected/sport.svg new file mode 100644 index 00000000..dda5dca3 --- /dev/null +++ b/src/assets/tag-chip/selected/sport.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/spring.svg b/src/assets/tag-chip/selected/spring.svg new file mode 100644 index 00000000..e958adac --- /dev/null +++ b/src/assets/tag-chip/selected/spring.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/summer.svg b/src/assets/tag-chip/selected/summer.svg new file mode 100644 index 00000000..298df783 --- /dev/null +++ b/src/assets/tag-chip/selected/summer.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/tag-chip/selected/winter.svg b/src/assets/tag-chip/selected/winter.svg new file mode 100644 index 00000000..cc1e14ef --- /dev/null +++ b/src/assets/tag-chip/selected/winter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/auth/AuthField.tsx b/src/components/auth/AuthField.tsx new file mode 100644 index 00000000..dd25ea0c --- /dev/null +++ b/src/components/auth/AuthField.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +interface AuthFieldProps { + id: string; + label?: string; + error?: string; + children: ReactNode; +} + +function AuthField({ id, label, error, children }: AuthFieldProps) { + return ( +
+ {label && ( + + )} + + {children} + + {error && ( + + )} +
+ ); +} + +export default AuthField; diff --git a/src/components/auth/KakaoIcon.tsx b/src/components/auth/KakaoIcon.tsx new file mode 100644 index 00000000..98d2d894 --- /dev/null +++ b/src/components/auth/KakaoIcon.tsx @@ -0,0 +1,34 @@ +import type { SVGProps } from 'react'; + +function KakaoIcon(props: SVGProps) { + return ( + + + + + + + + + + + ); +} + +export default KakaoIcon; diff --git a/src/components/auth/NaverIcon.tsx b/src/components/auth/NaverIcon.tsx new file mode 100644 index 00000000..e8b5fa9f --- /dev/null +++ b/src/components/auth/NaverIcon.tsx @@ -0,0 +1,32 @@ +import type { SVGProps } from 'react'; + +function NaverIcon(props: SVGProps) { + return ( + + + + + + + + + + + ); +} + +export default NaverIcon; diff --git a/src/components/auth/PasswordInput.tsx b/src/components/auth/PasswordInput.tsx new file mode 100644 index 00000000..d3d90a8b --- /dev/null +++ b/src/components/auth/PasswordInput.tsx @@ -0,0 +1,37 @@ +import { forwardRef, useState } from 'react'; +import type { InputHTMLAttributes } from 'react'; +import { IoEyeOffOutline, IoEyeOutline } from 'react-icons/io5'; + +type PasswordInputProps = InputHTMLAttributes; + +const PasswordInput = forwardRef( + function PasswordInput({ className, ...props }, ref) { + const [isVisible, setIsVisible] = useState(false); + + return ( +
+ + + +
+ ); + } +); + +export default PasswordInput; diff --git a/src/components/auth/ProtectedRoute.tsx b/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 00000000..dcd0e4e1 --- /dev/null +++ b/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import { Navigate, Outlet } from 'react-router-dom'; + +import { useAuth } from '../../hooks/useAuth'; + +function ProtectedRoute() { + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated) { + return ; + } + + return ; +} + +export default ProtectedRoute; diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts new file mode 100644 index 00000000..bf07acc2 --- /dev/null +++ b/src/components/auth/index.ts @@ -0,0 +1,4 @@ +export { default as AuthField } from './AuthField'; +export { default as KakaoIcon } from './KakaoIcon'; +export { default as NaverIcon } from './NaverIcon'; +export { default as PasswordInput } from './PasswordInput'; diff --git a/src/components/common/AdvertisementCard.tsx b/src/components/common/AdvertisementCard.tsx new file mode 100644 index 00000000..66caeb62 --- /dev/null +++ b/src/components/common/AdvertisementCard.tsx @@ -0,0 +1,108 @@ +import { useScaleFrame } from '../../hooks/useScaleFrame'; + +// Figma 390 디자인 기준 리터럴 px (카드 자체 폭 342 기준) +const CARD_DESIGN_WIDTH = 342; +const CARD_HEIGHT = 120; +const CARD_RADIUS = 12; + +const TEXT_LEFT = 16; +const TEXT_TOP = 56; +const TITLE_LINE_HEIGHT = 22; + +export interface AdvertisementCardProps { + image: string; + titleWhite: string; + titleOrangeBold: string; + titleOrangeRegular: string; + onClick?: () => void; +} + +function AdvertisementCard({ + image, + titleWhite, + titleOrangeBold, + titleOrangeRegular, + onClick, +}: AdvertisementCardProps) { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_DESIGN_WIDTH); + + const isClickable = Boolean(onClick); + + return ( +
+
+ {image && ( + + )} + + {/* 텍스트 가독성을 위한 하단 그라데이션 오버레이 */} +
+ + {/* Title */} +
+ + {titleWhite} + + + + + {titleOrangeBold} + + + + {titleOrangeRegular} + + +
+
+
+ ); +} + +export default AdvertisementCard; \ No newline at end of file diff --git a/src/components/common/AdvertisementCardSkeleton.tsx b/src/components/common/AdvertisementCardSkeleton.tsx new file mode 100644 index 00000000..28f25d52 --- /dev/null +++ b/src/components/common/AdvertisementCardSkeleton.tsx @@ -0,0 +1,34 @@ +import { useScaleFrame } from '../../hooks/useScaleFrame'; + +const CARD_DESIGN_WIDTH = 342; +const CARD_HEIGHT = 120; +const CARD_RADIUS = 12; + +function AdvertisementCardSkeleton() { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_DESIGN_WIDTH); + + return ( +
+
+
+ ); +} + +export default AdvertisementCardSkeleton; \ No newline at end of file diff --git a/src/components/common/CardActionMenu.tsx b/src/components/common/CardActionMenu.tsx new file mode 100644 index 00000000..0d83cdf8 --- /dev/null +++ b/src/components/common/CardActionMenu.tsx @@ -0,0 +1,177 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import fix from '../../assets/icons/fix.svg'; +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +const MENU_WIDTH = 80; +const MENU_ITEM_HEIGHT = 36; +const MENU_GAP = 4; + +export interface CardActionMenuProps { + onEdit?: () => void; + onDelete?: () => void; + /** 카드마다 버튼이 놓이는 자리가 달라 트리거 배치는 밖에서 정한다. */ + triggerClassName?: string; + /** 트리거 아이콘 크기(px, Figma 390 디자인 기준). */ + triggerSize?: number; + /** 카드 종류마다 스크린리더 안내를 다르게 하려면 넘긴다. */ + ariaLabel?: string; +} + +/** + * 수정 가능한 카드(EditableCourseCard/EditableContentCard)의 우측 상단 + * fix 버튼 메뉴. ReviewActionMenu와 같은 방식으로 패널을 body에 포털로 + * 띄우고 버튼 위치에 맞춰 고정한다 — 카드가 overflow-hidden이라 안에서 + * 그리면 잘린다. + */ +function CardActionMenu({ + onEdit, + onDelete, + triggerClassName = '', + triggerSize = 20, + ariaLabel = '카드 메뉴', +}: CardActionMenuProps) { + const scale = useGlobalScale(); + const triggerId = useId(); + const menuId = useId(); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [panelStyle, setPanelStyle] = useState< + { top: number; left: number } | undefined + >(); + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + + if ( + !triggerRef.current?.contains(target) && + !menuRef.current?.contains(target) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + + return () => document.removeEventListener('pointerdown', handlePointerDown); + }, []); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + triggerRef.current?.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); + + useLayoutEffect(() => { + if (!isOpen) return; + + const updatePanelStyle = () => { + const trigger = triggerRef.current; + if (!trigger) return; + + const rect = trigger.getBoundingClientRect(); + const width = MENU_WIDTH * scale; + + setPanelStyle({ + top: rect.bottom + MENU_GAP * scale, + left: Math.max( + MENU_GAP * scale, + Math.min(rect.right - width, window.innerWidth - width - MENU_GAP) + ), + }); + }; + + updatePanelStyle(); + window.addEventListener('resize', updatePanelStyle); + window.addEventListener('scroll', updatePanelStyle, true); + + return () => { + window.removeEventListener('resize', updatePanelStyle); + window.removeEventListener('scroll', updatePanelStyle, true); + }; + }, [isOpen, scale]); + + const handleSelect = (action?: () => void) => { + setIsOpen(false); + action?.(); + }; + + const menuItems = [ + { key: 'edit', label: '수정', action: onEdit }, + { key: 'delete', label: '삭제', action: onDelete }, + ]; + + return ( + <> + + + {isOpen && panelStyle + ? createPortal( + , + document.body + ) + : null} + + ); +} + +export default CardActionMenu; diff --git a/src/components/common/ConfirmDialog.tsx b/src/components/common/ConfirmDialog.tsx new file mode 100644 index 00000000..a95cf8d0 --- /dev/null +++ b/src/components/common/ConfirmDialog.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +export interface ConfirmDialogProps { + isOpen: boolean; + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + cancelClassName?: string; + confirmClassName?: string; + isPending?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * 되돌릴 수 없는 동작을 확인받는 다이얼로그. + * + * 여행 기록 삭제 다이얼로그의 생김새를 그대로 가져와 공용으로 올린 것이다. + * 카드 안에서 열리는 경우가 있어(후기 더보기 메뉴) body에 포털로 띄운다. + */ +function ConfirmDialog({ + isOpen, + title, + description, + confirmLabel = '삭제', + cancelLabel = '취소', + cancelClassName, + confirmClassName, + isPending = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onCancel(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onCancel]); + + if (!isOpen || typeof document === 'undefined') { + return null; + } + + return createPortal( +
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ + +
+
+
, + document.body + ); +} + +export default ConfirmDialog; diff --git a/src/components/common/ContentCard.tsx b/src/components/common/ContentCard.tsx new file mode 100644 index 00000000..80943f8b --- /dev/null +++ b/src/components/common/ContentCard.tsx @@ -0,0 +1,286 @@ +import calendar from '../../assets/icons/calendar.svg'; +import heart from '../../assets/icons/heart.svg'; +import location from '../../assets/icons/location.svg'; +import near from '../../assets/icons/near.svg'; +import oheart from '../../assets/icons/oheart.svg'; +import people from '../../assets/icons/people.svg'; + +import { useGlobalScale } from '../../hooks/useGlobalScale'; +import { useVisibleItemCount } from '../../hooks/useVisibleItemCount'; +import { getCompanionIcon } from '../../utils/companionIcon'; + +import TagChip, { type TagType } from './TagChip'; + +// 모든 수치는 Figma 390 디자인 기준(카드 자체 폭 163 기준) 리터럴 px +const CARD_DESIGN_WIDTH = 163; +const CARD_HEIGHT = 222; +const IMAGE_HEIGHT = 115; +const CONTENT_HEIGHT = 107; +const CONTENT_PADDING = 8; +const TITLE_SIZE = 14; +const INFO_SIZE = 12; +/** 아이콘 슬롯. 아이콘은 원본 비율 그대로 이 슬롯 가운데에 놓는다(중심 x = 15). */ +const ICON_SIZE = 14; +const ICON_GAP = 2; +/** 태그 줄이 없는 카드(장소)는 남는 높이만큼 정보 줄을 넓게 벌린다. */ +const INFO_MARGIN_TOP = 8; +const INFO_ROW_GAP = 4; +const INFO_MARGIN_TOP_WIDE = 17; +const INFO_ROW_GAP_WIDE = 8; +const HEART_SIZE = 16; +const HEART_TOP = 8; +const HEART_RIGHT = 8; +const TAG_HEIGHT = 20; +const TAG_GAP = 4; + +interface ContentCardProps { + image: string | null; + title: string; + firstInfo: string; + secondInfo: string; + /** 두 번째 정보 줄에 함께 붙는 보조 정보(예: 동행 유형). 없으면 렌더링하지 않는다. */ + thirdInfo?: string; + /** 현 위치 기준 거리 줄(예: '현위치와 314KM'). 없으면 렌더링하지 않는다. */ + distanceInfo?: string; + liked?: boolean; + className?: string; + tags?: TagType[]; + onClick?: () => void; + onLikeClick?: () => void; +} + +/** 아이콘은 늘리지 않고 원본 크기 그대로 14px 슬롯 가운데에 놓는다. */ +function InfoIcon({ src }: { src: string }) { + return ( + + ); +} + +function ContentCard({ + image, + title, + firstInfo, + secondInfo, + thirdInfo, + distanceInfo, + liked = false, + className = '', + tags, + onClick, + onLikeClick, +}: ContentCardProps) { + const scale = useGlobalScale(); + + const { + containerRef: visibleContainerRef, + hiddenRef: hiddenContainerRef, + visibleCount, + } = useVisibleItemCount(tags?.join('|') ?? '', tags?.length ?? 0); + + const hasTags = Boolean(tags && tags.length > 0); + const isClickable = Boolean(onClick); + const hasCustomWidth = /(?:^|\s)(?:w-|min-w|max-w)/.test(className); + + return ( +
+
{ + if (!onClick || (event.key !== 'Enter' && event.key !== ' ')) { + return; + } + + event.preventDefault(); + onClick(); + }} + role={isClickable ? 'button' : undefined} + tabIndex={isClickable ? 0 : undefined} + className={`flex flex-col overflow-hidden rounded-xl bg-[#F9F9F9] shadow-[0_1px_5px_rgba(0,0,0,0.07)] ${isClickable ? 'cursor-pointer' : ''} `} + style={{ + width: CARD_DESIGN_WIDTH, + height: CARD_HEIGHT, + transform: `scale(${scale})`, + transformOrigin: 'top left', + }} + > + {/* Image */} +
+ {image ? ( + {title} + ) : ( +
+ )} + + +
+ + {/* Content */} +
+ {/* Title */} +

+ {title} +

+ + {/* Info */} +
+
+ + + + {firstInfo} + +
+ +
+ + + + {secondInfo} + + + {thirdInfo ? ( + <> + {(() => { + const CompanionIcon = getCompanionIcon(thirdInfo); + return CompanionIcon ? ( + + ) : ( + + ); + })()} + + + {thirdInfo} + + + ) : null} +
+ + {distanceInfo ? ( +
+ + + + {distanceInfo} + +
+ ) : null} +
+ + {/* Tags */} + {hasTags && tags && ( + <> + + +
+
+ {tags.slice(0, visibleCount).map((tag, index) => ( + + ))} +
+
+ + )} +
+
+
+ ); +} + +export default ContentCard; diff --git a/src/components/common/ContentCardSkeleton.tsx b/src/components/common/ContentCardSkeleton.tsx new file mode 100644 index 00000000..ad3e56c9 --- /dev/null +++ b/src/components/common/ContentCardSkeleton.tsx @@ -0,0 +1,123 @@ +import heart from '../../assets/icons/heart.svg'; + +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +const CARD_WIDTH = 163; +const CARD_HEIGHT = 222; +const IMAGE_HEIGHT = 115; +const CONTENT_PADDING = 8; +const HEART_SIZE = 16; +const HEART_OFFSET = 8; +const TITLE_HEIGHT = 14; +const TITLE_WIDTH = 88; +const INFO_ICON_SIZE = 14; +const INFO_TEXT_HEIGHT = 12; +const INFO_GAP = 4; +const INFO_MARGIN_TOP = 8; +const TAG_HEIGHT = 20; +const TAG_GAP = 4; +const TAG_MARGIN_TOP = 8; + +interface ContentCardSkeletonProps { + className?: string; + imageClassName?: string; +} + +function ContentCardSkeleton({ + className = '', + imageClassName = '', +}: ContentCardSkeletonProps) { + const scale = useGlobalScale(); + const hasCustomWidth = /(?:^|\s)(?:w-|min-w|max-w)/.test(className); + const hasCustomImageHeight = /(?:^|\s)(?:h-|min-h|max-h|aspect-)/.test( + imageClassName + ); + + return ( +
+
+
+
+
+ +
+
+ +
+
+ +
+ {[56, 72].map((textWidth) => ( +
+
+
+
+ ))} +
+ +
+
+
+
+
+
+
+
+
+ ); +} + +export default ContentCardSkeleton; diff --git a/src/components/common/CourseCard.tsx b/src/components/common/CourseCard.tsx new file mode 100644 index 00000000..2503a7bf --- /dev/null +++ b/src/components/common/CourseCard.tsx @@ -0,0 +1,260 @@ +import calendar from '../../assets/icons/calendar.svg'; +import heart from '../../assets/icons/heart.svg'; +import location from '../../assets/icons/location.svg'; +import oheart from '../../assets/icons/oheart.svg'; +import people from '../../assets/icons/people.svg'; + +import { useScaleFrame } from '../../hooks/useScaleFrame'; +import { useVisibleItemCount } from '../../hooks/useVisibleItemCount'; +import { getCompanionIcon } from '../../utils/companionIcon'; + +import ReviewActionMenu from './ReviewActionMenu'; +import TagChip, { type TagType } from './TagChip'; + +// 모든 수치는 Figma 390 디자인 기준 리터럴 px. +// 개별 vw 계산 대신 useScaleFrame이 전체를 한 번에 scale한다. +const CARD_DESIGN_WIDTH = 342; +const CARD_MIN_HEIGHT = 100; +const IMAGE_WIDTH = 136; +const IMAGE_RADIUS = 8; +const CONTENT_PADDING_LEFT = 16; +const CONTENT_PADDING_RIGHT = 42; // 하트(20) + 간격(10) + 카드 우측 여백(12) +const HEART_SIZE = 20; +const HEART_TOP = 12; +const HEART_RIGHT = 12; +const TAG_HEIGHT = 20; +const TAG_GAP = 4; + +export interface CourseCardProps { + image: string; + title: string; + duration: string; + courseType: string; + companion: string; + tags: TagType[]; + liked?: boolean; + /** 본인이 등록한 코스면 좋아요 대신 더보기(수정/삭제) 메뉴를 같은 자리에 띄운다. */ + isMine?: boolean; + onClick?: () => void; + onLikeClick?: () => void; + onEditClick?: () => void; + showEdit?: boolean; + onDeleteClick?: () => void; +} + +function CourseCard({ + image, + title, + duration, + courseType, + companion, + tags, + liked = false, + isMine = false, + onClick, + onLikeClick, + onEditClick, + showEdit, + onDeleteClick, +}: CourseCardProps) { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_DESIGN_WIDTH); + + const metaItems = [ + { key: 'duration', icon: calendar, label: duration }, + { key: 'courseType', icon: location, label: courseType }, + { key: 'companion', icon: people, label: companion }, + ].filter((item) => Boolean(item.label)); + + const metaKey = metaItems.map((item) => item.label).join('|'); + const tagsKey = tags.join('|'); + + const { + containerRef: metaContainerRef, + hiddenRef: hiddenMetaRef, + visibleCount: visibleMetaCount, + } = useVisibleItemCount(metaKey, metaItems.length); + + const { + containerRef: tagContainerRef, + hiddenRef: hiddenTagRef, + visibleCount: visibleTagCount, + } = useVisibleItemCount(tagsKey, tags.length); + + return ( +
+
+ {/* Image: 세로형 원본이 들어와도 카드 높이를 밀어올리지 않도록 + 래퍼가 높이를 잡고 img 는 그 안을 채운다. */} +
+ {image && ( + {title} + )} +
+ + {/* Text column: Figma 스펙대로 제목/메타/태그 모두 동일한 우측 여백(하트 자리) 공유 */} +
+ {/* Title */} +

+ {title} +

+ + {/* Meta: 측정 전용 hidden 영역 */} + + + {/* Meta: 실제로 보여지는 부분 */} +
+ {metaItems.slice(0, visibleMetaCount).map((item) => { + const CompanionIcon = + item.key === 'companion' ? getCompanionIcon(item.label) : null; + + return ( +
+ {CompanionIcon ? ( + + ) : ( + + )} + + + {item.label} + +
+ ); + })} +
+ + {/* Tags: 측정 전용 hidden 영역 */} + + + {/* Tags: 실제로 보여지는 태그 */} +
+ {tags.slice(0, visibleTagCount).map((tag, index) => ( + + ))} +
+
+ + {/* Like / 더보기: 본인 코스는 좋아요 대신 같은 자리에 수정·삭제 메뉴를 띄운다. */} + {isMine && (onEditClick || onDeleteClick) ? ( + + ) : ( + + )} +
+
+ ); +} + +export default CourseCard; diff --git a/src/components/common/CourseCardSkeleton.tsx b/src/components/common/CourseCardSkeleton.tsx new file mode 100644 index 00000000..cfd217f1 --- /dev/null +++ b/src/components/common/CourseCardSkeleton.tsx @@ -0,0 +1,117 @@ +import heart from '../../assets/icons/heart.svg'; + +import { useScaleFrame } from '../../hooks/useScaleFrame'; + +const CARD_WIDTH = 342; +const CARD_HEIGHT = 100; +const IMAGE_WIDTH = 136; +const CONTENT_PADDING_X = 16; +const CONTENT_PADDING_Y = 16; +const HEART_SIZE = 20; +const HEART_OFFSET = 12; +const TITLE_HEIGHT = 16; +const TITLE_WIDTH = 150; +const META_ICON_SIZE = 14; +const META_TEXT_HEIGHT = 12; +const META_GAP = 8; +const META_ITEM_GAP = 2; +const META_MARGIN_TOP = 8; +const TAG_HEIGHT = 24; +const TAG_GAP = 8; +const TAG_MARGIN_TOP = 12; + +function CourseCardSkeleton() { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_WIDTH); + + return ( +
+
+
+ +
+
+ +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+ ))} +
+ +
+ {[48, 56, 44].map((width) => ( +
+ ))} +
+
+ +
+ +
+
+
+ ); +} + +export default CourseCardSkeleton; diff --git a/src/components/common/CourseFilterBar.tsx b/src/components/common/CourseFilterBar.tsx new file mode 100644 index 00000000..6b0f3343 --- /dev/null +++ b/src/components/common/CourseFilterBar.tsx @@ -0,0 +1,163 @@ +import type { RefObject } from 'react'; +import { useId } from 'react'; +import { IoChevronDown } from 'react-icons/io5'; + +import { + COURSE_FILTER_CONTAINER_CLASS_NAME, + getCourseFilterColumnClassName, + getCourseFilterGridClassName, +} from '../../constants/courseFilterLayout'; +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +const FILTER_DESIGN_WIDTH = 342; +const FILTER_HEIGHT = 29; + +export interface CourseFilterGroup { + key: TKey; + options: readonly string[]; +} + +interface CourseFilterBarProps { + filterGroups: readonly CourseFilterGroup[]; + selectedFilters: Record; + openFilterKey: TKey | null; + filterContainerRef: RefObject; + /** 코스 필터 전용. 다른 화면에서 gridClassName을 직접 넘길 때는 사용하지 않는다. */ + isExtendedTransport?: boolean; + /** 코스 필터 그리드가 아닌 다른 칩 배치가 필요할 때 그리드 클래스를 대체한다. */ + gridClassName?: string; + /** gridClassName을 대체했을 때 각 칩이 놓일 컬럼 클래스를 함께 넘긴다. */ + getColumnClassName?: (filterKey: TKey) => string; + marginTop: number; + onToggle: (filterKey: TKey) => void; + onSelect: (filterKey: TKey, option: string) => void; +} + +interface CourseFilterChipProps { + label: string; + options: readonly string[]; + isOpen: boolean; + onToggle: () => void; + onSelect: (option: string) => void; +} + +function CourseFilterChip({ + label, + options, + isOpen, + onToggle, + onSelect, +}: CourseFilterChipProps) { + const buttonId = useId(); + const listboxId = useId(); + + return ( +
+ + + {isOpen ? ( +
+ {options.map((option, index) => { + const isFirst = index === 0; + const isLast = index === options.length - 1; + const optionRadius = + isFirst && isLast + ? 'rounded-xl' + : isFirst + ? 'rounded-t-xl' + : isLast + ? 'rounded-b-xl' + : ''; + + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +function CourseFilterBar({ + filterGroups, + selectedFilters, + openFilterKey, + filterContainerRef, + isExtendedTransport = false, + gridClassName, + getColumnClassName = getCourseFilterColumnClassName, + marginTop, + onToggle, + onSelect, +}: CourseFilterBarProps) { + const scale = useGlobalScale(); + const filterGridClassName = + gridClassName ?? getCourseFilterGridClassName(isExtendedTransport); + + return ( +
+
+ {filterGroups.map((filter) => ( +
+ onToggle(filter.key)} + onSelect={(option) => onSelect(filter.key, option)} + /> +
+ ))} +
+
+ ); +} + +export default CourseFilterBar; diff --git a/src/components/common/CourseReviewCard.tsx b/src/components/common/CourseReviewCard.tsx new file mode 100644 index 00000000..50416eaf --- /dev/null +++ b/src/components/common/CourseReviewCard.tsx @@ -0,0 +1,259 @@ +import type { KeyboardEvent } from 'react'; + +import calendar from '../../assets/icons/calendar.svg'; +import darkStar from '../../assets/icons/dark star.svg'; +import location from '../../assets/icons/location.svg'; +import people from '../../assets/icons/people.svg'; +import star from '../../assets/icons/star.svg'; +import { useCardTap } from '../../hooks/useCardTap'; +import { useScaleFrame } from '../../hooks/useScaleFrame'; +import { useVisibleItemCount } from '../../hooks/useVisibleItemCount'; +import type { TagId } from '../../types/tag.type'; + +import ReviewActionMenu from './ReviewActionMenu'; +import TagChip from './TagChip'; + +const CARD_DESIGN_WIDTH = 342; +const CARD_HEIGHT = 166; + +export interface CourseReviewCardProps { + image: string; + title: string; + duration: string; + /** 이동 수단 라벨(걷기·자동차). 코스 타입(OFFICIAL·LOCAL)과 다르다. */ + transport: string; + // 리뷰 목록 API의 코스 정보에는 동행·해시태그가 없어, 값이 없으면 해당 + // 항목만 빼고 그린다. 백엔드에 추가 요청해 둔 상태다. + companion?: string; + tags?: TagId[]; + profileImage: string; + nickname: string; + meta: string; + content: string; + rating?: number; + isMine?: boolean; + onClick?: () => void; + onEditClick?: () => void; + onDeleteClick?: () => void; +} + +function CourseReviewCard({ + image, + title, + duration, + transport, + companion, + tags = [], + profileImage, + nickname, + meta, + content, + rating = 5, + isMine = false, + onClick, + onEditClick, + onDeleteClick, +}: CourseReviewCardProps) { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_DESIGN_WIDTH); + const isClickable = Boolean(onClick); + const displayedRating = Math.min(Math.max(Math.round(rating), 0), 5); + const tapHandlers = useCardTap({ onTap: () => onClick?.() }); + + // 탭은 포인터로만 판정하므로 키보드 경로를 따로 둔다. + const handleKeyDown = (event: KeyboardEvent) => { + if (!onClick || event.currentTarget !== event.target) return; + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick(); + } + }; + + const metaItems = [ + { key: 'duration', icon: calendar, label: duration }, + { key: 'transport', icon: location, label: transport }, + { key: 'companion', icon: people, label: companion }, + ].filter((item) => Boolean(item.label)); + + const { + containerRef: metaContainerRef, + hiddenRef: hiddenMetaRef, + visibleCount: visibleMetaCount, + } = useVisibleItemCount( + metaItems.map((item) => item.label).join('|'), + metaItems.length, + ); + + const { + containerRef: tagContainerRef, + hiddenRef: hiddenTagRef, + visibleCount: visibleTagCount, + } = useVisibleItemCount(tags.join('|'), tags.length); + + return ( +
+
+
+
+ {image && ( + {title} + )} +
+ +
+

+ {title} +

+ + {/* 메타: 폭 측정 전용 */} + + + {/* 메타: 한 줄에 들어가는 만큼만 */} +
+ {metaItems.slice(0, visibleMetaCount).map((item) => ( + + + {item.label} + + ))} +
+ + {/* 태그: 폭 측정 전용 */} + + + {/* 태그: 한 줄에 들어가는 만큼만 */} +
+ {tags.slice(0, visibleTagCount).map((tag, index) => ( + + ))} +
+
+
+ + {/* 카드가 overflow-hidden이라 메뉴 패널은 포털로 뜬다. */} + {isMine && ( + + )} + +
+ +
+
+ {profileImage ? ( + {`${nickname} + ) : ( +
+ )} + +
+

+ {nickname} + · {meta} +

+
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+
+ +

+ {content} +

+
+
+
+ ); +} + +export default CourseReviewCard; diff --git a/src/components/common/CourseReviewCardSkeleton.tsx b/src/components/common/CourseReviewCardSkeleton.tsx new file mode 100644 index 00000000..13661804 --- /dev/null +++ b/src/components/common/CourseReviewCardSkeleton.tsx @@ -0,0 +1,70 @@ +import darkStar from '../../assets/icons/dark star.svg'; +import { useScaleFrame } from '../../hooks/useScaleFrame'; + +// CourseReviewCard와 같은 값이어야 로딩 중과 로딩 후의 카드 크기가 맞는다. +const CARD_DESIGN_WIDTH = 342; +const CARD_HEIGHT = 166; + +function CourseReviewCardSkeleton() { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_DESIGN_WIDTH); + + return ( +
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+ +
+
+ +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+
+
+
+ ); +} + +export default CourseReviewCardSkeleton; diff --git a/src/components/common/DatePicker/DatePicker.tsx b/src/components/common/DatePicker/DatePicker.tsx new file mode 100644 index 00000000..ad4cdd77 --- /dev/null +++ b/src/components/common/DatePicker/DatePicker.tsx @@ -0,0 +1,319 @@ +import { useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import calendarIcon from '../../../assets/icons/calendar.svg'; +import vector from '../../../assets/icons/vector.svg'; +import { + createCalendarDays, + formatDateValue, + isFutureDate, + isFutureMonth, + parseDateValue, +} from './calendar'; + +const WEEKDAY_LABELS = ['일', '월', '화', '수', '목', '금', '토']; + +interface DatePickerProps { + readonly value: string; + readonly placeholder: string; + readonly scale: number; + readonly ariaLabel: string; + readonly onChange: (value: string) => void; +} + +export default function DatePicker({ + value, + placeholder, + scale, + ariaLabel, + onChange, +}: DatePickerProps) { + const [isOpen, setIsOpen] = useState(false); + const [isMonthSelectorOpen, setIsMonthSelectorOpen] = useState(false); + const [displayedDate, setDisplayedDate] = useState( + () => parseDateValue(value) ?? new Date() + ); + const [draftYear, setDraftYear] = useState(() => displayedDate.getFullYear()); + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsOpen(false); + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); + + const year = displayedDate.getFullYear(); + const month = displayedDate.getMonth(); + const today = new Date(); + const calendarDays = createCalendarDays(year, month); + const canGoToNextMonth = !isFutureMonth(year, month + 1, today); + + const moveMonth = (offset: number) => { + const nextDate = new Date(year, month + offset, 1); + if (isFutureMonth(nextDate.getFullYear(), nextDate.getMonth(), today)) { + return; + } + + setIsMonthSelectorOpen(false); + setDisplayedDate(nextDate); + }; + + const toggleCalendar = () => { + if (!isOpen) { + const selectedDate = parseDateValue(value); + if (selectedDate) setDisplayedDate(selectedDate); + } + + setIsMonthSelectorOpen(false); + setIsOpen((current) => !current); + }; + + const toggleMonthSelector = () => { + setDraftYear(year); + setIsMonthSelectorOpen((current) => !current); + }; + + return ( +
+ + + {isOpen && typeof document !== 'undefined' + ? createPortal( +
setIsOpen(false)} + > +
event.stopPropagation()} + className="w-full max-w-[342px] rounded-xl border border-[#e4e4e4] bg-[#f9f9f9] p-4 shadow-[0_4px_12px_rgba(0,0,0,0.08)]" + > + {!isMonthSelectorOpen ? ( +
+ + + +
+ ) : null} + {isMonthSelectorOpen ? ( +
+
+ + + {draftYear}년 + + +
+
+ {Array.from({ length: 12 }, (_, monthIndex) => { + const isSelected = + draftYear === year && monthIndex === month; + const isDisabled = isFutureMonth( + draftYear, + monthIndex, + today + ); + + return ( + + ); + })} +
+
+ ) : ( +
+ {WEEKDAY_LABELS.map((weekday) => ( + + {weekday} + + ))} + {calendarDays.map((day, index) => { + const dayValue = + day === null + ? '' + : formatDateValue(new Date(year, month, day)); + const isSelected = dayValue === value; + const isDisabled = + day !== null && + isFutureDate(new Date(year, month, day), today); + + return day === null ? ( + + ) : ( + + ); + })} +
+ )} +
+
, + document.body + ) + : null} +
+ ); +} diff --git a/src/components/common/DatePicker/calendar.ts b/src/components/common/DatePicker/calendar.ts new file mode 100644 index 00000000..f591ddf2 --- /dev/null +++ b/src/components/common/DatePicker/calendar.ts @@ -0,0 +1,67 @@ +const DATE_VALUE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; + +export function createCalendarDays(year: number, month: number) { + const firstWeekday = new Date(year, month, 1).getDay(); + const lastDate = new Date(year, month + 1, 0).getDate(); + const calendarDays: Array = Array(42).fill(null); + + for (let day = 1; day <= lastDate; day += 1) { + calendarDays[firstWeekday + day - 1] = day; + } + + return calendarDays; +} + +export function formatDateValue(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +} + +export function parseDateValue(value: string) { + const matched = DATE_VALUE_PATTERN.exec(value); + if (!matched) return null; + + const year = Number(matched[1]); + const month = Number(matched[2]) - 1; + const day = Number(matched[3]); + const date = new Date(year, month, day); + + if ( + date.getFullYear() !== year || + date.getMonth() !== month || + date.getDate() !== day + ) { + return null; + } + + return date; +} + +export function isFutureDate(date: Date, today: Date = new Date()) { + const targetDate = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ); + const currentDate = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + + return targetDate.getTime() > currentDate.getTime(); +} + +export function isFutureMonth( + year: number, + month: number, + today: Date = new Date() +) { + return ( + year > today.getFullYear() || + (year === today.getFullYear() && month > today.getMonth()) + ); +} diff --git a/src/components/common/DatePicker/index.ts b/src/components/common/DatePicker/index.ts new file mode 100644 index 00000000..2ef49e25 --- /dev/null +++ b/src/components/common/DatePicker/index.ts @@ -0,0 +1 @@ +export { default } from './DatePicker'; diff --git a/src/components/common/EditableContentCard.tsx b/src/components/common/EditableContentCard.tsx new file mode 100644 index 00000000..acc1e879 --- /dev/null +++ b/src/components/common/EditableContentCard.tsx @@ -0,0 +1,277 @@ +import calendar from '../../assets/icons/calendar.svg'; +import location from '../../assets/icons/location.svg'; +import near from '../../assets/icons/near.svg'; +import people from '../../assets/icons/people.svg'; + +import { useGlobalScale } from '../../hooks/useGlobalScale'; +import { useVisibleItemCount } from '../../hooks/useVisibleItemCount'; +import { getCompanionIcon } from '../../utils/companionIcon'; + +import CardActionMenu from './CardActionMenu'; +import TagChip, { type TagType } from './TagChip'; + +// 모든 수치는 Figma 390 디자인 기준(카드 자체 폭 163 기준) 리터럴 px +const CARD_DESIGN_WIDTH = 163; +const CARD_HEIGHT = 222; +const IMAGE_HEIGHT = 115; +const CONTENT_HEIGHT = 107; +const CONTENT_PADDING = 8; +const TITLE_SIZE = 14; +const INFO_SIZE = 12; +/** 아이콘 슬롯. 아이콘은 원본 비율 그대로 이 슬롯 가운데에 놓는다(중심 x = 15). */ +const ICON_SIZE = 14; +const ICON_GAP = 2; +/** 태그 줄이 없는 카드(장소)는 남는 높이만큼 정보 줄을 넓게 벌린다. */ +const INFO_MARGIN_TOP = 8; +const INFO_ROW_GAP = 4; +const INFO_MARGIN_TOP_WIDE = 17; +const INFO_ROW_GAP_WIDE = 8; +const ACTION_SIZE = 16; +const TAG_HEIGHT = 20; +const TAG_GAP = 4; + +export interface EditableContentCardProps { + image: string | null; + title: string; + firstInfo: string; + secondInfo: string; + /** 두 번째 정보 줄에 함께 붙는 보조 정보(예: 동행 유형). 없으면 렌더링하지 않는다. */ + thirdInfo?: string; + /** 현 위치 기준 거리 줄(예: '현위치와 314KM'). 없으면 렌더링하지 않는다. */ + distanceInfo?: string; + className?: string; + tags?: TagType[]; + onClick?: () => void; + onEdit?: () => void; + onDelete?: () => void; +} + +/** 아이콘은 늘리지 않고 원본 크기 그대로 14px 슬롯 가운데에 놓는다. */ +function InfoIcon({ src }: { src: string }) { + return ( + + ); +} + +/** 수정/삭제 메뉴가 달린 ContentCard. 목록 소유자 화면(마이페이지 등)에서 좋아요 대신 사용한다. */ +function EditableContentCard({ + image, + title, + firstInfo, + secondInfo, + thirdInfo, + distanceInfo, + className = '', + tags, + onClick, + onEdit, + onDelete, +}: EditableContentCardProps) { + const scale = useGlobalScale(); + + const tagsKey = tags?.join('|') ?? ''; + const { + containerRef: visibleContainerRef, + hiddenRef: hiddenContainerRef, + visibleCount, + } = useVisibleItemCount(tagsKey, tags?.length ?? 0); + + const hasTags = Boolean(tags && tags.length > 0); + const isClickable = Boolean(onClick); + const hasCustomWidth = /(?:^|\s)(?:w-|min-w|max-w)/.test(className); + + return ( +
+
{ + if (!onClick || (event.key !== 'Enter' && event.key !== ' ')) { + return; + } + + event.preventDefault(); + onClick(); + }} + role={isClickable ? 'button' : undefined} + tabIndex={isClickable ? 0 : undefined} + className={`flex flex-col overflow-hidden rounded-xl bg-[#F9F9F9] shadow-[0_1px_5px_rgba(0,0,0,0.07)] ${isClickable ? 'cursor-pointer' : ''} `} + style={{ + width: CARD_DESIGN_WIDTH, + height: CARD_HEIGHT, + transform: `scale(${scale})`, + transformOrigin: 'top left', + }} + > + {/* Image */} +
+ {image ? ( + {title} + ) : ( +
+ )} + + +
+ + {/* Content */} +
+ {/* Title */} +

+ {title} +

+ + {/* Info */} +
+
+ + + + {firstInfo} + +
+ +
+ + + + {secondInfo} + + + {thirdInfo ? ( + <> + {(() => { + const CompanionIcon = getCompanionIcon(thirdInfo); + return CompanionIcon ? ( + + ) : ( + + ); + })()} + + + {thirdInfo} + + + ) : null} +
+ + {distanceInfo ? ( +
+ + + + {distanceInfo} + +
+ ) : null} +
+ + {/* Tags */} + {hasTags && tags && ( + <> + + +
+
+ {tags.slice(0, visibleCount).map((tag, index) => ( + + ))} +
+
+ + )} +
+
+
+ ); +} + +export default EditableContentCard; diff --git a/src/components/common/EditableCourseCard.tsx b/src/components/common/EditableCourseCard.tsx new file mode 100644 index 00000000..d4abfd5b --- /dev/null +++ b/src/components/common/EditableCourseCard.tsx @@ -0,0 +1,238 @@ +import calendar from '../../assets/icons/calendar.svg'; +import location from '../../assets/icons/location.svg'; +import people from '../../assets/icons/people.svg'; + +import { useScaleFrame } from '../../hooks/useScaleFrame'; +import { useVisibleItemCount } from '../../hooks/useVisibleItemCount'; +import { getCompanionIcon } from '../../utils/companionIcon'; + +import CardActionMenu from './CardActionMenu'; +import TagChip, { type TagType } from './TagChip'; + +// 모든 수치는 Figma 390 디자인 기준 리터럴 px. +// 개별 vw 계산 대신 useScaleFrame이 전체를 한 번에 scale한다. +const CARD_DESIGN_WIDTH = 342; +const CARD_MIN_HEIGHT = 100; +const IMAGE_WIDTH = 136; +const IMAGE_RADIUS = 8; +const CONTENT_PADDING_LEFT = 16; +const CONTENT_PADDING_RIGHT = 42; // fix 버튼(20) + 간격(10) + 카드 우측 여백(12) +const ACTION_SIZE = 20; +const TAG_HEIGHT = 20; +const TAG_GAP = 4; + +export interface EditableCourseCardProps { + image: string; + title: string; + duration: string; + courseType: string; + companion: string; + tags: TagType[]; + onClick?: () => void; + onEdit?: () => void; + onDelete?: () => void; +} + +/** 수정/삭제 메뉴가 달린 CourseCard. 목록 소유자 화면(마이페이지 등)에서 좋아요 대신 사용한다. */ +function EditableCourseCard({ + image, + title, + duration, + courseType, + companion, + tags, + onClick, + onEdit, + onDelete, +}: EditableCourseCardProps) { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_DESIGN_WIDTH); + + const metaItems = [ + { key: 'duration', icon: calendar, label: duration }, + { key: 'courseType', icon: location, label: courseType }, + { key: 'companion', icon: people, label: companion }, + ].filter((item) => Boolean(item.label)); + + const metaKey = metaItems.map((item) => item.label).join('|'); + const tagsKey = tags.join('|'); + + const { + containerRef: metaContainerRef, + hiddenRef: hiddenMetaRef, + visibleCount: visibleMetaCount, + } = useVisibleItemCount(metaKey, metaItems.length); + + const { + containerRef: tagContainerRef, + hiddenRef: hiddenTagRef, + visibleCount: visibleTagCount, + } = useVisibleItemCount(tagsKey, tags.length); + + const isClickable = Boolean(onClick); + + return ( +
+
{ + if (!onClick || (event.key !== 'Enter' && event.key !== ' ')) { + return; + } + + event.preventDefault(); + onClick(); + }} + role={isClickable ? 'button' : undefined} + tabIndex={isClickable ? 0 : undefined} + className={`relative flex overflow-hidden rounded-xl bg-[#F9F9F9] shadow-[0_1px_5px_rgba(0,0,0,0.07)] ${isClickable ? 'cursor-pointer' : ''}`} + style={{ + width: CARD_DESIGN_WIDTH, + minHeight: CARD_MIN_HEIGHT, + transform: `scale(${scale})`, + transformOrigin: 'top left', + }} + > + {/* Image: 세로형 원본이 들어와도 카드 높이를 밀어올리지 않도록 + 래퍼가 높이를 잡고 img 는 그 안을 채운다. */} +
+ {image && ( + {title} + )} +
+ + {/* Text column: Figma 스펙대로 제목/메타/태그 모두 동일한 우측 여백(fix 버튼 자리) 공유 */} +
+ {/* Title */} +

+ {title} +

+ + {/* Meta: 측정 전용 hidden 영역 */} + + + {/* Meta: 실제로 보여지는 부분 */} +
+ {metaItems.slice(0, visibleMetaCount).map((item) => { + const CompanionIcon = + item.key === 'companion' ? getCompanionIcon(item.label) : null; + + return ( +
+ {CompanionIcon ? ( + + ) : ( + + )} + + + {item.label} + +
+ ); + })} +
+ + {/* Tags: 측정 전용 hidden 영역 */} + + + {/* Tags: 실제로 보여지는 태그 */} +
+ {tags.slice(0, visibleTagCount).map((tag, index) => ( + + ))} +
+
+ + +
+
+ ); +} + +export default EditableCourseCard; diff --git a/src/components/common/FloatingActionButton.tsx b/src/components/common/FloatingActionButton.tsx new file mode 100644 index 00000000..29df8d7f --- /dev/null +++ b/src/components/common/FloatingActionButton.tsx @@ -0,0 +1,53 @@ +import { createPortal } from 'react-dom'; + +import addIcon from '../../assets/icons/material-symbols_add-2-rounded.svg'; +import { APP_MAX_WIDTH, GLOBAL_DESIGN_WIDTH } from '../../constants/layout'; +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +const BUTTON_SIZE = 56; +const BUTTON_BOTTOM = 40; +const BUTTON_RIGHT = 24; +const ICON_SIZE = 32; + +interface FloatingActionButtonProps { + ariaLabel: string; + onClick: () => void; + bottomOffset?: number; + className?: string; +} + +function FloatingActionButton({ + ariaLabel, + onClick, + bottomOffset = BUTTON_BOTTOM, + className = '', +}: FloatingActionButtonProps) { + const scale = useGlobalScale(); + const button = ( + + ); + + return typeof document === 'undefined' + ? button + : createPortal(button, document.body); +} + +export default FloatingActionButton; diff --git a/src/components/common/LoadingSpinner.tsx b/src/components/common/LoadingSpinner.tsx new file mode 100644 index 00000000..03338b96 --- /dev/null +++ b/src/components/common/LoadingSpinner.tsx @@ -0,0 +1,28 @@ +import loadingIcon from '../../assets/icons/loading.svg'; + +interface LoadingSpinnerProps { + label?: string; + className?: string; +} + +function LoadingSpinner({ + label = '로딩 중', + className = '', +}: LoadingSpinnerProps) { + return ( +
+ +
+ ); +} + +export default LoadingSpinner; diff --git a/src/components/common/LoginRequiredModal.tsx b/src/components/common/LoginRequiredModal.tsx new file mode 100644 index 00000000..ab6c34fb --- /dev/null +++ b/src/components/common/LoginRequiredModal.tsx @@ -0,0 +1,168 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; + +import closeIcon from '../../assets/icons/close.svg'; +import { GLOBAL_CONTENT_WIDTH } from '../../constants/layout'; +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +const MODAL_RADIUS = 12; +const MODAL_PADDING = 20; + +const CLOSE_SIZE = 24; +const CLOSE_TOP = 20; +const CLOSE_RIGHT = 20; + +const TITLE_SIZE = 18; +const DESCRIPTION_SIZE = 14; + +const TITLE_GAP = 4; +const BUTTON_GAP = 8; +const BUTTON_HEIGHT = 43; +const BUTTON_RADIUS = 8; + +interface LoginRequiredModalProps { + isOpen: boolean; + onClose: () => void; + onLogin: () => void; +} + +function LoginRequiredModal({ + isOpen, + onClose, + onLogin, +}: LoginRequiredModalProps) { + const scale = useGlobalScale(); + + useEffect(() => { + if (!isOpen) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const handleLogin = () => { + onClose(); + onLogin(); + }; + + const modal = ( +
+
e.stopPropagation()} + className="relative bg-white shadow-[0_1px_5px_rgba(0,0,0,0.07)]" + style={{ + width: GLOBAL_CONTENT_WIDTH * scale, + borderRadius: MODAL_RADIUS * scale, + padding: MODAL_PADDING * scale, + }} + > + {/* Close */} + + + {/* Text */} +
+

+ 로그인 하시겠습니까? +

+ +

+ 로그인이 필요한 서비스입니다. +

+
+ + {/* Buttons */} +
+ + + +
+
+
+ ); + + return createPortal(modal, document.body); +} + +export default LoginRequiredModal; \ No newline at end of file diff --git a/src/components/common/Logo.tsx b/src/components/common/Logo.tsx new file mode 100644 index 00000000..b0c6652f --- /dev/null +++ b/src/components/common/Logo.tsx @@ -0,0 +1,29 @@ +import { Link } from 'react-router-dom'; + +import logo from '../../assets/icons/logo.svg'; +import yeogido from '../../assets/icons/yeogido.svg'; + +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +const GAP = 8; + +function Logo() { + const scale = useGlobalScale(); + + return ( + + + 여기도 + + ); +} + +export default Logo; \ No newline at end of file diff --git a/src/components/common/PromotionCard.tsx b/src/components/common/PromotionCard.tsx new file mode 100644 index 00000000..ae5a0c13 --- /dev/null +++ b/src/components/common/PromotionCard.tsx @@ -0,0 +1,226 @@ +import heart from '../../assets/icons/heart.svg'; +import location from '../../assets/icons/location.svg'; +import oheart from '../../assets/icons/oheart.svg'; + +import { useScaleFrame } from '../../hooks/useScaleFrame'; + +import ReviewActionMenu from './ReviewActionMenu'; + +const CARD_DESIGN_WIDTH = 342; +const HEART_SIZE = 16; +const HEART_TOP = 8; +const HEART_RIGHT = 8; + +const AVATAR_SIZE = 40; +const PROFILE_GAP = 8; +const HEADER_PADDING_X = 12; +const HEADER_PADDING_TOP = 12; +const HEADER_PADDING_BOTTOM = 8; +const PROFILE_HEADER_HEIGHT = + HEADER_PADDING_TOP + AVATAR_SIZE + HEADER_PADDING_BOTTOM; +const NAME_SIZE = 14; +const DATE_GAP = 4; +const DATE_SIZE = 12; + +const IMAGE_HEIGHT = 266; + +const BODY_PADDING_X = 12; +const BODY_PADDING_TOP = 12; +const BODY_PADDING_BOTTOM = 12; +const TITLE_SIZE = 15; +const DESCRIPTION_GAP = 6; +const DESCRIPTION_SIZE = 13; +const LOCATION_GAP = 12; +const LOCATION_ICON_GAP = 6; +const LOCATION_ICON_SIZE = 14; +const LOCATION_TEXT_SIZE = 13; + +export interface PromotionCardProps { + avatarUrl: string; + profileName: string; + date: string; + imageUrl: string; + title: string; + description: string; + location: string; + isMine?: boolean; + liked?: boolean; + onClick?: () => void; + onEditClick?: () => void; + onDeleteClick?: () => void; + onLikeClick?: () => void; + className?: string; +} + +function PromotionCard({ + avatarUrl, + profileName, + date, + imageUrl, + title, + description, + location: locationText, + isMine = false, + liked = false, + onClick, + onEditClick, + onDeleteClick, + onLikeClick, + className = '', +}: PromotionCardProps) { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_DESIGN_WIDTH); + + return ( +
+
+
{ + if (!event.repeat && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onClick?.(); + } + }} + role="button" + tabIndex={0} + className="flex w-full cursor-pointer flex-col overflow-hidden rounded-xl bg-white text-left shadow-[0_1px_5px_rgba(0,0,0,0.07)]" + > +
+
+ + +
+

+ {profileName} +

+

+ {date} +

+
+
+ + {isMine && (onEditClick || onDeleteClick) ? ( + + ) : null} +
+ + {title} + +
+

+ {title} +

+

+ {description} +

+
+
+ + + {locationText} + +
+
+
+
+ + {!isMine && onLikeClick ? ( + + ) : null} +
+
+ ); +} + +export default PromotionCard; diff --git a/src/components/common/PromotionCardSkeleton.tsx b/src/components/common/PromotionCardSkeleton.tsx new file mode 100644 index 00000000..47aa6ab1 --- /dev/null +++ b/src/components/common/PromotionCardSkeleton.tsx @@ -0,0 +1,177 @@ +import location from '../../assets/icons/location.svg'; + +import { useScaleFrame } from '../../hooks/useScaleFrame'; + +const CARD_DESIGN_WIDTH = 342; + +const AVATAR_SIZE = 40; +const PROFILE_GAP = 8; +const HEADER_PADDING_X = 12; +const HEADER_PADDING_TOP = 12; +const HEADER_PADDING_BOTTOM = 8; + +const NAME_WIDTH = 80; +const NAME_HEIGHT = 14; + +const DATE_WIDTH = 56; +const DATE_HEIGHT = 12; +const DATE_GAP = 4; + +const IMAGE_HEIGHT = 266; + +const BODY_PADDING_X = 12; +const BODY_PADDING_TOP = 12; +const BODY_PADDING_BOTTOM = 12; + +const TITLE_WIDTH = 180; +const TITLE_HEIGHT = 15; + +const DESCRIPTION_GAP = 6; +const DESCRIPTION_HEIGHT = 13; + +const LOCATION_GAP = 12; +const LOCATION_ICON_GAP = 6; +const LOCATION_ICON_SIZE = 14; +const LOCATION_TEXT_WIDTH = 90; +const LOCATION_TEXT_HEIGHT = 13; + +interface PromotionCardSkeletonProps { + className?: string; +} + +function PromotionCardSkeleton({ + className = '', +}: PromotionCardSkeletonProps) { + const { outerRef, innerRef, scale, scaledHeight } = + useScaleFrame(CARD_DESIGN_WIDTH); + + return ( +
+
+ {/* Header */} +
+
+ +
+
+ +
+
+
+ + {/* Image */} +
+ + {/* Body */} +
+
+ +
+ +
+ +
+
+ + +
+
+
+
+
+
+ ); +} + +export default PromotionCardSkeleton; \ No newline at end of file diff --git a/src/components/common/RegionHero.tsx b/src/components/common/RegionHero.tsx new file mode 100644 index 00000000..8a4c7c2c --- /dev/null +++ b/src/components/common/RegionHero.tsx @@ -0,0 +1,76 @@ +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +const TITLE_TOP = 53; +const TITLE_LEFT = 20; +const TITLE_WIDTH = 192; +const TITLE_SIZE = 20; +const TITLE_LINE_HEIGHT = 20; +const DESCRIPTION_MARGIN_TOP = 4; +const DESCRIPTION_SIZE = 12; +const DESCRIPTION_LINE_HEIGHT = 12; + +interface RegionHeroProps { + image: string; + title: string; + description: string; + alt: string; + onImageError?: () => void; +} + +function RegionHero({ + image, + title, + description, + alt, + onImageError, +}: RegionHeroProps) { + const scale = useGlobalScale(); + + return ( +
+ + +
+ +
+

+ {title} +

+

+ {description} +

+
+
+ ); +} + +export default RegionHero; diff --git a/src/components/common/RegionHeroSkeleton.tsx b/src/components/common/RegionHeroSkeleton.tsx new file mode 100644 index 00000000..fd4d5c7c --- /dev/null +++ b/src/components/common/RegionHeroSkeleton.tsx @@ -0,0 +1,17 @@ +function RegionHeroSkeleton() { + return ( +
+
+ +
+ +
+
+ ); +} + +export default RegionHeroSkeleton; \ No newline at end of file diff --git a/src/components/common/RegionImageCarousel.tsx b/src/components/common/RegionImageCarousel.tsx new file mode 100644 index 00000000..80c12fbb --- /dev/null +++ b/src/components/common/RegionImageCarousel.tsx @@ -0,0 +1,87 @@ +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +const CAROUSEL_DESIGN_WIDTH = 342; +const ITEM_SIZE = 64; +const ITEM_GAP = 8; +const LABEL_SIZE = 14; + +export interface RegionImageOption { + id: string; + name: string; + imageSrc: string; +} + +interface RegionImageCarouselProps { + options: readonly T[]; + selectedId: string; + ariaLabel: string; + onSelect: (option: T) => void; +} + +function RegionImageCarousel({ + options, + selectedId, + ariaLabel, + onSelect, +}: RegionImageCarouselProps) { + const scale = useGlobalScale(); + + return ( +
+ {options.map((option) => { + const isSelected = selectedId === option.id; + + return ( +
+ +
+ ); + })} +
+ ); +} + +export default RegionImageCarousel; diff --git a/src/components/common/ReviewActionMenu.tsx b/src/components/common/ReviewActionMenu.tsx new file mode 100644 index 00000000..f2691be3 --- /dev/null +++ b/src/components/common/ReviewActionMenu.tsx @@ -0,0 +1,191 @@ +import { useEffect, useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import more from '../../assets/icons/more.svg'; +import { useGlobalScale } from '../../hooks/useGlobalScale'; + +// 여행 기록 상세의 작업 메뉴와 같은 치수/생김새를 쓴다. +const TRIGGER_SIZE = 20; +const MENU_WIDTH = 80; +const MENU_ITEM_HEIGHT = 36; +const MENU_GAP = 4; + +export interface ReviewActionMenuProps { + /** + * 넘기지 않으면 메뉴에서 '수정'이 빠진다. 코스별 후기 응답에는 imageKey가 + * 없어 유지할 사진을 지목할 수 없으므로, 그 응답을 쓰는 화면(코스 상세· + * 코스 후기 전체보기)은 수정을 열지 않는다. + */ + onEditClick?: () => void; + showEdit?: boolean; + onDeleteClick?: () => void; + /** 카드마다 버튼이 놓이는 자리가 달라 트리거 배치는 밖에서 정한다. */ + triggerClassName?: string; + /** 카드 종류마다 스크린리더 안내를 다르게 하려면 넘긴다. */ + ariaLabel?: string; +} + +/** + * 본인이 쓴 후기 카드의 더보기(⋯) 메뉴. + * + * 정렬 드롭다운과 같은 방식으로, 패널을 body에 포털로 띄우고 버튼 위치에 + * 맞춰 고정한다. 카드가 overflow-hidden이라 안에서 그리면 잘린다. + */ +function ReviewActionMenu({ + onEditClick, + showEdit = false, + onDeleteClick, + triggerClassName = '-mt-[3px] -mr-[3px] ml-2', + ariaLabel = '리뷰 메뉴', +}: ReviewActionMenuProps) { + const scale = useGlobalScale(); + const triggerId = useId(); + const menuId = useId(); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [panelStyle, setPanelStyle] = useState< + { top: number; left: number } | undefined + >(); + + useEffect(() => { + const handlePointerDown = (event: PointerEvent) => { + const target = event.target as Node; + + if ( + !triggerRef.current?.contains(target) && + !menuRef.current?.contains(target) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + + return () => document.removeEventListener('pointerdown', handlePointerDown); + }, []); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + triggerRef.current?.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const updatePanelStyle = () => { + const trigger = triggerRef.current; + if (!trigger) return; + + const rect = trigger.getBoundingClientRect(); + const width = MENU_WIDTH * scale; + + setPanelStyle({ + top: rect.bottom + MENU_GAP * scale, + // 카드 오른쪽 끝에 붙은 버튼이라 오른쪽 정렬로 띄우되, 화면 밖으로 + // 나가지 않게 가둔다. + left: Math.max(MENU_GAP, Math.min(rect.right - width, window.innerWidth - width - MENU_GAP)), + }); + }; + + updatePanelStyle(); + window.addEventListener('resize', updatePanelStyle); + + return () => { + window.removeEventListener('resize', updatePanelStyle); + }; + }, [isOpen, scale]); + + const handleSelect = (action?: () => void) => { + setIsOpen(false); + action?.(); + }; + + // 동작이 붙어 있는 항목만 내보낸다. 후기 화면 넷은 모두 수정·삭제를 다 + // 붙이지만, 핸들러를 넘기지 않은 호출부에서 눌러도 아무 일 없는 항목이 + // 남지 않게 한다. + const menuItems = [ + { + key: 'edit', + label: '수정', + action: onEditClick, + show: showEdit || Boolean(onEditClick), + }, + { key: 'delete', label: '삭제', action: onDeleteClick, show: Boolean(onDeleteClick) }, + ].filter((item) => item.show); + + if (menuItems.length === 0) { + return null; + } + + return ( + <> + + + {isOpen && panelStyle + ? createPortal( + , + document.body + ) + : null} + + ); +} + +export default ReviewActionMenu; diff --git a/src/components/common/ReviewCard.tsx b/src/components/common/ReviewCard.tsx new file mode 100644 index 00000000..47cfacbc --- /dev/null +++ b/src/components/common/ReviewCard.tsx @@ -0,0 +1,270 @@ +import type { KeyboardEvent } from 'react'; + +import darkStar from '../../assets/icons/dark star.svg'; +import star from '../../assets/icons/star.svg'; + +import { useGlobalScale } from '../../hooks/useGlobalScale'; +import { useCardTap } from '../../hooks/useCardTap'; + +import ReviewActionMenu from './ReviewActionMenu'; + +// Figma 390 디자인 기준 리터럴 px (카드 자체 폭 290 기준) +const CARD_DESIGN_WIDTH = 342; +const CARD_DESIGN_HEIGHT = 286; +const COURSE_REVIEW_LIST_CARD_HEIGHT = 278; +const CARD_PADDING = 16; +const SECTION_GAP = 12; +const CARD_RADIUS = 12; + +const IMAGE_SIZE = 129; +const IMAGE_GAP = 12; +const IMAGE_RADIUS = 10; + +const TEXT_FONT_SIZE = 14; +const TEXT_LINE_HEIGHT = 18; + +const AVATAR_SIZE = 28; +const PROFILE_GAP = 8; + +const NAME_META_SIZE = 12; +const NAME_META_GAP = 2; + +const STAR_SIZE = 14; +const STAR_GAP = 2; + +export interface ReviewCardProps { + images?: string[]; + courseTitle?: string; + profileImage: string; + nickname: string; + meta: string; + content: string; + rating?: number; + isMine?: boolean; + onClick?: () => void; + onEditClick?: () => void; + onDeleteClick?: () => void; + variant?: 'default' | 'course-review-list'; + className?: string; +} + +function ReviewCard({ + images = [], + courseTitle, + profileImage, + nickname, + meta, + content, + rating = 5, + isMine = false, + onClick, + onEditClick, + onDeleteClick, + variant = 'default', + className = '', +}: ReviewCardProps) { + const scale = useGlobalScale(); + const isCourseReviewList = variant === 'course-review-list'; + const cardHeight = isCourseReviewList + ? COURSE_REVIEW_LIST_CARD_HEIGHT + : CARD_DESIGN_HEIGHT; + + const isClickable = Boolean(onClick); + const displayedRating = Math.min(Math.max(Math.round(rating), 0), 5); + const hasSingleImage = images.length === 1; + const tapHandlers = useCardTap({ onTap: () => onClick?.() }); + + // 탭은 포인터로만 판정하므로 키보드 경로를 따로 둔다. + const handleKeyDown = (event: KeyboardEvent) => { + if (!onClick || event.currentTarget !== event.target) return; + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onClick(); + } + }; + + return ( +
+
+
+ {/* Images: 카드 내부 가로 스크롤, 다음 이미지가 살짝 보이는 peek 효과 */} + {images.length > 0 && ( +
+ {images.map((src, index) => ( +
+ {src && ( + + )} +
+ ))} + + {/* 마지막 이미지 뒤에도 카드 패딩만큼 여백 확보 */} + {!hasSingleImage && ( + + )} + + {/* Text + Profile */} +
+
+

+ {courseTitle ?? ''} +

+ + {isMine ? ( + + ) : null} +
+ +

+ {content} +

+ +
+ {profileImage ? ( + {`${nickname} + ) : ( +
+ )} + +
+
+ + {nickname} + + + + · + + + + {meta} + +
+ +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+
+
+
+
+
+ ); +} + +export default ReviewCard; diff --git a/src/components/common/ReviewCardSkeleton.tsx b/src/components/common/ReviewCardSkeleton.tsx new file mode 100644 index 00000000..8fd90cb4 --- /dev/null +++ b/src/components/common/ReviewCardSkeleton.tsx @@ -0,0 +1,133 @@ +import { useGlobalScale } from '../../hooks/useGlobalScale'; +import { useMeasuredScaledHeight } from '../../hooks/useMeasuredScaledHeight'; +import darkStar from '../../assets/icons/dark star.svg'; +import more from '../../assets/icons/more.svg'; + +// ReviewCard와 동일한 디자인 기준 +const CARD_DESIGN_WIDTH = 342; +const CARD_PADDING = 16; +const CARD_RADIUS = 12; +const HOME_CARD_HEIGHT = 286; +const COURSE_REVIEW_LIST_CARD_HEIGHT = 278; + +const IMAGE_SIZE = 129; +const IMAGE_GAP = 12; + +const AVATAR_SIZE = 28; +const PROFILE_GAP = 8; + +interface ReviewCardSkeletonProps { + variant?: 'home' | 'course-review-list'; +} + +function ReviewCardSkeleton({ variant = 'home' }: ReviewCardSkeletonProps) { + const scale = useGlobalScale(); + const { innerRef } = useMeasuredScaledHeight(scale); + const cardHeight = + variant === 'course-review-list' + ? COURSE_REVIEW_LIST_CARD_HEIGHT + : HOME_CARD_HEIGHT; + + return ( +
+
+
+ {/* Images */} +
+ {Array.from({ length: 2 }).map((_, index) => ( +
+ ))} + + {/* peek */} +
+
+ + {/* Text + Profile */} +
+
+
+ +
+ {/* Review */} +
+
+
+
+ + {/* Profile */} +
+
+ +
+
+ +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+
+
+
+
+
+ ); +} + +export default ReviewCardSkeleton; diff --git a/src/components/common/ReviewDeleteDialog.tsx b/src/components/common/ReviewDeleteDialog.tsx new file mode 100644 index 00000000..abdaa366 --- /dev/null +++ b/src/components/common/ReviewDeleteDialog.tsx @@ -0,0 +1,26 @@ +import ConfirmDialog from './ConfirmDialog'; + +export interface ReviewDeleteDialogProps { + isOpen: boolean; + isPending: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * 후기 삭제 확인 다이얼로그. + * + * 후기 카드가 놓인 네 화면이 같은 문구를 쓰므로, 문구를 여기 한곳에만 둔다. + * useReviewDelete가 돌려주는 dialogProps를 그대로 펼쳐 넣으면 된다. + */ +function ReviewDeleteDialog(props: ReviewDeleteDialogProps) { + return ( + + ); +} + +export default ReviewDeleteDialog; diff --git a/src/components/common/ReviewDetailModal.tsx b/src/components/common/ReviewDetailModal.tsx new file mode 100644 index 00000000..11ca48f9 --- /dev/null +++ b/src/components/common/ReviewDetailModal.tsx @@ -0,0 +1,229 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import closeRounded from '../../assets/icons/close-rounded.svg'; +import darkStar from '../../assets/icons/dark star.svg'; +import star from '../../assets/icons/star.svg'; + +const PHOTO_SIZE = 129; +const PHOTO_GAP = 12; +const STAR_SIZE = 14; + +export interface ReviewDetailModalReview { + images?: string[]; + content: string; + profileImage: string; + nickname: string; + meta: string; + rating?: number; +} + +export interface ReviewDetailModalProps { + /** 열려 있을 때의 후기. 없으면 닫힌 상태다. */ + review: ReviewDetailModalReview | undefined; + /** 코스명을 알 수 있을 때만 제목에 넣는다. */ + courseTitle?: string; + onClose: () => void; + /** 코스를 특정할 수 있을 때만 '코스 바로가기'가 나온다. */ + onGoToCourse?: () => void; +} + +/** + * 후기 카드를 길게 눌렀을 때 뜨는 후기 상세 모달. + * + * 카드에서는 사진과 본문이 잘려 보이므로, 여기서는 자르지 않고 전부 보여준다. + */ +function ReviewDetailModal({ + review, + courseTitle, + onClose, + onGoToCourse, +}: ReviewDetailModalProps) { + const isOpen = Boolean(review); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + /** + * 닫기를 브라우저의 click 생성에 의존하지 않는다. + * + * 이 모달은 카드를 길게 누르는 중에 손가락(마우스) 아래에서 열린다. 그 + * 포인터를 모달 위에서 떼면 pointerdown 대상(카드)과 pointerup 대상(모달)이 + * 달라 브라우저가 click을 만들지 않는다. 그러면 X도 딤도 반응하지 않고, + * 딤이 화면 전체를 덮고 있어 페이지 전체가 멈춘 것처럼 보인다. + * + * 그래서 같은 요소에서 눌렀다 뗐는지를 직접 판정한다. 다만 pointerup으로 + * 닫으면 뒤따르는 click이 모달이 사라진 자리의 요소로 떨어지므로(click + * through) 그 한 번은 삼킨다. + */ + const pressedPointerIdRef = useRef(null); + + const closeAndSwallowClick = useCallback(() => { + const swallow = (event: MouseEvent) => { + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + document.addEventListener('click', swallow, true); + window.setTimeout( + () => document.removeEventListener('click', swallow, true), + 0 + ); + + onClose(); + }, [onClose]); + + const handleTapStart = (event: { pointerId: number }) => { + pressedPointerIdRef.current = event.pointerId; + }; + + const handleTapEnd = (event: { pointerId: number }) => { + const isSamePointer = pressedPointerIdRef.current === event.pointerId; + pressedPointerIdRef.current = null; + + // 모달이 열리기 전에 시작된 포인터는 여기서 걸러진다. + if (isSamePointer) { + closeAndSwallowClick(); + } + }; + + if (!review || typeof document === 'undefined') { + return null; + } + + const { images = [], content, profileImage, nickname, meta, rating = 5 } = + review; + const displayedRating = Math.min(Math.max(Math.round(rating), 0), 5); + const title = courseTitle ? `${courseTitle}의 후기예요!` : '여행자의 후기예요!'; + + return createPortal( +
+
event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + className="relative w-full max-w-[342px] rounded-xl bg-[#f9f9f9] px-6 pt-11 pb-5 shadow-[0_1px_5px_rgba(0,0,0,0.07)]" + > + + +

+ {title} +

+

+ 직접 다녀온 여행자의 생생한 후기를 확인해보세요 +

+ + {images.length > 0 && ( +
+ {images.map((src, index) => ( +
+ {src && ( + + )} +
+ ))} + {/* 마지막 사진 뒤에도 모달 패딩만큼 여백을 남긴다. */} +
+ )} + +

+ {content} +

+ +
+ {profileImage ? ( + {`${nickname} + ) : ( +
+ )} + +
+

+ {nickname} + {meta && ( + · {meta} + )} +

+
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+
+ + {onGoToCourse && ( + + )} +
+
, + document.body + ); +} + +export default ReviewDetailModal; diff --git a/src/components/common/ReviewEditModal.tsx b/src/components/common/ReviewEditModal.tsx new file mode 100644 index 00000000..70c6b15e --- /dev/null +++ b/src/components/common/ReviewEditModal.tsx @@ -0,0 +1,293 @@ +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import addPhoto from '../../pages/travel-record/photo-selection/assets/photo-add-icon.svg'; +import closeRounded from '../../assets/icons/close-rounded.svg'; +import darkStar from '../../assets/icons/dark star.svg'; +import star from '../../assets/icons/star.svg'; +import { useGlobalScale } from '../../hooks/useGlobalScale'; +import type { + EditableReview, + ReviewEditSubmission, +} from '../../hooks/useReviews'; +import { + DEFAULT_REVIEW_RATING, + isReviewFormValid, + MAX_REVIEW_PHOTOS, + REVIEW_CONTENT_MAX_LENGTH, + REVIEW_CONTENT_PLACEHOLDER, +} from '../../pages/review/reviewForm'; + +/** + * 화면에 놓인 사진 한 장. + * + * 기존 사진은 imageKey를 그대로 되돌려 보내야 서버가 유지하고, 새로 고른 + * 사진은 저장할 때 업로드해서 key를 받는다. 그래서 둘을 구분해 들고 있는다. + */ +type EditablePhoto = + | { id: string; kind: 'existing'; imageKey: string; src: string } + | { id: string; kind: 'new'; file: File; src: string }; + +export interface ReviewEditModalProps { + /** 수정할 후기. 없으면 닫힌 상태다. */ + review: EditableReview | undefined; + isPending?: boolean; + onClose: () => void; + onSubmit: (submission: ReviewEditSubmission) => void; +} + +function ReviewEditModal({ + review, + isPending = false, + onClose, + onSubmit, +}: ReviewEditModalProps) { + const scale = Math.min(useGlobalScale(), 1); + const fileInputRef = useRef(null); + // 호출부가 후기 id를 key로 주므로, 다른 후기를 열면 이 컴포넌트가 새로 + // 마운트되면서 아래 초기값이 다시 계산된다. + const [photos, setPhotos] = useState(() => + (review?.editableImages ?? []) + .slice(0, MAX_REVIEW_PHOTOS) + .map(({ imageKey, imageUrl }) => ({ + id: `existing-${imageKey}`, + kind: 'existing' as const, + imageKey, + src: imageUrl, + })) + ); + const [rating, setRating] = useState(() => + Math.min(Math.max(Math.round(review?.rating ?? DEFAULT_REVIEW_RATING), 1), 5) + ); + const [content, setContent] = useState(() => review?.content ?? ''); + const photosRef = useRef(photos); + + useEffect(() => { + photosRef.current = photos; + }, [photos]); + + // 새로 고른 사진의 미리보기 URL은 화면을 벗어날 때 해제한다. + useEffect( + () => () => { + photosRef.current.forEach((photo) => { + if (photo.kind === 'new') URL.revokeObjectURL(photo.src); + }); + }, + [] + ); + + if (!review || typeof document === 'undefined') return null; + + const removePhoto = (id: string) => { + setPhotos((current) => { + const removed = current.find((photo) => photo.id === id); + if (removed?.kind === 'new') URL.revokeObjectURL(removed.src); + return current.filter((photo) => photo.id !== id); + }); + }; + + // FileList는 input에 묶인 live 객체라 input.value를 비우면 함께 비워진다. + // 상태 갱신 함수 안에서 읽으면 그때는 이미 비어 있어 아무 사진도 안 붙는다. + // 여기서 즉시 배열로 떠 두고, 미리보기 URL도 업데이터 밖에서 만든다. + const handleFiles = (files: FileList | null) => { + // 음수면 slice가 뒤에서 잘라내 오히려 상한을 넘겨 담는다. + const remaining = Math.max(MAX_REVIEW_PHOTOS - photos.length, 0); + const additions = Array.from(files ?? []) + .slice(0, remaining) + .map((file) => { + const src = URL.createObjectURL(file); + return { id: src, kind: 'new' as const, file, src }; + }); + + if (additions.length === 0) return; + + setPhotos((current) => [...current, ...additions]); + }; + + const canSubmit = + !isPending && + isReviewFormValid({ rating, review: content, photoCount: photos.length }); + + // 사진을 손대지 않았으면 목록을 보내지 않는다. 명세대로 서버가 기존 + // 이미지를 그대로 두므로 헛된 삭제/삽입이 없다. + const initialImageKeys = review.editableImages + .slice(0, MAX_REVIEW_PHOTOS) + .map(({ imageKey }) => imageKey); + const isPhotosUnchanged = + photos.length === initialImageKeys.length && + photos.every( + (photo, index) => + photo.kind === 'existing' && photo.imageKey === initialImageKeys[index] + ); + + const handleSubmit = () => { + if (!canSubmit) return; + + onSubmit({ + rating, + content: content.trim(), + photos: isPhotosUnchanged + ? undefined + : photos.map((photo) => + photo.kind === 'existing' + ? { imageKey: photo.imageKey } + : { file: photo.file } + ), + }); + }; + + return createPortal( +
+
+ +

+ 후기를 수정해요 +

+

+ 사진과 후기를 수정할 수 있어요 +

+ +
+
+

+ 선택된 사진{' '} + + ({photos.length}/{MAX_REVIEW_PHOTOS}) + +

+ + 최대 {MAX_REVIEW_PHOTOS}장까지 선택할 수 있어요. + +
+
+ {photos.map((photo) => ( +
+
+ +
+ +
+ ))} + {photos.length < MAX_REVIEW_PHOTOS ? ( + + ) : null} +
+ { + handleFiles(event.target.files); + event.target.value = ''; + }} + /> +
+ +
+

+ 별점을 남겨주세요 +

+
+ {Array.from({ length: 5 }, (_, index) => index + 1).map((value) => ( + + ))} +
+
+ +
+

+ 총평을 남겨주세요 +

+ {/* + 테두리는 바깥 상자가 그린다. 카운터를 textarea 위에 겹쳐 놓으면 + 글이 스크롤될 때 그 아래로 지나가 겹쳐 보이므로 줄을 따로 둔다. + label로 두면 여백이나 카운터 줄을 눌러도 입력으로 포커스가 간다. + */} +