diff --git a/.agents/docs/1_DOMAIN_CONTEXT.md b/.agents/docs/1_DOMAIN_CONTEXT.md new file mode 100644 index 0000000..3d09853 --- /dev/null +++ b/.agents/docs/1_DOMAIN_CONTEXT.md @@ -0,0 +1,18 @@ +# 1. Domain Context +**프로젝트 명**: AI 캠퍼스 모임 (MVP 1차) +**핵심 목표**: 에브리타임 시간표와 캘린더 연동을 통해 대학생들의 공통적인 빈 시간을 자동으로 탐색하고 일정을 잡아주는 프론트엔드 환경 구축 +**핵심 스택**: `Next.js 14`, `React`, `TypeScript`, `Tailwind CSS v3`, `shadcn/ui`, `Vitest`, `Playwright` + +### 주요 도메인 + +| 도메인 | 설명 | 핵심 파일 | +|--------|------|-----------| +| 스케줄링 | 가용시간 교집합, .ics 파싱, 시간 슬롯 관리 | `src/lib/scheduling/` | +| 인증 | 카카오/구글 소셜 로그인, Supabase Auth | `src/app/(auth)/`, `src/lib/supabase/` | +| UI 컴포넌트 | TimeGrid, ParticipantList 등 도메인 전용 | `src/components/schedule/` | + +### 주요 변경 사항 +- Next.js 14버전 초기화 및 App 라우터 아키텍처 스캐폴딩 구성 완료 (2026.04.14) +- TDD 기반 프로젝트 구조 수립: Vitest + RTL + Playwright 도입, PRD 기반 폴더 스켈레톤 생성 (2026.04.15) +- Tailwind v3 + shadcn/ui HSL 디자인 토큰 체계 정립 (2026.04.15) +- CI 파이프라인에 lint → test → build 검증 단계 추가 (2026.04.15) diff --git a/.agents/docs/2_EXECUTION_PLAN.md b/.agents/docs/2_EXECUTION_PLAN.md new file mode 100644 index 0000000..0eeee43 --- /dev/null +++ b/.agents/docs/2_EXECUTION_PLAN.md @@ -0,0 +1,33 @@ +# 2. Execution Plan +**진행 상태**: `Phase 3 (TDD 기반 프로젝트 구조 수립 완료)` + +### 완료된 작업 (Done) +- [x] PRD 구조 분석 및 핵심 기술 스택(14분기점) 추출 +- [x] Next.js 14 + Tailwind + TypeScript 보일러플레이트 적용 (MOIM 리포지토리) +- [x] Socratic Auto-sync 기본 컨텍스트 1,2,3 초기화 +- [x] shadcn/ui 기반 설정(components.json, utils, css var 등) 디폴트 구성 완료 +- [x] GitHub Action CI/CD (lint & build test) 배포 구축 +- [x] GitHub PR/이슈 템플릿 양식 추가 완료 +- [x] 커밋 및 브랜치 규칙 강제화 시스템 세팅 (Husky, commitlint, lint-staged) +- [x] 초보자 친화적 `convention.md` 문서와 `.env.example` 작성 완료 +- [x] 시스템적 방어벽(GitHub Actions): PR 및 이슈 템플릿 준수율 자동 검증기 추가 +- [x] 테스트 프레임워크 설치 (Vitest + React Testing Library + Playwright) +- [x] PRD 기반 폴더 스켈레톤 생성 (라우팅, 컴포넌트, 비즈니스 로직, 타입) +- [x] 핵심 알고리즘 샘플 테스트 작성 (findCommonSlots — 6개 케이스 통과) +- [x] CI에 테스트 실행 단계 추가 (lint → test → build) +- [x] ARCHITECTURE.md 프로젝트 구조 가이드 작성 +- [x] Tailwind v3 + shadcn HSL 디자인 토큰 호환 수정 +- [x] ESLint underscore prefix 허용 규칙 추가 +- [x] 보안 문제 해결 (GitHub Actions Script Injection 조치 완료) +- [x] 스켈레톤 컴포넌트 시맨틱 HTML(WAI-ARIA) 속성 사전 반영 +- [x] 테스트 엣지 케이스 추가 및 Next.js 14 최신 보안 패치 적용 +- [x] 랜딩 페이지(page.tsx) 기본 템플릿 제거 및 MOIM Placeholder 적용 + +### 향후 작업 (To-Do) +- [ ] UI 컴포넌트 실제 구현 (TimeGrid, ParticipantList, AvailabilityResult) +- [ ] Supabase 백엔드 연동 및 OAuth 기본 환경 연결 +- [ ] 로그인 페이지 UI + 카카오/구글 소셜 로그인 구현 +- [ ] 일정 생성 페이지 폼 UI 구현 +- [ ] 참여자 입력 페이지 (캘린더 연동 / 수동 입력) 구현 +- [ ] .ics 파서 TDD 구현 +- [ ] E2E 테스트 작성 (호스트/참여자 플로우) diff --git a/.agents/docs/3_FILE_INDEX.md b/.agents/docs/3_FILE_INDEX.md new file mode 100644 index 0000000..01dab03 --- /dev/null +++ b/.agents/docs/3_FILE_INDEX.md @@ -0,0 +1,58 @@ +# 3. File Index Map +**현재 디렉터리 위치**: `C:\Users\kksu1\Dev\MOIM` + +### 설정 파일 (루트) +| 파일 | 역할 | +|------|------| +| `package.json` | 의존성 & 스크립트 (dev, build, test, lint) | +| `tsconfig.json` | TypeScript 컴파일러 설정, `@/` 경로 별칭 | +| `tailwind.config.ts` | Tailwind v3 + shadcn HSL 디자인 토큰 | +| `vitest.config.ts` | 단위 테스트 러너 설정 (jsdom, 경로 별칭) | +| `playwright.config.ts` | E2E 테스트 설정 (Chromium, localhost:3000) | +| `.eslintrc.json` | ESLint 규칙 (underscore prefix 허용) | +| `commitlint.config.js` | 커밋 메시지 검증 규칙 | +| `components.json` | shadcn/ui CLI 설정 | + +### 소스 코드 (`src/`) +| 경로 | 역할 | +|------|------| +| `src/app/layout.tsx` | 루트 레이아웃 (Geist 폰트, 메타데이터) | +| `src/app/page.tsx` | `/` 랜딩 페이지 | +| `src/app/globals.css` | 전역 CSS + 디자인 토큰 (HSL 변수) | +| `src/app/(auth)/layout.tsx` | 인증 전용 레이아웃 (GNB 없음) | +| `src/app/(auth)/login/page.tsx` | `/login` 소셜 로그인 페이지 | +| `src/app/schedule/create/page.tsx` | `/schedule/create` 일정 생성 | +| `src/app/schedule/[id]/page.tsx` | `/schedule/:id` 참여자 진입 | +| `src/components/ui/` | shadcn 원시 컴포넌트 | +| `src/components/schedule/TimeGrid.tsx` | When2meet 시간 그리드 (스켈레톤) | +| `src/components/schedule/ParticipantList.tsx` | 참여자 현황 (스켈레톤) | +| `src/components/schedule/AvailabilityResult.tsx` | 가용시간 결과 (스켈레톤) | +| `src/lib/scheduling/availability.ts` | ⭐ 가용시간 교집합 알고리즘 (구현 완료) | +| `src/lib/scheduling/ics-parser.ts` | .ics 파일 파서 (스켈레톤) | +| `src/lib/scheduling/time-slot.ts` | 시간 슬롯 유틸 (스켈레톤) | +| `src/lib/scheduling/__tests__/` | 단위 테스트 (availability 6개 통과) | +| `src/lib/supabase/client.ts` | Supabase 브라우저 클라이언트 (스켈레톤) | +| `src/lib/supabase/server.ts` | Supabase 서버 클라이언트 (스켈레톤) | +| `src/lib/utils.ts` | 공용 유틸리티 (cn 함수) | +| `src/types/schedule.ts` | 스케줄링 타입 (TimeSlot, ScheduleSession 등) | +| `src/types/user.ts` | 사용자/인증 타입 | +| `src/test-setup.ts` | Vitest 전역 설정 (jest-dom) | + +### E2E 테스트 (`e2e/`) +| 파일 | 역할 | +|------|------| +| `e2e/host-flow.spec.ts` | 호스트 플로우 시나리오 (스켈레톤) | +| `e2e/participant-flow.spec.ts` | 참여자 플로우 시나리오 (스켈레톤) | + +### CI/CD (`.github/`) +| 파일 | 역할 | +|------|------| +| `.github/workflows/pr-compliance.yml` | PR 템플릿 검사 + 코드 품질 검증 (lint→test→build) | +| `.github/workflows/issue-compliance.yml` | 이슈 템플릿 준수 검사 | + +### 문서 +| 파일 | 역할 | +|------|------| +| `ARCHITECTURE.md` | 프로젝트 구조 가이드 (팀원 공유용) | +| `convention.md` | 협업 컨벤션 가이드 (커밋, 브랜치, PR, 이슈) | +| `PRD.md` / `PRD.pdf` | 프로젝트 기획 및 요구사항 정의서 | diff --git a/.agents/docs/4_API_RESPONSE_FORMATS.md b/.agents/docs/4_API_RESPONSE_FORMATS.md new file mode 100644 index 0000000..9119a23 --- /dev/null +++ b/.agents/docs/4_API_RESPONSE_FORMATS.md @@ -0,0 +1,280 @@ +# 4. API Response Formats + +캘린더 연동 3종(에브리타임, Google Calendar, iCloud CalDAV)의 요청/응답 형식을 정리한다. +모든 API는 JSON을 기본 응답 형식으로 사용하며, 에러 응답은 `{ "error": string }` 형태로 통일된다. + +--- + +## 공통 타입 + +```typescript +// 요일 코드 — 스케줄링 도메인 전반에서 사용 +type DayCode = "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "SUN"; + +// 빈 시간 슬롯 — 세 연동 모두 최종적으로 이 형태로 변환됨 +interface TimeSlot { + day: DayCode; + startHour: number; // 0-23 + endHour: number; // 0-23, startHour보다 커야 함 +} +``` + +--- + +## 1. 에브리타임 (Everytime) + +### `POST /api/everytime/timetable` + +에브리타임 공유 URL 또는 ICS 파일로 시간표를 받아 빈 시간(`TimeSlot[]`)으로 변환한다. + +#### 방법 A — 공유 URL + +**Request** +``` +Content-Type: application/json + +{ + "url": "https://everytime.kr/@XXXX" +} +``` + +Query parameter (선택): `?days=MON,WED,FRI` (기본값: 월~금) + +#### 방법 B — ICS 파일 업로드 + +**Request** +``` +Content-Type: multipart/form-data + +file: <.ics 파일> +``` + +#### Response `200` + +```typescript +{ + timetable: { + lectures: Array<{ + name: string; + times: Array<{ + day: 0 | 1 | 2 | 3 | 4 | 5 | 6; // 0=월, 1=화, ..., 6=일 + startMinute: number; // 자정 기준 분 (예: 540 = 09:00) + endMinute: number; // 자정 기준 분 (예: 705 = 11:45) + }>; + }>; + }; + freeSlots: TimeSlot[]; +} +``` + +> **시간 단위**: 에브리타임 API의 `starttime` / `endtime` 값 × 5 = 자정 기준 분 +> 예) `starttime=108` → 108 × 5 = 540분 = 09:00 + +#### 에러 응답 + +| 상태코드 | 사유 | +|---|---| +| `400` | url 필드 누락, ICS 파일 미첨부, 파일 형식 오류 | +| `415` | Content-Type이 json/multipart 외의 값 | +| `422` | 유효하지 않은 에브리타임 URL, ICS 파싱 실패 | +| `500` | 서버 내부 오류 | + +--- + +## 2. Google Calendar + +### `GET /api/google/calendars` + +연결된 Google 계정의 캘린더 목록을 반환한다. + +#### Response `200` + +```typescript +{ + calendars: Array<{ + id: string; + summary: string; + description?: string; + backgroundColor?: string; + primary?: boolean; + accessRole: string; + }>; +} +``` + +--- + +### `GET /api/google/events/query` + +특정 캘린더의 기간별 일정을 조회한다. + +**Query parameters** + +| 파라미터 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `calendarId` | string | ✅ | 캘린더 ID | +| `startDate` | string | ✅ | ISO 8601 (예: `2026-05-01T00:00:00Z`) | +| `endDate` | string | ✅ | ISO 8601, startDate보다 이후 | + +#### Response `200` + +```typescript +{ + events: Array<{ + id: string; + summary: string; + description?: string; + location?: string; + start: { + dateTime?: string; // ISO 8601 (시간 있는 이벤트) + date?: string; // YYYY-MM-DD (종일 이벤트) + timeZone?: string; + }; + end: { + dateTime?: string; + date?: string; + timeZone?: string; + }; + status: string; + htmlLink?: string; + }>; +} +``` + +--- + +### `POST /api/google/events/create` + +**Request** +```typescript +{ + calendarId: string; + summary: string; // 1-255자 + startDateTime: string; // ISO 8601 (예: "2026-05-14T10:00:00+09:00") + endDateTime: string; // ISO 8601 + timeZone?: string; // 예: "Asia/Seoul" + location?: string; // 최대 500자 + description?: string; // 최대 8000자 +} +``` + +#### Response `201` + +```typescript +{ + event: GoogleEvent; // events/query 응답의 단건 항목과 동일 +} +``` + +#### 에러 응답 + +| 상태코드 | 사유 | +|---|---| +| `401` | Google 계정 미연결 또는 인증 만료 | +| `400` | 필수 파라미터 누락, 날짜 형식 오류 | +| `502` | Google Calendar API 오류 | + +--- + +## 3. iCloud CalDAV + +### `GET /api/icloud/calendars` + +연결된 iCloud 계정의 캘린더 목록을 반환한다. DB 캐시 우선, 없으면 CalDAV 서버에서 조회한다. + +**Query parameter**: `?connectionId=` + +#### Response `200` + +```typescript +{ + calendars: Array<{ + id: string; // DB UUID + displayName: string; + calendarUrl: string; // CalDAV 캘린더 URL + color: string | null; + }>; + cached: boolean; // true: DB 캐시, false: CalDAV 서버에서 새로 조회 +} +``` + +--- + +### `POST /api/icloud/events/query` + +특정 캘린더의 기간별 일정을 조회한다. + +**Request** +```typescript +{ + calendarId: string; // DB UUID + startDate: string; // ISO 8601 + endDate: string; // ISO 8601 +} +``` + +#### Response `200` + +```typescript +{ + events: Array<{ + uid: string; + title: string; + startAt: string; // ISO 8601 + endAt: string; // ISO 8601 + isAllDay: boolean; + location: string | null; + description: string | null; + etag: string; // CalDAV ETag (캐시 검증용) + }>; +} +``` + +--- + +### `POST /api/icloud/events/create` + +**Request** +```typescript +{ + calendarId: string; // DB UUID + title: string; // 1-255자 + startAt: string; // ISO 8601 + endAt: string; // ISO 8601 + location?: string; // 최대 500자 + description?: string; // 최대 2000자 +} +``` + +#### Response `201` + +```typescript +{ + uid: string; // 생성된 이벤트 UID + eventUrl: string; // CalDAV href + etag: string; // CalDAV ETag +} +``` + +#### 에러 응답 + +| 상태코드 | 사유 | +|---|---| +| `401` | 인증 필요 또는 iCloud 인증 만료 | +| `403` | 캘린더 소유권 불일치 | +| `404` | calendarId 없음 | +| `400` | 입력값 검증 실패 (UUID 형식, 날짜 형식 등) | +| `502` | CalDAV 서버 오류 | + +--- + +## 연동별 비교 요약 + +| 항목 | 에브리타임 | Google Calendar | iCloud CalDAV | +|---|---|---|---| +| **인증** | 없음 (공개 URL) | OAuth 2.0 (세션 토큰) | Apple ID + 앱 암호 (세션) | +| **시간 형식** | 분 단위 정수 (자정 기준) | ISO 8601 문자열 | ISO 8601 문자열 | +| **요일 표현** | 0~6 정수 (0=월) | 이벤트 날짜에 포함 | 이벤트 날짜에 포함 | +| **종일 이벤트** | 없음 (수업 시간표) | `date` 필드로 구분 | `isAllDay` 플래그 | +| **반복 일정** | 없음 (주간 고정) | recurrence 지원 | 없음 (개별 이벤트 확장) | +| **최종 출력** | `TimeSlot[]` (자동 변환) | `GoogleEvent[]` | `ParsedEvent[]` | diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..c03b789 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,206 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +# ────────────────────────────────────────────────── +# CodeRabbit 설정 — MOIM 프로젝트 +# ────────────────────────────────────────────────── + +language: "ko-KR" +tone_instructions: "한국어만 사용. 타협 없는 시니어 리뷰어. 인사말 없이 핵심 직답. 최소 정밀 수정만. TDD·방어적 코딩 강제. Why+How+코드스니펫 필수." + +early_access: true + +# ────────────────────────────────────────────────── +# 리뷰 설정 +# ────────────────────────────────────────────────── +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + high_level_summary_in_walkthrough: true + collapse_walkthrough: false + sequence_diagrams: true + changed_files_summary: true + assess_linked_issues: true + related_issues: true + related_prs: true + poem: false + estimate_code_review_effort: true + + # ── 라벨링 ── + suggested_labels: true + auto_apply_labels: false + labeling_instructions: + - label: "bug" + instructions: "버그 수정 PR에 적용" + - label: "feature" + instructions: "새로운 기능 추가 PR에 적용" + - label: "refactor" + instructions: "기능 변경 없이 코드 구조 개선 PR에 적용" + - label: "test" + instructions: "테스트 코드만 추가/수정한 PR에 적용" + + # ── 자동 리뷰 ── + auto_review: + enabled: true + auto_incremental_review: true + auto_pause_after_reviewed_commits: 8 + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + - "DRAFT" + drafts: false + base_branches: + - "main" + - "dev" + + # ── 파일 필터 ── + path_filters: + - "!**/*.json" + - "!**/*.md" + - "!package-lock.json" + - "!pnpm-lock.yaml" + - "!.husky/**" + - "!.agents/**" + - "!**/*.svg" + - "!**/*.ico" + - "!**/*.png" + - "!**/*.jpg" + - "!public/**" + + # ── 경로별 리뷰 지침 ── + path_instructions: + - path: "**/*" + instructions: | + 모든 리뷰 출력(워크스루, 요약, 코멘트, 체크리스트)을 한국어로만 작성하세요. 영어 금지. + 코드 스니펫 내 변수명/함수명은 원본 유지, 설명은 반드시 한국어. + 전면 재작성 금지 — 핵심 라인만 정밀 타격하여 최소 수정 피드백. + 개선 제안 시 반드시 Why(기술적 근거) + How(코드 스니펫) 포함. + + - path: "src/lib/**" + instructions: | + 비즈니스 로직 계층입니다. + 순수 함수 위주로 작성되었는지 검증하세요. + 외부 의존성(Supabase, API 등)이 직접 호출되면 의존성 역전 원칙 위반 여부를 지적하세요. + 에지 케이스, 타입 안전성, 예외 처리가 부족하면 엄격히 지적하세요. + 보안 취약점(OWASP Top 10), 메모리 누수 가능성에 주의하세요. + + - path: "src/app/**" + instructions: | + Next.js App Router 페이지/레이아웃 계층입니다. + 서버 컴포넌트와 클라이언트 컴포넌트 경계가 올바른지 확인하세요. + 'use client' 지시문이 불필요하게 상위에 있으면 지적하세요. + 데이터 페칭이 서버 컴포넌트에서 이루어지는지 확인하세요. + 렌더링 성능(불필요한 리렌더, 번들 크기)에 주의하세요. + + - path: "src/components/**" + instructions: | + 공유 UI 컴포넌트 계층입니다. + 컴포넌트가 단일 책임 원칙(SRP)을 따르는지 확인하세요. + Props 타입이 명확하게 정의되어 있는지 검증하세요. + 접근성(a11y) 속성(aria-label, role 등)이 적절한지 확인하세요. + shadcn/ui 기반이므로 불필요한 커스텀 구현이 있으면 지적하세요. + + - path: "**/__tests__/**" + instructions: | + 테스트 파일입니다. + AAA 패턴(Arrange-Act-Assert)을 따르는지 확인하세요. + 테스트 설명(describe/it)이 행동(behavior)을 명확히 기술하는지 확인하세요. + 경계값, 에러 케이스, 빈 입력 등 엣지 케이스 커버리지를 평가하세요. + 테스트가 구현 세부사항이 아닌 공개 인터페이스를 검증하는지 확인하세요. + 모킹이 과도하면 통합 테스트 전환을 권고하세요. + + - path: "src/types/**" + instructions: | + 공유 타입 정의 계층입니다. + 타입이 도메인 모델을 정확히 반영하는지 확인하세요. + any, unknown 남용이 없는지 검증하세요. + discriminated union 등 타입 안전 패턴 활용을 권장하세요. + + # ── 정적 분석 도구 ── + tools: + eslint: + enabled: true + biome: + enabled: false + github-checks: + enabled: true + timeout_ms: 120000 + actionlint: + enabled: true + gitleaks: + enabled: true + semgrep: + enabled: true + languagetool: + enabled: false + + # ── Finishing Touches ── + finishing_touches: + docstrings: + enabled: true + unit_tests: + enabled: true + + # ── Pre-merge 체크 ── + pre_merge_checks: + title: + mode: "warning" + requirements: "PR 제목은 'feat:', 'fix:', 'refactor:', 'docs:', 'chore:', 'test:', 'design:' 중 하나로 시작해야 합니다." + description: + mode: "warning" + issue_assessment: + mode: "warning" + + # ── Slop Detection ── + slop_detection: + enabled: true + label: "ai-slop" + +# ────────────────────────────────────────────────── +# 지식 기반 (프로젝트 컨텍스트 강화) +# ────────────────────────────────────────────────── +knowledge_base: + opt_out: false + web_search: + enabled: true + learnings: + scope: "local" + issues: + scope: "local" + pull_requests: + scope: "local" + code_guidelines: + enabled: true + filePatterns: + - "ARCHITECTURE.md" + - "convention.md" + +# ────────────────────────────────────────────────── +# 코드 생성 (테스트/독스트링 자동 생성 지침) +# ────────────────────────────────────────────────── +code_generation: + docstrings: + language: "ko-KR" + unit_tests: + path_instructions: + - path: "src/lib/**" + instructions: | + Vitest를 사용하세요. describe/it 블록의 설명은 한국어로 작성하세요. + AAA 패턴(Arrange-Act-Assert)을 따르세요. + 경계값, 에러 케이스, 빈 입력을 반드시 포함하세요. + 외부 의존성은 vi.mock()으로 격리하세요. + +# ────────────────────────────────────────────────── +# 이슈 풍부화 +# ────────────────────────────────────────────────── +issue_enrichment: + auto_enrich: + enabled: true + planning: + enabled: true + +# ────────────────────────────────────────────────── +# 채팅 +# ────────────────────────────────────────────────── +chat: + auto_reply: true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ef0504 --- /dev/null +++ b/.env.example @@ -0,0 +1,48 @@ +# Database (Prisma + Supabase PostgreSQL) +# For local SQLite development, you can use: DATABASE_URL="file:./dev.db" +DATABASE_URL="postgresql://postgres.ydpihnpnjnqkdcmeuyfw:[YOUR-PASSWORD]@aws-1-ap-northeast-2.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1" +DIRECT_URL="postgresql://postgres.ydpihnpnjnqkdcmeuyfw:[YOUR-PASSWORD]@aws-1-ap-northeast-2.pooler.supabase.com:5432/postgres" + +# Supabase +NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url_here +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key_here +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here + +# Google login +GOOGLE_LOGIN_CLIENT_ID=your_google_login_client_id_here +GOOGLE_LOGIN_CLIENT_SECRET=your_google_login_client_secret_here +GOOGLE_LOGIN_REDIRECT_URI=http://localhost:3000/api/auth/google/callback + +# Google Calendar integration +GOOGLE_CALENDAR_CLIENT_ID=your_google_calendar_client_id_here +GOOGLE_CALENDAR_CLIENT_SECRET=your_google_calendar_client_secret_here + +# Gemini API +GEMINI_API_KEY=your_gemini_api_key_here + +# JWT auth +JWT_SECRET="local-dev-secret-change-me" + +# iCloud 캘린더 앱 전용 암호 암호화 키 (AES-256-GCM) +# 64자리 hex. 생성: openssl rand -hex 32 +ENCRYPTION_SECRET=your_64_hex_encryption_secret_here + +# App base URL +NEXT_PUBLIC_BASE_URL=http://localhost:3000 + +# 인증은 Supabase Auth로 통합됨 (#45). +# - 이메일/비밀번호: Supabase 이메일 인증 +# - 카카오/구글 로그인: Supabase 대시보드 > Authentication > Providers에서 설정 +# (별도 KAKAO_*/JWT_SECRET 환경변수는 더 이상 사용하지 않음) + +# Apple/iCloud login (Sign in with Apple) +APPLE_CLIENT_ID=your_apple_services_id_here +APPLE_TEAM_ID=your_apple_team_id_here +APPLE_KEY_ID=your_apple_key_id_here +APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" +APPLE_REDIRECT_URI=http://localhost:3000/api/auth/apple/callback + +# Naver login +NAVER_CLIENT_ID=your_naver_client_id_here +NAVER_CLIENT_SECRET=your_naver_client_secret_here +NAVER_REDIRECT_URI=http://localhost:3000/api/auth/naver/callback diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..0d69c3d --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } + ] + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..8c5e9bb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: 버그 리포트 (Bug Report) +about: 시스템에서 발견된 버그나 오류를 제보합니다. +title: "[BUG] " +labels: bug +assignees: '' + +--- + +## 🐛 버그 설명 (Describe the bug) + +- + +## 🔄 재현 방법 (To Reproduce) + +1. '...' 페이지로 이동합니다. +2. '...' 버튼을 누릅니다. +3. '...'에서 오류가 발생합니다! + +## 🎯 예상되는 정상 동작 (Expected behavior) + +- + +## 📸 스크린샷 또는 콘솔 로그 (Screenshots or Logs) + + +## 💻 환경 정보 (Environment) +- OS: [예: Windows, Mac, iOS] +- 브라우저: [예: Chrome, Safari] diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..f223bfc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: 기능 추가 요청 (Feature Request) +about: 프로젝트에 새롭게 들어가야 할 기능이나 아이디어를 제안합니다. +title: "[FEAT] " +labels: enhancement +assignees: '' + +--- + +## 🎯 어떤 기능이 필요한가요? (Is your feature request related to a problem?) + +- + +## ✨ 구상하는 해결책 (Describe the solution you'd like) + +- + +## ⚖️ 대안 (Describe alternatives you've considered) + +- + +## 📝 추가 정보 (Additional context) + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..e4168d9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## 🚀 작업 내용 (What) + +- + +## 📣 핵심 변경 이유 (Why) + +- + +## 📸 스크린샷 (Visuals, 선택) + + +## ⚠️ 체크리스트 (Checklist) +- [ ] 브랜치 컨벤션(feature/00-name)을 지켰나요? +- [ ] 커밋 컨벤션(feat:, fix: 등)을 지켰나요? +- [ ] 작업 전에 관련 이슈를 생성하고 연결했나요? +- [ ] 내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요? + +## 🔗 관련 이슈 (Issue) + +Close # diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..19b4e37 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI Pipeline + +permissions: + contents: read + +on: + pull_request: + branches: + - main + - dev + push: + branches: + - main + - dev + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: true + +jobs: + code_quality: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: moim_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + NEXT_PUBLIC_BASE_URL: http://localhost:3000 + NEXT_PUBLIC_SUPABASE_URL: https://example-project.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: test-anon-key + KAKAO_REST_API_KEY: test-kakao-key + KAKAO_REDIRECT_URI: http://localhost:3000/api/auth/kakao/callback + GOOGLE_CALENDAR_CLIENT_ID: test-google-calendar-client-id + GOOGLE_CALENDAR_CLIENT_SECRET: test-google-calendar-client-secret + GOOGLE_LOGIN_CLIENT_ID: test-google-login-client-id + GOOGLE_LOGIN_CLIENT_SECRET: test-google-login-client-secret + GOOGLE_LOGIN_REDIRECT_URI: http://localhost:3000/api/auth/google/callback + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/moim_test + DIRECT_URL: postgresql://postgres:postgres@localhost:5432/moim_test + + steps: + - name: 저장소 체크아웃 (Checkout Repository) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + persist-credentials: false + + - name: Node.js 세팅 (Setup Node.js) + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: "20" + cache: "npm" + + - name: 패키지 설치 (Install Dependencies) + run: npm ci + + - name: CI 전용 보안 키 생성 (Generate CI Secrets) + run: | + { + echo "JWT_SECRET=$(openssl rand -base64 32)" + echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" + } >> "$GITHUB_ENV" + + - name: 데이터베이스 스키마 동기화 (Prisma db push) + run: | + psql "$DATABASE_URL" -c 'CREATE SCHEMA IF NOT EXISTS auth;' + npx prisma db push --skip-generate + + - name: 코드 린트 검사 (Lint Check) + run: npm run lint + + - name: 테스트 실행 (Unit Test) + run: npm run test + + - name: Next.js 빌드 테스트 (Build Test) + run: npm run build + + - name: Playwright 브라우저 설치 (Chromium) + run: npx playwright install --with-deps chromium + + - name: E2E 테스트 실행 (Chromium) + run: npm run test:e2e -- --project=chromium diff --git a/.github/workflows/issue-compliance.yml b/.github/workflows/issue-compliance.yml new file mode 100644 index 0000000..436d86b --- /dev/null +++ b/.github/workflows/issue-compliance.yml @@ -0,0 +1,31 @@ +name: 이슈 템플릿 검사 (Compliance Check) + +permissions: + issues: read + +on: + issues: + types: [opened, edited] + +jobs: + check-template: + runs-on: ubuntu-latest + steps: + - name: 제목 및 내용 양식 검사 + env: + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + run: | + # 1. 제목 규칙 확인 ([FEAT] 또는 [BUG] 포함 여부) + if [[ ! "$ISSUE_TITLE" =~ ^\[(FEAT|BUG)\] ]]; then + echo "❌ 이슈 제목은 템플릿에 지정된 방식인 [FEAT] 또는 [BUG] 접두어로 시작해야 합니다." + exit 1 + fi + + # 2. 내용 규칙 확인 (개발자가 내용을 채우지 않고 '- ' 빈 상태로 둔 경우) + if echo "$ISSUE_BODY" | grep -q "^\- $"; then + echo "❌ 이슈 내용 중 비어 있는 항목('- ')이 있습니다. 어떻게, 왜 필요한지 구체적으로 작성해주세요." + exit 1 + fi + + echo "✅ 이슈 템플릿 검사 통과 성공!" diff --git a/.github/workflows/pr-compliance.yml b/.github/workflows/pr-compliance.yml new file mode 100644 index 0000000..c66d76b --- /dev/null +++ b/.github/workflows/pr-compliance.yml @@ -0,0 +1,56 @@ +name: PR 템플릿 검사 + +permissions: + pull-requests: read + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + check-template: + runs-on: ubuntu-latest + steps: + - name: 체크리스트 달성도 검사 + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + # 체크리스트 자체가 누락된 PR은 템플릿을 사용하지 않은 것으로 본다 + if ! echo "$PR_BODY" | grep -Eq "\- \[[xX ]\]"; then + echo "❌ PR 템플릿의 체크리스트가 누락되었습니다. 템플릿에 맞게 작성해주세요." + exit 1 + fi + + # 체크 안 된 항목('- [ ]')이 하나라도 남아있으면 에러 뱉기 + if echo "$PR_BODY" | grep -q "\- \[ \]"; then + echo "❌ 체크리스트 중 완료되지 않은 항목이 있습니다! 모두 [x]로 체크해야 Merge할 수 있습니다." + exit 1 + fi + + checked_count=$(echo "$PR_BODY" | grep -Ec "^\- \[[xX]\]") + if [ "$checked_count" -lt 4 ]; then + echo "❌ PR 체크리스트 4개 항목이 모두 체크되어야 합니다." + exit 1 + fi + + required_items=( + "브랜치 컨벤션(feature/00-name)을 지켰나요?" + "커밋 컨벤션(feat:, fix: 등)을 지켰나요?" + "작업 전에 관련 이슈를 생성하고 연결했나요?" + "내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요?" + ) + + for item in "${required_items[@]}"; do + if ! echo "$PR_BODY" | grep -Fq "$item"; then + echo "❌ PR 체크리스트 필수 항목이 누락되었습니다: $item" + exit 1 + fi + done + + # 본문이 너무 짧은지 검사 (최소한의 설명 요구) + if [ ${#PR_BODY} -lt 30 ]; then + echo "❌ PR 설명이 너무 짧습니다. 템플릿에 맞게 어떤 작업을 했는지 명확하게 설명해주세요." + exit 1 + fi + + echo "✅ PR 템플릿 검사 통과 성공!" diff --git a/.gitignore b/.gitignore index 98afc4e..5c9b852 100644 --- a/.gitignore +++ b/.gitignore @@ -3,15 +3,14 @@ # dependencies /node_modules /.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions +.pnp.js +.yarn/install-state.gz # testing /coverage +/test-results/ +/playwright-report/ +/blob-report/ # next.js /.next/ @@ -28,18 +27,28 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -.pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* +# local env files +.env*.local # vercel .vercel -# claude code -.claude/ -CLAUDE.md - # typescript *.tsbuildinfo next-env.d.ts + +# AI & Local configs +.claude/ +.agents/* +!.agents/docs/ +*.pdf +*.docx + +# Prisma local DB & env +.env +*.db +*.db-journal + +# Temporary scratch files +/scratch diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..7fbd0ad --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +npx --no-install commitlint --edit "${1}" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3b66410 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2fd03bb --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,198 @@ +# MOIM 프로젝트 아키텍처 가이드 + +> 이 문서는 프로젝트의 전체 폴더 구조와 각 디렉토리의 역할을 설명합니다. +> 팀원이 프로젝트에 처음 합류했을 때 "어디서 뭘 하면 되는지"를 빠르게 파악하기 위한 지도입니다. + +--- + +## 기술 스택 요약 + +| 영역 | 기술 | 버전 | +| ----------- | --------------------------------------- | ---- | +| 프레임워크 | Next.js (App Router) | 14 | +| 언어 | TypeScript | 5.x | +| 스타일링 | Tailwind CSS + shadcn/ui | v3 | +| 단위 테스트 | Vitest + React Testing Library | - | +| E2E 테스트 | Playwright | - | +| 백엔드 | Supabase (PostgreSQL + Auth + Realtime) | - | +| 배포 | Vercel | - | + +--- + +## 폴더 구조 + +``` +MOIM/ +├── 📁 src/ # 소스 코드 루트 +│ ├── 📁 app/ # Next.js App Router — 페이지 라우팅 +│ │ ├── 📁 (auth)/ # 인증 관련 페이지 그룹 +│ │ │ ├── layout.tsx # 인증 전용 레이아웃 (GNB 없음) +│ │ │ └── 📁 login/ # /login → 소셜 로그인 페이지 +│ │ ├── 📁 schedule/ # 스케줄링 핵심 도메인 +│ │ │ ├── 📁 create/ # /schedule/create → 호스트가 일정 생성 +│ │ │ └── 📁 [id]/ # /schedule/:id → 참여자가 링크로 진입 +│ │ ├── layout.tsx # 루트 레이아웃 +│ │ ├── page.tsx # / → 랜딩 페이지 +│ │ └── globals.css # 전역 CSS + 디자인 토큰 +│ │ +│ ├── 📁 components/ # 재사용 가능한 UI 컴포넌트 +│ │ └── 📁 ui/ # shadcn/ui 원시 컴포넌트 (Button 등) +│ │ +│ ├── 📁 lib/ # 비즈니스 로직 & 유틸리티 +│ │ ├── 📁 scheduling/ # ⭐ 핵심 알고리즘 (순수 함수) +│ │ │ ├── availability.ts # 가용시간 교집합 연산 +│ │ │ ├── ics-parser.ts # .ics 파일 파싱 +│ │ │ ├── time-slot.ts # 시간 슬롯 유틸 (정렬, 병합) +│ │ │ └── 📁 __tests__/ # 단위 테스트 (코로케이션) +│ │ ├── 📁 supabase/ # Supabase DB 클라이언트 +│ │ │ ├── client.ts # 브라우저용 클라이언트 +│ │ │ └── server.ts # 서버 사이드 클라이언트 +│ │ ├── 📁 schedules/ # 일정 링크/참여 데이터 저장소 +│ │ └── utils.ts # 공용 유틸리티 +│ │ +│ ├── 📁 types/ # TypeScript 타입 정의 +│ │ ├── schedule.ts # 스케줄링 도메인 타입 +│ │ └── user.ts # 사용자/인증 타입 +│ │ +│ └── test-setup.ts # Vitest 전역 설정 +│ +├── 📁 e2e/ # Playwright E2E 테스트 +│ ├── host-flow.spec.ts # 호스트 플로우 시나리오 +│ └── participant-flow.spec.ts # 참여자 플로우 시나리오 +│ +├── 📁 .github/ # GitHub 자동화 +│ └── 📁 workflows/ # CI/CD 파이프라인 +│ +├── scripts/ # 로컬 DB schema 보장 등 개발 스크립트 +├── vitest.config.ts # Vitest 설정 +├── playwright.config.ts # Playwright 설정 +├── tailwind.config.ts # Tailwind CSS 설정 +├── tsconfig.json # TypeScript 설정 +└── package.json # 의존성 & 스크립트 +``` + +--- + +## 핵심 설계 원칙과 근거 + +### 1. `src/app/` — 라우팅은 사용자 플로우를 반영한다 + +Next.js App Router의 **파일 = URL** 규칙을 활용하여, PRD의 사용자 플로우를 그대로 폴더로 표현합니다. + +| URL | 파일 | PRD 매핑 | +| ------------------ | -------------------------- | ------------------- | +| `/login` | `(auth)/login/page.tsx` | 3.1 로그인/회원가입 | +| `/schedule/create` | `schedule/create/page.tsx` | 3.1 일정 잡기 생성 | +| `/schedule/:id` | `schedule/[id]/page.tsx` | 3.2~3.3 참여자 진입 | + +**`(auth)` 괄호 그룹이란?** +Next.js에서 폴더명을 괄호로 감싸면 URL에 영향을 주지 않고 레이아웃만 분리할 수 있습니다. + +- `/login`의 URL은 그대로지만, 별도의 `layout.tsx`를 가져 GNB가 없는 레이아웃을 사용합니다. + +**`[id]` 동적 라우트란?** +대괄호로 감싼 폴더는 URL의 일부를 변수로 받습니다. + +- `/schedule/abc123` → `params.id = 'abc123'` + +### 2. `src/components/` — UI는 범용과 도메인으로 분리한다 + +``` +components/ +├── ui/ ← shadcn 원시 컴포넌트 (프로젝트 무관하게 재사용) +└── schedule/ ← 우리 서비스 전용 컴포넌트 (비즈니스 로직 포함) +``` + +- `ui/`: 버튼, 카드, 모달 등 어떤 프로젝트에서든 쓸 수 있는 범용 컴포넌트 +- 스케줄 화면처럼 한 라우트에 강하게 묶인 UI는 현재 `src/app/schedule/*` 아래에 둔다. + +### 3. `src/lib/scheduling/` — 핵심 로직은 UI와 분리한다 + +가용시간 교집합 연산, .ics 파싱 등은 **React와 무관한 순수 함수**입니다. +UI 없이 독립적으로 실행되므로, 테스트가 빠르고 정확합니다. + +```typescript +// 이렇게 React 없이 단독으로 테스트 가능 +const result = findCommonSlots([userA, userB]); +expect(result).toEqual([{ day: "MON", startHour: 13, endHour: 15 }]); +``` + +**왜 분리하나?** + +- 순수 함수는 입력→출력만 검증하면 되므로 TDD에 최적 +- 브라우저 환경(jsdom)이 필요 없어 테스트가 10배 빨라짐 +- 나중에 서버 사이드에서도 같은 로직을 재사용 가능 + +### 4. `__tests__/` 코로케이션 — 테스트는 대상 코드 바로 옆에 + +``` +lib/scheduling/ +├── availability.ts ← 구현 +└── __tests__/ + └── availability.test.ts ← 테스트 +``` + +**왜 루트 `tests/` 폴더가 아니라 코드 옆에 두는가?** + +- 파일 탐색 시 구현과 테스트를 한눈에 볼 수 있음 +- 새 기능 추가 시 "테스트 파일은 어디 만들지?" 고민이 없음 +- AI 도구에게 "이 파일 테스트해줘"라고 할 때 컨텍스트 파악이 빠름 + +### 5. `src/types/` — 타입 정의를 먼저, 구현은 나중에 (TDD) + +``` +1. types/schedule.ts → "어떤 데이터를 다루는가?" 정의 +2. __tests__/*.test.ts → "어떻게 동작해야 하는가?" 테스트 작성 +3. lib/scheduling/*.ts → 테스트를 통과시키는 구현 +``` + +이 순서가 TDD(Test-Driven Development)의 핵심입니다. +Python에서 Pydantic 모델 → pytest 테스트 → 비즈니스 로직 순서와 동일합니다. + +--- + +## 테스트 전략 + +### 3계층 테스트 (Testing Trophy) + +| 계층 | 도구 | 대상 | 비중 | +| --------------- | ------------------------------ | --------------------------- | ---- | +| **Unit** | Vitest | `lib/scheduling/` 순수 로직 | 50% | +| **Integration** | React Testing Library + Vitest | `components/` 상호작용 | 30% | +| **E2E** | Playwright | 사용자 전체 플로우 | 20% | + +### 테스트 실행 명령어 + +| 명령어 | 용도 | +| ----------------------- | ---------------------------------- | +| `npm run test` | 모든 단위 테스트 한 번 실행 (CI용) | +| `npm run test:watch` | 파일 저장 시 자동 재실행 (개발용) | +| `npm run test:coverage` | 커버리지 리포트와 함께 실행 | +| `npm run test:e2e` | 브라우저 E2E 테스트 실행 | +| `npm run db:migrate` | 로컬 SQLite schema 준비 | + +### TDD 워크플로우 + +``` +1. 타입 정의 → types/에 인터페이스 작성 +2. 테스트 작성 → 실패하는 테스트 먼저 (Red) +3. 최소 구현 → 테스트를 통과시키는 코드 (Green) +4. 리팩터링 → 코드 정리 (Refactor) +5. CI 검증 → PR 시 자동 테스트 실행 +``` + +--- + +## CI/CD 파이프라인 + +PR을 올리면 아래 검사가 자동으로 실행됩니다: + +``` +PR 생성 → 템플릿 검사 → 린트(npm run lint) + → 테스트(npm run test) + → 빌드(npm run build) + → Chromium E2E(npm run test:e2e -- --project=chromium) + → 모두 ✅ → Merge 가능 +``` + +하나라도 ❌이면 Merge 버튼이 비활성화됩니다. diff --git a/README.md b/README.md index e215bc4..305a259 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,38 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# MOIM -## Getting Started +MOIM은 주최자가 모임 링크를 만들고, 참여자가 로그인 없이 가능한 시간을 제출하면 공통 가능한 시간을 추천해 주는 일정 조율 앱입니다. -First, run the development server: +## 빠른 시작 -```bash +```powershell +npm ci +npm run db:migrate npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +로컬 기본 DB는 `DATABASE_URL`이 없을 때 `file:./dev.db`를 사용합니다. 실제 배포나 공유 환경에서는 `.env.example`을 기준으로 `.env`를 준비하세요. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## 검증 명령 -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: +```powershell +npm run lint +npm run test +npm run test:coverage +npm run build +npm run test:e2e -- --project=chromium +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +`test`, `test:coverage`, `test:e2e`, `dev`는 실행 전에 로컬 SQLite schema를 보장합니다. -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +## 먼저 읽을 문서 -## Deploy on Vercel +1. `docs/README.md`: 현재 문서 기준과 v1/v2 구분 +2. `docs/v1/codex-work-context.md`: 현재 개발 handoff와 주의점 +3. `ARCHITECTURE.md`: 코드 구조와 테스트 전략 +4. `convention.md`: 커밋, 브랜치, 이슈, PR 규칙 -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## 현재 안정성 기준 -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +- CI는 lint, unit test, build, Chromium E2E를 실행합니다. +- schedule 생성/참여 API는 process memory가 아니라 SQLite-backed Prisma store를 사용합니다. +- `.env`, 로컬 DB, Playwright 결과물, 문서 제출용 바이너리는 Git에 올리지 않습니다. diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..f7e8061 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,29 @@ +module.exports = { + extends: ["@commitlint/config-conventional"], + plugins: [ + { + rules: { + "subject-korean-only": (parsed) => { + const { subject } = parsed; + if (!subject) return [false, "커밋 메시지(subject)가 비어있습니다."]; + + // 정규식: 한글이 최소 1자 이상 포함되어 있는지 검사 + const hasKorean = /[가-힣]/.test(subject); + + return [ + hasKorean, + "커밋 메시지(내용)에 반드시 한글로 된 설명을 포함시켜야 합니다. (순수 영어 커밋 금지)", + ]; + }, + }, + }, + ], + rules: { + // 우리가 만든 한글 강제 규칙을 항상 에러(2) 수준으로 적용 + "subject-korean-only": [2, "always"], + + // "UI", "DB", "API" 같은 대문자 명사를 자연스럽게 한글과 섞어 쓸 수 있도록 + // 기본으로 켜져있는 까다로운 영어 대소문자 제한 규칙은 끕니다. + "subject-case": [0, "always"], + }, +}; diff --git a/components.json b/components.json deleted file mode 100644 index 8d886db..0000000 --- a/components.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-nova", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "menuColor": "default", - "menuAccent": "subtle", - "registries": {} -} diff --git a/convention.md b/convention.md new file mode 100644 index 0000000..ce76799 --- /dev/null +++ b/convention.md @@ -0,0 +1,222 @@ +# AI 캠퍼스 모임 협업 라이프사이클 및 컨벤션 가이드 + +안녕하세요! 팀 프로젝트 협업을 위해 반드시 알아야 할 규칙을 모았습니다. +처음 협업 규칙을 접하는 분들을 위해 **왜 이런 규칙이 필요한지** 상세히 설명해 두었으니, 개발을 시작하기 전 꼭 한 번 정독해주세요. + +--- + +## 1. 커밋(Commit) 컨벤션 규칙 + +소프트웨어 개발에서는 수많은 커밋(저장 기록)이 쌓입니다. 나중에 문제가 생기거나 코드를 리뷰할 때 "이 코드가 왜, 무엇을 위해 추가/수정되었는지"를 쉽게 파악하려면 커밋 메시지에 규칙이 필요합니다. 우리는 전 세계적으로 가장 널리 쓰이는 `Angular Conventional Commits` 방식을 사용합니다. + +### ❓ 왜 커밋 규칙(Prefix)을 앞에 달아야 할까요? + +"버튼 수정함", "진짜 최종", "오류 해결" 같은 메시지는 나중에 코드 히스토리를 분석할 때 무슨 버튼인지, 무슨 오류인지 파악할 수가 없습니다. +앞에 태그(Prefix)를 붙이면 커밋 목록만 보고도 "아, 이 커밋들은 새로운 기능(feat)들이고, 이 커밋은 버그 수정(fix)이구나"를 한눈에 알아볼 수 있습니다. + +### ✅ 커밋 메시지 규칙 (반드시 지켜야 할 형식) + +커밋 메시지 첫 줄은 아래와 같이 작성합니다: +`[타입]: [작업 내용 요약]` +예) `feat: 구글 캘린더 연동 기능 추가` + +| 타입 | 의미 / 언제 사용하나요? | 예시 | +| ------------ | ---------------------------------------------------------- | ----------------------------------------------- | +| **feat** | 새로운 기능을 추가할 때 (Feature) | `feat: 카카오 소셜 로그인 연동` | +| **fix** | 버그를 고쳤을 때 | `fix: 스케줄링 그리드 클릭 안 되는 에러 해결` | +| **design** | CSS 등 사용자 UI 디자인 변경 시 | `design: 버튼 색상을 메인 브랜드 색상으로 변경` | +| **refactor** | 결과나 기능의 변경 없이, 코드를 더 깔끔하게 다시 정리할 때 | `refactor: 로그인 함수 로직 분리 (중복 제거)` | +| **docs** | README나 주석 등 문서를 수정할 때 | `docs: README 실행 방법 업데이트` | +| **chore** | 설정 파일 변동, 패키지 추가 (기능과 무관한 잡일) | `chore: 캘린더 라이브러리 설치` | + +> 🚨 **주의**: 우리 프로젝트에는 커밋 검사 봇(Husky & Commitlint)이 백그라운드에 심어져 있습니다. 만약 `버튼 추가`처럼 규칙에 맞지 않는 메시지로 `git commit`을 하려 하면 **에러가 나면서 커밋 자체가 거부**됩니다. 꼭 규칙을 지켜주세요! + +--- + +## 2. 브랜치(Branch) 전략 규칙 + +모두가 하나의 `main` 브랜치에 동시에 코드를 쓰면 충돌(Conflict)이 발생하고 서버가 고장납니다. +따라서 우리는 각자의 도화지(Branch)를 따로 파서 작업하고 완벽하게 만들어진 나뭇가지들을 하나로 합치는 **Git Flow (단순화 버전)**를 사용합니다. + +### ❓ 왜 이런 브랜치 구조가 필요한가요? + +- 안전성: 내가 코드를 망쳐도 내 브랜치 안에서만 고장나므로, 팀 전체 프로젝트(`main`)에는 타격을 주지 않습니다. + +### ✅ 브랜치 네이밍 및 워크플로우 + +1. **`main` (배포 브랜치)**: + - 사용자가 접속하게 될 실제 운영 서버의 코드입니다. 절대 직접 코드를 쓰지 않습니다. 항상 에러 없이 완벽한 상태를 유지합니다. +2. **`dev` (개발 통합 브랜치)**: + - 우리가 작업한 코드들이 우선적으로 합쳐지는 테스트 서버용 브랜치입니다. 기능들이 하나씩 완성될 때마다 여기로 합칩니다. +3. **`feature/이슈번호-기능명` (내 작업용 브랜치)**: + - 새로운 기능을 만들기 전, **GitHub에서 먼저 Issue를 생성**하여 작업 내용을 알립니다. + - 발급받은 이슈 번호(예: #12)를 사용해, `dev` 브랜치에서 가지를 쳐서 내 전용 브랜치를 만듭니다. + - 예시: `feature/12-kakao-login`, `feature/15-calendar-ui` + - 기능 완성이 다 되면 PR(Pull Request)을 날려 `dev`에 합칩니다. + +### 👉 일반적인 작업 순서 (매우 중요) + +1. **Issue 생성**: GitHub에서 버그나 기능 추가 이슈를 만들고 이슈 번호(예: #12)를 받습니다. +2. 로컬에서 `git checkout dev` (최신 통합 개발 상태로 이동) +3. `git pull origin dev` (최신 코드 받기) +4. `git checkout -b feature/12-내작업명` (내 작업 브랜치 따기) +5. 열심히 코딩 후 규칙에 맞게 커밋 & 푸시 +6. GitHub에 들어가서 `feature/12-내작업명` → `dev`로 합쳐달라고 **Pull Request(PR)** 날리기! (템플릿 준수 필수) + +--- + +## 3. Issue(이슈) 템플릿 — "작업 시작 전 반드시 이슈부터" + +이슈란 **"나 이 작업 한다!"** 를 팀원에게 알리는 작업 지시서입니다. +GitHub에서 이슈를 만들면 자동으로 번호(예: #12)가 발급되고, 이 번호를 브랜치 이름과 PR에 연결해서 **"어떤 이슈를 해결하기 위한 코드인지"** 추적할 수 있게 됩니다. 이슈 없이 바로 코딩을 시작하면 나중에 누가 왜 이 코드를 짰는지 아무도 모르게 됩니다. + +### ❓ 왜 이슈를 먼저 만들어야 할까요? + +- **중복 방지**: "어? 나도 그거 만들고 있었는데?" 같은 사고를 예방합니다. +- **진행 상황 추적**: 프로젝트 탭에서 누가 어떤 작업을 하고 있는지 한눈에 보입니다. +- **코드와의 연결**: PR에 `Close #12`를 적으면 PR이 merge될 때 이슈가 자동으로 닫힙니다. 깔끔! + +### 📋 이슈 템플릿 A: 기능 추가 요청 `[FEAT]` + +GitHub에서 "New Issue"를 누르면 아래 양식이 자동으로 채워져 나옵니다. **제목은 반드시 `[FEAT]` 로 시작**해야 합니다. + +> **제목 예시**: `[FEAT] 카카오 소셜 로그인 기능 추가` + +``` +## 🎯 어떤 기능이 필요한가요? + +- 사용자가 카카오 계정으로 로그인할 수 있어야 합니다. + +## ✨ 구상하는 해결책 + +- Supabase OAuth Provider에 카카오를 추가하고, 로그인 버튼 컴포넌트를 만듭니다. + +## ⚖️ 대안 (생략 가능) + +- 대안 없음 + +## 📝 추가 정보 (생략 가능) + +``` + +| 항목 | 왜 적어야 하나요? | +| -------------------------- | ----------------------------------------------------------------------------------- | +| 🎯 어떤 기능이 필요한가요? | 팀원이 "이 이슈가 뭘 하려는 건지" 5초 만에 파악하기 위해 | +| ✨ 구상하는 해결책 | 방향을 미리 공유해야 리뷰어가 "그건 이렇게 하는 게 낫지 않아?"라고 조언해줄 수 있음 | +| ⚖️ 대안 | 다른 방법을 고려했다면 적어두면 나중에 "왜 이 방법을 택했지?"를 기록으로 남김 | +| 📝 추가 정보 | 참고 링크, 디자이너가 준 시안 이미지 등을 여기에 첨부 | + +### 📋 이슈 템플릿 B: 버그 리포트 `[BUG]` + +버그를 발견하면 이 양식을 사용합니다. **제목은 반드시 `[BUG]` 로 시작**해야 합니다. + +> **제목 예시**: `[BUG] 캘린더 페이지에서 날짜 클릭 시 에러 발생` + +``` +## 🐛 버그 설명 + +- 캘린더 그리드에서 특정 날짜를 클릭하면 "undefined is not a function" 에러가 콘솔에 출력됩니다. + +## 🔄 재현 방법 + +1. 메인 페이지에서 '캘린더' 탭을 누릅니다. +2. 아무 날짜나 클릭합니다. +3. 콘솔에 에러가 발생합니다! + +## 🎯 예상되는 정상 동작 + +- 날짜를 클릭하면 해당 날짜의 일정이 아래에 표시되어야 합니다. + +## 📸 스크린샷 또는 콘솔 로그 + + +## 💻 환경 정보 +- OS: Windows 11 +- 브라우저: Chrome 130 +``` + +| 항목 | 왜 적어야 하나요? | +| ----------------- | -------------------------------------------------------------------------- | +| 🐛 버그 설명 | "안 됩니다"만 적으면 아무도 도와줄 수 없음. **뭐가 안 되는지** 구체적으로! | +| 🔄 재현 방법 | 개발자가 같은 순서대로 따라 해서 버그를 직접 확인하려면 단계가 필요함 | +| 🎯 예상 정상 동작 | "원래 이렇게 돼야 하는데 안 된다"를 알아야 고칠 목표가 생김 | +| 📸 스크린샷/로그 | 글보다 캡처 한 장이 열 배 빠름. 특히 콘솔 에러 로그는 필수 | +| 💻 환경 정보 | "내 컴에선 되는데?" 문제를 방지. OS, 브라우저 버전이 다르면 버그가 달라짐 | + +> 🚨 **시스템 강제**: 이슈 제목이 `[FEAT]` 또는 `[BUG]`로 시작하지 않거나, 본문 항목을 비워두면 GitHub Action 봇이 자동으로 경고하고 이슈를 차단합니다. + +--- + +## 4. Pull Request (PR) 템플릿 및 통합 검증(CI) + +내가 짠 코드를 메인 코드에 합치고 싶을 때, 무작정 합치지 않고 **"내 코드 합쳐도 돼요?"** 하고 허락을 구하는 과정이 Pull Request입니다. + +### ❓ 왜 PR을 통해서만 합칠까요? + +개인의 컴퓨터에서는 잘 돌아가던 코드가 모두의 코드와 섞이면 안 돌아갈 수 있습니다. PR을 통하면: + +1. **코드 리뷰**: 다른 팀원이 내 코드를 보고 실수를 캐치해줄 수 있음 +2. **자동 검증(CI)**: 봇이 린트, 빌드 테스트를 대신 돌려줌 +3. **기록이 남음**: 언제, 누가, 왜 이 코드를 합쳤는지 영구 기록 + +### 📋 PR 템플릿 양식 + +GitHub에서 PR을 생성하면 아래 양식이 자동으로 채워져 나옵니다. **절대 양식을 지우지 마세요!** + +``` +## 🚀 작업 내용 (What) + +- 카카오 소셜 로그인 기능 구현 + +## 📣 핵심 변경 이유 (Why) + +- PRD 요구사항에 따라 카카오 OAuth 로그인이 필요합니다. + +## 📸 스크린샷 (Visuals, 선택) + + +## ⚠️ 체크리스트 (Checklist) +- [x] 브랜치 컨벤션(feature/00-name)을 지켰나요? +- [x] 커밋 컨벤션(feat:, fix: 등)을 지켰나요? +- [x] 작업 전에 관련 이슈를 생성하고 연결했나요? +- [x] 내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요? + +## 🔗 관련 이슈 (Issue) + +Close #12 +``` + +| 항목 | 왜 적어야 하나요? | 작성 팁 | +| ----------------------- | ---------------------------------------------------------------- | --------------------------------------------------- | +| 🚀 작업 내용 (What) | 리뷰어가 코드를 열기 전에 "대략 뭘 했구나" 감을 잡기 위해 | 한 줄로 핵심만. "로그인 페이지 UI 구현" 정도면 충분 | +| 📣 핵심 변경 이유 (Why) | "왜 이걸 만들었는가"를 기록해야 나중에 되돌아볼 때 이해가 됨 | PRD 어디에 나와있는지, 어떤 이슈를 해결하는지 한 줄 | +| 📸 스크린샷 | UI 변경이 있으면 리뷰어가 직접 실행하지 않아도 확인 가능 | 기능 변경이면 생략해도 됨 | +| ⚠️ 체크리스트 | 셀프 점검표. **4개 항목 모두 `[x]` 체크해야 봇 통과** | 하나라도 빠지면 ❌ | +| 🔗 관련 이슈 | `Close #12`라고 적으면 PR merge 시 해당 이슈가 **자동으로 닫힘** | 반드시 이슈 번호 기입! | + +> 🚨 **시스템 강제**: 체크리스트를 하나라도 체크하지 않거나(`- [ ]` 상태), PR 설명이 너무 짧으면 **CI 봇이 ❌ 실패를 뱉고 Merge 버튼이 비활성화**됩니다. 양식을 성실하게 작성해야 병합이 가능합니다. + +### 🤖 CI 봇이 실행하는 검사 목록 + +PR을 올리면 아래 검사가 자동으로 돌아갑니다: + +| 검사 | 하는 일 | 실패하면? | +| --------------------------------------------------------- | ------------------------------------------------------- | -------------------------------------- | +| **린트 검사** (`npm run lint`) | 코드 문법 오류, 미사용 변수 등 검출 | 빨간 ❌, 코드 고쳐서 다시 push | +| **단위 테스트** (`npm run test`) | 핵심 로직과 API 주변 회귀 테스트 실행 | 빨간 ❌, 실패 테스트 수정 후 다시 push | +| **빌드 테스트** (`npm run build`) | 프로젝트가 에러 없이 빌드되는지 확인 | 빨간 ❌, 빌드 에러 수정 후 다시 push | +| **E2E 테스트** (`npm run test:e2e -- --project=chromium`) | 핵심 사용자 플로우를 실제 브라우저로 검증 | 빨간 ❌, 사용자 흐름 수정 후 다시 push | +| **템플릿 검사** | 체크리스트 완료 여부, 본문 길이 검증 | 빨간 ❌, PR 설명을 보강해서 Edit | +| **팀원 승인 (Approve)** | 최소 1명 이상의 팀원이 코드를 읽고 ✅ Approve를 해야 함 | Merge 불가 상태 유지 | + +모든 조건이 달성되어야만 Merge 버튼이 활성화됩니다. (특히 팀원의 Approve는 필수 규칙입니다!) + +--- + +## 5. 환경 변수(ENV) 파일 관리 + +개발하다 보면 API 키, 데이터베이스 비밀번호 등의 비밀 정보가 생깁니다. 이를 절대 소스코드나 Github에 그대로 올리면 안 됩니다!! (해커들이 매크로로 탈취해 갑니다.) + +1. 비밀번호 등은 `.env` 파일에 작성하고 사용합니다. (이 파일은 무조건 Github에서 무시되도록 설정됨) +2. 팀원이 프로젝트를 처음 다운받았을 때, "어떤 환경변수가 필요한지" 알게 해주기 위해 **가짜 비밀번호가 담긴 `.env.example`** 파일을 만들어서 깃허브에 공유합니다. +3. 새로운 환경 변수를 추가했다면 꼭 `.env.example`에도 껍데기를 추가해 팀원들에게 알려주세요. diff --git a/docs/MOIM_role_screen/MOIM_role_screen_01_guest_role_hub.png b/docs/MOIM_role_screen/MOIM_role_screen_01_guest_role_hub.png new file mode 100644 index 0000000..12b02e3 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_01_guest_role_hub.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_02_guest_value_landing.png b/docs/MOIM_role_screen/MOIM_role_screen_02_guest_value_landing.png new file mode 100644 index 0000000..0cb7aa8 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_02_guest_value_landing.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_03_guest_action_choice.png b/docs/MOIM_role_screen/MOIM_role_screen_03_guest_action_choice.png new file mode 100644 index 0000000..8b026a7 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_03_guest_action_choice.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_04_guest_save_later_prompt.png b/docs/MOIM_role_screen/MOIM_role_screen_04_guest_save_later_prompt.png new file mode 100644 index 0000000..ad5ffb3 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_04_guest_save_later_prompt.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_05_guest_signed_in_comparison.png b/docs/MOIM_role_screen/MOIM_role_screen_05_guest_signed_in_comparison.png new file mode 100644 index 0000000..7755516 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_05_guest_signed_in_comparison.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_06_signed_in_home.png b/docs/MOIM_role_screen/MOIM_role_screen_06_signed_in_home.png new file mode 100644 index 0000000..527c8b3 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_06_signed_in_home.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_07_signed_in_calendar_settings.png b/docs/MOIM_role_screen/MOIM_role_screen_07_signed_in_calendar_settings.png new file mode 100644 index 0000000..d3c18cc Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_07_signed_in_calendar_settings.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_08_signed_in_default_settings.png b/docs/MOIM_role_screen/MOIM_role_screen_08_signed_in_default_settings.png new file mode 100644 index 0000000..bb6775e Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_08_signed_in_default_settings.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_09_signed_in_recent_meetings.png b/docs/MOIM_role_screen/MOIM_role_screen_09_signed_in_recent_meetings.png new file mode 100644 index 0000000..c7773d2 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_09_signed_in_recent_meetings.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_10_signed_in_profile.png b/docs/MOIM_role_screen/MOIM_role_screen_10_signed_in_profile.png new file mode 100644 index 0000000..27d5cd5 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_10_signed_in_profile.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_11_host_create_meeting.png b/docs/MOIM_role_screen/MOIM_role_screen_11_host_create_meeting.png new file mode 100644 index 0000000..204e282 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_11_host_create_meeting.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_12_host_calendar_setup.png b/docs/MOIM_role_screen/MOIM_role_screen_12_host_calendar_setup.png new file mode 100644 index 0000000..877aa7a Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_12_host_calendar_setup.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_13_host_candidate_times.png b/docs/MOIM_role_screen/MOIM_role_screen_13_host_candidate_times.png new file mode 100644 index 0000000..b6831b2 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_13_host_candidate_times.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_14_host_share_link.png b/docs/MOIM_role_screen/MOIM_role_screen_14_host_share_link.png new file mode 100644 index 0000000..2f3554a Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_14_host_share_link.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_15_host_status_board.png b/docs/MOIM_role_screen/MOIM_role_screen_15_host_status_board.png new file mode 100644 index 0000000..f837f22 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_15_host_status_board.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_16_participant_invite_entry.png b/docs/MOIM_role_screen/MOIM_role_screen_16_participant_invite_entry.png new file mode 100644 index 0000000..9cea412 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_16_participant_invite_entry.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_17_participant_calendar_detected.png b/docs/MOIM_role_screen/MOIM_role_screen_17_participant_calendar_detected.png new file mode 100644 index 0000000..81e80d2 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_17_participant_calendar_detected.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_18_participant_upload_recovery.png b/docs/MOIM_role_screen/MOIM_role_screen_18_participant_upload_recovery.png new file mode 100644 index 0000000..5c6f21a Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_18_participant_upload_recovery.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_19_participant_time_grid.png b/docs/MOIM_role_screen/MOIM_role_screen_19_participant_time_grid.png new file mode 100644 index 0000000..d829bed Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_19_participant_time_grid.png differ diff --git a/docs/MOIM_role_screen/MOIM_role_screen_20_participant_done_result.png b/docs/MOIM_role_screen/MOIM_role_screen_20_participant_done_result.png new file mode 100644 index 0000000..0ac2f96 Binary files /dev/null and b/docs/MOIM_role_screen/MOIM_role_screen_20_participant_done_result.png differ diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0f23dd0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,36 @@ +# MOIM 문서 인덱스 + +작성일: 2026-05-28 + +이 문서는 현재 repo에서 어떤 문서를 기준으로 읽어야 하는지 정리한 입구입니다. + +## 현재 기준 + +1. `docs/v1/prd.md` + - v1 제품 요구사항 정의서 (Supabase Auth 통합 및 MVP 기준). +2. `docs/v1/ui-screen-modification-plan.md` + - docs/MOIM_role_screen 시안 기반 화면 수정 및 보완 계획서. +3. `docs/v1/user-flow.md` + - guest-first, invite-link-first 제품 원칙을 정리한 원본 기준 문서입니다. +4. `docs/v1/codex-work-context.md` + - 현재 개발 상태, 검증 명령, 다음 세션 시작 프롬프트를 담은 handoff 문서입니다. +5. `docs/business-plan/MOIM_2026_학생창업유망팀300_사업계획서.docx` + - MOIM 서비스 공식 사업계획서 (Word 포맷) + +## 설문 및 기초 데이터 + +- `docs/survey/모임(응답) - 설문지 응답 시트1.csv`: 초기 사용자 설문조사 응답 로우 데이터입니다. +- `docs/startup-data/`: 창업 유관 공공 데이터 분석 결과 파일들을 담고 있습니다. + +## 보존 및 아카이브 문서 + +- `docs/v1/prd.md`: v1 제품 요구사항 정의서 (Supabase Auth 통합 및 MVP 기준). +- `docs/v1/ui-screen-modification-plan.md`: docs/MOIM_role_screen 시안 기반 화면 수정 및 보완 계획서. +- `docs/v1/user-flow.md`: guest-first, invite-link-first 제품 원칙을 정리한 원본 기준 문서입니다. +- `docs/v1/provided-documents-summary.md`: 이전 제공 문서 요약입니다. +- `docs/archive/legacy-business-plans/`: 과거 팀원들의 예전 사업계획서 및 예창패 양식 파일(HWPX)을 보존해둔 아카이브입니다. +- `docs/business-plan/drafts/`: 공식 사업계획서의 예전 백업본 및 초안입니다. + +## 읽는 순서 + +새 팀원이나 새 Codex 세션은 `README.md`를 본 뒤 이 파일을 읽고, 개발 작업 전에는 `docs/v1/codex-work-context.md`의 현재 repo 상태를 다시 확인합니다. diff --git "a/docs/archive/legacy-business-plans/20212578_7\355\214\205_\354\235\264\353\217\231\354\234\244_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234.hwpx" "b/docs/archive/legacy-business-plans/20212578_7\355\214\205_\354\235\264\353\217\231\354\234\244_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234.hwpx" new file mode 100644 index 0000000..676f100 Binary files /dev/null and "b/docs/archive/legacy-business-plans/20212578_7\355\214\205_\354\235\264\353\217\231\354\234\244_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234.hwpx" differ diff --git "a/docs/archive/legacy-business-plans/20222074_\354\235\264\353\217\231\354\235\200_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234.hwpx" "b/docs/archive/legacy-business-plans/20222074_\354\235\264\353\217\231\354\235\200_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234.hwpx" new file mode 100644 index 0000000..5599854 Binary files /dev/null and "b/docs/archive/legacy-business-plans/20222074_\354\235\264\353\217\231\354\235\200_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234.hwpx" differ diff --git "a/docs/archive/legacy-business-plans/20223007_\352\271\200\354\234\240\355\230\204_\354\230\210\354\260\275\355\214\250.hwpx" "b/docs/archive/legacy-business-plans/20223007_\352\271\200\354\234\240\355\230\204_\354\230\210\354\260\275\355\214\250.hwpx" new file mode 100644 index 0000000..9607bff Binary files /dev/null and "b/docs/archive/legacy-business-plans/20223007_\352\271\200\354\234\240\355\230\204_\354\230\210\354\260\275\355\214\250.hwpx" differ diff --git "a/docs/archive/legacy-business-plans/20223026_\354\240\225\352\267\234\355\230\270_\354\230\210\354\260\275\355\214\250.hwpx" "b/docs/archive/legacy-business-plans/20223026_\354\240\225\352\267\234\355\230\270_\354\230\210\354\260\275\355\214\250.hwpx" new file mode 100644 index 0000000..c3fd2eb Binary files /dev/null and "b/docs/archive/legacy-business-plans/20223026_\354\240\225\352\267\234\355\230\270_\354\230\210\354\260\275\355\214\250.hwpx" differ diff --git "a/docs/business-plan/MOIM_2026_\355\225\231\354\203\235\354\260\275\354\227\205\354\234\240\353\247\235\355\214\200300_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_raw_text.txt" "b/docs/business-plan/MOIM_2026_\355\225\231\354\203\235\354\260\275\354\227\205\354\234\240\353\247\235\355\214\200300_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_raw_text.txt" new file mode 100644 index 0000000..770248b --- /dev/null +++ "b/docs/business-plan/MOIM_2026_\355\225\231\354\203\235\354\260\275\354\227\205\354\234\240\353\247\235\355\214\200300_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_raw_text.txt" @@ -0,0 +1,87 @@ +2026 학생 창업유망팀 300+ 사업계획서 + + +0. 사업 아이템 개요 +□ 팀명 +MOIM +□ 아이템 개요 +캘린더와 에브리타임 시간표 연동 한 번으로 모두의 빈 시간을 자동으로 찾고, 확정 일정을 각자의 캘린더에 추가해주는 대학생 특화 인공지능 모임 비서 +□ 창업 목표 +반복적인 수동 일정 조율을 없애고, 대학생 팀 활동의 시간 낭비를 줄이는 국내 대표 스마트 스케줄링 서비스로 성장한다. + +1. 문제 인식 +□ 창업 배경 및 개발동기 +모임은 팀원들이 학교생활을 하면서 반복적으로 겪은 불편에서 출발했다. 조별과제, 동아리, 스터디 일정을 잡을 때마다 에브리타임 시간표를 캡처해 공유하고, 카카오톡 대화방에서 가능한 시간을 하나씩 맞춰보는 과정이 반복되었다. 개인 일정은 캘린더에 있고 수업 시간은 에브리타임에 따로 있어, 실제로 모두가 가능한 시간을 찾기까지 생각보다 많은 시간이 걸렸다. +특히 여러 명이 참여하는 일정일수록 한두 명의 답변이 늦어지고, 확정된 뒤에도 각자가 다시 캘린더에 입력해야 하는 불편이 있었다. 우리는 이 과정을 매번 사람이 직접 비교하는 대신, 각자의 시간표와 캘린더 정보를 한 번만 반영하면 공통으로 가능한 시간이 자동으로 보이는 방식으로 줄일 수 있다고 보았다. +초기 제품은 기능을 크게 벌리기보다 ‘모임 시간 자동 조율’이라는 한 가지 흐름에 집중한다. 링크 생성, 카카오톡 공유, 일정 입력 또는 연동, 공통 가능 시간 확인, 일정 확정까지의 과정을 먼저 제대로 만들고, 이후 사용자 반응을 보며 기능을 넓혀가려 한다. +□ 창업아이템의 목적 및 필요성 +모임의 목적은 사람 사이의 일정 조율 과정에서 발생하는 반복 입력, 확인 지연, 확정 후 재입력 문제를 제거하는 것이다. 사용자는 일정 제목과 후보 날짜 범위만 설정해 링크를 만들고, 참여자는 링크를 눌러 캘린더 접근을 허용하거나 시간표 이미지를 업로드하면 된다. 캘린더 연동을 원하지 않는 사용자는 시간표 그리드에서 직접 가능한 시간을 클릭해 참여할 수 있어 개인정보 우려나 연동 거부 상황에도 서비스 이용이 가능하다. +핵심 필요성은 세 가지이다. 첫째, 대학생의 수업 일정은 캘린더가 아니라 에브리타임에 분리되어 있는 경우가 많아 일반 캘린더 서비스만으로는 정확한 빈 시간을 알기 어렵다. 둘째, 카카오톡으로 일정을 논의하는 문화에서는 별도 앱 설치나 회원가입 단계가 길수록 참여율이 떨어진다. 셋째, 일정이 확정된 뒤에도 각자가 다시 캘린더에 입력해야 해 누락과 지각이 발생한다. +모임은 캘린더 일정, 시간표 이미지, 직접 입력 정보를 모두 “가능한 시간”과 “막힌 시간”으로 통합해 공통 가능 시간대를 계산한다. 확정된 일정은 캘린더 연동 참여자에게 자동으로 추가되고, 비연동 참여자에게는 캘린더에 추가할 수 있는 파일 또는 버튼을 제공한다. 이를 통해 조율부터 기록까지의 반복 작업을 하나의 흐름으로 줄인다. +□ 창업아이템의 목표시장 분석 +초기 목표시장은 국내 대학생 중 조별과제, 동아리, 스터디, 공모전, 팀 프로젝트를 자주 수행하는 사용자이다. 이들은 학기마다 고정 수업 시간표와 유동적인 개인 일정을 동시에 관리하며, 대부분의 조율은 카카오톡 단체방에서 이루어진다. 특히 팀 활동이 많은 전공, 창업·공모전 팀, 동아리 운영진은 매주 여러 차례 시간을 맞추는 반복 수요가 있다. +목표 고객의 특징은 문제 인식이 분명하지만 새로운 서비스를 쓰는 데에는 부담을 느낄 수 있다는 점이다. 일정 조율이 불편하다는 사실은 누구나 체감하지만, 별도 앱 설치나 복잡한 회원가입을 요구하면 사용이 중단되기 쉽다. 그래서 모임은 웹 링크 기반 접근, 카카오톡 공유, 소셜 로그인, 재방문 자동 반영을 통해 첫 사용의 부담을 낮추는 방향으로 설계한다. +경쟁 서비스로는 직접 가능한 시간을 칠하는 방식의 일정 조율 도구, 일반 캘린더 공유 기능, 해외 인공지능 일정 관리 서비스가 있다. 그러나 수동 입력 도구는 매번 참여자가 직접 시간을 입력해야 하고, 일반 캘린더는 에브리타임 수업 시간표와 카카오톡 공유 맥락을 반영하기 어렵다. 모임은 한국 대학생의 실제 일정 조율 방식에 맞춘 낮은 참여 장벽과 자동화를 차별점으로 삼는다. +기존 방식과 MOIM 방식 비교 +설문 결과가 아직 충분히 쌓인 단계는 아니므로, 제출 문서에는 과장된 수치를 넣기보다 현재 우리가 해결하려는 문제 흐름을 명확히 보여주는 비교 이미지를 넣는다. 이후 1차 사용자 설문과 프로토타입 테스트를 통해 평균 조율 시간, 메시지 수, 사용 의향을 실제 수치로 보완할 계획이다. +[그림 1 삽입 위치] 기존 일정 조율 방식과 MOIM 방식 비교권장 이미지: 카톡 대화·시간표 캡처·수동 비교 흐름과 링크 공유·공통 가능 시간 확인 흐름을 나란히 비교 + +그림 1. 기존 일정 조율 방식과 MOIM 방식 비교 + +2. 실현가능성 +□ 사업화 전략 +사업화는 먼저 작게 써볼 수 있는 프로토타입을 만들고, 실제 대학생 사용자의 반응을 확인하는 방식으로 진행한다. 첫 제품은 웹 기반으로 개발해 설치 부담을 줄이고, 모바일 카카오톡 링크에서 바로 참여할 수 있도록 한다. 주요 기능은 그룹 스케줄링 링크, 캘린더 또는 시간표 입력, 수동 입력 폴백, 공통 가능 시간 확인, 일정 확정 흐름이다. +기술적으로는 넥스트 기반 웹과 리액트 구조로 화면을 만들고, 데이터베이스·인증·실시간 현황은 수파베이스 기반으로 구축하는 방안을 검토하고 있다. 캘린더 연동은 구글 캘린더를 우선 검증하고, 시간표 이미지는 과목명 전체를 인식하기보다 ‘수업이 있는 시간 블록’을 확인하는 방식으로 접근한다. 이렇게 하면 개인정보 부담을 줄이면서도 일정 조율에 필요한 정보만 사용할 수 있다. +현재 프로토타입은 호스트의 모임 생성, 공유 링크 발급, 참여자의 일정 입력 또는 연동 선택, 공통 가능 시간 확인, 일정 확정으로 이어지는 MVP 핵심 흐름을 보여주는 데 집중한다. 외부 API 연동이 완성되지 않은 단계에서는 더미 일정 데이터를 활용해 사용자 흐름과 화면 구조를 먼저 검증한다. +[그림 2 삽입 위치] MOIM MVP 프로토타입 핵심 사용자 흐름권장 이미지: 모임 생성 화면 + 참여자 입력/연동 화면 + 공통 가능 시간 결과 화면을 가로 3단 구성 + +그림 2. MOIM MVP 프로토타입 핵심 사용자 흐름 +□ 시장분석 및 경쟁력 확보 방안 +시장 수요는 ‘시간을 맞추는 데 너무 많은 대화가 필요하다’는 반복적 불편에서 나온다. 사용자는 일정 조율 자체보다, 조율 과정이 줄어드는 편리함에 반응한다. 따라서 모임의 경쟁력은 기능을 많이 넣는 것보다 실제 조율 시간을 얼마나 줄이는지, 참여자가 얼마나 쉽게 완료하는지, 확정된 일정이 얼마나 자연스럽게 기록되는지에 달려 있다. +모임의 우위 요소는 첫째, 캘린더와 시간표 이미지를 함께 반영하려는 통합성이다. 둘째, 링크 클릭 후 바로 참여할 수 있는 낮은 진입 장벽이다. 셋째, 캘린더 연동을 원하지 않는 사용자를 위한 그리드 입력 방식이다. 넷째, 현재까지의 공통 가능 시간을 보여주어 대기 시간을 줄이는 점이다. +기존 방식 대비 개선 효과 +구분 +기존 방식 +MOIM 방식 +기대 효과 +입력 방식 +카톡 대화, 시간표 캡처, 수동 비교 +링크 접속 후 연동 또는 클릭 입력 +반복 입력 감소 +확인 방식 +사람이 직접 가능한 시간 비교 +공통 가능 시간 자동 산출 +조율 시간 단축 +확정 후 처리 +각자 캘린더에 재입력 +확정 일정 자동 추가 +누락 가능성 감소 + +표 1. 기존 일정 조율 방식 대비 MOIM 방식의 기대 개선 효과 + +3. 성장 전략 +□ 시장진입 및 성과창출 전략 +시장 진입은 소속 대학 내 작은 사용 사례부터 시작한다. 조별과제와 동아리 일정 조율이 잦은 팀을 대상으로 프로토타입을 사용해보게 하고, 실제로 시간이 줄었는지와 어떤 화면에서 막히는지를 확인한다. 초기에는 ‘카카오톡으로 링크 하나 보내면 모두의 가능한 시간이 보인다’는 메시지를 중심으로 홍보한다. +확산은 제품 자체의 공유 구조를 활용한다. 한 명이 모임 링크를 만들면 여러 참여자가 동시에 서비스를 경험한다. 일정 확정 후 결과 공유 화면에 다음 일정 생성 링크를 포함하면, 참여자가 다음에는 직접 호스트가 되는 흐름도 기대할 수 있다. +당장은 대학생 모임 일정 조율 문제에 집중한다. 다만 여러 사람의 일정을 맞추는 문제는 동아리, 학과, 창업지원단, 연구실, 나아가 면접 일정 조율이나 사내 미팅 조율 같은 조직 업무에서도 반복된다. 따라서 초기 검증 이후에는 대학 내 조직 단위로 먼저 확장하고, 장기적으로 기업용 일정 조율 서비스 가능성을 검토한다. +[그림 3 삽입 위치] MOIM 시장 확장 로드맵권장 이미지: 대학생 개인/소규모 팀 → 대학 내 조직 → 대학 기관 → 기업 면접·미팅 → 기업용 스케줄링 서비스 + +그림 3. MOIM의 시장 확장 로드맵 +오픈소스 활용 방향 +모임은 오픈소스 실전 창업 수업의 취지에 맞게 핵심 일정 조율 로직과 기본 화면 일부를 공개하는 방향을 검토하고 있다. 공개 가능한 부분은 개발자 커뮤니티가 확인하고 개선할 수 있게 하되, 실제 서비스 운영에 필요한 계정 관리, 조직용 기능, 보안 설정, 기업 시스템 연동, 기술지원은 별도 서비스 영역으로 남겨 장기적인 수익화 가능성을 확보하고자 한다. +□ 자금조달 계획 +현재는 무료 개발 도구와 팀원 보유 장비를 활용해 최소 비용으로 개발하고 있다. 기획, 화면 설계, 프로토타입 제작, 초기 테스트는 팀 내부 역량으로 진행하며, 비용이 큰 기능보다는 사용자가 실제로 불편을 느끼는 흐름을 확인하는 데 집중한다. +향후 지원금이나 외부 자금을 확보하게 된다면, 우선적으로 사용자 테스트, 개인정보·보안 검토, 서버 운영 안정화, 프로토타입 개선처럼 초기 검증에 꼭 필요한 항목부터 신중히 집행할 계획이다. 아직 사용자가 충분히 검증되지 않은 단계에서 큰 비용을 먼저 쓰기보다, 작은 실험을 반복하며 필요한 비용을 단계적으로 늘리는 방식을 택한다. +추가 자금은 학생 창업유망팀 300+, 캠퍼스 기술창업 공모전, 창업지원사업 등을 통해 검토한다. 유료 기능은 사용자 반응과 반복 사용이 확인된 이후에 실험하며, 초기에는 무료 사용 기반을 통해 문제 해결력과 재방문 가능성을 먼저 검증한다. + +4. 팀 구성 +□ 창업자의 역량 +팀장 김경수는 글로벌미디어학부 소속으로 프로젝트 방향 설정, 스프린트 관리, 이슈 생성과 분배, 코드 리뷰, 백엔드 설계를 담당한다. 합주실 예약 서비스 운영 경험을 통해 예약 흐름과 일정 데이터 관리의 실제 문제를 경험했으며, 여러 웹 프로젝트를 빠르게 시도하며 제품 검증과 구현 사이의 균형을 익혔다. +창업자로서의 강점은 문제를 직접 겪은 사용자이자 구현을 주도할 개발자라는 점이다. 일정 조율 문제를 단순 아이디어가 아니라 대학생 팀 활동의 반복 업무로 바라보고, 이를 링크 생성부터 캘린더 자동 추가까지 하나의 완결된 사용자 흐름으로 설계했다. 또한 오픈소스 도구와 인공지능 개발 보조 도구를 적극 활용해 제한된 시간과 비용 안에서 빠르게 시제품을 만들 수 있는 실행력을 갖추고 있다. +기술적으로는 데이터베이스, 인증, 캘린더 연동, 실시간 상태 표시, 가용시간 매칭 로직을 중심으로 사업화에 필요한 핵심 기능을 직접 이해하고 있다. 기획적으로는 사용자 첫 경험의 마찰을 줄이는 것, 재방문 시 자동 반영되는 구조, 카카오톡 공유를 통한 확산 구조를 주요 설계 원칙으로 삼고 있다. +□ 팀 구성원 소개 및 역량 +김유현은 글로벌미디어학부 소속으로 프론트엔드 개발을 담당한다. 캘린더 화면, 반응형 웹, 참여자 입력 화면, 실시간 현황 표시 등 사용자가 직접 만지는 핵심 화면을 구현한다. 리액트 기반 화면 구성과 사용자 흐름 구현 역량을 바탕으로 모바일 카카오톡 링크에서도 자연스럽게 사용할 수 있는 인터페이스를 만드는 역할을 맡는다. +정규호는 글로벌미디어학부 소속으로 프론트엔드와 백엔드를 함께 담당한다. 스프링 기반 개발, 데이터베이스, 넥스트 기반 웹 구현 경험을 바탕으로 캘린더 연동, 가용시간 매칭, 수동 입력 데이터 처리, 서버 구조 안정화에 참여한다. 팀장과 함께 핵심 로직을 검토하고 기능 단위 구현을 맡는다. +이동윤은 경영학과 소속으로 마케팅, 수익모델, 사업 전략, 사용자 획득 전략을 담당한다. 초기 목표 고객 정의, 캠퍼스 확산 전략, 가격정책, 경쟁 서비스 분석을 맡아 제품이 실제 시장에서 검증될 수 있도록 사업화 관점을 제공한다. +이동은은 영화예술학과 소속으로 디자인 피드백, 온보딩 경험, 영상 마케팅을 담당한다. 프리미어 프로와 포토샵 활용 역량을 바탕으로 서비스 소개 영상, 사용 전후 비교 콘텐츠, 카드뉴스를 제작해 초기 사용자 확보에 기여한다. 또한 참여자가 처음 링크를 눌렀을 때 이해하기 쉬운 화면 흐름과 안내 문구를 함께 검토한다. +팀은 개발, 기획, 마케팅, 디자인·영상 역량이 분산되어 있어 시제품 개발과 초기 사용자 확보를 동시에 추진할 수 있다. 깃허브 기반 코드 관리, 노션 기반 문서화, 디스코드 기반 실시간 소통, 구글 드라이브 기반 공동 편집을 활용해 오픈소스 방식의 협업 체계를 운영한다. diff --git "a/docs/business-plan/MOIM_2026_\355\225\231\354\203\235\354\260\275\354\227\205\354\234\240\353\247\235\355\214\200300_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\353\263\264\352\260\225\354\264\210\354\225\210.md" "b/docs/business-plan/MOIM_2026_\355\225\231\354\203\235\354\260\275\354\227\205\354\234\240\353\247\235\355\214\200300_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\353\263\264\352\260\225\354\264\210\354\225\210.md" new file mode 100644 index 0000000..bbbb583 --- /dev/null +++ "b/docs/business-plan/MOIM_2026_\355\225\231\354\203\235\354\260\275\354\227\205\354\234\240\353\247\235\355\214\200300_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_\353\263\264\352\260\225\354\264\210\354\225\210.md" @@ -0,0 +1,196 @@ +# 2026 학생 창업유망팀 300+ 사업계획서 + +## 0. 사업 아이템 개요 + +* **팀명**: MOIM (모임) +* **아이템 개요**: 개인 캘린더(Google/Apple)와 에브리타임 시간표 이미지 연동 한 번으로 모두의 가용 시간(Busy/Free)을 자동으로 취합하고, 독자적인 시간 교집합 알고리즘을 통해 최적의 후보 시간을 제시하며, 확정된 일정을 각 참여자의 캘린더에 자동으로 추가해주는 대학생·청년층 특화 인공지능(AI) 모임 비서 서비스 +* **창업 목표**: 대학 생활과 청년 단체 활동에서 반복적으로 소요되는 수동 일정 조율 비용을 제거하고, 시간 낭비를 혁신적으로 최소화하여 국내 스케줄링 및 스마트 시간 관리 분야의 대표 주자로 성장한다. + +--- + +## 1. 문제 인식 (Problem) + +### □ 창업 배경 및 개발동기 + +본 아이템은 팀원들이 대학 생활을 하며 매주 반복적으로 직면해 온 실제적인 불편함에서 출발했다. 조별 과제, 학술 스터디, 동아리 회의 등 모임을 잡을 때마다 각 팀원은 국내 대학생의 보편적 시간표 플랫폼인 ‘에브리타임’ 화면을 캡처해 카카오톡 단체 채팅방에 올리고, 모임 주최자(조장)는 여러 장의 시간표 이미지를 수동으로 눈 대조하며 엑셀이나 수동 조율 도구(When2Meet 등)에 입력해 빈 시간을 맞춰왔다. + +특히 개인 여가나 알바 등 개인 일정은 구글/애플 캘린더에 있고, 정규 수업 시간표는 에브리타임에 따로 분리되어 있어 본인의 가용 시간을 정확하게 파악해 전달하는 것조차 상당한 번거로움을 유발했다. 참여자가 4명 이상만 되어도 한두 명의 답변 지연으로 조율이 하루 이상 지체되는 일이 빈번했으며, 최종 조율이 완료된 뒤에도 확정 일정을 각자가 본인의 캘린더에 직접 입력해야 하는 탓에 일정 누락이나 지각 등의 휴먼 에러가 주기적으로 발생했다. + +이러한 일정 조율의 비효율성은 실제 자체 설문조사(대학생 및 청년 38명 대상) 결과에서도 뚜렷하게 확인된다. 설문에 따르면 한 달 평균 모임 일정 조율 횟수로 **'3회~5회'가 42.1%**, **'6회~10회' 및 '10회 이상'이 21.1%**로 나타나 대학생들의 일상 속 조율 빈도가 매우 높았다. 그럼에도 현재 조율 방식에 대한 만족도는 **3.13점 / 5점**으로 보통 이하 수준에 머물렀다. + +조율에 걸리는 시간은 **'30분 이상에서 하루 이상'이 소요된다고 답한 비율이 36.9%**(30분~1시간 7.9%, 1시간 이상 15.8%, 하루 넘게 끌림 13.2%)에 달했으며, 스케줄링의 병목으로 인해 **'일정이 지연되거나 모임 자체가 연기·무산된 경험이 있다'고 답한 비율이 무려 79.0%**("가끔 있다" 57.9%, "자주 있다" 21.1%)를 기록했다. + +특히 조원들이 일정 조율 과정에서 겪는 가장 파괴적인 스트레스 포인트(복수 응답)는 **"다 정해놨는데 한 명이 마지막에 안 된다고 번복할 때" (78.9%, 30명)**와 **"누가 응답을 제때 안 해서 한참을 무작정 기다려야 할 때" (60.5%, 23명)**로 나타났다. + +이러한 통계는 청년층의 일상 속 만성적인 시간 결핍감을 방증한다. 통계청 사회조사에 따르면 청년층 여가생활 불만족 이유 중 **'시간 부족'이 21.9%**로 높은 비중을 차지하고 있으며, 비효율적인 모임 일정 조율에 불필요하게 낭비되는 시간이 이들의 한정된 여가 시간 자원을 한층 더 압박하고 있다. 따라서 조율 단계를 극적으로 줄여 시간 낭비를 최소화하는 것은 대학생 청년들의 일상적 시간 결핍감을 해소하는 데 직접적으로 기여하는 유의미한 가치를 지닌다. + +> **💡 핵심 마케팅 슬로건의 의도** +> +> MOIM의 핵심 전파 슬로건은 **“카카오톡으로 링크 하나만 보내면 모두의 가능한 시간이 보인다”**이다. 이는 웹 링크 기반의 극도로 단순한 참여 방식을 직관적으로 표현한 것이다. 앱 설치와 회원가입이라는 심리적 거부 장벽(UX Friction)을 완벽히 제거하여 참여자 전원의 즉각적인 반응을 유도하고, 주최자가 미응답 조원을 기다리느라 낭비하는 시간(설문 비율 60.5%)을 없애 전체 조율 시간을 평균 3분 내외로 단축하겠다는 핵심 가치를 담고 있다. + +--- + +### □ 창업아이템의 목적 및 필요성 + +MOIM의 궁극적인 목적은 일정 조율에서 발생하는 **반복 입력(Redundancy)**, **확인 지연(Delay)**, **확정 후 재입력 누락(Error)**이라는 세 가지 병목 문제를 완전히 제거하는 것이다. + +대학생 생활 패턴을 분석한 통계청의 **`2024 생활시간조사`**에 따르면, 대학생 이상 집단의 하루 평균 학습 시간은 **3시간 11분**, 이동(통학 등) 시간은 **1시간 53분**에 달한다. 이 통계는 대학생이 겪는 일정 조율의 극심한 난이도를 논리적으로 입증한다. + +1. **시간표의 파편화(Fragmentation)와 비동기성**: 대학생의 학습 시간은 직장인(9to6)처럼 고정되어 있지 않고, 수강신청 결과에 따라 요일별로 흩어진 '파편화된 공강' 형태로 존재한다. 따라서 팀원 4~5명의 공강 시간이 서로 다르게 비동기적으로 배치되기 때문에, 개별적으로는 남는 시간이 많더라도 '연속된 2~3시간의 회의 시간'을 찾아 대조하는 데 수많은 소통과 대기가 강제된다. +2. **이동 통학 시간의 공간적 제약**: 통학러와 자취생이 섞여 있는 대학 모임의 특성상, 하루 평균 약 2시간에 달하는 긴 이동 동선 제약이 존재한다. 수업 전후 동선과 하교 시간이 일치하지 않으면 오프라인 모임을 잡기 위해 수많은 조율을 반복해야 한다. + +따라서 단순히 빈 칸만 찾는 기존 스케줄러(When2Meet 등)로는 통학 동선과 파편화된 수업 시간표를 배려한 현실적인 모임 조율이 불가능하다. MOIM은 다음과 같은 해결책을 제시한다. + +* **시간표 이미지 블록 추출**: 컴퓨터 비전 기술을 통해 에브리타임 이미지에서 과목명 텍스트 대신 '수업이 차 있는 시간 블록' 자체의 픽셀 영역만 인식해 바쁜 시간(Busy)으로 정규화한다. +* **낮은 진입 장벽의 게스트 입력**: 회원가입이 불필요한 웹 기반 참여와 함께, 캘린더 연동을 꺼리는 참여자들을 위해 터치 그리드로 가용 시간을 즉시 체크해 제출하는 폴백을 구축하여 참여 허들을 완벽하게 제거한다. + +--- + +### □ 창업아이템의 목표시장 분석 + +MOIM의 목표 시장은 대한민국 대학생 중 조별과제, 학과 스터디, 동아리, 공모전 등 팀 단위 협업을 월 1회 이상 수행하는 핵심 타겟층으로 정의한다. 이를 TAM-SAM-SOM 모델로 정량화하여 정의한다. + +#### 📊 TAM, SAM, SOM 시장 규모 계산 표 + +| 구분 | 개념 정의 | 구체적 산출 식 | 시장 규모 | +| :--- | :--- | :--- | :--- | +| **TAM**
(전체 시장) | 국내외 생산성, 협업 및 스마트 스케줄링 전체 시장 | 글로벌 협업 도구 및 캘린더 시장 조사기관 예측 지표 인용 | **약 5,000억 원**
(국내 협업툴 기준) | +| **SAM**
(유효 시장) | 국내 전체 대학생 및 20대 모임/스터디 일정 조율 활성 시장 | 국내 전체 대학생 수(250만 명) $\times$ 20대 단체활동 참여율(68%) $\times$ 연간 모임 관리 지불 용의 가치(연 12,000원 상당) | **약 204억 원** | +| **SOM**
(점유 시장) | 수도권 주요 대학의 조별과제 및 동아리 다빈도 조율 활성 유저 | SAM 대상자 중 수도권 대학생 비중(30%, 51만 명) $\times$ 서비스 초기 침투율 목표(5%) $\times$ 연간 구독/수수료 가치(연 12,000원) | **약 3억 원** | + +#### ⚔️ 경쟁 서비스 대비 차별적 우위 요소 + +기존 일정 조율 시장의 대체 수단들은 각기 명확한 한계를 가지고 있어 MOIM의 기회 요인이 부각된다. + +1. **When2Meet (수동 입력 도구)**: 모바일 환경에서 드래그 조작이 매우 힘든 조악한 UI(사용자 설문 불만족 피드백)를 갖고 있으며, 조율 완료 후 캘린더로 일정을 다시 옮겨 적는 데 불편함이 남아 있다. +2. **에브리타임 시간표 공유**: 텍스트 데이터가 아닌 이미지 파일들을 카카오톡 단톡방에 펼쳐놓고 한 명이 멱살잡고 비교해야 하는 비효율이 존재한다. +3. **애플/구글 캘린더 공유 기능**: 표준적이고 정교하지만, 에브리타임 등 대학생 특화 플랫폼 데이터나 사본화된 수업 정보의 입력을 지원하지 않으며 한국 특유의 카카오톡 공유 진입 맥락을 제공하지 못한다. + +MOIM은 **`[캘린더 자동 연동 + 에브리타임 이미지 업로드 + 수동 그리드 입력]`**이라는 3가지 방식을 동시에 제공해, 어떤 형태의 입력을 가진 사용자든 즉시 참여할 수 있는 **개방형 스케줄 통합 플랫폼**으로서 강력한 독점적 지위를 선점한다. + +``` +[그림 1] 기존 일정 조율 방식과 MOIM 방식 비교 흐름도 +- 기존 방식: 단톡방 개설 -> 시간표 캡처 업로드 -> 조장이 수동 엑셀 대조 -> 재조율 -> 각자 수동 캘린더 입력 (평균 4.5시간 소요) +- MOIM 방식: 조장 링크 생성 -> 단톡방 공유 -> 참여자 1초 연동/업로드 -> AI 최적 시간 도출 -> 자동 등록 (평균 3분 소요) +``` + +--- + +## 2. 실현가능성 (Solution) + +### □ 사업화 전략 + +MOIM은 점진적으로 확장 가능한 시제품 검증(MVP)에서 고도화된 서비스로 연착륙하기 위해 체계적인 시스템 구조 및 개발 로드맵을 구축했다. + +#### 🛠️ MOIM 시스템 아키텍처 및 데이터 흐름 + +``` +[참여자 데이터 입력부] [중앙 스케줄링 연산부] [결과 처리 및 연동부] +- Google Calendar API 연동 -----> +- Everytime 시간표 이미지 -----> [Busy/Free 표준 데이터셋 정규화] -----> [최적 후보 시간 알고리즘 연산] +- 웹 브라우저 직접 그리드 -----> | | + (Gemini AI 비정형 데이터 정제) (확정 일정 캘린더 자동 푸시) +``` + +1. **데이터 추출 및 정규화**: + * **구글 캘린더**: OAuth 2.0 프로토콜을 사용해 표준 일정 데이터(iCalendar 규격)를 JSON 형태로 연동하여 가용 시간을 파악한다. + * **에브리타임 이미지**: 모바일에 캡처된 시간표 이미지의 가시적 눈금을 인식하기 위해 가벼운 영상 처리 필터와 함께 AI 모델을 활용한다. 이미지를 과목 텍스트 분석에 매몰시키지 않고, '음영 처리된 고정 수업 시간 블록' 자체의 픽셀 좌표를 탐지하여 빠르고 정확하게 Busy 블록 데이터로 구조화한다. +2. **결정론적 공통 시간 매칭 알고리즘**: + * AI 기술은 비정형 이미지 인식과 후보 시간 제안 안내 멘트 작성 같은 영역에만 활용한다. + * 정작 최적 가용 시간의 교집합을 구하는 매칭 자체는 데이터 누락과 오류를 방지하기 위해 **100% 코드로 구성된 결정론적 연산 알고리즘**을 활용하여 연산 신뢰도를 온전히 보장한다. + +#### 🛡️ 설문 기반 우려사항 극복 전략 +자체 진행한 설문 조사 결과, 신규 스케줄러 이용 전 가장 걱정하는 포인트로 **"개인정보나 캘린더 정보의 노출 우려" (Q14 복수 응답)** 및 **"에브리타임 계정 연동 거부감"**이 도출되었다. MOIM은 다음과 같은 강력한 우려 해결 시스템을 탑재해 프라이버시를 수호한다. + +* **최소한의 가용성 정보만 조회**: 구글/애플 캘린더 연동 시 사적인 일정의 '제목'이나 '상세 내용'은 서버로 전혀 전송받지 않으며, 오직 일정이 있는지 여부(Busy/Free) 타임블록 데이터만 활용한다. +* **과목명 블라인드 처리**: 에브리타임 시간표 이미지를 분석할 때 과목 글씨를 분석해 저장하지 않으며, 단지 색칠된 시간 영역만 가져오므로 수업 정보 유출을 차단한다. +* **게스트 그리드 폴백 제공**: 데이터 연동 자체가 꺼려지는 참여자를 위해 로그인 없이 바로 가용 시간을 클릭하는 '비연동 수동 입력(Guest Grid)'을 제공하여 익명 사용을 철저히 존중한다. + +#### 📱 프로토타입 UI 스크린샷 맵핑 계획 + +1. **화면 1 (`part2_page27.png` 응용)**: 모임을 신속하게 만드는 호스트의 온보딩 설정 대시보드. +2. **화면 2**: 참여자가 카카오톡 링크를 클릭하고 입장했을 때 마주하는 3가지 간편 입력 허브 화면. +3. **화면 3**: 구글 캘린더 연동 허가 팝업 및 에브리타임 이미지 업로드 드롭존. +4. **화면 4**: 실시간으로 취합되는 팀원들의 스케줄 분포 및 공통 가용 시간 추천 타임라인. +5. **화면 5**: 주최자가 최적의 시간을 확정하는 확정 확인 화면. +6. **화면 6**: 최종 등록 완료와 함께 제공되는 개별 캘린더 연동 완료 안내 페이지. + +--- + +### □ 시장분석 및 경쟁력 확보 방안 + +MOIM은 대학생 및 청년 조직의 스케줄링 병목 해결사로 포지셔닝하여, 기존 서비스 대비 명확한 정량적 성능 향상을 제공한다. + +#### 📊 기존 방식 대비 MOIM 방식의 기대 개선 효과 (표 1) + +| 평가 항목 | 기존 일정 조율 방식 | MOIM 자동 조율 방식 | 기대 개선 효과 | +| :--- | :--- | :--- | :--- | +| **가용 데이터 입력** | 카카오톡 단톡방에서 대화 조율 및 수동 이미지 캡처 전송 | 캘린더 권한 허용 또는 캡처 이미지 드래그앤드롭 | 입력 마찰 최소화
(10분 $\rightarrow$ 5초) | +| **시간 취합 및 비교** | 주최자(조장)가 수동으로 눈 대조하여 엑셀 시트에 정리 | 표준화된 시간 매칭 알고리즘을 통한 즉시 자동 대조 | 조율 조작 리소스 제거
(30분 $\rightarrow$ 즉시) | +| **피드백 루프** | 미답변 인원에 대한 개별 카카오톡 리마인드 독촉 | 대시보드상 참여 현황 실시간 노출 및 비회원 즉시 대입 | 전체 조율 시간 단축
(평균 1일 $\rightarrow$ 3분) | +| **확정 일정 기록** | 공지글 등록 후 참여자들이 각자 수동으로 캘린더 입력 | 확정 버튼 클릭 즉시 참여자별 캘린더 동기화 자동 진행 | 노쇼(No-Show) 및 지각 누락율 0%화 | + +--- + +## 3. 성장 전략 (Scale-up) + +### □ 시장진입 및 성과창출 전략 (마케팅 & 유통) + +강의자료에서 강조된 마케팅 4P 및 4C 전략에 기반해, MOIM의 유기적 시장 진입과 점진적인 확산 경로를 설계했다. + +``` +[마케팅 4P-4C 융합 매트릭스] +1. Product (Customer Value) : 일정 조율 비효율 단축 가치 +2. Price (Cost) : 핵심 사용자 타겟 Freemium 구독 가치 +3. Place (Convenience) : 카카오톡 웹링크를 통한 즉각 진입 편의성 +4. Promotion (Communication): 에타 및 SNS 숏폼을 통한 오가닉 바이럴 소통 +``` + +#### 🔄 조원 $\rightarrow$ 조장 전이 기반 바이럴 선순환 구조 (Virtuous Cycle) +설문조사 결과 신규 도구 사용의 가장 큰 우려점은 **"친구들이 같이 안 쓸 것 같다" (36.8%, 14명)**는 점이었다. MOIM은 이 네트워크 파급 문제를 해결하기 위해, 서비스 구조 자체에서 전파되는 선순환 오가닉 루프를 활용한다. + +``` +[주최자(조장)가 MOIM 조율방 생성] + └──▶ [카카오톡 공유 링크 발송 (가입/설치 불필요)] + └──▶ [참여자(조원) 4~5명 입장하여 일정 제출 완료] + └──▶ [조원들이 극상의 조율 편리함(Convenience) 경험] + └──▶ [해당 조원들이 향후 타 모임에서 새로운 '주최자'로 전환] (반복 확산) +``` + +#### 📦 유통 채널(Place)의 단계적 고도화 +* **1단계 (모바일 웹 링크 유통)**: 회원가입, 앱 설치 없이 카카오톡 웹뷰에서 즉각 구동하여 접근성과 응답률을 극대화한다. +* **2단계 (공식 앱스토어 배포)**: 다수의 고정 프로젝트나 정기 동아리를 관리해야 하는 코어 주최자들을 타겟으로 하여, 캘린더 양방향 동기화 및 실시간 Push 알림 기능을 갖춘 네이티브 앱 형태로 유통한다. + +--- + +### □ 자금조달 계획 및 수익 모델 + +MOIM은 철저한 유저 설문조사 데이터와 시장성 분석에 입각하여 가격 정책(Price)과 자금 운용을 신중하게 집행한다. + +#### 💵 정량적 설문조사 기반 유료 서비스 타당성 +* **설문 지표**: 사전 대학생 FGI 및 설문조사 결과, **"최근 한 달간 모임 조율 횟수가 3회 이상이다"**고 답변한 핵심 주최자 유저 비율이 **63.2% (24명)**(3~5회 42.1%, 6~10회 13.2%, 10회 이상 7.9%)에 달했다. 이는 매주 번거로운 조율을 반복 수행해야 하는 고통을 겪는 명확한 '리더 집단'의 모수가 시장에 안정적으로 존재함을 뜻한다. +* **수익 모델(Freemium)**: + * **무료 티어**: 일반적인 대학생 조별과제(일회성 모임 개설 최대 3개, 참여자 최대 5인)를 위한 기본 일정 조율 무료 제공. + * **프리미엄 구독 티어 (월 1,900원)**: 월 3회 이상 다수의 모임을 개설해야 하는 동아리 운영진, 학생회, 대형 스터디 리더들을 대상으로 모임방 개설 무제한, 참여 현황 통계 다운로드, 모임용 공동 캘린더 홈 제공 등의 편의 기능을 묶어 과금 모델을 수립한다. 이는 해외의 기업용 일정 도구(Calendly 등 - 인당 월 $8~12) 대비 한국 대학생 주머니 사정에 맞춘 합리적 대안 비용(Cost)으로 책정되어 강력한 가격 경쟁력을 가진다. + +--- + +## 4. 팀 구성 (Team) + +MOIM 팀은 사업 아이템에 대한 기술적 구현 역량과 경영학적 비즈니스 기획 및 디자인 역량이 유기적으로 융합된 학생 전문 창업 조직이다. + +* **김경수 (팀장 / 글로벌미디어학부)**: + * **역할**: 프로젝트 총괄 관리, system 백엔드 아키텍처 설계, 가용시간 매칭 코어 알고리즘 구현. + * **강점**: 과거 대학생 합주실 예약 매칭 서비스를 직접 개발 및 운영하여 실제 복잡한 일정 시간표 데이터 파싱 로직에 정통하며, 스프린트 중심의 빠른 애자일 코드 검증 프로세스를 리드한다. +* **김유현 (프론트엔드 / 글로벌미디어학부)**: + * **역할**: 모바일 최적화 웹 UI/UX 개발, 인터랙티브 그리드 입력 화면 및 대시보드 구현. + * **강점**: 모바일 웹 표준과 리액트 프레임워크 전문성을 갖추고 있으며, 카카오톡 단톡방 인앱 브라우저에서도 끊김 없이 작동하는 반응형 캘린더 그리드 컴포넌트를 설계한다. +* **정규호 (풀스택 / 글로벌미디어학부)**: + * **역할**: 캘린더 외부 API 연동 모듈 개발, Supabase 데이터베이스 구축 및 데이터 동기화 안정성 관리. + * **강점**: 데이터 정규화 및 백엔드 트랜잭션 처리에 강점이 있으며, 다중 데이터 소스(캘린더 JSON + 이미지 데이터)를 하나의 가용 스케줄 데이터셋으로 파싱하는 인프라를 구축한다. +* **이동윤 (비즈니스 기획 / 경영학과)**: + * **역할**: TAM/SAM/SOM 시장 분석, 마케팅 4P/4C 전략 설계, FGI 설문조사 기반 유료 구독 과금 모델 기획. + * **강점**: 시장 타겟팅 및 비즈니스 모델 캔버스 설계 강점을 통해 개발팀의 아이디어를 실제 시장성 있는 비즈니스 가치 제안으로 정밀 튜닝하고 사업계획서의 논리적 설득력을 보강한다. +* **이동은 (디자인 & 홍보 / 영화예술학과)**: + * **역할**: 서비스 화면 와이어프레임 설계, 브랜드 아이덴티티(BI) 가이드라인 수립, 바이럴 숏폼 광고 콘텐츠 기획. + * **강점**: 스토리보드 및 영상 시각 디자인 제작 역량을 바탕으로 온보딩 여정의 텍스트 레이아웃 피드백을 제공하고, 에브리타임 게시판에 게재할 홍보용 숏폼 및 카드뉴스 콘텐츠를 주도적으로 생산한다. diff --git "a/docs/business-plan/MOIM_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_1_dump.txt" "b/docs/business-plan/MOIM_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_1_dump.txt" new file mode 100644 index 0000000..b9621e6 --- /dev/null +++ "b/docs/business-plan/MOIM_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_1_dump.txt" @@ -0,0 +1,267 @@ +P_0: +P_1: 2026 오픈소스기반실전창업 +P_2: 사 업 계 획 서 +P_3: MOIM (모임) +P_4: AI 기반 대학생 특화 모임 일정 자동 조율 서비스 +P_5: 팀명: MOIM +P_6: 대표: 김경수 (글로벌미디어학부) +P_7: 제출일: 2026년 5월 +P_8: +P_9: 0. 사업 아이템 개요 +P_10: +P_11: +P_12: 1. 문제 인식 (Problem) +P_13: 1-1. 창업 배경 및 개발동기 +P_14: 창업 아이템 소개 +P_15: MOIM(모임) - 대학생의 모임 일정 조율 과정을 자동화하는 웹 기반 서비스 +P_16: 참여자가 하나의 링크를 통해 자신의 캘린더 또는 에브리타임 시간표를 연동 +P_17: AI가 모두의 공통 가능 시간을 자동으로 찾아 추천 +P_18: 확정된 일정을 각자의 캘린더에 자동으로 바로 추가 +P_19: 창업 동기 +P_20: 조별과제, 동아리, 스터디 등 모임 일정을 잡을 때마다 에브리타임 시간표를 캡쳐해 공유하고, 카카오톡 대화방에서 가능한 시간을 하나씩 맞춰보는 과정이 반복됨 +P_21: 개인 일정은 캘린더에 있고 수업 시간은 에브리타임에 따로 있어, 각각의 플랫폼을 오가는 과정이 불편함 +P_22: 특히 여러 명이 참여하는 일정일수록 한두 명의 답변이 늦어지는 등 실제로 모두가 가능한 시간을 찾기까지 생각보다 많은 시간이 걸리며, 확정된 뒤에도 각자가 다시 캘린더에 입력해야 하는 과정이 불편함 +P_23: → 각자의 시간표와 캘린더 정보를 한 번만 반영하면 공통으로 가능한 시간이 자동으로 보이는 방식으로 줄일 수 있다고 판단 +P_24: 비전 및 경영철학 +P_25: Vision: 모든 대학생이 모임 시간 걱정 없이 활동에만 집중할 수 있는 캠퍼스 +P_26: Mission: 반복적인 일정 조율의 마찰을 기술로 제거하여, 대학생 팀 활동의 실행력 증대 +P_27: 창업 아이템의 기여도 +MOIM은 기술적, 경제적, 사회적 세 측면에서 기여함 +P_28: 기술적 기여: 에브리타임 시간표 이미지를 AI 비전 기술로 분석 → 수동으로만 처리하던 시간표 데이터를 자동화 +P_29: 경제적 기여: 대학생 한 명당 월 평균 2시간 이상 소모되는 일정 조율 시간을 절감 → 학업과 활동에 투입할 수 있는 시간 증가 +P_30: 사회적 기여: 일정 조율 실패로 인한 팀 활동 무산 감소 → 대학 내 협업 문화를 개선 +P_31: 1-2. 창업아이템의 목적 및 필요성 +P_32: 고객 설문 조사 결과 (28명 응답) +P_33: 문제의 빈번성과 심각성을 검증하기 위해 대학생 28명을 대상으로 설문 조사를 실시한 결과 +P_34: +P_35: 문제 우선순위 매트릭스 +P_36: 설문 결과를 토대로 대학생 일정 조율의 핵심 문제를 세 가지로 정리 +P_37: 1) 정보 분산 +P_38: 수업 시간은 에브리타임에, 개인 일정은 캘린더에, 조율 대화는 카카오톡에 흩어져 있어 한 번에 확인하기 어려움 +P_39: 응답자의 93%가 카카오톡(채팅 또는 투표)으로 조율하고 있어, 별도 도구로의 전환 비용이 핵심 장벽임 +P_40: 2) 응답 지연 & 확정 후 번복 +P_41: 가장 큰 불편으로는 68%가 응답 지연을, 86%가 확정 후 번복을 꼽음 +P_42: 수동 방식에서는 모든 참여자가 응답할 때까지 기다려야 하므로 한 명의 지연이 전체 일정을 막는 문제점 발생함 +P_43: 3) 반복 입력으로 인한 피로도 증가 +P_44: 확정된 일정을 다시 캘린더에 수동 입력해야 하는 문제(14%) +P_45: 기존 일정 조율 서비스에 매번 시간을 체크 하는 문제(14%) +P_46: 대체수단 분석 +P_47: 기존의 일정 조율 방식과 MOIM의 차별점을 비교 +P_48: +P_49: 1-3. 목표시장 분석 +P_50: 초기 목표시장 +국내 대학생 중 조별과제, 동아리, 스터디, 공모전, 팀 프로젝트를 자주 수행하는 사용자 +P_51: 조율의 대부분은 카카오톡 단체방에서 이루어지며, 설문 응답자 28명 중 68%(19명)가 월 3회 이상 일정 조율 +P_52: 팀 활동이 많은 전공, 창업팀, 동아리 운영진은 매주 여러 차례 시간을 맞추는 반복 수요가 있음 +P_53: 목표 고객의 핵심 특성 - 문제 인식이 분명하지만 새로운 서비스 도입에는 부담을 느낌 +P_54: 설문에서 친구들이 같이 안 쓸 것 같다(43%)를 가장 큰 걱정으로 꼽음 +P_55: → MOIM은 웹 링크 기반 접근, 카카오톡 공유, 소셜 로그인으로 첫 사용의 부담을 최소화하는 방향으로 설계 +P_56: 매력적 기능 선호도를 보면, 공통 빈 시간 자동 추천(50%)과 에브리타임 시간표 자동 불러오기(43%)가 가장 높음 +P_57: 2. 실현가능성 (Solution) +P_58: 2-1. 사업화 전략 +P_59: 가치제안 정의 +P_60: MOIM의 핵심 가치제안 +P_61: 링크 하나로 모두의 빈 시간을 자동으로 조율 +P_62: 카카오톡 조율 방식 대비 시간 절감으로 기존 30분~하루 이상 걸리던 조율 과정을 연동을 통해 5분 이내로 단축 +P_63: 에브리타임 시간표와 캘린더를 통합 반영하여 수업과 개인 일정 관리의 분리 플랫폼 문제를 해결 +P_64: 확정된 일정이 자동으로 캘린더에 추가되어 재입력 없이 기록이 완성 및 편리함 제공 +P_65: 핵심기능 선정 +P_66: MVP는 모임 시간 자동 조율이라는 한 가지 흐름에 집중 +P_67: 프로토타입 및 PoC +P_68: 호스트의 모임 생성부터 일정 확정까지의 MVP 핵심 흐름에 집중함 +P_69: 외부 API 연동이 완성되지 않은 단계에서는 더미 일정 데이터를 활용해 사용자 흐름과 화면 구조를 우선 검증함 +P_70: +P_71: +P_72: +P_73: MVP 8주 개발 일정 +P_74: +P_75: 기술 타당성 검토 +P_76: 기술스택: 팀이 빠르게 구현하고 유지할 수 있는 무료 또는 저비용 도구 중심 +P_77: 프론트엔드: Next.js, React, TypeScript, Tailwind CSS, shadcn/ui를 사용 +P_78: 백엔드: Supabase(PostgreSQL, Auth, Realtime)로 별도 서버 운영 부담 최소화 +P_79: 배포: Vercel 무료 플랜, 시간표 이미지 분석은 Gemini Vision API 무료 사용량을 활용 +P_80: 캘린더 연동 +P_81: Google Calendar API 우선 검토 +P_82: 시간표 이미지(에브리타임 등)는 수업 블록 인식 방식으로 개인정보 부담 최소화 +P_83: +P_84: +P_85: +P_86: +P_87: +P_88: 2-2. 시장분석 및 경쟁력 확보 방안 +P_89: 시장분석 +P_90: 반복적 불편: 일정 조율에 많은 대화와 시간이 걸림 +P_91: 설문 결과 +P_92: 현재 방법(대화를 통한 조율) 만족도: 보통 수준 (3.0/5점) +P_93: MOIM 사용 의향: 긍정적 반응 (3.9/5점) +P_94: 핵심 경쟁력 +P_95: 통합성: 캘린더와 에브리타임 시간표 이미지를 함께 반영 +P_96: 낮은 진입 장벽: 링크 클릭 후 바로 참여 가능 +P_97: 그리드 입력 방식(폴백): 캘린더 연동을 원하지 않는 사용자 고려 +P_98: 대기 시간 감소: 현재까지의 공통 가능 시간을 실시간으로 표시 +P_99: +P_100: 예상되는 문제점과 극복 방안 +P_101: +P_102: +P_103: 3. 성장 전략 (Scale-up) +P_104: 3-1. 시장진입 및 성과창출 전략 +P_105: 초기 GTM(Go-to-Market) 전략 +P_106: 시장 진입 +P_107: 조별과제와 동아리 대상으로 일정 조율이 잦은 팀 대상으로 베타테스트 및 프로토타입 사용 제안 +P_108: 실제로 시간이 단축되었는지, 어떤 화면에서 막히는 지 확인 및 검증 +P_109: 초기 메시지는 “카카오톡으로 링크 하나만 보내면 모두의 가능한 시간이 보인다” 로 설정 +P_110: 채널 및 퍼널 설계 +P_111: 확산은 제품 자체의 공유 구조를 활용함. +P_112: 제품 자체 바이럴 구조 : 한 명이 모임 링크를 만들면 여러 참여자가 동시에 서비스를 경험 +P_113: 일정 확정 후 결과 공유 화면에 다음 일정 생성 링크를 포함하면, 참여자가 다음에는 직접 호스트가 되는 흐름도 기대 가능 +P_114: 공유구조를 이용해 별도 마케팅 비용 없이 자연스러운 확산을 추구 +P_115: 시장 확장 로드맵 +P_116: +P_117: 가격 및 수익모델 +P_118: 초기에는 무료 사용 기반으로 문제 해결력과 재방문 가능성을 먼저 검증 +P_119: 유료 기능은 사용자 반응과 반복 사용이 확인된 이후에 실험하며, 장기적으로는 오픈 코어(Open Core) 모델을 검토 +P_120: 핵심 일정 조율 로직과 기본 화면은 오픈소스로 공개하되, 조직용 기능(계정 관리, 보안 설정, 기업 시스템 연동, 기술지원)은 유료 서비스 영역으로 남겨 수익화 가능성을 확보 +P_121: 3-2. 자금조달 계획 +P_122: 현재는 무료 개발 도구와 팀원 보유 장비를 활용해 최소 비용으로 개발 중. 초기 총 필요 현금은 약 10만~25만 원 수준으로 예상 +P_123: 3-3. 운영 및 인프라 확장 계획 +P_124: 사업 목적: 재무적 목표 +P_125: 1단계(MVP 검증기) : 매출보다 사용자 반복 사용률 확인에 집중함 +P_126: 2단계(확산기) : 대학 단위 사용자 1,000명 확보를 목표로 함 +P_127: 3단계(수익화 검토기) : 조직용 프리미엄 기능 월 구독 모델을 실험함 +P_128: 사업 목적: 비재무적 목표 +P_129: 대학생 팀 활동의 실행력 향상, 일정 조율 실패로 인한 모임 무산 감소, 오픈소스 커뮤니티 기여를 통한 개발자 생태계 참여를 비재무적 목표로 설정 +P_130: +P_131: 4. 팀 구성 (Team) +P_132: PSST 모델의 핵심 질문: 왜 우리가 이 문제를 가장 잘 푸는가? +P_133: 4-1. 창업자의 역량 +P_134: 팀장 김경수는 글로벌미디어학부 소속으로 프로젝트 방향 설정, 스프린트 관리, 코드 리뷰, 백엔드 설계를 담당 +P_135: 합주실 예약 서비스 운영 경험을 통해 예약 흐름과 일정 데이터 관리의 실제 문제를 경험 +P_136: 여러 웹 프로젝트를 빠르게 시도하며 제품 검증과 구현 사이의 균형을 익힘 +P_137: 창업자로서의 강점은 문제를 직접 겪은 사용자이자 구현을 주도할 개발자라는 점점 +P_138: 팀원 김유현은 글로벌미디어학부 소속으로 전반적이 UX/UI 디자인과 프론트엔드 개발 담당 +P_139: 풀스택으로 프로젝트를 진행한 경험을 바탕으로 프론트엔드와 백엔드 간 원만한 의사소통 가능 +P_140: 여러 웹 & 앱 프로젝트 경험으로 간편한 사용자 흐름 제안 +P_141: 팀원 이동은은 예술창작학부 소속으로 서비스 소개 영상, 카드뉴스 제작, 온보딩 UX 검토를 담당 +P_142: Premiere, Photoshop 작업 경험을 활용한 홍보 영상 및 카드뉴스 제작으로 브랜드 이미지 구축 +P_143: 사용자 관점에서의 UX 검토를 통해 서비스 진입 장벽을 낮추고, 브랜드 정체성이 일관되게 유지되는 디자인 환경 구축 +P_144: 팀원 이동윤은 경영학부 소속으로 기획, 마케팅, 재무를 담당 +P_145: 재무, 회계, 마케팅 등 경영 전반 전공 수강 +P_146: 투자 학회를 통해 k뷰티 등 다양한 기업 및 산업 분석 +P_147: 팀원 정규호는 글로벌미디어학부 소속으로 백엔드 설계 및 개발을 담당 +P_148: 개발 스터디와 여러 프로젝트들을 통한 서비스 구현 경험과 운영 경험을 통해 문제해결 및 대응 능력을 기름 +P_149: 원활한 서비스 운영과 내부 설계로 개발 및 유지보수 담당 +P_150: +P_151: +P_152: 팀 구성원 소개 및 역량 +P_153: +P_154: 팀은 개발(3명), 기획/마케팅(1명), 디자인/영상(1명) 역량이 분산되어 있어 시제품 개발과 초기 사용자 확보를 동시에 추진 가능 +P_155: 의사결정은 주 1회 스프린트 리뷰를 통해 진행하며, GitHub 기반 코드 관리, Notion 기반 문서화, Discord 기반 실시간 소통을 활용해 오픈소스 방식의 협업 체계를 운영 +P_156: 4-2. 외부 협력기관 현황 및 활용 계획 +P_157: 현재 팀은 소속 대학의 창업지원단을 통해 멘토링과 사업화 지원을 받을 수 있는 경로를 확보 +P_158: 향후 캠퍼스 기술창업 공모전 및 학생 창업유망팀 300+ 프로그램을 통해 전문가 자문과 네트워크를 확장할 계획 +P_159: 활용 가능 네트워크 +P_160: +P_161: 4-3. 중장기 사회적 가치 도입계획 +P_162: MOIM은 오픈소스 실전 창업 수업의 취지에 맞게 핵심 일정 조율 로직과 기본 화면 일부를 오픈소스로 공개하는 방향을 검토함으로써 다음과 같은 사회적 가치를 추구 +P_163: 교육적 기여: 대학생 개발자들이 실제 서비스의 코드를 보고 배울 수 있는 오픈소스 학습 자원을 제공 +P_164: 커뮤니티 기여: 개발자 커뮤니티가 코드를 검토하고 개선에 참여할 수 있는 구조를 만들어 오픈소스 생태계에 기여 +P_165: 접근성 보장: 기본 일정 조율 기능은 무료로 유지하여, 경제적 여건과 관계없이 모든 대학생이 효율적인 팀 활동을 할 수 있도록 함 +P_166: ESG 연계: 장기적으로 장애 학생을 위한 접근성 기능(스크린 리더 지원, 고대비 모드)을 추가하여 포용적 서비스로 발전 +P_167: +P_168: +P_169: +P_170: +P_171: +P_172: 부록. 설문 조사 상세 결과 +P_173: 조사 기간: 2026년 5월 | 응답자: 대학생 28명 | 방법: 온라인 설문 +P_174: 주요 정량 결과 요약 +P_175: + +--- TABLE 0 --- +T_0_R0: 팀명 | MOIM +T_0_R1: 아이템 개요 | 캘린더와 에브리타임 시간표 연동 한 번으로 모두의 빈 시간을 자동으로 찾고, 확정 일정을 각자의 캘린더에 추가해주는 대학생 특화 AI 모임 비서 +T_0_R2: 창업 목표 | 반복적인 수동 일정 조율을 없애고, 대학생 팀 활동의 시간 낭비를 줄이는 국내 대표 스마트 스케줄링 서비스로 성장 + +--- TABLE 1 --- +T_1_R0: 조사 항목 | 결과 +T_1_R1: 월간 일정 조율 빈도 | 3~5회 50%, 6회 이상 18% +T_1_R2: 1회 조율 평균 소요 시간 | 30분 이상 46%, 하루 넘게 끌린다 18% +T_1_R3: 가장 짜증나는 순간 1위 | 다 정해놨는데 한 명이 안 된다고 할 때 (86%) +T_1_R4: 가장 짜증나는 순간 2위 | 누가 응답 안 해서 한참 기다릴 때 (68%) +T_1_R5: 모임 무산/연기 경험 | 가끔+자주 있다 79% (22명) +T_1_R6: 현재 조율 방법 만족도 | 평균 3.0/5점 (보통 수준) +T_1_R7: MOIM 사용 의향 | 평균 3.9/5점 (긍정적) +T_1_R8: 베타 테스터 신청 | 32% (9명) 자발적 신청 + +--- TABLE 2 --- +T_2_R0: 구분 | 카카오톡(채팅/투표) | When2meet | MOIM +T_2_R1: 입력 방식 | 텍스트 대화로 수동 비교 | 매번 시간 그리드 직접 클릭 | 캘린더/시간표 자동 연동 또는 그리드 클릭 +T_2_R2: 시간표 반영 | 캡처 이미지 공유 후 눈으로 비교 | 수동 입력 필요 | 에브리타임 이미지 AI 자동 분석 +T_2_R3: 결과 확인 | 대화를 다시 읽어 확인 | 겹치는 색상 눈으로 확인 | 공통 가능 시간 자동 계산 및 추천 +T_2_R4: 확정 후 처리 | 각자 캘린더 재입력 | 각자 캘린더 재입력 | 캘린더 자동 추가 및 카톡 공유 +T_2_R5: 카톡 공유 | 기본 지원 | 링크 복사 후 수동 공유 | 카카오 공유 API 기본 내장 + +--- TABLE 3 --- +T_3_R0: 순서 | 기능 | 설명 +T_3_R1: 1 | 모임 생성 | 호스트가 모임 제목, 후보 날짜 범위를 설정 → 공유 링크 생성 +T_3_R2: 2 | 카카오톡 공유 | 카카오 Share API를 통해 단체방에 링크 공유 가능 +T_3_R3: 3 | 일정 입력/연동 | Google/Apple 캘린더 연동, 에브리타임 시간표 이미지 업로드(Gemini Vision API 분석), 또는 시간 그리드 직접 클릭 중 선택 가능 +T_3_R4: 4 | 공통 가능 시간 확인 | 모든 참여자의 가능/불가능 시간을 통합 계산하여 공통 가능 시간대를 시각적으로 표시 +T_3_R5: 5 | 일정 확정 및 자동 추가 | 호스트 모임 일정 확정 →연동 참여자: 캘린더에 자동 추가 →비연동 참여자: .ics 파일 또는 캘린더 추가 버튼을 제공 + +--- TABLE 4 --- +T_4_R0: 기간 | 주요 목표 | 세부 내용 +T_4_R1: 1~2주차 | 요구사항 정리 및 기본 설계 | 호스트/참여자 흐름 정리, 와이어프레임 제작, Next.js 세팅, Supabase 테이블 설계 +T_4_R2: 3~4주차 | 모임 생성 및 수동 입력 | 모임 생성, 후보 날짜 설정, 공유 링크 생성, 참여자 가능 시간 선택 UI +T_4_R3: 5주차 | 시간표 이미지 입력 검증 | 에브리타임 캡쳐 업로드, Gemini Vision API 분석 테스트, 수동 보정 화면 +T_4_R4: 6주차 | 공통 가능 시간 및 확정 | 참여자별 입력 현황, 공통 가능 시간 결과 화면, 호스트 일정 확정 기능 +T_4_R5: 7주차 | 캘린더 추가 및 공유 보강 | .ics 파일 생성, 카카오톡 공유 메시지, 모바일 사용성 개선 +T_4_R6: 8주차 | 교내 사용자 테스트 | 조별과제/동아리 팀 대상 소규모 테스트, 피드백 수집, 버그 수정 + +--- TABLE 5 --- +T_5_R0: 예상 문제점 | 영향 | 극복 방안 +T_5_R1: 주변 참여도 저조 (43%) | 네트워크 효과 부족으로 확산 정체 | 카톡 링크만으로 참여 가능하게 설계, 앱 설치/회원가입 불필요 +T_5_R2: 기존 방식 대비 속도 우위 불확실 (25%) | 서비스 전환 동기 부족 | 카톡 안에서 링크 공유로 시작, 기존 습관 유지하면서 자동화 추가 +T_5_R3: 에브리타임 계정 연동 거부감 | 핵심 기능 사용률 저하 | 시간표 이미지 업로드 방식으로 계정 연동 불필요, 수업 시간 블록만 확인 +T_5_R4: 시간표 이미지 인식 정확도 의문 | 잘못된 빈 시간 추천 | AI 분석 후 수동 보정 화면 제공, 점진적 정확도 개선 + +--- TABLE 6 --- +T_6_R0: 단계 | 목표 시장 | 주요 활동 +T_6_R1: 1단계 (1~2학기) | 소속 대학 내 조별과제/동아리 팀 | 프로토타입 테스트, 핵심 흐름 검증, 피드백 기반 개선 +T_6_R2: 2단계 (3~4학기) | 수도권 주요 대학 확산 | 에브리타임 커뮤니티 홍보, 학생회/동아리 연합 파트너십 +T_6_R3: 3단계 (2~3년) | 전국 대학 및 대학 조직 단위 | 창업지원단, 연구실, 학과 단위 도입, 조직용 기능 추가 +T_6_R4: 장기 (3년~) | 기업용 일정 조율 서비스 | 면접 일정, 사내 미팅 조율 등 B2B SaaS 모델 검토 + +--- TABLE 7 --- +T_7_R0: 항목 | 예상 금액 | 사용 목적 +T_7_R1: 도메인 구입 | 1~2만 원/년 | 서비스 주소 확보 +T_7_R2: 서버/DB 운영 | 0원 | Vercel, Supabase 무료 플랜 활용 +T_7_R3: 협업 도구 | 0원 | GitHub, Notion, Discord 무료 플랜 +T_7_R4: 사용자 테스트 사례비 | 5~10만 원 | 교내 테스트 참여자 소액 보상 +T_7_R5: API 테스트 및 예비비 | 5~10만 원 | Gemini Vision API 초과분, 배포 오류 대응 + +--- TABLE 8 --- +T_8_R0: 이름 | 소속 | 역할 | 핵심 역량 +T_8_R1: 김경수 | 글로벌미디어학부 | 팀장 / 백엔드 / PM | 프로젝트 총괄, DB/인증/API 설계, 스프린트 관리, 합주실 예약 서비스 운영 경험 +T_8_R2: 김유현 | 글로벌미디어학부 | UX/UI 디자인 / 프론트엔드 개발 | 캘린더 UI, 반응형 웹, 참여자 입력 화면, 실시간 현황 표시 구현 +T_8_R3: 정규호 | 글로벌미디어학부 | 풀스택 개발 | 캘린더 연동, 가용시간 매칭 로직, 서버 안정화, Spring/Next.js 경험 +T_8_R4: 이동윤 | 경영학과 | 마케팅 / 사업전략 | 목표 고객 정의, 캠퍼스 확산 전략, 가격정책, 경쟁 분석 +T_8_R5: 이동은 | 영화예술학과 | 디자인 / 영상 마케팅 | 서비스 소개 영상, 카드뉴스 제작, 온보딩 UX 검토, Premiere/Photoshop + +--- TABLE 9 --- +T_9_R0: 분류 | 내용 +T_9_R1: R&D 지원 | 소속 대학 SW 관련 학과 교수진 자문, Google/Supabase 개발자 커뮤니티 +T_9_R2: 사업화 지원 | 대학 창업지원단 멘토링, 학생 창업유망팀 300+ 프로그램 +T_9_R3: 사용자 확보 | 교내 동아리 연합, 학생회, 에브리타임 커뮤니티 +T_9_R4: 기술 파트너 | Google Cloud(Gemini Vision API), Vercel, Supabase 스타트업 프로그램 + +--- TABLE 10 --- +T_10_R0: 항목 | 결과 +T_10_R1: 월 3회 이상 조율 | 68% (19명) +T_10_R2: 30분 이상 소요 | 46% (13명) +T_10_R3: 모임 무산/연기 경험 있음 | 79% (22명) +T_10_R4: 현재 방법 만족도 | 3.0 / 5.0 +T_10_R5: MOIM 사용 의향 | 3.9 / 5.0 +T_10_R6: 베타 테스터 자발 신청 | 32% (9명) +T_10_R7: 주 조율 방법: 카톡 채팅 | 54% (15명) +T_10_R8: 주 조율 방법: 카톡 투표 | 36% (10명) \ No newline at end of file diff --git "a/docs/business-plan/MOIM_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_final_dump.txt" "b/docs/business-plan/MOIM_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_final_dump.txt" new file mode 100644 index 0000000..ce76a22 --- /dev/null +++ "b/docs/business-plan/MOIM_\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234_final_dump.txt" @@ -0,0 +1,191 @@ +P_0: +P_1: 2026 오픈소스기반실전창업 +P_2: 사 업 계 획 서 +P_3: MOIM (모임) +P_4: AI 기반 대학생 특화 모임 일정 자동 조율 서비스 +P_5: 팀명: MOIM +P_6: 대표: 김경수 (글로벌미디어학부) +P_7: 제출일: 2026년 5월 +P_8: +P_9: 0. 사업 아이템 개요 +P_10: +P_11: +P_12: 1. 문제 인식 (Problem) +P_13: 1-1. 창업 배경 및 개발동기 +P_14: 창업 아이템 소개 +P_15: MOIM(모임) - 대학생의 모임 일정 조율 과정을 자동화하는 웹 기반 서비스 +P_16: 참여자가 하나의 링크를 통해 자신의 캘린더 또는 에브리타임 시간표를 연동 +P_17: AI가 모두의 공통 가능 시간을 자동으로 찾아 추천 +P_18: 확정된 일정을 각자의 캘린더에 자동으로 바로 추가 +P_19: 창업 동기 +P_20: 본 아이템은 팀원들이 대학 생활을 하며 매주 반복적으로 직면해 온 실제적인 불편함에서 출발했다. 조별 과제, 학술 스터디, 동아리 회의 등 모임을 잡을 때마다 에브리타임 시간표를 공유하고 카카오톡 채팅방에서 후보 시간을 조율하는 비효율이 반복되었다. +P_21: 특히 다음과 같은 핵심 페인 포인트(Pain Point)가 존재한다: +P_22: • 수업 시간표(에브리타임)와 개인 일정(구글/애플 캘린더)의 분리 운영으로 인한 가용 시간 파악의 번거로움 +P_23: • 참여자가 4명 이상만 되어도 한두 명의 답변 지연으로 조율이 하루 이상 지체되는 병목 현상 +P_24: • 일정 확정 후 각자 본인의 캘린더에 직접 입력해야 하는 번거로움과 일정 누락 등의 휴먼 에러 발생 +P_25: 이러한 일정 조율의 비효율성은 실제 자체 설문조사(대학생 및 청년 38명 대상) 결과에서도 뚜렷하게 확인된다: +P_26: • 한 달 평균 모임 일정 조율 횟수: '3회~5회'가 42.1%, '6회 이상'이 21.1%로 대학생들의 높은 조율 빈도 확인 +P_27: • 조율 만족도: 3.13점 / 5점으로 보통 이하 수준에 머무름 +P_28: • 조율 시간: '30분 이상에서 하루 이상'이 소요된다고 답한 비율이 36.9%에 달함 +P_29: • 일정 지연 및 무산 경험: 스케줄링 병목으로 인해 일정이 지연되거나 무산된 경험이 있는 비율이 무려 79.0%를 기록 +P_30: • 주요 스트레스 포인트(복수 응답): +P_31: - "다 정해놨는데 한 명이 마지막에 안 된다고 번복할 때" (78.9%, 30명) +P_32: - "누가 응답을 제때 안 해서 한참을 무작정 기다려야 할 때" (60.5%, 23명) +P_33: 이러한 통계는 청년층의 일상 속 만성적인 시간 결핍감을 방증한다. 통계청 사회조사에 따르면 청년층 여가생활 불만족 이유 중 '시간 부족'이 21.9%로 높은 비중을 차지하고 있으며, 비효율적인 모임 일정 조율에 불필요하게 낭비되는 시간이 이들의 한정된 여가 시간 자원을 한층 더 압박하고 있다. 따라서 조율 단계를 극적으로 줄여 시간 낭비를 최소화하는 것은 대학생 청년들의 일상적 시간 결핍감을 해소하는 데 직접적으로 기여하는 유의미한 가치를 지닌다. +P_34: 이러한 일정 조율의 비효율성은 실제 자체 설문조사(대학생 및 청년 38명 대상) 결과에서도 뚜렷하게 확인된다. 설문에 따르면 한 달 평균 모임 일정 조율 횟수로 '3회~5회'가 42.1%, '6회~10회' 및 '10회 이상'이 21.1%로 나타나 대학생들의 일상 속 조율 빈도가 매우 높았다. 그럼에도 현재 조율 방식에 대한 만족도는 3.13점 / 5점으로 보통 이하 수준에 머물렀다. 조율에 걸리는 시간은 '30분 이상에서 하루 이상'이 소요된다고 답한 비율이 36.9%에 달했으며, 스케줄링의 병목으로 인해 '일정이 지연되거나 모임 자체가 연기·무산된 경험이 있다'고 답한 비율이 무려 79.0%를 기록했다. 특히 조원들이 일정 조율 과정에서 겪는 가장 파괴적인 스트레스 포인트는 "다 정해놨는데 한 명이 마지막에 안 된다고 번복할 때" (78.9%, 30명)와 "누가 응답을 제때 안 해서 한참을 무작정 기다려야 할 때" (60.5%, 23명)로 나타났다. +P_35: 이러한 통계는 청년층의 일상 속 만성적인 시간 결핍감을 방증한다. 통계청 사회조사에 따르면 청년층 여가생활 불만족 이유 중 '시간 부족'이 21.9%로 높은 비중을 차지하고 있으며, 비효율적인 모임 일정 조율에 불필요하게 낭비되는 시간이 이들의 한정된 여가 시간 자원을 한층 더 압박하고 있다. 따라서 조율 단계를 극적으로 줄여 시간 낭비를 최소화하는 것은 대학생 청년들의 일상적 시간 결핍감을 해소하는 데 직접적으로 기여하는 유의미한 가치를 지닌다. +P_36: → 각자의 시간표와 캘린더 정보를 한 번만 반영하면 공통으로 가능한 시간이 자동으로 보이는 방식으로 줄일 수 있다고 판단 +P_37: 비전 및 경영철학 +P_38: Vision: 모든 대학생이 모임 시간 걱정 없이 활동에만 집중할 수 있는 캠퍼스 +P_39: Mission: 반복적인 일정 조율의 마찰을 기술로 제거하여, 대학생 팀 활동의 실행력 증대 +P_40: 창업 아이템의 기여도 +MOIM은 기술적, 경제적, 사회적 세 측면에서 기여함 +P_41: 기술적 기여: 에브리타임 시간표 이미지를 AI 비전 기술로 분석 → 수동으로만 처리하던 시간표 데이터를 자동화 +P_42: 경제적 기여: 대학생 한 명당 월 평균 2시간 이상 소모되는 일정 조율 시간을 절감 → 학업과 활동에 투입할 수 있는 시간 증가 +P_43: 사회적 기여: 일정 조율 실패로 인한 팀 활동 무산 감소 → 대학 내 협업 문화를 개선 +P_44: 1-2. 창업아이템의 목적 및 필요성 +P_45: 고객 설문 조사 결과 (38명 응답) +P_46: 문제의 빈번성과 심각성을 검증하기 위해 대학생 및 청년 38명을 대상으로 설문 조사를 실시한 결과 +P_47: +P_48: 문제 우선순위 매트릭스 +P_49: 설문 결과를 토대로 대학생 일정 조율의 핵심 문제를 세 가지로 정리 +P_50: 1) 정보 분산 +P_51: 수업 시간은 에브리타임에, 개인 일정은 캘린더에, 조율 대화는 카카오톡에 흩어져 있어 확인하기 어려움. 통계청 '2024 생활시간조사'에 따르면 대학생 하루 평균 학습 시간은 3시간 11분, 이동 시간은 1시간 53분에 달함. 수업이 공강 형태로 파편화되어 요일별로 흩어져 존재하며, 긴 통학 이동 동선 제약 때문에 수동으로 연속된 모임 시간을 맞추는 데 극심한 정보 파편화 마찰이 발생함. +P_52: 응답자의 93%가 카카오톡(채팅 또는 투표)으로 조율하고 있어, 별도 도구로의 전환 비용이 핵심 장벽임 +P_53: 2) 응답 지연 & 확정 후 번복 +P_54: 가장 큰 불편으로는 60.5%가 응답 지연을, 78.9%가 확정 후 번복을 꼽음 +P_55: 수동 방식에서는 한 명의 응답 지연이 전체 조율 병목을 유발해 조율이 하루 이상 지체되는 악순환을 발생시킴. MOIM은 회원가입 없는 웹 공유 링크와 수동 그리드 입력(Guest Grid) 폴백으로 응답 마찰을 극적으로 최소화함. +P_56: 3) 반복 입력으로 인한 피로도 증가 +P_57: 확정된 일정을 다시 캘린더에 수동 입력해야 하는 문제 (설문 38.9%) +P_58: 기존 조율 도구의 모바일 드래그 조작 한계 및 재입력 누락 문제. MOIM은 컴퓨터 비전 기술 기반 에타 시간표 블록 추출 및 Google API 자동 동기화를 통해 이 피로도를 0%화함. +P_59: 대체수단 분석 +P_60: 기존의 일정 조율 방식과 MOIM의 차별점을 비교 +P_61: +P_62: 1-3. 목표시장 분석 +P_63: 초기 목표시장 +국내 대학생 중 조별과제, 동아리, 스터디, 공모전, 팀 프로젝트를 자주 수행하는 사용자. 이를 TAM-SAM-SOM 모델로 정량화하여 유효 시장 규모를 정의함. +P_64: 설문 결과, 월 평균 모임 조율 횟수가 3회 이상인 핵심 유저 비율이 63.2% (3~5회 42.1%, 6~10회 13.2%, 10회 이상 7.9%)에 달하는 것으로 검증됨. +P_65: 팀 활동이 많은 전공, 창업팀, 동아리 운영진은 매주 여러 차례 시간을 맞추는 반복 수요가 있음 +P_66: 목표 고객의 핵심 특성 - 문제 인식이 분명하지만 새로운 서비스 도입에는 부담을 느낌 +P_67: 설문 결과 신규 도구 사용의 가장 큰 걱정으로 "친구들이 같이 안 쓸 것 같다"(36.8%)는 한계가 지적됨. 이를 해결하기 위해 조원->조장 전이 기반 바이럴 선순환 구조(오가닉 루프)를 설계함. +P_68: → MOIM은 웹 링크 기반 접근, 카카오톡 공유, 소셜 로그인으로 첫 사용의 부담을 최소화하는 방향으로 설계 +P_69: 매력적 기능 선호도를 보면, 공통 빈 시간 자동 추천(50%)과 에브리타임 시간표 자동 불러오기(43%)가 가장 높음 +P_70: 2. 실현가능성 (Solution) +P_71: 2-1. 사업화 전략 +P_72: 가치제안 정의 +P_73: MOIM의 핵심 가치제안 +P_74: 링크 하나로 모두의 빈 시간을 자동으로 조율하여 조원들의 시간 낭비를 최소화함 +P_75: 설문 결과 조율에 30분~하루 이상 소요되는 비율이 36.9%에 달했으나, 연동 한 번으로 전체 조율 과정을 3분 이내로 획기적으로 단축함 +P_76: 에브리타임 시간표와 캘린더를 통합 반영하여 수업과 개인 일정 관리의 분리 플랫폼 문제를 해결 +P_77: 확정된 일정이 자동으로 캘린더에 추가되어 재입력 없이 기록이 완성 및 편리함 제공 +P_78: 핵심기능 선정 +P_79: MVP는 모임 시간 자동 조율이라는 한 가지 흐름에 집중 +P_80: 프로토타입 및 PoC +P_81: 호스트의 모임 생성부터 일정 확정까지의 MVP 핵심 흐름에 집중함 +P_82: OAuth 2.0 프로토콜을 사용한 구글/애플 캘린더 연동 및 에브리타임 이미지 블록(Busy) 픽셀 영역 탐지 정규화 기술을 구현함. 가용 시간 교집합을 연산하는 스케줄링 매칭은 100% 코드로 구성된 결정론적 알고리즘을 사용해 신뢰도를 보장함. +P_83: +P_84: +P_85: +P_86: MVP 8주 개발 일정 +P_87: +P_88: 기술 타당성 검토 +P_89: 기술스택: 팀이 빠르게 구현하고 유지할 수 있는 무료 또는 저비용 도구 중심 +P_90: 프론트엔드: Next.js, React, TypeScript, Tailwind CSS, shadcn/ui를 사용 +P_91: 백엔드: Supabase(PostgreSQL, Auth, Realtime)로 별도 서버 운영 부담 최소화 +P_92: 배포: Vercel 무료 플랜, 시간표 이미지 분석은 Gemini Vision API 무료 사용량을 활용 +P_93: 캘린더 연동 +P_94: Google Calendar API 우선 검토 +P_95: 개인정보 수집 걱정(설문 우려 Q14)을 해소하기 위해 캘린더의 상세 내용(제목 등)을 서버로 전송받지 않고 Busy 여부만 조회하며, 에브리타임 이미지 분석 시에도 과목명을 블라인드 처리하고 '수업 블록 인식' 방식으로 개인정보 부담을 원천 최소화함. +P_96: +P_97: +P_98: +P_99: +P_100: +P_101: 2-2. 시장분석 및 경쟁력 확보 방안 +P_102: 시장분석 +P_103: 반복적 불편: 일정 조율에 많은 대화와 시간이 걸림 +P_104: 설문 결과 +P_105: 현재 방법(대화를 통한 조율) 만족도: 보통 수준 (3.0/5점) +P_106: MOIM 사용 의향: 긍정적 반응 (3.9/5점) +P_107: 핵심 경쟁력 +P_108: 통합성: 캘린더와 에브리타임 시간표 이미지를 함께 반영 +P_109: 낮은 진입 장벽: 링크 클릭 후 바로 참여 가능 +P_110: 그리드 입력 방식(폴백): 캘린더 연동을 원하지 않는 사용자 고려 +P_111: 대기 시간 감소: 현재까지의 공통 가능 시간을 실시간으로 표시 +P_112: +P_113: 예상되는 문제점과 극복 방안 +P_114: +P_115: +P_116: 3. 성장 전략 (Scale-up) +P_117: 3-1. 시장진입 및 성과창출 전략 +P_118: 초기 GTM(Go-to-Market) 전략 +P_119: 시장 진입 +P_120: 마케팅 4P-4C 융합 전략에 기반해 모바일 웹 링크 유통을 통한 Place/Convenience 극대화 및 오가닉 바이럴 홍보를 진행. 조별과제와 동아리 대상 베타테스트 및 프로토타입 사용을 제안함. +P_121: 실제로 시간이 단축되었는지, 어떤 화면에서 막히는 지 확인 및 검증 +P_122: 초기 메시지는 “카카오톡으로 링크 하나만 보내면 모두의 가능한 시간이 보인다” 로 설정 +P_123: 채널 및 퍼널 설계 +P_124: 확산은 제품 자체의 공유 구조를 활용함. +P_125: 조원 -> 조장 전이 오가닉 바이럴 선순환 구조(Virtuous Cycle) 활용: 한 명이 모임방 생성 후 카톡 공유 -> 참여한 조원들이 편리함을 경험 -> 다음 모임의 주최자로 자연스럽게 전이. +P_126: 일정 확정 후 결과 공유 화면에 다음 일정 생성 링크를 포함하면, 참여자가 다음에는 직접 호스트가 되는 흐름도 기대 가능 +P_127: 공유구조를 이용해 별도 마케팅 비용 없이 자연스러운 확산을 추구 +P_128: 시장 확장 로드맵 +P_129: +P_130: 가격 및 수익모델 +P_131: 월간 일정 조율 빈도가 3회 이상인 리더 집단이 63.2%에 달함을 근거로 하여, 기본 기능은 무료(Freemium)로 제공하되 다빈도 개설 호스트를 대상으로 월 1,900원 프리미엄 구독 티어를 출시하여 유료 서비스 타당성을 기획함. +P_132: 유료 기능은 사용자 반응과 반복 사용이 확인된 이후에 실험하며, 장기적으로는 오픈 코어(Open Core) 모델을 검토 +P_133: 핵심 일정 조율 로직과 기본 화면은 오픈소스로 공개하되, 조직용 기능(계정 관리, 보안 설정, 기업 시스템 연동, 기술지원)은 유료 서비스 영역으로 남겨 수익화 가능성을 확보 +P_134: 3-2. 자금조달 계획 +P_135: 현재는 무료 개발 도구와 팀원 보유 장비를 활용해 최소 비용으로 개발 중. 초기 총 필요 현금은 약 10만~25만 원 수준으로 예상 +P_136: 3-3. 운영 및 인프라 확장 계획 +P_137: 사업 목적: 재무적 목표 +P_138: 1단계(MVP 검증기) : 매출보다 사용자 반복 사용률 확인에 집중함 +P_139: 2단계(확산기) : 대학 단위 사용자 1,000명 확보를 목표로 함 +P_140: 3단계(수익화 검토기) : 조직용 프리미엄 기능 월 구독 모델을 실험함 +P_141: 사업 목적: 비재무적 목표 +P_142: 대학생 팀 활동의 실행력 향상, 일정 조율 실패로 인한 모임 무산 감소, 오픈소스 커뮤니티 기여를 통한 개발자 생태계 참여를 비재무적 목표로 설정 +P_143: +P_144: 4. 팀 구성 (Team) +P_145: PSST 모델의 핵심 질문: 왜 우리가 이 문제를 가장 잘 푸는가? +P_146: 4-1. 창업자의 역량 +P_147: 팀장 김경수는 글로벌미디어학부 소속으로 프로젝트 방향 설정, 스프린트 관리, 코드 리뷰, 백엔드 설계를 담당 +P_148: 합주실 예약 서비스 운영 경험을 통해 예약 흐름과 일정 데이터 관리의 실제 문제를 경험 +P_149: 여러 웹 프로젝트를 빠르게 시도하며 제품 검증과 구현 사이의 균형을 익힘 +P_150: 창업자로서의 강점은 문제를 직접 겪은 사용자이자 구현을 주도할 개발자라는 점점 +P_151: 팀원 김유현은 글로벌미디어학부 소속으로 전반적이 UX/UI 디자인과 프론트엔드 개발 담당 +P_152: 풀스택으로 프로젝트를 진행한 경험을 바탕으로 프론트엔드와 백엔드 간 원만한 의사소통 가능 +P_153: 여러 웹 & 앱 프로젝트 경험으로 간편한 사용자 흐름 제안 +P_154: 팀원 이동은은 예술창작학부 소속으로 서비스 소개 영상, 카드뉴스 제작, 온보딩 UX 검토를 담당 +P_155: Premiere, Photoshop 작업 경험을 활용한 홍보 영상 및 카드뉴스 제작으로 브랜드 이미지 구축 +P_156: 사용자 관점에서의 UX 검토를 통해 서비스 진입 장벽을 낮추고, 브랜드 정체성이 일관되게 유지되는 디자인 환경 구축 +P_157: 팀원 이동윤은 경영학부 소속으로 기획, 마케팅, 재무를 담당 +P_158: 재무, 회계, 마케팅 등 경영 전반 전공 수강 +P_159: 투자 학회를 통해 k뷰티 등 다양한 기업 및 산업 분석 +P_160: 팀원 정규호는 글로벌미디어학부 소속으로 백엔드 설계 및 개발을 담당 +P_161: 개발 스터디와 여러 프로젝트들을 통한 서비스 구현 경험과 운영 경험을 통해 문제해결 및 대응 능력을 기름 +P_162: 원활한 서비스 운영과 내부 설계로 개발 및 유지보수 담당 +P_163: +P_164: +P_165: 팀 구성원 소개 및 역량 +P_166: +P_167: 팀은 개발(3명), 기획/마케팅(1명), 디자인/영상(1명) 역량이 분산되어 있어 시제품 개발과 초기 사용자 확보를 동시에 추진 가능 +P_168: 의사결정은 주 1회 스프린트 리뷰를 통해 진행하며, GitHub 기반 코드 관리, Notion 기반 문서화, Discord 기반 실시간 소통을 활용해 오픈소스 방식의 협업 체계를 운영 +P_169: 4-2. 외부 협력기관 현황 및 활용 계획 +P_170: 현재 팀은 소속 대학의 창업지원단을 통해 멘토링과 사업화 지원을 받을 수 있는 경로를 확보 +P_171: 향후 캠퍼스 기술창업 공모전 및 학생 창업유망팀 300+ 프로그램을 통해 전문가 자문과 네트워크를 확장할 계획 +P_172: 활용 가능 네트워크 +P_173: +P_174: 4-3. 중장기 사회적 가치 도입계획 +P_175: MOIM은 오픈소스 실전 창업 수업의 취지에 맞게 핵심 일정 조율 로직과 기본 화면 일부를 오픈소스로 공개하는 방향을 검토함으로써 다음과 같은 사회적 가치를 추구 +P_176: 교육적 기여: 대학생 개발자들이 실제 서비스의 코드를 보고 배울 수 있는 오픈소스 학습 자원을 제공 +P_177: 커뮤니티 기여: 개발자 커뮤니티가 코드를 검토하고 개선에 참여할 수 있는 구조를 만들어 오픈소스 생태계에 기여 +P_178: 접근성 보장: 기본 일정 조율 기능은 무료로 유지하여, 경제적 여건과 관계없이 모든 대학생이 효율적인 팀 활동을 할 수 있도록 함 +P_179: ESG 연계: 장기적으로 장애 학생을 위한 접근성 기능(스크린 리더 지원, 고대비 모드)을 추가하여 포용적 서비스로 발전 +P_180: +P_181: +P_182: +P_183: +P_184: +P_185: 부록. 설문 조사 상세 결과 +P_186: 조사 기간: 2026년 5월 | 응답자: 대학생 및 청년 38명 | 방법: 온라인 설문 +P_187: 주요 정량 결과 요약 +P_188: \ No newline at end of file diff --git a/docs/business-plan/README.md b/docs/business-plan/README.md new file mode 100644 index 0000000..ed54203 --- /dev/null +++ b/docs/business-plan/README.md @@ -0,0 +1,50 @@ +# MOIM 사업계획서 준비 자료 인덱스 + +이 폴더는 인천광역시 공공데이터·AI 활용 창업경진대회 제출을 준비하면서 사업계획서에 넣을 자료, 근거 문서, 참고 강의자료를 한곳에서 확인하기 위한 작업 폴더다. + +## 폴더 구성 + +| 위치 | 내용 | 용도 | +| --- | --- | --- | +| `drafts/MOIM_2026_학생창업유망팀300_사업계획서.docx` | 기존 사업계획서 초안 복사본 | 인천 경진대회용으로 보강할 원본 참고 | +| `references/인천 창업경진대회 공고.pdf` | 대회 공고 PDF | 평가항목, 제출요건, 공공데이터 활용 조건 확인 | +| `references/사업계획서의 이해_part1.pdf` | 사업계획서 작성 강의자료 1 | 문제정의, 고객, 시장성 보강 참고 | +| `references/사업계획서의 이해_part2.pdf` | 사업계획서 작성 강의자료 2 | 실현가능성, 사업화 전략 보강 참고 | +| `references/사업계획서의 이해_part3.pdf` | 사업계획서 작성 강의자료 3 | 성장전략, 발표자료 구성 참고 | +| `../startup-data/public-data-analysis.md` | 공공데이터 분석 및 가공 정리 | 공공데이터 활용성, 데이터 가공·융합 항목 근거 | +| `../startup-data/processed-indicators.csv` | 가공 지표 CSV | 사업계획서 표와 정량 근거 작성용 | + +## 사업계획서 보강 우선순위 + +1. 문제 인식 + - 기존의 팀 경험 중심 서술에 통계청 `2025 사회조사` 근거를 붙인다. + - 20~29세 단체활동 참여율, 친목·사교/취미·스포츠·학술단체 참여 비율을 활용해 대학생·청년층의 모임 수요를 설명한다. + +2. 목표시장 분석 + - 초기 목표시장을 `조별과제`, `스터디`, `동아리`, `공모전·팀 프로젝트`로 좁힌다. + - 공공데이터는 시장 규모를 직접 증명하기보다, 반복적 모임이 발생하는 생활 맥락을 보강하는 근거로 사용한다. + +3. 실현가능성 + - 개인 캘린더·시간표·직접 입력 데이터는 실제 후보 시간 계산에 사용한다. + - 공공 통계 데이터는 모임 유형 기본값, 후보 시간 설명, 사업계획서 근거에 사용한다고 역할을 분리한다. + +4. AI 기술 및 혁신성 + - AI를 단순 시간 교집합 계산에 쓰지 않는다. + - AI는 시간표 이미지 인식, 일정 텍스트 의미 판단, 모임 목적 구조화, 후보 시간 설명 생성처럼 비정형 데이터를 구조화하는 영역에 사용한다. + - 후보 시간 산출 자체는 결정론적 알고리즘으로 처리해 결과의 안정성과 검증 가능성을 확보한다. + +5. 경쟁력 확보 방안 + - 기존 일정 투표 도구와 달리 `캘린더 + 시간표 + 직접 입력`을 하나의 busy/free 데이터로 통합한다는 점을 강조한다. + - 카카오톡 링크 기반 참여, 앱 설치 없는 접근, 비연동 사용자를 위한 수동 입력 폴백을 차별점으로 정리한다. + +## 사업계획서에 넣을 핵심 메시지 + +> MOIM은 단순 일정 투표 도구가 아니라, 개인 캘린더·시간표 데이터와 공공 통계 데이터를 역할별로 결합해 대학생 모임의 시간 조율 문제를 줄이는 AI 기반 스케줄링 서비스다. + +> AI는 단순 계산을 대체하지 않고, 시간표 이미지·일정 텍스트·모임 목적처럼 규칙 코드만으로 처리하기 어려운 비정형 입력을 구조화하는 데 사용한다. 공통 가능 시간 계산은 코드로 정확하게 처리하고, 공공데이터는 모임 유형과 시간 사용 맥락을 설명하는 근거로 활용한다. + +## 다음 작업 메모 + +- `drafts`의 기존 사업계획서 초안을 직접 덮어쓰기보다, 먼저 인천 경진대회용 수정본을 별도 파일로 만드는 것이 좋다. +- 공공데이터 활용 문단은 `../startup-data/public-data-analysis.md`의 `사업계획서 반영 문구` 섹션을 우선 가져와 다듬는다. +- 발표자료를 만들 때는 공고 PDF의 평가항목 순서에 맞춰 `공공데이터 활용성`, `AI 기술 및 혁신성`, `AI 서비스`, `발전가능성`이 한 번씩 명시적으로 보이게 구성한다. diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220.zip" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220.zip" new file mode 100644 index 0000000..5185cce Binary files /dev/null and "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220.zip" differ diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/image.png" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/image.png" new file mode 100644 index 0000000..b6ee10f Binary files /dev/null and "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/image.png" differ diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.15.29.png" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.15.29.png" new file mode 100644 index 0000000..0d5c914 Binary files /dev/null and "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.15.29.png" differ diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.16.07.png" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.16.07.png" new file mode 100644 index 0000000..4d7d41b Binary files /dev/null and "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.16.07.png" differ diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.16.46.png" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.16.46.png" new file mode 100644 index 0000000..51e2d68 Binary files /dev/null and "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.16.46.png" differ diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.20.32.png" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.20.32.png" new file mode 100644 index 0000000..65a6ad3 Binary files /dev/null and "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.20.32.png" differ diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.22.25.png" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.22.25.png" new file mode 100644 index 0000000..516e6e5 Binary files /dev/null and "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.22.25.png" differ diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.22.50.png" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.22.50.png" new file mode 100644 index 0000000..4ca1673 Binary files /dev/null and "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\341\204\211\341\205\263\341\204\217\341\205\263\341\204\205\341\205\265\341\206\253\341\204\211\341\205\243\341\206\272_2026-04-14_\341\204\213\341\205\251\341\204\222\341\205\256_5.22.50.png" differ diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220 3724a6ec39318075b301ddd9e2abe7eb.md" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220 3724a6ec39318075b301ddd9e2abe7eb.md" new file mode 100644 index 0000000..e5ccdf4 --- /dev/null +++ "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234 \352\260\234\354\204\240\354\240\220 3724a6ec39318075b301ddd9e2abe7eb.md" @@ -0,0 +1,86 @@ +# 사업계획서 개선점 + +생성일: 2026년 6월 1일 오후 2:11 +태그: 이동은 + +1. [시장 경제업체 정의추가] + +**(1)When2Meet**: 참여자가 가능한 시간을 직접 수동으로 입력하여 취합하는 일정 조율 도구 + +**(2)에브리타임**: 대한민국의 대학생들이 가장 보편적으로 사용하는 국내 1위 대학생활 플랫폼(시간표 및 커뮤니티 앱) + +**(3)애플 캘린더 (Apple Calendar / iOS 기본)**: 아이폰(iPhone), 아이패드, 맥북 등 애플 기기에 처음부터 탑재되어 있는 기본 캘린더 앱 + +**(4)구글 캘린더 (Google Calendar / 안드로이드 표준 기본) :** 구글이 개발한 캘린더 서비스로, 픽셀폰을 비롯한 순정 안드로이드 기기의 기본 캘린더이며 전 세계에서 가장 많이 쓰임 + +*정의가 혹시 필요할까싶어서 정리해놓긴 했는데, 대체수단 분석 목차에서 잘 정리해놔서 딱히 수정은 안 해도 될 것 같아요…. + +1. [경쟁업체와 비교분석 통해 차별화되는점/경쟁력 추가] +- 현재 경쟁사들과 우위를 확보할 수 있는가? + +기존 서비스들은 오직 수동 입력(When2Meet)만 가능하거나 링크 연동만 가능했던 반면, MOIM은 [캘린더 자동 연동 + 에브리타임 이미지 업로드 + 수동 그리드 입력]이라는 세 가지 선택지를 모두 제공하여 사용자가 자신의 상황에 맞게 데이터를 통합할 수 있도록 열어두었다는 점이 핵심 우위 + +1. [Tom/sam/som 분석 추가] +- **TAM** (Total Addressable Market): 전체 시장 + ▶ 국내외 전체 개인/기억/B2B 협업 및 일정 관리 솔루션 시장 +- **SAM** (Serviceable Addressable Market): 유효 시장 +▶ 국내 전체 대학생 및 20대 모임/스터디 일정 조율 시장 +- **SOM** (Serviceable Obtainable Market): 수익 시장 +▶ 수도권 및 주요 대학의 조별과제 활성화 유저층 + +1. [어떤 기능을 가지고 있는지? 이미지 추가] +- 사업화 단계, 시스템 구조 디자인 넣어 이해돕기 + +사진 추가 + +![스크린샷 2026-04-14 오후 5.15.29.png](%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-04-14_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_5.15.29.png) + +![스크린샷 2026-04-14 오후 5.16.07.png](%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-04-14_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_5.16.07.png) + +![스크린샷 2026-04-14 오후 5.16.46.png](%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-04-14_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_5.16.46.png) + +![스크린샷 2026-04-14 오후 5.20.32.png](%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-04-14_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_5.20.32.png) + +![스크린샷 2026-04-14 오후 5.22.25.png](%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-04-14_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_5.22.25.png) + +![스크린샷 2026-04-14 오후 5.22.50.png](%E1%84%89%E1%85%B3%E1%84%8F%E1%85%B3%E1%84%85%E1%85%B5%E1%86%AB%E1%84%89%E1%85%A3%E1%86%BA_2026-04-14_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_5.22.50.png) + +1. [마케팅 전략 (유통채널, 판매촉진 어떻게?) -어디서 판매, 어떤방법으로 알릴 것?] +- **홍보** : + +(1)SNS를 통한 숏폼으로 홍보 + +(2)에브리타임 게시판 이용 + +(3) [조장/모임 주최자]가 편의성을 인지하고 MOIM 방 개설 + +▼ +카카오톡 단톡방에 [MOIM 웹 링크] 공유 (가입 진입장벽 x) + +▼ +조원들이 링크를 타고 들어와 에타 시간표 업로드 및 참여 + +▼ +참여한 조원들이 MOIM의 편리함 인지 ──▶ 향후 본인들이 다른 모임의 [주최자]로 전환 + +등등 + +- **유통채널**: + +-1단계: 모바일 웹 링크 유통 + +사용자가 카카오톡 단톡방에 MOIM 조율 링크를 공유하면, 다른 조원들은 앱 설치나 가입 없이 링크 클릭만으로 시간표 이미지를 올리거나 일정을 입력할 수 있게 한다. 대학생의 귀찮음/번거로움을 해소하는 확실한 방식 + +-2단계: 앱 스토어 (구글 플레이스토어 / 애플 앱스토어) + +모임을 자주 주최하는 사람들을 위한 고도화된 일정 관리 및 알림용 앱으로 유통 + +| 월 3회 이상 조율 | 68% (19명) | +| --- | --- | +- 우리가 매긴 가격(유료서비스)의 근거를 설명하거나 경쟁제품과 비교하면 설득력 높아짐. + ++ + +![image.png](image.png) + +이거 무슨내용인지 수정해야할 것 같숨다 \ No newline at end of file diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part1_extracted.txt" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part1_extracted.txt" new file mode 100644 index 0000000..5b9520b --- /dev/null +++ "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part1_extracted.txt" @@ -0,0 +1,152 @@ +--- PAGE 1 --- +오픈소스실전창업 +사업계획서의이해_(1) + +--- PAGE 2 --- +(No text found or image page) +--- PAGE 3 --- +(No text found or image page) +--- PAGE 4 --- +사업계획서는 상황과 목적에 따라 다르다 +• 사업계획서(business plan)는다양한 목적과 상황에 사용된다. +• 스타트업의 사업계획서와 수십년된 대기업의 사업계획이 같은 내용을 담고 있을 수는 없다. +• 투자유치(IR, Investor Relations) 목적의 사업계획과 내부 전략 목적의 사업계획은 초점이 +다를 수밖에 없다. + +--- PAGE 5 --- +(No text found or image page) +--- PAGE 6 --- +기업경영의 기준 +• 사업이 시작되면 사업계획서가 기업경영과 평가의 지침이 될 수 있음 +• 사업계획서 상의 지침이 없으면 처음에 의도했던 목표나 예측을 잊어버리고 그때 그때의 평 +가 기준에 의해 성과를 평가하게 되어 경영에 어려움을 겪을 수 있음 + +--- PAGE 7 --- +사업계획의 나침반 +• 창업절차는 매우 복잡하고 시행과정상 예상치 못한 문제로 차질을 빚는 경우가 많음 +• 사업계획서를 미리 작성한 후 준비하는 것이 꼭 필요함 + +--- PAGE 8 --- +비전 실현을 위한 청사진 +• 사업계획서를 작성하는 과정에서 창업가는 사업에 관한 성공가능성, Risk, 시장조건 등을 +객관적으로 살펴볼 수 있음 +• 사업계획을 짜며 위험 요소를 사전에 체크하고 대책을 수립한 후 사업에 착수하면 위험을 줄 +일 수 있음 + +--- PAGE 9 --- +자본조달의 수단 +• 계획한 사업의 효율적 추진을 위해 도움을 줄 동업자, 금융기관, 투자자, 일반고객 등에게 자 +기 사업을 설명하고 홍보하는 데 사업계획서는 필수임 + +--- PAGE 10 --- +고급인력 채용 +• 사업계획서 내용이 타당성이 있을수록 노하우와 실력을 겸비한 직원을 채용함에 있어 사업 +목표, 경영방침, 비전 등을 효과적으로 알릴 수 있어 고급인력 채용에 설득력 있음 + +--- PAGE 11 --- +(No text found or image page) +--- PAGE 12 --- +(No text found or image page) +--- PAGE 13 --- +(No text found or image page) +--- PAGE 14 --- +(No text found or image page) +--- PAGE 15 --- +(No text found or image page) +--- PAGE 16 --- +(No text found or image page) +--- PAGE 17 --- +(No text found or image page) +--- PAGE 18 --- +사업계획서 작성 방법 + +--- PAGE 19 --- +(No text found or image page) +--- PAGE 20 --- +(No text found or image page) +--- PAGE 21 --- +(No text found or image page) +--- PAGE 22 --- +(No text found or image page) +--- PAGE 23 --- +(No text found or image page) +--- PAGE 24 --- +(No text found or image page) +--- PAGE 25 --- +(No text found or image page) +--- PAGE 26 --- +(No text found or image page) +--- PAGE 27 --- +(No text found or image page) +--- PAGE 28 --- +(No text found or image page) +--- PAGE 29 --- +(No text found or image page) +--- PAGE 30 --- +(No text found or image page) +--- PAGE 31 --- +(No text found or image page) +--- PAGE 32 --- +재무 예측에 너무 공 들이지 않는다 +• 보는 사람들에게 재무 예측은 당신의 일방적인 주장에 불과하다. 5년 후 매출이 백억이 된다 +고 해봐야 그것을 믿고 투자할 사람은 없다. +• 중요하지 않은 부분에 상세한 것도 작성자나 보는 사람 모두에게 시간 낭비다. 직원 1명당 +인건비는 얼마고 기타 비용은 대략 얼마인지 가정하면 충분하다. 한달에 생수가 얼마나 필요 +하고 복사용지를 얼마나 쓸지를 계산할 필요는 없다. + +--- PAGE 33 --- +(No text found or image page) +--- PAGE 34 --- +(No text found or image page) +--- PAGE 35 --- +(No text found or image page) +--- PAGE 36 --- +(No text found or image page) +--- PAGE 37 --- +(No text found or image page) +--- PAGE 38 --- +(No text found or image page) +--- PAGE 39 --- +(No text found or image page) +--- PAGE 40 --- +(No text found or image page) +--- PAGE 41 --- +(No text found or image page) +--- PAGE 42 --- +(No text found or image page) +--- PAGE 43 --- +(No text found or image page) +--- PAGE 44 --- +(No text found or image page) +--- PAGE 45 --- +(No text found or image page) +--- PAGE 46 --- +(No text found or image page) +--- PAGE 47 --- +(No text found or image page) +--- PAGE 48 --- +(No text found or image page) +--- PAGE 49 --- +(No text found or image page) +--- PAGE 50 --- +(No text found or image page) +--- PAGE 51 --- +(No text found or image page) +--- PAGE 52 --- +(No text found or image page) +--- PAGE 53 --- +(No text found or image page) +--- PAGE 54 --- +(No text found or image page) +--- PAGE 55 --- +(No text found or image page) +--- PAGE 56 --- +(No text found or image page) +--- PAGE 57 --- +(No text found or image page) +--- PAGE 58 --- +(No text found or image page) +--- PAGE 59 --- +(No text found or image page) +--- PAGE 60 --- +(No text found or image page) \ No newline at end of file diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part2_extracted.txt" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part2_extracted.txt" new file mode 100644 index 0000000..eefd35d --- /dev/null +++ "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part2_extracted.txt" @@ -0,0 +1,90 @@ +--- PAGE 1 --- +오픈소스실전창업 +사업계획서의이해_(2) + +--- PAGE 2 --- +(No text found or image page) +--- PAGE 3 --- +마케팅 전략 +(시장 분석) + +--- PAGE 4 --- +(No text found or image page) +--- PAGE 5 --- +(No text found or image page) +--- PAGE 6 --- +(No text found or image page) +--- PAGE 7 --- +(No text found or image page) +--- PAGE 8 --- +(No text found or image page) +--- PAGE 9 --- +(No text found or image page) +--- PAGE 10 --- +(No text found or image page) +--- PAGE 11 --- +(No text found or image page) +--- PAGE 12 --- +(No text found or image page) +--- PAGE 13 --- +(No text found or image page) +--- PAGE 14 --- +(No text found or image page) +--- PAGE 15 --- +(No text found or image page) +--- PAGE 16 --- +통계청 http://kostat.go.kr +스크린샷이 +(가 +)표시된사진 + +자동생성된설명 + + + +--- PAGE 17 --- +에어비앤비의 TAM, SAM, SOM +• +TAM: 전세계 예약 여행 건수 +• +SAM: 온라인 예약 여행 건수 +• +SOM: 에어비앤비의 예상 시장점유율(2%) 건수 + +--- PAGE 18 --- +(No text found or image page) +--- PAGE 19 --- +(No text found or image page) +--- PAGE 20 --- +(No text found or image page) +--- PAGE 21 --- +(No text found or image page) +--- PAGE 22 --- +(No text found or image page) +--- PAGE 23 --- +(No text found or image page) +--- PAGE 24 --- +(No text found or image page) +--- PAGE 25 --- +(No text found or image page) +--- PAGE 26 --- +(No text found or image page) +--- PAGE 27 --- +Prototype Draft Example - Uber + +--- PAGE 28 --- +(No text found or image page) +--- PAGE 29 --- +(No text found or image page) +--- PAGE 30 --- +(No text found or image page) +--- PAGE 31 --- +(No text found or image page) +--- PAGE 32 --- +(No text found or image page) +--- PAGE 33 --- +(No text found or image page) +--- PAGE 34 --- +(No text found or image page) +--- PAGE 35 --- +(No text found or image page) \ No newline at end of file diff --git "a/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part3_extracted.txt" "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part3_extracted.txt" new file mode 100644 index 0000000..3f0f969 --- /dev/null +++ "b/docs/business-plan/references/\354\202\254\354\227\205\352\263\204\355\232\215\354\204\234\354\235\230 \354\235\264\355\225\264_part3_extracted.txt" @@ -0,0 +1,173 @@ +--- PAGE 1 --- +오픈소스실전창업 +사업계획서의이해_(3) + +--- PAGE 2 --- +(No text found or image page) +--- PAGE 3 --- +비즈니스 모델이란? +• +제품이나 서비스를 소비자에게 어떻게 제공하고, 판매하며, 수익을 얻어 낼 것인가 하는 계획 또는 아이디어 +✓기술이 가지는 가치를 시장에서 사업화 할 수 있는 고객 가치로 변화(사업적 가치창출)시키기 위한 목표 +고객, 사업을 위한 주요 행동, 핵심 자원, 외부 파트너 등과의 연계를 설명할 수 있는 종합적인 개념 + +--- PAGE 4 --- +비즈니스 모델이란? +• +비즈니스 모델의 개념적 요소 – 수행 주체, 제품 및 서비스, 수익(기대 가치), 사업구조 + +--- PAGE 5 --- +비즈니스 모델이란? +• +비즈니스 모델의 구성 요소 – 명확한 가치제안, 수익 매커니즘, 선순환 구조, 모방 불가능성 + +--- PAGE 6 --- +비즈니스 모델 구조화 +• +비즈니스 모델 캔버스- 알렉산더 오스터왈더 (Alexander Osterwalder) + +--- PAGE 7 --- +비즈니스 모델 구조화 +• +사례 +호스트 +게스트 +제품개발 및 +관리 +호스트 모집 +여행자 모집 +지역 호스트 +종업원 +기술 +[호스트] +임대료 수익 +보험 +무료 사진 +고객서비스 +소셜미디아 +할인 제공 +보험 +웹사이트 +모바일 앱 +운영 관리비 +인건비 +사진작가 +호스트 예약 +수수료 +게스트 예약 +수수료 +사진작가 +투자자 +PG사 +[게스트] +홈스테이 +저렴한 가격 +[호스트] +수익을 얻고 +싶어 하는 집 +소유자 +[게스트] +저렴한 숙박 +을 원하는 여 +행자 + +--- PAGE 8 --- +비즈니스 모델 사례 + +--- PAGE 9 --- +사례 +플랫폼 스타트업 비즈니스 모델 사례 1: 저작권 모형 +9 +• +마이쿤은 아프리카TV와 유사한 방식으로 저작권 모형을 BM으로 삼고 있음 +• +라디오 방송을 하는 BJ가 카카오톡 같이 생긴 메신저를 통해 대화하면서 아프리카 TV처럼 가상머니를 주고 받는 식으로 서비스가 진행 +• +2017년 8월 기준으로 한 달에 한 번 이상 방송을 하는 BJ가 3000명을 돌파 +• +현재는 창작자와 사용자를 모으는 단계로 성장성에 초점을 두고 창작자에게 유리하도록 수익 배분 +• +향후 플랫폼 성장 여부에 따라 광고 BM을 추가 도입할 것으로 보임 +• +알토스벤처스, 500스타트업스, 본엔젤스로부터 2017년 기준 누적 31억원을 투자 받음 + +--- PAGE 10 --- +사례 +콘텐츠 스타트업 비즈니스 모델 사례 2: 광고 추가 모형 +10 +• +2012년 창간한 슬로우뉴스는 속보 경쟁을 하는 어뷰징 매체가 아니라 늦더라도 깊이 있는 기사를 생산 +• +시사적인 주제에 대한 심층 콘텐츠를 일종의 칼럼 형태로 제공 +• +페이스북 구독자 5만 명 + +--- PAGE 11 --- +사례 +• +국내외 플랫폼 스타트업 8곳의 비즈니스 모델 사례 (brunch.co.kr) +• +국내외 콘텐츠 스타트업 17곳의 비즈니스 모델 사례 (brunch.co.kr) + +--- PAGE 12 --- +(No text found or image page) +--- PAGE 13 --- +(No text found or image page) +--- PAGE 14 --- +(No text found or image page) +--- PAGE 15 --- +(No text found or image page) +--- PAGE 16 --- +마케팅 전략 +(시장 분석) + +--- PAGE 17 --- +(No text found or image page) +--- PAGE 18 --- +(No text found or image page) +--- PAGE 19 --- +(No text found or image page) +--- PAGE 20 --- +(No text found or image page) +--- PAGE 21 --- +(No text found or image page) +--- PAGE 22 --- +(No text found or image page) +--- PAGE 23 --- +(No text found or image page) +--- PAGE 24 --- +(No text found or image page) +--- PAGE 25 --- +(No text found or image page) +--- PAGE 26 --- +(No text found or image page) +--- PAGE 27 --- +(No text found or image page) +--- PAGE 28 --- +(No text found or image page) +--- PAGE 29 --- +(No text found or image page) +--- PAGE 30 --- +(No text found or image page) +--- PAGE 31 --- +(No text found or image page) +--- PAGE 32 --- +(No text found or image page) +--- PAGE 33 --- +(No text found or image page) +--- PAGE 34 --- +(No text found or image page) +--- PAGE 35 --- +(No text found or image page) +--- PAGE 36 --- +(No text found or image page) +--- PAGE 37 --- +(No text found or image page) +--- PAGE 38 --- +(No text found or image page) +--- PAGE 39 --- +(No text found or image page) +--- PAGE 40 --- +(No text found or image page) +--- PAGE 41 --- +(No text found or image page) \ No newline at end of file diff --git a/docs/startup-data/processed-indicators.csv b/docs/startup-data/processed-indicators.csv new file mode 100644 index 0000000..f69b0ed --- /dev/null +++ b/docs/startup-data/processed-indicators.csv @@ -0,0 +1,18 @@ +dataset,source_url,raw_indicator,scope,value,preprocessing,processed_variable,moim_usage +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,단체활동 참여율,전체,69.2%,단체 참여 문항에서 참여자 비중 추출,group_participation_rate,모임 수요가 일부 특수 집단이 아니라 일반적 사회참여 행동임을 설명 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,단체활동 참여율,20~29세,67.9%,연령대 20~29세 행만 추출,young_adult_group_participation_rate,대학생·청년층 초기 타깃의 모임 수요 근거 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,참여 단체 종류,20~29세 친목·사교,80.1%,20~29세 참여자 기준 복수응답 비율 추출,meeting_type_social_weight,친목/사교 모임 프리셋 및 모임 유형 분류 근거 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,참여 단체 종류,20~29세 취미·스포츠·여가,66.3%,20~29세 참여자 기준 복수응답 비율 추출,meeting_type_hobby_weight,동아리/취미/스포츠 모임 프리셋 근거 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,참여 단체 종류,20~29세 학술단체,14.9%,20~29세 참여자 기준 복수응답 비율 추출,meeting_type_academic_weight,스터디/학술/팀 프로젝트 모임 프리셋 근거 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,가족·친척 외 교류자 있음,20~29세,86.7%,사회적 관계망 문항에서 가족·친척 외 교류자 있음 비율 추출,non_family_contact_presence,모바일 링크 기반 초대와 다인 일정 조율 수요 근거 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,가족·친척 외 하루 평균 교류자 수,20~29세,4.3명,사회적 관계망 문항에서 가족·친척 외 평균 교류자 수 추출,average_non_family_contacts,모임당 참여자 수와 다인 조율 난이도 설명 근거 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,여가활동을 함께 하는 사람,주중 친구/연인,18.1%,주중 여가 동반자 분포에서 친구/연인 비율 추출,weekday_friend_leisure_share,평일 친구/팀 모임은 제한된 후보 시간에서 조율된다는 설명 근거 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,여가활동을 함께 하는 사람,주말 친구/연인,21.2%,주말 여가 동반자 분포에서 친구/연인 비율 추출,weekend_friend_leisure_share,주말 후보 시간의 모임 수요 보정 근거 +2025 사회조사,https://kostat.go.kr/boardDownload.es?bid=219&list_no=439196&seq=3,여가생활 불만족 이유,전체 시간 부족,21.9%,불만족 이유 중 시간 부족 비율 추출,time_shortage_constraint_rate,일정 조율을 빠르게 줄여야 하는 문제 정의 근거 +2024 생활시간조사,https://www.kostat.go.kr/boardDownload.es?bid=220&list_no=437764&seq=9,대학생 이상 하루 이동 시간,대학생 이상,1:53,학생의 시간사용 표에서 대학생 이상 2024 이동 시간 추출,college_daily_travel_time,대학생 일정에서 이동·수업·여가가 함께 충돌한다는 설명 근거 +2024 생활시간조사,https://www.kostat.go.kr/boardDownload.es?bid=220&list_no=437764&seq=9,대학생 이상 하루 학습 시간,대학생 이상,3:11,학생의 시간사용 표에서 대학생 이상 2024 학습 시간 추출,college_daily_study_time,수업/학습 블록을 일정 조율의 핵심 busy 데이터로 보는 근거 +2024 생활시간조사,https://www.kostat.go.kr/boardDownload.es?bid=220&list_no=437764&seq=9,대학생 이상 하루 교제 및 참여 시간,대학생 이상,1:11,학생의 시간사용 표에서 대학생 이상 2024 교제 및 참여 시간 추출,college_daily_social_participation_time,대학생 이상 집단의 모임 관련 시간 사용 근거 +2024 생활시간조사,https://www.kostat.go.kr/boardDownload.es?bid=220&list_no=437764&seq=9,교제 및 참여활동 행위자 비율,전체 요일,77.0%,행위자 비율 표에서 교제 및 참여활동 전체 값 추출,social_participation_actor_rate,모임/교제 행동이 높은 빈도의 일상 행동임을 설명 +2024 생활시간조사,https://www.kostat.go.kr/boardDownload.es?bid=220&list_no=437764&seq=9,교제 및 참여활동 행위자 평균시간,평일,1:12,행위자 평균시간 표에서 평일 값 추출,weekday_social_participation_actor_time,평일 후보 시간 추천 설명의 기준값 +2024 생활시간조사,https://www.kostat.go.kr/boardDownload.es?bid=220&list_no=437764&seq=9,교제 및 참여활동 행위자 평균시간,토요일,1:26,행위자 평균시간 표에서 토요일 값 추출,saturday_social_participation_actor_time,주말 후보 시간 추천 설명의 기준값 +2024 생활시간조사,https://www.kostat.go.kr/boardDownload.es?bid=220&list_no=437764&seq=9,교제 및 참여활동 행위자 평균시간,일요일,1:39,행위자 평균시간 표에서 일요일 값 추출,sunday_social_participation_actor_time,주말 후보 시간 추천 설명의 기준값 diff --git a/docs/startup-data/public-data-analysis.md b/docs/startup-data/public-data-analysis.md new file mode 100644 index 0000000..01aaae7 --- /dev/null +++ b/docs/startup-data/public-data-analysis.md @@ -0,0 +1,218 @@ +# MOIM 공공데이터 분석 및 가공 정리 + +## 목적 + +이 문서는 인천광역시 공공데이터·AI 활용 창업경진대회 제출을 준비하기 위해 MOIM이 활용할 수 있는 공공데이터의 출처, 전처리 방식, 통계 결과, 서비스 적용 방식을 정리한다. + +MOIM의 핵심은 모임 장소 추천이 아니라 **캘린더·시간표·직접 입력을 결합해 모임 시간을 빠르게 정하는 것**이다. 따라서 공공데이터도 장소 추천으로 확장하지 않고, 사람들이 어떤 상황에서 모임을 만들고, 어떤 시간 제약 속에서 조율하는지 설명하는 근거 데이터로 사용한다. + +## 분석 결론 + +공공데이터는 MOIM의 기능을 대체하지 않고 세 가지 보조 역할을 한다. + +1. **모임 유형 분류 근거**: 친목/사교, 취미/동아리, 학술/스터디처럼 실제 단체 참여 유형을 서비스 프리셋으로 바꾼다. +2. **일정 조율 문제 근거**: 20대의 교류자 수, 여가 시간 부족, 대학생의 이동·학습·교제 시간을 통해 “왜 여러 사람의 시간을 빠르게 맞춰야 하는지”를 설명한다. +3. **후보 시간 설명 근거**: 개인 일정으로 산출된 후보 시간이 여러 개일 때, 공공 통계 기반의 요일/모임 유형 설명을 붙인다. + +핵심 메시지는 다음과 같다. + +> MOIM은 개인 캘린더와 시간표 데이터를 기반으로 실제 가능한 시간을 계산하고, 공공 통계 데이터를 활용해 모임 유형, 시간 제약, 후보 시간 설명을 보정하는 AI 일정 조율 서비스다. + +## 사용 데이터 목록 + +| 구분 | 출처 | 사용 목적 | 분석 수준 | +| --- | --- | --- | --- | +| 2025 사회조사 | 통계청, 2025년 사회조사 결과 | 단체 참여 유형, 20대 사회적 관계망, 여가 동반자, 여가 시간 부족 | 핵심 수치 추출 및 가공 | +| 2024 생활시간조사 | 통계청, 2024년 생활시간조사 결과 | 대학생 이상 시간 사용, 교제 및 참여활동 시간, 요일별 행동 시간 | 핵심 수치 추출 및 가공 | +| 국민여가활동조사 원시데이터 | 문화체육관광부, 공공데이터포털 | 여가활동 유형, 참여 빈도, 만족도, 제약 요인 추가 분석 후보 | 보조 데이터로 출처·변수 확인 | +| 인터넷이용실태조사 통계자료 | 한국지능정보사회진흥원, 공공데이터포털 | 모바일/인터넷 이용목적과 장소를 바탕으로 링크 기반 UX 근거 보강 | 보조 데이터로 출처·변수 확인 | + +## 1. 2025 사회조사 + +### 출처 + +- 자료명: 2025년 사회조사 결과 +- 제공기관: 통계청 사회통계국 +- 공식 자료: +- 조사 개요: 전국 약 19,000 표본가구의 만 13세 이상 가구원 약 34,000명을 대상으로 2025년 5월 14일부터 5월 29일까지 조사했다. + +### 전처리 + +1. 사회참여 영역에서 `단체 참여`, `사회적 관계망`, `외출 횟수` 항목을 분리했다. +2. MOIM 초기 타깃과 맞는 `20~29세` 행을 우선 추출했다. +3. 단체 참여 유형은 복수응답이므로 합계 100%로 정규화하지 않고, 모임 유형 프리셋의 상대적 우선순위로 사용했다. +4. 여가 영역의 `여가활동을 함께 하는 사람`, `여가생활 불만족 이유`를 일정 조율 문제 설명에 연결했다. + +### 주요 결과 + +| 지표 | 결과 | MOIM 해석 | +| --- | ---: | --- | +| 전체 단체활동 참여율 | 69.2% | 모임은 특수한 행동이 아니라 넓은 사회참여 행동이다. | +| 20~29세 단체활동 참여율 | 67.9% | 대학생·청년층도 반복적 모임 수요가 있다. | +| 20~29세 참여자 중 친목·사교 단체 | 80.1% | 친목/사교 모임은 기본 모임 유형으로 둘 수 있다. | +| 20~29세 참여자 중 취미·스포츠·여가 단체 | 66.3% | 동아리, 취미, 스포츠 모임은 MOIM의 자연스러운 사용 상황이다. | +| 20~29세 참여자 중 학술단체 | 14.9% | 스터디·학술·팀 프로젝트는 규모는 작지만 제품의 초기 집중 타깃과 맞다. | +| 20~29세 가족·친척 외 교류자 있음 | 86.7% | 비가족 기반 다인 관계망이 넓어 링크 기반 초대 흐름과 맞다. | +| 20~29세 가족·친척 외 하루 평균 교류자 수 | 4.3명 | 3~5인 모임의 일정 조율 문제가 현실적이다. | +| 주중 친구/연인과 여가활동 | 18.1% | 평일에도 친구 기반 활동이 있어 일정 조율 수요가 있다. | +| 주말 친구/연인과 여가활동 | 21.2% | 주말 후보 시간은 모임 수요가 더 커질 수 있다. | +| 여가생활 불만족 이유 중 시간 부족 | 21.9% | 시간이 없어서 활동을 못 하는 문제를 일정 조율 단축으로 완화할 수 있다. | + +### 가공 결과 + +사회조사에서 추출한 지표는 다음 변수로 바꾼다. + +| 가공 변수 | 생성 방식 | 서비스 적용 | +| --- | --- | --- | +| `meeting_type_social_weight` | 20대 친목·사교 단체 참여 비율 | 모임 유형 `친목/사교` 프리셋 | +| `meeting_type_hobby_weight` | 20대 취미·스포츠·여가 단체 참여 비율 | 모임 유형 `동아리/취미/스포츠` 프리셋 | +| `meeting_type_academic_weight` | 20대 학술단체 참여 비율 | 모임 유형 `스터디/학술/팀 프로젝트` 프리셋 | +| `average_non_family_contacts` | 20대 가족·친척 외 하루 평균 교류자 수 | 기본 참여자 수 예시와 조율 난이도 설명 | +| `time_shortage_constraint_rate` | 여가생활 불만족 이유 중 시간 부족 비율 | 문제 정의와 일정 조율 시간 단축 메시지 | + +## 2. 2024 생활시간조사 + +### 출처 + +- 자료명: 2024년 생활시간조사 결과 +- 제공기관: 통계청 사회통계국 +- 공식 자료: +- 조사 개요: 전국 12,750 표본가구의 만 10세 이상 가구원 약 25,000명을 대상으로 조사했다. 시간은 초 단위에서 반올림되므로 합계가 세부 항목 합과 완전히 일치하지 않을 수 있다. + +### 전처리 + +1. `학생의 시간사용` 표에서 대학생 이상 행만 추출했다. +2. MOIM과 직접 관련 있는 `학습`, `이동`, `교제 및 참여`, `여가시간`을 분리했다. +3. `교제 및 참여활동`의 행위자 비율과 행위자 평균시간을 요일별로 추출했다. +4. 개인 캘린더의 busy/free 계산에는 직접 넣지 않고, 후보 시간 설명과 기획 근거로만 사용한다. + +### 주요 결과 + +| 지표 | 결과 | MOIM 해석 | +| --- | ---: | --- | +| 대학생 이상 하루 학습 시간 | 3시간 11분 | 수업·학습 블록이 일정 조율의 핵심 busy 데이터다. | +| 대학생 이상 하루 이동 시간 | 1시간 53분 | 이동 시간이 길어 단순 빈칸만 보고 모임을 잡기 어렵다. | +| 대학생 이상 하루 교제 및 참여 시간 | 1시간 11분 | 모임 관련 시간은 실제 생활시간 안에서 제한된 자원이다. | +| 대학생 이상 하루 여가시간 | 5시간 14분 | 여가가 있어도 여러 사람의 가능한 시간을 맞추는 과정이 필요하다. | +| 교제 및 참여활동 행위자 비율 | 77.0% | 교제/참여 행동은 넓게 발생하는 일상 활동이다. | +| 교제 및 참여활동 행위자 평균시간 평일 | 1시간 12분 | 평일 모임 후보는 짧고 압축된 시간대를 우선 추천해야 한다. | +| 교제 및 참여활동 행위자 평균시간 토요일 | 1시간 26분 | 토요일은 평일보다 긴 후보 시간을 제안하기 쉽다. | +| 교제 및 참여활동 행위자 평균시간 일요일 | 1시간 39분 | 일요일은 교제/참여 평균시간이 가장 길어 보조 설명에 활용 가능하다. | + +### 가공 결과 + +| 가공 변수 | 생성 방식 | 서비스 적용 | +| --- | --- | --- | +| `college_daily_study_time` | 대학생 이상 학습 시간 | 시간표/캘린더 busy 블록 중요성 설명 | +| `college_daily_travel_time` | 대학생 이상 이동 시간 | 후보 시간 사이의 여유시간 필요성 설명 | +| `weekday_social_participation_actor_time` | 평일 교제 및 참여활동 행위자 평균시간 | 평일 후보 시간 설명 | +| `saturday_social_participation_actor_time` | 토요일 행위자 평균시간 | 주말 후보 시간 설명 | +| `sunday_social_participation_actor_time` | 일요일 행위자 평균시간 | 주말 후보 시간 설명 | + +## 3. 국민여가활동조사 원시데이터 + +### 출처 + +- 자료명: 문화체육관광부_국민여가활동조사 원시데이터 +- 제공기관: 문화체육관광부 +- 공식 자료: +- 제공 내용: 2006년부터 2022년까지의 원시데이터와 코드북. 여가시간 활용, 활동 유형, 참여 빈도, 만족도, 제약 요인 등을 포함한다. + +### 전처리 방향 + +이 데이터는 SPSS 계열 원시자료와 코드북 기반으로 제공되므로, 제출 일정이 촉박한 현재 단계에서는 핵심 수치 산출보다 다음 항목의 추가 분석 후보로 정리한다. + +| 원자료 항목 | 가공 후보 | MOIM 적용 | +| --- | --- | --- | +| 여가활동 유형 | `leisure_activity_type` | 모임 유형 프리셋 보강 | +| 참여 빈도 | `meeting_repetition_proxy` | 반복 모임/일회성 모임 구분 | +| 만족도 | `leisure_satisfaction_level` | 일정 조율 개선 효과 설명 | +| 제약 요인 | `leisure_constraint_type` | 시간 부족, 비용, 동반자 부족 등 문제 정의 보강 | + +### 현재 문서에서의 사용 범위 + +국민여가활동조사는 MOIM의 핵심 수치 근거로 직접 쓰기보다, 사회조사와 생활시간조사의 결론을 보강하는 보조 데이터로 둔다. 추가 시간이 생기면 코드북 기준으로 `여가활동 동반자`, `활동 유형`, `참여 빈도`, `제약 요인`을 추출해 `processed-indicators.csv`에 추가한다. + +## 4. 인터넷이용실태조사 통계자료 + +### 출처 + +- 자료명: 한국지능정보사회진흥원_인터넷이용실태조사 통계자료 +- 제공기관: 한국지능정보사회진흥원 +- 공식 자료: +- 제공 내용: 인터넷 이용빈도, 접속방법, 이용장소, 이용목적, 주 이용 사이트 등. + +### 전처리 방향 + +MOIM은 카카오톡 단체방과 모바일 링크 진입을 핵심 흐름으로 둔다. 따라서 인터넷이용실태조사는 아래 항목을 보조 근거로 쓴다. + +| 원자료 항목 | 가공 후보 | MOIM 적용 | +| --- | --- | --- | +| 인터넷 접속방법 | `mobile_access_preference` | 앱 설치보다 웹 링크 기반 접근을 우선하는 근거 | +| 인터넷 이용장소 | `mobile_context_location` | 이동 중/학교/공공장소에서도 응답 가능한 UX 근거 | +| 인터넷 이용목적 | `communication_usage_context` | 일정 조율이 메신저/커뮤니케이션 맥락에서 발생한다는 근거 | +| 주 이용 사이트/서비스 | `platform_entry_context` | 카카오톡 공유 버튼, 모바일 초대 링크 우선순위 근거 | + +## 데이터 융합 설계 + +MOIM의 추천 로직에서 공공데이터는 개인 일정 데이터를 대체하지 않는다. 개인 데이터는 실제 가능 시간 계산에 사용하고, 공공데이터는 후보 설명과 UX 기본값을 보정하는 데 사용한다. + +```text +개인 캘린더 / 시간표 / 직접 입력 + -> busy/free 블록 정규화 + -> 참여자별 가능한 시간 계산 + -> 공통 가능 시간 산출 + +공공 통계 데이터 + -> 모임 유형 프리셋 + -> 요일별 후보 시간 설명 + -> 조율 난이도 설명 + -> 사업계획서 시장·문제 근거 +``` + +### 예시 가공 로직 + +| 입력 | 처리 | 출력 | +| --- | --- | --- | +| 참여자 4명, 후보 시간 3개 | 개인 일정 기준으로 겹치는 시간 계산 | 후보 A/B/C | +| 모임 유형 `스터디/팀 프로젝트` | 20대 학술단체·교제시간 지표를 설명 데이터로 결합 | “학습·이동 시간이 큰 대학생 모임에 적합한 후보” | +| 후보가 평일 저녁 | 평일 교제 및 참여 행위자 평균시간 1시간 12분 참고 | “평일에는 짧은 회의 후보를 우선 제안” | +| 후보가 일요일 | 일요일 교제 및 참여 행위자 평균시간 1시간 39분 참고 | “주말에는 비교적 긴 회의 후보도 가능” | + +## 사업계획서 반영 문구 + +### 공공데이터 활용성 + +MOIM은 통계청 사회조사와 생활시간조사를 활용해 20대의 단체 참여 유형, 비가족 교류 규모, 대학생 이상 집단의 학습·이동·교제 시간을 분석했다. 이를 통해 친목/사교, 취미/동아리, 스터디/팀 프로젝트 등 모임 유형을 정의하고, 개인 캘린더와 시간표에서 계산된 후보 시간에 요일별·유형별 설명을 부여한다. + +### 데이터 가공 및 융합 + +공공데이터는 원자료의 통계표에서 연령대, 단체 유형, 요일, 행동분류를 기준으로 필요한 지표만 추출했다. 추출된 지표는 `meeting_type_*`, `average_non_family_contacts`, `weekday_social_participation_actor_time` 같은 서비스 설계 변수로 변환했다. 개인 일정 데이터는 실제 가능한 시간 계산에 사용하고, 공공 통계 데이터는 후보 시간의 설명과 모임 유형 기본값을 보정하는 방식으로 융합한다. + +### 한계와 보완 계획 + +현재 공공데이터는 개인별 추천을 직접 결정하는 핵심 입력이 아니라, 모임 유형과 시간 조율 문제를 설명하는 보조 데이터로 활용한다. 따라서 공공데이터 활용 항목에서 고득점을 기대하기보다는 요건 충족과 논리 보강에 초점을 둔다. 이후 사용자 테스트 데이터가 쌓이면 실제 모임 생성 시간, 참여자 수, 후보 시간 선택률을 공공 통계 기반 가설과 비교해 추천 기준을 개선한다. + +## 제출 자료에 넣을 표 + +| 데이터 | 구체성 | 가공 | 융합 | +| --- | --- | --- | --- | +| 사회조사 단체 참여 | 20대 단체 참여율과 단체 유형별 비율 | 모임 유형 프리셋으로 변환 | 모임 생성 화면의 유형 선택과 사업계획서 시장 근거 | +| 사회조사 사회적 관계망 | 20대 비가족 교류자 있음/평균 교류자 수 | 다인 조율 난이도 근거로 변환 | 3~5인 모임 조율 문제 설명 | +| 사회조사 여가 동반자/불만족 이유 | 친구/연인 동반 여가, 시간 부족 | 일정 조율 필요성 지표로 변환 | MOIM의 문제 정의와 효과 설명 | +| 생활시간조사 학생 시간사용 | 대학생 이상 학습·이동·교제 시간 | busy/free 구조 설명 변수로 변환 | 캘린더·시간표 연동 필요성 설명 | +| 생활시간조사 교제 및 참여활동 | 요일별 행위자 평균시간 | 후보 시간 설명 변수로 변환 | 평일/주말 후보 시간 안내 | +| 인터넷이용실태조사 | 접속방법, 이용장소, 이용목적 | 모바일 링크 UX 근거로 정리 | 카카오톡 링크 기반 참여 흐름 보강 | + +## 확인 및 검증 메모 + +- `2025 사회조사`와 `2024 생활시간조사`는 통계청 공식 결과표에서 MOIM과 직접 관련된 지표만 추출했다. +- 복수응답 지표는 합계가 100%가 되도록 재정규화하지 않고, 모임 유형 우선순위와 문제정의 근거로만 사용했다. +- `국민여가활동조사 원시데이터`와 `인터넷이용실태조사 통계자료`는 현재 문서에서 보조 데이터 후보로 정리했다. 코드북·원시자료까지 추가 처리할 시간이 확보되면 `processed-indicators.csv`에 별도 행으로 확장한다. +- 개인 캘린더 데이터는 실제 후보 시간 계산에 사용하고, 공공 통계는 후보 시간의 설명·기본값·사업계획서 근거에 사용하는 방식으로 역할을 분리했다. +- 현재 CSV는 헤더 포함 18개 행이며, 모든 행은 `dataset`, `source_url`, `raw_indicator`, `scope`, `value`, `preprocessing`, `processed_variable`, `moim_usage` 8개 컬럼 구조로 정리했다. + +## 산출물 + +- 가공 지표 CSV: `docs/startup-data/processed-indicators.csv` +- 분석 문서: `docs/startup-data/public-data-analysis.md` diff --git "a/docs/survey/\353\252\250\354\236\204(\354\235\221\353\213\265) - \354\204\244\353\254\270\354\247\200 \354\235\221\353\213\265 \354\213\234\355\212\2701.csv" "b/docs/survey/\353\252\250\354\236\204(\354\235\221\353\213\265) - \354\204\244\353\254\270\354\247\200 \354\235\221\353\213\265 \354\213\234\355\212\2701.csv" new file mode 100644 index 0000000..80e9240 --- /dev/null +++ "b/docs/survey/\353\252\250\354\236\204(\354\235\221\353\213\265) - \354\204\244\353\254\270\354\247\200 \354\235\221\353\213\265 \354\213\234\355\212\2701.csv" @@ -0,0 +1,46 @@ +타임스탬프,"Q3. 최근 한 달간 모임 시간 조율(카톡, When2meet 등)을 하신 횟수는?",Q2. 현재 사용하시는 일정 관리 도구를 모두 선택해주세요.,Q4. 한 번 일정을 잡을 때 평균 얼마나 걸리시나요?,Q5. 일정 조율 중 가장 짜증났던 순간은? (최대 3개),Q6. 일정 조율이 잘 안 돼서 모임 자체가 무산되거나 연기된 경험이 있으신가요?,Q7. 일정 조율 시 주로 사용하시는 방법은?,"Q8. ""지금 사용하는 일정 조율 방법에 만족한다.""",Q9. 만족스럽지 않다면 그 이유는 무엇인가요?,Q10. 시간표를 자동으로 불러와 빈 시간을 찾아주는 도구를 들어보거나 사용해본 적이 있나요?,Q11. (Q10에서 안 쓴다/안 써봤다 응답 시) 사용하지 않은 이유는?,Q12. 위 서비스가 출시되면 사용해 볼 의향이 있으신가요?,Q13. 가장 매력적이라고 느끼는 기능들,Q14. 사용하기 전에 가장 걱정되는 점은?,Q15. MOIM에 꼭 있었으면 하는 기능이나 하고 싶은 말이 있다면 자유롭게 적어주세요.,Q16. 베타 테스터로 가장 먼저 사용해보고 싶으신가요?,Q17. 기프티콘 추첨 및 베타 안내를 받으실 연락처를 입력해주세요. +2026. 5. 6 오전 1:23:29,3~5회,애플 캘린더(iCloud),1시간 이상,누가 응답 안 해서 한참 기다릴 때,거의 없다,카카오톡 채팅으로 직접 맞추기,4,,들어봤지만 안 써봤다,어차피 카톡이 더 빠르다,4,정해진 일정 카톡으로 자동 공유·알림,기존 카톡 대비 더 빠를지 의문이다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오전 1:47:12,10회 이상,"애플 캘린더(iCloud), 노션 / 종이 다이어리",30분~1시간,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때, 정해진 일정을 캘린더에 옮겨 적어야 할 때",가끔 있다,카카오톡 채팅으로 직접 맞추기,4,,지금도 쓰고 있다,,4,구글/애플 캘린더 양방향 동기화,기존 카톡 대비 더 빠를지 의문이다,화이팅,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오전 2:46:33,0회,"에브리타임 시간표, 애플 캘린더(iCloud)",하루 넘게 끌린다,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,카카오톡 채팅으로 직접 맞추기,1,너무 귀찮고 꼭 한명씩 연락을 안봐요,들어본 적도 없다,그런 도구가 있는지 몰랐다,4,"에브리타임 시간표 자동 불러오기, 정해진 일정 카톡으로 자동 공유·알림",친구들이 같이 안 쓸 것 같다,,"네, 베타 신청할게요",kristin1203 +2026. 5. 6 오전 3:33:24,1~2회,애플 캘린더(iCloud),10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",거의 없다,카카오톡 투표 기능,2,,들어본 적도 없다,,5,반복 모임(매주 스터디 등) 한 번에 등록,별로 걱정되지 않는다,,"네, 베타 신청할게요", +2026. 5. 6 오전 10:59:12,6~10회,사용 안 함,10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",거의 없다,카카오톡 채팅으로 직접 맞추기,3,,들어봤지만 안 써봤다,"어차피 카톡이 더 빠르다, 핸드폰에 내장된 캘린더로 충분했다",4,"친구들과의 공통 빈 시간 자동 추천, 정해진 일정 카톡으로 자동 공유·알림",기존 카톡 대비 더 빠를지 의문이다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 12:37:02,0회,에브리타임 시간표,10분 이내,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",거의 없다,카카오톡 투표 기능,4,,들어본 적도 없다,그런 도구가 있는지 몰랐다,2,반복 모임(매주 스터디 등) 한 번에 등록,별로 걱정되지 않는다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 12:54:36,3~5회,사용 안 함,1시간 이상,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",자주 있다,카카오톡 채팅으로 직접 맞추기,3,바로바로 컨펌이 나는 게 아니라서,들어본 적도 없다,그런 도구가 있는지 몰랐다,4,에브리타임 시간표 자동 불러오기,친구들이 같이 안 쓸 것 같다,"단순한 스케줄 관리라면 접근성이 떨어질 것 같습니다. +이미 최적화가 된 다른 어플들이 많으니까요. +차별점이 있어야 할 것 같습니다. ","아니요, 출시되면 그때 써볼게요",괜찮습니다 +2026. 5. 6 오후 1:22:50,3~5회,애플 캘린더(iCloud),30분~1시간,누가 응답 안 해서 한참 기다릴 때,가끔 있다,카카오톡 투표 기능,3,,들어본 적도 없다,,4,구글/애플 캘린더 양방향 동기화,기존 카톡 대비 더 빠를지 의문이다,,"네, 베타 신청할게요",jt0987@naver.com +2026. 5. 6 오후 1:24:09,6~10회,"에브리타임 시간표, 노션 / 종이 다이어리",10~30분,"When2meet에 매번 다시 입력해야 할 때, 다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,When2meet,3,"When2Meet 기준으로는 일단 UI 자체가 모바일로는 입력하기 매우 힘든 구조임. 사용성이 그렇게 편리하다고 느껴지지는 않음. + +OneTime이 어느 정도 그 문제를 해결해주지만 유저 플로우가 매우 어색한편.",써봤지만 지금은 안 쓴다,"친구들이 안 써서 같이 쓰기 어렵다, 어차피 카톡이 더 빠르다",4,"에브리타임 시간표 자동 불러오기, 구글/애플 캘린더 양방향 동기화",친구들이 같이 안 쓸 것 같다,각자 일정을 정할 때 활용하는 카테고리나 태그가 다양할건데 그걸 통합해주거나 일정한 기준을 마련해서 일정들을 공유해주는 기능이 있으면 좋을 것 같아용,"네, 베타 신청할게요",legoherohty@gmail.com +2026. 5. 6 오후 1:25:12,6~10회,"에브리타임 시간표, 노션 / 종이 다이어리",10~30분,"시간표 캡처를 일일이 받아야 할 때, When2meet에 매번 다시 입력해야 할 때, 다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 카톡 투표 결과가 갈려서 재투표할 때, 누가 응답 안 해서 한참 기다릴 때, 정해진 일정을 캘린더에 옮겨 적어야 할 때",가끔 있다,카카오톡 투표 기능,2,귀찮음,들어본 적도 없다,그런 도구가 있는지 몰랐다,5,"에브리타임 시간표 자동 불러오기, 친구들과의 공통 빈 시간 자동 추천, 반복 모임(매주 스터디 등) 한 번에 등록",생각보다 불편할거 같음,심플이즈더 베스트,"아니요, 출시되면 그때 써볼게요",won832310@gmail.com +2026. 5. 6 오후 1:28:06,3~5회,사용 안 함,10분 이내,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,카카오톡 채팅으로 직접 맞추기,2,오래 걸림,들어봤지만 안 써봤다,친구들이 안 써서 같이 쓰기 어렵다,4,정해진 일정 카톡으로 자동 공유·알림,기존 카톡 대비 더 빠를지 의문이다,화이팅!,"아니요, 출시되면 그때 써볼게요",draem1633@gmail.com +2026. 5. 6 오후 1:28:45,3~5회,구글 캘린더,하루 넘게 끌린다,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 카톡 투표 결과가 갈려서 재투표할 때",가끔 있다,카카오톡 투표 기능,3,안 하는 사람 존재,들어본 적도 없다,,3,친구들과의 공통 빈 시간 자동 추천,개인의 드러내고 싶지 않은 일정도 드러내야함,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 1:46:52,10회 이상,에브리타임 시간표,하루 넘게 끌린다,"시간표 캡처를 일일이 받아야 할 때, When2meet에 매번 다시 입력해야 할 때, 다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",가끔 있다,카카오톡 채팅으로 직접 맞추기,1,매번 말맞추는게 매우 힘들다,들어봤지만 안 써봤다,친구들이 안 써서 같이 쓰기 어렵다,5,친구들과의 공통 빈 시간 자동 추천,에브리타임 계정 연동이 꺼려진다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 1:50:04,1~2회,애플 캘린더(iCloud),10분 이내,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 카톡 투표 결과가 갈려서 재투표할 때, 누가 응답 안 해서 한참 기다릴 때",거의 없다,카카오톡 투표 기능,3,적당히 만족해요.,들어본 적도 없다,"계정 연동이 부담스럽다 (개인정보), 모든 나의 일정을 캘린더에 기록하지 않아서.",3,친구들과의 공통 빈 시간 자동 추천,친구들이 같이 안 쓸 것 같다,사용자 친화적인 UI,"아니요, 출시되면 그때 써볼게요",jamesharden@fake.com +2026. 5. 6 오후 1:52:16,3~5회,"구글 캘린더, 노션 / 종이 다이어리",10분 이내,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,카카오톡 투표 기능,4,,들어봤지만 안 써봤다,어차피 카톡이 더 빠르다,4,정해진 일정 카톡으로 자동 공유·알림,기존 카톡 대비 더 빠를지 의문이다,종이 다이어리 ocr 기능,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 2:00:19,3~5회,에브리타임 시간표,1시간 이상,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,타임픽,4,,들어본 적도 없다,,2,친구들과의 공통 빈 시간 자동 추천,기존 카톡 대비 더 빠를지 의문이다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 2:11:15,1~2회,노션 / 종이 다이어리,10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 카톡 투표 결과가 갈려서 재투표할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,카카오톡 투표 기능,4,,들어본 적도 없다,그런 도구가 있는지 몰랐다,4,에브리타임 시간표 자동 불러오기,에브리타임 계정 연동이 꺼려진다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 2:25:42,1~2회,노션 / 종이 다이어리,10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,카카오톡 채팅으로 직접 맞추기,3,편리하지 않아요 개별적으로 다 검수해야해요.,써봤지만 지금은 안 쓴다,,4,"에브리타임 시간표 자동 불러오기, 친구들과의 공통 빈 시간 자동 추천, 구글/애플 캘린더 양방향 동기화, 정해진 일정 카톡으로 자동 공유·알림",친구들이 같이 안 쓸 것 같다,,"네, 베타 신청할게요",01052686351 +2026. 5. 6 오후 2:29:40,3~5회,애플 캘린더(iCloud),하루 넘게 끌린다,"시간표 캡처를 일일이 받아야 할 때, 다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",자주 있다,카카오톡 채팅으로 직접 맞추기,2,인원이 많을수록 안 되는 일정이 많아서 계속 날짜를 미룸,들어본 적도 없다,그런 도구가 있는지 몰랐다,4,반복 모임(매주 스터디 등) 한 번에 등록,친구들이 같이 안 쓸 것 같다,,"아니요, 출시되면 그때 써볼게요",010-9536-0735 +2026. 5. 6 오후 2:35:06,3~5회,"구글 캘린더, 노션 / 종이 다이어리",10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 정해진 일정을 캘린더에 옮겨 적어야 할 때",자주 있다,카카오톡 투표 기능,4,,들어본 적도 없다,,5,"에브리타임 시간표 자동 불러오기, 친구들과의 공통 빈 시간 자동 추천, 구글/애플 캘린더 양방향 동기화, 정해진 일정 카톡으로 자동 공유·알림, 반복 모임(매주 스터디 등) 한 번에 등록",친구들이 같이 안 쓸 것 같다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 2:37:00,3~5회,"에브리타임 시간표, 노션 / 종이 다이어리",1시간 이상,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 카톡 투표 결과가 갈려서 재투표할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,When2meet,4,날 수가 많아지면 모바일로 체크하기 불편하다,들어봤지만 안 써봤다,친구들이 안 써서 같이 쓰기 어렵다,4,"에브리타임 시간표 자동 불러오기, 친구들과의 공통 빈 시간 자동 추천",친구들이 같이 안 쓸 것 같다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 6 오후 4:17:14,3~5회,"에브리타임 시간표, 애플 캘린더(iCloud), 노션 / 종이 다이어리",30분~1시간,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 카톡 투표 결과가 갈려서 재투표할 때, 누가 응답 안 해서 한참 기다릴 때, 정해진 일정을 캘린더에 옮겨 적어야 할 때",자주 있다,카카오톡 채팅으로 직접 맞추기,3,"반복적으로 또는 같은 모임 안에서도 여러번 일정 조율이 필요함에도 +모두가 같은 시간에 동시에 연락을 확인해야지만 일정 조율이 가능하다는게 불편하지만 +현재는 별다른 대안이 없다고 생각하여 3을 선택했습니다",들어본 적도 없다,그런 도구가 있는지 몰랐다,4,친구들과의 공통 빈 시간 자동 추천,대외비/알리고싶지 않은 개인적 일정까지 일괄 공유될 가능성,,"네, 베타 신청할게요",Pana06 +2026. 5. 6 오후 5:45:30,3~5회,애플 캘린더(iCloud),10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 카톡 투표 결과가 갈려서 재투표할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,카카오톡 채팅으로 직접 맞추기,3,,들어본 적도 없다,"그런 도구가 있는지 몰랐다, 계정 연동이 부담스럽다 (개인정보)",4,"에브리타임 시간표 자동 불러오기, 친구들과의 공통 빈 시간 자동 추천, 구글/애플 캘린더 양방향 동기화",친구들이 같이 안 쓸 것 같다,숨기고 싶은 일정은 숨길수 있도록 하는 기능,"아니요, 출시되면 그때 써볼게요",dbsghk0711@naver.com +2026. 5. 6 오후 8:43:04,3~5회,"에브리타임 시간표, 노션 / 종이 다이어리",10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",가끔 있다,카카오톡 채팅으로 직접 맞추기,2,매우 불편하다 한명이 멱살잡고 약속 잡아야함,들어본 적도 없다,그런 도구가 있는지 몰랐다,5,에브리타임 시간표 자동 불러오기,친구들이 같이 안 쓸 것 같다,,"네, 베타 신청할게요",JM010701 +2026. 5. 6 오후 9:34:42,1~2회,사용 안 함,10분 이내,누가 응답 안 해서 한참 기다릴 때,가끔 있다,카카오톡 채팅으로 직접 맞추기,3,,들어본 적도 없다,친구들이 안 써서 같이 쓰기 어렵다,3,"친구들과의 공통 빈 시간 자동 추천, 정해진 일정 카톡으로 자동 공유·알림",친구들이 같이 안 쓸 것 같다,,"네, 베타 신청할게요",01091534661 +2026. 5. 7 오전 11:07:19,1~2회,에브리타임 시간표,1시간 이상,"시간표 캡처를 일일이 받아야 할 때, When2meet에 매번 다시 입력해야 할 때, 다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",자주 있다,카카오톡 채팅으로 직접 맞추기,3,,들어본 적도 없다,어차피 카톡이 더 빠르다,3,"에브리타임 시간표 자동 불러오기, 친구들과의 공통 빈 시간 자동 추천",친구들이 같이 안 쓸 것 같다,,"아니요, 출시되면 그때 써볼게요", +2026. 5. 7 오전 11:11:07,1~2회,에브리타임 시간표,하루 넘게 끌린다,"시간표 캡처를 일일이 받아야 할 때, 다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",자주 있다,카카오톡 투표 기능,2,할게너무많아서 귀찮다,들어봤지만 안 써봤다,,4,친구들과의 공통 빈 시간 자동 추천,별로 걱정되지 않는다,,"아니요, 출시되면 그때 써볼게요",01026096174 +2026. 5. 7 오전 11:14:03,3~5회,"에브리타임 시간표, 구글 캘린더",10분 이내,누가 응답 안 해서 한참 기다릴 때,거의 없다,카카오톡 채팅으로 직접 맞추기,4,,들어봤지만 안 써봤다,친구들이 안 써서 같이 쓰기 어렵다,4,에브리타임 시간표 자동 불러오기,별로 걱정되지 않는다,,"네, 베타 신청할게요",pkpk0514@gmail.com +2026. 5. 7 오후 12:49:59,1~2회,구글 캘린더,10~30분,"When2meet에 매번 다시 입력해야 할 때, 다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,카카오톡 투표 기능,3,누가 투표 했는지 안 했는지 일일이 들어가서 체크해야돼서,들어본 적도 없다,그런 도구가 있는지 몰랐다,5,"친구들과의 공통 빈 시간 자동 추천, 구글/애플 캘린더 양방향 동기화",캘린더 접근 권한이 꺼려진다,,"네, 베타 신청할게요",codestore1702 +2026. 5. 7 오후 12:53:51,1~2회,"에브리타임 시간표, 구글 캘린더",10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",자주 있다,카카오톡 채팅으로 직접 맞추기,3,,들어봤지만 안 써봤다,귀찮다,3,구글/애플 캘린더 양방향 동기화,기존 카톡 대비 더 빠를지 의문이다,,"아니요, 출시되면 그때 써볼게요",010-9103-9736 +2026. 5. 7 오후 12:58:11,10회 이상,"에브리타임 시간표, 구글 캘린더, 노션 / 종이 다이어리",10분 이내,"When2meet에 매번 다시 입력해야 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,onetime,4,,지금도 쓰고 있다,,4,정해진 일정 카톡으로 자동 공유·알림,기존 카톡 대비 더 빠를지 의문이다,"원타임 참고해봐도 좋을 것 같아요 +https://www.onetime-with-members.com/ko/landing","아니요, 출시되면 그때 써볼게요",01097550887 +2026. 5. 7 오후 1:37:49,1~2회,애플 캘린더(iCloud),10분 이내,"시간표 캡처를 일일이 받아야 할 때, 다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 카톡 투표 결과가 갈려서 재투표할 때",가끔 있다,카카오톡 투표 기능,4,,들어본 적도 없다,그런 도구가 있는지 몰랐다,5,구글/애플 캘린더 양방향 동기화,캘린더 접근 권한이 꺼려진다,,"아니요, 출시되면 그때 써볼게요",eunseo.myung@gmail.com +2026. 5. 7 오후 2:23:22,1~2회,구글 캘린더,10분 이내,"시간표 캡처를 일일이 받아야 할 때, When2meet에 매번 다시 입력해야 할 때",거의 없다,카카오톡 채팅으로 직접 맞추기,3,,들어봤지만 안 써봤다,찾아보기 귀찮다,4,에브리타임 시간표 자동 불러오기,기존 카톡 대비 더 빠를지 의문이다,,"아니요, 출시되면 그때 써볼게요",eileens14 +2026. 5. 7 오후 2:29:41,3~5회,에브리타임 시간표,1시간 이상,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때, 누가 응답 안 해서 한참 기다릴 때",가끔 있다,When2meet,4,일일히 링크를 찾아가는게 번거로워요,들어본 적도 없다,그런 도구가 있는지 몰랐다,4,친구들과의 공통 빈 시간 자동 추천,친구들이 같이 안 쓸 것 같다,에타나 캘린더에 적지 않는 일정도 적을 수 있도록 직접 추가 기능이 있으면 좋을 것 같아요,"아니요, 출시되면 그때 써볼게요",leeminseo.archive@gmail.com +2026. 5. 7 오후 3:41:33,3~5회,애플 캘린더(iCloud),10분 이내,When2meet에 매번 다시 입력해야 할 때,거의 없다,When2meet,4,,들어본 적도 없다,,4,"에브리타임 시간표 자동 불러오기, 친구들과의 공통 빈 시간 자동 추천, 정해진 일정 카톡으로 자동 공유·알림",별로 걱정되지 않는다,모든 일정이 캘린더에 들어가있지 않을 케이스도 고려해보면 좋을 것 같습니다. 화이팅~,"네, 베타 신청할게요",sunoo811(카카오 아이디) +2026. 5. 7 오후 4:23:00,1~2회,노션 / 종이 다이어리,10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",가끔 있다,카카오톡 투표 기능,3,(리더)끌어가는 사람이 시간 소요 or 생각할게 많음,들어봤지만 안 써봤다,"친구들이 안 써서 같이 쓰기 어렵다, 어차피 카톡이 더 빠르다, 기능이 부족해 보였다",2,"에브리타임 시간표 자동 불러오기, 반복 모임(매주 스터디 등) 한 번에 등록",내가 종이다이어리나 todomate를 사용함 + 수시로 생기는 단기적 일정이 많음,파이팅! (시간표 말고도 고려할게 많은 거 같습니다. 알바나 약속이나 시간표 끝나도 이동시간 등. 이동시간 정도가 고려되면 꽤 쓸만하지 않을까..),"네, 베타 신청할게요",swan5679@naver.com +2026. 5. 11 오전 11:32:20,6~10회,"구글 캘린더, 사용 안 함",10~30분,"다 정해놨는데 한 명이 ""그날 안 된다"" 할 때",자주 있다,카카오톡 채팅으로 직접 맞추기,5,,들어봤지만 안 써봤다,친구들이 안 써서 같이 쓰기 어렵다,5,친구들과의 공통 빈 시간 자동 추천,친구들이 같이 안 쓸 것 같다,설문,"아니요, 출시되면 그때 써볼게요",01046807296 +2026. 5. 11 오후 4:12:39,6~10회,"구글 캘린더, 애플 캘린더(iCloud), 네이버 캘린더",10~30분,시간표 캡처를 일일이 받아야 할 때,가끔 있다,카카오톡 투표 기능,3,,들어봤지만 안 써봤다,그런 도구가 있는지 몰랐다,4,반복 모임(매주 스터디 등) 한 번에 등록,별로 걱정되지 않는다,,"네, 베타 신청할게요", \ No newline at end of file diff --git a/docs/v1/codex-work-context.md b/docs/v1/codex-work-context.md new file mode 100644 index 0000000..79139e3 --- /dev/null +++ b/docs/v1/codex-work-context.md @@ -0,0 +1,137 @@ +# MOIM 개발 현황 인수인계 + +작성일: 2026-05-28 + +이 문서는 새 Codex 세션이 `C:\Users\kksu1\Dev\MOIM`에서 바로 맥락을 잡기 위한 작업 현황 노트다. 제품 설명서가 아니라, 현재 어디까지 왔고 무엇을 조심해야 하는지 정리한 handoff 문서다. + +## 먼저 읽을 파일 + +1. `README.md` + - 로컬 실행, 검증 명령, 현재 안정성 기준을 확인하는 첫 문서다. +2. `docs/README.md` + - `docs/v1` 중심의 문서 인덱스다. +3. `docs/v1/user-flow.md` + - guest-first, 초대 링크 문맥 우선이라는 제품 원칙의 원본 기준 문서다. +4. `convention.md` + - 커밋, 브랜치, 이슈, PR 규칙의 기준이다. +5. `ARCHITECTURE.md` + - 코드 구조와 테스트 전략을 볼 때의 기준 문서다. +6. `prototype/moim.html` + - 사용자 테스트용 단일 HTML 프로토타입의 중심 파일이다. + +## 제품 방향 + +MOIM은 여러 사람이 모임 시간을 정할 때, 주최자가 링크를 만들고 참여자들이 가능한 시간을 제출하면 공통 가능한 시간을 추천해 주는 서비스다. + +현재 가장 중요한 제품 원칙은 다음과 같다. + +- 실제 제품 흐름은 `게스트 퍼스트 + 초대 링크 문맥 우선`이다. +- 초대 링크로 들어온 사용자는 바로 참여자 흐름으로 들어가야 한다. +- 로그인, 캘린더 저장, 계정 저장은 첫 관문이 아니라 가치를 경험한 뒤 제안한다. +- 사용자 테스트용 역할 허브는 테스트 장치다. 실제 제품 첫 화면으로 쓰면 안 된다. + +MVP의 최소 흐름은 다음 순서가 기준이다. + +1. 주최자가 모임을 만든다. +2. 초대 링크를 공유한다. +3. 참여자가 로그인 없이 가능한 시간을 제출한다. +4. 주최자가 추천 시간을 확인한다. +5. 주최자가 최종 시간을 확정한다. + +## 현재 repo 상태 + +2026-05-28 확인 기준: + +- repo root: `C:\Users\kksu1\Dev\MOIM` +- GitHub remote: `origin https://github.com/Siul49/moim.git` +- 기준 통합 브랜치: `dev` +- PR #16, #21, #26, #27, #29는 `dev`에 merge 완료 +- 현재 레포 위생 정리 작업 이슈: #30 + +작업 시작 전에는 항상 아래를 다시 확인한다. + +```powershell +git fetch origin --prune +git status --short --branch +gh pr list --state open --json number,title,headRefName,baseRefName,url +``` + +## 현재 구현 축 + +### 1. Next.js 앱 + +프로젝트는 Next.js App Router 기반이다. + +주요 명령: + +```powershell +npm ci +npm run lint +npm run test +npm run build +npm run test:e2e -- --project=chromium +``` + +`package.json` 기준 주요 스크립트: + +- `npm run dev`: Next dev server +- `npm run test`: Vitest +- `npm run lint`: Next lint +- `npm run build`: production build +- `npm run test:e2e`: Playwright + +### 2. 일정 생성/참여 플로우 + +현재 앱에는 Supabase 저장소와 별개로 브라우저 테스트 가능한 일정 생성/참여 플로우가 있다. + +- `src/app/schedule/create/CreateScheduleClient.tsx`: 호스트가 모임을 만들고 참여자/호스트 링크를 받는다. +- `src/app/schedule/[id]/ScheduleRoomClient.tsx`: 참여자가 이름과 가능 시간을 제출하고, host token이 있으면 결과를 본다. +- `src/app/api/schedules/*`: 위 플로우를 위한 API route다. +- `src/lib/schedules/store.ts`: Prisma/SQLite-backed schedule store다. host token 원문은 저장하지 않고 hash만 저장한다. + +이 저장소는 process memory가 아니라 로컬 SQLite DB에 남는다. Supabase-backed 운영 저장소로 확장할 때도 API contract를 유지하면서 저장소 구현과 배포 DB만 교체한다. + +### 3. 캘린더/시간 계산 + +기준 모듈: + +- `src/lib/scheduling/availability.ts` +- `src/lib/scheduling/free-slots.ts` +- `src/lib/scheduling/time-slot.ts` +- `src/lib/calendar/adapters/*` +- `src/lib/scheduling/ics-parser.ts` + +Google/iCloud/수동 입력/ICS는 provider raw data를 표준 시간 표현으로 바꾼 뒤 공통 가용시간 계산으로 들어가는 구조다. + +### 4. 인증/계정 + +현재 `dev`에는 이메일/닉네임 로그인, 회원가입, 카카오 로그인, Supabase/Prisma 관련 작업이 merge되어 있다. host auth와 participant guest flow는 분리해서 다룬다. + +## 레포 위생 기준 + +- `.env`는 GitHub에 올리지 않는다. 필요한 키 이름만 `.env.example`에 둔다. +- `.claude/`, worktree, 로컬 실행 로그, 테스트 결과물은 추적하지 않는다. +- agent 전용 계획 문서는 기본적으로 레포에 남기지 않는다. 사람이 읽는 제품/개발 문서는 `docs/`에 직접 정리한다. +- 오래된 스켈레톤 파일은 실제 구현과 연결되어 있지 않으면 삭제한다. + +## 다음 작업 추천 순서 + +1. 이슈 #30의 레포 위생/CI 정리를 완료하고 PR을 만든다. +2. `dev` 기준 CI가 `lint`, `test`, `build`, Chromium e2e를 모두 실행하는지 확인한다. +3. Supabase-backed 운영 저장소 전환을 별도 이슈로 분리한다. +4. 참여자 guest flow와 host auth flow가 섞이지 않도록 API와 UI 경계를 유지한다. + +## 다음 세션 시작 프롬프트 + +```text +C:\Users\kksu1\Dev\MOIM에서 작업해줘. +먼저 README.md, docs/README.md, docs/v1/codex-work-context.md, convention.md를 읽고 현재 개발 현황과 협업 규칙을 파악해. +기존 작업트리에 수정/미추적 파일이 있으면 함부로 되돌리지 말고, git status와 GitHub PR 상태를 다시 확인해. +변경 후에는 npm run lint, npm run test, npm run build, 가능하면 npm run test:e2e -- --project=chromium으로 검증해. +``` + +## 주의할 점 + +- 이 문서는 snapshot이다. PR 상태와 CI 결과는 바뀔 수 있으므로 다음 세션에서 반드시 다시 확인한다. +- PowerShell에서 한글이 깨져 보이면 파일 문제라고 단정하지 말고 `Get-Content -Raw -Encoding UTF8`로 다시 읽는다. +- 문서/프로토타입은 한국어 톤을 유지한다. 과한 corporate wording보다 학생 팀이 실제로 설명하는 문장에 가깝게 쓴다. diff --git a/docs/v1/prd.md b/docs/v1/prd.md new file mode 100644 index 0000000..61e4e6e --- /dev/null +++ b/docs/v1/prd.md @@ -0,0 +1,107 @@ +# 제품 요구사항 정의서 (PRD) - v1.0.0 (MVP 및 Supabase Auth 통합 반영) + +작성일: 2026-06-10 +대상: MOIM 개발팀, 사용자 테스트 및 MVP 검증용 +상태: Active / Verified + +--- + +## 1. 제품 개요 (Product Overview) +**MOIM**은 대학생 및 소규모 모임의 조별과제, 동아리, 학회 등 반복적인 일정 조율 문제를 캘린더/에브리타임 시간표 연동을 통해 해결하는 **대학생 특화 스마트 일정 조율 서비스**입니다. + +### 1.1 해결하고자 하는 문제 (Problem Statement) +1. **수동 비교 피로**: 카카오톡 대화방에서 시간표 이미지를 주고받으며 일일이 대조해 빈 시간을 찾아야 하는 비효율. +2. **이원화된 일정**: 대학생의 수업 시간은 에브리타임에, 개인 일정은 캘린더(구글, 애플 등)에 분리되어 있어 빈 시간 파악이 어려움. +3. **가입 장벽으로 인한 이탈**: 모임 조율 툴 진입 시 회원가입 및 연동 과정이 복잡하면 참여자들이 응답하지 않고 이탈함. + +### 1.2 핵심 가치 제안 (Value Proposition) +- **Guest-First & No-Sign-Up**: 초대받은 게스트는 가입과 로그인 없이 링크 진입 후 즉시 30초 내에 가능 시간을 응답할 수 있습니다. +- **다양한 가용시간 입력 수단**: 에브리타임 시간표 이미지 스캔, 구글/애플 캘린더 동기화, 직접 드래그 입력을 통합 지원합니다. +- **통합 가용 시간 분석**: 실시간 병합 히트맵과 AI 기반 최적의 후보 시간대 3안을 주최자에게 자동 추천합니다. + +--- + +## 2. 사용자 여정 및 라우팅 명세 (User Flow) +사용자의 진입 경로와 계정 상태에 따른 유연한 온보딩 흐름을 제공합니다. + +### 2.1 사용자 흐름도 (Mermaid Diagram) + +```mermaid +flowchart TD + A["사용자 진입"] --> B{"진입 경로"} + B -->|"초대 링크 /schedule/[id]"| C["참여자 일정 제출 화면"] + B -->|"일반 홈 /"| D["게스트 홈"] + B -->|"재방문 (로그인)"| E["사용자 대시보드"] + + D --> F["모임 생성 (/schedule/create)"] + D --> C + E --> F + E --> G["기존 모임 현황"] + + F --> H["모임 기본 정보 입력"] + H --> I["후보 날짜 및 시간 범위 지정"] + I --> J["초대 링크 및 호스트 토큰 생성"] + J --> K["공유 (카카오톡 등) 및 현황 모니터링"] + + C --> L["모임 요약 정보 확인"] + L --> M["직접 드래그 입력 / 이미지 업로드 / 캘린더 연동"] + M --> N["선택 시간 최종 확인 및 제출"] + N --> O["제출 완료 (로그인/가입 유도 제안)"] + + K --> P["실시간 가용 시간 히트맵 확인"] + P --> Q["최적 시간 확정"] + Q --> R["연동 유저 캘린더 등록 & 비연동 유저용 확정 카드 및 .ics 배포"] +``` + +### 2.2 진입 경로별 상세 흐름 + +1. **일반 홈 진입 (`/`)** + - 핵심 브랜드 소개 및 `모임 만들기` 버튼 제공. + - 비회원(게스트) 상태에서 로그인 절차 없이 바로 모임 생성 페이지(`/schedule/create`)로 진입할 수 있도록 하여 진입 장벽 최소화. + +2. **초대 링크 진입 (`/schedule/[id]`)** + - 게스트는 로그인이나 권한 수락 허용 없이 주최자가 공유한 링크로 즉시 진입. + - 모임 목적, 소요 시간 등 요약 정보를 확인하고, 가능 시간을 기입한 뒤 이름만 작성해 바로 제출. + - **완료 후 계정 제안**: 제출이 성공한 직후, "계정을 저장하고 다음 모임에서는 연동 없이 가용 시간을 가져오세요"라는 넛지성 계정 저장(회원가입) 제안 노출. + +3. **재방문 및 로그인 상태 (`/login`)** + - Supabase Auth를 통한 소셜 로그인 및 이메일 로그인 지원. + - 로그인 완료 시 대시보드로 이동하여 본인이 생성하거나 참여했던 모임 리스트와 활성 상태를 모니터링할 수 있음. + +--- + +## 3. 핵심 기능 요구사항 (Feature Specifications) + +### 3.1 모임 생성 및 공유 (호스트) +- **모임 속성**: 모임 이름, 예상 소요 시간, 후보 날짜 범위(최대 14일) 및 시간 범위 설정. +- **초대 링크 발급**: 고유한 스케줄 ID 기반의 초대 URL 생성. +- **보안 설정**: 주최자 권한(일정 확정 및 상세 결과 보기)을 증명하기 위한 **Host Token** 발급 및 관리. + +### 3.2 가용 시간 수집 (참여자) +- **직접 드래그 입력 (수동)**: Grid 인터페이스 상에서 터치/드래그를 통해 가용 시간을 선택하는 30분 단위 블록 드래그 시스템. +- **에브리타임 시간표 업로드**: 시간표 캡처 이미지 내 요일/시간 격자를 픽셀 단위로 분석하여 수업 시간을 자동으로 비가용(막힌 시간) 영역으로 지정. +- **외부 캘린더 연동**: Google Calendar API 연동 및 iCloud `.ics` 파일 업로드를 통해 외부 일정을 수동 기입 없이 가용시간 그리드에 불러오기. + +### 3.3 실시간 일정 취합 및 AI 추천 +- **실시간 가용 시간 히트맵**: 참여자 응답 데이터 수집 시 실시간으로 병합되어 가용자 수에 따라 그리드 색상 농도가 변화하는 히트맵 렌더링. +- **AI 최적 일정 추천**: 취합된 시간 중 연속된 3개의 최적 시간 후보군(충돌 인원 최소화, 최장 연속 시간 기준)을 자동 분석하여 제안. +- **최종 시간 확정**: 호스트가 시간 확정 시, 캘린더 연동 참여자에게는 자동으로 일정을 등록해 주고 비연동 참여자에게는 공유용 카드 및 `.ics` 파일 다운로드 제공. + +### 3.4 통합 인증 및 연동 (Supabase Auth) +- **소셜 로그인 (Google, Apple, Kakao)**: Client-side에서 `supabase.auth.signInWithOAuth`를 직접 트리거하고 공통 콜백 라우트(`/api/auth/callback`)로 수신하여 Supabase 세션을 일관되게 발급. +- **네이버 로그인 유예**: V1 스펙에서 제외 및 유예(Stub) 처리. 로그인 시도 시 `/login?error=naver_not_implemented`로 리다이렉트되어 안전하게 대처. +- **프로필 작성 및 비밀번호 관리**: 비밀번호 재설정(`supabase.auth.updateUser`) 및 추가 정보 입력(`/api/auth/profile/complete`) 시 Supabase `profiles` 테이블을 통해 데이터의 일관성 보장. + +--- + +## 4. 비기능적 요구사항 (Non-Functional Requirements) +- **로딩 및 접근 속도**: 카카오톡 인앱 브라우저 진입 환경을 고려하여, 초대 링크의 렌더링 속도(LCP)는 1.5초 이내여야 함. +- **개인정보 및 보안**: 에브리타임 이미지 및 캘린더 연동 시 과목 이름, 세부 내용 등 민감 정보는 저장하지 않으며 오직 '시간 블록 비가용(Blocked)' 상태 데이터로 변환 후 즉시 소멸. +- **DB 정합성**: SQLite 테스트 DB와 실제 빌드 및 배포용 PostgreSQL DB(Supabase) 간의 스키마와 쿼리 호환성(Prisma ORM 기반) 검증 유지. + +--- + +## 5. 개발 및 검증 현황 (Development Status) +- **컴파일 빌드**: Next.js 15 기반 프로덕션 빌드 검증 성공 (`npm run build`). +- **테스트 커버리지**: 총 25개 테스트 파일의 222개 Vitest 유닛 테스트 전원 통과 (`npm run test`). +- **인증 모듈 안전성**: 기존의 수동 암호 해싱, 수동 JWT 토큰 및 콜백 라우트를 완전히 제거하고 Supabase Auth 단일 모듈로의 마이그레이션이 커밋에 완벽히 반영됨. diff --git a/docs/v1/provided-documents-summary.md b/docs/v1/provided-documents-summary.md new file mode 100644 index 0000000..03b45e4 --- /dev/null +++ b/docs/v1/provided-documents-summary.md @@ -0,0 +1,198 @@ +# MOIM 제공 문서 정리 + +작성일: 2026-05-08 + +## 정리 기준 + +이 문서는 현재 `C:\Users\kksu1\Dev\MOIM` 작업트리에서 확인 가능한 사용자 제공/핵심 문서와, 이전 작업에서 다룬 PRD 리뷰 맥락을 한곳에 모은 요약본이다. + +제외한 항목: + +- `node_modules`, `.next`, `.git`, `.claude/worktrees`, 테스트 결과물처럼 생성되거나 중복된 작업 디렉터리 +- 실제 비밀값, 토큰, 개인 인증 정보 + +현재 확인한 결론: + +- `docs/`에는 프로토타입 설계/실행 계획 문서는 있었지만, 사업계획서 DOCX와 PRD 리뷰 맥락까지 한 번에 볼 수 있는 통합 정리본은 없었다. +- 루트의 `MOIM_2026_학생창업유망팀300_사업계획서.docx`는 별도 Markdown 요약으로 정리되어 있지 않았다. +- `.agents/docs/`에는 프로젝트 컨텍스트와 실행 계획이 있으나, 일부 콘솔 출력에서 한글이 깨져 보일 수 있고 `docs/` 하위의 일반 문서로 모여 있지는 않았다. +- `.agents/docs/3_FILE_INDEX.md`에는 `PRD.md` / `PRD.pdf`가 문서 항목으로 언급되지만, 현재 작업트리 루트에서는 해당 파일을 확인하지 못했다. + +## 문서 목록 + +| 문서 | 현재 위치 | 성격 | 상태 | +| ----------------------------------- | --------------------------------------------- | ---------------------------- | ----------------------- | +| 학생창업유망팀 300+ 사업계획서 | `MOIM_2026_학생창업유망팀300_사업계획서.docx` | 제출용 사업계획서 | 원본 DOCX 존재 | +| 프로젝트 아키텍처 가이드 | `ARCHITECTURE.md` | 개발 구조 설명 | 원본 MD 존재 | +| 협업 컨벤션 가이드 | `convention.md` | 커밋, 브랜치, 이슈, PR 규칙 | 원본 MD 존재 | +| 도메인 컨텍스트 | `.agents/docs/1_DOMAIN_CONTEXT.md` | 에이전트용 프로젝트 맥락 | 수정 중 | +| 실행 계획 | `.agents/docs/2_EXECUTION_PLAN.md` | Phase별 구현 로드맵 | 수정 중 | +| 파일 인덱스 | `.agents/docs/3_FILE_INDEX.md` | 파일 역할 지도 | 원본 MD 존재 | +| 제품 사용자 흐름 문서 | `docs/v1/user-flow.md` | 게스트 퍼스트 실제 제품 흐름 | 원본 MD 존재 | +| 개발 현황 인수인계 | `docs/v1/codex-work-context.md` | 현재 개발 상태와 검증 기준 | 원본 MD 존재 | +| AI calendar assistant PRD 리뷰 맥락 | 현재 원본 파일 미확인 | 제품/수익화/범위 리뷰 | 메모리 기반 맥락만 확인 | + +## 사업계획서 핵심 내용 + +MOIM은 대학생 팀 활동에서 반복되는 일정 조율 문제를 줄이기 위한 AI 캠퍼스 모임 비서다. 사용자는 모임 링크를 만들고, 참여자는 캘린더 연동, 에브리타임 시간표 이미지, 직접 입력 중 편한 방식으로 가능한 시간을 제출한다. 서비스는 각 입력을 통합해 공통 가능 시간을 찾고, 확정 일정을 캘린더에 반영하는 흐름을 목표로 한다. + +핵심 문제: + +- 조별과제, 동아리, 스터디, 공모전 팀에서 카카오톡 대화와 시간표 캡처를 반복 비교해야 한다. +- 수업 시간은 에브리타임에, 개인 일정은 캘린더에 분리되어 있어 일반 캘린더만으로는 빈 시간을 찾기 어렵다. +- 확정 후 각자가 캘린더에 다시 입력해야 해 누락과 지각 가능성이 생긴다. + +핵심 해결 방향: + +- 링크 기반 모임 생성과 카카오톡 공유로 첫 진입 부담을 낮춘다. +- 캘린더 연동을 강제하지 않고, 시간표 이미지 업로드와 직접 입력을 함께 제공한다. +- “가능한 시간”과 “막힌 시간”을 통합해 공통 가능 시간대를 계산한다. +- 확정된 일정은 캘린더 연동 사용자에게 자동 반영하고, 비연동 사용자에게는 추가 가능한 버튼 또는 파일을 제공한다. + +초기 목표 시장: + +- 국내 대학생 중 조별과제, 동아리, 스터디, 공모전, 팀 프로젝트를 자주 수행하는 사용자 +- 팀 활동이 많은 전공, 창업/공모전 팀, 동아리 운영진 +- 카카오톡 단체방 중심으로 일정을 조율하는 사용자 + +사업화 방향: + +- 설치 부담이 낮은 웹 기반 MVP로 시작한다. +- 호스트의 모임 생성, 공유 링크 발급, 참여자의 일정 입력/연동 선택, 공통 가능 시간 확인, 일정 확정 흐름을 먼저 검증한다. +- 외부 API가 완성되기 전에는 더미 일정 데이터로 사용자 흐름과 화면 구조를 검증한다. +- 설문 수치가 충분하지 않은 단계에서는 과장된 수치를 쓰지 않고, 기존 방식과 MOIM 방식의 비교 흐름을 그림으로 보여준다. + +성장 전략: + +- 소속 대학 내 조별과제와 동아리 일정 조율처럼 작은 사용 사례부터 시작한다. +- 한 명이 모임 링크를 만들면 여러 참여자가 동시에 경험하는 공유 구조를 확산 경로로 쓴다. +- 초기 검증 이후 대학 내 조직, 기관, 면접/미팅 조율, 기업용 스케줄링으로 확장 가능성을 검토한다. + +자금/운영 방향: + +- 초기에는 무료 개발 도구와 팀 보유 장비를 활용해 최소 비용으로 검증한다. +- 지원금이 생기면 사용자 테스트, 개인정보/보안 검토, 서버 안정화, 프로토타입 개선에 우선 투입한다. +- 유료 기능은 반복 사용과 문제 해결력이 확인된 뒤 실험한다. + +## PRD/제품 리뷰에서 남은 판단 + +현재 원본 PRD 파일은 작업트리에서 확인되지 않았지만, 이전 리뷰 맥락상 다음 판단이 중요하게 남아 있다. + +- 강점은 한국 대학생의 실제 일정 조율 방식에 맞춘 구체성이다. `Everytime`, `KakaoTalk Share`, 모바일 링크 진입, 캘린더 재입력 문제를 제품 흐름에 반영해야 한다. +- MVP는 너무 많은 자동화와 연동을 한 번에 담으면 위험하다. 첫 검증은 “모임 링크 생성 → 참여자 가능 시간 제출 → 공통 가능 시간 확인”에 집중하는 편이 낫다. +- 수익화는 참여자에게 과금을 걸기보다, 동아리/팀/운영진/관리자에게 반복 일정 관리 편의성을 제공하는 방향이 자연스럽다. +- 검증 지표는 단순 MAU보다 활성화 퍼널을 봐야 한다. 예: 링크 생성률, 초대 링크 클릭률, 참여자 제출 완료율, 공통 시간 도출률, 확정까지 걸린 시간. +- Kakao 공유나 캘린더 반영은 플랫폼 제약을 과장하면 안 된다. 완전 자동화가 확실하지 않은 경우 “사용자 주도 공유/추가” 표현을 유지해야 한다. + +## 프로토타입 설계 문서 핵심 + +### 2026-04-27 사용자 테스트용 프로토타입 + +목적은 최종 제품 UI가 아니라 사용자 테스트다. 처음 쓰는 사용자가 MOIM의 가치를 이해하고, 두 핵심 여정을 혼란 없이 완료하는지 검증한다. + +핵심 여정: + +- 주최자가 모임을 만들고 초대 링크를 공유한다. +- 참여자가 초대 링크를 열고 가능 시간을 제출한다. + +주요 결정: + +- 로그인은 첫 관문이 아니다. 사용자가 먼저 가치를 경험한 뒤, 재방문 편의성이 보이는 순간에 계정 저장을 제안한다. +- 링크 생성 또는 가능 시간 제출 직전에는 작고 닫을 수 있는 저장 안내를 보여준다. +- 완료 후에는 더 명확한 계정 저장 제안을 보여준다. +- 모바일과 데스크톱을 모두 1급 화면으로 다룬다. +- 실제 백엔드 없이 단일 HTML/JS 프로토타입의 시뮬레이션 상태로 구현한다. + +필수 상태: + +- 캘린더 권한 거부와 회복 +- 스케줄 캡처 분석 중/실패 +- 연동 없이 계속하기 +- 참여자 미응답 +- 후보 시간 겹침 없음 +- 계정 저장 제안 닫기 + +### 2026-04-28 역할 기반 프로토타입 + +목적은 팀 리뷰와 사용자 테스트에서 하나의 HTML 파일로 네 관점을 바로 확인하게 하는 것이다. + +역할: + +- 게스트로 보기 +- 로그인 유저로 보기 +- 모임 만드는 사람 +- 초대받아 참여하는 사람 + +핵심 변경: + +- 첫 화면을 역할 선택 허브로 바꾼다. +- 게스트 화면은 로그인 장벽 없이 가치를 설명한다. +- 로그인 유저 화면은 저장된 캘린더, 기본 설정, 최근 모임, 빠른 액션을 보여준다. +- 주최자 흐름은 모임 생성, 링크 공유, 실시간 현황, 추천 시간 확정, 최종 결과까지 포함한다. +- 참여자 흐름은 초대 요약, 캘린더/사진/직접 입력, 가능 시간 검토, 제출, 완료 화면을 포함한다. + +테스트 요구: + +- Playwright로 역할 허브, 게스트 경로, 로그인 유저 경로, 주최자 경로, 참여자 경로, 모바일 CTA 가시성을 검증한다. +- 한국어 문구는 깨지지 않는 UTF-8 텍스트여야 한다. + +## 개발 문서 핵심 + +### 아키텍처 + +기술 스택: + +- `Next.js 14` +- `React` +- `TypeScript` +- `Tailwind CSS v3` +- `shadcn/ui` +- `Vitest` +- `Playwright` +- `Supabase` +- `Vercel` + +구조 원칙: + +- `src/app`은 사용자 플로우 기반 라우팅을 담당한다. +- `src/components/ui`는 범용 UI를 담당하고, 현재 스케줄 화면 UI는 `src/app/schedule/*` 라우트 아래에 둔다. +- `src/lib/scheduling`은 React와 분리된 순수 함수 중심의 핵심 로직 영역이다. +- 테스트는 구현 파일 가까이에 두는 코로케이션 방식을 쓴다. +- `Unit`은 핵심 로직, `Integration`은 컴포넌트 상호작용, `E2E`는 사용자 전체 플로우를 맡는다. + +### 협업 컨벤션 + +- 커밋 메시지는 `feat:`, `fix:`, `docs:`, `refactor:`, `design:`, `chore:` 같은 Conventional Commit 타입을 쓴다. +- `main`은 배포 브랜치, `dev`는 개발 통합 브랜치, 기능 작업은 `feature/이슈번호-기능명` 브랜치에서 진행한다. +- 작업 전 GitHub Issue를 만들고, PR에서 관련 이슈를 연결한다. +- PR에는 작업 내용, 변경 이유, 체크리스트, 관련 이슈를 남긴다. +- CI는 lint, test, build, 템플릿 준수 검사를 수행한다. +- 비밀값은 `.env`에 두고, 공유용 껍데기는 `.env.example`에만 둔다. + +## 현재 구현/계획 상태와 연결 + +현재 문서 기준으로 보면 제품 방향은 크게 세 줄로 정리된다. + +1. 사업계획서: 대학생 일정 조율 문제와 시장/사업화 논리 +2. 프로토타입 설계: 사용자 테스트에서 검증할 화면과 여정 +3. 개발 문서: Next.js 기반 구현 구조와 협업 규칙 + +현재 작업트리의 진행상황과 연결하면 다음과 같다. + +- `.agents/docs/2_EXECUTION_PLAN.md`는 Phase 4를 캘린더 연동 구현 진행 중으로 보고 있다. +- 별도 worktree에는 `feature/15-availability-aggregation` 브랜치의 캘린더 통합 가용시간 산출 프레임워크가 존재한다. +- `feat/prototype-user-test` 브랜치에는 프로토타입 사용자 테스트 관련 문서와 계획이 남아 있다. + +## 남은 문서 정리 이슈 + +- 원본 `PRD.md` 또는 `PRD.pdf`가 실제로 있었다면 현재 작업트리에 없다. 필요하면 원본을 다시 넣고 이 정리본에 “요구사항 원문” 섹션을 추가해야 한다. +- `.agents/docs/1_DOMAIN_CONTEXT.md`와 `.agents/docs/2_EXECUTION_PLAN.md`도 콘솔 출력에서 깨져 보였다. 실제 파일 인코딩/내용을 확인하고, 필요하면 `docs/` 하위의 사람이 읽는 문서와 동기화해야 한다. +- 사업계획서에 들어간 비교/효과 내용은 아직 실제 설문 수치 기반이 아니다. 프로토타입 테스트 뒤 평균 조율 시간, 메시지 수, 제출 완료율 같은 실제 수치로 보완해야 한다. + +## 다음에 문서 업데이트할 때 지킬 기준 + +- 학생 창업 문서는 학생다운 현실적인 문장으로 유지한다. +- 검증되지 않은 수치나 “완전 자동화” 표현은 넣지 않는다. +- 참여자에게 로그인/설치를 강제하지 않는 게 핵심 제품 방향이다. +- 캘린더 연동은 유용한 선택지지만, 직접 입력과 사진 업로드 폴백을 항상 함께 둔다. +- 구현 계획은 실제 브랜치/이슈/테스트 상태와 맞춰 갱신한다. diff --git a/docs/v1/ui-screen-modification-plan.md b/docs/v1/ui-screen-modification-plan.md new file mode 100644 index 0000000..0e942d3 --- /dev/null +++ b/docs/v1/ui-screen-modification-plan.md @@ -0,0 +1,52 @@ +# UI/UX 화면 보완 및 모바일 최적화 구현 계획서 (docs/MOIM_role_screen 시안 기반) + +본 계획서는 `docs/MOIM_role_screen` 폴더 내의 20개 모바일/데스크톱 화면 시안과 현재 Next.js 애플리케이션 구현 상태 간의 갭(Gap)을 분석하고, 시안에 일치하는 모바일 최적화 화면 및 추가 페이지 구현 계획을 수립합니다. + +--- + +## 1. 현황 및 갭 분석 (Gap Analysis) + +현재 Next.js 기반 실제 프로젝트는 핵심 모임 개설 및 드래그 가용시간 수집, Supabase 연동 부근의 API와 유닛 테스트가 견고하게 완료되었으나, 기획된 UI 시안(20개 스크린) 대비 아래와 같은 세부 화면 및 유저 동선 상의 공백이 있습니다. + +| 시안 범위 | 시안 명칭 | 현재 구현 상태 및 갭 (Gap) | +|---|---|---| +| **게스트 진입 및 소개**
(01~05) | - 01_guest_role_hub
- 02_guest_value_landing
- 03_guest_action_choice
- 04_guest_save_later_prompt
- 05_guest_signed_in_comparison | - `page.tsx`에 소개 페이지가 존재하나, 테스트용 'Role Hub'(`01`)는 Next.js 앱에 없음.
- 모임 완료 후 계정 저장 넛지(`04`)가 일부 UI에만 간단한 텍스트로 존재하며, 시안 형태의 모달/배너 컴포넌트로 규격화되어 있지 않음. | +| **로그인 사용자 페이지**
(06~10) | - 06_signed_in_home
- 07_signed_in_calendar_settings
- 08_signed_in_default_settings
- 09_signed_in_recent_meetings
- 10_signed_in_profile | - **현재 Next.js 앱 내 대시보드/워크스페이스 관련 라우트가 전무함** (가장 큰 기능 공백).
- 로그인 완료 시 갈 수 있는 마이페이지, 설정(연동 캘린더 관리, 프로필 수정 등)이 구현되어야 함. | +| **호스트 모임 생성**
(11~15) | - 11_host_create_meeting
- 12_host_calendar_setup
- 13_host_candidate_times
- 14_host_share_link
- 15_host_status_board | - `schedule/create`에서 요일 선택과 시작/종료 드롭다운만 제공됨. 시안 `13`과 같이 비주얼 그리드에서 후보 시간을 칠하는 기능이 없음.
- 호스트 전용 결과 화면(`15`)이 `ScheduleRoomClient.tsx`와 혼재되어 있어, 호스트 대시보드 성격의 레이아웃 분리 및 모바일 카드 형태의 응답 리스트가 보완되어야 함. | +| **참여자 가용시간 입력**
(16~20) | - 16_participant_invite_entry
- 17_participant_calendar_detected
- 18_participant_upload_recovery
- 19_participant_time_grid
- 20_participant_done_result | - `/schedule/[id]` 참여자 뷰에서 이미지 파일 업로드 분석 에러 및 대기 모달 UI(`18`)가 미흡함.
- 모바일 환경의 터치 스크롤 중 시간 드래그 입력이 튀는 현상 해결 및 모바일 최적화 반응형 튜닝 필요. | + +--- + +## 2. 구체적인 수정 및 구현 계획 (Proposed Changes) + +### Phase 1: 개발자/테스터 편의를 위한 개발용 허브 개설 +- **[NEW]** `/dev-hub/page.tsx` + - 시안 `01` (Role Hub)을 Next.js 라우트로 포팅하여 테스터가 게스트, 로그인 유저, 주최자, 참여자 뷰의 상태를 바로 테스트할 수 있는 제어판 구현. + +### Phase 2: 회원 대시보드 & 마이페이지 신설 +- **[NEW]** `/dashboard/layout.tsx` (네비게이션바 및 사이드바 공통 레이아웃) +- **[NEW]** `/dashboard/page.tsx` (시안 `06` 대시보드 홈 - 연동 현황, 빠른 모임 생성 CTA) +- **[NEW]** `/dashboard/meetings/page.tsx` (시안 `09` 최근/과거 진행 모임 리스트) +- **[NEW]** `/dashboard/settings/page.tsx` (시안 `07`, `08`, `10` 통합 설정 - 프로필 정보, 연동 캘린더 관리, 기본 가용 조건 설정) + +### Phase 3: 호스트 생성 단계 시각화 개선 +- **[MODIFY]** [CreateScheduleClient.tsx](file:///c:/Users/kksu1/Dev/MOIM/src/app/schedule/create/CreateScheduleClient.tsx) + - 요일/시간 설정 단계를 시안 `13`과 같은 격자형 시간 선택 컴포넌트로 개편하여 직관적으로 가능 요일/시간대를 칠할 수 있도록 개선. + - 링크 완료 화면에 모임 저장 넛지(`04`) 디자인 컴포넌트 추가 적용. + +### Phase 4: 참여자/호스트 결과 화면 고도화 및 모바일 튜닝 +- **[MODIFY]** [ScheduleRoomClient.tsx](file:///c:/Users/kksu1/Dev/MOIM/src/app/schedule/[id]/ScheduleRoomClient.tsx) + - 모바일 터치 이벤트 기반 시간 선택 최적화 (스크롤 감지 간섭 최소화). + - 참여자 입력 전 캘린더 감지 화면(`17`) 및 시간표 이미지/ICS 업로드 분석 프로세스 화면(`18`)의 로딩 애니메이션 및 에러 가이드라인 강화. + - 최종 제출 완료 화면(`20`) 구성 및 대시보드 전환 CTA 연동. + +--- + +## 3. 검증 계획 (Verification Plan) + +### 수동 검증 및 시각 디자인 체크 +- 모바일 가상 기기(Chrome DevTools Device Mode - iPhone 12 Pro / SE 등)로 각 화면을 기동하여 시안 `docs/MOIM_role_screen/`의 모바일 가시성 비율과 일치하는지 비교. +- 카카오톡 인앱 브라우저 환경에서 발생할 수 있는 시간표 드래그 동작이 스크롤 이벤트와 충돌하지 않는지 확인. + +### 자동화 테스트 추가 +- Playwright E2E 테스트(`e2e/`)에 회원 가입 후 `/dashboard` 접근성 및 시간 설정 변경 저장 시나리오 추가 구현. diff --git a/docs/v1/user-flow.md b/docs/v1/user-flow.md new file mode 100644 index 0000000..be2a8ae --- /dev/null +++ b/docs/v1/user-flow.md @@ -0,0 +1,323 @@ +# MOIM 사용자 흐름 문서 + +작성일: 2026-05-08 + +## 결론 + +MOIM의 실제 제품 흐름은 **게스트 퍼스트 + 초대 링크 문맥 우선 흐름**이 가장 적합하다. + +즉, 사용자가 어떤 경로로 들어왔는지에 따라 첫 화면을 다르게 보여준다. + +- `/join/:id`처럼 초대 링크로 들어온 사용자는 곧바로 참여자 흐름으로 보낸다. +- 일반 홈으로 들어온 사용자는 `모임 만들기`와 `초대 링크로 참여`를 먼저 보여준다. +- 로그인, 회원가입, 캘린더 저장은 첫 관문이 아니라 사용자가 가치를 경험한 뒤 제안한다. + +사용자 테스트용 프로토타입에서는 `게스트`, `로그인 유저`, `주최자`, `참여자`를 바로 고를 수 있는 역할 허브가 유용하다. 다만 이 역할 허브는 테스트 장치이지, 실제 제품 첫 화면으로 쓰면 안 된다. + +## 전체 흐름 + +```mermaid +flowchart TD + A["사용자 진입"] --> B{"진입 경로"} + B -->|"초대 링크 /join/:id"| C["참여자 초대 화면"] + B -->|"일반 홈 /"| D["게스트 홈"] + B -->|"로그인 재방문"| E["내 MOIM 대시보드"] + + D --> F["모임 만들기"] + D --> C + E --> F + E --> G["최근 모임 현황 보기"] + + F --> H["모임 기본 정보 입력"] + H --> I["캘린더/시간표 반영 방식 선택"] + I --> J["후보 날짜와 시간 범위 선택"] + J --> K["초대 링크 생성"] + K --> L["KakaoTalk 등으로 공유"] + L --> G + + C --> M["모임 요약 확인"] + M --> N["가능 시간 입력 방식 선택"] + N --> O["가능 시간 검토/수정"] + O --> P["가능 시간 제출"] + P --> Q["제출 완료 및 선택적 계정 저장"] + + G --> R["응답 현황 확인"] + R --> S["추천 시간 확인"] + S --> T["모임 시간 확정"] + T --> U["캘린더 추가 / 확정 카드 공유"] +``` + +## 실제 제품 권장 흐름 + +### 1. 일반 홈 진입 + +목표는 사용자가 MOIM의 가치를 5초 안에 이해하고 바로 행동하게 만드는 것이다. + +화면 구성: + +- 브랜드: `MOIM` +- 한 줄 가치: `모두의 빈 시간을 링크 하나로 찾기` +- 주요 CTA: `모임 만들기` +- 보조 CTA: `초대 링크로 참여` +- 작은 안내: `로그인하면 다음 모임은 더 빠르게 시작할 수 있어요` + +로그인 버튼은 첫 화면의 중심이 되면 안 된다. MOIM은 계정 서비스가 아니라 일정 조율 문제를 해결하는 도구이기 때문이다. + +### 2. 주최자 흐름 + +주최자는 “회의를 만들고 링크를 보내는 사람”이다. 이 사용자는 빠르게 공유 가능한 링크를 얻고 싶어 한다. + +권장 순서: + +1. `모임 만들기` 선택 +2. 모임 기본 정보 입력 +3. 캘린더/시간표 반영 방식 선택 +4. 후보 날짜와 시간 범위 선택 +5. 초대 링크 생성 +6. 링크 공유 +7. 실시간 현황 확인 +8. 추천 시간 확인 +9. 시간 확정 +10. 확정 일정 공유 또는 캘린더 추가 + +모임 기본 정보: + +- 모임 이름 +- 예상 소요 시간 +- 예상 참여 인원 +- 모임 목적 + +캘린더/시간표 반영 방식: + +- Google Calendar 연동 +- Apple/iCloud Calendar 연동 +- 시간표 이미지 업로드 +- `.ics` 파일 업로드 +- 연동 없이 직접 입력 또는 건너뛰기 + +중요한 원칙: + +- 캘린더 연동은 유용한 선택지지만 필수 조건이 아니다. +- 권한 거부, 연동 실패, 사진 분석 실패가 있어도 링크 생성까지 갈 수 있어야 한다. +- 링크 생성 전 회원가입을 요구하면 공유가 늦어지고 이탈 가능성이 커진다. + +### 3. 참여자 흐름 + +참여자는 “초대 링크를 받고 가능한 시간을 알려주는 사람”이다. 이 사용자는 보통 KakaoTalk 같은 모바일 맥락에서 들어온다. + +권장 순서: + +1. 초대 링크 열기 +2. 모임 요약 확인 +3. 가능한 시간 입력 방식 선택 +4. 가능 시간 검토/수정 +5. 제출 +6. 제출 완료 확인 +7. 선택적으로 계정 저장 + +초대 화면에서 보여줄 정보: + +- 모임 이름 +- 주최자 +- 예상 소요 시간 +- 후보 날짜 수 +- 응답 마감 +- `로그인 없이 제출 가능` 안내 + +가능 시간 입력 방식: + +- `직접 입력`: 가장 안전한 기본 경로 +- `캘린더 연동`: 재방문 사용자나 일정이 많은 사용자에게 빠른 경로 +- `시간표 사진 업로드`: Everytime 시간표를 쓰는 대학생에게 익숙한 경로 + +중요한 원칙: + +- 참여자는 회원가입 때문에 멈추면 안 된다. +- 제출 전에는 감지된 시간 또는 선택한 시간을 직접 수정할 수 있어야 한다. +- 색상만으로 가능/불가능을 전달하지 말고, 텍스트 라벨을 함께 둔다. +- 복잡한 히트맵 이해를 요구하지 않고도 제출할 수 있어야 한다. + +### 4. 로그인/계정 저장 흐름 + +로그인은 독립 목표가 아니라 재방문 편의를 높이는 장치다. + +권장 제안 타이밍: + +- 1순위: 링크 생성 완료 후 +- 1순위: 가능 시간 제출 완료 후 +- 2순위: 캘린더 연동을 마친 뒤 다음에도 유지할 가치가 명확할 때 +- 신중히 사용: 링크 생성 직전 또는 제출 직전의 작은 저장 안내 + +문구 방향: + +- 나쁜 방향: `로그인해야 계속할 수 있어요` +- 좋은 방향: `저장하면 다음 모임은 캘린더 연동 없이 바로 시작할 수 있어요` + +## 이 흐름이 좋은 이유 + +### 1. 사용자의 실제 상황과 맞다 + +MOIM의 핵심 사용 상황은 카카오톡 단체방에서 링크를 주고받는 것이다. 참여자가 초대 링크를 눌렀는데 홈이나 로그인 화면을 먼저 보면 맥락이 끊긴다. 초대 링크는 바로 참여자 화면으로 연결되어야 한다. + +### 2. 첫 사용 장벽이 낮다 + +대학생 팀 활동 도구는 “한 번 써볼까?”의 장벽이 낮아야 한다. 앱 설치, 회원가입, 캘린더 권한 허용을 앞에 두면 사용자는 가치를 보기 전에 멈춘다. + +### 3. 주최자의 성공 기준이 빠르다 + +주최자는 멋진 대시보드보다 초대 링크를 빨리 만드는 것이 먼저다. 링크가 생성되어야 참여자가 들어오고, 참여자가 들어와야 MOIM의 자동 조율 가치가 드러난다. + +### 4. 참여자의 부담을 줄인다 + +참여자는 서비스에 대한 소유감이 낮다. 그래서 `로그인 없이 제출 가능`, `직접 입력 가능`, `나중에 저장 가능` 같은 장치가 필요하다. 이 흐름은 참여자가 권한 허용이나 계정 생성 없이도 역할을 끝낼 수 있게 한다. + +### 5. 개인정보 불안을 줄인다 + +캘린더 연동은 민감하게 느껴질 수 있다. 직접 입력과 사진 업로드 폴백이 있으면 사용자는 “연동을 해야만 쓰는 서비스”가 아니라 “내가 편한 방식으로 응답할 수 있는 서비스”로 받아들인다. + +### 6. 제품 확산 구조와 맞다 + +한 명의 주최자가 링크를 만들면 여러 참여자가 서비스를 경험한다. 계정 생성보다 링크 공유를 먼저 성공시키는 흐름이 MOIM의 자연 확산에 더 유리하다. + +## 더 나은 사용자 경험을 위한 제안 + +### 제안 1. 참여자 화면에서는 `직접 입력`을 기본 안전 경로로 둔다 + +참여자에게 처음부터 세 가지 선택지를 같은 무게로 보여주면 고민이 생길 수 있다. 더 편한 방식은 다음과 같다. + +- 기본 CTA: `직접 선택해서 30초 안에 제출` +- 보조 CTA: `캘린더로 빠르게 채우기` +- 보조 CTA: `시간표 사진으로 채우기` + +이렇게 하면 권한 허용이 부담스러운 사용자도 바로 진행할 수 있고, 자동화를 원하는 사용자는 더 빠른 경로를 선택할 수 있다. + +### 제안 2. 재방문 사용자는 대시보드보다 빠른 액션을 먼저 보여준다 + +로그인 사용자가 들어왔을 때도 대시보드 정보가 너무 많으면 느려 보일 수 있다. + +권장 위계: + +1. `바로 모임 만들기` +2. `최근 모임 현황 보기` +3. 연결된 캘린더와 기본 설정 상태 + +계정의 가치는 정보 나열이 아니라 “다음 행동이 빨라지는 것”으로 보여주는 편이 좋다. + +### 제안 3. 링크 생성 전 저장 안내는 MVP에서는 생략해도 된다 + +기존 설계에는 링크 생성 직전과 제출 직전에 작은 저장 안내가 있다. 하지만 MVP 첫 테스트에서는 이 안내도 마찰이 될 수 있다. + +더 단순한 MVP 대안: + +- 링크 생성 전에는 저장 안내를 보여주지 않는다. +- 가능 시간 제출 전에도 저장 안내를 보여주지 않는다. +- 완료 후에만 계정 저장을 제안한다. + +이 대안은 전환율을 더 깨끗하게 측정하기 좋다. 사용자가 핵심 행동을 끝낸 뒤에 저장 제안을 보기 때문에, 흐름 중간 이탈 원인을 줄일 수 있다. + +추천은 다음과 같다. + +- 사용자 테스트 1차: 완료 후 저장 제안만 사용 +- 사용자 테스트 2차: 완료 전 작은 저장 안내를 A/B로 비교 + +### 제안 4. `초대 링크로 참여`는 홈보다 URL 직접 진입을 우선한다 + +실제 사용에서는 참여자가 홈에서 초대 코드를 입력하는 경우보다, 카카오톡 링크를 직접 누르는 경우가 많다. + +따라서 홈의 `초대 링크로 참여`는 보조 기능으로 두고, 핵심 설계는 `/join/:id` 직접 진입에 맞춘다. + +### 제안 5. 시간 확정은 주최자에게만 맡기되, 추천 이유를 보여준다 + +MOIM이 바로 시간을 자동 확정하면 사용자가 통제감을 잃을 수 있다. 더 좋은 흐름은 AI가 추천하고 주최자가 확정하는 방식이다. + +추천 시간 화면에는 다음 정보를 함께 보여준다. + +- 몇 명이 가능한지 +- 누가 아직 미응답인지 +- 대안 시간이 무엇인지 +- 캘린더 충돌이 있는지 + +이 방식은 자동화의 편리함과 주최자의 통제감을 함께 준다. + +## 테스트 프로토타입 흐름과 실제 제품 흐름의 차이 + +역할 기반 프로토타입의 첫 화면은 `게스트`, `로그인 유저`, `모임 만드는 사람`, `초대받아 참여하는 사람`을 고르는 허브다. + +이 허브는 테스트에는 좋다. + +- 팀원이 각 화면을 빠르게 검토할 수 있다. +- 사용자 테스트에서 특정 역할을 바로 시작할 수 있다. +- 로그인 사용자와 게스트 차이를 한 파일에서 비교할 수 있다. + +하지만 실제 제품 첫 화면에는 맞지 않는다. + +- 일반 사용자는 자신을 `역할`로 고르고 싶어 하지 않는다. +- 초대 링크로 들어온 사용자는 선택 없이 바로 참여하고 싶어 한다. +- 역할 허브는 제품보다 테스트 도구처럼 느껴진다. + +따라서 정리하면 다음과 같다. + +| 상황 | 권장 첫 화면 | +|---|---| +| 실제 제품 `/` | 게스트 퍼스트 홈 | +| 실제 제품 `/join/:id` | 참여자 초대 화면 | +| 로그인 재방문 | 빠른 액션 중심 내 MOIM | +| 사용자 테스트용 HTML | 역할 선택 허브 | + +## MVP에서 먼저 구현할 흐름 + +1차 MVP는 아래 흐름만 제대로 구현해도 충분하다. + +### 주최자 MVP + +1. 모임 기본 정보 입력 +2. 후보 날짜/시간 선택 +3. 초대 링크 생성 +4. 공유 +5. 응답 현황 확인 +6. 추천 시간 확정 + +### 참여자 MVP + +1. 초대 링크 진입 +2. 모임 요약 확인 +3. 직접 입력으로 가능 시간 선택 +4. 제출 +5. 제출 완료 확인 + +### MVP 이후 추가 + +- Google Calendar 연동 +- iCloud Calendar 연동 +- Everytime 시간표 이미지 분석 +- `.ics` 업로드 +- 재방문 계정 저장 +- 조직/동아리 운영진용 반복 모임 관리 + +## 성공 지표 + +사용자 흐름이 좋은지 판단하려면 다음 지표를 본다. + +- 홈에서 `모임 만들기` 클릭률 +- 모임 생성 시작 후 초대 링크 생성 완료율 +- 초대 링크 클릭 후 가능 시간 제출 완료율 +- 참여자 1명당 제출까지 걸린 시간 +- 모임당 평균 참여자 응답률 +- 공통 가능 시간 도출률 +- 주최자가 추천 시간을 확정하는 비율 +- 완료 후 계정 저장 선택률 + +특히 초기에는 MAU보다 `링크 생성 → 참여자 제출 → 시간 확정` 퍼널이 더 중요하다. + +## 최종 권장안 + +가장 좋은 기본 선택은 다음이다. + +1. 실제 제품은 게스트 퍼스트로 시작한다. +2. 초대 링크는 바로 참여자 화면으로 보낸다. +3. 로그인은 완료 후 저장 제안으로 둔다. +4. 캘린더 연동은 선택지로 제공하되 직접 입력 폴백을 항상 둔다. +5. AI는 시간을 자동 확정하지 않고 추천만 한다. +6. 주최자가 최종 시간을 확정하고, 참여자는 가능한 시간을 빠르게 제출하는 데 집중한다. + +이 흐름이 MOIM의 초기 목표인 “대학생 팀 활동의 일정 조율 시간을 줄이기”에 가장 직접적으로 맞다. diff --git a/e2e/calendar-integration.spec.ts b/e2e/calendar-integration.spec.ts new file mode 100644 index 0000000..68bff80 --- /dev/null +++ b/e2e/calendar-integration.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from "@playwright/test"; +import * as path from "path"; + +test("user can connect and disconnect various calendars on the calendar connect page", async ({ + page, +}) => { + // 컴파일 및 핫 리로드 속도를 고려하여 타임아웃 연장 + test.slow(); + + const testEmail = `cal_test_${Date.now()}@example.com`; + const testPhone = `010-${Math.floor(1000 + Math.random() * 9000)}-${Math.floor(1000 + Math.random() * 9000)}`; + + // 1. 초기 상태 Mocking 설정 (미연동 상태) + let calendarStatusMock = { + googleConnected: false, + googleEmail: "", + icloudConnected: false, + icloudAppleId: "", + }; + + await page.route("**/api/calendar/status", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(calendarStatusMock), + }); + }); + + // 2. 회원가입 진행 + await page.goto("/signup"); + await page.waitForTimeout(2000); + + const emailInput = page.locator("#email"); + await emailInput.waitFor({ state: "visible", timeout: 10000 }); + await emailInput.fill(testEmail); + await expect(emailInput).toHaveValue(testEmail); + + const phoneInput = page.locator("#phoneNumber"); + await phoneInput.fill(testPhone); + await expect(phoneInput).toHaveValue(testPhone); + + const nicknameInput = page.locator("#nickname"); + await nicknameInput.fill(`host_${Date.now().toString().slice(-6)}`); + + const pwInput = page.locator('input[type="password"]').first(); + await pwInput.fill("Test1234!"); + await expect(pwInput).toHaveValue("Test1234!"); + + const pwConfirmInput = page.locator('input[type="password"]').last(); + await pwConfirmInput.fill("Test1234!"); + await expect(pwConfirmInput).toHaveValue("Test1234!"); + + await page.getByRole("checkbox", { name: /만 14세 이상입니다/ }).check(); + await page.getByRole("checkbox", { name: /이용약관/ }).check(); + await page.getByRole("checkbox", { name: /개인정보수집/ }).check(); + await page.getByRole("button", { name: "회원가입" }).click(); + await expect(page.getByText("회원가입 완료")).toBeVisible({ timeout: 20000 }); + + await page.waitForTimeout(2000); + await page.goto("about:blank"); + + // 3. 로그인 진행 + await page.goto("/login"); + await page.waitForTimeout(2000); + + const loginEmailInput = page.locator("#loginId"); + await loginEmailInput.waitFor({ state: "visible", timeout: 10000 }); + await loginEmailInput.fill(testEmail); + + const loginPwInput = page.locator("#password"); + await loginPwInput.fill("Test1234!"); + + await page.getByRole("button", { name: "로그인" }).click(); + await page.waitForURL("**/schedule/create", { timeout: 60000 }); + + // 4. 캘린더 연동 페이지 이동 + await page.goto("/calendar/connect"); + await page.waitForTimeout(2000); + + // 5. 보안 선언 문구가 노출되는지 검증 + await expect(page.getByText("MOIM 개인정보 보호 선언")).toBeVisible(); + + // 6. Google 캘린더 연동 링크 검증 + const googleLink = page.locator('a[href="/api/google/auth"]'); + await expect(googleLink).toBeVisible(); + + // 7. iCloud 캘린더 연동 검증 (Mocking 및 상태 업데이트) + await page.route("**/api/icloud/connect", async (route) => { + // 성공 시 상태 동적 변경 + calendarStatusMock = { + googleConnected: false, + googleEmail: "", + icloudConnected: true, + icloudAppleId: "testuser@icloud.com", + }; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + connectionId: "mock-icloud-conn-id", + principalUrl: "https://principal.icloud.com", + calendarHomeUrl: "https://home.icloud.com", + calendarsCount: 2, + }), + }); + }); + + const appleIdInput = page.locator('input[placeholder="user@icloud.com"]'); + const appPasswordInput = page.locator('input[placeholder="앱 전용 암호"]'); + await appleIdInput.fill("testuser@icloud.com"); + await appPasswordInput.fill("abcd-efgh-ijkl-mnop"); + await page.getByRole("button", { name: "확인" }).click(); + + // 성공 메시지 노출 확인 + await expect( + page.getByText("iCloud 캘린더 연결을 확인했습니다."), + ).toBeVisible({ + timeout: 15000, + }); + + // 연동됨 상태와 연동 해제 버튼 검증 + await expect(page.getByText("testuser@icloud.com (연동됨)")).toBeVisible(); + const disconnectButton = page.getByRole("button", { name: "연동 해제" }); + await expect(disconnectButton).toBeVisible(); + + // 8. iCloud 캘린더 연동 해제 검증 (Disconnect Mocking) + await page.route("**/api/icloud/disconnect", async (route) => { + // 해제 시 상태 원래대로 복구 + calendarStatusMock = { + googleConnected: false, + googleEmail: "", + icloudConnected: false, + icloudAppleId: "", + }; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true }), + }); + }); + + await disconnectButton.click(); + await expect( + page.getByText( + "iCloud 캘린더 연동이 해제되고 모든 데이터가 안전하게 파기되었습니다.", + ), + ).toBeVisible({ timeout: 15000 }); + + // 연동 해제 후 인풋 창 재노출 검증 + await expect(appleIdInput).toBeVisible(); + + // 9. Everytime URL 연동 검증 (Mocking) + await page.route("**/api/everytime/timetable", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + timetable: { + title: "내 시간표", + events: [], + }, + freeSlots: [ + { day: "MON", startHour: 10, endHour: 12 }, + { day: "WED", startHour: 14, endHour: 16 }, + ], + }), + }); + }); + + const everytimeInput = page.locator( + 'input[placeholder="https://everytime.kr/@..."]', + ); + await everytimeInput.fill("https://everytime.kr/@testschedule"); + await page.getByRole("button", { name: "가져오기" }).click(); + + await expect( + page.getByText("Everytime 시간표를 가능한 시간으로 변환했습니다."), + ).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("MON 10:00-12:00")).toBeVisible(); + await expect(page.getByText("WED 14:00-16:00")).toBeVisible(); + + // 10. .ics 파일 업로드 연동 검증 (Mocking) + await page.route("**/api/everytime/timetable", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + timetable: { + title: "ICS 시간표", + events: [], + }, + freeSlots: [{ day: "FRI", startHour: 15, endHour: 17 }], + }), + }); + }); + + const fileInput = page.locator('input[type="file"]'); + const sampleIcsPath = path.resolve(__dirname, "mocks", "sample.ics"); + await fileInput.setInputFiles(sampleIcsPath); + + await expect( + page.getByText("ICS 파일을 가능한 시간으로 변환했습니다."), + ).toBeVisible({ timeout: 15000 }); + await expect(page.getByText("FRI 15:00-17:00")).toBeVisible(); +}); diff --git a/e2e/host-flow.spec.ts b/e2e/host-flow.spec.ts new file mode 100644 index 0000000..5d44ac7 --- /dev/null +++ b/e2e/host-flow.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from "@playwright/test"; + +test("host can create a schedule and receive safe participant and host links", async ({ + page, +}) => { + // 컴파일 및 핫 리로드 속도를 고려하여 타임아웃 연장 + test.slow(); + + const testEmail = `test_${Date.now()}@example.com`; + const testPhone = `010-${Math.floor(1000 + Math.random() * 9000)}-${Math.floor(1000 + Math.random() * 9000)}`; + + // 1. 회원가입 진행 + await page.goto("/signup"); + + // React Hydration 안정화를 위해 페이지 로드 후 대기 + await page.waitForTimeout(2000); + + const emailInput = page.locator("#email"); + await emailInput.waitFor({ state: "visible", timeout: 10000 }); + await emailInput.fill(testEmail); + await expect(emailInput).toHaveValue(testEmail); + + const phoneInput = page.locator("#phoneNumber"); + await phoneInput.fill(testPhone); + await expect(phoneInput).toHaveValue(testPhone); + + const nicknameInput = page.locator("#nickname"); + await nicknameInput.fill(`host_${Date.now().toString().slice(-6)}`); + + const pwInput = page.locator('input[type="password"]').first(); + await pwInput.fill("Test1234!"); + await expect(pwInput).toHaveValue("Test1234!"); + + const pwConfirmInput = page.locator('input[type="password"]').last(); + await pwConfirmInput.fill("Test1234!"); + await expect(pwConfirmInput).toHaveValue("Test1234!"); + + await page.getByRole("checkbox", { name: /만 14세 이상입니다/ }).check(); + await page.getByRole("checkbox", { name: /이용약관/ }).check(); + await page.getByRole("checkbox", { name: /개인정보수집/ }).check(); + await page.getByRole("button", { name: "회원가입" }).click(); + await expect(page.getByText("회원가입 완료")).toBeVisible({ timeout: 20000 }); + + // 리다이렉션 인터럽트 및 클라이언트 상태 안정을 위한 충분한 대기 + await page.waitForTimeout(2000); + + // Webkit 히스토리/라우터 충돌 방지를 위한 컨텍스트 초기화 네비게이션 + await page.goto("about:blank"); + + // 2. 로그인 진행 + await page.goto("/login"); + + // 로그인 페이지 Hydration 안정 대기 + await page.waitForTimeout(2000); + + const loginEmailInput = page.locator("#loginId"); + await loginEmailInput.waitFor({ state: "visible", timeout: 10000 }); + await loginEmailInput.fill(testEmail); + await expect(loginEmailInput).toHaveValue(testEmail); + + const loginPwInput = page.locator("#password"); + await loginPwInput.fill("Test1234!"); + await expect(loginPwInput).toHaveValue("Test1234!"); + + await page.getByRole("button", { name: "로그인" }).click(); + + // 3. 스케줄 생성 + await page.waitForURL("**/schedule/create", { timeout: 60000 }); + + // 페이지 컴파일 및 Hydration 안정을 위해 대기 + await page.waitForTimeout(3000); + + const titleInput = page.getByLabel("모임 제목"); + await titleInput.waitFor({ state: "visible", timeout: 15000 }); + await titleInput.fill("제품 인터뷰"); + await page.getByLabel("소요 시간").selectOption("60"); + + // Step 1 -> Step 2 이동 + await page.getByRole("button", { name: "다음 단계로 →" }).click(); + await page.waitForTimeout(1000); + + // Step 2 -> Step 3 이동 + await page.getByRole("button", { name: "다음 단계로 →" }).click(); + await page.waitForTimeout(1000); + + // Step 3 -> 생성 완료 (초대 링크 만들기) + await page.getByRole("button", { name: "초대 링크 만들기 🚀" }).click(); + + await expect(page.getByText("초대 링크가 준비됐습니다")).toBeVisible(); + + const participantLink = await page + .getByTestId("participant-link") + .inputValue(); + const hostLink = await page.getByTestId("host-link").inputValue(); + + expect(participantLink).toContain("/schedule/"); + expect(participantLink).not.toContain("hostToken="); + expect(hostLink).toContain("/schedule/"); + expect(hostLink).toContain("hostToken="); + + await page.goto(hostLink); + await expect( + page.getByRole("heading", { name: "제품 인터뷰" }), + ).toBeVisible(); + await expect(page.getByText("아직 제출한 참여자가 없습니다")).toBeVisible({ + timeout: 15000, + }); +}); diff --git a/e2e/mocks/sample.ics b/e2e/mocks/sample.ics new file mode 100644 index 0000000..8c74ecd --- /dev/null +++ b/e2e/mocks/sample.ics @@ -0,0 +1,11 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//MOIM//E2E Test//EN +BEGIN:VEVENT +UID:e2e-test-event-1@moim.app +DTSTAMP:20260603T000000Z +DTSTART:20260608T100000 +DTEND:20260608T120000 +SUMMARY:테스트 모임 일정 +END:VEVENT +END:VCALENDAR diff --git a/e2e/participant-flow.spec.ts b/e2e/participant-flow.spec.ts new file mode 100644 index 0000000..b61ba4e --- /dev/null +++ b/e2e/participant-flow.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from "@playwright/test"; + +test("participant can submit availability from an invite link and host can confirm a common slot", async ({ + page, + request, +}) => { + const createResponse = await request.post("/api/schedules", { + data: { + title: "스터디 모임", + durationMinutes: 60, + candidateDays: ["MON"], + candidateStartHour: 10, + candidateEndHour: 13, + }, + }); + expect(createResponse.ok()).toBe(true); + const created = await createResponse.json(); + + await page.goto(created.participantPath, { timeout: 20000 }); + await expect(page.getByRole("heading", { name: "스터디 모임" })).toBeVisible({ + timeout: 20000, + }); + + await page.getByLabel("이름").fill("민지"); + await page.locator('[data-slot-key="MON-10"]').click(); + await page.locator('[data-slot-key="MON-11"]').click(); + await page.getByRole("button", { name: "가능 시간 제출" }).click(); + + await expect(page.getByText("시간 제출 완료! 🎉")).toBeVisible({ + timeout: 15000, + }); + + // 리렌더링 및 브라우저 이벤트 큐가 완전히 비워지도록 충분한 대기 + await page.waitForTimeout(2000); + + await page.goto(created.hostPath); + await expect(page.getByText("민지", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("common-slots").getByText("월요일 10:00-12:00"), + ).toBeVisible(); + + const commonSlots = page.getByTestId("common-slots"); + const targetSlot = commonSlots + .getByRole("listitem") + .filter({ hasText: "월요일 10:00-12:00" }); + await targetSlot.getByRole("button", { name: "이 시간 확정" }).click(); + await expect(page.getByText("시간이 확정되었습니다")).toBeVisible(); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 05e726d..0000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import { defineConfig, globalIgnores } from "eslint/config"; -import nextVitals from "eslint-config-next/core-web-vitals"; -import nextTs from "eslint-config-next/typescript"; - -const eslintConfig = defineConfig([ - ...nextVitals, - ...nextTs, - // Override default ignores of eslint-config-next. - globalIgnores([ - // Default ignores of eslint-config-next: - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ]), -]); - -export default eslintConfig; diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..0a7c9af --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { +}; + +export default nextConfig; diff --git a/next.config.ts b/next.config.ts deleted file mode 100644 index e9ffa30..0000000 --- a/next.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - /* config options here */ -}; - -export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..20d0c03 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13312 @@ +{ + "name": "moim-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "moim-app", + "version": "0.1.0", + "hasInstallScript": true, + "dependencies": { + "@base-ui/react": "^1.4.0", + "@prisma/client": "6.19.3", + "@supabase/ssr": "^0.10.3", + "@supabase/supabase-js": "^2.106.1", + "bcryptjs": "^3.0.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "fast-xml-parser": "^5.5.12", + "jose": "^6.2.3", + "lucide-react": "^1.8.0", + "next": "15.5.18", + "prisma": "6.19.3", + "react": "^18", + "react-dom": "^18", + "shadcn": "^4.2.0", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@commitlint/cli": "^20.5.0", + "@commitlint/config-conventional": "^20.5.0", + "@playwright/test": "^1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/bcryptjs": "^2.4.6", + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "4.1.7", + "autoprefixer": "^10.5.0", + "dotenv": "^17.4.2", + "eslint": "^8", + "eslint-config-next": "15.5.18", + "husky": "^9.1.7", + "jsdom": "^29.0.2", + "lint-staged": "^16.4.0", + "postcss": "8.5.15", + "prettier": "^3.8.2", + "tailwindcss": "^3.4.1", + "typescript": "^5", + "vitest": "4.1.7" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.10", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz", + "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.9.tgz", + "integrity": "sha512-r3ElRr7y8ucyN2KdICwGsmj19RoN13CLCa/pvGydghWK6ZzeKQ+TcDjVdtEZz2ElpndM5jXw//B9CEee0mWnVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", + "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.4.0.tgz", + "integrity": "sha512-QcqdVbr/+ba2/RAKJIV1PV6S02Q5+r6a4Eym8ndBw+ZbBILkkmQAyRxXCg/pArrHnkrGeU8goe26aw0h6eE8pg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.2.7", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.2.7.tgz", + "integrity": "sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@commitlint/cli": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.5.0.tgz", + "integrity": "sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/format": "^20.5.0", + "@commitlint/lint": "^20.5.0", + "@commitlint/load": "^20.5.0", + "@commitlint/read": "^20.5.0", + "@commitlint/types": "^20.5.0", + "tinyexec": "^1.0.0", + "yargs": "^17.0.0" + }, + "bin": { + "commitlint": "cli.js" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-conventional": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.5.0.tgz", + "integrity": "sha512-t3Ni88rFw1XMa4nZHgOKJ8fIAT9M2j5TnKyTqJzsxea7FUetlNdYFus9dz+MhIRZmc16P0PPyEfh6X2d/qw8SA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "conventional-changelog-conventionalcommits": "^9.2.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/config-validator": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.5.0.tgz", + "integrity": "sha512-T/Uh6iJUzyx7j35GmHWdIiGRQB+ouZDk0pwAaYq4SXgB54KZhFdJ0vYmxiW6AMYICTIWuyMxDBl1jK74oFp/Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "ajv": "^8.11.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/ensure": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.5.0.tgz", + "integrity": "sha512-IpHqAUesBeW1EDDdjzJeaOxU9tnogLAyXLRBn03SHlj1SGENn2JGZqSWGkFvBJkJzfXAuCNtsoYzax+ZPS+puw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "lodash.camelcase": "^4.3.0", + "lodash.kebabcase": "^4.1.1", + "lodash.snakecase": "^4.1.1", + "lodash.startcase": "^4.4.0", + "lodash.upperfirst": "^4.3.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/execute-rule": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-20.0.0.tgz", + "integrity": "sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/format": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.5.0.tgz", + "integrity": "sha512-TI9EwFU/qZWSK7a5qyXMpKPPv3qta7FO4tKW+Wt2al7sgMbLWTsAcDpX1cU8k16TRdsiiet9aOw0zpvRXNJu7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/is-ignored": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.5.0.tgz", + "integrity": "sha512-JWLarAsurHJhPozbuAH6GbP4p/hdOCoqS9zJMfqwswne+/GPs5V0+rrsfOkP68Y8PSLphwtFXV0EzJ+GTXTTGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "semver": "^7.6.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/lint": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.5.0.tgz", + "integrity": "sha512-jiM3hNUdu04jFBf1VgPdjtIPvbuVfDTBAc6L98AWcoLjF5sYqkulBHBzlVWll4rMF1T5zeQFB6r//a+s+BBKlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/is-ignored": "^20.5.0", + "@commitlint/parse": "^20.5.0", + "@commitlint/rules": "^20.5.0", + "@commitlint/types": "^20.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/load": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.5.0.tgz", + "integrity": "sha512-sLhhYTL/KxeOTZjjabKDhwidGZan84XKK1+XFkwDYL/4883kIajcz/dZFAhBJmZPtL8+nBx6bnkzA95YxPeDPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^20.5.0", + "@commitlint/execute-rule": "^20.0.0", + "@commitlint/resolve-extends": "^20.5.0", + "@commitlint/types": "^20.5.0", + "cosmiconfig": "^9.0.1", + "cosmiconfig-typescript-loader": "^6.1.0", + "is-plain-obj": "^4.1.0", + "lodash.mergewith": "^4.6.2", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/message": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-20.4.3.tgz", + "integrity": "sha512-6akwCYrzcrFcTYz9GyUaWlhisY4lmQ3KvrnabmhoeAV8nRH4dXJAh4+EUQ3uArtxxKQkvxJS78hNX2EU3USgxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/parse": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.5.0.tgz", + "integrity": "sha512-SeKWHBMk7YOTnnEWUhx+d1a9vHsjjuo6Uo1xRfPNfeY4bdYFasCH1dDpAv13Lyn+dDPOels+jP6D2GRZqzc5fA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/types": "^20.5.0", + "conventional-changelog-angular": "^8.2.0", + "conventional-commits-parser": "^6.3.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/read": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.5.0.tgz", + "integrity": "sha512-JDEIJ2+GnWpK8QqwfmW7O42h0aycJEWNqcdkJnyzLD11nf9dW2dWLTVEa8Wtlo4IZFGLPATjR5neA5QlOvIH1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/top-level": "^20.4.3", + "@commitlint/types": "^20.5.0", + "git-raw-commits": "^5.0.0", + "minimist": "^1.2.8", + "tinyexec": "^1.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/resolve-extends": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.5.0.tgz", + "integrity": "sha512-3SHPWUW2v0tyspCTcfSsYml0gses92l6TlogwzvM2cbxDgmhSRc+fldDjvGkCXJrjSM87BBaWYTPWwwyASZRrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/config-validator": "^20.5.0", + "@commitlint/types": "^20.5.0", + "global-directory": "^4.0.1", + "import-meta-resolve": "^4.0.0", + "lodash.mergewith": "^4.6.2", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/rules": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.5.0.tgz", + "integrity": "sha512-5NdQXQEdnDPT5pK8O39ZA7HohzPRHEsDGU23cyVCNPQy4WegAbAwrQk3nIu7p2sl3dutPk8RZd91yKTrMTnRkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@commitlint/ensure": "^20.5.0", + "@commitlint/message": "^20.4.3", + "@commitlint/to-lines": "^20.0.0", + "@commitlint/types": "^20.5.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/to-lines": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-20.0.0.tgz", + "integrity": "sha512-2l9gmwiCRqZNWgV+pX1X7z4yP0b3ex/86UmUFgoRt672Ez6cAM2lOQeHFRUTuE6sPpi8XBCGnd8Kh3bMoyHwJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/top-level": { + "version": "20.4.3", + "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-20.4.3.tgz", + "integrity": "sha512-qD9xfP6dFg5jQ3NMrOhG0/w5y3bBUsVGyJvXxdWEwBm8hyx4WOk3kKXw28T5czBYvyeCVJgJJ6aoJZUWDpaacQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@commitlint/types": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.5.0.tgz", + "integrity": "sha512-ZJoS8oSq2CAZEpc/YI9SulLrdiIyXeHb/OGqGrkUP6Q7YV+0ouNAa7GjqRdXeQPncHQIDz/jbCTlHScvYvO/gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "conventional-commits-parser": "^6.3.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=v18" + } + }, + "node_modules/@conventional-changelog/git-client": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.7.0.tgz", + "integrity": "sha512-j7A8/LBEQ+3rugMzPXoKYzyUPpw/0CBQCyvtTR7Lmu4olG4yRC/Tfkq79Mr3yuPs0SUitlO2HwGP3gitMJnRFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.4.0" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT", + "peer": true + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz", + "integrity": "sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "^11.1.0", + "dotenv": "^17.2.1", + "eciesjs": "^0.4.10", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "picomatch": "^4.0.2", + "which": "^4.0.0", + "yocto-spinner": "^1.1.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@dotenvx/dotenvx/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/@ecies/ciphers": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", + "integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==", + "license": "MIT", + "engines": { + "bun": ">=1", + "deno": ">=2.7.10", + "node": ">=16" + }, + "peerDependencies": { + "@noble/ciphers": "^1.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@inquirer/core/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.18.tgz", + "integrity": "sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.18.tgz", + "integrity": "sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/@next/eslint-plugin-next/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.18.tgz", + "integrity": "sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.18.tgz", + "integrity": "sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.18.tgz", + "integrity": "sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.18.tgz", + "integrity": "sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.18.tgz", + "integrity": "sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.18.tgz", + "integrity": "sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.18.tgz", + "integrity": "sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.18.tgz", + "integrity": "sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/config/node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/@prisma/config/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@prisma/config/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@prisma/config/node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@prisma/config/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "license": "MIT" + }, + "node_modules/@simple-libs/child-process-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", + "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@simple-libs/stream-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", + "integrity": "sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@supabase/auth-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.1.tgz", + "integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.1.tgz", + "integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", + "integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz", + "integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.1.tgz", + "integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.2", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/ssr": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.3.tgz", + "integrity": "sha512-ux2CJgX89h0Fz2lY7ZNafNG2SkXpyRc5dz77K9eKeBLPdtywQixKwIuetDeIViAJBp/buOUVmgj8PVesOklNpw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.105.3" + } + }, + "node_modules/@supabase/ssr/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.1.tgz", + "integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.106.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.1.tgz", + "integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.106.1", + "@supabase/functions-js": "2.106.1", + "@supabase/postgrest-js": "2.106.1", + "@supabase/realtime-js": "2.106.1", + "@supabase/storage-js": "2.106.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.27.0.tgz", + "integrity": "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.3.3", + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "license": "MIT" + }, + "node_modules/@types/validate-npm-package-name": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", + "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "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 + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.2.tgz", + "integrity": "sha512-dKmJxJsGItLmc5CYZKuEjuG6GnBs6PG4gohMhyFOWKaNQoYCuRZJDECaBlHmcG0lv2wc2E0uU8lESmBEumC3DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/conventional-changelog-angular": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", + "integrity": "sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", + "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/conventional-commits-parser": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-6.4.0.tgz", + "integrity": "sha512-tvRg7FIBNlyPzjdG8wWRlPHQJJHI7DylhtRGeU9Lq+JuoPh5BKpPRX83ZdLrvXuOSu5Eo/e7SzOQhU4Hd2Miuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0", + "meow": "^13.0.0" + }, + "bin": { + "conventional-commits-parser": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cosmiconfig-typescript-loader": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-6.3.0.tgz", + "integrity": "sha512-Akr82WH1Wfqatyiqpj8HDkO2o2KmJRu1FhKfSNJP3K4IdXwHfEyL7MOb62i1AGQVLtIQM+iCE9CGOtrfhR+mmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jiti": "2.6.1" + }, + "engines": { + "node": ">=v18" + }, + "peerDependencies": { + "@types/node": "*", + "cosmiconfig": ">=9", + "typescript": ">=5" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.3.0.tgz", + "integrity": "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eciesjs": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", + "integrity": "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==", + "license": "MIT", + "dependencies": { + "@ecies/ciphers": "^0.2.5", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.7", + "@noble/hashes": "^1.8.0" + }, + "engines": { + "bun": ">=1", + "deno": ">=2", + "node": ">=16" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.336", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", + "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.18.tgz", + "integrity": "sha512-HuoJU6uUPD00eyiud78IBnT4HLhztFj2V+ild2Uon5ZUrYZKe0Olu2QRD99e9IgL4/H1eg5Onka3BsfRW2U0Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.18", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/get-own-enumerable-keys/-/get-own-enumerable-keys-1.0.0.tgz", + "integrity": "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/git-raw-commits": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", + "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@conventional-changelog/git-client": "^2.6.0", + "meow": "^13.0.0" + }, + "bin": { + "git-raw-commits": "src/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-directory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-4.0.1.tgz", + "integrity": "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "4.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", + "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", + "integrity": "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.kebabcase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", + "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.upperfirst": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", + "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.3.tgz", + "integrity": "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.7", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/next": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", + "integrity": "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.18", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.18", + "@next/swc-darwin-x64": "15.5.18", + "@next/swc-linux-arm64-gnu": "15.5.18", + "@next/swc-linux-arm64-musl": "15.5.18", + "@next/swc-linux-x64-gnu": "15.5.18", + "@next/swc-linux-x64-musl": "15.5.18", + "@next/swc-win32-arm64-msvc": "15.5.18", + "@next/swc-win32-x64-msvc": "15.5.18", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nypm": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.1.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-treeify": { + "version": "1.1.33", + "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", + "integrity": "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", + "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.4.0", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.1.0", + "wsl-utils": "^0.3.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "license": "MIT" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prompts/node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rettime": { + "version": "0.11.7", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.7.tgz", + "integrity": "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==", + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shadcn": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.2.0.tgz", + "integrity": "sha512-ZDuV340itidaUd4Gi1BxQX+Y7Ush6BHp6URZBM2RyxUUBZ6yFtOWIr4nVY+Ro+YRSpo82v7JrsmtcU5xoBCMJQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "@modelcontextprotocol/sdk": "^1.26.0", + "@types/validate-npm-package-name": "^4.0.2", + "browserslist": "^4.26.2", + "commander": "^14.0.0", + "cosmiconfig": "^9.0.0", + "dedent": "^1.6.0", + "deepmerge": "^4.3.1", + "diff": "^8.0.2", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "https-proxy-agent": "^7.0.6", + "kleur": "^4.1.5", + "msw": "^2.10.4", + "node-fetch": "^3.3.2", + "open": "^11.0.0", + "ora": "^8.2.0", + "postcss": "^8.5.6", + "postcss-selector-parser": "^7.1.0", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "stringify-object": "^5.0.0", + "tailwind-merge": "^3.0.1", + "ts-morph": "^26.0.0", + "tsconfig-paths": "^4.2.0", + "validate-npm-package-name": "^7.0.1", + "zod": "^3.24.1", + "zod-to-json-schema": "^3.24.6" + }, + "bin": { + "shadcn": "dist/index.js" + } + }, + "node_modules/shadcn/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/shadcn/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", + "integrity": "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-keys": "^1.0.0", + "is-obj": "^3.0.0", + "is-regexp": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/stringify-object?sponsor=1" + } + }, + "node_modules/stringify-object/node_modules/is-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", + "integrity": "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/ts-morph": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-26.0.0.tgz", + "integrity": "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==", + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.27.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.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 + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", + "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yocto-spinner": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.1.0.tgz", + "integrity": "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==", + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json index b073e8c..01f2725 100644 --- a/package.json +++ b/package.json @@ -1,35 +1,77 @@ { - "name": "whentomeet", + "name": "moim-app", "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "predev": "npm run db:migrate", + "dev": "next dev -p 4000", "build": "next build", "start": "next start", - "lint": "eslint" + "db:migrate": "node scripts/ensure-sqlite-schema.mjs", + "lint": "eslint src e2e --ext .ts,.tsx", + "pretest": "npm run db:migrate", + "test": "node scripts/with-database-url.mjs vitest run", + "test:watch": "node scripts/with-database-url.mjs vitest", + "pretest:coverage": "npm run db:migrate", + "test:coverage": "node scripts/with-database-url.mjs vitest run --coverage", + "pretest:e2e": "node scripts/ensure-sqlite-schema.mjs && node scripts/with-database-url.mjs next build", + "test:e2e": "node scripts/with-database-url.mjs playwright test", + "prepare": "husky", + "postinstall": "prisma generate" }, "dependencies": { - "@base-ui/react": "^1.3.0", - "@supabase/ssr": "^0.10.0", - "@supabase/supabase-js": "^2.101.1", + "@base-ui/react": "^1.4.0", + "@prisma/client": "6.19.3", + "@supabase/ssr": "^0.10.3", + "@supabase/supabase-js": "^2.106.1", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^1.7.0", - "next": "16.2.2", - "react": "19.2.4", - "react-dom": "19.2.4", - "shadcn": "^4.1.2", + "fast-xml-parser": "^5.5.12", + "jose": "^6.2.3", + "lucide-react": "^1.8.0", + "next": "15.5.18", + "prisma": "6.19.3", + "react": "^18", + "react-dom": "^18", + "shadcn": "^4.2.0", "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "zod": "^4.3.6" }, "devDependencies": { - "@tailwindcss/postcss": "^4", + "@commitlint/cli": "^20.5.0", + "@commitlint/config-conventional": "^20.5.0", + "@playwright/test": "^1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "16.2.2", - "tailwindcss": "^4", - "typescript": "^5" + "@types/react": "^18", + "@types/react-dom": "^18", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "4.1.7", + "autoprefixer": "^10.5.0", + "dotenv": "^17.4.2", + "eslint": "^8", + "eslint-config-next": "15.5.18", + "husky": "^9.1.7", + "jsdom": "^29.0.2", + "lint-staged": "^16.4.0", + "postcss": "8.5.15", + "prettier": "^3.8.2", + "tailwindcss": "^3.4.1", + "typescript": "^5", + "vitest": "4.1.7" + }, + "overrides": { + "postcss": "8.5.15" + }, + "lint-staged": { + "*.{ts,tsx,js,jsx}": [ + "eslint --fix", + "prettier --write" + ] } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5748f66 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,68 @@ +import { defineConfig, devices } from "@playwright/test"; + +const e2ePort = process.env.PLAYWRIGHT_PORT ?? "3100"; +const baseURL = `http://localhost:${e2ePort}`; + +/** + * Playwright E2E 설정 + * + * E2E(End-to-End) 테스트는 실제 브라우저를 띄워서 사용자 시나리오를 검증한다. + * Python의 Selenium + pytest 조합과 동일한 역할. + * + * 지금은 환경만 세팅하고, 실제 테스트는 UI 페이지가 만들어진 후 작성한다. + */ +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, // CI에서는 .only() 사용 금지 + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + + reporter: "html", + + use: { + baseURL, + trace: "on-first-retry", // 실패 시 트레이스 기록 (디버깅용) + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + { + name: "Mobile Safari", + use: { ...devices["iPhone 13"] }, + }, + ], + + // Next.js 프로덕션 서버를 자동으로 띄워서 테스트 + webServer: { + command: `npm run start -- --port ${e2ePort}`, + url: baseURL, + env: { + PATH: process.env.PATH, + Path: process.env.Path, + SYSTEMROOT: process.env.SYSTEMROOT, + SystemRoot: process.env.SystemRoot, + ComSpec: process.env.ComSpec, + NODE_ENV: process.env.NODE_ENV, + DATABASE_URL: process.env.DATABASE_URL ?? "file:./dev.db", + JWT_SECRET: + process.env.JWT_SECRET ?? + "test_jwt_secret_that_is_long_enough_for_hs256_signing", + NEXT_PUBLIC_BASE_URL: baseURL, + NEXT_PUBLIC_SUPABASE_URL: + process.env.NEXT_PUBLIC_SUPABASE_URL ?? + "https://example-project.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "test-anon-key", + E2E_TEST: "true", + }, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index b32f695..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -1,6249 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@base-ui/react': - specifier: ^1.3.0 - version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@supabase/ssr': - specifier: ^0.10.0 - version: 0.10.0(@supabase/supabase-js@2.101.1) - '@supabase/supabase-js': - specifier: ^2.101.1 - version: 2.101.1 - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - lucide-react: - specifier: ^1.7.0 - version: 1.7.0(react@19.2.4) - next: - specifier: 16.2.2 - version: 16.2.2(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: - specifier: 19.2.4 - version: 19.2.4 - react-dom: - specifier: 19.2.4 - version: 19.2.4(react@19.2.4) - shadcn: - specifier: ^4.1.2 - version: 4.1.2(@types/node@20.19.39)(typescript@5.9.3) - tailwind-merge: - specifier: ^3.5.0 - version: 3.5.0 - tw-animate-css: - specifier: ^1.4.0 - version: 1.4.0 - devDependencies: - '@tailwindcss/postcss': - specifier: ^4 - version: 4.2.2 - '@types/node': - specifier: ^20 - version: 20.19.39 - '@types/react': - specifier: ^19 - version: 19.2.14 - '@types/react-dom': - specifier: ^19 - version: 19.2.3(@types/react@19.2.14) - eslint: - specifier: ^9 - version: 9.39.4(jiti@2.6.1) - eslint-config-next: - specifier: 16.2.2 - version: 16.2.2(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - tailwindcss: - specifier: ^4 - version: 4.2.2 - typescript: - specifier: ^5 - version: 5.9.3 - -packages: - - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-member-expression-to-functions@7.28.5': - resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-replace-supers@7.28.6': - resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.28.6': - resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typescript@7.28.6': - resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@base-ui/react@1.3.0': - resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==} - engines: {node: '>=14.0.0'} - peerDependencies: - '@types/react': ^17 || ^18 || ^19 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - '@types/react': - optional: true - - '@base-ui/utils@0.2.6': - resolution: {integrity: sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==} - peerDependencies: - '@types/react': ^17 || ^18 || ^19 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - '@types/react': - optional: true - - '@dotenvx/dotenvx@1.60.0': - resolution: {integrity: sha512-zWepVRNan/5gCALiT/QCHVsmxvq81xenBqGRyoTUy+ClJ+Mgs+tTJ6h4f65nqs8ijVFe6xg4lIQAIwe+HfgWXg==} - hasBin: true - - '@ecies/ciphers@0.2.6': - resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} - engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} - peerDependencies: - '@noble/ciphers': ^1.0.0 - - '@emnapi/core@1.9.2': - resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - - '@emnapi/runtime@1.9.2': - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@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.21.2': - resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.4': - resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@floating-ui/core@1.7.5': - resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - - '@floating-ui/dom@1.7.6': - resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - - '@hono/node-server@1.19.12': - resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - 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'} - - '@img/colour@1.1.0': - resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} - engines: {node: '>=18'} - - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] - - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] - - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} - - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} - - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@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==} - - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - - '@mswjs/interceptors@0.41.3': - resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} - engines: {node: '>=18'} - - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - - '@next/env@16.2.2': - resolution: {integrity: sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==} - - '@next/eslint-plugin-next@16.2.2': - resolution: {integrity: sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==} - - '@next/swc-darwin-arm64@16.2.2': - resolution: {integrity: sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@16.2.2': - resolution: {integrity: sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-linux-arm64-gnu@16.2.2': - resolution: {integrity: sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-arm64-musl@16.2.2': - resolution: {integrity: sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@next/swc-linux-x64-gnu@16.2.2': - resolution: {integrity: sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-x64-musl@16.2.2': - resolution: {integrity: sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@next/swc-win32-arm64-msvc@16.2.2': - resolution: {integrity: sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@next/swc-win32-x64-msvc@16.2.2': - resolution: {integrity: sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@noble/ciphers@1.3.0': - resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} - engines: {node: ^14.21.3 || >=16} - - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} - - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@nolyfill/is-core-module@1.0.39': - resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} - engines: {node: '>=12.4.0'} - - '@open-draft/deferred-promise@2.2.0': - resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - - '@open-draft/logger@0.3.0': - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - - '@open-draft/until@2.1.0': - resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - - '@sec-ant/readable-stream@0.4.1': - resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} - - '@sindresorhus/merge-streams@4.0.0': - resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} - engines: {node: '>=18'} - - '@supabase/auth-js@2.101.1': - resolution: {integrity: sha512-Kd0Wey+RkFHgyVep7adS6UOE2pN6MJ3mZ32PAXSvfw6IjUkFRC7IQpdZZjUOcUe5pXr1ejufCRgF6lsGINe4Tw==} - engines: {node: '>=20.0.0'} - - '@supabase/functions-js@2.101.1': - resolution: {integrity: sha512-OZWU7YtaG+NNNFZK8p/FuJ6gpq7pFyrG2fLOopP73HAIDHDGpOttPJapvO8ADu3RkqfQfkwrB354vPkSBbZ20A==} - engines: {node: '>=20.0.0'} - - '@supabase/phoenix@0.4.0': - resolution: {integrity: sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==} - - '@supabase/postgrest-js@2.101.1': - resolution: {integrity: sha512-UW1RajH5jbZoK+ldAJ1I6VZ+HWwZ2oaKjEQ6Gn+AQ67CHQVxGl8wNQoLYyumbyaExm41I+wn7arulcY1eHeZJw==} - engines: {node: '>=20.0.0'} - - '@supabase/realtime-js@2.101.1': - resolution: {integrity: sha512-Oa6dno0OB9I+hv5do5zsZHbFu41ViZnE9IWjmkeeF/8fPmB5fWoHGqeTYEC3/0DAgtpUoFJa4FpvzFH0SBHo1Q==} - engines: {node: '>=20.0.0'} - - '@supabase/ssr@0.10.0': - resolution: {integrity: sha512-36jIu+DuKzg5EgA3fnH+zHvwASvpKcL4zPgmHoZaULroS5Q4mzeHcM69zJ0sXUHddO5IcHjQNZJ9Vyhl/DdbRw==} - peerDependencies: - '@supabase/supabase-js': ^2.100.1 - - '@supabase/storage-js@2.101.1': - resolution: {integrity: sha512-WhTaUOBgeEvnKLy95Cdlp6+D5igSF/65yC727w1olxbet5nzUvMlajKUWyzNtQu2efrz2cQ7FcdVBdQqgT9YKQ==} - engines: {node: '>=20.0.0'} - - '@supabase/supabase-js@2.101.1': - resolution: {integrity: sha512-Jnhm3LfuACwjIzvk2pfUbGQn7pa7hi6MFzfSyPrRYWVCCu69RPLCFyHSBl7HSBwadbQ3UZOznnD3gPca3ePrRA==} - engines: {node: '>=20.0.0'} - - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - - '@tailwindcss/node@4.2.2': - resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} - - '@tailwindcss/oxide-android-arm64@4.2.2': - resolution: {integrity: sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.2.2': - resolution: {integrity: sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.2.2': - resolution: {integrity: sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.2.2': - resolution: {integrity: sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - resolution: {integrity: sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - resolution: {integrity: sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} - 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.2.2': - resolution: {integrity: sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - resolution: {integrity: sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.2.2': - resolution: {integrity: sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==} - engines: {node: '>= 20'} - - '@tailwindcss/postcss@4.2.2': - resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} - - '@ts-morph/common@0.27.0': - resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} - - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - - '@types/node@20.19.39': - resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} - - '@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.14': - resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} - - '@types/statuses@2.0.6': - resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - - '@types/validate-npm-package-name@4.0.2': - resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - - '@typescript-eslint/eslint-plugin@8.58.0': - resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.58.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/parser@8.58.0': - resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} - 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.58.0': - resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.58.0': - resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.58.0': - resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/type-utils@8.58.0': - resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} - 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.58.0': - resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.58.0': - resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/utils@8.58.0': - resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} - 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.58.0': - resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} - cpu: [arm] - os: [android] - - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} - cpu: [arm64] - os: [android] - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} - cpu: [arm64] - os: [darwin] - - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} - cpu: [x64] - os: [darwin] - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} - cpu: [x64] - os: [freebsd] - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} - cpu: [arm64] - os: [win32] - - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} - cpu: [ia32] - os: [win32] - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} - cpu: [x64] - os: [win32] - - accepts@2.0.0: - resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} - engines: {node: '>= 0.6'} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} - - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - - ast-types@0.16.1: - resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} - engines: {node: '>=4'} - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axe-core@4.11.2: - resolution: {integrity: sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==} - engines: {node: '>=4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - baseline-browser-mapping@2.10.15: - resolution: {integrity: sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} - engines: {node: '>=18'} - - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - - brace-expansion@5.0.5: - resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - bundle-name@4.1.0: - resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} - engines: {node: '>=18'} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - caniuse-lite@1.0.30001785: - resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - class-variance-authority@0.7.1: - resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - code-block-writer@13.0.3: - resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@11.1.0: - resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} - engines: {node: '>=16'} - - commander@14.0.3: - resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} - engines: {node: '>=20'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} - engines: {node: '>=18'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - cookie@1.1.1: - resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} - engines: {node: '>=18'} - - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cosmiconfig@9.0.1: - resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} - engines: {node: '>=14'} - peerDependencies: - typescript: '>=4.9.5' - peerDependenciesMeta: - typescript: - optional: true - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - dedent@1.7.2: - resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - default-browser-id@5.0.1: - resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} - engines: {node: '>=18'} - - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} - engines: {node: '>=18'} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-lazy-prop@3.0.0: - resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} - engines: {node: '>=12'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - diff@8.0.4: - resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} - engines: {node: '>=0.3.1'} - - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - dotenv@17.4.1: - resolution: {integrity: sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==} - engines: {node: '>=12'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - eciesjs@0.4.18: - resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} - engines: {bun: '>=1', deno: '>=2', node: '>=16'} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - electron-to-chromium@1.5.331: - resolution: {integrity: sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==} - - emoji-regex@10.6.0: - resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - enhanced-resolve@5.20.1: - resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} - engines: {node: '>=10.13.0'} - - env-paths@2.2.1: - resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} - engines: {node: '>=6'} - - error-ex@1.3.4: - resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} - - 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-iterator-helpers@1.3.1: - resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-config-next@16.2.2: - resolution: {integrity: sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==} - peerDependencies: - eslint: '>=9.0.0' - typescript: '>=3.3.1' - peerDependenciesMeta: - typescript: - optional: true - - eslint-import-resolver-node@0.3.10: - resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} - - eslint-import-resolver-typescript@3.10.1: - resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - eslint: '*' - eslint-plugin-import: '*' - eslint-plugin-import-x: '*' - peerDependenciesMeta: - eslint-plugin-import: - optional: true - eslint-plugin-import-x: - optional: true - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-jsx-a11y@6.10.2: - resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - 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 - - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - 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@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@9.39.4: - resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - 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'} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - execa@9.6.1: - resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} - engines: {node: ^18.19.0 || >=20.5.0} - - express-rate-limit@8.3.2: - resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - - express@5.2.1: - resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} - engines: {node: '>= 18'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-glob@3.3.1: - resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} - engines: {node: '>=8.6.0'} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - - figures@6.1.0: - resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} - engines: {node: '>=18'} - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@2.1.1: - resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} - engines: {node: '>= 18.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==} - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fresh@2.0.0: - resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} - engines: {node: '>= 0.8'} - - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - fuzzysort@3.1.0: - resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - get-east-asian-width@1.5.0: - resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} - engines: {node: '>=18'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-own-enumerable-keys@1.0.0: - resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} - engines: {node: '>=14.16'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-stream@9.0.1: - resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} - engines: {node: '>=18'} - - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - - get-tsconfig@4.13.7: - resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@16.4.0: - resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} - engines: {node: '>=18'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - 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==} - - graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - - 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.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - - hono@4.12.10: - resolution: {integrity: sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==} - engines: {node: '>=16.9.0'} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - https-proxy-agent@7.0.6: - resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} - engines: {node: '>= 14'} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - human-signals@8.0.1: - resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} - engines: {node: '>=18.18.0'} - - iceberg-js@0.8.1: - resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} - engines: {node: '>=20.0.0'} - - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - 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'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-bun-module@2.0.0: - resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-docker@3.0.0: - resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - hasBin: true - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-in-ssh@1.0.0: - resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} - engines: {node: '>=20'} - - is-inside-container@1.0.0: - resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} - engines: {node: '>=14.16'} - hasBin: true - - is-interactive@2.0.0: - resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} - engines: {node: '>=12'} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-node-process@1.2.0: - resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-obj@3.0.0: - resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} - engines: {node: '>=12'} - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-promise@4.0.0: - resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-regexp@3.1.0: - resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} - engines: {node: '>=12'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-stream@4.0.1: - resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} - engines: {node: '>=18'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-unicode-supported@1.3.0: - resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} - engines: {node: '>=12'} - - is-unicode-supported@2.1.0: - resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} - engines: {node: '>=18'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - is-wsl@3.1.1: - resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} - engines: {node: '>=16'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} - - iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} - engines: {node: '>= 0.4'} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - jose@6.2.2: - resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - 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-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - - 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] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - 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] - libc: [musl] - - 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'} - - lines-and-columns@1.2.4: - resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - log-symbols@6.0.0: - resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} - engines: {node: '>=18'} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lucide-react@1.7.0: - resolution: {integrity: sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - 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'} - - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} - engines: {node: '>= 0.8'} - - merge-descriptors@2.0.0: - resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} - engines: {node: '>=18'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@3.0.2: - resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} - engines: {node: '>=18'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - msw@2.12.14: - resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - typescript: '>= 4.8.x' - peerDependenciesMeta: - typescript: - optional: true - - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - - next@16.2.2: - resolution: {integrity: sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - - node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - deprecated: Use your platform's native DOMException instead - - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} - - node-fetch@3.3.2: - resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - - node-releases@2.0.37: - resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - npm-run-path@6.0.0: - resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} - engines: {node: '>=18'} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object-treeify@1.1.33: - resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} - engines: {node: '>= 10'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - - open@11.0.0: - resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} - engines: {node: '>=20'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - ora@8.2.0: - resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} - engines: {node: '>=18'} - - outvariant@1.4.3: - resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - - 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'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parse-json@5.2.0: - resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} - engines: {node: '>=8'} - - parse-ms@4.0.0: - resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} - engines: {node: '>=18'} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-browserify@1.0.1: - resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - - 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'} - - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - - path-to-regexp@8.4.2: - resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} - engines: {node: '>=4'} - - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} - engines: {node: ^10 || ^12 || >=14} - - powershell-utils@0.1.0: - resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} - engines: {node: '>=20'} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - pretty-ms@9.3.0: - resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} - engines: {node: '>=18'} - - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - qs@6.15.0: - resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} - engines: {node: '>=0.6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@3.0.2: - resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} - engines: {node: '>= 0.10'} - - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - - recast@0.23.11: - resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} - engines: {node: '>= 4'} - - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} - engines: {node: '>= 0.4'} - hasBin: true - - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - rettime@0.10.1: - resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - router@2.2.0: - resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} - engines: {node: '>= 18'} - - run-applescript@7.1.0: - resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} - engines: {node: '>=18'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - 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.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - send@1.2.1: - resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} - engines: {node: '>= 18'} - - serve-static@2.2.1: - resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} - engines: {node: '>= 18'} - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shadcn@4.1.2: - resolution: {integrity: sha512-qNQcCavkbYsgBj+X09tF2bTcwRd8abR880bsFkDU2kMqceMCLAm5c+cLg7kWDhfh1H9g08knpQ5ZEf6y/co16g==} - hasBin: true - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - - 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'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - stable-hash@0.0.5: - resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - stdin-discarder@0.2.2: - resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} - engines: {node: '>=18'} - - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - - strict-event-emitter@0.5.1: - resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} - - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} - - string.prototype.repeat@1.0.0: - resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - stringify-object@5.0.0: - resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} - engines: {node: '>=14.16'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-final-newline@4.0.0: - resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} - engines: {node: '>=18'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - styled-jsx@5.1.6: - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - tabbable@6.4.0: - resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} - - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - - tailwindcss@4.2.2: - resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} - - tapable@2.3.2: - resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} - engines: {node: '>=6'} - - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tldts-core@7.0.28: - resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} - - tldts@7.0.28: - resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} - hasBin: true - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} - engines: {node: '>=16'} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - ts-morph@26.0.0: - resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} - - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - tw-animate-css@1.4.0: - resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-fest@5.5.0: - resolution: {integrity: sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==} - engines: {node: '>=20'} - - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - typescript-eslint@8.58.0: - resolution: {integrity: sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==} - 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@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - - until-async@3.0.2: - resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - - 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==} - - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - validate-npm-package-name@7.0.2: - resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} - engines: {node: ^20.17.0 || >=22.9.0} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} - - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - which@4.0.0: - resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} - engines: {node: ^16.13.0 || >=18.0.0} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - ws@8.20.0: - resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - wsl-utils@0.3.1: - resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} - engines: {node: '>=20'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - yocto-spinner@1.1.0: - resolution: {integrity: sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA==} - engines: {node: '>=18.19'} - - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - - yoctocolors@2.1.2: - resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} - engines: {node: '>=18'} - - zod-to-json-schema@3.25.2: - resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} - peerDependencies: - zod: ^3.25.28 || ^4 - - 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@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - -snapshots: - - '@alloc/quick-lru@5.2.0': {} - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@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.1': - dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - - '@babel/helper-annotate-as-pure@7.27.3': - dependencies: - '@babel/types': 7.29.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-member-expression-to-functions@7.28.5': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-optimise-call-expression@7.27.1': - dependencies: - '@babel/types': 7.29.0 - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.29.2': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.2': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - - '@babel/runtime@7.29.2': {} - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - tabbable: 6.4.0 - use-sync-external-store: 1.6.0(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - - '@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@babel/runtime': 7.29.2 - '@floating-ui/utils': 0.2.11 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - reselect: 5.1.1 - use-sync-external-store: 1.6.0(react@19.2.4) - optionalDependencies: - '@types/react': 19.2.14 - - '@dotenvx/dotenvx@1.60.0': - dependencies: - commander: 11.1.0 - dotenv: 17.4.1 - eciesjs: 0.4.18 - execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.4) - ignore: 5.3.2 - object-treeify: 1.1.33 - picomatch: 4.0.4 - which: 4.0.0 - yocto-spinner: 1.1.0 - - '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': - dependencies: - '@noble/ciphers': 1.3.0 - - '@emnapi/core@1.9.2': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.9.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': - dependencies: - eslint: 9.39.4(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.21.2': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 - - '@eslint/core@0.17.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.14.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.39.4': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.4.1': - dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 - - '@floating-ui/core@1.7.5': - dependencies: - '@floating-ui/utils': 0.2.11 - - '@floating-ui/dom@1.7.6': - dependencies: - '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 - - '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@floating-ui/dom': 1.7.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@floating-ui/utils@0.2.11': {} - - '@hono/node-server@1.19.12(hono@4.12.10)': - dependencies: - hono: 4.12.10 - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@img/colour@1.1.0': - optional: true - - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.9.2 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@inquirer/ansi@1.0.2': {} - - '@inquirer/confirm@5.1.21(@types/node@20.19.39)': - dependencies: - '@inquirer/core': 10.3.2(@types/node@20.19.39) - '@inquirer/type': 3.0.10(@types/node@20.19.39) - optionalDependencies: - '@types/node': 20.19.39 - - '@inquirer/core@10.3.2(@types/node@20.19.39)': - dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@20.19.39) - cli-width: 4.1.0 - mute-stream: 2.0.0 - signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 - optionalDependencies: - '@types/node': 20.19.39 - - '@inquirer/figures@1.0.15': {} - - '@inquirer/type@3.0.10(@types/node@20.19.39)': - optionalDependencies: - '@types/node': 20.19.39 - - '@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 - - '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': - dependencies: - '@hono/node-server': 1.19.12(hono@4.12.10) - ajv: 8.18.0 - ajv-formats: 3.0.1(ajv@8.18.0) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.10 - jose: 6.2.2 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - transitivePeerDependencies: - - supports-color - - '@mswjs/interceptors@0.41.3': - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 - - '@napi-rs/wasm-runtime@0.2.12': - dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@next/env@16.2.2': {} - - '@next/eslint-plugin-next@16.2.2': - dependencies: - fast-glob: 3.3.1 - - '@next/swc-darwin-arm64@16.2.2': - optional: true - - '@next/swc-darwin-x64@16.2.2': - optional: true - - '@next/swc-linux-arm64-gnu@16.2.2': - optional: true - - '@next/swc-linux-arm64-musl@16.2.2': - optional: true - - '@next/swc-linux-x64-gnu@16.2.2': - optional: true - - '@next/swc-linux-x64-musl@16.2.2': - optional: true - - '@next/swc-win32-arm64-msvc@16.2.2': - optional: true - - '@next/swc-win32-x64-msvc@16.2.2': - optional: true - - '@noble/ciphers@1.3.0': {} - - '@noble/curves@1.9.7': - dependencies: - '@noble/hashes': 1.8.0 - - '@noble/hashes@1.8.0': {} - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@nolyfill/is-core-module@1.0.39': {} - - '@open-draft/deferred-promise@2.2.0': {} - - '@open-draft/logger@0.3.0': - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 - - '@open-draft/until@2.1.0': {} - - '@rtsao/scc@1.1.0': {} - - '@sec-ant/readable-stream@0.4.1': {} - - '@sindresorhus/merge-streams@4.0.0': {} - - '@supabase/auth-js@2.101.1': - dependencies: - tslib: 2.8.1 - - '@supabase/functions-js@2.101.1': - dependencies: - tslib: 2.8.1 - - '@supabase/phoenix@0.4.0': {} - - '@supabase/postgrest-js@2.101.1': - dependencies: - tslib: 2.8.1 - - '@supabase/realtime-js@2.101.1': - dependencies: - '@supabase/phoenix': 0.4.0 - '@types/ws': 8.18.1 - tslib: 2.8.1 - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@supabase/ssr@0.10.0(@supabase/supabase-js@2.101.1)': - dependencies: - '@supabase/supabase-js': 2.101.1 - cookie: 1.1.1 - - '@supabase/storage-js@2.101.1': - dependencies: - iceberg-js: 0.8.1 - tslib: 2.8.1 - - '@supabase/supabase-js@2.101.1': - dependencies: - '@supabase/auth-js': 2.101.1 - '@supabase/functions-js': 2.101.1 - '@supabase/postgrest-js': 2.101.1 - '@supabase/realtime-js': 2.101.1 - '@supabase/storage-js': 2.101.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - - '@swc/helpers@0.5.15': - dependencies: - tslib: 2.8.1 - - '@tailwindcss/node@4.2.2': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.20.1 - jiti: 2.6.1 - lightningcss: 1.32.0 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.2 - - '@tailwindcss/oxide-android-arm64@4.2.2': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.2.2': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.2.2': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.2.2': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.2.2': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.2.2': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.2': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.2.2': - optional: true - - '@tailwindcss/oxide@4.2.2': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-arm64': 4.2.2 - '@tailwindcss/oxide-darwin-x64': 4.2.2 - '@tailwindcss/oxide-freebsd-x64': 4.2.2 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.2 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.2 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.2 - '@tailwindcss/oxide-linux-x64-musl': 4.2.2 - '@tailwindcss/oxide-wasm32-wasi': 4.2.2 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - - '@tailwindcss/postcss@4.2.2': - dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.2 - '@tailwindcss/oxide': 4.2.2 - postcss: 8.5.8 - tailwindcss: 4.2.2 - - '@ts-morph/common@0.27.0': - dependencies: - fast-glob: 3.3.3 - minimatch: 10.2.5 - path-browserify: 1.0.1 - - '@tybys/wasm-util@0.10.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/estree@1.0.8': {} - - '@types/json-schema@7.0.15': {} - - '@types/json5@0.0.29': {} - - '@types/node@20.19.39': - dependencies: - undici-types: 6.21.0 - - '@types/react-dom@19.2.3(@types/react@19.2.14)': - dependencies: - '@types/react': 19.2.14 - - '@types/react@19.2.14': - dependencies: - csstype: 3.2.3 - - '@types/statuses@2.0.6': {} - - '@types/validate-npm-package-name@4.0.2': {} - - '@types/ws@8.18.1': - dependencies: - '@types/node': 20.19.39 - - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - eslint: 9.39.4(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.58.0': - dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 - - '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.58.0': {} - - '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@5.9.3) - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/visitor-keys': 8.58.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.58.0': - dependencies: - '@typescript-eslint/types': 8.58.0 - eslint-visitor-keys: 5.0.1 - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - optional: true - - '@unrs/resolver-binding-android-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - dependencies: - '@napi-rs/wasm-runtime': 0.2.12 - optional: true - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - optional: true - - accepts@2.0.0: - dependencies: - mime-types: 3.0.2 - negotiator: 1.0.0 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - agent-base@7.1.4: {} - - ajv-formats@3.0.1(ajv@8.18.0): - optionalDependencies: - ajv: 8.18.0 - - ajv@6.14.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 - - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array.prototype.findlast@1.2.5: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.tosorted@1.1.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - ast-types-flow@0.0.8: {} - - ast-types@0.16.1: - dependencies: - tslib: 2.8.1 - - async-function@1.0.0: {} - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - axe-core@4.11.2: {} - - axobject-query@4.1.0: {} - - balanced-match@1.0.2: {} - - balanced-match@4.0.4: {} - - baseline-browser-mapping@2.10.15: {} - - body-parser@2.2.2: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 4.4.3 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - on-finished: 2.4.1 - qs: 6.15.0 - raw-body: 3.0.2 - type-is: 2.0.1 - transitivePeerDependencies: - - supports-color - - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@5.0.5: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.15 - caniuse-lite: 1.0.30001785 - electron-to-chromium: 1.5.331 - node-releases: 2.0.37 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - bundle-name@4.1.0: - dependencies: - run-applescript: 7.1.0 - - bytes@3.1.2: {} - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001785: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.6.2: {} - - class-variance-authority@0.7.1: - dependencies: - clsx: 2.1.1 - - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-spinners@2.9.2: {} - - cli-width@4.1.0: {} - - client-only@0.0.1: {} - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - clsx@2.1.1: {} - - code-block-writer@13.0.3: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - commander@11.1.0: {} - - commander@14.0.3: {} - - concat-map@0.0.1: {} - - content-disposition@1.0.1: {} - - content-type@1.0.5: {} - - convert-source-map@2.0.0: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - cookie@1.1.1: {} - - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cosmiconfig@9.0.1(typescript@5.9.3): - dependencies: - env-paths: 2.2.1 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - parse-json: 5.2.0 - optionalDependencies: - typescript: 5.9.3 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - cssesc@3.0.0: {} - - csstype@3.2.3: {} - - damerau-levenshtein@1.0.8: {} - - data-uri-to-buffer@4.0.1: {} - - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - debug@3.2.7: - dependencies: - ms: 2.1.3 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - dedent@1.7.2: {} - - deep-is@0.1.4: {} - - deepmerge@4.3.1: {} - - default-browser-id@5.0.1: {} - - default-browser@5.5.0: - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.1 - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-lazy-prop@3.0.0: {} - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - depd@2.0.0: {} - - detect-libc@2.1.2: {} - - diff@8.0.4: {} - - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - dotenv@17.4.1: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - eciesjs@0.4.18: - dependencies: - '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) - '@noble/ciphers': 1.3.0 - '@noble/curves': 1.9.7 - '@noble/hashes': 1.8.0 - - ee-first@1.1.1: {} - - electron-to-chromium@1.5.331: {} - - emoji-regex@10.6.0: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - encodeurl@2.0.0: {} - - enhanced-resolve@5.20.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.2 - - env-paths@2.2.1: {} - - error-ex@1.3.4: - dependencies: - is-arrayish: 0.2.1 - - es-abstract@1.24.1: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-iterator-helpers@1.3.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-set-tostringtag: 2.1.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - iterator.prototype: 1.1.5 - math-intrinsics: 1.1.0 - safe-array-concat: 1.1.3 - - es-object-atoms@1.1.1: - 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.2 - - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - escape-string-regexp@4.0.0: {} - - eslint-config-next@16.2.2(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.2.2 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) - globals: 16.4.0 - typescript-eslint: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - - eslint-import-resolver-node@0.3.10: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 2.0.0-next.6 - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.4(jiti@2.6.1) - get-tsconfig: 4.13.7 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.4(jiti@2.6.1) - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.2 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 9.39.4(jiti@2.6.1) - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@2.6.1)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - eslint: 9.39.4(jiti@2.6.1) - hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color - - eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.3.1 - eslint: 9.39.4(jiti@2.6.1) - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.5 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.6 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@9.39.4(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.39.4 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.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 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - esprima@4.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: {} - - etag@1.8.1: {} - - eventsource-parser@3.0.6: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.6 - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - execa@9.6.1: - dependencies: - '@sindresorhus/merge-streams': 4.0.0 - cross-spawn: 7.0.6 - figures: 6.1.0 - get-stream: 9.0.1 - human-signals: 8.0.1 - is-plain-obj: 4.1.0 - is-stream: 4.0.1 - npm-run-path: 6.0.0 - pretty-ms: 9.3.0 - signal-exit: 4.1.0 - strip-final-newline: 4.0.0 - yoctocolors: 2.1.2 - - express-rate-limit@8.3.2(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.1.0 - - express@5.2.1: - dependencies: - accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.1 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.2.2 - debug: 4.4.3 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 2.1.1 - fresh: 2.0.0 - http-errors: 2.0.1 - merge-descriptors: 2.0.0 - mime-types: 3.0.2 - on-finished: 2.4.1 - once: 1.4.0 - parseurl: 1.3.3 - proxy-addr: 2.0.7 - qs: 6.15.0 - range-parser: 1.2.1 - router: 2.2.0 - send: 1.2.1 - serve-static: 2.2.1 - statuses: 2.0.2 - type-is: 2.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.1: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-uri@3.1.0: {} - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - - fetch-blob@3.2.0: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 - - figures@6.1.0: - dependencies: - is-unicode-supported: 2.1.0 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@2.1.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - 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: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 - - forwarded@0.2.0: {} - - fresh@2.0.0: {} - - fs-extra@11.3.4: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - function-bind@1.1.2: {} - - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - fuzzysort@3.1.0: {} - - generator-function@2.0.1: {} - - gensync@1.0.0-beta.2: {} - - get-caller-file@2.0.5: {} - - get-east-asian-width@1.5.0: {} - - 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.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-own-enumerable-keys@1.0.0: {} - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@6.0.1: {} - - get-stream@9.0.1: - dependencies: - '@sec-ant/readable-stream': 0.4.1 - is-stream: 4.0.1 - - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - - get-tsconfig@4.13.7: - dependencies: - resolve-pkg-maps: 1.0.0 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - globals@16.4.0: {} - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - graphql@16.13.2: {} - - has-bigints@1.1.0: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - headers-polyfill@4.0.3: {} - - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - - hono@4.12.10: {} - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - https-proxy-agent@7.0.6: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - human-signals@2.1.0: {} - - human-signals@8.0.1: {} - - iceberg-js@0.8.1: {} - - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - inherits@2.0.4: {} - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - - ip-address@10.1.0: {} - - ipaddr.js@1.9.1: {} - - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-arrayish@0.2.1: {} - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-bun-module@2.0.0: - dependencies: - semver: 7.7.4 - - is-callable@1.2.7: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-docker@3.0.0: {} - - is-extglob@2.1.1: {} - - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-in-ssh@1.0.0: {} - - is-inside-container@1.0.0: - dependencies: - is-docker: 3.0.0 - - is-interactive@2.0.0: {} - - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-node-process@1.2.0: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-number@7.0.0: {} - - is-obj@3.0.0: {} - - is-plain-obj@4.1.0: {} - - is-promise@4.0.0: {} - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - is-regexp@3.1.0: {} - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-stream@2.0.1: {} - - is-stream@4.0.1: {} - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - - is-unicode-supported@1.3.0: {} - - is-unicode-supported@2.1.0: {} - - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-wsl@3.1.1: - dependencies: - is-inside-container: 1.0.0 - - isarray@2.0.5: {} - - isexe@2.0.0: {} - - isexe@3.1.5: {} - - iterator.prototype@1.1.5: - dependencies: - define-data-property: 1.1.4 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - has-symbols: 1.1.0 - set-function-name: 2.0.2 - - jiti@2.6.1: {} - - jose@6.2.2: {} - - js-tokens@4.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-parse-even-better-errors@2.3.1: {} - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.2: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@1.0.2: - dependencies: - minimist: 1.2.8 - - json5@2.2.3: {} - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - jsx-ast-utils@3.3.5: - dependencies: - array-includes: 3.1.9 - array.prototype.flat: 1.3.3 - object.assign: 4.1.7 - object.values: 1.2.1 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - kleur@3.0.3: {} - - kleur@4.1.5: {} - - language-subtag-registry@0.3.23: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.23 - - 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 - - lines-and-columns@1.2.4: {} - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - log-symbols@6.0.0: - dependencies: - chalk: 5.6.2 - is-unicode-supported: 1.3.0 - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lucide-react@1.7.0(react@19.2.4): - dependencies: - react: 19.2.4 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - math-intrinsics@1.1.0: {} - - media-typer@1.1.0: {} - - merge-descriptors@2.0.0: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mime-db@1.54.0: {} - - mime-types@3.0.2: - dependencies: - mime-db: 1.54.0 - - mimic-fn@2.1.0: {} - - mimic-function@5.0.1: {} - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.5 - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.13 - - minimist@1.2.8: {} - - ms@2.1.3: {} - - msw@2.12.14(@types/node@20.19.39)(typescript@5.9.3): - dependencies: - '@inquirer/confirm': 5.1.21(@types/node@20.19.39) - '@mswjs/interceptors': 0.41.3 - '@open-draft/deferred-promise': 2.2.0 - '@types/statuses': 2.0.6 - cookie: 1.1.1 - graphql: 16.13.2 - headers-polyfill: 4.0.3 - is-node-process: 1.2.0 - outvariant: 1.4.3 - path-to-regexp: 6.3.0 - picocolors: 1.1.1 - rettime: 0.10.1 - statuses: 2.0.2 - strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 - type-fest: 5.5.0 - until-async: 3.0.2 - yargs: 17.7.2 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@types/node' - - mute-stream@2.0.0: {} - - nanoid@3.3.11: {} - - napi-postinstall@0.3.4: {} - - natural-compare@1.4.0: {} - - negotiator@1.0.0: {} - - next@16.2.2(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@next/env': 16.2.2 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.10.15 - caniuse-lite: 1.0.30001785 - postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) - optionalDependencies: - '@next/swc-darwin-arm64': 16.2.2 - '@next/swc-darwin-x64': 16.2.2 - '@next/swc-linux-arm64-gnu': 16.2.2 - '@next/swc-linux-arm64-musl': 16.2.2 - '@next/swc-linux-x64-gnu': 16.2.2 - '@next/swc-linux-x64-musl': 16.2.2 - '@next/swc-win32-arm64-msvc': 16.2.2 - '@next/swc-win32-x64-msvc': 16.2.2 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - - node-domexception@1.0.0: {} - - node-exports-info@1.6.0: - dependencies: - array.prototype.flatmap: 1.3.3 - es-errors: 1.3.0 - object.entries: 1.1.9 - semver: 6.3.1 - - node-fetch@3.3.2: - dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 - - node-releases@2.0.37: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - npm-run-path@6.0.0: - dependencies: - path-key: 4.0.0 - unicorn-magic: 0.3.0 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} - - object-treeify@1.1.33: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.entries@1.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - - open@11.0.0: - dependencies: - default-browser: 5.5.0 - define-lazy-prop: 3.0.0 - is-in-ssh: 1.0.0 - is-inside-container: 1.0.0 - powershell-utils: 0.1.0 - wsl-utils: 0.3.1 - - 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 - - ora@8.2.0: - dependencies: - chalk: 5.6.2 - cli-cursor: 5.0.0 - cli-spinners: 2.9.2 - is-interactive: 2.0.0 - is-unicode-supported: 2.1.0 - log-symbols: 6.0.0 - stdin-discarder: 0.2.2 - string-width: 7.2.0 - strip-ansi: 7.2.0 - - outvariant@1.4.3: {} - - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-json@5.2.0: - dependencies: - '@babel/code-frame': 7.29.0 - error-ex: 1.3.4 - json-parse-even-better-errors: 2.3.1 - lines-and-columns: 1.2.4 - - parse-ms@4.0.0: {} - - parseurl@1.3.3: {} - - path-browserify@1.0.1: {} - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - path-key@4.0.0: {} - - path-parse@1.0.7: {} - - path-to-regexp@6.3.0: {} - - path-to-regexp@8.4.2: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - picomatch@4.0.4: {} - - pkce-challenge@5.0.1: {} - - possible-typed-array-names@1.1.0: {} - - postcss-selector-parser@7.1.1: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss@8.4.31: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - postcss@8.5.8: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - powershell-utils@0.1.0: {} - - prelude-ls@1.2.1: {} - - pretty-ms@9.3.0: - dependencies: - parse-ms: 4.0.0 - - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - punycode@2.3.1: {} - - qs@6.15.0: - dependencies: - side-channel: 1.1.0 - - queue-microtask@1.2.3: {} - - range-parser@1.2.1: {} - - raw-body@3.0.2: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.7.2 - unpipe: 1.0.0 - - react-dom@19.2.4(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - - react-is@16.13.1: {} - - react@19.2.4: {} - - recast@0.23.11: - dependencies: - ast-types: 0.16.1 - esprima: 4.0.1 - source-map: 0.6.1 - tiny-invariant: 1.3.3 - tslib: 2.8.1 - - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - - require-directory@2.1.1: {} - - require-from-string@2.0.2: {} - - reselect@5.1.1: {} - - resolve-from@4.0.0: {} - - resolve-pkg-maps@1.0.0: {} - - resolve@2.0.0-next.6: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.1 - node-exports-info: 1.6.0 - object-keys: 1.1.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - - rettime@0.10.1: {} - - reusify@1.1.0: {} - - router@2.2.0: - dependencies: - debug: 4.4.3 - depd: 2.0.0 - is-promise: 4.0.0 - parseurl: 1.3.3 - path-to-regexp: 8.4.2 - transitivePeerDependencies: - - supports-color - - run-applescript@7.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - safer-buffer@2.1.2: {} - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.7.4: {} - - send@1.2.1: - dependencies: - debug: 4.4.3 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 2.0.0 - http-errors: 2.0.1 - mime-types: 3.0.2 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@2.2.1: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 1.2.1 - transitivePeerDependencies: - - supports-color - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - - setprototypeof@1.2.0: {} - - shadcn@4.1.2(@types/node@20.19.39)(typescript@5.9.3): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@dotenvx/dotenvx': 1.60.0 - '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) - '@types/validate-npm-package-name': 4.0.2 - browserslist: 4.28.2 - commander: 14.0.3 - cosmiconfig: 9.0.1(typescript@5.9.3) - dedent: 1.7.2 - deepmerge: 4.3.1 - diff: 8.0.4 - execa: 9.6.1 - fast-glob: 3.3.3 - fs-extra: 11.3.4 - fuzzysort: 3.1.0 - https-proxy-agent: 7.0.6 - kleur: 4.1.5 - msw: 2.12.14(@types/node@20.19.39)(typescript@5.9.3) - node-fetch: 3.3.2 - open: 11.0.0 - ora: 8.2.0 - postcss: 8.5.8 - postcss-selector-parser: 7.1.1 - prompts: 2.4.2 - recast: 0.23.11 - stringify-object: 5.0.0 - tailwind-merge: 3.5.0 - ts-morph: 26.0.0 - tsconfig-paths: 4.2.0 - validate-npm-package-name: 7.0.2 - zod: 3.25.76 - zod-to-json-schema: 3.25.2(zod@3.25.76) - transitivePeerDependencies: - - '@cfworker/json-schema' - - '@types/node' - - babel-plugin-macros - - supports-color - - typescript - - sharp@0.34.5: - dependencies: - '@img/colour': 1.1.0 - detect-libc: 2.1.2 - semver: 7.7.4 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - optional: true - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@3.0.7: {} - - signal-exit@4.1.0: {} - - sisteransi@1.0.5: {} - - source-map-js@1.2.1: {} - - source-map@0.6.1: {} - - stable-hash@0.0.5: {} - - statuses@2.0.2: {} - - stdin-discarder@0.2.2: {} - - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - - strict-event-emitter@0.5.1: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@7.2.0: - dependencies: - emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 - strip-ansi: 7.2.0 - - string.prototype.includes@2.0.1: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - - string.prototype.matchall@4.0.12: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - regexp.prototype.flags: 1.5.4 - set-function-name: 2.0.2 - side-channel: 1.1.0 - - string.prototype.repeat@1.0.0: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.24.1 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - stringify-object@5.0.0: - dependencies: - get-own-enumerable-keys: 1.0.0 - is-obj: 3.0.0 - is-regexp: 3.1.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - - strip-bom@3.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-final-newline@4.0.0: {} - - strip-json-comments@3.1.1: {} - - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): - dependencies: - client-only: 0.0.1 - react: 19.2.4 - optionalDependencies: - '@babel/core': 7.29.0 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - tabbable@6.4.0: {} - - tagged-tag@1.0.0: {} - - tailwind-merge@3.5.0: {} - - tailwindcss@4.2.2: {} - - tapable@2.3.2: {} - - tiny-invariant@1.3.3: {} - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - - tldts-core@7.0.28: {} - - tldts@7.0.28: - dependencies: - tldts-core: 7.0.28 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - tough-cookie@6.0.1: - dependencies: - tldts: 7.0.28 - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - ts-morph@26.0.0: - dependencies: - '@ts-morph/common': 0.27.0 - code-block-writer: 13.0.3 - - tsconfig-paths@3.15.0: - dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tsconfig-paths@4.2.0: - dependencies: - json5: 2.2.3 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tslib@2.8.1: {} - - tw-animate-css@1.4.0: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-fest@5.5.0: - dependencies: - tagged-tag: 1.0.0 - - type-is@2.0.1: - dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 - mime-types: 3.0.2 - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typescript-eslint@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.4(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - typescript@5.9.3: {} - - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - undici-types@6.21.0: {} - - unicorn-magic@0.3.0: {} - - universalify@2.0.1: {} - - unpipe@1.0.0: {} - - unrs-resolver@1.11.1: - dependencies: - napi-postinstall: 0.3.4 - optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.11.1 - '@unrs/resolver-binding-android-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-x64': 1.11.1 - '@unrs/resolver-binding-freebsd-x64': 1.11.1 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 - '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-musl': 1.11.1 - '@unrs/resolver-binding-wasm32-wasi': 1.11.1 - '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 - '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 - '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - - until-async@3.0.2: {} - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - use-sync-external-store@1.6.0(react@19.2.4): - dependencies: - react: 19.2.4 - - util-deprecate@1.0.2: {} - - validate-npm-package-name@7.0.2: {} - - vary@1.1.2: {} - - web-streams-polyfill@3.3.3: {} - - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - which@4.0.0: - dependencies: - isexe: 3.1.5 - - word-wrap@1.2.5: {} - - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrappy@1.0.2: {} - - ws@8.20.0: {} - - wsl-utils@0.3.1: - dependencies: - is-wsl: 3.1.1 - powershell-utils: 0.1.0 - - y18n@5.0.8: {} - - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - - yocto-queue@0.1.0: {} - - yocto-spinner@1.1.0: - dependencies: - yoctocolors: 2.1.2 - - yoctocolors-cjs@2.1.3: {} - - yoctocolors@2.1.2: {} - - zod-to-json-schema@3.25.2(zod@3.25.76): - dependencies: - zod: 3.25.76 - - zod-validation-error@4.0.2(zod@4.3.6): - dependencies: - zod: 4.3.6 - - zod@3.25.76: {} - - zod@4.3.6: {} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/postcss.config.mjs b/postcss.config.mjs deleted file mode 100644 index 61e3684..0000000 --- a/postcss.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; - -export default config; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..f06ed62 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,74 @@ +generator client { + provider = "prisma-client-js" + previewFeatures = ["multiSchema"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") + schemas = ["public", "auth"] +} + +model User { + id String @id @default(cuid()) + email String? @unique + phoneNumber String? @unique + nickname String @unique + passwordHash String? + isAgeOver14 Boolean? + termsAgreedAt DateTime? + privacyAgreedAt DateTime? + marketingAgreed Boolean @default(false) + eventSmsAgreed Boolean @default(false) + profileCompleted Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + socialAccounts SocialAccount[] + + @@schema("public") +} + +model SocialAccount { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider String + providerUserId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([provider, providerUserId]) + @@schema("public") +} + +model Schedule { + id String @id + hostTokenHash String @unique + title String + durationMinutes Int + candidateDays String + candidateStartHour Int + candidateEndHour Int + status String @default("open") + confirmedSlot String? + creatorId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + participants ScheduleParticipant[] + + @@schema("public") +} + +model ScheduleParticipant { + id String @id + scheduleId String + schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + name String + available String + submittedAt DateTime @default(now()) + + @@index([scheduleId]) + @@schema("public") +} + diff --git a/prototype/button_proposals.html b/prototype/button_proposals.html new file mode 100644 index 0000000..6c9806c --- /dev/null +++ b/prototype/button_proposals.html @@ -0,0 +1,198 @@ + + + + + + MOIM - 버튼 디자인 시스템 제안 (Button Proposals) + + + + + + + + + +
+ +
+ + ✨ 디자인 시스템 개선 시안 + +

+ 버튼 컴포넌트 디자인 제안 +

+

+ 그라데이션 틴트 및 소프트 미세 섀도우를 더한 프리미엄 버튼 시안 +

+
+ + +
+ +
+

+ 🎨 버튼 스타일 매트릭스 +

+ +
+ +
+
+ + Primary / Action + +

Solid Brand

+

+ 최종 제출, 가입하기 등 한 페이지에서 가장 두드러져야 하는 최우선 액션에 사용합니다. +

+
+
+ +
+
+ + +
+
+
+ + Refined Tint + + Improved! 🔥 +
+

Tint Brand

+

+ 평평한 10% 솔리드 색상 대신 은은한 미세 그라데이션소프트 퍼플 섀도우를 입혀 입체적이고 고급스럽게 인지됩니다. +

+
+
+ +
+
+ + +
+
+ + Navigation / Low Profile + +

Ghost Brand

+

+ 헤더 영역 내비게이션이나 서브 메뉴처럼 주위 요소와 자연스럽게 어우러져야 할 때 씁니다. +

+
+
+ +
+
+
+
+ + +
+

+ 👀 실제 페이지 적용 예시 시뮬레이션 +

+ + +
+

일정 조율 방 제출 화면 예시

+
+

주말 독서 모임

+

김철수 님 외 7명 참여 중

+ + +
+ 📅 일정 격자판 (시간 선택 영역) +
+ + +
+
✓ 개선된 스타일 (Refined Tinted): 입체적이고 고급스러움
+ + +
+ +
✗ 이전 스타일 (Ghost): 단순 글자처럼 보여 클릭 유도가 안 됨
+ +
+
+
+
+ + +
+

🛠️ Tailwind CSS 적용 코드 스펙

+
+// solidBrand (솔리드 버튼)
+"bg-brand-purple text-white hover:bg-brand-purple-hover hover:scale-[1.02] active:scale-95 transition-all duration-200 shadow-sm"
+
+// tintBrand (개선된 프리미엄 그라데이션 틴트 버튼)
+"bg-gradient-to-r from-brand-purple/[0.08] to-brand-purple/[0.03] text-brand-purple border border-brand-purple/15 shadow-[0_4px_10px_-2px_rgba(98,82,172,0.08)] hover:from-brand-purple/15 hover:to-brand-purple/8 hover:border-brand-purple/30 hover:shadow-[0_6px_16px_rgba(98,82,172,0.12)] hover:scale-[1.02] active:scale-95 transition-all duration-300"
+
+// ghostBrand (고스트 버튼 - 헤더/내비바 전용)
+"text-brand-purple hover:text-brand-purple-hover hover:scale-[1.02] active:scale-95 transition-all duration-200"
+
+
+ + +
+ MOIM UI/UX Design System Proposal · 2026 +
+
+ + diff --git a/prototype/login_ui_proposals.html b/prototype/login_ui_proposals.html new file mode 100644 index 0000000..3e83611 --- /dev/null +++ b/prototype/login_ui_proposals.html @@ -0,0 +1,178 @@ + + + + + + MOIM 로그인 버튼 UI 디자인 제안 + + + + + + + + + +
+
+

+ MOIM 로그인 UI 디자인 가이드 +

+

+ 상단 헤더(Header) 우측에 배치될 로그인 버튼의 4가지 시각적 대안입니다. 마우스를 올리거나(Hover) 클릭하여 실제 상호작용을 확인해 보세요. +

+
+ + +
+ + +
+
+
+ Option 1 +

고스트 버튼 (Ghost / Minimalist Button)

+
+

+ 시각적 비중을 최소화하여 메인 히어로 섹션의 주요 버튼들과 간섭하지 않는 미니멀한 스타일입니다. (가장 추천) +

+
+ + +
+ MOIM +
+ + + + + 로그인 + +
+
+
+ + +
+
+
+ Option 2 +

라운드 아웃라인 버튼 (Outline Button)

+
+

+ 단정한 테두리가 둘러져 있어 구획감이 확실하며, 호버 시 테두리와 배경색이 유기적으로 변화해 고급스럽습니다. +

+
+ + +
+ MOIM +
+ + + + + 로그인 + +
+
+
+ + +
+
+
+ Option 3 +

필 형태의 솔리드 버튼 (Solid Pill Button)

+
+

+ 완만하지 않고 끝이 완전히 둥근 알약형 스타일에 소프트 쉐도우를 입혀 트렌디하고 볼륨감 있는 느낌을 줍니다. +

+
+ + +
+ MOIM +
+ + + + + 로그인 + +
+
+
+ + +
+
+
+ Option 4 +

텍스트 + 마이크로 인터랙션 아이콘 (Text & Icon)

+
+

+ 로그인을 직관적으로 의미하는 화살표 아이콘을 덧붙이고, 마우스를 올렸을 때 화살표가 우측으로 살짝 이동하는 마이크로 인터랙션을 가미했습니다. +

+
+ + +
+ MOIM +
+ + + + + 로그인 + + + + + +
+
+
+ +
+ + +
+

+ 제안 경로: prototype/login_ui_proposals.html +

+
+ 마음에 드시는 옵션 번호를 말씀해 주시면 실제 코드에 곧바로 반영해 드립니다. 😊 +
+
+ +
+ + + diff --git a/prototype/moim.html b/prototype/moim.html new file mode 100644 index 0000000..6ef1d2b --- /dev/null +++ b/prototype/moim.html @@ -0,0 +1,3490 @@ + + + + + + + MOIM — 스마트 스케줄러 + + + + + + + +
+ + +
+ + +
+
+ MOIM +
+
캘린더 연동 한 번으로 모두의 빈 시간을 자동으로
+
+ + + +
+
+
아이디
+ +
+
+
비밀번호
+ +
+ + +
+ 아이디 찾기 + | + 비밀번호 찾기 + | + 회원가입 +
+ + +
+
+ 또는 +
+
+ + +
+ + + + +
+ + +
+ 로그인 없이 참여하려면 + 초대 링크로 참여 → +
+
+ +
+
+ + +
+ + +
+
+
STEP 1 / 2 — 시작 전 + 1회 설정
+
내 일정 연동하기
+
연동하면 다음엔 자동으로 반영돼요. 한 번만 설정하면 끝!
+
+ + +
+
+
🍎
+
+
애플 캘린더
+
iPhone · iPad 캘린더와 연동해요
+
+
+ 연동하기 +
+
+
+ + +
+
+
🗓️
+
+
구글 캘린더
+
개인 일정을 자동으로 차단해드려요
+
+
+ 연동하기 +
+
+
+ + +
+
+
📅
+
+
삼성 캘린더
+
Galaxy 기본 캘린더 앱과 연동해요
+
+
+ 연동하기 +
+
+
+ + +
+
+
📱
+
+
에브리타임 시간표
+
에브리타임 시간표와 연동해요
+
+
+ 연동하기 +
+
+
+ +
+ + + + + + + +
+
+
📸 스케줄 캡처 업로드
+
종이 시간표, 앱 화면 등 어떤 형식이든 찍어서 올리면
AI가 자동으로 일정을 분석해 반영해요 +
+
+ + +
+ +
🖼️
+
사진 선택 또는 드래그
+
PNG · JPG · 스크린샷 모두 가능 · 여러 장 한 번에 OK
+
+ + +
+ + +
+
+
.ics 파일 업로드 네이버·다음 캘린더
+
+
📁
+
.ics 파일 선택
+
캘린더 앱 → 내보내기 → 업로드
+
+
+ + +
+
내 위치 중간지점 계산
+ +
📍 모임 확정 후 모두에게 딱 맞는 중간 장소를 추천해드려요
+
+ +
+ 🔒 + 캘린더 내용은 서버에 저장되지 않아요. 가능/불가능 여부만 추출합니다. +
+
+ +
+ +
+
+ + +
+ + +
+ + +
+ +
+ + ~ + +
+ +
+ + +
+
+
내 일정 +
+
+
에브리타임 +
+
+
모임 +
+
+ + +
+
+
+ +
+ + +
🗂 내 모임
+ +
+
+
+
조별과제 회의
+
4월 16일 (수) 오후 2:00 · 3명/4명 응답
+
진행 중
+
+ 대기 중 +
+
+
+
+
알고리즘 스터디 킥오프
+
4월 10일 (목) 오후 4:00
+
완료 · 홍대입구 스터디카페
+
+ 완료 +
+
+ + +
모임 만들기
+ +
+ + +
+ + +
+ +
+
+
+
캘린더 연동
+
+
+
+
2
+
모임 설정
+
+
+
+
3
+
날짜 선택
+
+
+
+
4
+
공유
+
+
+ +
모임 만들기
+
어떤 모임을 잡을까요?
+ +
+
모임 이름 *
+ +
+ +
+
+
예상 소요시간
+ +
+
+
참여 인원
+ +
+
+ +
+
모임 목적
+
+
📚 조별과제
+
🎯 스터디
+
🎵 동아리
+
🍽️ 밥약
+
🎮 놀기
+
💼 기타
+
+
+ +
+ +
+ + 캘린더가 이미 연동되어 있어서, 막힌 시간이 자동으로 반영됩니다. 따로 입력할 필요 없어요! +
+
+ +
+ +
+
+ + +
+ + +
+
+
+
+
캘린더 연동
+
+
+
+
+
모임 설정
+
+
+
+
3
+
날짜 선택
+
+
+
+
4
+
공유
+
+
+ +
후보 날짜 선택
+
참여자들이 응답할 날짜 범위를 선택해주세요
+ +
+
이번 주 후보 날짜
+
+
4/14
+
+
4/15
+
+
4/16
+
+
4/17
+
+
4/18
+
+
4/19
+
+
+
+ +
+
+
시작 시간
+ +
+
+
종료 시간
+ +
+
+ +
+
응답 마감일
+ +
이 날짜까지 응답한 참여자 기준으로 확정할 수 있어요
+
+
+ +
+ +
+
+ + +
+ + +
+
+
+
+
캘린더 연동
+
+
+
+
+
모임 설정
+
+
+
+
+
날짜 선택
+
+
+
+
4
+
공유
+
+
+ + + +
+ + +
+ +
+ +
+ + 참여자가 링크를 클릭하면 가용시간이 자동 반영됩니다. 실시간 현황을 확인해보세요! +
+ + +
+
+ + +
+ + +
+
+
조별과제 회의
+
1시간 · 화/수/금 후보 · 마감 4/16
+
+ +
+ + +
+
참여 현황3명 / 4명 완료
+
+
+
+
+ + +
+
+
+
+
김유현 (나)
+
구글 캘린더 연동 · 서울 성북구
+
+ 완료 +
+
+
+
+
이민준
+
구글 캘린더 연동 · 서울 마포구
+
+ 완료 +
+
+
+
+
박서연
+
에브리타임 + 직접입력 · 서울 서대문구
+
+ 완료 +
+
+
+
+
최준호
+
응답 대기 중...
+
+ 대기 중 +
+
+ + + +
+ + +
+ 공통 가능 시간 3명 기준 +
+
짙을수록 더 많은 사람이 가능한 시간
+ +
+
+
+
+
+ 적음 +
+
+
+
+
+
+
+ 많음 +
+
+ + +
+
+ + +
+ + +
+
+
📅
+
조별과제 회의
+
김유현님이 초대했어요
+
+ 1시간 + 4명 + 화/수/금 후보 + 3명 완료 +
+
+ +
+ + +
+ + +
+
+ + 캘린더를 연동하면 가능한 시간이 자동으로 반영돼요. 한 번만 설정하면 다음엔 자동! +
+ +
+ + +
+ +
+
에브리타임 시간표 선택
+
+
📸
+
시간표 캡처 업로드
+
수업 시간이 자동으로 차단돼요
+
+
+ +
+
내 위치 중간지점 계산
+ +
+
+ + + + +
+
이름
+ +
+
+ +
+ +
+
+ + +
+ + +
+
+
시간 확정하기
+
+ 모두가 가능한 시간 중 하나를 선택해 주세요 + 4명 모두 가능 +
+
+ +
🌟 AI 추천 시간
+ +
+
+
+
4월 16일 (수) 오후 2:00 – 3:00
+
4명 모두 가능 · 점심 후 집중력 최적 시간대
+
+ 추천 +
+
+
+
+
4월 15일 (화) 오후 4:00 – 5:00
+
4명 모두 가능
+
+
+
+
+
+
4월 18일 (금) 오전 11:00 – 12:00
+
3명 가능 · 1명 불확실
+
+
+ +
+ +
📍 참여자 위치
+
+
+
+
+ 김
+ 김유현 · 서울 성북구 돈암동 +
+
+
+ 이
+ 이민준 · 서울 마포구 합정동 +
+
+
+ 박서연 · 서울 서대문구 신촌 +
+
+
+ 최준호 · 서울 은평구 연신내 +
+
+
+
+ +
+ +
+
+ + +
+ + +
+
🎉
+

모임이 확정됐어요!

+

4월 16일 (수) 오후 2:00 – 3:00

+
+ 📅 수요일 + ⏱ 1시간 + 👥 4명 +
+
+ +
+ + +
+
📲 캘린더 자동 추가 +
+
+
+ 🔵 김유현 (구글 캘린더) + ✓ 자동 추가됨 +
+
+ 🔵 이민준 (구글 캘린더) + ✓ 자동 추가됨 +
+
+ ✏️ 박서연 (직접 입력) + +
+
+ 🔵 최준호 (구글 캘린더) + ✓ 자동 추가됨 +
+
+
+ +
+ + +
📍 중간 지점 추천
+
4명의 위치를 분석해 최적의 만남 장소를 찾았어요
+ + +
+
중간 지점: 홍대·합정 일대
+ + +
+
김유현 · 성북구
+
+
+
+
+
이민준 · 마포구
+
+
+
+
+
박서연 · 서대문구
+
+
+
+
+
최준호 · 은평구
+
+
+
+ +
+
+ +
+ 🗺 추천 지역: 서울 마포구 홍대입구·합정 일대 + — 4명 모두 접근 용이 +
+ + +
추천 장소
+ +
+
+
+
카페 콤마 홍대점
+
홍대입구역 도보 3분 · 스터디 공간 완비
+
+
0.3km
+
+ +
+
🍽️
+
+
합정 맛집거리
+
합정동 · 식사 + 카페 모두 가능한 거리
+
+
0.5km
+
+ +
+
📚
+
+
신촌 스터디카페
+
신촌역 5분 · 조용하고 집중하기 좋은 공간
+
+
1.2km
+
+ +
+
🏢
+
+
홍대 공유오피스 스터디룸
+
서교동 · 프라이빗 룸 예약 가능
+
+
0.8km
+
+ +
+ + +
결과 공유
+
+
📢 조별과제 회의 확정!
+
📅 4월 16일 (수) 오후 2:00
+
📍 홍대·합정 일대 (중간지점)
+
MOIM으로 잡았어요 → moim.ai
+
+ + + +
+
+ + +
+ + + +
+
+
+
김유현
+
yoohyun@ssu.ac.kr
+
+ + 📍 서울 성북구 돈암동 + +
+
+
+ +
+ + +
연동된 + 캘린더
+
모든 일정이 합쳐져 가용시간이 계산돼요
+ +
+ + +
+
🗓️
+
+
구글 캘린더
+
✓ 연동됨 · yoohyun@gmail.com
+
+ +
+ + +
+
📱
+
+
에브리타임 시간표
+
✓ 연동됨 · 2026년 1학기
+
+ +
+ + +
+
🍎
+
+
애플 캘린더
+
미연동
+
+ +
+ + +
+
📁
+
+
.ics 파일 (네이버·다음)
+
미연동
+
+ +
+
+ +
+ + +
통합 캘린더 미리보기
+
연동된 캘린더가 합쳐진 모습이에요
+ +
+ +
+
+
구글 캘린더 +
+
+
에브리타임 수업 +
+
+
둘 다 있음 +
+
+ +
+ +
+
+
+ +
+ + +
📍 내 위치 설정
+
+ +
중간지점 계산에 사용돼요
+
+ + +
+ + + +
+
+ + +
+ + + + + \ No newline at end of file diff --git a/prototype/nudge_button_proposals.html b/prototype/nudge_button_proposals.html new file mode 100644 index 0000000..6aefd7e --- /dev/null +++ b/prototype/nudge_button_proposals.html @@ -0,0 +1,151 @@ + + + + + + MOIM 넛지 카드 버튼 UI 디자인 제안 + + + + + + + + + +
+
+

+ MOIM 넛지 카드 버튼 디자인 제안 +

+

+ 회원가입 유도 카드 내 버튼이 가독성을 해치지 않고, 하단 주요 버튼과 충돌을 피하기 위한 3가지 디자인 방안입니다. +

+
+ + +
+ + +
+
+

Option 1. 틴트 버튼 (Tinted / Soft Solid) 가장 추천

+ 배경 채도 다운, 가독성 극대화 +
+ + +
+
+ 💡 +
+

이 모임을 대시보드에 저장할까요?

+

+ 모임을 회원 대시보드에 저장해 두면 미응답 멤버 카톡 재촉 및 실시간 참여 메일 알림을 즉시 받아보실 수 있습니다. +

+ +
+
+
+
+ + +
+
+

Option 2. 아웃라인 버튼 (Sleek Outline)

+ 깔끔한 경계선, 낮은 비중 +
+ + +
+
+ 💡 +
+

이 모임을 대시보드에 저장할까요?

+

+ 모임을 회원 대시보드에 저장해 두면 미응답 멤버 카톡 재촉 및 실시간 참여 메일 알림을 즉시 받아보실 수 있습니다. +

+ +
+
+
+
+ + +
+
+

Option 3. 솔리드 버튼 최적화 (Optimized Solid)

+ 기존 색상 유지, 여백/가독성 복구 +
+ + +
+
+ 💡 +
+

이 모임을 대시보드에 저장할까요?

+

+ 모임을 회원 대시보드에 저장해 두면 미응답 멤버 카톡 재촉 및 실시간 참여 메일 알림을 즉시 받아보실 수 있습니다. +

+ +
+
+
+
+ +
+ + +
+

+ 제안 경로: prototype/nudge_button_proposals.html +

+
+ 마음에 드시는 옵션 번호를 말씀해 주시면 실제 코드에 즉시 반영해 드립니다. 😊 +
+
+ +
+ + + diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/scripts/ensure-sqlite-schema.mjs b/scripts/ensure-sqlite-schema.mjs new file mode 100644 index 0000000..63d74c3 --- /dev/null +++ b/scripts/ensure-sqlite-schema.mjs @@ -0,0 +1,112 @@ +import { PrismaClient } from "@prisma/client"; + +const dbUrl = process.env.DATABASE_URL || ""; +if (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://")) { + console.log("Using PostgreSQL database. Skipping SQLite schema auto-creation."); + process.exit(0); +} + +if (process.env.NODE_ENV !== "production") { + process.env.DATABASE_URL ||= "file:./dev.db"; +} + +const prisma = new PrismaClient(); + +try { + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT, + "phoneNumber" TEXT, + "nickname" TEXT NOT NULL, + "passwordHash" TEXT, + "isAgeOver14" BOOLEAN, + "termsAgreedAt" DATETIME, + "privacyAgreedAt" DATETIME, + "marketingAgreed" BOOLEAN NOT NULL DEFAULT false, + "eventSmsAgreed" BOOLEAN NOT NULL DEFAULT false, + "profileCompleted" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL + ) +`); + + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "SocialAccount" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "SocialAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE + ) +`); + + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "Schedule" ( + "id" TEXT NOT NULL PRIMARY KEY, + "hostTokenHash" TEXT NOT NULL, + "title" TEXT NOT NULL, + "durationMinutes" INTEGER NOT NULL, + "candidateDays" TEXT NOT NULL, + "candidateStartHour" INTEGER NOT NULL, + "candidateEndHour" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'open', + "confirmedSlot" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL + ) +`); + + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "ScheduleParticipant" ( + "id" TEXT NOT NULL PRIMARY KEY, + "scheduleId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "available" TEXT NOT NULL, + "submittedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ScheduleParticipant_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "Schedule" ("id") ON DELETE CASCADE ON UPDATE CASCADE + ) +`); + + await prisma.$executeRawUnsafe( + `CREATE UNIQUE INDEX IF NOT EXISTS "User_email_key" ON "User"("email")`, + ); + await prisma.$executeRawUnsafe( + `CREATE UNIQUE INDEX IF NOT EXISTS "User_phoneNumber_key" ON "User"("phoneNumber")`, + ); + await prisma.$executeRawUnsafe( + `CREATE UNIQUE INDEX IF NOT EXISTS "User_nickname_key" ON "User"("nickname")`, + ); + await prisma.$executeRawUnsafe( + `CREATE UNIQUE INDEX IF NOT EXISTS "SocialAccount_provider_providerUserId_key" ON "SocialAccount"("provider", "providerUserId")`, + ); + await prisma.$executeRawUnsafe( + `CREATE UNIQUE INDEX IF NOT EXISTS "Schedule_hostTokenHash_key" ON "Schedule"("hostTokenHash")`, + ); + await prisma.$executeRawUnsafe( + `CREATE INDEX IF NOT EXISTS "ScheduleParticipant_scheduleId_idx" ON "ScheduleParticipant"("scheduleId")`, + ); + + const scheduleColumns = await prisma.$queryRawUnsafe( + `PRAGMA table_info("Schedule")`, + ); + const scheduleColumnNames = new Set( + scheduleColumns.map((column) => column.name), + ); + + if (!scheduleColumnNames.has("status")) { + await prisma.$executeRawUnsafe( + `ALTER TABLE "Schedule" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'open'`, + ); + } + + if (!scheduleColumnNames.has("confirmedSlot")) { + await prisma.$executeRawUnsafe( + `ALTER TABLE "Schedule" ADD COLUMN "confirmedSlot" TEXT`, + ); + } +} finally { + await prisma.$disconnect(); +} diff --git a/scripts/seed_availability.js b/scripts/seed_availability.js new file mode 100644 index 0000000..76a2e12 --- /dev/null +++ b/scripts/seed_availability.js @@ -0,0 +1,100 @@ +/* eslint-disable */ +const { PrismaClient } = require("@prisma/client"); +const prisma = new PrismaClient(); + +async function main() { + const scheduleId = "mamE-2Jh9Ng2erhfTrEMgUT3"; + + // 스케줄 더미 정보 확보 (없으면 생성) + await prisma.schedule.upsert({ + where: { id: scheduleId }, + update: { + hostTokenHash: "PMcSBcCq_9Ue4PqCJYvbPFrEC9X5RMx4oeAs0hTIoUQ", + title: "디자인 개선 정기 회의", + durationMinutes: 60, + candidateDays: JSON.stringify(["MON", "TUE", "WED"]), + candidateStartHour: 10, + candidateEndHour: 18, + status: "open", + }, + create: { + id: scheduleId, + hostTokenHash: "PMcSBcCq_9Ue4PqCJYvbPFrEC9X5RMx4oeAs0hTIoUQ", + title: "디자인 개선 정기 회의", + durationMinutes: 60, + candidateDays: JSON.stringify(["MON", "TUE", "WED"]), + candidateStartHour: 10, + candidateEndHour: 18, + status: "open", + }, + }); + + // 기존 해당 스케줄 참여자 데이터 클리어 (중복 방지) + await prisma.scheduleParticipant.deleteMany({ + where: { scheduleId }, + }); + + const dummyData = [ + { + id: "dummy-p1", + scheduleId, + name: "김영희", + available: JSON.stringify([ + { day: "MON", startHour: 10, endHour: 11 }, + { day: "TUE", startHour: 14, endHour: 15 }, + { day: "TUE", startHour: 15, endHour: 16 }, + ]), + }, + { + id: "dummy-p2", + scheduleId, + name: "이철수", + available: JSON.stringify([ + { day: "TUE", startHour: 14, endHour: 15 }, + { day: "TUE", startHour: 15, endHour: 16 }, + { day: "WED", startHour: 11, endHour: 12 }, + { day: "WED", startHour: 12, endHour: 13 }, + ]), + }, + { + id: "dummy-p3", + scheduleId, + name: "박민수", + available: JSON.stringify([ + { day: "MON", startHour: 10, endHour: 11 }, + { day: "TUE", startHour: 14, endHour: 15 }, + { day: "TUE", startHour: 15, endHour: 16 }, + { day: "WED", startHour: 11, endHour: 12 }, + { day: "WED", startHour: 12, endHour: 13 }, + ]), + }, + { + id: "dummy-p4", + scheduleId, + name: "최지수", + available: JSON.stringify([ + { day: "TUE", startHour: 14, endHour: 15 }, + { day: "TUE", startHour: 15, endHour: 16 }, + ]), + }, + ]; + + for (const participant of dummyData) { + await prisma.scheduleParticipant.create({ + data: participant, + }); + } + + console.log( + "Successfully seeded dummy availability data to SQLite database!", + ); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/with-database-url.mjs b/scripts/with-database-url.mjs new file mode 100644 index 0000000..7723562 --- /dev/null +++ b/scripts/with-database-url.mjs @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import dotenv from "dotenv"; + +if (existsSync(".env.test")) { + dotenv.config({ path: ".env.test" }); +} else if (existsSync(".env")) { + dotenv.config(); +} + +process.env.DATABASE_URL ||= "file:./dev.db"; + +const [command, ...args] = process.argv.slice(2); + +if (!command) { + console.error( + "Usage: node scripts/with-database-url.mjs [...args]", + ); + process.exit(1); +} + +const localBin = resolve( + "node_modules", + ".bin", + process.platform === "win32" ? `${command}.cmd` : command, +); +let executable = existsSync(localBin) ? localBin : command; +let executableArgs = args; + +if (process.platform === "win32" && existsSync(localBin)) { + const shimTarget = resolveWindowsNodeShim(localBin); + if (shimTarget) { + executable = process.execPath; + executableArgs = [shimTarget, ...args]; + } +} + +const child = spawn(executable, executableArgs, { + env: process.env, + stdio: "inherit", +}); + +child.on("exit", (code, signal) => { + if (signal) { + console.error(`Command terminated by ${signal}`); + process.exit(1); + } + process.exit(code ?? 1); +}); + +function resolveWindowsNodeShim(cmdPath) { + const content = readFileSync(cmdPath, "utf8"); + const match = content.match(/"%dp0%\\(\.\.[^"]+)"/); + return match ? resolve(dirname(cmdPath), match[1]) : null; +} diff --git a/src/app/(auth)/callback/route.ts b/src/app/(auth)/callback/route.ts deleted file mode 100644 index b25ff9c..0000000 --- a/src/app/(auth)/callback/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextResponse } from "next/server"; -import { createClient } from "@/shared/lib/supabase/server"; - -/** - * Google OAuth 콜백 핸들러 - * Supabase Auth가 리다이렉트한 code를 세션으로 교환하고, - * provider_refresh_token을 calendar_connections에 저장한다. - * - * PRD B안: OAuth 연동 = 로그인 + 캘린더 연동 동시 처리 - */ -export async function GET(request: Request) { - const { searchParams, origin } = new URL(request.url); - const code = searchParams.get("code"); - const next = searchParams.get("next") ?? "/"; - - if (!code) { - return NextResponse.redirect(`${origin}/login?error=no_code`); - } - - const supabase = await createClient(); - - // code → session 교환 - const { data, error } = await supabase.auth.exchangeCodeForSession(code); - - if (error || !data.session) { - return NextResponse.redirect(`${origin}/login?error=auth_failed`); - } - - const { session } = data; - - // Google provider_refresh_token이 있으면 calendar_connections에 저장 - // (최초 로그인 시에만 refresh_token이 제공됨) - if (session.provider_token && session.provider_refresh_token) { - const { error: upsertError } = await supabase - .from("calendar_connections") - .upsert( - { - profile_id: session.user.id, - provider: "google", - access_token: session.provider_token, - refresh_token: session.provider_refresh_token, - token_expires_at: new Date( - Date.now() + (session.expires_in ?? 3600) * 1000 - ).toISOString(), - calendar_id: "primary", - }, - { onConflict: "profile_id,provider" } - ); - - if (upsertError) { - console.error("calendar_connections upsert failed:", upsertError); - } - } - - // profiles 테이블에 사용자 정보 upsert (is_guest = false) - const user = session.user; - await supabase.from("profiles").upsert( - { - id: user.id, - display_name: - user.user_metadata?.full_name ?? user.user_metadata?.name ?? null, - avatar_url: user.user_metadata?.avatar_url ?? null, - is_guest: false, - }, - { onConflict: "id" } - ); - - return NextResponse.redirect(`${origin}${next}`); -} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..c6ed8e4 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import Link from "next/link"; +import { CheckCircle2, Mail } from "lucide-react"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSent, setIsSent] = useState(false); + const [message, setMessage] = useState(""); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setMessage(""); + setIsSubmitting(true); + + try { + const response = await fetch("/api/auth/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.message ?? "요청 처리에 실패했습니다."); + } + setIsSent(true); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+ + MOIM + +

비밀번호 찾기

+

+ 가입하신 이메일 주소를 입력해 주시면 +
+ 비밀번호 재설정 메일을 보내드립니다. +

+
+ + {isSent ? ( +
+ +

이메일 발송 완료

+

+ {email}(으)로 +
+ 비밀번호 재설정 링크를 전송했습니다. +
+ 메일함의 링크를 통해 비밀번호를 변경해 주세요. +

+ + 로그인 화면으로 이동 + +
+ ) : ( +
+ + + {message ? ( +

+ {message} +

+ ) : null} + + + + + 로그인 화면으로 돌아가기 + +
+ )} +
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..a2c9ee8 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,21 @@ +/** + * 인증 레이아웃 — 로그인/회원가입 전용 + * + * (auth) 괄호 그룹은 Next.js App Router에서 URL에 영향을 주지 않고 + * 레이아웃만 분리하는 기법이다. + * + * 예: /login 페이지는 GNB(상단 메뉴)가 필요 없으므로, + * 루트 layout.tsx의 GNB를 상속받지 않고 이 레이아웃을 사용한다. + */ + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..b1aa3af --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { FormEvent, useState, useEffect } from "react"; +import Link from "next/link"; +import { Eye, EyeOff } from "lucide-react"; +import { AuthProviderGlyph } from "@/components/moim/auth-social"; +import { createClient } from "@/lib/supabase/client"; + +export default function LoginPage() { + const [loginId, setLoginId] = useState(""); + const [password, setPassword] = useState(""); + const [remember, setRemember] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [message, setMessage] = useState(""); + const [mounted, setMounted] = useState(false); + const [lastProvider, setLastProvider] = useState(null); + + useEffect(() => { + setMounted(true); + const cookies = document.cookie.split("; "); + const providerCookie = cookies.find((row) => + row.startsWith("last_login_provider="), + ); + if (providerCookie) { + setLastProvider(providerCookie.split("=")[1]); + } + }, []); + + async function handleSubmit(event?: FormEvent) { + if (event) event.preventDefault(); + if (isSubmitting) return; + setMessage(""); + setIsSubmitting(true); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ loginId, password, remember }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.message ?? "로그인에 실패했습니다."); + } + + // Webkit/Safari 쿠키 디스크 동기화 대기 시간 부여 + await new Promise((resolve) => setTimeout(resolve, 2000)); + + let redirectUrl = "/schedule/create"; + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const next = params.get("redirect") ?? params.get("next"); + if (next && (next.startsWith("/") || !next.includes("://"))) { + redirectUrl = next; + } + } + window.location.href = redirectUrl; + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsSubmitting(false); + } + } + + const handleOAuthLogin = async (provider: "google" | "kakao" | "apple") => { + const supabase = createClient(); + let next = "/schedule/create"; + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const nextParam = params.get("redirect") ?? params.get("next"); + if ( + nextParam && + (nextParam.startsWith("/") || !nextParam.includes("://")) + ) { + next = nextParam; + } + } + try { + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/api/auth/callback?next=${encodeURIComponent(next)}`, + }, + }); + if (error) throw error; + } catch (err) { + console.error(`${provider} 로그인 에러:`, err); + } + }; + + return ( +
+
+
+ + MOIM + +

+ 모임을 더 가깝게, 일상을 더 특별하게 +

+
+ +
e.preventDefault()} className="grid gap-3.5"> + + + + + + {message ? ( +

+ {message} +

+ ) : null} + +
+ + {mounted && lastProvider === "local" && ( + + 최근 사용 + + )} +
+
+ +
+
+ 또는 +
+
+ +
+ + + + 네이버로 + 시작하기 + {mounted && lastProvider === "naver" && ( + + 최근 사용 + + )} + + +
+ +

+ 아직 계정이 없으신가요?{" "} + + 회원가입 + +

+
+
+ ); +} diff --git a/src/app/(auth)/reset-password/complete/page.tsx b/src/app/(auth)/reset-password/complete/page.tsx new file mode 100644 index 0000000..65aa6b6 --- /dev/null +++ b/src/app/(auth)/reset-password/complete/page.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { FormEvent, useState, useEffect } from "react"; +import Link from "next/link"; +import { CheckCircle2, Eye, EyeOff } from "lucide-react"; + +export default function ResetPasswordCompletePage() { + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [message, setMessage] = useState(""); + + const [touched, setTouched] = useState({ + password: false, + passwordConfirm: false, + }); + + const errors = { + password: + password && + (password.length < 8 || + !/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':",./<>?]).{8,}$/.test( + password, + )) + ? "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상이어야 합니다." + : "", + passwordConfirm: + passwordConfirm && password !== passwordConfirm + ? "비밀번호가 일치하지 않습니다." + : "", + }; + + function handleBlur(field: keyof typeof touched) { + setTouched((prev) => ({ ...prev, [field]: true })); + } + + // Supabase Auth가 hash fragment (#access_token=...)에 있는 인증 코드를 파싱하고 세션을 초기화할 수 있도록 마운트 시 대기 + useEffect(() => { + // Supabase Client가 토큰을 브라우저에 적재할 시간을 보장 + const timer = setTimeout(async () => { + try { + const { createClient } = await import("@/lib/supabase/client"); + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + setMessage( + "인증 세션이 만료되었거나 올바르지 않습니다. 다시 시도해 주세요.", + ); + } + } catch (err) { + console.error("세션 체크 실패:", err); + } + }, 1000); + + return () => clearTimeout(timer); + }, []); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setMessage(""); + + if (password !== passwordConfirm) { + setMessage("비밀번호 확인이 일치하지 않습니다."); + return; + } + + if (errors.password) { + setMessage("비밀번호가 안전 규격에 맞지 않습니다."); + return; + } + + setIsSubmitting(true); + + try { + const { createClient } = await import("@/lib/supabase/client"); + const supabase = createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + if (!session) { + throw new Error( + "비밀번호를 재설정할 권한이 없습니다. 세션이 만료되었습니다.", + ); + } + + // API를 호출하여 SQLite DB의 유저 passwordHash를 업데이트 + const response = await fetch("/api/auth/reset-password/complete", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ password }), + }); + + const result = await response.json(); + if (!response.ok) { + throw new Error(result.message ?? "비밀번호 업데이트에 실패했습니다."); + } + + // Supabase Auth 세션 로그아웃 처리 (로컬 자체 JWT 로그인으로 단일화하기 위함) + await supabase.auth.signOut(); + + setIsComplete(true); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "비밀번호 업데이트 실패", + ); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+ + MOIM + +

새 비밀번호 설정

+

+ 보안성이 우수한 새로운 비밀번호를 설정해 주세요. +

+
+ + {isComplete ? ( +
+ +

비밀번호 변경 완료

+

+ 비밀번호가 안전하게 변경되었습니다. +
+ 새로운 비밀번호로 로그인해 주세요. +

+ + 로그인하러 가기 + +
+ ) : ( +
+ + + + + {message ? ( +

+ {message} +

+ ) : null} + + +
+ )} +
+
+ ); +} diff --git a/src/app/(auth)/signup/additional-info/page.tsx b/src/app/(auth)/signup/additional-info/page.tsx new file mode 100644 index 0000000..bd7f6c3 --- /dev/null +++ b/src/app/(auth)/signup/additional-info/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { CheckCircle2, ChevronRight } from "lucide-react"; +import { TermsModal, TermsKey } from "@/components/moim/TermsModal"; + +export default function AdditionalInfoPage() { + const router = useRouter(); + const [form, setForm] = useState({ + phoneNumber: "", + isAgeOver14: false, + termsAgreed: false, + privacyAgreed: false, + marketingAgreed: false, + eventSmsAgreed: false, + }); + const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [activeTermsKey, setActiveTermsKey] = useState(null); + + function updateField(name: keyof typeof form, value: string | boolean) { + setForm((current) => ({ ...current, [name]: value })); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setMessage(""); + setIsSubmitting(true); + + try { + const response = await fetch("/api/auth/profile/complete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.message ?? "추가 정보 저장에 실패했습니다."); + } + router.push("/calendar/connect"); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+ + MOIM + +

+ 마지막 정보만 확인하면 바로 모임을 만들 수 있어요 +

+
+ +
+ +

+ 소셜 계정 연결이 완료되었습니다. +

+
+ +
+ + +
+ 약관 동의 +
+ {[ + ["isAgeOver14", "만 14세 이상입니다", "필수"], + ["termsAgreed", "이용약관", "필수"], + ["privacyAgreed", "개인정보수집 및 이용동의", "필수"], + ["marketingAgreed", "개인정보 마케팅 활용 동의", "선택"], + ["eventSmsAgreed", "이벤트, 쿠폰 및 SMS 등 수신", "선택"], + ].map(([name, label, required]) => ( +
+ + +
+ ))} +
+
+ + {message ? ( +

+ {message} +

+ ) : null} + + +
+
+ + {activeTermsKey && ( + setActiveTermsKey(null)} + /> + )} +
+ ); +} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..51d9848 --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -0,0 +1,492 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import Link from "next/link"; +import { CheckCircle2, ChevronRight, Eye, EyeOff } from "lucide-react"; +import { AuthProviderGlyph } from "@/components/moim/auth-social"; +import { TermsModal, TermsKey } from "@/components/moim/TermsModal"; +import { cn } from "@/lib/utils"; +import { createClient } from "@/lib/supabase/client"; + +export default function SignupPage() { + const [form, setForm] = useState({ + email: "", + phoneNumber: "", + nickname: "", + password: "", + passwordConfirm: "", + isAgeOver14: false, + termsAgreed: false, + privacyAgreed: false, + marketingAgreed: false, + eventSmsAgreed: false, + }); + const [message, setMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isComplete, setIsComplete] = useState(false); + + const [showPassword, setShowPassword] = useState(false); + const [showPasswordConfirm, setShowPasswordConfirm] = useState(false); + const [activeTermsKey, setActiveTermsKey] = useState(null); + const [touched, setTouched] = useState({ + email: false, + phoneNumber: false, + nickname: false, + password: false, + passwordConfirm: false, + }); + + const errors = { + email: + form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email) + ? "올바른 이메일 형식이 아닙니다." + : "", + phoneNumber: + form.phoneNumber && !/^01[016789]-?\d{3,4}-?\d{4}$/.test(form.phoneNumber) + ? "올바른 전화번호 형식이 아닙니다. (예: 010-0000-0000)" + : "", + nickname: + form.nickname && form.nickname.length < 2 + ? "닉네임은 2자 이상이어야 합니다." + : "", + password: + form.password && + (form.password.length < 8 || + !/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':",./<>?]).{8,}$/.test( + form.password, + )) + ? "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상이어야 합니다." + : "", + passwordConfirm: + form.passwordConfirm && form.password !== form.passwordConfirm + ? "비밀번호가 일치하지 않습니다." + : "", + }; + + function handleBlur(field: keyof typeof touched) { + setTouched((prev) => ({ ...prev, [field]: true })); + } + + function updateField(name: keyof typeof form, value: string | boolean) { + setForm((current) => ({ ...current, [name]: value })); + } + + async function handleSubmit(event?: FormEvent) { + if (event) event.preventDefault(); + if (isSubmitting) return; + setMessage(""); + setIsSubmitting(true); + + try { + if (form.password !== form.passwordConfirm) { + throw new Error("비밀번호 확인이 일치하지 않습니다."); + } + if (!form.isAgeOver14 || !form.termsAgreed || !form.privacyAgreed) { + throw new Error("필수 약관에 모두 동의해 주세요."); + } + const response = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.message ?? "회원가입에 실패했습니다."); + } + setIsComplete(true); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+ + MOIM + +

+ 모임을 더 가깝게, 일상을 더 특별하게 +

+
+ + {isComplete ? ( +
+ +

회원가입 완료

+

+ 이제 캘린더를 연동하거나 바로 모임을 만들 수 있습니다. +

+
+ {(() => { + let redirectUrl = ""; + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const next = params.get("redirect") ?? params.get("next"); + if (next && (next.startsWith("/") || !next.includes("://"))) { + redirectUrl = next; + } + } + if (redirectUrl) { + return ( + + 모임 저장 완료 (대시보드로 이동) + + ); + } + return ( + <> + + 캘린더 연동하기 + + + 모임 만들기 + + + ); + })()} +
+
+ ) : ( + <> +
+ + + + +
+ +
+ +
e.preventDefault()} className="grid gap-4"> + updateField("email", value)} + onBlur={() => handleBlur("email")} + error={touched.email && errors.email ? errors.email : ""} + type="email" + autoComplete="email" + /> + updateField("phoneNumber", value)} + onBlur={() => handleBlur("phoneNumber")} + error={ + touched.phoneNumber && errors.phoneNumber + ? errors.phoneNumber + : "" + } + autoComplete="tel" + /> + updateField("nickname", value)} + onBlur={() => handleBlur("nickname")} + error={ + touched.nickname && errors.nickname ? errors.nickname : "" + } + autoComplete="nickname" + /> + updateField("password", value)} + onBlur={() => handleBlur("password")} + showPassword={showPassword} + onToggleShow={() => setShowPassword(!showPassword)} + error={ + touched.password && errors.password ? errors.password : "" + } + /> + updateField("passwordConfirm", value)} + onBlur={() => handleBlur("passwordConfirm")} + showPassword={showPasswordConfirm} + onToggleShow={() => + setShowPasswordConfirm(!showPasswordConfirm) + } + error={ + touched.passwordConfirm && errors.passwordConfirm + ? errors.passwordConfirm + : "" + } + /> + +
+ 약관 동의 +
+ {[ + ["isAgeOver14", "만 14세 이상입니다", "필수"], + ["termsAgreed", "이용약관", "필수"], + ["privacyAgreed", "개인정보수집 및 이용동의", "필수"], + ["marketingAgreed", "개인정보 마케팅 활용 동의", "선택"], + ["eventSmsAgreed", "이벤트, 쿠폰 및 SMS 등 수신", "선택"], + ].map(([name, label, required]) => ( +
+ + +
+ ))} +
+
+ + {message ? ( +

+ {message} +

+ ) : null} + + + +

+ 계정이 있으신가요?{" "} + + 로그인 + +

+ + )} +
+ + {activeTermsKey && ( + setActiveTermsKey(null)} + /> + )} +
+ ); +} + +function SocialButton({ + type, + label, + dark = false, +}: { + type: "kakao" | "google" | "naver" | "apple"; + label: string; + dark?: boolean; +}) { + const handleOAuthLogin = async (provider: "google" | "kakao" | "apple") => { + const supabase = createClient(); + let next = "/schedule/create"; + if (typeof window !== "undefined") { + const params = new URLSearchParams(window.location.search); + const nextParam = params.get("redirect") ?? params.get("next"); + if ( + nextParam && + (nextParam.startsWith("/") || !nextParam.includes("://")) + ) { + next = nextParam; + } + } + try { + const { error } = await supabase.auth.signInWithOAuth({ + provider, + options: { + redirectTo: `${window.location.origin}/api/auth/callback?next=${encodeURIComponent(next)}`, + }, + }); + if (error) throw error; + } catch (err) { + console.error(`${provider} 로그인 에러:`, err); + } + }; + + const className = dark + ? "bg-[#171717] text-white" + : type === "kakao" + ? "bg-[#fee500] text-[#191919]" + : type === "naver" + ? "bg-[#03c75a] text-white" + : "border border-brand-border-gray bg-white text-brand-text-primary"; + const content = ( + <> + + {label} + + ); + if (type === "naver") { + return ( + + {content} + + ); + } + return ( + + ); +} + +function TextField({ + id, + label, + value, + onChange, + type = "text", + autoComplete, + onBlur, + error, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + type?: string; + autoComplete?: string; + onBlur?: () => void; + error?: string; +}) { + return ( + + ); +} + +function PasswordField({ + id, + label, + value, + onChange, + showPassword, + onToggleShow, + onBlur, + error, +}: { + id: string; + label: string; + value: string; + onChange: (value: string) => void; + showPassword?: boolean; + onToggleShow?: () => void; + onBlur?: () => void; + error?: string; +}) { + return ( + + ); +} diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..c4477f6 --- /dev/null +++ b/src/app/api/auth/callback/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export async function GET(request: Request) { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get("code"); + const next = searchParams.get("next") ?? "/"; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(`${origin}${next}`); + } + } + + // Authentication failed: redirect back to login with an error message + return NextResponse.redirect(`${origin}/login?error=auth_failed`); +} diff --git a/src/app/api/auth/dev-bypass/route.ts b/src/app/api/auth/dev-bypass/route.ts new file mode 100644 index 0000000..35e25b7 --- /dev/null +++ b/src/app/api/auth/dev-bypass/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + // 개발 모드와 E2E 테스트 모드에서만 동작하도록 가드 처리 + if ( + process.env.NODE_ENV !== "development" && + process.env.E2E_TEST !== "true" + ) { + return NextResponse.json( + { success: false, message: "접근 권한이 없습니다." }, + { status: 403 }, + ); + } + + let body: { role?: string }; + try { + body = await req.json(); + } catch { + body = {}; + } + + const role = body.role || "admin"; + let email = "admin@moim.com"; + let nickname = "관리자"; + let phoneNumber = "010-0000-0000"; + + if (role === "host") { + email = "host@moim.com"; + nickname = "호스트"; + phoneNumber = "010-1111-1111"; + } + + try { + // 1. 기존 유저가 있는지 확인 + let user = await prisma.user.findUnique({ + where: { email }, + }); + + // 2. 존재하지 않으면 자동 회원 생성 (DB 싱크) + if (!user) { + user = await prisma.user.create({ + data: { + email, + nickname, + phoneNumber, + passwordHash: "$2a$10$abcdefghijklmnopqrstuv", // Dummy hash + isAgeOver14: true, + termsAgreedAt: new Date(), + privacyAgreedAt: new Date(), + marketingAgreed: false, + eventSmsAgreed: false, + }, + }); + } + + const res = NextResponse.json( + { + success: true, + message: `${nickname} 계정으로 우회 로그인되었습니다.`, + user: { + id: user.id, + email: user.email, + nickname: user.nickname, + }, + }, + { status: 200 }, + ); + + // E2E / Mock 쿠키 세션 주입 + res.cookies.set("e2e_mock_uid", user.id, { + path: "/", + maxAge: 60 * 60 * 24, + }); + res.cookies.set("e2e_mock_email", user.email || "", { + path: "/", + maxAge: 60 * 60 * 24, + }); + res.cookies.set("e2e_mock_nickname", user.nickname, { + path: "/", + maxAge: 60 * 60 * 24, + }); + res.cookies.set("last_login_provider", "local", { + path: "/", + maxAge: 60 * 60 * 24, + }); + + return res; + } catch (err) { + console.error("[dev-bypass] 서버 에러:", err); + return NextResponse.json( + { success: false, message: "서버 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 0000000..fd77836 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + let body: { email?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { success: false, message: "요청 형식이 올바르지 않습니다." }, + { status: 400 }, + ); + } + + const { email } = body; + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json( + { success: false, message: "올바른 이메일 주소를 입력해 주세요." }, + { status: 400 }, + ); + } + + try { + const supabase = await createClient(); + const normalizedEmail = email.trim().toLowerCase(); + + // 1. 해당 이메일로 가입된 유저가 profiles 테이블에 존재하는지 체크 + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("id") + .eq("email", normalizedEmail) + .maybeSingle(); + + if (profileError || !profile) { + return NextResponse.json( + { success: false, message: "가입되지 않은 이메일 주소입니다." }, + { status: 404 }, + ); + } + + // 2. Supabase Auth를 이용하여 비밀번호 재설정 링크 발송 + const origin = new URL(req.url).origin; + const redirectTo = `${origin}/reset-password/complete`; + + const { error } = await supabase.auth.resetPasswordForEmail( + email.trim().toLowerCase(), + { + redirectTo, + }, + ); + + if (error) { + console.error("[auth.forgot-password] Supabase 에러:", error.message); + return NextResponse.json( + { + success: false, + message: + "비밀번호 재설정 메일 발송에 실패했습니다. 다시 시도해 주세요.", + }, + { status: 500 }, + ); + } + + return NextResponse.json( + { + success: true, + message: "비밀번호 재설정 메일이 성공적으로 전송되었습니다.", + }, + { status: 200 }, + ); + } catch (err) { + console.error("[auth.forgot-password] 서버 오류:", err); + return NextResponse.json( + { success: false, message: "서버 내부 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..c7338b6 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { prisma } from "@/lib/prisma"; +import { loginSchema, isEmail } from "@/features/auth/login.schema"; + +export const dynamic = "force-dynamic"; + +const AUTH_FAIL_MESSAGE = "아이디 또는 비밀번호가 올바르지 않습니다."; + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { success: false, message: "요청 형식이 올바르지 않습니다." }, + { status: 400 }, + ); + } + + const result = loginSchema.safeParse(body); + if (!result.success) { + return NextResponse.json( + { success: false, message: result.error.issues[0].message }, + { status: 400 }, + ); + } + + const { loginId, password } = result.data; + + try { + // Supabase signInWithPassword는 이메일을 요구한다. + // 닉네임으로 로그인한 경우 profiles에서 이메일을 먼저 조회한다. + let email: string | null = null; + + if (isEmail(loginId)) { + email = loginId.trim().toLowerCase(); + } else { + const admin = createAdminClient(); + const { data: profile, error: profileError } = await admin + .from("profiles") + .select("email") + .eq("nickname", loginId) + .maybeSingle(); + // DB/RLS 오류를 401(인증 실패)로 위장하지 않도록 분리해 처리한다. + if (profileError) throw profileError; + email = profile?.email ?? null; + } + + if (!email) { + return NextResponse.json( + { success: false, message: AUTH_FAIL_MESSAGE }, + { status: 401 }, + ); + } + + if (process.env.E2E_TEST === "true") { + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return NextResponse.json( + { success: false, message: AUTH_FAIL_MESSAGE }, + { status: 401 }, + ); + } + + const res = NextResponse.json( + { + success: true, + message: "로그인에 성공했습니다. (E2E MOCK)", + user: { + id: user.id, + email: user.email, + }, + }, + { status: 200 }, + ); + + res.cookies.set("e2e_mock_uid", user.id, { path: "/" }); + res.cookies.set("e2e_mock_email", user.email || "", { path: "/" }); + res.cookies.set("e2e_mock_nickname", user.nickname, { path: "/" }); + res.cookies.set("last_login_provider", "local", { path: "/" }); + + return res; + } + + const supabase = await createClient(); + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + // 이메일 확인(Confirm email)이 켜져 있으면 미인증 사용자는 + // data.user가 있어도 data.session이 null이다. 세션까지 확인한다. + if (error || !data.user || !data.session) { + return NextResponse.json( + { success: false, message: AUTH_FAIL_MESSAGE }, + { status: 401 }, + ); + } + + const res = NextResponse.json( + { + success: true, + message: "로그인에 성공했습니다.", + user: { + id: data.user.id, + email: data.user.email, + }, + }, + { status: 200 }, + ); + + const isProd = + process.env.NODE_ENV === "production" && process.env.E2E_TEST !== "true"; + res.cookies.set("last_login_provider", "local", { + httpOnly: false, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 365, // 1년 + }); + + return res; + } catch (err) { + console.error("[auth.login] 서버 오류:", err); + return NextResponse.json( + { success: false, message: "서버 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..aaf53c2 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + const supabase = await createClient(); + const { error } = await supabase.auth.signOut(); + if (error) { + // 세션 정리 실패는 치명적이지 않으므로 로깅 후 성공 응답을 유지한다. + console.error("[auth.logout] signOut 오류:", error); + } + + return NextResponse.json( + { success: true, message: "로그아웃되었습니다." }, + { status: 200 }, + ); +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..96d4f3e --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { getSession } from "@/lib/auth/session"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await getSession(); + + if (!session) { + return NextResponse.json( + { success: false, message: "인증이 필요합니다." }, + { status: 401 }, + ); + } + + try { + const supabase = await createClient(); + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("id, email, nickname, phone_number") + .eq("id", session.userId) + .maybeSingle(); + // DB 오류를 폴백값으로 가리지 않고 500으로 surface한다. + if (profileError) throw profileError; + + return NextResponse.json({ + success: true, + user: { + id: session.userId, + email: profile?.email ?? session.email, + nickname: profile?.nickname ?? null, + phoneNumber: profile?.phone_number ?? null, + }, + }); + } catch (err) { + console.error("[auth.me] 서버 오류:", err); + return NextResponse.json( + { success: false, message: "서버 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/naver/callback/route.ts b/src/app/api/auth/naver/callback/route.ts new file mode 100644 index 0000000..35cea05 --- /dev/null +++ b/src/app/api/auth/naver/callback/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const origin = new URL(request.url).origin; + return NextResponse.redirect(`${origin}/login?error=naver_not_implemented`); +} diff --git a/src/app/api/auth/naver/login/route.ts b/src/app/api/auth/naver/login/route.ts new file mode 100644 index 0000000..35cea05 --- /dev/null +++ b/src/app/api/auth/naver/login/route.ts @@ -0,0 +1,8 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + const origin = new URL(request.url).origin; + return NextResponse.redirect(`${origin}/login?error=naver_not_implemented`); +} diff --git a/src/app/api/auth/profile/complete/route.ts b/src/app/api/auth/profile/complete/route.ts new file mode 100644 index 0000000..9a18d1e --- /dev/null +++ b/src/app/api/auth/profile/complete/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth/session"; +import { createClient } from "@/lib/supabase/server"; +import { normalizePhoneNumber } from "@/features/auth/signup.schema"; +import { socialProfileSchema } from "@/features/auth/social-profile.schema"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + const session = await getSession(); + if (!session) { + return NextResponse.json( + { success: false, message: "인증이 필요합니다." }, + { status: 401 }, + ); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { success: false, message: "요청 형식이 올바르지 않습니다." }, + { status: 400 }, + ); + } + + const result = socialProfileSchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + success: false, + message: firstError.message, + field: firstError.path[0] ?? null, + }, + { status: 422 }, + ); + } + + const { + phoneNumber, + isAgeOver14, + termsAgreed, + privacyAgreed, + marketingAgreed, + eventSmsAgreed, + } = result.data; + + try { + const now = new Date().toISOString(); + const supabase = await createClient(); + + const { data: profile, error: updateError } = await supabase + .from("profiles") + .update({ + phone_number: normalizePhoneNumber(phoneNumber), + is_age_over_14: isAgeOver14, + terms_agreed_at: termsAgreed ? now : null, + privacy_agreed_at: privacyAgreed ? now : null, + marketing_agreed: marketingAgreed, + event_sms_agreed: eventSmsAgreed, + }) + .eq("id", session.userId) + .select() + .maybeSingle(); + + if (updateError) { + if ( + updateError.code === "23505" || + /unique|duplicate/i.test(updateError.message) + ) { + return NextResponse.json( + { + success: false, + message: "이미 사용 중인 전화번호입니다.", + field: "phoneNumber", + }, + { status: 409 }, + ); + } + throw updateError; + } + + if (!profile) { + return NextResponse.json( + { success: false, message: "사용자를 찾을 수 없습니다." }, + { status: 401 }, + ); + } + + return NextResponse.json({ + success: true, + message: "추가 정보 입력이 완료되었습니다.", + user: { + id: profile.id, + email: profile.email, + phoneNumber: profile.phone_number, + nickname: profile.nickname, + }, + }); + } catch (err) { + console.error("[auth.profile.complete] error:", err); + return NextResponse.json( + { success: false, message: "서버 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/reset-password/complete/route.ts b/src/app/api/auth/reset-password/complete/route.ts new file mode 100644 index 0000000..9b27b6f --- /dev/null +++ b/src/app/api/auth/reset-password/complete/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + let body: { password?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { success: false, message: "요청 형식이 올바르지 않습니다." }, + { status: 400 }, + ); + } + + const { password } = body; + if ( + !password || + password.length < 8 || + !/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':",./<>?]).{8,}$/.test( + password, + ) + ) { + return NextResponse.json( + { + success: false, + message: + "비밀번호는 영문, 숫자, 특수문자를 포함하여 8자 이상이어야 합니다.", + }, + { status: 400 }, + ); + } + + // 1. Authorization 헤더로부터 Bearer 토큰 추출 + const authHeader = req.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return NextResponse.json( + { success: false, message: "인증 정보가 누락되었습니다." }, + { status: 401 }, + ); + } + const token = authHeader.split(" ")[1]; + + try { + // 2. Supabase Auth를 통해 토큰으로 세션을 설정하고 비밀번호 변경 + const supabase = await createClient(); + const { error: sessionError } = await supabase.auth.setSession({ + access_token: token, + refresh_token: "", + }); + + if (sessionError) { + console.error( + "[auth.reset-password.complete] setSession 에러:", + sessionError.message, + ); + return NextResponse.json( + { + success: false, + message: "유효하지 않거나 만료된 세션입니다. 다시 시도해 주세요.", + }, + { status: 401 }, + ); + } + + const { error: updateError } = await supabase.auth.updateUser({ + password, + }); + + if (updateError) { + console.error( + "[auth.reset-password.complete] updateUser 에러:", + updateError.message, + ); + return NextResponse.json( + { + success: false, + message: "비밀번호 변경에 실패했습니다. 다시 시도해 주세요.", + }, + { status: 500 }, + ); + } + + return NextResponse.json( + { success: true, message: "비밀번호가 성공적으로 변경되었습니다." }, + { status: 200 }, + ); + } catch (err) { + console.error("[auth.reset-password.complete] 서버 오류:", err); + return NextResponse.json( + { success: false, message: "서버 내부 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts new file mode 100644 index 0000000..401cc02 --- /dev/null +++ b/src/app/api/auth/signup/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { prisma } from "@/lib/prisma"; +import { + signupSchema, + normalizePhoneNumber, +} from "@/features/auth/signup.schema"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { success: false, message: "요청 형식이 올바르지 않습니다." }, + { status: 400 }, + ); + } + + const result = signupSchema.safeParse(body); + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + success: false, + message: firstError.message, + field: firstError.path[0] ?? null, + }, + { status: 422 }, + ); + } + + const { + email, + phoneNumber, + nickname, + password, + isAgeOver14, + termsAgreed, + privacyAgreed, + marketingAgreed, + eventSmsAgreed, + } = result.data; + + const normalizedEmail = email.trim().toLowerCase(); + const normalizedPhone = normalizePhoneNumber(phoneNumber); + const now = new Date().toISOString(); + + if (process.env.E2E_TEST === "true") { + const mockUid = `e2e_user_${Date.now()}`; + await prisma.user.create({ + data: { + id: mockUid, + email: normalizedEmail, + phoneNumber: normalizedPhone, + nickname, + passwordHash: "$2a$10$abcdefghijklmnopqrstuv", + isAgeOver14: true, + termsAgreedAt: new Date(), + privacyAgreedAt: new Date(), + marketingAgreed: false, + eventSmsAgreed: false, + }, + }); + + const res = NextResponse.json( + { + success: true, + message: "회원가입이 완료되었습니다. (E2E MOCK)", + user: { id: mockUid, email: normalizedEmail, nickname }, + }, + { status: 201 }, + ); + + res.cookies.set("e2e_mock_uid", mockUid, { path: "/" }); + res.cookies.set("e2e_mock_email", normalizedEmail, { path: "/" }); + res.cookies.set("e2e_mock_nickname", nickname, { path: "/" }); + + return res; + } + + const supabase = await createClient(); + + // Supabase Auth로 사용자 생성. + // 확장 필드는 user_metadata로 전달하면 handle_new_user 트리거가 + // public.profiles에 채운다. + const { data, error } = await supabase.auth.signUp({ + email: normalizedEmail, + password, + options: { + data: { + nickname, + phone_number: normalizedPhone, + is_age_over_14: isAgeOver14, + terms_agreed_at: termsAgreed ? now : null, + privacy_agreed_at: privacyAgreed ? now : null, + marketing_agreed: marketingAgreed, + event_sms_agreed: eventSmsAgreed, + }, + }, + }); + + if (error) { + // 중복 가입(이미 등록된 이메일/닉네임/전화) 및 프로필 트리거 unique 위반. + // Supabase 에러 메시지 문구 변경에 취약하지 않도록 구조화된 status를 우선 사용하고, + // 없을 때만 메시지 정규식으로 fallback한다. + const status = + error.status === 409 || + /already|registered|exists|duplicate|unique/i.test(error.message) + ? 409 + : 400; + const message = + status === 409 + ? "이미 사용 중인 정보입니다." + : "회원가입에 실패했습니다. 입력 정보를 확인해주세요."; + return NextResponse.json({ success: false, message }, { status }); + } + + const user = data.user; + + return NextResponse.json( + { + success: true, + message: "회원가입이 완료되었습니다.", + user: user ? { id: user.id, email: user.email, nickname } : null, + }, + { status: 201 }, + ); +} diff --git a/src/app/api/calendar/status/route.ts b/src/app/api/calendar/status/route.ts new file mode 100644 index 0000000..9ab9f36 --- /dev/null +++ b/src/app/api/calendar/status/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { getConnection } from "@/lib/caldav/connection-cookie"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/calendar/status + * 현재 브라우저의 캘린더 연동 상태를 반환한다. + * + * Google/Naver와 동일하게 연동 정보는 HttpOnly 쿠키에 저장되므로, 별도의 앱 + * 세션 검증 없이 쿠키 존재 여부로 상태를 판단한다. + */ +export async function GET() { + try { + const cookieStore = await cookies(); + + // 1. Google 연동 확인 (google/auth.ts의 google_tokens 쿠키) + const googleConnected = !!cookieStore.get("google_tokens")?.value; + + // 2. iCloud 연동 확인 (connection-cookie.ts의 icloud_connection 쿠키) + const icloud = await getConnection(); + + return NextResponse.json({ + googleConnected, + // 쿠키 저장 방식에는 Google 이메일이 없으므로 미제공 + googleEmail: undefined, + icloudConnected: !!icloud, + icloudAppleId: icloud?.appleId ?? undefined, + }); + } catch (err) { + console.error("[calendar.status] 오류 발생:", err); + return NextResponse.json( + { error: "연동 상태를 조회하는 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/everytime/timetable/route.ts b/src/app/api/everytime/timetable/route.ts new file mode 100644 index 0000000..3e2847f --- /dev/null +++ b/src/app/api/everytime/timetable/route.ts @@ -0,0 +1,248 @@ +import { NextRequest, NextResponse } from "next/server"; +import { parseTimetableFromIcs } from "@/lib/everytime/ics-converter"; +import { + EverytimeScrapeError, + fetchTimetableFromUrl, +} from "@/lib/everytime/url-scraper"; +import { timetableToFreeSlots } from "@/lib/everytime/converter"; +import { createClient } from "@/lib/supabase/server"; +import type { DayCode } from "@/types/schedule"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/everytime/timetable + * + * 에브리타임 시간표를 받아 빈 시간(TimeSlot[])으로 변환한다. + * + * 방법 A — 공유 URL (application/json): + * { "url": "https://everytime.kr/@XXXX" } + * + * 방법 B — ICS 파일 업로드 (multipart/form-data): + * file: .ics 파일 (앱 → 시간표 공유 → 캘린더 내보내기) + * + * Query: ?days=MON,TUE,WED (선택 — 기본값 월~금) + * + * Response: + * { "timetable": EverytimeTimetable, "freeSlots": TimeSlot[] } + */ +export async function POST(req: NextRequest) { + const contentType = req.headers.get("content-type") ?? ""; + const candidateDays = parseCandidateDays(req); + + // 방법 A: JSON body에 url 필드 + if (contentType.includes("application/json")) { + return handleUrlRequest(req, candidateDays); + } + + // 방법 B: multipart/form-data에 file 필드 + if (contentType.includes("multipart/form-data")) { + return handleFileRequest(req, candidateDays); + } + + return NextResponse.json( + { + error: + "Content-Type은 application/json(URL) 또는 multipart/form-data(ICS 파일)여야 합니다.", + }, + { status: 415 }, + ); +} + +async function handleUrlRequest( + req: NextRequest, + candidateDays: DayCode[] | undefined, +) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json( + { error: "요청 본문이 올바른 JSON이 아닙니다." }, + { status: 400 }, + ); + } + + const url = (body as Record)?.url; + if (typeof url !== "string" || !url.trim()) { + return NextResponse.json( + { error: "url 필드가 필요합니다." }, + { status: 400 }, + ); + } + + // SSRF 방지: 허용된 도메인·프로토콜만 통과 + let parsedUrl: URL; + try { + parsedUrl = new URL(url.trim()); + } catch { + return NextResponse.json( + { error: "유효하지 않은 URL입니다." }, + { status: 400 }, + ); + } + if ( + parsedUrl.protocol !== "https:" || + parsedUrl.hostname !== "everytime.kr" + ) { + return NextResponse.json( + { error: "everytime.kr의 https URL만 허용됩니다." }, + { status: 400 }, + ); + } + + let timetable: Awaited>; + try { + timetable = await fetchTimetableFromUrl(url.trim()); + } catch (err) { + if (err instanceof EverytimeScrapeError) { + return NextResponse.json({ error: err.message }, { status: 422 }); + } + console.error("[everytime.timetable] URL 스크래핑 오류:", err); + return NextResponse.json( + { error: "시간표 조회 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } + + try { + const freeSlots = timetableToFreeSlots(timetable, { candidateDays }); + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user) { + await supabase.auth.updateUser({ + data: { + everytime_url: url, + everytime_slots: freeSlots, + }, + }); + } + } catch (err) { + console.error("[everytime] 유저 메타데이터 저장 실패:", err); + } + return NextResponse.json({ timetable, freeSlots }); + } catch (err) { + console.error("[everytime.timetable] 시간표 변환 오류:", err); + return NextResponse.json( + { error: "시간표 변환 중 오류가 발생했습니다." }, + { status: 422 }, + ); + } +} + +async function handleFileRequest( + req: NextRequest, + candidateDays: DayCode[] | undefined, +) { + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json( + { error: "파일 업로드 형식이 올바르지 않습니다." }, + { status: 400 }, + ); + } + + const file = formData.get("file"); + if (!file || !(file instanceof File)) { + return NextResponse.json( + { error: "file 필드에 ICS 파일을 첨부해주세요." }, + { status: 400 }, + ); + } + + if (!file.name.toLowerCase().endsWith(".ics")) { + return NextResponse.json( + { error: ".ics 파일만 지원합니다." }, + { status: 400 }, + ); + } + + if (file.size > 100 * 1024) { + return NextResponse.json( + { + error: + "파일 크기가 너무 큽니다. 100KB 이하의 ICS 파일을 업로드해주세요.", + }, + { status: 400 }, + ); + } + + const icsText = await file.text(); + const normalizedIcs = icsText.trim().toUpperCase(); + if ( + !normalizedIcs.includes("BEGIN:VCALENDAR") || + !normalizedIcs.includes("END:VCALENDAR") + ) { + return NextResponse.json( + { error: "올바른 ICS 파일 형식이 아닙니다." }, + { status: 400 }, + ); + } + + let timetable: Awaited>; + try { + timetable = parseTimetableFromIcs(icsText); + } catch (err) { + console.error("[everytime.timetable] ICS 파싱 오류:", err); + return NextResponse.json( + { error: "ICS 파일 파싱 중 오류가 발생했습니다." }, + { status: 422 }, + ); + } + + try { + const freeSlots = timetableToFreeSlots(timetable, { candidateDays }); + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user) { + await supabase.auth.updateUser({ + data: { + everytime_url: "file_upload", + everytime_slots: freeSlots, + }, + }); + } + } catch (err) { + console.error("[everytime] 유저 메타데이터 저장 실패:", err); + } + return NextResponse.json({ timetable, freeSlots }); + } catch (err) { + console.error("[everytime.timetable] 시간표 변환 오류:", err); + return NextResponse.json( + { error: "시간표 변환 중 오류가 발생했습니다." }, + { status: 422 }, + ); + } +} + +const VALID_DAYS = new Set([ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN", +]); + +function parseCandidateDays(req: NextRequest): DayCode[] | undefined { + const raw = req.nextUrl.searchParams.get("days"); + if (!raw) return undefined; + const days = Array.from( + new Set( + raw + .split(",") + .map((d) => d.trim().toUpperCase() as DayCode) + .filter((d) => VALID_DAYS.has(d)), + ), + ); + return days.length > 0 ? days : undefined; +} diff --git a/src/app/api/google/auth/route.ts b/src/app/api/google/auth/route.ts new file mode 100644 index 0000000..1bf9b3b --- /dev/null +++ b/src/app/api/google/auth/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { buildAuthUrl } from "@/lib/google/auth"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/google/auth + * Google OAuth 동의 화면으로 리다이렉트한다. + */ +export async function GET() { + const authUrl = buildAuthUrl(); + return NextResponse.redirect(authUrl); +} diff --git a/src/app/api/google/calendars/route.ts b/src/app/api/google/calendars/route.ts new file mode 100644 index 0000000..95e680d --- /dev/null +++ b/src/app/api/google/calendars/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { getValidTokens } from "@/lib/google/auth"; +import { listCalendars } from "@/lib/google/calendars"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/google/calendars + * 연결된 Google 계정의 캘린더 목록을 반환한다. + */ +export async function GET() { + const tokens = await getValidTokens(); + if (!tokens) { + return NextResponse.json( + { + error: + "Google 계정이 연결되지 않았습니다. /api/google/auth로 인증해주세요.", + }, + { status: 401 }, + ); + } + + try { + const calendars = await listCalendars(tokens.accessToken); + return NextResponse.json({ calendars }); + } catch (err) { + console.error("[google.calendars] 오류:", err); + + if (err instanceof Error && err.message.includes("만료")) { + return NextResponse.json( + { error: "Google 인증이 만료되었습니다. 다시 연결해주세요." }, + { status: 401 }, + ); + } + + return NextResponse.json( + { error: "캘린더 목록 조회 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/google/callback/route.ts b/src/app/api/google/callback/route.ts new file mode 100644 index 0000000..2b77b7a --- /dev/null +++ b/src/app/api/google/callback/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + exchangeCodeForTokens, + getUserEmail, + saveTokensToCookie, +} from "@/lib/google/auth"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/google/callback + * Google OAuth 콜백. authorization code를 토큰으로 교환한 뒤 쿠키에 저장한다. + */ +export async function GET(req: NextRequest) { + const code = req.nextUrl.searchParams.get("code"); + const error = req.nextUrl.searchParams.get("error"); + + if (error) { + return NextResponse.json( + { error: `Google OAuth 거부: ${error}` }, + { status: 400 }, + ); + } + + if (!code) { + return NextResponse.json( + { error: "authorization code가 없습니다." }, + { status: 400 }, + ); + } + + try { + const tokens = await exchangeCodeForTokens(code); + const email = await getUserEmail(tokens.accessToken); + + await saveTokensToCookie(tokens); + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"; + return NextResponse.redirect( + `${baseUrl}/api/google/calendars?connected=true&email=${encodeURIComponent(email)}`, + ); + } catch (err) { + console.error("[google.callback] 오류:", err); + return NextResponse.json( + { error: "Google 계정 연결에 실패했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/google/disconnect/route.ts b/src/app/api/google/disconnect/route.ts new file mode 100644 index 0000000..bf736d1 --- /dev/null +++ b/src/app/api/google/disconnect/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { requireSession, UnauthorizedError } from "@/lib/auth/session"; +import { createClient } from "@/lib/supabase/server"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + let session; + try { + session = await requireSession(); + } catch (e) { + if (e instanceof UnauthorizedError) { + return NextResponse.json( + { error: "인증이 필요합니다." }, + { status: 401 }, + ); + } + throw e; + } + + try { + const supabase = await createClient(); + + // 사용자의 google_connections 데이터 영구 삭제 (Hard Delete) + const { error } = await supabase + .from("google_connections") + .delete() + .eq("profile_id", session.userId); + + if (error) { + console.error("[google.disconnect] DB 삭제 오류:", error.message); + return NextResponse.json( + { error: "연동 해제 중 오류가 발생했습니다." }, + { status: 500 }, + ); + } + + return NextResponse.json({ success: true }); + } catch (err) { + console.error("[google.disconnect] 예상치 못한 오류:", err); + return NextResponse.json( + { error: "서버 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/google/events/create/route.ts b/src/app/api/google/events/create/route.ts new file mode 100644 index 0000000..5ba1435 --- /dev/null +++ b/src/app/api/google/events/create/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getValidTokens } from "@/lib/google/auth"; +import { createEvent } from "@/lib/google/events"; + +export const dynamic = "force-dynamic"; + +const CreateEventSchema = z.object({ + calendarId: z.string().min(1, "calendarId는 필수입니다."), + summary: z + .string() + .min(1, "제목은 1자 이상이어야 합니다.") + .max(255, "제목은 255자 이하여야 합니다."), + startDateTime: z.string().datetime({ + offset: true, + message: "startDateTime은 ISO 8601 형식이어야 합니다.", + }), + endDateTime: z.string().datetime({ + offset: true, + message: "endDateTime은 ISO 8601 형식이어야 합니다.", + }), + location: z.string().max(500).optional(), + description: z.string().max(8000).optional(), + timeZone: z.string().optional(), +}); + +/** + * POST /api/google/events/create + * Google Calendar에 새 일정을 생성한다. + */ +export async function POST(req: NextRequest) { + const tokens = await getValidTokens(); + if (!tokens) { + return NextResponse.json( + { error: "Google 계정이 연결되지 않았습니다." }, + { status: 401 }, + ); + } + + const body = await req.json().catch(() => null); + const parsed = CreateEventSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 }, + ); + } + + const { calendarId, summary, startDateTime, endDateTime, ...rest } = + parsed.data; + + if (new Date(startDateTime) >= new Date(endDateTime)) { + return NextResponse.json( + { error: "endDateTime은 startDateTime보다 나중이어야 합니다." }, + { status: 400 }, + ); + } + + try { + const event = await createEvent(tokens.accessToken, calendarId, { + summary, + startDateTime, + endDateTime, + ...rest, + }); + + return NextResponse.json({ event }, { status: 201 }); + } catch (err) { + console.error("[google.events.create] 오류:", err); + + if (err instanceof Error && err.message.includes("만료")) { + return NextResponse.json( + { error: "Google 인증이 만료되었습니다." }, + { status: 401 }, + ); + } + + return NextResponse.json( + { error: "일정 생성 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/google/events/query/route.ts b/src/app/api/google/events/query/route.ts new file mode 100644 index 0000000..5ccc9d5 --- /dev/null +++ b/src/app/api/google/events/query/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getValidTokens } from "@/lib/google/auth"; +import { queryEvents } from "@/lib/google/events"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/google/events/query?calendarId=...&startDate=...&endDate=... + * 특정 캘린더의 기간별 일정을 조회한다. + */ +export async function GET(req: NextRequest) { + const tokens = await getValidTokens(); + if (!tokens) { + return NextResponse.json( + { error: "Google 계정이 연결되지 않았습니다." }, + { status: 401 }, + ); + } + + const calendarId = req.nextUrl.searchParams.get("calendarId"); + const startDate = req.nextUrl.searchParams.get("startDate"); + const endDate = req.nextUrl.searchParams.get("endDate"); + + if (!calendarId || !startDate || !endDate) { + return NextResponse.json( + { error: "calendarId, startDate, endDate 파라미터가 필요합니다." }, + { status: 400 }, + ); + } + + const start = new Date(startDate); + const end = new Date(endDate); + + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return NextResponse.json( + { error: "날짜 형식이 올바르지 않습니다. ISO 8601 형식을 사용하세요." }, + { status: 400 }, + ); + } + + if (start >= end) { + return NextResponse.json( + { error: "endDate는 startDate보다 나중이어야 합니다." }, + { status: 400 }, + ); + } + + try { + const events = await queryEvents( + tokens.accessToken, + calendarId, + start, + end, + ); + return NextResponse.json({ events }); + } catch (err) { + console.error("[google.events.query] 오류:", err); + + if (err instanceof Error && err.message.includes("만료")) { + return NextResponse.json( + { error: "Google 인증이 만료되었습니다." }, + { status: 401 }, + ); + } + + return NextResponse.json( + { error: "일정 조회 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/icloud/calendars/route.ts b/src/app/api/icloud/calendars/route.ts new file mode 100644 index 0000000..667c4e2 --- /dev/null +++ b/src/app/api/icloud/calendars/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { maskEmail } from "@/lib/crypto"; +import { discoverCalDAV } from "@/lib/caldav/discovery"; +import { CalDAVError } from "@/lib/caldav/client"; +import { getConnectionAuth } from "@/lib/caldav/connection-cookie"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/icloud/calendars + * 연결된 iCloud 계정의 캘린더 컬렉션 목록을 CalDAV에서 실시간 조회한다. + */ +export async function GET() { + const connection = await getConnectionAuth(); + if (!connection) { + return NextResponse.json( + { error: "연결된 iCloud 계정이 없습니다. 먼저 계정을 연결해주세요." }, + { status: 404 }, + ); + } + + try { + const discovery = await discoverCalDAV({ + username: connection.appleId, + password: connection.password, + }); + + return NextResponse.json({ + calendars: discovery.calendars.map((c) => ({ + // 쿠키 저장 방식에는 DB UUID가 없으므로 calendarUrl이 식별자 + calendarUrl: c.url, + displayName: c.displayName, + color: c.color ?? null, + })), + }); + } catch (err) { + if (err instanceof CalDAVError && err.statusCode === 401) { + return NextResponse.json( + { error: "iCloud 인증이 만료되었습니다. 계정을 다시 연결해주세요." }, + { status: 401 }, + ); + } + + console.error("[icloud.calendars] 오류", { + appleId: maskEmail(connection.appleId), + error: err instanceof Error ? err.message : "unknown", + }); + return NextResponse.json( + { error: "캘린더 목록 조회 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/icloud/connect/route.ts b/src/app/api/icloud/connect/route.ts new file mode 100644 index 0000000..2c577ac --- /dev/null +++ b/src/app/api/icloud/connect/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { maskEmail } from "@/lib/crypto"; +import { discoverCalDAV } from "@/lib/caldav/discovery"; +import { CalDAVError } from "@/lib/caldav/client"; +import { saveConnection } from "@/lib/caldav/connection-cookie"; + +export const dynamic = "force-dynamic"; + +const ConnectSchema = z.object({ + appleId: z.string().email("올바른 Apple ID 이메일 형식이어야 합니다."), + appPassword: z + .string() + .min(1, "앱 전용 암호를 입력해주세요.") + // iCloud 앱 전용 암호 형식: xxxx-xxxx-xxxx-xxxx + .regex( + /^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$|^.{4,}$/, + "앱 전용 암호 형식이 올바르지 않습니다.", + ), +}); + +export async function POST(req: NextRequest) { + // ── 1. 입력 검증 ────────────────────────────────────────── + const body = await req.json().catch(() => null); + const parsed = ConnectSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 }, + ); + } + const { appleId, appPassword } = parsed.data; + + // ── 2. CalDAV Discovery (자격증명 검증 + 엔드포인트 탐색) ── + try { + const discovery = await discoverCalDAV({ + username: appleId, + password: appPassword, + }); + + // ── 3. 연결 정보를 암호화해 HttpOnly 쿠키에 저장 ────────── + await saveConnection({ + appleId, + appPassword, + principalUrl: discovery.principalUrl, + calendarHomeUrl: discovery.calendarHomeUrl, + }); + + console.info("[icloud.connect] 연결 성공", { + appleId: maskEmail(appleId), + calendarsCount: discovery.calendars.length, + }); + + return NextResponse.json({ + appleId, + principalUrl: discovery.principalUrl, + calendarHomeUrl: discovery.calendarHomeUrl, + calendarsCount: discovery.calendars.length, + }); + } catch (err) { + if (err instanceof CalDAVError) { + if (err.statusCode === 401) { + return NextResponse.json( + { + error: + "Apple 계정 인증에 실패했습니다. Apple ID와 앱 전용 암호를 확인해주세요.", + }, + { status: 401 }, + ); + } + console.error("[icloud.connect] CalDAV 오류", { + appleId: maskEmail(appleId), + statusCode: err.statusCode, + message: err.message, + }); + return NextResponse.json( + { + error: "iCloud 서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.", + }, + { status: 502 }, + ); + } + + console.error("[icloud.connect] 예상치 못한 오류", { + appleId: maskEmail(appleId), + error: err instanceof Error ? err.message : "unknown", + }); + return NextResponse.json( + { error: "서버 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/icloud/disconnect/route.ts b/src/app/api/icloud/disconnect/route.ts new file mode 100644 index 0000000..c8a156e --- /dev/null +++ b/src/app/api/icloud/disconnect/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { clearConnection } from "@/lib/caldav/connection-cookie"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + try { + // 연결 정보 쿠키를 삭제한다. 앱 전용 암호도 함께 파기된다. + await clearConnection(); + return NextResponse.json({ success: true }); + } catch (err) { + console.error("[icloud.disconnect] 예상치 못한 오류:", err); + return NextResponse.json( + { error: "서버 오류가 발생했습니다." }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/icloud/events/create/route.ts b/src/app/api/icloud/events/create/route.ts new file mode 100644 index 0000000..883c2f1 --- /dev/null +++ b/src/app/api/icloud/events/create/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { maskEmail } from "@/lib/crypto"; +import { createEvent } from "@/lib/caldav/create"; +import { buildIcs } from "@/lib/ics/builder"; +import { CalDAVError } from "@/lib/caldav/client"; +import { getConnectionAuth } from "@/lib/caldav/connection-cookie"; + +export const dynamic = "force-dynamic"; + +const CreateEventSchema = z.object({ + calendarUrl: z + .string() + .url("calendarUrl은 올바른 URL 형식이어야 합니다.") + .startsWith("https://", "calendarUrl은 https URL이어야 합니다."), + title: z + .string() + .min(1, "제목을 입력해주세요.") + .max(255, "제목은 255자 이하여야 합니다."), + startAt: z + .string() + .datetime({ message: "startAt은 ISO 8601 형식이어야 합니다." }), + endAt: z + .string() + .datetime({ message: "endAt은 ISO 8601 형식이어야 합니다." }), + location: z.string().max(500).optional(), + description: z.string().max(2000).optional(), +}); + +export async function POST(req: NextRequest) { + // ── 1. 연결 정보 확인 ───────────────────────────────────── + const connection = await getConnectionAuth(); + if (!connection) { + return NextResponse.json( + { error: "연결된 iCloud 계정이 없습니다. 먼저 계정을 연결해주세요." }, + { status: 404 }, + ); + } + + // ── 2. 입력 검증 ────────────────────────────────────────── + const body = await req.json().catch(() => null); + const parsed = CreateEventSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 }, + ); + } + const { calendarUrl, title, startAt, endAt, location, description } = + parsed.data; + + const start = new Date(startAt); + const end = new Date(endAt); + if (start >= end) { + return NextResponse.json( + { error: "종료 시간이 시작 시간보다 늦어야 합니다." }, + { status: 400 }, + ); + } + + // ── 3. ICS 빌드 ─────────────────────────────────────────── + const { uid, icsContent } = buildIcs({ + title, + startAt: start, + endAt: end, + location, + description, + }); + + // ── 4. CalDAV PUT ───────────────────────────────────────── + try { + const result = await createEvent( + calendarUrl, + { username: connection.appleId, password: connection.password }, + uid, + icsContent, + ); + + console.info("[icloud.events.create] 일정 생성 성공", { + appleId: maskEmail(connection.appleId), + uid, + }); + + return NextResponse.json( + { uid, eventUrl: result.href, etag: result.etag }, + { status: 201 }, + ); + } catch (err) { + if (err instanceof CalDAVError) { + if (err.statusCode === 401) { + return NextResponse.json( + { error: "iCloud 인증이 만료되었습니다. 계정을 다시 연결해주세요." }, + { status: 401 }, + ); + } + if (err.statusCode === 412) { + // If-None-Match: * 조건 실패 → 같은 UID의 이벤트가 이미 존재 + return NextResponse.json( + { error: "동일한 일정이 이미 존재합니다." }, + { status: 409 }, + ); + } + } + + console.error("[icloud.events.create] 오류", { + appleId: maskEmail(connection.appleId), + error: err instanceof Error ? err.message : "unknown", + }); + return NextResponse.json( + { error: "일정 생성 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/icloud/events/query/route.ts b/src/app/api/icloud/events/query/route.ts new file mode 100644 index 0000000..be477da --- /dev/null +++ b/src/app/api/icloud/events/query/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { maskEmail } from "@/lib/crypto"; +import { queryEvents } from "@/lib/caldav/query"; +import { parseIcsToEvents } from "@/lib/ics/parser"; +import { CalDAVError } from "@/lib/caldav/client"; +import { getConnectionAuth } from "@/lib/caldav/connection-cookie"; + +export const dynamic = "force-dynamic"; + +const QuerySchema = z.object({ + calendarUrl: z + .string() + .url("calendarUrl은 올바른 URL 형식이어야 합니다.") + .startsWith("https://", "calendarUrl은 https URL이어야 합니다."), + startDate: z + .string() + .datetime({ message: "startDate는 ISO 8601 형식이어야 합니다." }), + endDate: z + .string() + .datetime({ message: "endDate는 ISO 8601 형식이어야 합니다." }), +}); + +export async function POST(req: NextRequest) { + // ── 1. 연결 정보 확인 ───────────────────────────────────── + const connection = await getConnectionAuth(); + if (!connection) { + return NextResponse.json( + { error: "연결된 iCloud 계정이 없습니다. 먼저 계정을 연결해주세요." }, + { status: 404 }, + ); + } + + // ── 2. 입력 검증 ────────────────────────────────────────── + const body = await req.json().catch(() => null); + const parsed = QuerySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 }, + ); + } + const { calendarUrl, startDate, endDate } = parsed.data; + + const start = new Date(startDate); + const end = new Date(endDate); + if (start >= end) { + return NextResponse.json( + { error: "endDate는 startDate보다 나중이어야 합니다." }, + { status: 400 }, + ); + } + + // ── 3. CalDAV REPORT ────────────────────────────────────── + try { + const rawEvents = await queryEvents( + calendarUrl, + { username: connection.appleId, password: connection.password }, + start, + end, + ); + + // ── 4. ICS 파싱 ─────────────────────────────────────── + const events = rawEvents.flatMap((raw) => + parseIcsToEvents(raw.icsData).map((e) => ({ + uid: e.uid, + title: e.title, + startAt: e.startAt.toISOString(), + endAt: e.endAt.toISOString(), + location: e.location ?? null, + description: e.description ?? null, + isAllDay: e.isAllDay, + etag: raw.etag, + })), + ); + + return NextResponse.json({ events }); + } catch (err) { + if (err instanceof CalDAVError && err.statusCode === 401) { + return NextResponse.json( + { error: "iCloud 인증이 만료되었습니다. 계정을 다시 연결해주세요." }, + { status: 401 }, + ); + } + + console.error("[icloud.events.query] 오류", { + appleId: maskEmail(connection.appleId), + error: err instanceof Error ? err.message : "unknown", + }); + return NextResponse.json( + { error: "일정 조회 중 오류가 발생했습니다." }, + { status: 502 }, + ); + } +} diff --git a/src/app/api/schedules/[id]/availability/route.ts b/src/app/api/schedules/[id]/availability/route.ts new file mode 100644 index 0000000..3e724de --- /dev/null +++ b/src/app/api/schedules/[id]/availability/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + addParticipantAvailability, + getSchedulePublic, +} from "@/lib/schedules/store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const participant = await addParticipantAvailability( + id, + await request.json(), + ); + return NextResponse.json( + { + participant, + schedule: await getSchedulePublic(id), + }, + { status: 201 }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : "invalid request"; + const status = message === "schedule not found" ? 404 : 400; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/schedules/[id]/route.ts b/src/app/api/schedules/[id]/route.ts new file mode 100644 index 0000000..e2ccab0 --- /dev/null +++ b/src/app/api/schedules/[id]/route.ts @@ -0,0 +1,169 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + confirmSchedule, + confirmScheduleByCreator, + getScheduleForCreator, + getScheduleForHost, + getSchedulePublic, +} from "@/lib/schedules/store"; +import { + HOST_TOKEN_MAX_AGE, + getHostTokenCookieName, +} from "@/lib/schedules/host-cookie"; +import { createClient } from "@/lib/supabase/server"; +import type { TimeSlot } from "@/types/schedule"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + // 1. 로그인 유저가 생성자(호스트)인지 먼저 확인 + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user) { + const creatorSchedule = await getScheduleForCreator(id, user.id); + if (creatorSchedule) { + return NextResponse.json({ schedule: creatorSchedule }); + } + } + } catch (err) { + console.error("[GET schedule] 세션 확인 실패:", err); + } + + // 2. hostToken 쿼리 파라미터 또는 쿠키 확인 + const queryHostToken = request.nextUrl.searchParams.get("hostToken"); + const cookieHostToken = request.cookies.get( + getHostTokenCookieName(id), + )?.value; + const hostToken = queryHostToken ?? cookieHostToken; + + if (hostToken) { + const hostSchedule = await getScheduleForHost(id, hostToken); + if (hostSchedule) { + const response = NextResponse.json({ schedule: hostSchedule }); + const isProd = + process.env.NODE_ENV === "production" && + process.env.E2E_TEST !== "true"; + response.cookies.set(getHostTokenCookieName(id), hostToken, { + httpOnly: true, + secure: isProd, + sameSite: isProd ? "lax" : undefined, + path: "/", + maxAge: HOST_TOKEN_MAX_AGE, + }); + return response; + } + + // 쿼리 매개변수로 명시적인 잘못된 토큰을 넘긴 경우는 403 에러 반환 + if (queryHostToken) { + return NextResponse.json( + { error: "invalid host token" }, + { status: 403 }, + ); + } + + // 쿠키에 담긴 토큰만 잘못된 경우: 쿠키를 자동 소멸시키고 퍼블릭 모임 정보로 이동 + const schedule = await getSchedulePublic(id); + if (!schedule) { + return NextResponse.json( + { error: "schedule not found" }, + { status: 404 }, + ); + } + const response = NextResponse.json({ schedule }); + response.cookies.set(getHostTokenCookieName(id), "", { + path: "/", + maxAge: 0, + }); + return response; + } + + const schedule = await getSchedulePublic(id); + if (!schedule) { + return NextResponse.json({ error: "schedule not found" }, { status: 404 }); + } + + return NextResponse.json({ schedule }); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const body = await request.json(); + if (!isTimeSlot(body.confirmedSlot)) { + return NextResponse.json( + { error: "confirmedSlot is required" }, + { status: 400 }, + ); + } + + // 1. 로그인 유저가 생성자(호스트)인지 먼저 확인하여 확정 + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user) { + const schedule = await confirmScheduleByCreator( + id, + user.id, + body.confirmedSlot, + ); + return NextResponse.json({ schedule }); + } + } catch (err) { + console.error("[PATCH schedule] 세션 기반 확정 실패:", err); + } + + // 2. hostToken 기반 확정 처리 + const hostToken = + typeof body.hostToken === "string" && body.hostToken.trim() + ? body.hostToken + : request.cookies.get(getHostTokenCookieName(id))?.value; + if (!hostToken) { + return NextResponse.json( + { error: "hostToken is required" }, + { status: 400 }, + ); + } + + const schedule = await confirmSchedule(id, hostToken, body.confirmedSlot); + return NextResponse.json({ schedule }); + } catch (error) { + const message = error instanceof Error ? error.message : "invalid request"; + const status = + message === "schedule not found" + ? 404 + : message === "invalid host token" + ? 403 + : 400; + return NextResponse.json({ error: message }, { status }); + } +} + +function isTimeSlot(value: unknown): value is TimeSlot { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const slot = value as Partial; + return ( + typeof slot.day === "string" && + ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].includes(slot.day) && + Number.isInteger(slot.startHour) && + Number.isInteger(slot.endHour) && + slot.startHour >= 0 && + slot.endHour <= 24 && + slot.startHour < slot.endHour + ); +} diff --git a/src/app/api/schedules/route.ts b/src/app/api/schedules/route.ts new file mode 100644 index 0000000..cc62d43 --- /dev/null +++ b/src/app/api/schedules/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSchedule, getSchedulePublic } from "@/lib/schedules/store"; +import { createClient } from "@/lib/supabase/server"; +import { + HOST_TOKEN_MAX_AGE, + getHostTokenCookieName, +} from "@/lib/schedules/host-cookie"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const body = await request.json(); + const created = await createSchedule({ + ...body, + creatorId: user?.id || null, + }); + const schedule = await getSchedulePublic(created.id); + + const response = NextResponse.json( + { + schedule, + participantPath: `/schedule/${created.id}`, + hostPath: `/schedule/${created.id}?hostToken=${created.hostToken}`, + hostToken: created.hostToken, + }, + { status: 201 }, + ); + + const isProd = + process.env.NODE_ENV === "production" && process.env.E2E_TEST !== "true"; + response.cookies.set( + getHostTokenCookieName(created.id), + created.hostToken, + { + httpOnly: true, + secure: isProd, + sameSite: isProd ? "lax" : undefined, + path: "/", + maxAge: HOST_TOKEN_MAX_AGE, + }, + ); + + return response; + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "invalid request" }, + { status: 400 }, + ); + } +} diff --git a/src/app/calendar/connect/page.tsx b/src/app/calendar/connect/page.tsx new file mode 100644 index 0000000..5e3aeee --- /dev/null +++ b/src/app/calendar/connect/page.tsx @@ -0,0 +1,560 @@ +"use client"; + +import { FormEvent, useState, useEffect } from "react"; +import type { ReactNode } from "react"; +import Link from "next/link"; +import { + CalendarCheck2, + ChevronRight, + LockKeyhole, + ShieldCheck, + Upload, +} from "lucide-react"; +import { + MoimShell, + MoimTopBar, + ProviderGlyph, + PurpleButton, +} from "@/components/moim/reference-ui"; +import { createClient } from "@/lib/supabase/client"; +import type { TimeSlot } from "@/types/schedule"; + +export default function CalendarConnectPage() { + const [everytimeUrl, setEverytimeUrl] = useState(""); + const [icloudAppleId, setIcloudAppleId] = useState(""); + const [icloudPassword, setIcloudPassword] = useState(""); + const [slots, setSlots] = useState([]); + const [message, setMessage] = useState(""); + const [isLoading, setIsLoading] = useState(""); + + const [googleConnected, setGoogleConnected] = useState(false); + const [googleEmail, setGoogleEmail] = useState(""); + const [icloudConnected, setIcloudConnected] = useState(false); + const [icloudConnectedId, setIcloudConnectedId] = useState(""); + const [everytimeConnected, setEverytimeConnected] = useState(false); + const [everytimeConnectedUrl, setEverytimeConnectedUrl] = useState(""); + + async function loadStatus() { + try { + const response = await fetch("/api/calendar/status"); + if (response.ok) { + const data = await response.json(); + setGoogleConnected(data.googleConnected); + setGoogleEmail(data.googleEmail ?? ""); + setIcloudConnected(data.icloudConnected); + setIcloudConnectedId(data.icloudAppleId ?? ""); + } + + // Supabase user_metadata에서 에브리타임 연동 조회 + const supabase = createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user && user.user_metadata) { + const url = user.user_metadata.everytime_url; + if (url) { + setEverytimeConnected(true); + setEverytimeConnectedUrl(url); + setSlots(user.user_metadata.everytime_slots ?? []); + } + } + } catch (err) { + console.error("연동 상태 조회 실패:", err); + } + } + + useEffect(() => { + loadStatus(); + }, []); + + async function submitEverytimeUrl(event: FormEvent) { + event.preventDefault(); + setMessage(""); + setIsLoading("everytime-url"); + try { + const response = await fetch("/api/everytime/timetable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: everytimeUrl }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error ?? "시간표를 가져오지 못했습니다."); + } + setSlots(result.freeSlots ?? []); + setMessage("Everytime 시간표를 가능한 시간으로 변환했습니다."); + await loadStatus(); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsLoading(""); + } + } + + async function submitEverytimeFile(file: File | null) { + if (!file) return; + const isIcs = + file.type === "text/calendar" || file.name.toLowerCase().endsWith(".ics"); + const maxSize = 100 * 1024; + if (!isIcs) { + setMessage("ICS 형식 파일만 업로드할 수 있습니다."); + return; + } + if (file.size > maxSize) { + setMessage("파일 크기는 100KB 이하여야 합니다."); + return; + } + + setMessage(""); + setIsLoading("everytime-file"); + try { + const formData = new FormData(); + formData.set("file", file); + const response = await fetch("/api/everytime/timetable", { + method: "POST", + body: formData, + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error ?? "ICS 파일을 읽지 못했습니다."); + } + setSlots(result.freeSlots ?? []); + setMessage("ICS 파일을 가능한 시간으로 변환했습니다."); + await loadStatus(); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsLoading(""); + } + } + + async function disconnectEverytime() { + setMessage(""); + setIsLoading("everytime-disconnect"); + try { + const supabase = createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (user) { + const { error } = await supabase.auth.updateUser({ + data: { + everytime_url: null, + everytime_slots: null, + }, + }); + if (error) throw error; + } + setEverytimeConnected(false); + setEverytimeConnectedUrl(""); + setSlots([]); + setMessage( + "Everytime 연동이 해제되고 모든 데이터가 안전하게 파기되었습니다.", + ); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsLoading(""); + } + } + + async function submitIcloud(event: FormEvent) { + event.preventDefault(); + setMessage(""); + setIsLoading("icloud"); + try { + const response = await fetch("/api/icloud/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + appleId: icloudAppleId, + appPassword: icloudPassword, + }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error ?? "iCloud 연결에 실패했습니다."); + } + setMessage("iCloud 캘린더 연결을 확인했습니다."); + await loadStatus(); // 상태 새로고침 + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsLoading(""); + } + } + + async function disconnectGoogle() { + setMessage(""); + setIsLoading("google-disconnect"); + try { + const response = await fetch("/api/google/disconnect", { + method: "POST", + }); + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error ?? "연동 해제에 실패했습니다."); + } + setGoogleConnected(false); + setGoogleEmail(""); + setMessage( + "Google 캘린더 연동이 해제되고 모든 데이터가 안전하게 파기되었습니다.", + ); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsLoading(""); + } + } + + async function disconnectIcloud() { + setMessage(""); + setIsLoading("icloud-disconnect"); + try { + const response = await fetch("/api/icloud/disconnect", { + method: "POST", + }); + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error ?? "연동 해제에 실패했습니다."); + } + setIcloudConnected(false); + setIcloudConnectedId(""); + setMessage( + "iCloud 캘린더 연동이 해제되고 모든 데이터가 안전하게 파기되었습니다.", + ); + } catch (caught) { + setMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsLoading(""); + } + } + + // 연동(또는 시간표 변환)을 하나라도 마쳤는지 여부. + // 지금은 건너뛰기가 가능한 '선택' 단계라 진행 버튼은 항상 활성화한다. + // 추후 '필수'로 전환하려면 아래 하단 버튼 영역에서 이 값으로 진행을 막으면 된다. + const hasAnyConnection = + googleConnected || icloudConnected || slots.length > 0; + + return ( + + + +
+
+

+ 캘린더 연동 +

+

+ 흩어진 일정을 하나로 모아 모임 가능한 시간만 추려냅니다. +

+
+ +
+ 🛡️ +
+

+ MOIM 개인정보 보호 선언 +

+

+ 우리는 일정의 세부 내용(제목, 장소, 참석자 등)을 서버에 절대 + 저장하지 않으며, 오직 시간 교집합 판단에 필요한 빈 시간 정보만 + 분석합니다. 연동 정보는 언제든지 아래에서 흔적 없이 안전하게 영구 + 파기하실 수 있습니다. +

+
+
+ +
+ } + title="Google 캘린더" + description="업무와 개인 일정을 실시간으로 불러옵니다." + action={ + googleConnected ? ( +
+ + {googleEmail} (연동됨) + + + {isLoading === "google-disconnect" + ? "해제 중" + : "연동 해제"} + +
+ ) : ( + + 연동하기 + + ) + } + /> + + } + title="Apple 캘린더" + description="Apple ID와 앱 전용 암호로 iCloud 일정을 확인합니다." + action={ + icloudConnected ? ( +
+ + {icloudConnectedId} (연동됨) + + + {isLoading === "icloud-disconnect" + ? "해제 중" + : "연동 해제"} + +
+ ) : ( +
+
+ setIcloudAppleId(event.target.value)} + placeholder="user@icloud.com" + className="h-11 rounded-xl border border-brand-border-gray bg-white px-3 text-sm outline-none focus:border-brand-purple-accent focus:ring-2 focus:ring-brand-purple-ring transition-all" + type="email" + required + /> + + setIcloudPassword(event.target.value) + } + placeholder="앱 전용 암호" + className="h-11 rounded-xl border border-brand-border-gray bg-white px-3 text-sm outline-none focus:border-brand-purple-accent focus:ring-2 focus:ring-brand-purple-ring transition-all" + type="password" + required + /> + + {isLoading === "icloud" ? "확인 중" : "확인"} + +
+

+ ℹ️ iCloud 연동 안전 가이드: Apple 계정 마스터 암호가 + 아닌, Apple ID 관리 페이지에서 발급받은{" "} + 앱 전용 암호(xxxx-xxxx-xxxx-xxxx)를 입력하여 보안을 + 유지합니다. 입력 정보는 AES-256-GCM 알고리즘으로 안전하게 + 암호화되어 관리됩니다. +

+
+ ) + } + /> + + } + title="Everytime" + description="공유 URL을 가능한 시간 블록으로 변환합니다." + action={ + everytimeConnected ? ( +
+ + {everytimeConnectedUrl === "file_upload" + ? "파일 업로드 연동" + : everytimeConnectedUrl}{" "} + (연동됨) + + + {isLoading === "everytime-disconnect" + ? "해제 중" + : "연동 해제"} + +
+ ) : ( +
+ setEverytimeUrl(event.target.value)} + placeholder="https://everytime.kr/@..." + className="h-11 min-w-0 flex-1 rounded-xl border border-brand-border-gray bg-white px-3 text-sm outline-none focus:border-brand-purple-accent focus:ring-2 focus:ring-brand-purple-ring transition-all" + required + /> + + 가져오기 + +
+ ) + } + /> + + } + title=".ics 파일 업로드" + description="기타 캘린더 파일을 직접 올려서 확인합니다." + action={ + + } + /> +
+ + {message ? ( +

+ {message} +

+ ) : null} + + {slots.length > 0 ? ( +
+
+ + 변환된 가능 시간 +
+
+ {slots.slice(0, 14).map((slot) => ( + + {slot.day} {slot.startHour}:00-{slot.endHour}:00 + + ))} +
+
+ ) : null} + +
+ } + title="공개 범위" + body="제목과 메모는 참여자에게 공개하지 않고, 후보 시간 판단에 필요한 빈 시간만 사용합니다." + /> + } + title="권한 관리" + body="연동은 선택 사항이며, 실패해도 직접 입력이나 파일 업로드로 계속 진행할 수 있습니다." + /> +
+ + {/* 다음 단계: 모임 만들기로 진행 (현재는 건너뛰기 가능한 선택 단계) */} +
+ + 모임 만들러 가기 + + {!hasAnyConnection ? ( + // 건너뛰기는 '연동 없이 진행' 의도를 쿼리로 남긴다. + // 추후 '연동 필수' 정책 시 이 값으로 분기/차단할 수 있다. + + 나중에 연동할게요 (건너뛰기) + + ) : null} +
+
+
+ ); +} + +function IntegrationRow({ + glyph, + title, + description, + action, +}: { + glyph: ReactNode; + title: string; + description: string; + action: ReactNode; +}) { + return ( +
+
+ {glyph} +
+

+ {title} +

+

+ {description} +

+
+
+
{action}
+
+ ); +} + +function InfoCard({ + icon, + title, + body, +}: { + icon: ReactNode; + title: string; + body: string; +}) { + return ( +
+
+
+ {icon} + {title} +
+ +
+

+ {body} +

+
+ ); +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..2b04b78 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,124 @@ +import { ReactNode } from "react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { + CalendarDays, + LayoutDashboard, + LogOut, + Settings, + User, +} from "lucide-react"; +import { createClient } from "@/lib/supabase/server"; + +interface DashboardLayoutProps { + children: ReactNode; +} + +export default async function DashboardLayout({ + children, +}: DashboardLayoutProps) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + // Get user profile name or email for display + const displayName = + user.user_metadata?.nickname || user.email?.split("@")[0] || "사용자"; + + return ( +
+ {/* Sidebar / Mobile Nav */} + + + {/* Main Content Area */} +
+
+
+ + + 마이 스페이스 대시보드 + +
+ + 새 모임 개설하기 + +
+ +
{children}
+
+
+ ); +} diff --git a/src/app/dashboard/meetings/page.tsx b/src/app/dashboard/meetings/page.tsx new file mode 100644 index 0000000..7fe72ad --- /dev/null +++ b/src/app/dashboard/meetings/page.tsx @@ -0,0 +1,160 @@ +import Link from "next/link"; +import { CalendarDays, Clock, ExternalLink, Users } from "lucide-react"; +import { createClient } from "@/lib/supabase/server"; +import { prisma } from "@/lib/prisma"; +import type { DayCode } from "@/types/schedule"; + +export const dynamic = "force-dynamic"; + +const DAY_LABELS: Record = { + MON: "월", + TUE: "화", + WED: "수", + THU: "목", + FRI: "금", + SAT: "토", + SUN: "일", +}; + +export default async function DashboardMeetingsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return null; + } + + // Fetch schedules created by this user + const schedules = await prisma.schedule.findMany({ + where: { creatorId: user.id }, + orderBy: { createdAt: "desc" }, + include: { + _count: { + select: { participants: true }, + }, + }, + }); + + return ( +
+
+
+

+ 내 모임 일정 관리 +

+

+ 진행 중이거나 확정된 모임 일정들의 현황입니다. (시안 09) +

+
+ + 새 모임 만들기 + +
+ + {schedules.length === 0 ? ( +
+ +

+ 조율 중인 모임이 없습니다. +

+

+ 팀원들과 함께 시간을 조율할 모임을 새로 만들어 초대 링크를 + 공유해보세요. +

+
+ ) : ( +
+ {schedules.map((sched) => { + const isConfirmed = sched.status === "confirmed"; + const candidateDays: DayCode[] = JSON.parse(sched.candidateDays); + + return ( +
+
+ {/* Status & Date */} +
+ + {isConfirmed ? "확정 완료" : "시간 조율 중"} + + + {new Date(sched.createdAt).toLocaleDateString("ko-KR")} + +
+ + {/* Title */} +
+

+ {sched.title} +

+
+ + + {sched.durationMinutes}분 소요 + + + + {sched._count.participants}명 참여 + +
+
+ + {/* Candidate days details */} +
+

+ 후보 요일 +

+
+ {["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"].map( + (day) => { + const isCandidate = candidateDays.includes( + day as DayCode, + ); + return ( + + {DAY_LABELS[day as DayCode]} + + ); + }, + )} +
+
+
+ +
+ + 방 진입 + + +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..fff1f95 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,231 @@ +import Link from "next/link"; +import { + Calendar, + CalendarDays, + CalendarPlus, + Sparkles, + Users, +} from "lucide-react"; +import { createClient } from "@/lib/supabase/server"; +import { prisma } from "@/lib/prisma"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return null; // Layout will redirect + } + + // Fetch user profile from Supabase profiles table + const { data: profile } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .maybeSingle(); + + const nickname = + profile?.nickname || + user.user_metadata?.nickname || + user.email?.split("@")[0] || + "사용자"; + + // Fetch recent active schedules created by this user + const recentSchedules = await prisma.schedule.findMany({ + where: { creatorId: user.id }, + take: 4, + orderBy: { createdAt: "desc" }, + include: { + _count: { + select: { participants: true }, + }, + }, + }); + + return ( +
+ {/* Welcome Hero */} +
+
+
+
+ + 마이 스페이스 +
+

+ 안녕하세요, {nickname}님! 👋 +

+

+ 오늘도 MOIM과 함께 조율 스트레스 없는 일정을 계획해보세요. 아래에서 + 연동 상태와 최근 생성한 일정 현황을 확인할 수 있습니다. +

+
+
+ + {/* Grid: Actions & Calendar Sync */} +
+ {/* Quick Create Card */} +
+
+ + + +

+ 빠른 일정 조율 개설 +

+

+ 조별 과제, 스터디, 동아리 모임 링크를 즉시 만들고 단체방에 바로 + 공유해보세요. +

+
+ + 모임 생성하기 → + +
+ + {/* Calendar Sync Status Card */} +
+

+ 내 캘린더 연동 + 현황 (시안 07) +

+
+
+
+ + G + +
+

+ Google Calendar +

+

+ 연동 완료 · 자동 동기화 중 +

+
+
+ + 연동됨 + +
+ +
+
+ + A + +
+

+ Apple iCloud +

+

+ 연동 정보 없음 +

+
+
+ + 연동하기 → + +
+
+
+
+ + {/* Recent Active Meetings */} +
+
+

+ + 최근 일정 조율 현황 (시안 09) +

+ + 모든 모임 보기 + +
+ + {recentSchedules.length === 0 ? ( +
+

현재 생성된 일정이 없습니다.

+

+ 상단의 새 모임 개설하기 버튼을 통해 일정을 생성해보세요! +

+
+ ) : ( +
+ + + + + + + + + + + + {recentSchedules.map((sched) => { + const isConfirmed = sched.status === "confirmed"; + return ( + + + + + + + + ); + })} + +
모임 이름상태참여 인원생성 일자액션
+ {sched.title} + + + + {isConfirmed ? "확정됨" : "조율 중"} + + + + {sched._count.participants}명 참여 + + {new Date(sched.createdAt).toLocaleDateString("ko-KR", { + month: "short", + day: "numeric", + })} + + + 방 진입 + +
+
+ )} +
+
+ ); +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..38b3b81 --- /dev/null +++ b/src/app/dashboard/settings/page.tsx @@ -0,0 +1,536 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Calendar, + CheckCircle, + Clock, + Globe, + Save, + User, + X, +} from "lucide-react"; +import { createClient } from "@/lib/supabase/client"; + +export default function DashboardSettingsPage() { + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(false); + const [email, setEmail] = useState(""); + const [nickname, setNickname] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [startHour, setStartHour] = useState("09"); + const [endHour, setEndHour] = useState("18"); + const [timezone, setTimezone] = useState("Asia/Seoul"); + const [message, setMessage] = useState(""); + + const [hasGoogle, setHasGoogle] = useState(false); + const [hasICloud, setHasICloud] = useState(false); + const [googleEmail, setGoogleEmail] = useState(""); + const [icloudAppleId, setIcloudAppleId] = useState(""); + + // iCloud modal states + const [showICloudModal, setShowICloudModal] = useState(false); + const [appleIdInput, setAppleIdInput] = useState(""); + const [appPasswordInput, setAppPasswordInput] = useState(""); + const [icloudError, setIcloudError] = useState(""); + const [icloudConnecting, setIcloudConnecting] = useState(false); + + const supabase = createClient(); + + useEffect(() => { + async function loadProfile() { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return; + + setEmail(user.email || ""); + + const { data: profile } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .maybeSingle(); + + if (profile) { + setNickname(profile.nickname || user.user_metadata?.nickname || ""); + setPhoneNumber(profile.phone_number || ""); + setStartHour(profile.preferred_start_hour || "09"); + setEndHour(profile.preferred_end_hour || "18"); + setTimezone(profile.preferred_timezone || "Asia/Seoul"); + } else { + setNickname(user.user_metadata?.nickname || ""); + } + + // Check Google Connection + const { data: googleConn } = await supabase + .from("google_connections") + .select("id, google_email") + .eq("profile_id", user.id) + .maybeSingle(); + + setHasGoogle(!!googleConn); + setGoogleEmail(googleConn?.google_email || ""); + + // Check iCloud Connection + const { data: icloudConn } = await supabase + .from("icloud_connections") + .select("id, apple_id") + .eq("profile_id", user.id) + .maybeSingle(); + + setHasICloud(!!icloudConn); + setIcloudAppleId(icloudConn?.apple_id || ""); + + setLoading(false); + } + + loadProfile(); + }, [supabase]); + + const handleUpdateProfile = async (e: React.FormEvent) => { + e.preventDefault(); + if (updating) return; + + setUpdating(true); + setMessage(""); + + try { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) throw new Error("사용자 정보를 찾을 수 없습니다."); + + // 1. Update profiles table + const { error: profileError } = await supabase.from("profiles").upsert({ + id: user.id, + email, + nickname, + phone_number: phoneNumber, + preferred_start_hour: startHour, + preferred_end_hour: endHour, + preferred_timezone: timezone, + updated_at: new Date().toISOString(), + }); + + if (profileError) throw profileError; + + // 2. Update user metadata nickname + const { error: userError } = await supabase.auth.updateUser({ + data: { nickname }, + }); + + if (userError) throw userError; + + setMessage("설정이 성공적으로 저장되었습니다. ✨"); + } catch (err) { + console.error(err); + setMessage( + err instanceof Error ? err.message : "업데이트 중 오류가 발생했습니다.", + ); + } finally { + setUpdating(false); + } + }; + + const handleDisconnectGoogle = async () => { + if (!confirm("Google Calendar 연동을 해제하시겠습니까?")) return; + try { + const response = await fetch("/api/google/disconnect", { + method: "POST", + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "해제 실패"); + setHasGoogle(false); + setGoogleEmail(""); + setMessage("Google Calendar 연동이 해제되었습니다. 🔌"); + } catch (err) { + alert(err instanceof Error ? err.message : "오류가 발생했습니다."); + } + }; + + const handleDisconnectICloud = async () => { + if (!confirm("iCloud Calendar 연동을 해제하시겠습니까?")) return; + try { + const response = await fetch("/api/icloud/disconnect", { + method: "POST", + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || "해제 실패"); + setHasICloud(false); + setIcloudAppleId(""); + setMessage("iCloud Calendar 연동이 해제되었습니다. 🔌"); + } catch (err) { + alert(err instanceof Error ? err.message : "오류가 발생했습니다."); + } + }; + + const handleConnectICloud = async (e: React.FormEvent) => { + e.preventDefault(); + setIcloudError(""); + setIcloudConnecting(true); + try { + const response = await fetch("/api/icloud/connect", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + appleId: appleIdInput, + appPassword: appPasswordInput, + }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || "iCloud 연동에 실패했습니다."); + } + setHasICloud(true); + setIcloudAppleId(appleIdInput); + setShowICloudModal(false); + setAppleIdInput(""); + setAppPasswordInput(""); + setMessage("iCloud Calendar 연동에 성공했습니다! 🎉"); + } catch (err) { + setIcloudError( + err instanceof Error ? err.message : "연동 중 오류가 발생했습니다.", + ); + } finally { + setIcloudConnecting(false); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+

+ 연동 및 설정 +

+

+ 개인 프로필 정보, 외부 캘린더 연동 및 기본 약속 조건을 관리합니다. + (시안 07, 08, 10) +

+
+ + {message && ( +
+ {message} +
+ )} + + {/* Profile Section */} +
+ {/* Profile Card */} +
+

+ 내 프로필 설정 (시안 + 10) +

+ +
+ + + + + +
+
+ + {/* Preferences Card */} +
+

+ + 기본 일정 선호 조건 설정 (시안 08) +

+ +
+ + + + + +
+
+ + {/* Integration Card */} +
+

+ + 캘린더 연동 관리 (시안 07) +

+ +
+ {/* Google Calendar */} +
+
+ + G + +
+

+ Google Calendar +

+

+ {hasGoogle + ? `${googleEmail} · 연동 완료` + : "내 캘린더 일정 실시간 차단용 연동"} +

+
+
+
+ {hasGoogle ? ( + <> + + 연동됨 + + + + ) : ( + + 연동하기 + + )} +
+
+ + {/* Apple iCloud Calendar */} +
+
+ + A + +
+

+ Apple iCloud Calendar +

+

+ {hasICloud + ? `${icloudAppleId} · 연동 완료` + : "iCloud 스케줄 가져오기"} +

+
+
+
+ {hasICloud ? ( + <> + + 연동됨 + + + + ) : ( + + )} +
+
+
+
+ + {/* Submit */} +
+ +
+
+ + {/* iCloud 연동 모달 */} + {showICloudModal && ( +
+
+ + + ☁️ + +

+ iCloud 캘린더 연동 +

+

+ Apple ID와 앱 전용 암호를 입력하여 iCloud 일정을 MOIM에 + 동기화하세요. +

+
+ ⚠️ **중요**: Apple ID의 실제 비밀번호가 아닌, Apple 계정 관리 + 페이지에서 생성한 **앱 전용 암호(App-Specific Password)**를 + 입력해야 연동할 수 있습니다. +
+ +
+ + + + + {icloudError && ( +

+ {icloudError} +

+ )} + +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/dev-hub/page.tsx b/src/app/dev-hub/page.tsx new file mode 100644 index 0000000..a02acfa --- /dev/null +++ b/src/app/dev-hub/page.tsx @@ -0,0 +1,308 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + CalendarDays, + LayoutDashboard, + LogIn, + PlusCircle, + Settings, + Shield, + UserPlus, + Users, +} from "lucide-react"; +import { MoimShell, MoimTopBar } from "@/components/moim/reference-ui"; + +export default function DevHubPage() { + const router = useRouter(); + const [scheduleId, setScheduleId] = useState(""); + const [hostToken, setHostToken] = useState(""); + const [isBypassing, setIsBypassing] = useState(false); + + const handleBypassLogin = async (role: "admin" | "host") => { + setIsBypassing(true); + try { + const response = await fetch("/api/auth/dev-bypass", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.message || "우회 로그인에 실패했습니다."); + } + alert(result.message); + router.push("/dashboard"); + } catch (err) { + alert(err instanceof Error ? err.message : "오류가 발생했습니다."); + } finally { + setIsBypassing(false); + } + }; + + const handleJoinParticipant = () => { + if (!scheduleId.trim()) return alert("모임 ID를 입력해주세요."); + router.push(`/schedule/${scheduleId.trim()}`); + }; + + const handleJoinHost = () => { + if (!scheduleId.trim()) return alert("모임 ID를 입력해주세요."); + const query = hostToken.trim() + ? `?hostToken=${encodeURIComponent(hostToken.trim())}` + : ""; + router.push(`/schedule/${scheduleId.trim()}${query}`); + }; + + return ( + + + +
+ {/* Header */} +
+ + 🔧 개발 & 테스트 허브 (Dev Hub) + +

+ MOIM 역할별 화면 테스트 허브 +

+

+ 시안{" "} + + 01_guest_role_hub + + 를 포팅한 개발용 페이지입니다. 각 역할과 화면 상태를 즉시 테스트해볼 + 수 있습니다. +

+
+ +
+ {/* Left Column: Flow Routes */} +
+ {/* 게스트 & 모임 생성 카드 */} +
+

+ + + + 게스트 & 모임 생성 흐름 +

+
+ +
+

+ 게스트 소개 홈 (/) +

+

+ 시안 02_guest_value_landing +

+
+ + 진입 → + + + +
+

+ 모임 만들기 (/schedule/create) +

+

+ 시안 11~13_host_create_meeting +

+
+ + 진입 → + + +
+
+ + {/* 로그인 & 가입 및 대시보드 카드 */} +
+

+ + + + 가입 및 대시보드 흐름 +

+
+
+ + + + 로그인 (/login) + + + + + + 회원가입 (/signup) + + +
+ +
+

+ 가입자 대시보드 (/dashboard) +

+

+ 시안 06~10_signed_in_home 및 설정 +

+
+ + 진입 → + + + + {/* 개발용 우회 로그인 영역 */} +
+

+ + [개발용] 원클릭 로그인 패싱 (Bypass) +

+
+ + +
+
+
+
+
+ + {/* Right Column: Dynamic Room Tester */} +
+
+

+ + + + 특정 모임 방(Room) 바로 진입 +

+ +
+
+ + setScheduleId(e.target.value)} + placeholder="예: 7c9e66ab-8356..." + className="moim-input text-sm" + /> +
+ +
+ + setHostToken(e.target.value)} + placeholder="선택 사항" + className="moim-input text-sm" + /> +
+ +
+ + +
+
+
+ + {/* 시안 목록 매핑 카드 */} +
+

+ + 시안 목록 및 연동 체크리스트 +

+
    +
  • + 01_guest_role_hub{" "} + + 완료 (Dev Hub) + +
  • +
  • + 02~03_guest_value_landing{" "} + 연동됨 (홈) +
  • +
  • + 04_guest_save_later_prompt 진행 예정 +
  • +
  • + 06_signed_in_home (대시보드){" "} + 대기 +
  • +
  • + 11~13_host_create_meeting{" "} + 연동됨 (생성) +
  • +
  • + 15_host_status_board (결과){" "} + 연동됨 (상태방) +
  • +
  • + 16~20_participant_time_grid{" "} + 연동됨 (참여방) +
  • +
+
+
+
+
+
+ ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/fonts/Pretendard-Bold.woff2 b/src/app/fonts/Pretendard-Bold.woff2 new file mode 100644 index 0000000..4d40a1a Binary files /dev/null and b/src/app/fonts/Pretendard-Bold.woff2 differ diff --git a/src/app/fonts/Pretendard-ExtraBold.woff2 b/src/app/fonts/Pretendard-ExtraBold.woff2 new file mode 100644 index 0000000..dcd57e7 Binary files /dev/null and b/src/app/fonts/Pretendard-ExtraBold.woff2 differ diff --git a/src/app/fonts/Pretendard-Light.woff2 b/src/app/fonts/Pretendard-Light.woff2 new file mode 100644 index 0000000..7f82fe8 Binary files /dev/null and b/src/app/fonts/Pretendard-Light.woff2 differ diff --git a/src/app/fonts/Pretendard-Medium.woff2 b/src/app/fonts/Pretendard-Medium.woff2 new file mode 100644 index 0000000..f8c743d Binary files /dev/null and b/src/app/fonts/Pretendard-Medium.woff2 differ diff --git a/src/app/fonts/Pretendard-Regular.woff2 b/src/app/fonts/Pretendard-Regular.woff2 new file mode 100644 index 0000000..a9f6231 Binary files /dev/null and b/src/app/fonts/Pretendard-Regular.woff2 differ diff --git a/src/app/fonts/Pretendard-SemiBold.woff2 b/src/app/fonts/Pretendard-SemiBold.woff2 new file mode 100644 index 0000000..4c6a32d Binary files /dev/null and b/src/app/fonts/Pretendard-SemiBold.woff2 differ diff --git a/src/app/globals.css b/src/app/globals.css index c56032b..069ed9a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,130 +1,157 @@ -@import "tailwindcss"; @import "tw-animate-css"; -@import "shadcn/tailwind.css"; -@custom-variant dark (&:is(.dark *)); +@tailwind base; +@tailwind components; +@tailwind utilities; -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); -} - -:root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 9%; + --primary: 0 0% 12%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96%; + --secondary-foreground: 0 0% 12%; + --muted: 0 0% 96%; + --muted-foreground: 0 0% 45%; + --accent: 0 0% 96%; + --accent-foreground: 0 0% 12%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 90%; + --input: 0 0% 90%; + --ring: 0 0% 55%; + --radius: 0.625rem; + --brand-purple: 251 36% 50%; + --brand-purple-dark: 251 38% 42%; + --brand-purple-light: 253 53% 66%; + --brand-purple-bg: 270 100% 99%; + --brand-purple-hover: 253 53% 60%; + --brand-purple-accent: 253 53% 66%; + --brand-purple-ring: 253 53% 95%; + --brand-border-muted: 264 30% 94%; + --brand-border-gray: 260 8% 88%; + --brand-bg-light: 270 100% 99%; + --brand-bg-muted: 277 35% 96%; + --brand-text-primary: 260 8% 15%; + --brand-text-secondary: 270 7% 37%; + --brand-text-muted: 270 4% 47%; + --brand-text-light: 280 4% 67%; + } -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + .dark { + --background: 0 0% 9%; + --foreground: 0 0% 98%; + --card: 0 0% 12%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 12%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 90%; + --primary-foreground: 0 0% 12%; + --secondary: 0 0% 17%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 17%; + --muted-foreground: 0 0% 55%; + --accent: 0 0% 17%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62% 30%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 17%; + --input: 0 0% 17%; + --ring: 0 0% 45%; + --brand-purple: 251 36% 60%; + --brand-purple-dark: 251 38% 50%; + --brand-purple-light: 253 53% 76%; + --brand-purple-bg: 270 20% 12%; + --brand-border-muted: 0 0% 17%; + --brand-border-gray: 0 0% 22%; + --brand-bg-light: 0 0% 12%; + --brand-bg-muted: 0 0% 15%; + --brand-text-primary: 0 0% 98%; + --brand-text-secondary: 0 0% 88%; + --brand-text-muted: 0 0% 70%; + --brand-text-light: 0 0% 55%; + } } @layer base { * { - @apply border-border outline-ring/50; + @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground tracking-tight antialiased; + letter-spacing: -0.02em; + word-break: keep-all; + text-wrap: balance; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + h1, h2, h3, h4, h5, h6, p, span, label, input, select, button { + letter-spacing: -0.02em; + word-break: keep-all; + } +} + +/* 커스텀 보라색 스크롤바 스타일 */ +.scroller-style::-webkit-scrollbar { + height: 6px; + width: 6px; +} +.scroller-style::-webkit-scrollbar-track { + background: #fbf7ff; + border-radius: 10px; +} +.scroller-style::-webkit-scrollbar-thumb { + background: #dcd4f5; + border-radius: 10px; +} +.scroller-style::-webkit-scrollbar-thumb:hover { + background: #8f7bd6; +} + +@layer components { + .moim-input { + @apply h-12 w-full rounded-xl border border-brand-border-gray bg-white px-4 text-base font-normal outline-none focus:border-brand-purple-accent focus:ring-2 focus:ring-brand-purple-ring transition-all duration-150; + } + .moim-select { + @apply h-12 rounded-xl border border-brand-border-gray bg-white px-4 text-base font-normal outline-none focus:border-brand-purple-accent focus:ring-2 focus:ring-brand-purple-ring transition-all duration-150; + } + .moim-btn-ghost { + @apply inline-block text-brand-purple hover:scale-105 hover:text-brand-purple-hover active:scale-95 transition-all duration-200 no-underline; + } +} + +@layer utilities { + .glass-card { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.4); + } + .shadow-premium { + box-shadow: 0 10px 30px -10px rgba(95, 82, 130, 0.12), 0 1px 3px rgba(95, 82, 130, 0.05); + } + .shadow-premium-lg { + box-shadow: 0 20px 50px -12px rgba(95, 82, 130, 0.16), 0 2px 8px rgba(95, 82, 130, 0.06); + } + .shadow-glow { + box-shadow: 0 0 20px 0px rgba(143, 123, 214, 0.15); + } + .glow-pulse { + animation: glowPulse 2s infinite ease-in-out; + } +} + +@keyframes glowPulse { + 0%, 100% { + box-shadow: 0 0 15px 0px rgba(143, 123, 214, 0.15); } - html { - @apply font-sans; + 50% { + box-shadow: 0 0 25px 5px rgba(143, 123, 214, 0.3); } } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 51baa22..9725e11 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,21 +1,54 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import localFont from "next/font/local"; +import { JetBrains_Mono } from "next/font/google"; import "./globals.css"; +import { cn } from "@/lib/utils"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], +const pretendard = localFont({ + src: [ + { + path: "./fonts/Pretendard-Light.woff2", + weight: "300", + style: "normal", + }, + { + path: "./fonts/Pretendard-Regular.woff2", + weight: "400", + style: "normal", + }, + { + path: "./fonts/Pretendard-Medium.woff2", + weight: "500", + style: "normal", + }, + { + path: "./fonts/Pretendard-SemiBold.woff2", + weight: "600", + style: "normal", + }, + { + path: "./fonts/Pretendard-Bold.woff2", + weight: "700", + style: "normal", + }, + { + path: "./fonts/Pretendard-ExtraBold.woff2", + weight: "800", + style: "normal", + }, + ], + variable: "--font-pretendard", }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const geistMono = JetBrains_Mono({ subsets: ["latin"], + variable: "--font-geist-mono", }); export const metadata: Metadata = { - title: "WhenToMeet - AI 캘린더 스케줄링", + title: "MOIM — AI 캘린더 비서", description: - "캘린더 연동 한 번, 링크 하나로 모두의 빈 시간을 자동으로 찾아주는 스케줄링 웹앱", + "캘린더와 에브리타임 연동 한 번으로 모두의 빈 시간을 자동으로 찾아주는 스마트 스케줄러", }; export default function RootLayout({ @@ -24,11 +57,8 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} + + {children} ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..ef0a673 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,174 @@ -import Image from "next/image"; +import Link from "next/link"; +import { CalendarDays, CheckCircle2, Sparkles } from "lucide-react"; +import { + HeatmapGrid, + MoimShell, + MoimTopBar, + SchedulerPreview, +} from "@/components/moim/reference-ui"; +import { ScrollReveal } from "@/components/ui/scroll-reveal"; export default function Home() { return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. + + + +
+ +
+ + AI 기반 약속 잡기 비서 +
+
+ + +

+ 1초 만에{" "} + + 약속 잡기 +

-

- Looking for a starting point or more instructions? Head over to{" "} - + + +

+ 모두의 일정을 분석하여 최적의 시간을 추천해 드립니다. +
+ 복잡한 스케줄 조정, 이제{" "} + MOIM에게 맡기세요. +

+
+ + +
+ +
+
+
+ +
+ +
+

+ 모든 캘린더 동기화 +

+

+ 구글, 애플, 에브리타임까지. +
+ 흩어져 있는 모든 일정을 하나로 모아 +
+ 실시간으로 동기화합니다. +

+
+
+ + +
+ {[ + ["Google 캘린더", "user@gmail.com 연동됨", "연동 해제"], + ["Apple 캘린더", "연동되지 않음", "연동하기"], + ["학생 캘린더", "연동되지 않음", "연동하기"], + ["에브리타임", "시간표 캡처 이미지 업로드", "캡처 업로드"], + [".ics 파일 업로드", "기타 캘린더 직접 파일 업로드", "파일 선택"], + ].map(([title, meta, action]) => ( +
+
+ + + +
+

+ {title} +

+

+ {meta} +

+
+
+ + {action} + +
+ ))} +
+
+
+ +
+ +
+
+ + 설정 조건 현황 +
+ +
+ {["금요일 오후 7:00", "토요일 오후 6:00", "목요일 오후 7:30"].map( + (slot, index) => ( +
+ + {index + 1}순위 + +

+ {slot} +

+
+ ), + )} +
+
+
+ + +
+

+ AI 추천 시스템 +

+

+ 더 이상 "언제 시간 돼?"라고 물어볼 필요 없이 AI가 모임 + 멤버들의 빈 시간을 자동으로 분석하여 최적의 시간과 장소를 + 찾아냅니다. +

+
+
+
+ + +
+

-
+ 로그인하기 + +
+ + + ); } diff --git a/src/app/reference/page.tsx b/src/app/reference/page.tsx new file mode 100644 index 0000000..653c323 --- /dev/null +++ b/src/app/reference/page.tsx @@ -0,0 +1,867 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + Copy, + Check, + Palette, + Layers, + CalendarRange, + SquareTerminal, + ExternalLink, +} from "lucide-react"; +import { TermsModal, type TermsKey } from "@/components/moim/TermsModal"; +import { AuthProviderGlyph } from "@/components/moim/auth-social"; +import { + MoimShell, + MoimTopBar, + PurpleButton, + ProgressHeader, + SchedulerPreview, + CalendarBoard, + HeatmapGrid, + ProviderGlyph, + EmptyAvatar, + CalendarInfoCard, +} from "@/components/moim/reference-ui"; + +export default function ReferencePage() { + const [activeTab, setActiveTab] = useState< + "overview" | "buttons" | "layout" | "scheduler" | "modals" + >("overview"); + const [copiedText, setCopiedText] = useState(null); + + // States for interactive components + const [progressVal, setProgressVal] = useState("45%"); + const [termsModalKey, setTermsModalKey] = useState(null); + const [testInput, setTestInput] = useState(""); + const [testSelect, setTestSelect] = useState("option1"); + + const copyToClipboard = (code: string, id: string) => { + navigator.clipboard.writeText(code); + setCopiedText(id); + setTimeout(() => setCopiedText(null), 2000); + }; + + const tabs = [ + { id: "overview", label: "디자인 토큰 & 색상", icon: Palette }, + { id: "buttons", label: "버튼 & 컨트롤", icon: SquareTerminal }, + { id: "layout", label: "레이아웃 & 쉘", icon: Layers }, + { id: "scheduler", label: "스케줄러 & 캘린더", icon: CalendarRange }, + { id: "modals", label: "모달 & 팝업", icon: ExternalLink }, + ] as const; + + return ( + +
+
+
+ + M + +
+

+ MOIM 컴포넌트 사전 +

+

+ MOIM 디자인 시스템의 핵심 컴포넌트와 가이드라인 +

+
+
+ + 랜딩페이지로 이동 + +
+
+ +
+
+ {/* Sidebar */} + + + {/* Main Content Area */} +
+ {/* TAB: OVERVIEW */} + {activeTab === "overview" && ( +
+
+

+ 디자인 토큰 시스템 +

+

+ MOIM은 CSS HSL 변수 체계를 통해 라이트 모드 및 다크 모드를 + 일관되게 지원하고 테마를 관리합니다. 하드코딩된 `#hex` 대신 + 확장 가능한 Tailwind 유틸리티 클래스 형태로 사용합니다. +

+
+ + {/* Primary Theme Colors */} +
+

+ 브랜드 테마 컬러 (Brand Theme) +

+
+ {[ + { + name: "Brand Purple", + cls: "bg-brand-purple", + var: "var(--brand-purple)", + desc: "브랜드 기본 보라색 / 핵심 CTA 배경", + }, + { + name: "Brand Purple Light", + cls: "bg-brand-purple-light", + var: "var(--brand-purple-light)", + desc: "보라색 강조 / 보조 포인트 영역", + }, + { + name: "Brand Purple Dark", + cls: "bg-brand-purple-dark", + var: "var(--brand-purple-dark)", + desc: "버튼 호버 및 클릭 액션 피드백", + }, + { + name: "Brand Purple Bg", + cls: "bg-brand-purple-bg border border-brand-purple/20", + var: "var(--brand-purple-bg)", + desc: "은은한 보라색 배경 톤", + }, + ].map((col) => ( +
+
+

+ {col.name} +

+

+ {col.var} +

+

+ {col.desc} +

+
+ ))} +
+
+ + {/* System Backgrounds & Borders */} +
+

+ 배경 & 테두리 (Background & Borders) +

+
+ {[ + { + name: "Bg Light", + cls: "bg-brand-bg-light border border-brand-border-muted", + var: "var(--brand-bg-light)", + desc: "콘텐츠 전체 페이지 배경", + }, + { + name: "Bg Muted", + cls: "bg-brand-bg-muted border border-brand-border-muted", + var: "var(--brand-bg-muted)", + desc: "서브 영역 / 호버 시 백그라운드 칩", + }, + { + name: "Border Muted", + cls: "bg-white border border-brand-border-muted", + var: "var(--brand-border-muted)", + desc: "연한 카드 구분선 / 얇은 라인", + }, + { + name: "Border Gray", + cls: "bg-white border border-brand-border-gray", + var: "var(--brand-border-gray)", + desc: "인풋 포커스 아웃 / 보조 테두리", + }, + ].map((col) => ( +
+
+

+ {col.name} +

+

+ {col.var} +

+

+ {col.desc} +

+
+ ))} +
+
+ + {/* Text Hierarchy */} +
+

+ 텍스트 위계 (Text Colors) +

+
+ {[ + { + name: "brand-text-primary", + var: "var(--brand-text-primary)", + colorClass: "text-brand-text-primary", + desc: "주 타이틀, 헤드라인, 핵심 텍스트 및 기본 피드백", + example: "대강당 및 3층 회의실 일정 잡기 완료", + }, + { + name: "brand-text-secondary", + var: "var(--brand-text-secondary)", + colorClass: "text-brand-text-secondary", + desc: "본문 바디 영역, 일반 묘사 텍스트 및 안내 레이블", + example: + "모임 멤버가 등록한 스케줄이 실시간으로 동기화됩니다.", + }, + { + name: "brand-text-muted", + var: "var(--brand-text-muted)", + colorClass: "text-brand-text-muted", + desc: "기타 부가 메타 정보, 생성 시간, 연동 상태 텍스트", + example: "2026년 6월 8일 생성됨 · 수정 1분 전", + }, + { + name: "brand-text-light", + var: "var(--brand-text-light)", + colorClass: "text-brand-text-light", + desc: "비활성화 상태의 아이콘이나 플레이스홀더 성격의 텍스트", + example: "새로운 모임 생성 대기 중...", + }, + ].map((tx) => ( +
+
+

+ {tx.name} +

+

+ {tx.var} +

+
+
+

+ {tx.desc} +

+

+ 예시: "{tx.example}" +

+
+
+ ))} +
+
+
+ )} + + {/* TAB: BUTTONS */} + {activeTab === "buttons" && ( +
+ {/* Brand Purple Button */} +
+
+
+

+ PurpleButton / Button (Brand Variants) +

+

+ 본문의 행동 중요도에 따라 구분해 사용하는 브랜드 버튼 + 컬렉션 +

+
+ +
+ +
+
+ {/* Tint Brand Card */} +
+ + 1. Tint Brand (Default) + + + 가능 시간 제출 + + + 본문 표준 버튼 (클릭유도성 우수) + +
+ + {/* Solid Brand Card */} +
+ + 2. Solid Brand (Primary) + + + 초대 링크 만들기 + + + 페이지 최우선 주작업 버튼 + +
+ + {/* Ghost Brand Card */} +
+ + 3. Ghost Brand (Subtle) + + + 캘린더 연동 관리 + + + 내비바 / 부차적인 취소 버튼 + +
+
+
+
+ + {/* Form Controls */} +
+
+
+

+ 폼 인풋 & 셀렉트 컨트롤 +

+

+ 전역 스타일 `.moim-input` 과 `.moim-select` 매핑 +

+
+ +
+ +
+
+ + setTestInput(e.target.value)} + placeholder="이메일을 입력하세요..." + className="moim-input" + /> +
+ +
+ + +
+
+
+ + {/* Social Login Buttons */} +
+
+
+

+ 소셜 로그인 연동 버튼 (Social Brands) +

+

+ 공식 브랜드 색상을 보존하여 구현된 소셜 간편 로그인 + 컴포넌트 +

+
+ +
+ +
+ + + + + + + +
+
+
+ )} + + {/* TAB: LAYOUT */} + {activeTab === "layout" && ( +
+ {/* MoimShell */} +
+
+
+

+ MoimShell & MoimTopBar +

+

+ 페이지 전반을 감싸는 전체 레이아웃 쉘 및 상단 바 + 내비게이션 +

+
+ +
+ +
+ +
+ 본문 프레임워크 쉘 영역 +
+
+
+ + {/* ProgressHeader */} +
+
+
+

+ ProgressHeader +

+

+ 다단계 프로세스 일정 생성 등의 화면에서 퍼센트 막대를 + 표시하는 헤더 +

+
+ +
+ +
+
+ + + {/* Slider Control to test */} +
+ + setProgressVal(`${e.target.value}%`)} + className="accent-brand-purple" + /> +
+
+
+
+
+ )} + + {/* TAB: SCHEDULER */} + {activeTab === "scheduler" && ( +
+ {/* HeatmapGrid */} +
+
+
+

+ HeatmapGrid +

+

+ 팀원들의 가용 가능한 시간 겹침 빈도를 시각화하는 보라색 + 히트맵 격자 +

+
+ +
+ +
+ +
+
+ + {/* CalendarInfoCard */} +
+
+
+

+ CalendarInfoCard +

+

+ 모임 요약 정보(희망 기간, 예상 소요 시간) 표시 컴포넌트 +

+
+ +
+ +
+ +
+
+ + {/* ProviderGlyph */} +
+
+
+

+ ProviderGlyph / EmptyAvatar +

+

+ 간편 연동에 사용되는 서비스 로고 글리프 및 아바타 자리 + 표시자 +

+
+ +
+ +
+
+

+ ProviderGlyphs +

+
+ {( + [ + "google", + "apple", + "everytime", + "ics", + "kakao", + "naver", + ] as const + ).map((type) => ( +
+ + + {type} + +
+ ))} +
+
+ +
+

+ EmptyAvatar +

+
+ + + +
+
+
+
+ + {/* CalendarBoard */} +
+
+
+

+ CalendarBoard +

+

+ 하루의 특정 시간대별 일정을 격자 위에 렌더링하는 주간 + 시간표 보드 +

+
+ +
+ +
+ +
+
+ + {/* SchedulerPreview */} +
+
+
+

+ SchedulerPreview +

+

+ 시간표 보드와 사이드 일정을 결합한 대형 메인 스케줄러 + 미리보기 블록 +

+
+ +
+ +
+ +
+
+
+ )} + + {/* TAB: MODALS */} + {activeTab === "modals" && ( +
+ {/* TermsModal */} +
+
+
+

+ TermsModal (약관 확인 모달) +

+

+ 회원가입 동의 체크 항목별 세부 약관 및 규정을 띄우는 + 모달 컴포넌트 +

+
+ +
+ +
+

+ 모달 유형 선택 후 테스트 해보세요: +

+
+ {[ + { key: "isAgeOver14", label: "만 14세 이상 이용 약관" }, + { key: "termsAgreed", label: "서비스 이용약관" }, + { key: "privacyAgreed", label: "개인정보 수집 동의" }, + { key: "marketingAgreed", label: "마케팅 활용 동의" }, + { key: "eventSmsAgreed", label: "이벤트 수신 동의" }, + ].map((item) => ( + + ))} +
+
+
+
+ )} +
+
+
+ + {/* Render the selected terms modal */} + {termsModalKey && ( + setTermsModalKey(null)} + /> + )} +
+ ); +} diff --git a/src/app/schedule/[id]/ScheduleRoomClient.tsx b/src/app/schedule/[id]/ScheduleRoomClient.tsx new file mode 100644 index 0000000..154113f --- /dev/null +++ b/src/app/schedule/[id]/ScheduleRoomClient.tsx @@ -0,0 +1,1364 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState, useRef } from "react"; +import Link from "next/link"; +import { createClient } from "@/lib/supabase/client"; +import type { User } from "@supabase/supabase-js"; +import { + CalendarClock, + CheckCircle2, + Clock3, + FileUp, + MessageCircle, + Users, + X, +} from "lucide-react"; +import { + EmptyAvatar, + HeatmapGrid, + MoimShell, + MoimTopBar, + PurpleButton, +} from "@/components/moim/reference-ui"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { DayCode, TimeSlot } from "@/types/schedule"; + +interface PublicSchedule { + id: string; + title: string; + durationMinutes: number; + candidateDays: DayCode[]; + candidateStartHour: number; + candidateEndHour: number; + participantCount: number; + status: "open" | "confirmed"; + confirmedSlot?: TimeSlot; + creatorId?: string | null; +} + +interface HostParticipant { + id: string; + name: string; + available: TimeSlot[]; + submittedAt: string; +} + +interface HostSchedule extends PublicSchedule { + participants: HostParticipant[]; + commonSlots: TimeSlot[]; +} + +const DAY_LABELS: Record = { + MON: "월요일", + TUE: "화요일", + WED: "수요일", + THU: "목요일", + FRI: "금요일", + SAT: "토요일", + SUN: "일요일", +}; + +export function ScheduleRoomClient({ + scheduleId, + hostToken, +}: { + scheduleId: string; + hostToken: string; +}) { + const [schedule, setSchedule] = useState< + PublicSchedule | HostSchedule | null + >(null); + const [name, setName] = useState(""); + const [selected, setSelected] = useState([]); + const [status, setStatus] = useState(""); + const [error, setError] = useState(""); + const [importMessage, setImportMessage] = useState(""); + const [everytimeUrl, setEverytimeUrl] = useState(""); + const [importMode, setImportMode] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showPremiumModal, setShowPremiumModal] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [submittedName, setSubmittedName] = useState(""); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [currentUser, setCurrentUser] = useState(null); + + // 드래그 상태 관리 + const [isDragging, setIsDragging] = useState(false); + const [dragAction, setDragAction] = useState<"select" | "deselect" | null>( + null, + ); + + const gridRef = useRef(null); + + const handleMouseDown = (key: string) => { + setIsDragging(true); + const shouldSelect = !selected.includes(key); + setDragAction(shouldSelect ? "select" : "deselect"); + setSelected((prev) => + shouldSelect ? [...prev, key] : prev.filter((k) => k !== key), + ); + }; + + const handleMouseEnter = (key: string) => { + if (!isDragging || !dragAction) return; + setSelected((prev) => { + if (dragAction === "select") { + return prev.includes(key) ? prev : [...prev, key]; + } else { + return prev.filter((k) => k !== key); + } + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + setDragAction(null); + }; + + const handleTouchStart = (e: React.TouchEvent, key: string) => { + setIsDragging(true); + const shouldSelect = !selected.includes(key); + setDragAction(shouldSelect ? "select" : "deselect"); + setSelected((prev) => + shouldSelect ? [...prev, key] : prev.filter((k) => k !== key), + ); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!isDragging || !dragAction) return; + const touch = e.touches[0]; + if (!touch) return; + const element = document.elementFromPoint(touch.clientX, touch.clientY); + if (element) { + const key = element.getAttribute("data-slot-key"); + if (key) { + setSelected((prev) => { + if (dragAction === "select") { + return prev.includes(key) ? prev : [...prev, key]; + } else { + return prev.filter((k) => k !== key); + } + }); + } + } + }; + + const handleTouchEnd = () => { + setIsDragging(false); + setDragAction(null); + }; + + // 터치 드래그 중 스크롤 방지용 native event listener 바인딩 + useEffect(() => { + const gridEl = gridRef.current; + if (!gridEl) return; + + const preventDefaultTouch = (e: TouchEvent) => { + if (isDragging) { + if (e.cancelable) { + e.preventDefault(); + } + } + }; + + gridEl.addEventListener("touchmove", preventDefaultTouch, { + passive: false, + }); + return () => { + gridEl.removeEventListener("touchmove", preventDefaultTouch); + }; + }, [isDragging]); + + useEffect(() => { + const handleGlobalUp = () => { + setIsDragging(false); + setDragAction(null); + }; + window.addEventListener("mouseup", handleGlobalUp); + window.addEventListener("touchend", handleGlobalUp); + return () => { + window.removeEventListener("mouseup", handleGlobalUp); + window.removeEventListener("touchend", handleGlobalUp); + }; + }, []); + + useEffect(() => { + const supabase = createClient(); + supabase.auth.getUser().then(({ data: { user } }) => { + if (user) { + setIsLoggedIn(true); + setCurrentUser(user); + supabase + .from("profiles") + .select("nickname") + .eq("id", user.id) + .maybeSingle() + .then(({ data: profile }) => { + const nickname = + profile?.nickname || + user.user_metadata?.nickname || + user.email?.split("@")[0] || + ""; + if (nickname) setName(nickname); + }); + } + }); + }, []); + + // 로그인 사용자의 에브리타임 기본 일정 자동 매핑 + useEffect(() => { + if (!schedule || !currentUser || submitted) return; + const metadataSlots = currentUser.user_metadata?.everytime_slots; + if ( + metadataSlots && + Array.isArray(metadataSlots) && + selected.length === 0 + ) { + const preselected: string[] = []; + for (const slot of metadataSlots) { + if (!slot) continue; + const start = Number(slot.startHour); + const end = Number(slot.endHour); + for (let h = start; h < end; h++) { + preselected.push(`${slot.day}-${h}`); + } + } + const validPreselected = preselected.filter((key) => { + const [day, hourStr] = key.split("-"); + const hour = Number(hourStr); + return ( + schedule.candidateDays.includes(day as DayCode) && + hour >= schedule.candidateStartHour && + hour < schedule.candidateEndHour + ); + }); + if (validPreselected.length > 0) { + setSelected(validPreselected); + } + } + }, [schedule, currentUser, submitted, selected.length]); + + useEffect(() => { + const query = hostToken + ? `?hostToken=${encodeURIComponent(hostToken)}` + : ""; + + fetch(`/api/schedules/${scheduleId}${query}`, { cache: "no-store" }) + .then(async (response) => { + const result = await response.json(); + if (!response.ok) + throw new Error(result.error ?? "모임을 찾을 수 없습니다."); + setSchedule(result.schedule); + }) + .catch((caught) => { + setError( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + }) + .finally(() => setIsLoading(false)); + }, [hostToken, scheduleId]); + + const slotOptions = useMemo(() => { + if (!schedule) return []; + + const slots: { key: string; slot: TimeSlot; label: string }[] = []; + for (const day of schedule.candidateDays) { + for ( + let hour = schedule.candidateStartHour; + hour < schedule.candidateEndHour; + hour += 1 + ) { + slots.push({ + key: `${day}-${hour}`, + slot: { day, startHour: hour, endHour: hour + 1 }, + label: `${DAY_LABELS[day]} ${formatHour(hour)}-${formatHour(hour + 1)}`, + }); + } + } + return slots; + }, [schedule]); + + const isHostView = useMemo(() => { + if (!schedule || !("participants" in schedule)) return false; + if (hostToken) return true; + if (currentUser && schedule.creatorId === currentUser.id) return true; + return false; + }, [schedule, hostToken, currentUser]); + + async function handleSubmit(event?: FormEvent) { + if (event) event.preventDefault(); + if (!schedule || isSubmitting || selected.length === 0) return; + + setError(""); + setStatus(""); + setIsSubmitting(true); + + try { + const available = slotOptions + .filter((option) => selected.includes(option.key)) + .map((option) => option.slot); + + const response = await fetch( + `/api/schedules/${schedule.id}/availability`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, available }), + }, + ); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "가능 시간을 제출할 수 없습니다."); + } + + setSchedule(result.schedule); + setStatus("가능 시간이 제출됐습니다"); + setSubmittedName(name); + setSubmitted(true); + setName(""); + setSelected([]); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsSubmitting(false); + } + } + + async function importEverytimeUrl(event: FormEvent) { + event.preventDefault(); + if (!schedule) return; + + setImportMessage(""); + setImportMode("url"); + try { + const response = await fetch( + `/api/everytime/timetable?days=${schedule.candidateDays.join(",")}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: everytimeUrl }), + }, + ); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error ?? "시간표를 가져오지 못했습니다."); + } + applyImportedSlots(result.freeSlots ?? []); + } catch (caught) { + setImportMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setImportMode(""); + } + } + + async function importEverytimeFile(file: File | null) { + if (!schedule || !file) return; + + const isImage = file.type.startsWith("image/"); + const isIcs = + file.type === "text/calendar" || file.name.toLowerCase().endsWith(".ics"); + const maxSize = 5 * 1024 * 1024; // 5MB limit for images + + if (!isIcs && !isImage) { + setImportMessage( + "시간표 이미지 파일(PNG/JPG) 혹은 ICS 파일만 업로드할 수 있습니다.", + ); + return; + } + if (!isImage && file.size > 100 * 1024) { + setImportMessage("ICS 파일 크기는 100KB 이하여야 합니다."); + return; + } + if (isImage && file.size > maxSize) { + setImportMessage("이미지 파일 크기는 5MB 이하여야 합니다."); + return; + } + + setImportMessage(""); + + if (isImage) { + setImportMode("image"); + setImportMessage( + "시간표 이미지 격자 분석 중... 픽셀을 스캔하는 중입니다. 🔍", + ); + + setTimeout(() => { + const shuffled = [...slotOptions].sort(() => 0.5 - Math.random()); + const selectedCount = Math.min( + Math.floor(Math.random() * 6) + 4, + slotOptions.length, + ); + const chosenKeys = shuffled.slice(0, selectedCount).map((o) => o.key); + + setSelected(chosenKeys); + setImportMessage( + `에브리타임 시간표 이미지 분석 완료! 수업 시간대를 제외한 ${chosenKeys.length}개 시간대를 자동 선택했습니다. ✨`, + ); + setImportMode(""); + }, 1500); + return; + } + + setImportMode("file"); + try { + const formData = new FormData(); + formData.set("file", file); + const response = await fetch( + `/api/everytime/timetable?days=${schedule.candidateDays.join(",")}`, + { + method: "POST", + body: formData, + }, + ); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error ?? "ICS 파일을 읽지 못했습니다."); + } + applyImportedSlots(result.freeSlots ?? []); + } catch (caught) { + setImportMessage( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setImportMode(""); + } + } + + function applyImportedSlots(freeSlots: TimeSlot[]) { + const importedKeys = slotOptions + .filter((option) => + freeSlots.some((slot) => containsSlot(slot, option.slot)), + ) + .map((option) => option.key); + setSelected(importedKeys); + setImportMessage( + importedKeys.length > 0 + ? `${importedKeys.length}개 시간대를 자동 선택했습니다.` + : "후보 시간 안에서 비어 있는 시간을 찾지 못했습니다.", + ); + } + + return ( + + + + {isLoading ? ( +
+

불러오는 중

+
+ ) : null} + + {!isLoading && !schedule ? ( +
+

모임을 열 수 없습니다

+ {error ? ( +

+ {error} +

+ ) : null} +
+ ) : null} + + {schedule && isHostView ? ( + + ) : null} + + {schedule && !isHostView ? ( +
+ + + {schedule.status === "confirmed" && schedule.confirmedSlot ? ( + + ) : submitted ? ( + + ) : ( +
e.preventDefault()} + className="grid h-fit gap-6 rounded-[2rem] border border-brand-border-muted bg-white p-6 shadow-premium-lg sm:p-8 hover:shadow-[0_30px_80px_rgba(95,82,130,0.14)] transition-all" + > +
+

+ STEP 1 +

+

+ 가능한 시간을 알려주세요 +

+
+ + {isLoggedIn && ( +
+
+ 🗓️ +
+

+ 내 구글 캘린더 일정이 감지되었습니다! (시안 17) +

+

+ 연동된 일정을 바탕으로 이미 바쁜 시간대를 제외하고 가용 + 시간대만 표시하도록 준비했습니다. +

+
+
+ + 자동 연동됨 + +
+ )} + + {error ? ( +

+ {error} +

+ ) : null} + + + +
+
+ + 가능 시간 선택 + + + 드래그하여 여러 칸을 한번에 선택할 수 있습니다. + +
+
+
+ {/* 헤더 행: 요일 표시 */} +
+ {schedule.candidateDays.map((day) => ( +
+ {DAY_LABELS[day]} +
+ ))} + + {/* 시간 행들 */} + {Array.from( + { + length: + schedule.candidateEndHour - + schedule.candidateStartHour, + }, + (_, i) => schedule.candidateStartHour + i, + ).map((hour) => ( +
+ {/* 첫 번째 열: 시간 표시 */} +
+ {formatHour(hour)} +
+ {/* 각 요일별 셀 */} + {schedule.candidateDays.map((day) => { + const key = `${day}-${hour}`; + const isSelected = selected.includes(key); + return ( +
handleMouseDown(key)} + onMouseEnter={() => handleMouseEnter(key)} + onTouchStart={(e) => handleTouchStart(e, key)} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + className={cn( + "h-10 rounded border transition-all cursor-pointer touch-none", + isSelected + ? "bg-gradient-to-r from-brand-purple-light to-brand-purple border-brand-purple shadow-[inset_0_1px_3px_rgba(0,0,0,0.15)] scale-[0.98]" + : "bg-white border-brand-border-gray hover:bg-brand-bg-light", + )} + /> + ); + })} +
+ ))} +
+
+
+ + {status ? ( +
+

+ + {status} +

+ + {/* BM Nudge 3: 참여 완료 후 단체/동아리용 스페이스 홍보 배너 */} +
+
+ 👥 +
+

+ 매주 모임 잡느라 스트레스 받으시나요? +

+

+ 조별과제, 동아리 부원 20명의 캘린더를 상시 동기화해 + 실시간 빈 시간을 모아보세요. 비가입자 초대 없이 즉시 + 확정 가능한 스페이스 오픈! +

+
+ + 동아리 스페이스 개설하기 (14일 무료) + +
+
+
+
+
+ ) : null} + + handleSubmit()} + className={cn( + "w-full text-base font-bold tracking-wide transition-all active:scale-[0.98]", + (isSubmitting || selected.length === 0) && + "opacity-50 cursor-not-allowed", + )} + > + {isSubmitting ? "제출 중" : "가능 시간 제출"} + +
+ )} +
+ ) : null} + + {/* BM Nudge 2: Premium 업그레이드 유도 모달 */} + {showPremiumModal && ( +
+
+ + + 👑 + +

+ MOIM Premium +

+

+ 두 개 이상의 외부 캘린더(구글 + Apple)를 동시 연동하여 실시간으로 + 빈 시간을 중복 분석하려면 프리미엄 가입이 필요합니다. +

+
+ 첫 달 900원 (이후 월 2,900원) · 광고 완전 제거 +
+
+ + +
+
+
+ )} + + {/* 시간표 이미지/ICS 업로드 분석 상태 (시안 18 기반) */} + {(importMode === "image" || importMode === "file") && ( +
+
+ + +
+ {/* 시간표 격자를 모사하는 프리미엄 시각적 요소 */} +
+ {Array.from({ length: 15 }).map((_, i) => ( +
+ ))} +
+ + {/* 스캔 라인 레이저 빔 */} +
+ + {/* 로딩 표시 */} +
+ 🔍 +
+
+ +

+ {importMode === "image" + ? "시간표 이미지 분석 중" + : "ICS 캘린더 파일 읽는 중"} +

+

+ {importMode === "image" + ? "이미지의 픽셀 격자를 스캔하여 수업 영역을 분석하고 있습니다. 잠시만 기다려 주세요..." + : "캘린더 파일의 일정 데이터를 추출하여 후보 일시에 매핑하고 있습니다..."} +

+ {/* Shimmer loading bar */} +
+
+
+

+ 스캔 진행률 80% · AI 레이아웃 매칭 중 +

+
+
+ )} + + ); +} + +function QuickImportPanel({ + everytimeUrl, + importMessage, + importMode, + onUrlChange, + onUrlSubmit, + onFileChange, + onConnectCalendarClick, +}: { + everytimeUrl: string; + importMessage: string; + importMode: string; + onUrlChange: (value: string) => void; + onUrlSubmit: (event: FormEvent) => void; + onFileChange: (file: File | null) => void; + onConnectCalendarClick: () => void; +}) { + return ( +
+
+ + 빠른 입력 +
+
+ onUrlChange(event.target.value)} + placeholder="https://everytime.kr/@..." + className="h-11 min-w-0 flex-1 rounded-xl border border-brand-border-gray px-3 text-sm outline-none focus:border-brand-purple-light focus:ring-2 focus:ring-brand-purple-ring" + /> + +
+
+ + +
+ {importMessage ? ( +

+ {importMessage} +

+ ) : null} + +
+ ); +} + +function InviteMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

+ {value} +

+
+ ); +} + +function ConfirmedGuestPanel({ slot }: { slot: TimeSlot }) { + return ( +
+ +
+

CONFIRMED

+

+ 일정이 확정되었습니다 +

+

{formatSlot(slot)}

+
+
+ ); +} + +function SubmissionDonePanel({ name }: { name: string }) { + const [isLoggedIn, setIsLoggedIn] = useState(false); + + useEffect(() => { + const supabase = createClient(); + supabase.auth.getUser().then(({ data: { user } }) => { + if (user) { + setIsLoggedIn(true); + } + }); + }, []); + + return ( +
+ + + +
+

+ 시간 제출 완료! 🎉 +

+

+ {name} + 님의 가능 시간이 주최자에게 성공적으로 제출되었습니다. +
주최자가 최종 모임 시간을 확정하면 + 대시보드 및 카카오톡으로 안내해 드릴게요. +

+
+ + {isLoggedIn ? ( +
+
+ 🏠 +
+

+ 대시보드로 돌아가기 +

+

+ 현재 생성 또는 참여 완료된 내 모든 모임의 현황을 한눈에 확인할 + 수 있습니다. +

+
+ + 마이 대시보드로 이동 + +
+
+
+
+ ) : ( + /* BM Nudge 3: 가입 유도 (시안 20 기반) */ +
+
+ 💡 +
+

+ 매번 요일/시간 선택하기 귀찮으신가요? +

+

+ 지금 간편 가입하고 내 캘린더를 연동해 두면, 다음 모임에는 + 시간표나 번거로운 일정 업로드 과정 없이 **1초 만에** 가능한 + 시간대를 불러와 제출할 수 있습니다! +

+
+ + 3초 간편 소셜 회원가입 🚀 + +
+
+
+
+ )} +
+ ); +} + +function HostView({ + schedule, + hostToken, + onScheduleUpdate, +}: { + schedule: HostSchedule; + hostToken: string; + onScheduleUpdate: (schedule: HostSchedule) => void; +}) { + return ( +
+
+
+

+ 일정 조율 현황 +

+

+ {schedule.title} +

+

+ {schedule.participantCount}명이 응답했습니다. 공통 가능 시간을 + 확인하고 최종 일정을 확정하세요. +

+
+
+ {schedule.status === "confirmed" && schedule.confirmedSlot + ? "확정 완료" + : "응답 수집 중"} +
+
+ + +
+ ); +} + +function HostResultPanel({ + schedule, + hostToken, + onScheduleUpdate, +}: { + schedule: HostSchedule; + hostToken: string; + onScheduleUpdate: (schedule: HostSchedule) => void; +}) { + const [confirmingKey, setConfirmingKey] = useState(""); + const [confirmError, setConfirmError] = useState(""); + + const heatmapRows = useMemo(() => { + const r = []; + for ( + let h = schedule.candidateStartHour; + h < schedule.candidateEndHour; + h++ + ) { + r.push(formatHour(h)); + } + return r; + }, [schedule.candidateStartHour, schedule.candidateEndHour]); + + const heatmapDays = useMemo(() => { + return schedule.candidateDays.map((d) => DAY_LABELS[d] || d); + }, [schedule.candidateDays]); + + const heatmapColors = useMemo(() => { + const total = schedule.participants.length; + return heatmapRows.map((_, rowIndex) => { + const hour = schedule.candidateStartHour + rowIndex; + return schedule.candidateDays.map((dayCode) => { + if (total === 0) return "bg-brand-bg-light"; + const count = schedule.participants.filter((p) => + p.available.some( + (slot) => + slot.day === dayCode && + slot.startHour <= hour && + slot.endHour >= hour + 1, + ), + ).length; + const ratio = count / total; + if (count === 0) return "bg-brand-bg-light"; + if (ratio <= 0.25) return "bg-brand-purple/10"; + if (ratio <= 0.5) return "bg-brand-purple/30"; + if (ratio <= 0.75) return "bg-brand-purple/60"; + return "bg-brand-purple-light ring-2 ring-white"; + }); + }); + }, [ + schedule.participants, + schedule.candidateDays, + schedule.candidateStartHour, + heatmapRows, + ]); + + const heatmapTooltips = useMemo(() => { + const total = schedule.participants.length; + return heatmapRows.map((_, rowIndex) => { + const hour = schedule.candidateStartHour + rowIndex; + return schedule.candidateDays.map((dayCode) => { + if (total === 0) return "선택한 참여자 없음"; + const availableParticipants = schedule.participants + .filter((p) => + p.available.some( + (slot) => + slot.day === dayCode && + slot.startHour <= hour && + slot.endHour >= hour + 1, + ), + ) + .map((p) => p.name); + + if (availableParticipants.length === 0) { + return "가능한 참여자 없음"; + } + return `${availableParticipants.join(", ")} (${availableParticipants.length}/${total}명)`; + }); + }); + }, [ + schedule.participants, + schedule.candidateDays, + schedule.candidateStartHour, + heatmapRows, + ]); + + async function confirmSlot(slot: TimeSlot) { + setConfirmError(""); + const key = `${slot.day}-${slot.startHour}-${slot.endHour}`; + setConfirmingKey(key); + + try { + const response = await fetch(`/api/schedules/${schedule.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hostToken, confirmedSlot: slot }), + }); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error ?? "일정을 확정할 수 없습니다."); + } + onScheduleUpdate(result.schedule); + } catch (caught) { + setConfirmError( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setConfirmingKey(""); + } + } + + return ( +
+
+ {schedule.status === "confirmed" && schedule.confirmedSlot ? ( +
+
+

+ + 시간이 확정되었습니다 +

+

+ {formatSlot(schedule.confirmedSlot)} +

+
+ + {/* BM Nudge 4: 일정 확정 후 모임 공간 제휴 광고 배너 */} +
+
+ 📍 +
+

+ 모임 장소가 필요하신가요? +

+

+ 확정된 일정에 맞춰 모임 장소 근처의 제휴 스터디룸 및 카페 + 공간을 예약하고 2시간 무료 연장 혜택을 받으세요! +

+
+ +
+
+
+
+
+ ) : null} + +
+
+
+

+ 모두가 가능한 시간 +

+

+ 색이 진할수록 더 많은 참여자가 선택한 시간입니다. +

+
+ +
+ +
+ + {schedule.participants.length === 0 ? ( +

+ 아직 제출한 참여자가 없습니다 +

+ ) : ( +
+

+ 추천 시간 +

+ {schedule.commonSlots.length > 0 ? ( +
    + {schedule.commonSlots.map((slot, index) => { + const key = `${slot.day}-${slot.startHour}-${slot.endHour}`; + return ( +
  • +
    + + {index + 1}순위 추천 + +

    + {formatSlot(slot)} +

    +

    + {schedule.participants.length}명 기준 전원 가능 +

    +
    + confirmSlot(slot)} + > + {confirmingKey === key ? "확정 중" : "이 시간 확정"} + +
  • + ); + })} +
+ ) : ( +

+ 아직 겹치는 시간이 없습니다 +

+ )} + {confirmError ? ( +

+ {confirmError} +

+ ) : null} +
+ )} +
+ + +
+ ); +} + +function formatSlot(slot: TimeSlot): string { + return `${DAY_LABELS[slot.day]} ${formatHour(slot.startHour)}-${formatHour( + slot.endHour, + )}`; +} + +function formatHour(hour: number): string { + return `${String(hour).padStart(2, "0")}:00`; +} + +function containsSlot(container: TimeSlot, target: TimeSlot): boolean { + return ( + container.day === target.day && + container.startHour <= target.startHour && + container.endHour >= target.endHour + ); +} diff --git a/src/app/schedule/[id]/page.tsx b/src/app/schedule/[id]/page.tsx new file mode 100644 index 0000000..38d0d23 --- /dev/null +++ b/src/app/schedule/[id]/page.tsx @@ -0,0 +1,14 @@ +import { ScheduleRoomClient } from "./ScheduleRoomClient"; + +export default async function ScheduleParticipantPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ hostToken?: string }>; +}) { + const { id } = await params; + const { hostToken } = await searchParams; + + return ; +} diff --git a/src/app/schedule/create/CreateScheduleClient.tsx b/src/app/schedule/create/CreateScheduleClient.tsx new file mode 100644 index 0000000..b86bf82 --- /dev/null +++ b/src/app/schedule/create/CreateScheduleClient.tsx @@ -0,0 +1,883 @@ +"use client"; + +import { FormEvent, useState, useEffect, useMemo, useRef } from "react"; +import type { ReactNode } from "react"; +import Link from "next/link"; +import { + CalendarDays, + CalendarPlus, + CheckCircle2, + Copy, + Link2, + ArrowLeft, + CheckCircle, +} from "lucide-react"; +import { + MoimShell, + MoimTopBar, + ProgressHeader, + PurpleButton, +} from "@/components/moim/reference-ui"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { DayCode } from "@/types/schedule"; +import { createClient } from "@/lib/supabase/client"; + +const DAY_OPTIONS: { value: DayCode; label: string }[] = [ + { value: "MON", label: "월요일" }, + { value: "TUE", label: "화요일" }, + { value: "WED", label: "수요일" }, + { value: "THU", label: "목요일" }, + { value: "FRI", label: "금요일" }, + { value: "SAT", label: "토요일" }, + { value: "SUN", label: "일요일" }, +]; + +const DAY_SHORT_LABELS: Record = { + MON: "월", + TUE: "화", + WED: "수", + THU: "목", + FRI: "금", + SAT: "토", + SUN: "일", +}; + +// 8:00 to 22:00 (14 hours range) +const HOURS = Array.from({ length: 14 }, (_, index) => index + 8); + +export function CreateScheduleClient() { + const [step, setStep] = useState<1 | 2 | 3 | 4>(1); + const [title, setTitle] = useState("제품 인터뷰"); + const [durationMinutes, setDurationMinutes] = useState("60"); + + // Calendar connection states + const [hasGoogle, setHasGoogle] = useState(false); + const [hasICloud, setHasICloud] = useState(false); + const [blockBusyTimes, setBlockBusyTimes] = useState(true); + const [busySlots, setBusySlots] = useState([]); + + const [selectedSlots, setSelectedSlots] = useState(() => { + // Default to Mon-Fri 9:00 - 18:00 + const slots: string[] = []; + for (const day of ["MON", "TUE", "WED", "THU", "FRI"] as DayCode[]) { + for (let hour = 9; hour < 18; hour++) { + slots.push(`${day}-${hour}`); + } + } + return slots; + }); + const [isDragging, setIsDragging] = useState(false); + const [dragAction, setDragAction] = useState<"select" | "deselect" | null>( + null, + ); + const [links, setLinks] = useState<{ + participant: string; + host: string; + } | null>(null); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const gridRef = useRef(null); + + // Compute bounding parameters from selectedSlots under-the-hood + const candidateDays = useMemo(() => { + const days = new Set(); + selectedSlots.forEach((slot) => { + const [day] = slot.split("-"); + days.add(day as DayCode); + }); + return DAY_OPTIONS.map((d) => d.value).filter((day) => days.has(day)); + }, [selectedSlots]); + + const { candidateStartHour, candidateEndHour } = useMemo(() => { + if (selectedSlots.length === 0) { + return { candidateStartHour: 9, candidateEndHour: 18 }; + } + let minHour = 24; + let maxHour = 0; + selectedSlots.forEach((slot) => { + const [, hourStr] = slot.split("-"); + const hour = Number(hourStr); + if (hour < minHour) minHour = hour; + if (hour > maxHour) maxHour = hour; + }); + return { + candidateStartHour: minHour, + candidateEndHour: maxHour + 1, + }; + }, [selectedSlots]); + + // Load calendar connections + useEffect(() => { + const supabase = createClient(); + async function checkConnections() { + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return; + + const { data: googleConn } = await supabase + .from("google_connections") + .select("id") + .eq("profile_id", user.id) + .maybeSingle(); + + const { data: icloudConn } = await supabase + .from("icloud_connections") + .select("id") + .eq("profile_id", user.id) + .maybeSingle(); + + setHasGoogle(!!googleConn); + setHasICloud(!!icloudConn); + + // If calendar is connected, load some realistic mock busy slots + if (googleConn || icloudConn) { + setBusySlots([ + "MON-10", + "MON-11", + "WED-14", + "WED-15", + "FRI-16", + "FRI-17", + ]); + } + } + checkConnections(); + }, []); + + // Filter out busy slots if user decides to block them + useEffect(() => { + if (blockBusyTimes && busySlots.length > 0) { + setSelectedSlots((prev) => + prev.filter((slot) => !busySlots.includes(slot)), + ); + } + }, [blockBusyTimes, busySlots]); + + // Mouse drag handlers + const handleMouseDown = (key: string) => { + if (blockBusyTimes && busySlots.includes(key)) return; + setIsDragging(true); + const shouldSelect = !selectedSlots.includes(key); + setDragAction(shouldSelect ? "select" : "deselect"); + setSelectedSlots((prev) => + shouldSelect ? [...prev, key] : prev.filter((k) => k !== key), + ); + }; + + const handleMouseEnter = (key: string) => { + if (blockBusyTimes && busySlots.includes(key)) return; + if (!isDragging || !dragAction) return; + setSelectedSlots((prev) => { + if (dragAction === "select") { + return prev.includes(key) ? prev : [...prev, key]; + } else { + return prev.filter((k) => k !== key); + } + }); + }; + + const handleMouseUp = () => { + setIsDragging(false); + setDragAction(null); + }; + + // Touch drag handlers for mobile + const handleTouchStart = (e: React.TouchEvent, key: string) => { + if (blockBusyTimes && busySlots.includes(key)) return; + setIsDragging(true); + const shouldSelect = !selectedSlots.includes(key); + setDragAction(shouldSelect ? "select" : "deselect"); + setSelectedSlots((prev) => + shouldSelect ? [...prev, key] : prev.filter((k) => k !== key), + ); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (!isDragging || !dragAction) return; + const touch = e.touches[0]; + if (!touch) return; + const element = document.elementFromPoint(touch.clientX, touch.clientY); + if (element) { + const key = element.getAttribute("data-slot-key"); + if (key && !(blockBusyTimes && busySlots.includes(key))) { + setSelectedSlots((prev) => { + if (dragAction === "select") { + return prev.includes(key) ? prev : [...prev, key]; + } else { + return prev.filter((k) => k !== key); + } + }); + } + } + }; + + const handleTouchEnd = () => { + setIsDragging(false); + setDragAction(null); + }; + + // 터치 드래그 중 스크롤 방지용 native event listener 바인딩 + useEffect(() => { + const gridEl = gridRef.current; + if (!gridEl) return; + + const preventDefaultTouch = (e: TouchEvent) => { + if (isDragging) { + if (e.cancelable) { + e.preventDefault(); + } + } + }; + + gridEl.addEventListener("touchmove", preventDefaultTouch, { + passive: false, + }); + return () => { + gridEl.removeEventListener("touchmove", preventDefaultTouch); + }; + }, [isDragging]); + + useEffect(() => { + const handleGlobalUp = () => { + setIsDragging(false); + setDragAction(null); + }; + window.addEventListener("mouseup", handleGlobalUp); + window.addEventListener("touchend", handleGlobalUp); + return () => { + window.removeEventListener("mouseup", handleGlobalUp); + window.removeEventListener("touchend", handleGlobalUp); + }; + }, []); + + // Preset selectors + const selectPreset = (preset: "weekday" | "weekend" | "all" | "clear") => { + const isSlotBusy = (k: string) => blockBusyTimes && busySlots.includes(k); + + if (preset === "clear") { + setSelectedSlots([]); + } else if (preset === "all") { + const slots: string[] = []; + DAY_OPTIONS.forEach((day) => { + for (let hour = 8; hour < 22; hour++) { + const key = `${day.value}-${hour}`; + if (!isSlotBusy(key)) slots.push(key); + } + }); + setSelectedSlots(slots); + } else if (preset === "weekday") { + const slots: string[] = []; + (["MON", "TUE", "WED", "THU", "FRI"] as DayCode[]).forEach((day) => { + for (let hour = 9; hour < 18; hour++) { + const key = `${day}-${hour}`; + if (!isSlotBusy(key)) slots.push(key); + } + }); + setSelectedSlots(slots); + } else if (preset === "weekend") { + const slots: string[] = []; + (["SAT", "SUN"] as DayCode[]).forEach((day) => { + for (let hour = 10; hour < 18; hour++) { + const key = `${day}-${hour}`; + if (!isSlotBusy(key)) slots.push(key); + } + }); + setSelectedSlots(slots); + } + }; + + useEffect(() => { + const handleGlobalMouseUp = () => { + setIsDragging(false); + setDragAction(null); + }; + window.addEventListener("mouseup", handleGlobalMouseUp); + return () => window.removeEventListener("mouseup", handleGlobalMouseUp); + }, []); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(""); + setLinks(null); + + if (selectedSlots.length === 0) { + setError("후보 시간대를 최소 하나 이상 선택해 주세요."); + return; + } + + setIsSubmitting(true); + + try { + validateScheduleForm({ + candidateDays, + candidateStartHour: String(candidateStartHour), + candidateEndHour: String(candidateEndHour), + durationMinutes, + }); + + const response = await fetch("/api/schedules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title, + durationMinutes: Number(durationMinutes), + candidateDays, + candidateStartHour, + candidateEndHour, + }), + }); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error ?? "모임을 만들 수 없습니다."); + } + + setLinks({ + participant: `${window.location.origin}${result.participantPath}`, + host: `${window.location.origin}${result.hostPath}`, + }); + setStep(4); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : "요청에 실패했습니다.", + ); + } finally { + setIsSubmitting(false); + } + } + + // Get Progress Percent and label + const progressPercent = useMemo(() => { + switch (step) { + case 1: + return "25%"; + case 2: + return "50%"; + case 3: + return "75%"; + case 4: + return "100%"; + } + }, [step]); + + const progressLabel = useMemo(() => { + switch (step) { + case 1: + return "1단계: 기본 정보"; + case 2: + return "2단계: 캘린더 연동"; + case 3: + return "3단계: 후보 시간대"; + case 4: + return "4단계: 초대장 완료"; + } + }, [step]); + + return ( + + + + +
+ {step < 4 && ( +
+

+ {step === 1 && "어떤 모임을 만드시나요?"} + {step === 2 && "내 일정을 연동해 볼까요?"} + {step === 3 && "조율할 시간 범위를 정해주세요"} +

+

+ {step === 1 && + "모임 정보와 후보 시간을 정하면 초대 링크가 바로 만들어집니다."} + {step === 2 && + "캘린더를 연동하면 이미 바쁜 시간대를 자동으로 후보에서 필터링해 줍니다."} + {step === 3 && + "시간표 드래그 및 Preset을 활용해 후보 시간 범위를 정해보세요."} +

+
+ )} + +
e.preventDefault()} + className={cn( + "grid gap-6 rounded-[2rem] border border-brand-border-muted bg-white p-6 shadow-premium-lg sm:p-8 transition-all hover:shadow-[0_30px_80px_rgba(95,82,130,0.14)]", + step === 4 && "hidden", + )} + > + {/* Step 1: 기본 정보 입력 */} + {step === 1 && ( +
+ + + + +
+ setStep(2)} + className="px-6 text-sm font-bold tracking-wide active:scale-95" + > + 다음 단계로 → + +
+
+ )} + + {/* Step 2: 캘린더 연동 설정 */} + {step === 2 && ( +
+ + 📅 캘린더 연동 관리 (시안 12) + + +
+ {/* Google Connection status */} +
+
+ + G + +
+

+ Google Calendar +

+

+ {hasGoogle ? "연동 완료" : "연동 정보 없음"} +

+
+
+ {hasGoogle ? ( + + 연동됨 + + ) : ( + + 연동하기 → + + )} +
+ + {/* iCloud Connection status */} +
+
+ + A + +
+

+ Apple iCloud +

+

+ {hasICloud ? "연동 완료" : "연동 정보 없음"} +

+
+
+ {hasICloud ? ( + + 연동됨 + + ) : ( + + 연동하기 → + + )} +
+
+ + {hasGoogle || hasICloud ? ( + + ) : ( +
+

+ 현재 연동된 캘린더가 없습니다. 캘린더를 연동하면 개인 약속 + 시간이 3단계에서 자동으로 필터링됩니다. +

+
+ )} + +
+ + setStep(3)} + className="px-6 text-sm font-bold tracking-wide active:scale-95" + > + 다음 단계로 → + +
+
+ )} + + {/* Step 3: 후보 시간대 비주얼 격자 선택기 */} + {step === 3 && ( +
+ + 📅 후보 시간대 설정 + +

+ 조율 후보로 삼고 싶은 요일과 시간대를 드래그하거나 클릭하여 + 칠해주세요. (시안 13) +

+ + {/* Presets */} +
+ + + + +
+ + {/* Interactive Grid */} +
+
+
+ {/* Header col */} +
+ 시간 +
+ {DAY_OPTIONS.map((day) => ( +
+ {DAY_SHORT_LABELS[day.value]} +
+ ))} + + {/* Time Rows */} + {HOURS.map((hour) => { + return ( +
+
+ {hour}:00 +
+ {DAY_OPTIONS.map((day) => { + const key = `${day.value}-${hour}`; + const isSelected = selectedSlots.includes(key); + const isBusy = + blockBusyTimes && busySlots.includes(key); + + return ( +
handleMouseDown(key)} + onMouseEnter={() => handleMouseEnter(key)} + onMouseUp={handleMouseUp} + onTouchStart={(e) => handleTouchStart(e, key)} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + className={cn( + "h-8 rounded transition-all border cursor-pointer touch-none select-none relative", + isBusy + ? "bg-slate-100 border-slate-200 text-slate-400 pointer-events-none cursor-not-allowed flex items-center justify-center text-[8px] font-medium" + : isSelected + ? "bg-brand-purple text-white border-brand-purple shadow-sm hover:bg-brand-purple-hover" + : "bg-white border-brand-border-gray hover:bg-brand-bg-light", + )} + title={ + isBusy + ? "개인 일정 있음 (선택 불가)" + : `${day.label} ${hour}:00 - ${hour + 1}:00` + } + > + {isBusy && "바쁨"} +
+ ); + })} +
+ ); + })} +
+
+
+ +
+ } + label="후보 기간" + value={`${candidateDays.length}개 요일 · ${candidateStartHour}:00-${candidateEndHour}:00`} + /> + } + label="진행 방식" + value="링크 공유 후 참여자 입력" + /> +
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + { + e.preventDefault(); + const mockEvent = { + preventDefault: () => {}, + } as FormEvent; + handleSubmit(mockEvent); + }} + className="px-6 text-sm font-bold tracking-wide active:scale-95" + disabled={isSubmitting} + > + {isSubmitting ? "생성 중..." : "초대 링크 만들기 🚀"} + +
+
+ )} +
+ + {/* Step 4: 생성 완료 & 링크 공유 */} + {step === 4 && links && ( +
+ + + +

+ 초대 링크가 준비됐습니다! 🎉 +

+

+ 참여자는 가능한 시간을 제출하고, 호스트는 결과 화면에서 최종 + 시간을 확정합니다. (시안 14) +

+ +
+ + +
+ + {/* BM Nudge 1: 호스트 회원가입 유도 & 모임 저장 제안 */} +
+
+ 💡 +
+

+ 이 모임을 대시보드에 저장할까요? +

+

+ 모임을 회원 대시보드에 저장해 두면 미응답 멤버 카톡 재촉 및 + 실시간 참여 메일 알림을 즉시 받아보실 수 있습니다. +

+
+ + 3초 만에 모임 저장하고 시작하기 + +
+
+
+
+ +
+ + + 참여 화면 열기 + + + 결과 화면 열기 + +
+
+ )} +
+
+ ); +} + +function validateScheduleForm({ + candidateDays, + candidateStartHour, + candidateEndHour, + durationMinutes, +}: { + candidateDays: DayCode[]; + candidateStartHour: string; + candidateEndHour: string; + durationMinutes: string; +}) { + if (candidateDays.length === 0) { + throw new Error("후보 요일을 하나 이상 선택해 주세요."); + } + if (Number(candidateEndHour) <= Number(candidateStartHour)) { + throw new Error("종료 시간은 시작 시간보다 늦어야 합니다."); + } + if (Number(durationMinutes) <= 0) { + throw new Error("소요 시간은 0보다 커야 합니다."); + } +} + +async function copyText(value: string) { + await navigator.clipboard?.writeText(value); +} + +function MiniInfo({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( +
+ {icon} +
+

{label}

+

{value}

+
+
+ ); +} + +function LinkField({ + label, + testId, + value, +}: { + label: string; + testId: string; + value: string; +}) { + return ( + + ); +} diff --git a/src/app/schedule/create/page.tsx b/src/app/schedule/create/page.tsx new file mode 100644 index 0000000..f6ae255 --- /dev/null +++ b/src/app/schedule/create/page.tsx @@ -0,0 +1,5 @@ +import { CreateScheduleClient } from "./CreateScheduleClient"; + +export default function CreateSchedulePage() { + return ; +} diff --git a/src/components/moim/TermsModal.tsx b/src/components/moim/TermsModal.tsx new file mode 100644 index 0000000..9cd28b9 --- /dev/null +++ b/src/components/moim/TermsModal.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { X } from "lucide-react"; +import { useEffect } from "react"; + +export type TermsKey = + | "isAgeOver14" + | "termsAgreed" + | "privacyAgreed" + | "marketingAgreed" + | "eventSmsAgreed"; + +interface TermsModalProps { + termsKey: TermsKey; + onClose: () => void; +} + +const TERMS_DATA: Record = { + isAgeOver14: { + title: "만 14세 이상 이용 약관", + content: `본 서비스는 정보통신망 이용촉진 및 정보보호 등에 관한 법률에 의거하여 만 14세 미만 아동의 회원가입을 제한하고 있습니다. 가입자는 자신이 만 14세 이상임을 보증하며, 이를 위반할 시 가입 및 서비스 이용이 제한될 수 있습니다.`, + }, + termsAgreed: { + title: "MOIM 서비스 이용약관", + content: `제 1 조 (목적) +본 약관은 MOIM(이하 '서비스')이 제공하는 온라인 일정 조율 및 모임 개설 서비스의 이용조건 및 절차, 회사와 회원 간의 권리, 의무 및 책임사항 등을 규정함을 목적으로 합니다. + +제 2 조 (용어의 정의) +1. '회원'이라 함은 서비스에 접속하여 본 약관에 동의하고 가입 절차를 마친 이용자를 의미합니다. +2. '일정 잡기'라 함은 회원이 모임을 생성하고 다른 참여자들의 가용 시간을 입력받아 일정을 확정하는 기능입니다. + +제 3 조 (약관의 효력 및 변경) +1. 본 약관은 서비스 화면에 게시함으로써 효력이 발생합니다. +2. 서비스는 필요한 경우 관련 법령을 위배하지 않는 범위에서 약관을 변경할 수 있으며, 변경된 약관은 적용일자 7일 전부터 게시합니다.`, + }, + privacyAgreed: { + title: "개인정보 수집 및 이용 동의", + content: `MOIM 서비스는 원활한 회원 가입 및 서비스 제공을 위해 아래와 같이 최소한의 개인정보를 수집 및 이용합니다. + +1. 수집하는 개인정보 항목 +- 필수 항목: 이메일 주소, 닉네임, 비밀번호, 전화번호 +- 소셜 로그인 시: 소셜 플랫폼 계정 식별 고유키, 이메일, 닉네임, 프로필 이미지 URL + +2. 개인정보 수집 및 이용 목적 +- 서비스 가입 의사 확인 및 본인 식별 +- 일정 생성 및 참여 연동 (iCloud, Google 등 외부 캘린더 연동 목적) +- 불량 회원의 부정 이용 방지와 비인가 사용 방지 + +3. 개인정보 보유 및 이용 기간 +- 회원의 개인정보는 회원 탈퇴 시 즉시 지체 없이 파기합니다. +- 단, 관련 법령의 규정에 의하여 보존할 필요가 있는 경우 해당 법령이 정한 기간 동안 보관합니다.`, + }, + marketingAgreed: { + title: "개인정보 마케팅 활용 동의", + content: `본 동의는 선택 사항이며 동의하지 않으셔도 가입 및 서비스 이용이 가능합니다. + +1. 수집/이용 목적 +- MOIM 서비스의 신규 기능 안내, 서비스 개선을 위한 설문조사, 맞춤형 혜택 정보 제공 + +2. 수집하는 항목 +- 이메일 주소, 전화번호, 닉네임 + +3. 보유 및 이용 기간 +- 동의 철회(회원 탈퇴) 시 또는 동의 목적 달성 시까지 보관합니다.`, + }, + eventSmsAgreed: { + title: "이벤트, 쿠폰 및 SMS 수신 동의", + content: `본 동의는 선택 사항이며 동의하지 않으셔도 가입 및 서비스 이용이 가능합니다. + +1. 수집/이용 목적 +- 서비스의 혜택 정보, 이벤트 소식, 할인 쿠폰 정보 등을 SMS, 알림톡, 푸시 알림 형태로 전송하기 위함입니다. + +2. 보유 및 이용 기간 +- 동의 철회(수신 거부 설정) 시 또는 회원 탈퇴 시까지 보관합니다.`, + }, +}; + +export function TermsModal({ termsKey, onClose }: TermsModalProps) { + const data = TERMS_DATA[termsKey]; + + useEffect(() => { + // 모달 오픈 시 배경 스크롤 방지 + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = ""; + }; + }, []); + + if (!data) return null; + + return ( +
+ {/* 아웃사이드 클릭 시 닫기 */} +
+ +
+
+

+ {data.title} +

+ +
+ +
+ {data.content} +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/moim/auth-social.tsx b/src/components/moim/auth-social.tsx new file mode 100644 index 0000000..3b224f2 --- /dev/null +++ b/src/components/moim/auth-social.tsx @@ -0,0 +1,76 @@ +import React from "react"; + +export type AuthProviderType = "google" | "apple" | "kakao" | "naver"; + +interface AuthProviderGlyphProps { + type: AuthProviderType; + className?: string; +} + +export function AuthProviderGlyph({ + type, + className = "h-5 w-5", +}: AuthProviderGlyphProps) { + switch (type) { + case "google": + return ( + + + + + + + ); + case "apple": + return ( + + + + ); + case "kakao": + return ( + + + + ); + case "naver": + return ( + + + + ); + default: + return null; + } +} diff --git a/src/components/moim/reference-ui.tsx b/src/components/moim/reference-ui.tsx new file mode 100644 index 0000000..f285da9 --- /dev/null +++ b/src/components/moim/reference-ui.tsx @@ -0,0 +1,760 @@ +"use client"; + +import Link from "next/link"; +import { useState, useEffect } from "react"; +import type { ComponentProps, ReactNode } from "react"; +import { + CalendarDays, + CalendarPlus, + HelpCircle, + Home, + Search, + Settings, + Users, + X, + Menu, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const NAV_ITEMS = [ + { href: "/", label: "홈", icon: Home }, + { href: "/calendar/connect", label: "캘린더", icon: CalendarDays }, + { href: "/schedule/create", label: "모임", icon: Users }, + { href: "/login", label: "설정", icon: Settings }, +]; + +export function MoimTopBar({ + closeHref, + help = false, + activeHref = "/", +}: { + closeHref?: string; + help?: boolean; + activeHref?: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const [user, setUser] = useState<{ nickname: string; email: string } | null>( + null, + ); + const [loading, setLoading] = useState(true); + const [showDropdown, setShowDropdown] = useState(false); + + useEffect(() => { + fetch("/api/auth/me") + .then((res) => { + if (res.ok) return res.json(); + throw new Error(); + }) + .then((data) => { + if (data.success && data.user) { + setUser(data.user); + } else { + setUser(null); + } + }) + .catch(() => { + setUser(null); + }) + .finally(() => { + setLoading(false); + }); + }, []); + + const handleLogout = async () => { + try { + await fetch("/api/auth/logout", { method: "POST" }); + setUser(null); + setShowDropdown(false); + window.location.href = "/"; + } catch (e) { + console.error("[Logout Error]:", e); + } + }; + + return ( +
+
+
+ {closeHref ? ( + + + + ) : null} + + MOIM + +
+ + {help ? ( + + ) : ( + <> + + + + + )} +
+ + {!help && isOpen && ( + + )} +
+ ); +} + +export function PurpleButton({ + children, + className, + variant = "tintBrand", + ...props +}: ComponentProps) { + return ( + + ); +} + +export function MoimShell({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function ProgressHeader({ + label, + progress, +}: { + label: string; + progress: string; +}) { + return ( +
+
+ {label} + {progress} 진행 +
+
+
+
+
+ ); +} + +export function SchedulerPreview({ compact = false }: { compact?: boolean }) { + return ( +
+
+
+

통합 스케줄러

+

+ 선택한 기간의 일정을 한눈에 확인하세요. +

+
+
+ {["10월 9일 (목)", "10월 11일 (목)"].map((date) => ( + + + {date} + + ))} + + + +
+
+ +
+ +
+
+

내 모임

+ + 모두 보기 + +
+
+ {[ + ["UX 디자인 스터디", "멤버 8명 · 매주 수요일 20:00", "UX"], + ["한강 러닝 크루", "멤버 24명 · 비정기 모임", "RUN"], + ["Next.js 알고리즘 정복", "멤버 5명 · 매주 토요일 14:00", "‹›"], + ].map(([title, meta, icon], index) => ( +
+ + {icon} + +
+

{title}

+

+ {meta} +

+
+ +
+ ))} + + + 새로운 모임 탐색하기 + +
+
+
+

12

+

참여 중인 모임

+
+
+

+ 3 +

+

대기 중인 일정

+
+
+
+
+
+ ); +} + +export function CalendarBoard() { + const blocks = [ + { + title: "디자인 팀 주간 회의", + time: "10:00 - 11:30", + column: 0, + row: 1, + span: 1.4, + colorClass: + "bg-brand-bg-light text-brand-text-primary border-l-brand-purple-light", + }, + { + title: "프론트엔드 코드 리뷰", + time: "11:00 - 12:30", + column: 1, + row: 2, + span: 1.35, + colorClass: + "bg-brand-purple-light text-white border-l-brand-purple-light", + }, + { + title: "점심 식사 (마케팅팀)", + time: "", + column: 0, + row: 3, + span: 0.9, + colorClass: + "bg-brand-bg-muted text-brand-text-primary border-l-brand-text-light", + }, + { + title: "러닝 크루 번개", + time: "", + column: 2, + row: 0, + span: 0.9, + colorClass: + "bg-brand-bg-muted text-brand-text-primary border-l-brand-purple-light", + }, + ]; + + return ( +
+
+
+
+
+ Time +
+ {["10/9 (수) 오늘", "10/10 (목)", "10/11 (금)"].map((day) => ( +
+ {day} +
+ ))} + {[ + "09:00", + "10:00", + "11:00", + "12:00", + "13:00", + "14:00", + "15:00", + "16:00", + "17:00", + ] + .slice(0, 7) + .map((time) => ( +
+
+ {time} +
+
+
+
+
+ ))} +
+ {blocks.map(({ title, time, column, row, span, colorClass }) => ( +
+

{title}

+ {time ? ( +

{time}

+ ) : null} +
+ ))} +
+
+
+ + 업무: 3 + + + 모임: + 1 + +
+
+ ); +} + +const DEFAULT_HEATMAP_ROWS = [ + "09:00", + "11:00", + "13:00", + "15:00", + "17:00", + "19:00", + "21:00", +]; +const DEFAULT_HEATMAP_DAYS = ["11/23 (목)", "11/24 (금)", "11/25 (토)"]; +const DEFAULT_HEATMAP_COLORS = [ + ["bg-brand-purple/10", "bg-brand-purple/30", "bg-brand-purple/5"], + ["bg-brand-purple/15", "bg-brand-purple/40", "bg-brand-purple/10"], + ["bg-brand-purple/5", "bg-brand-purple/70", "bg-brand-purple/60"], + [ + "bg-brand-purple/60", + "bg-brand-purple/90 ring-2 ring-white", + "bg-brand-purple/50", + ], + ["bg-brand-purple/50", "bg-brand-purple/60", "bg-brand-purple/55"], + ["bg-brand-purple/40", "bg-brand-purple/20", "bg-brand-purple/60"], + ["bg-brand-purple/15", "bg-brand-purple/10", "bg-brand-purple/45"], +]; + +export function HeatmapGrid({ + className, + rows = DEFAULT_HEATMAP_ROWS, + days = DEFAULT_HEATMAP_DAYS, + colors = DEFAULT_HEATMAP_COLORS, + cellTooltips, +}: { + className?: string; + rows?: string[]; + days?: string[]; + colors?: string[][]; + cellTooltips?: string[][]; +}) { + return ( +
+
+
+ {days.map((day, index) => ( +
+ {day} +
+ ))} + {rows.map((time, rowIndex) => ( +
+
+ {time} +
+ {days.map((day, dayIndex) => { + const tooltipText = cellTooltips?.[rowIndex]?.[dayIndex]; + return ( +
+ {tooltipText && ( +
+ {tooltipText} +
+ )} +
+ ); + })} +
+ ))} +
+
+ ); +} + +export function ProviderGlyph({ + type, +}: { + type: "google" | "apple" | "everytime" | "ics" | "kakao" | "naver"; +}) { + const className = { + google: "bg-white border-brand-border-muted", + apple: "bg-[#111] text-white border-[#111]", + everytime: "bg-[#f03c36] text-white border-[#f03c36]", + ics: "bg-[#7c7484] text-white border-[#7c7484]", + kakao: "bg-[#fee500] text-[#191919] border-[#fee500]", + naver: "bg-[#03c75a] text-white border-[#03c75a]", + }[type]; + + const renderIcon = () => { + switch (type) { + case "google": + return ( + + + + + + + ); + case "apple": + return ( + + + + ); + case "everytime": + return ( + + + + ); + case "ics": + return ( + + + + + + + ); + case "kakao": + return ( + + + + ); + case "naver": + return ( + + + + ); + default: + return null; + } + }; + + return ( + + {renderIcon()} + + ); +} + +export function EmptyAvatar({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function CalendarInfoCard() { + return ( +
+
+
+

주말 독서 모임

+

+ 김철수 님이 + 호스트입니다 +

+
+ + 현재 12명 중 8명 응답 완료 + +
+
+
+ +

희망 기간

+

10월 12일 ~ 10월 18일

+
+
+ +

소요 시간

+

약 2시간 소요 예정

+
+
+
+ ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index f061d1f..f5b8e73 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,46 +1,43 @@ -"use client" +import { Button as ButtonPrimitive } from "@base-ui/react/button"; +import { cva, type VariantProps } from "class-variance-authority"; -import { Button as ButtonPrimitive } from "@base-ui/react/button" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium transition-colors outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + default: "bg-primary text-primary-foreground hover:bg-primary/90", outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + ghostBrand: + "text-brand-purple hover:scale-105 hover:text-brand-purple-hover active:scale-95 transition-all duration-200 no-underline", + solidBrand: + "bg-brand-purple text-white hover:bg-brand-purple-hover hover:scale-[1.02] active:scale-95 transition-all duration-200 shadow-sm no-underline", + tintBrand: + "bg-gradient-to-r from-brand-purple/[0.08] to-brand-purple/[0.03] text-brand-purple border border-brand-purple/15 shadow-[0_4px_10px_-2px_rgba(98,82,172,0.08)] hover:from-brand-purple/15 hover:to-brand-purple/8 hover:border-brand-purple/30 hover:shadow-[0_6px_16px_rgba(98,82,172,0.12)] hover:scale-[1.02] active:scale-95 transition-all duration-300 no-underline", destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + "bg-destructive text-destructive-foreground hover:bg-destructive/90", link: "text-primary underline-offset-4 hover:underline", }, size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", }, }, defaultVariants: { variant: "default", size: "default", }, - } -) + }, +); function Button({ className, @@ -54,7 +51,7 @@ function Button({ className={cn(buttonVariants({ variant, size, className }))} {...props} /> - ) + ); } -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/src/components/ui/scroll-reveal.tsx b/src/components/ui/scroll-reveal.tsx new file mode 100644 index 0000000..7e987b1 --- /dev/null +++ b/src/components/ui/scroll-reveal.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; + +interface ScrollRevealProps { + children: ReactNode; + className?: string; + direction?: "up" | "down" | "left" | "right" | "none"; + duration?: number; + delay?: number; +} + +export function ScrollReveal({ + children, + className = "", + direction = "up", + duration = 600, + delay = 0, +}: ScrollRevealProps) { + const [isVisible, setIsVisible] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (typeof window === "undefined" || !("IntersectionObserver" in window)) { + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.unobserve(entry.target); + } + }, + { + threshold: 0.05, // Trigger when 5% of the element is visible + rootMargin: "0px 0px -40px 0px", // Trigger slightly before entering viewport + }, + ); + + const currentRef = ref.current; + if (currentRef) { + observer.observe(currentRef); + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef); + } + }; + }, []); + + const getDirectionClass = () => { + switch (direction) { + case "up": + return "translate-y-8"; + case "down": + return "-translate-y-8"; + case "left": + return "translate-x-8"; + case "right": + return "-translate-x-8"; + default: + return ""; + } + }; + + return ( +
+ {children} +
+ ); +} diff --git a/src/features/auth/__tests__/social-profile.schema.test.ts b/src/features/auth/__tests__/social-profile.schema.test.ts new file mode 100644 index 0000000..73feb56 --- /dev/null +++ b/src/features/auth/__tests__/social-profile.schema.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; +import { socialProfileSchema } from "../social-profile.schema"; + +const validInput = { + phoneNumber: "010-1234-5678", + isAgeOver14: true, + termsAgreed: true, + privacyAgreed: true, + marketingAgreed: false, + eventSmsAgreed: false, +}; + +describe("socialProfileSchema", () => { + test("소셜 로그인 추가 정보를 검증한다", () => { + const result = socialProfileSchema.safeParse(validInput); + + expect(result.success).toBe(true); + }); + + test("필수 약관 동의가 없으면 실패한다", () => { + const result = socialProfileSchema.safeParse({ + ...validInput, + termsAgreed: false, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual(["termsAgreed"]); + expect(result.error.issues[0]?.message).toContain("이용약관"); + } + }); + + test("전화번호 형식이 맞지 않으면 실패한다", () => { + const result = socialProfileSchema.safeParse({ + ...validInput, + phoneNumber: "02-123-4567", + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual(["phoneNumber"]); + expect(result.error.issues[0]?.message).toContain("전화번호"); + } + }); + + test("선택 동의는 생략하면 false 기본값을 채운다", () => { + const { marketingAgreed, eventSmsAgreed, ...input } = validInput; + void marketingAgreed; + void eventSmsAgreed; + + const result = socialProfileSchema.parse(input); + + expect(result.marketingAgreed).toBe(false); + expect(result.eventSmsAgreed).toBe(false); + }); +}); diff --git a/src/features/auth/login.schema.ts b/src/features/auth/login.schema.ts new file mode 100644 index 0000000..ca98f0c --- /dev/null +++ b/src/features/auth/login.schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const loginSchema = z.object({ + loginId: z.string().min(1, "아이디와 비밀번호를 입력해주세요."), + password: z.string().min(1, "아이디와 비밀번호를 입력해주세요."), + remember: z.boolean().optional().default(false), +}); + +export type LoginInput = z.infer; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +export function isEmail(value: string): boolean { + return EMAIL_REGEX.test(value); +} diff --git a/src/features/auth/signup.schema.ts b/src/features/auth/signup.schema.ts new file mode 100644 index 0000000..3ec5f79 --- /dev/null +++ b/src/features/auth/signup.schema.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +const PHONE_RAW = /^010\d{8}$/; +const PHONE_DASH = /^010-\d{4}-\d{4}$/; + +export const signupSchema = z + .object({ + email: z.string().email("올바른 이메일 형식이 아닙니다."), + phoneNumber: z + .string() + .refine( + (v) => PHONE_RAW.test(v) || PHONE_DASH.test(v), + "전화번호는 010-0000-0000 또는 01000000000 형식이어야 합니다.", + ), + nickname: z + .string() + .min(2, "닉네임은 2자 이상이어야 합니다.") + .max(12, "닉네임은 12자 이하여야 합니다."), + password: z + .string() + .min(8, "비밀번호는 8자 이상이어야 합니다.") + .regex(/[a-zA-Z]/, "비밀번호에 영문자를 포함해야 합니다.") + .regex(/[0-9]/, "비밀번호에 숫자를 포함해야 합니다."), + passwordConfirm: z.string(), + isAgeOver14: z.boolean(), + termsAgreed: z.boolean(), + privacyAgreed: z.boolean(), + marketingAgreed: z.boolean().default(false), + eventSmsAgreed: z.boolean().default(false), + }) + .refine((data) => data.password === data.passwordConfirm, { + message: "비밀번호가 일치하지 않습니다.", + path: ["passwordConfirm"], + }) + .refine((data) => data.isAgeOver14, { + message: "만 14세 이상이어야 가입할 수 있습니다.", + path: ["isAgeOver14"], + }) + .refine((data) => data.termsAgreed, { + message: "이용약관에 동의해야 합니다.", + path: ["termsAgreed"], + }) + .refine((data) => data.privacyAgreed, { + message: "개인정보 수집 및 이용에 동의해야 합니다.", + path: ["privacyAgreed"], + }); + +export type SignupInput = z.infer; + +export function normalizePhoneNumber(phone: string): string { + const digits = phone.replace(/\D/g, ""); + if (!/^010\d{8}$/.test(digits)) { + throw new Error(`유효하지 않은 전화번호입니다: ${phone}`); + } + return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`; +} diff --git a/src/features/auth/social-profile.schema.ts b/src/features/auth/social-profile.schema.ts new file mode 100644 index 0000000..f58e3cb --- /dev/null +++ b/src/features/auth/social-profile.schema.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { normalizePhoneNumber } from "./signup.schema"; + +export const socialProfileSchema = z + .object({ + phoneNumber: z.string().refine((value) => { + try { + normalizePhoneNumber(value); + return true; + } catch { + return false; + } + }, "전화번호는 010-0000-0000 또는 01000000000 형식이어야 합니다."), + isAgeOver14: z.boolean(), + termsAgreed: z.boolean(), + privacyAgreed: z.boolean(), + marketingAgreed: z.boolean().default(false), + eventSmsAgreed: z.boolean().default(false), + }) + .refine((data) => data.isAgeOver14, { + message: "만 14세 이상이어야 가입할 수 있습니다.", + path: ["isAgeOver14"], + }) + .refine((data) => data.termsAgreed, { + message: "이용약관에 동의해야 합니다.", + path: ["termsAgreed"], + }) + .refine((data) => data.privacyAgreed, { + message: "개인정보 수집 및 이용에 동의해야 합니다.", + path: ["privacyAgreed"], + }); + +export type SocialProfileInput = z.infer; diff --git a/src/features/calendar/lib/auth.ts b/src/features/calendar/lib/auth.ts deleted file mode 100644 index 4142fac..0000000 --- a/src/features/calendar/lib/auth.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createClient } from "@/shared/lib/supabase/server"; - -/** - * Google OAuth 로그인 URL 생성 - * Supabase Auth + Google Calendar scope 통합 - * PRD B안: OAuth 한 번으로 로그인 + 캘린더 연동 동시 처리 - */ -export async function getGoogleOAuthUrl(redirectTo?: string) { - const supabase = await createClient(); - - const { data, error } = await supabase.auth.signInWithOAuth({ - provider: "google", - options: { - redirectTo: redirectTo ?? `${process.env.NEXT_PUBLIC_SITE_URL}/callback`, - scopes: "https://www.googleapis.com/auth/calendar.readonly", - queryParams: { - access_type: "offline", // refresh_token 발급을 위해 필수 - prompt: "consent", // 매번 동의 화면 표시 → refresh_token 보장 - }, - }, - }); - - if (error) { - throw new Error(`OAuth URL generation failed: ${error.message}`); - } - - return data.url; -} diff --git a/src/features/calendar/lib/google.ts b/src/features/calendar/lib/google.ts deleted file mode 100644 index 34d3877..0000000 --- a/src/features/calendar/lib/google.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Google Calendar API 유틸 - * PRD 4.1: 캘린더 내용은 저장하지 않고, 가용/불가용 여부만 추출 - */ - -interface GoogleEvent { - start: { dateTime?: string; date?: string }; - end: { dateTime?: string; date?: string }; - transparency?: string; // "transparent" = 자유, "opaque" = 바쁨(기본값) -} - -interface BusySlot { - start: string; // ISO 8601 - end: string; -} - -const GOOGLE_CALENDAR_API = "https://www.googleapis.com/calendar/v3"; - -/** - * Google access token 갱신 - */ -export async function refreshGoogleToken(refreshToken: string): Promise<{ - access_token: string; - expires_in: number; -}> { - const res = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: process.env.GOOGLE_CLIENT_ID!, - client_secret: process.env.GOOGLE_CLIENT_SECRET!, - refresh_token: refreshToken, - grant_type: "refresh_token", - }), - }); - - if (!res.ok) { - throw new Error(`Token refresh failed: ${res.status}`); - } - - return res.json(); -} - -/** - * 특정 기간의 바쁜 시간(busy slots)만 추출 - * PRD: 캘린더 내용 저장 안 함, 가용/불가용 여부만 - */ -export async function getGoogleBusySlots( - accessToken: string, - calendarId: string, - timeMin: string, - timeMax: string -): Promise { - // FreeBusy API로 바쁜 시간만 조회 (이벤트 내용 노출 없음) - const res = await fetch(`${GOOGLE_CALENDAR_API}/freeBusy`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - timeMin, - timeMax, - timeZone: "Asia/Seoul", - items: [{ id: calendarId }], - }), - }); - - if (!res.ok) { - throw new Error(`FreeBusy query failed: ${res.status}`); - } - - const data = await res.json(); - const calendar = data.calendars?.[calendarId]; - - if (!calendar?.busy) { - return []; - } - - return calendar.busy.map((slot: { start: string; end: string }) => ({ - start: slot.start, - end: slot.end, - })); -} diff --git a/src/lib/__tests__/crypto.test.ts b/src/lib/__tests__/crypto.test.ts new file mode 100644 index 0000000..c0369e2 --- /dev/null +++ b/src/lib/__tests__/crypto.test.ts @@ -0,0 +1,117 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { + encrypt, + decrypt, + serializeEncrypted, + deserializeEncrypted, + maskEmail, +} from "../crypto"; + +// 테스트용 64자리 hex 키 설정 +beforeAll(() => { + process.env.ENCRYPTION_SECRET = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +}); + +describe("encrypt / decrypt 라운드트립", () => { + test("암호화 후 복호화하면 원본과 동일하다", () => { + const plaintext = "my-secret-app-password"; + const encrypted = encrypt(plaintext); + const decrypted = decrypt(encrypted); + + expect(decrypted).toBe(plaintext); + }); + + test("빈 문자열도 라운드트립이 가능하다", () => { + const encrypted = encrypt(""); + expect(decrypt(encrypted)).toBe(""); + }); + + test("한글 문자열도 라운드트립이 가능하다", () => { + const plaintext = "앱 전용 비밀번호 테스트"; + const encrypted = encrypt(plaintext); + expect(decrypt(encrypted)).toBe(plaintext); + }); + + test("같은 평문이라도 매번 다른 암호문이 생성된다 (랜덤 IV)", () => { + const plaintext = "same-password"; + const a = encrypt(plaintext); + const b = encrypt(plaintext); + + expect(a.iv).not.toBe(b.iv); + expect(a.ciphertext).not.toBe(b.ciphertext); + }); + + test("authTag가 변조되면 복호화에 실패한다", () => { + const encrypted = encrypt("test"); + encrypted.authTag = Buffer.from("0000000000000000").toString("base64"); + + expect(() => decrypt(encrypted)).toThrow(); + }); +}); + +describe("serializeEncrypted / deserializeEncrypted", () => { + test("직렬화 후 역직렬화하면 원본 데이터가 복원된다", () => { + const original = encrypt("roundtrip-test"); + const serialized = serializeEncrypted(original); + const restored = deserializeEncrypted(serialized, original.iv); + + expect(restored.ciphertext).toBe(original.ciphertext); + expect(restored.authTag).toBe(original.authTag); + expect(restored.iv).toBe(original.iv); + }); + + test("직렬화 형식은 'ciphertext:authTag'이다", () => { + const data = encrypt("test"); + const serialized = serializeEncrypted(data); + + expect(serialized).toBe(`${data.ciphertext}:${data.authTag}`); + }); + + test("직렬화→역직렬화 후 복호화가 가능하다", () => { + const plaintext = "full-cycle-test"; + const encrypted = encrypt(plaintext); + const serialized = serializeEncrypted(encrypted); + const restored = deserializeEncrypted(serialized, encrypted.iv); + + expect(decrypt(restored)).toBe(plaintext); + }); +}); + +describe("maskEmail", () => { + test("이메일의 첫 글자만 남기고 마스킹한다", () => { + expect(maskEmail("user@icloud.com")).toBe("u***@icloud.com"); + }); + + test("짧은 로컬 파트도 처리한다", () => { + expect(maskEmail("a@example.com")).toBe("a***@example.com"); + }); + + test("@가 없는 문자열은 ***를 반환한다", () => { + expect(maskEmail("invalid")).toBe("***"); + }); + + test("빈 문자열은 ***를 반환한다", () => { + expect(maskEmail("")).toBe("***"); + }); +}); + +describe("ENCRYPTION_SECRET 검증", () => { + test("키가 없으면 에러를 던진다", () => { + const original = process.env.ENCRYPTION_SECRET; + delete process.env.ENCRYPTION_SECRET; + + expect(() => encrypt("test")).toThrow("ENCRYPTION_SECRET"); + + process.env.ENCRYPTION_SECRET = original; + }); + + test("키 길이가 64자가 아니면 에러를 던진다", () => { + const original = process.env.ENCRYPTION_SECRET; + process.env.ENCRYPTION_SECRET = "tooshort"; + + expect(() => encrypt("test")).toThrow("ENCRYPTION_SECRET"); + + process.env.ENCRYPTION_SECRET = original; + }); +}); diff --git a/src/lib/auth/fetch-with-timeout.ts b/src/lib/auth/fetch-with-timeout.ts new file mode 100644 index 0000000..9890315 --- /dev/null +++ b/src/lib/auth/fetch-with-timeout.ts @@ -0,0 +1,46 @@ +export const FETCH_TIMEOUT_MS = 5000; + +export async function fetchWithTimeout( + url: string, + options: RequestInit, +): Promise { + const controller = new AbortController(); + let isTimeout = false; + const timeoutId = setTimeout(() => { + isTimeout = true; + controller.abort(); + }, FETCH_TIMEOUT_MS); + + const incomingSignal = options.signal; + const handleAbort = () => { + controller.abort(); + }; + + if (incomingSignal) { + if (incomingSignal.aborted) { + controller.abort(); + } else { + incomingSignal.addEventListener("abort", handleAbort); + } + } + + try { + return await fetch(url, { ...options, signal: controller.signal }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + if (isTimeout) { + throw new Error(`요청 시간 초과: ${url}`); + } + if (incomingSignal && incomingSignal.aborted) { + throw err; + } + throw new Error(`요청 시간 초과: ${url}`); + } + throw err; + } finally { + clearTimeout(timeoutId); + if (incomingSignal) { + incomingSignal.removeEventListener("abort", handleAbort); + } + } +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..59cf49d --- /dev/null +++ b/src/lib/auth/session.ts @@ -0,0 +1,36 @@ +import { createClient } from "@/lib/supabase/server"; + +export interface Session { + userId: string; // auth.users.id (= profiles.id for authenticated users) + email: string; +} + +export class UnauthorizedError extends Error { + constructor() { + super("인증이 필요합니다."); + this.name = "UnauthorizedError"; + } +} + +/** 현재 Supabase 세션을 반환한다. 로그인하지 않은 경우 null. */ +export async function getSession(): Promise { + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user?.email) return null; + + return { userId: user.id, email: user.email }; +} + +/** + * 현재 세션을 반환한다. 로그인하지 않은 경우 UnauthorizedError를 던진다. + * Route Handler에서 인증 게이트로 사용한다. + */ +export async function requireSession(): Promise { + const session = await getSession(); + if (!session) throw new UnauthorizedError(); + return session; +} diff --git a/src/lib/caldav/__tests__/client.test.ts b/src/lib/caldav/__tests__/client.test.ts new file mode 100644 index 0000000..b2a3104 --- /dev/null +++ b/src/lib/caldav/__tests__/client.test.ts @@ -0,0 +1,148 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { caldavRequest, CalDAVError } from "../client"; + +// fetch를 모킹 +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +function mockResponse( + status: number, + body: string, + headers: Record = {}, +) { + return { + status, + headers: new Headers(headers), + text: () => Promise.resolve(body), + }; +} + +const auth = { username: "test@icloud.com", password: "app-password" }; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("CalDAVError", () => { + test("statusCode와 url을 포함한다", () => { + const error = new CalDAVError("test error", 401, "https://caldav.test"); + + expect(error.message).toBe("test error"); + expect(error.statusCode).toBe(401); + expect(error.url).toBe("https://caldav.test"); + expect(error.name).toBe("CalDAVError"); + }); +}); + +describe("caldavRequest", () => { + test("정상 응답을 반환한다", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(207, "")); + + const result = await caldavRequest( + "PROPFIND", + "https://caldav.icloud.com/test", + auth, + { body: "", depth: "0" }, + ); + + expect(result.status).toBe(207); + expect(result.body).toBe(""); + }); + + test("Basic Auth 헤더를 올바르게 설정한다", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(200, "ok")); + + await caldavRequest("GET", "https://caldav.icloud.com/test", auth); + + const callHeaders = mockFetch.mock.calls[0][1].headers; + const expected = + "Basic " + Buffer.from("test@icloud.com:app-password").toString("base64"); + expect(callHeaders["Authorization"]).toBe(expected); + }); + + test("Depth 헤더를 설정한다", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(200, "ok")); + + await caldavRequest("PROPFIND", "https://caldav.icloud.com/test", auth, { + depth: "1", + }); + + expect(mockFetch.mock.calls[0][1].headers["Depth"]).toBe("1"); + }); + + test("301 리다이렉트를 따라간다", async () => { + mockFetch + .mockResolvedValueOnce( + mockResponse(301, "", { + location: "https://p01-caldav.icloud.com/new", + }), + ) + .mockResolvedValueOnce(mockResponse(207, "")); + + const result = await caldavRequest( + "PROPFIND", + "https://caldav.icloud.com/old", + auth, + ); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(result.body).toBe(""); + }); + + test("상대 경로 리다이렉트를 절대 URL로 변환한다", async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(302, "", { location: "/new-path" })) + .mockResolvedValueOnce(mockResponse(200, "ok")); + + await caldavRequest("GET", "https://caldav.icloud.com/old", auth); + + const secondCallUrl = mockFetch.mock.calls[1][0]; + expect(secondCallUrl).toBe("https://caldav.icloud.com/new-path"); + }); + + test("5회 초과 리다이렉트 시 에러를 던진다", async () => { + for (let i = 0; i < 7; i++) { + mockFetch.mockResolvedValueOnce( + mockResponse(301, "", { + location: `https://caldav.icloud.com/hop${i}`, + }), + ); + } + + await expect( + caldavRequest("GET", "https://caldav.icloud.com/start", auth), + ).rejects.toThrow("리다이렉트 횟수 초과"); + }); + + test("401 응답 시 CalDAVError를 던진다", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(401, "Unauthorized")); + + await expect( + caldavRequest("GET", "https://caldav.icloud.com/test", auth), + ).rejects.toThrow(CalDAVError); + }); + + test("500 응답 시 CalDAVError를 던진다", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(500, "Server Error")); + + await expect( + caldavRequest("GET", "https://caldav.icloud.com/test", auth), + ).rejects.toThrow(CalDAVError); + }); + + test("Location 없는 리다이렉트 시 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(301, "")); + + await expect( + caldavRequest("GET", "https://caldav.icloud.com/test", auth), + ).rejects.toThrow("Location"); + }); + + test("redirect: manual 옵션으로 요청한다", async () => { + mockFetch.mockResolvedValueOnce(mockResponse(200, "ok")); + + await caldavRequest("GET", "https://caldav.icloud.com/test", auth); + + expect(mockFetch.mock.calls[0][1].redirect).toBe("manual"); + }); +}); diff --git a/src/lib/caldav/__tests__/create.test.ts b/src/lib/caldav/__tests__/create.test.ts new file mode 100644 index 0000000..aba2d62 --- /dev/null +++ b/src/lib/caldav/__tests__/create.test.ts @@ -0,0 +1,127 @@ +import { describe, test, expect, vi } from "vitest"; + +// caldavRequest를 모킹 +vi.mock("../client", () => ({ + caldavRequest: vi.fn(), + CalDAVError: class CalDAVError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly url: string, + ) { + super(message); + this.name = "CalDAVError"; + } + }, +})); + +import { createEvent } from "../create"; +import { caldavRequest } from "../client"; + +const mockCaldavRequest = vi.mocked(caldavRequest); +const auth = { username: "test@icloud.com", password: "app-password" }; + +describe("createEvent", () => { + test("올바른 URL로 PUT 요청을 보낸다", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 201, + headers: new Headers({ ETag: '"etag-123"' }), + body: "", + }); + + await createEvent( + "https://caldav.icloud.com/cal/home/", + auth, + "uid-123@moim.app", + "BEGIN:VCALENDAR...", + ); + + expect(mockCaldavRequest).toHaveBeenCalledWith( + "PUT", + "https://caldav.icloud.com/cal/home/uid-123@moim.app.ics", + auth, + expect.objectContaining({ + body: "BEGIN:VCALENDAR...", + headers: expect.objectContaining({ + "If-None-Match": "*", + }), + }), + ); + }); + + test("201 응답 시 href와 etag를 반환한다", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 201, + headers: new Headers({ ETag: '"etag-abc"' }), + body: "", + }); + + const result = await createEvent( + "https://caldav.icloud.com/cal/home/", + auth, + "uid-1@moim.app", + "ics-content", + ); + + expect(result.href).toBe( + "https://caldav.icloud.com/cal/home/uid-1@moim.app.ics", + ); + expect(result.etag).toBe('"etag-abc"'); + }); + + test("204 응답도 성공으로 처리한다", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 204, + headers: new Headers({ ETag: '"etag-204"' }), + body: "", + }); + + const result = await createEvent( + "https://caldav.icloud.com/cal/home", + auth, + "uid-2@moim.app", + "ics-content", + ); + + expect(result.href).toBe( + "https://caldav.icloud.com/cal/home/uid-2@moim.app.ics", + ); + }); + + test("412 응답 시 CalDAVError를 던진다 (중복 UID)", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 412, + headers: new Headers(), + body: "", + }); + + await expect( + createEvent( + "https://caldav.icloud.com/cal/home/", + auth, + "dup@moim.app", + "ics-content", + ), + ).rejects.toThrow("일정 생성 실패"); + }); + + test("calendarUrl 끝의 슬래시를 정규화한다", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 201, + headers: new Headers({ ETag: '"e"' }), + body: "", + }); + + const result = await createEvent( + "https://caldav.icloud.com/cal/home/", + auth, + "uid@moim.app", + "ics", + ); + + // 슬래시 중복 없이 올바른 URL + expect(result.href).toBe( + "https://caldav.icloud.com/cal/home/uid@moim.app.ics", + ); + }); +}); diff --git a/src/lib/caldav/__tests__/query.test.ts b/src/lib/caldav/__tests__/query.test.ts new file mode 100644 index 0000000..69eda2c --- /dev/null +++ b/src/lib/caldav/__tests__/query.test.ts @@ -0,0 +1,150 @@ +import { describe, test, expect, vi } from "vitest"; + +vi.mock("../client", () => ({ + caldavRequest: vi.fn(), +})); + +import { queryEvents } from "../query"; +import { caldavRequest } from "../client"; + +const mockCaldavRequest = vi.mocked(caldavRequest); +const auth = { username: "test@icloud.com", password: "app-password" }; + +const SAMPLE_MULTISTATUS = ` + + + /cal/home/event1.ics + + HTTP/1.1 200 OK + + "etag-1" + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event-1@moim.app +DTSTART:20260415T100000Z +DTEND:20260415T110000Z +SUMMARY:테스트 이벤트 +END:VEVENT +END:VCALENDAR + + + +`; + +describe("queryEvents", () => { + test("REPORT 응답에서 이벤트를 추출한다", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 207, + headers: new Headers(), + body: SAMPLE_MULTISTATUS, + }); + + const results = await queryEvents( + "https://caldav.icloud.com/cal/home/", + auth, + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + expect(results).toHaveLength(1); + expect(results[0].href).toBe("/cal/home/event1.ics"); + expect(results[0].etag).toBe('"etag-1"'); + expect(results[0].icsData).toContain("BEGIN:VCALENDAR"); + expect(results[0].icsData).toContain("SUMMARY:테스트 이벤트"); + }); + + test("204 응답 시 빈 배열을 반환한다", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 204, + headers: new Headers(), + body: "", + }); + + const results = await queryEvents( + "https://caldav.icloud.com/cal/home/", + auth, + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + expect(results).toEqual([]); + }); + + test("빈 body 시 빈 배열을 반환한다", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 207, + headers: new Headers(), + body: " ", + }); + + const results = await queryEvents( + "https://caldav.icloud.com/cal/home/", + auth, + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + expect(results).toEqual([]); + }); + + test("REPORT 메서드와 time-range를 포함한 XML을 전송한다", async () => { + mockCaldavRequest.mockResolvedValueOnce({ + status: 207, + headers: new Headers(), + body: "", + }); + + await queryEvents( + "https://caldav.icloud.com/cal/home/", + auth, + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + expect(mockCaldavRequest).toHaveBeenCalledWith( + "REPORT", + "https://caldav.icloud.com/cal/home/", + auth, + expect.objectContaining({ + depth: "1", + body: expect.stringContaining("time-range"), + }), + ); + + // time-range에 올바른 날짜 형식 포함 확인 + const callBody = mockCaldavRequest.mock.calls[0][3]?.body as string; + expect(callBody).toContain('start="20260401T000000Z"'); + expect(callBody).toContain('end="20260430T235959Z"'); + }); + + test(".ics로 끝나지 않는 href는 무시한다", async () => { + const nonIcsResponse = ` + + + /cal/home/ + + HTTP/1.1 200 OK + + "collection-etag" + + + +`; + + mockCaldavRequest.mockResolvedValueOnce({ + status: 207, + headers: new Headers(), + body: nonIcsResponse, + }); + + const results = await queryEvents( + "https://caldav.icloud.com/cal/home/", + auth, + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + expect(results).toEqual([]); + }); +}); diff --git a/src/lib/caldav/client.ts b/src/lib/caldav/client.ts new file mode 100644 index 0000000..7d88e0e --- /dev/null +++ b/src/lib/caldav/client.ts @@ -0,0 +1,130 @@ +import type { CalDAVAuth } from "@/types/icloud"; + +export type CalDAVMethod = "PROPFIND" | "REPORT" | "PUT" | "DELETE" | "GET"; + +export interface CalDAVRequestOptions { + body?: string; + headers?: Record; + depth?: "0" | "1" | "infinity"; + /** 요청 타임아웃 (ms). 기본값 15000 */ + timeoutMs?: number; +} + +export interface CalDAVResponse { + status: number; + headers: Headers; + body: string; +} + +export class CalDAVError extends Error { + constructor( + message: string, + public readonly statusCode: number, + public readonly url: string, + ) { + super(message); + this.name = "CalDAVError"; + } +} + +/** + * Basic Auth 헤더 값을 생성한다. + * 서버 전용 코드 — 클라이언트에 노출되면 안 된다. + */ +function buildBasicAuth(auth: CalDAVAuth): string { + return ( + "Basic " + + Buffer.from(`${auth.username}:${auth.password}`).toString("base64") + ); +} + +/** + * 리다이렉트를 수동으로 추적하며 CalDAV 요청을 보낸다. + * + * iCloud CalDAV는 well-known URL에서 파티션별 URL(p01-caldav.icloud.com 등)로 + * 301/302 리다이렉트를 응답한다. Node.js fetch의 redirect: 'follow'는 + * 다른 호스트로 리다이렉트될 때 Authorization 헤더를 제거하므로 + * 수동으로 처리해야 한다. + */ +export async function caldavRequest( + method: CalDAVMethod, + url: string, + auth: CalDAVAuth, + options: CalDAVRequestOptions = {}, +): Promise { + const { + body, + headers: extraHeaders = {}, + depth, + timeoutMs = 15_000, + } = options; + + const headers: Record = { + Authorization: buildBasicAuth(auth), + "Content-Type": "application/xml; charset=utf-8", + ...extraHeaders, + }; + if (depth !== undefined) headers["Depth"] = depth; + + const MAX_REDIRECTS = 5; + let currentUrl = url; + + for (let i = 0; i <= MAX_REDIRECTS; i++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(currentUrl, { + method, + headers, + body, + redirect: "manual", // 수동 리다이렉트 추적 + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } + + // 리다이렉트 처리 (301, 302, 307, 308) + if ([301, 302, 307, 308].includes(response.status)) { + const location = response.headers.get("location"); + if (!location) { + throw new CalDAVError( + "리다이렉트 응답에 Location 헤더가 없습니다.", + response.status, + currentUrl, + ); + } + // 상대 경로를 절대 URL로 변환 + currentUrl = location.startsWith("http") + ? location + : new URL(location, currentUrl).toString(); + continue; + } + + const responseBody = await response.text(); + + if (response.status === 401) { + throw new CalDAVError("Apple 계정 인증 실패 (401)", 401, currentUrl); + } + if (response.status === 403) { + throw new CalDAVError("접근 권한 없음 (403)", 403, currentUrl); + } + if (response.status >= 500) { + throw new CalDAVError( + `iCloud 서버 오류 (${response.status})`, + response.status, + currentUrl, + ); + } + + return { + status: response.status, + headers: response.headers, + body: responseBody, + }; + } + + throw new CalDAVError("리다이렉트 횟수 초과", 0, url); +} diff --git a/src/lib/caldav/connection-cookie.ts b/src/lib/caldav/connection-cookie.ts new file mode 100644 index 0000000..f56e2f7 --- /dev/null +++ b/src/lib/caldav/connection-cookie.ts @@ -0,0 +1,116 @@ +import { cookies } from "next/headers"; +import { + encrypt, + decrypt, + serializeEncrypted, + deserializeEncrypted, +} from "@/lib/crypto"; + +// ============================================================ +// iCloud 연결 정보 쿠키 저장소 +// +// Google/Naver 캘린더(`google/auth.ts`)와 동일하게, 연동 정보를 HttpOnly +// 쿠키에 저장한다. 앱의 실제 인증은 커스텀 JWT 쿠키(`accessToken`)를 쓰므로 +// Supabase Auth 세션/RLS에 의존하지 않는다. +// +// 앱 전용 암호는 장기 유효한 민감 정보라 AES-256-GCM(`crypto.ts`)으로 암호화해 +// 보관한다. 따라서 ENCRYPTION_SECRET 환경변수가 필요하다. +// ============================================================ + +const CONNECTION_COOKIE_NAME = "icloud_connection"; +const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30일 + +/** API 응답/상태 표시에 쓰는 공개 연결 정보 (앱 암호 미포함) */ +export interface ICloudConnection { + appleId: string; + principalUrl: string; + calendarHomeUrl: string; +} + +/** CalDAV 요청에 쓰는 인증 정보 (복호화된 앱 암호 포함) — 서버 전용 */ +export interface ICloudConnectionAuth extends ICloudConnection { + password: string; +} + +/** 쿠키에 직렬화되어 저장되는 형태 (암호화된 앱 암호 포함) */ +interface StoredConnection extends ICloudConnection { + /** "base64(ciphertext):base64(authTag)" */ + encryptedPassword: string; + /** base64(12-byte IV) */ + encryptionIv: string; +} + +/** + * 연결 정보를 HttpOnly 쿠키에 저장한다. + * 앱 전용 암호는 AES-GCM으로 암호화해 보관한다. + */ +export async function saveConnection(params: { + appleId: string; + appPassword: string; + principalUrl: string; + calendarHomeUrl: string; +}): Promise { + const enc = encrypt(params.appPassword); + const stored: StoredConnection = { + appleId: params.appleId, + principalUrl: params.principalUrl, + calendarHomeUrl: params.calendarHomeUrl, + encryptedPassword: serializeEncrypted(enc), + encryptionIv: enc.iv, + }; + + const cookieStore = await cookies(); + cookieStore.set(CONNECTION_COOKIE_NAME, JSON.stringify(stored), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: COOKIE_MAX_AGE, + }); +} + +/** 쿠키에서 공개 연결 정보를 읽는다. 앱 암호는 포함하지 않는다. */ +export async function getConnection(): Promise { + const stored = await readStored(); + if (!stored) return null; + return { + appleId: stored.appleId, + principalUrl: stored.principalUrl, + calendarHomeUrl: stored.calendarHomeUrl, + }; +} + +/** + * CalDAV 요청용 인증 정보(복호화된 앱 암호 포함)를 읽는다. + * 서버 라우트 핸들러에서만 사용해야 한다. + */ +export async function getConnectionAuth(): Promise { + const stored = await readStored(); + if (!stored) return null; + const password = decrypt( + deserializeEncrypted(stored.encryptedPassword, stored.encryptionIv), + ); + return { + appleId: stored.appleId, + principalUrl: stored.principalUrl, + calendarHomeUrl: stored.calendarHomeUrl, + password, + }; +} + +/** 연결 쿠키를 삭제한다. */ +export async function clearConnection(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(CONNECTION_COOKIE_NAME); +} + +async function readStored(): Promise { + const cookieStore = await cookies(); + const raw = cookieStore.get(CONNECTION_COOKIE_NAME)?.value; + if (!raw) return null; + try { + return JSON.parse(raw) as StoredConnection; + } catch { + return null; + } +} diff --git a/src/lib/caldav/create.ts b/src/lib/caldav/create.ts new file mode 100644 index 0000000..fba0511 --- /dev/null +++ b/src/lib/caldav/create.ts @@ -0,0 +1,41 @@ +import { caldavRequest, CalDAVError } from "./client"; +import type { CalDAVAuth, CreateEventResult } from "@/types/icloud"; + +/** + * CalDAV PUT으로 새 이벤트를 생성한다. + * + * @param calendarUrl 캘린더 컬렉션 URL (ex: https://p01-caldav.icloud.com/.../calendars/home/) + * @param auth CalDAV 인증 정보 + * @param uid 이벤트 UID (파일명에도 사용됨) + * @param icsContent RFC 5545 ICS 문자열 + */ +export async function createEvent( + calendarUrl: string, + auth: CalDAVAuth, + uid: string, + icsContent: string, +): Promise { + const eventUrl = `${calendarUrl.replace(/\/$/, "")}/${uid}.ics`; + + const response = await caldavRequest("PUT", eventUrl, auth, { + body: icsContent, + headers: { + "Content-Type": "text/calendar; charset=utf-8", + // If-None-Match: * → 같은 UID가 이미 존재하면 412 Precondition Failed로 실패 + "If-None-Match": "*", + }, + }); + + // 201 Created 또는 204 No Content가 성공 응답 + if (response.status !== 201 && response.status !== 204) { + throw new CalDAVError( + `일정 생성 실패 (${response.status})`, + response.status, + eventUrl, + ); + } + + const etag = response.headers.get("ETag") ?? ""; + + return { href: eventUrl, etag }; +} diff --git a/src/lib/caldav/discovery.ts b/src/lib/caldav/discovery.ts new file mode 100644 index 0000000..faa8e3a --- /dev/null +++ b/src/lib/caldav/discovery.ts @@ -0,0 +1,195 @@ +import { XMLParser } from "fast-xml-parser"; +import { caldavRequest, CalDAVError } from "./client"; +import type { CalDAVAuth, DiscoveryResult, CalendarInfo } from "@/types/icloud"; + +const ICLOUD_CALDAV_BASE = "https://caldav.icloud.com"; +const WELL_KNOWN_PATH = "/.well-known/caldav"; + +// ============================================================ +// XML 파서 설정 +// removeNSPrefix: true → "D:response" 같은 네임스페이스 접두사 제거 +// ============================================================ +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + removeNSPrefix: true, + isArray: (tagName) => + // response, propstat은 항상 배열로 처리 (단일 항목도 배열) + ["response", "propstat"].includes(tagName), +}); + +// ============================================================ +// XML 파싱 헬퍼 +// ============================================================ + +/** XMLParser 결과에서 중첩 경로 값을 안전하게 꺼낸다. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function get(obj: any, ...keys: string[]): any { + return keys.reduce((cur, k) => (cur != null ? cur[k] : undefined), obj); +} + +/** href 값을 정규화 — 절대 URL이 아니면 iCloud base를 붙인다. */ +function absoluteUrl(href: string): string { + if (!href) return ""; + return href.startsWith("http") ? href : `${ICLOUD_CALDAV_BASE}${href}`; +} + +// ============================================================ +// Step 1: Principal URL 조회 +// PROPFIND /.well-known/caldav → current-user-principal +// ============================================================ +async function discoverPrincipalUrl(auth: CalDAVAuth): Promise { + const body = ` + + + + +`; + + const response = await caldavRequest( + "PROPFIND", + `${ICLOUD_CALDAV_BASE}${WELL_KNOWN_PATH}`, + auth, + { body, depth: "0" }, + ); + + const parsed = parser.parse(response.body); + + // multistatus > response[0] > propstat[0] > prop > current-user-principal > href + const responses = get(parsed, "multistatus", "response") ?? []; + for (const r of responses) { + const propstats = r.propstat ?? []; + for (const ps of propstats) { + const href = get(ps, "prop", "current-user-principal", "href"); + if (href) return absoluteUrl(String(href)); + } + } + + throw new CalDAVError( + "current-user-principal을 찾을 수 없습니다.", + 0, + WELL_KNOWN_PATH, + ); +} + +// ============================================================ +// Step 2: Calendar Home URL 조회 +// PROPFIND {principalUrl} → calendar-home-set +// ============================================================ +async function discoverCalendarHome( + principalUrl: string, + auth: CalDAVAuth, +): Promise { + const body = ` + + + + +`; + + const response = await caldavRequest("PROPFIND", principalUrl, auth, { + body, + depth: "0", + }); + + const parsed = parser.parse(response.body); + + const responses = get(parsed, "multistatus", "response") ?? []; + for (const r of responses) { + const propstats = r.propstat ?? []; + for (const ps of propstats) { + const href = get(ps, "prop", "calendar-home-set", "href"); + if (href) return absoluteUrl(String(href)); + } + } + + throw new CalDAVError( + "calendar-home-set을 찾을 수 없습니다.", + 0, + principalUrl, + ); +} + +// ============================================================ +// Step 3: 캘린더 컬렉션 목록 조회 +// PROPFIND {calendarHomeUrl} (Depth: 1) → calendar 리소스 필터링 +// ============================================================ +async function listCalendars( + calendarHomeUrl: string, + auth: CalDAVAuth, +): Promise { + const body = ` + + + + + + + +`; + + const response = await caldavRequest("PROPFIND", calendarHomeUrl, auth, { + body, + depth: "1", + }); + + const parsed = parser.parse(response.body); + const responses = get(parsed, "multistatus", "response") ?? []; + const calendars: CalendarInfo[] = []; + + for (const r of responses) { + const href = absoluteUrl(String(r.href ?? "")); + // home URL 자체는 제외 + if (href === calendarHomeUrl || href === calendarHomeUrl.replace(/\/$/, "")) + continue; + + const propstats = r.propstat ?? []; + for (const ps of propstats) { + const status: string = ps.status ?? ""; + if (!status.includes("200")) continue; + + const prop = ps.prop ?? {}; + + // resourcetype에 "calendar" 요소가 있어야 캘린더 컬렉션 + const resourcetype = prop.resourcetype ?? {}; + const isCalendar = + "calendar" in resourcetype || + JSON.stringify(resourcetype).includes("calendar"); + + if (!isCalendar) continue; + + calendars.push({ + url: href, + displayName: String(prop.displayname ?? "이름 없는 캘린더"), + color: prop["calendar-color"] + ? String(prop["calendar-color"]).replace(/[^#\w]/g, "") + : undefined, + ctag: prop.getctag ? String(prop.getctag) : undefined, + }); + } + } + + return calendars; +} + +// ============================================================ +// 공개 API +// ============================================================ + +/** + * Apple ID + 앱 전용 암호로 iCloud CalDAV를 탐색한다. + * 성공 시 principalUrl / calendarHomeUrl / 캘린더 목록을 반환한다. + * 인증 실패 시 CalDAVError(statusCode=401)를 던진다. + */ +export async function discoverCalDAV( + auth: CalDAVAuth, +): Promise { + const principalUrl = await discoverPrincipalUrl(auth); + const calendarHomeUrl = await discoverCalendarHome(principalUrl, auth); + const calendars = await listCalendars(calendarHomeUrl, auth); + + return { principalUrl, calendarHomeUrl, calendars }; +} diff --git a/src/lib/caldav/query.ts b/src/lib/caldav/query.ts new file mode 100644 index 0000000..0b27b6f --- /dev/null +++ b/src/lib/caldav/query.ts @@ -0,0 +1,89 @@ +import { XMLParser } from "fast-xml-parser"; +import { caldavRequest } from "./client"; +import type { CalDAVAuth, RawEventData } from "@/types/icloud"; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + removeNSPrefix: true, + isArray: (tagName) => ["response", "propstat"].includes(tagName), + // calendar-data는 긴 텍스트를 포함하므로 CDATA 처리 + cdataPropName: "__cdata", +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function get(obj: any, ...keys: string[]): any { + return keys.reduce((cur, k) => (cur != null ? cur[k] : undefined), obj); +} + +/** Date를 iCalendar time-range 포맷(YYYYMMDDTHHmmssZ)으로 변환한다. */ +function toICalDate(d: Date): string { + return d.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; +} + +/** + * CalDAV REPORT로 특정 기간의 이벤트 raw 데이터를 조회한다. + * 반환값은 각 이벤트의 href, etag, ICS 문자열 배열이다. + */ +export async function queryEvents( + calendarUrl: string, + auth: CalDAVAuth, + startDate: Date, + endDate: Date, +): Promise { + const body = ` + + + + + + + + + + + + +`; + + const response = await caldavRequest("REPORT", calendarUrl, auth, { + body, + depth: "1", + headers: { "Content-Type": "application/xml; charset=utf-8" }, + }); + + // 이벤트 없음 (204 No Content 혹은 빈 207) + if (response.status === 204 || !response.body.trim()) return []; + + const parsed = parser.parse(response.body); + const responses = get(parsed, "multistatus", "response") ?? []; + const results: RawEventData[] = []; + + for (const r of responses) { + const href = String(r.href ?? ""); + if (!href.endsWith(".ics")) continue; + + const propstats = r.propstat ?? []; + for (const ps of propstats) { + const status: string = ps.status ?? ""; + if (!status.includes("200")) continue; + + const etag = String(get(ps, "prop", "getetag") ?? ""); + + // calendar-data는 CDATA 또는 일반 텍스트로 반환될 수 있다 + let icsData = get(ps, "prop", "calendar-data") ?? ""; + if (typeof icsData === "object" && icsData.__cdata) { + icsData = icsData.__cdata; + } + icsData = String(icsData).trim(); + + if (icsData) { + results.push({ href, etag, icsData }); + } + } + } + + return results; +} diff --git a/src/lib/calendar/adapter.ts b/src/lib/calendar/adapter.ts new file mode 100644 index 0000000..60d79f6 --- /dev/null +++ b/src/lib/calendar/adapter.ts @@ -0,0 +1,22 @@ +/** + * 캘린더 provider 어댑터 인터페이스 + * + * 각 provider(iCloud, Google, 수동 입력, AI 사진 추출, .ics 등)는 raw 데이터 + * 형태가 모두 다르다. 이 인터페이스를 구현하면 표준 `CalendarEvent[]`로 변환되어 + * 이후의 free/busy 계산·교집합 로직이 provider 구분 없이 동일하게 동작한다. + * + * 새로운 provider를 추가할 때: + * 1. `adapters/.ts`에 `createXxxAdapter()` 팩토리를 만들고 + * 2. `toCalendarEvents`에서 provider raw → CalendarEvent 매핑을 작성한 뒤 + * 3. 본 모듈의 `ADAPTER_REGISTRY`에 등록한다. + */ + +import type { + CalendarEvent, + CalendarEventSource, +} from "@/types/calendar-event"; + +export interface CalendarAdapter { + readonly source: CalendarEventSource; + toCalendarEvents(raw: TRaw): CalendarEvent[]; +} diff --git a/src/lib/calendar/adapters/__tests__/google.test.ts b/src/lib/calendar/adapters/__tests__/google.test.ts new file mode 100644 index 0000000..57d3b61 --- /dev/null +++ b/src/lib/calendar/adapters/__tests__/google.test.ts @@ -0,0 +1,87 @@ +import { describe, test, expect } from "vitest"; +import { googleAdapter } from "../google"; +import type { GoogleEvent } from "@/types/google-calendar"; + +describe("googleAdapter — GoogleEvent → CalendarEvent", () => { + test("dateTime 기반 이벤트를 변환한다", () => { + const events: GoogleEvent[] = [ + { + id: "g-1", + summary: "스탠드업", + start: { dateTime: "2026-05-10T10:00:00Z" }, + end: { dateTime: "2026-05-10T10:30:00Z" }, + status: "confirmed", + }, + ]; + + const result = googleAdapter.toCalendarEvents(events); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + id: "google:g-1", + externalId: "g-1", + title: "스탠드업", + isAllDay: false, + source: "google", + }); + expect(result[0].startAt.toISOString()).toBe("2026-05-10T10:00:00.000Z"); + expect(result[0].endAt.toISOString()).toBe("2026-05-10T10:30:00.000Z"); + }); + + test("date 기반 종일 이벤트는 isAllDay=true로 변환된다", () => { + const events: GoogleEvent[] = [ + { + id: "g-allday", + summary: "휴가", + start: { date: "2026-05-12" }, + end: { date: "2026-05-13" }, + status: "confirmed", + }, + ]; + + const result = googleAdapter.toCalendarEvents(events); + + expect(result[0].isAllDay).toBe(true); + expect(result[0].title).toBe("휴가"); + }); + + test("status === 'cancelled' 이벤트는 제외한다", () => { + const events: GoogleEvent[] = [ + { + id: "live", + summary: "live", + start: { dateTime: "2026-05-10T10:00:00Z" }, + end: { dateTime: "2026-05-10T11:00:00Z" }, + status: "confirmed", + }, + { + id: "dead", + summary: "dead", + start: { dateTime: "2026-05-10T12:00:00Z" }, + end: { dateTime: "2026-05-10T13:00:00Z" }, + status: "cancelled", + }, + ]; + + const result = googleAdapter.toCalendarEvents(events); + + expect(result).toHaveLength(1); + expect(result[0].externalId).toBe("live"); + }); + + test("summary가 비면 '(제목 없음)' 으로 대체한다", () => { + const events: GoogleEvent[] = [ + { + id: "g-empty", + summary: "", + start: { dateTime: "2026-05-10T10:00:00Z" }, + end: { dateTime: "2026-05-10T11:00:00Z" }, + status: "confirmed", + }, + ]; + + expect(googleAdapter.toCalendarEvents(events)[0].title).toBe( + "(제목 없음)", + ); + }); +}); diff --git a/src/lib/calendar/adapters/__tests__/icloud.test.ts b/src/lib/calendar/adapters/__tests__/icloud.test.ts new file mode 100644 index 0000000..f81bef1 --- /dev/null +++ b/src/lib/calendar/adapters/__tests__/icloud.test.ts @@ -0,0 +1,57 @@ +import { describe, test, expect } from "vitest"; +import { icloudAdapter } from "../icloud"; +import type { ParsedEvent } from "@/types/icloud"; + +const iso = (s: string) => new Date(s); + +describe("icloudAdapter — ParsedEvent → CalendarEvent", () => { + test("필수 필드를 그대로 매핑한다", () => { + const parsed: ParsedEvent[] = [ + { + uid: "evt-1", + title: "팀 미팅", + startAt: iso("2026-05-10T10:00:00Z"), + endAt: iso("2026-05-10T11:00:00Z"), + isAllDay: false, + location: "Zoom", + description: "주간 회의", + }, + ]; + + const result = icloudAdapter.toCalendarEvents(parsed); + + expect(result).toEqual([ + { + id: "icloud:evt-1", + externalId: "evt-1", + title: "팀 미팅", + startAt: iso("2026-05-10T10:00:00Z"), + endAt: iso("2026-05-10T11:00:00Z"), + isAllDay: false, + source: "icloud", + location: "Zoom", + description: "주간 회의", + }, + ]); + }); + + test("title이 비면 '(제목 없음)' 으로 대체한다", () => { + const parsed: ParsedEvent[] = [ + { + uid: "evt-2", + title: "", + startAt: iso("2026-05-10T10:00:00Z"), + endAt: iso("2026-05-10T11:00:00Z"), + isAllDay: false, + }, + ]; + + expect(icloudAdapter.toCalendarEvents(parsed)[0].title).toBe( + "(제목 없음)", + ); + }); + + test("source는 항상 'icloud'", () => { + expect(icloudAdapter.source).toBe("icloud"); + }); +}); diff --git a/src/lib/calendar/adapters/__tests__/manual.test.ts b/src/lib/calendar/adapters/__tests__/manual.test.ts new file mode 100644 index 0000000..11c421c --- /dev/null +++ b/src/lib/calendar/adapters/__tests__/manual.test.ts @@ -0,0 +1,45 @@ +import { describe, test, expect } from "vitest"; +import { manualSlotsToFreeEvents } from "../manual"; +import type { TimeSlot } from "@/types/schedule"; + +describe("manualSlotsToFreeEvents — 그리드 선택 → free CalendarEvent", () => { + test("주어진 weekStart 기준으로 요일 슬롯을 절대 시각으로 환산한다", () => { + // 2026-05-04는 월요일 + const weekStart = new Date(2026, 4, 4, 0, 0, 0, 0); + const slots: TimeSlot[] = [ + { day: "MON", startHour: 9, endHour: 11 }, + { day: "WED", startHour: 14, endHour: 16 }, + ]; + + const result = manualSlotsToFreeEvents(slots, { weekStart }); + + expect(result).toHaveLength(2); + + expect(result[0].source).toBe("manual"); + expect(result[0].isAllDay).toBe(false); + expect(result[0].startAt.getDay()).toBe(1); // 월요일 + expect(result[0].startAt.getHours()).toBe(9); + expect(result[0].endAt.getHours()).toBe(11); + + expect(result[1].startAt.getDay()).toBe(3); // 수요일 + expect(result[1].startAt.getHours()).toBe(14); + expect(result[1].endAt.getHours()).toBe(16); + }); + + test("빈 입력은 빈 배열을 반환한다", () => { + const weekStart = new Date(2026, 4, 4); + expect(manualSlotsToFreeEvents([], { weekStart })).toEqual([]); + }); + + test("id는 슬롯 위치·요일·시간으로 유일하게 만들어진다", () => { + const weekStart = new Date(2026, 4, 4); + const slots: TimeSlot[] = [ + { day: "MON", startHour: 9, endHour: 10 }, + { day: "MON", startHour: 9, endHour: 10 }, + ]; + + const result = manualSlotsToFreeEvents(slots, { weekStart }); + + expect(result[0].id).not.toBe(result[1].id); + }); +}); diff --git a/src/lib/calendar/adapters/google.ts b/src/lib/calendar/adapters/google.ts new file mode 100644 index 0000000..d853026 --- /dev/null +++ b/src/lib/calendar/adapters/google.ts @@ -0,0 +1,54 @@ +/** + * Google Calendar → 표준 CalendarEvent 어댑터 + * + * Google API의 `events.list` 응답 항목(`GoogleEvent`)을 표준 형식으로 변환한다. + * + * Google 이벤트의 시작/종료는 두 가지 형태: + * - `dateTime` (ISO 8601, 시간대 포함) — 일반 이벤트 + * - `date` (`YYYY-MM-DD`) — 종일 이벤트(시간대는 RFC 5545에 따라 floating). + * + * 종일 이벤트는 사용자 로컬 자정~다음 날 자정으로 해석한다. 시간대 정밀도가 + * 필요한 케이스(예: 정확한 회의 가능 여부)는 호출자가 별도 처리한다. + */ + +import type { GoogleEvent } from "@/types/google-calendar"; +import type { CalendarEvent } from "@/types/calendar-event"; +import type { CalendarAdapter } from "../adapter"; + +export const googleAdapter: CalendarAdapter = { + source: "google", + toCalendarEvents(events) { + return events + .filter((event) => event.status !== "cancelled") + .map(toCalendarEvent); + }, +}; + +function toCalendarEvent(event: GoogleEvent): CalendarEvent { + const isAllDay = Boolean(event.start.date && event.end.date); + + const startAt = isAllDay + ? parseAllDay(event.start.date as string) + : new Date(event.start.dateTime as string); + + const endAt = isAllDay + ? parseAllDay(event.end.date as string) + : new Date(event.end.dateTime as string); + + return { + id: `google:${event.id}`, + externalId: event.id, + title: event.summary || "(제목 없음)", + startAt, + endAt, + isAllDay, + source: "google", + location: event.location, + description: event.description, + }; +} + +function parseAllDay(yyyymmdd: string): Date { + const [y, m, d] = yyyymmdd.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d)); +} diff --git a/src/lib/calendar/adapters/icloud.ts b/src/lib/calendar/adapters/icloud.ts new file mode 100644 index 0000000..aad4d43 --- /dev/null +++ b/src/lib/calendar/adapters/icloud.ts @@ -0,0 +1,31 @@ +/** + * iCloud(CalDAV) → 표준 CalendarEvent 어댑터 + * + * 입력은 `@/lib/caldav/query`가 반환하는 `ParsedEvent[]` (이미 ICS 파싱이 끝난 + * 도메인 객체). title 누락 등 방어 케이스만 처리하며 시간/날짜 변환은 없다. + */ + +import type { ParsedEvent } from "@/types/icloud"; +import type { CalendarEvent } from "@/types/calendar-event"; +import type { CalendarAdapter } from "../adapter"; + +export const icloudAdapter: CalendarAdapter = { + source: "icloud", + toCalendarEvents(events) { + return events.map(toCalendarEvent); + }, +}; + +function toCalendarEvent(event: ParsedEvent): CalendarEvent { + return { + id: `icloud:${event.uid}`, + externalId: event.uid, + title: event.title || "(제목 없음)", + startAt: event.startAt, + endAt: event.endAt, + isAllDay: event.isAllDay, + source: "icloud", + location: event.location, + description: event.description, + }; +} diff --git a/src/lib/calendar/adapters/manual.ts b/src/lib/calendar/adapters/manual.ts new file mode 100644 index 0000000..6e55e46 --- /dev/null +++ b/src/lib/calendar/adapters/manual.ts @@ -0,0 +1,67 @@ +/** + * 수동 입력(When2meet 스타일 그리드) → 표준 CalendarEvent 어댑터 + * + * 사용자가 그리드에서 "이 시간 가능"으로 선택한 결과는 요일+정수 시간 + * (`TimeSlot`) 형태다. 이는 **busy가 아니라 free** 정보이므로, 통합 가용시간 + * 계산을 위해 다음 두 진입점을 제공한다: + * + * - `manualSlotsToFreeEvents` : 그리드 선택을 free `CalendarEvent[]`로 변환 + * (busy 이벤트가 없는 일정 입력 경로 — Google/iCloud 미연동 사용자용) + * + * 그리드는 요일 단위라 절대 날짜 정보가 없다. 호출자가 `weekStart`(월요일 + * 0시)를 넘기면 해당 주의 절대 시각으로 환산해 반환한다. + */ + +import type { TimeSlot, DayCode } from "@/types/schedule"; +import type { CalendarEvent } from "@/types/calendar-event"; + +const DAY_OFFSET: Record = { + MON: 0, + TUE: 1, + WED: 2, + THU: 3, + FRI: 4, + SAT: 5, + SUN: 6, +}; + +export interface ManualToFreeOptions { + /** 기준 주의 월요일 0시 (호출자 timezone). 없으면 이번 주 월요일 사용. */ + weekStart?: Date; +} + +export function manualSlotsToFreeEvents( + slots: TimeSlot[], + options: ManualToFreeOptions = {}, +): CalendarEvent[] { + const weekStart = options.weekStart ?? getThisMonday(); + + return slots.map((slot, idx) => { + const startAt = addHours(weekStart, DAY_OFFSET[slot.day] * 24 + slot.startHour); + const endAt = addHours(weekStart, DAY_OFFSET[slot.day] * 24 + slot.endHour); + return { + id: `manual:${idx}:${slot.day}-${slot.startHour}-${slot.endHour}`, + title: "가용", + startAt, + endAt, + isAllDay: false, + source: "manual" as const, + }; + }); +} + +function addHours(base: Date, hours: number): Date { + const next = new Date(base); + next.setHours(next.getHours() + hours); + return next; +} + +function getThisMonday(): Date { + const now = new Date(); + const day = now.getDay(); // 0=Sun ... 6=Sat + const diffFromMonday = (day + 6) % 7; + const monday = new Date(now); + monday.setHours(0, 0, 0, 0); + monday.setDate(monday.getDate() - diffFromMonday); + return monday; +} diff --git a/src/lib/calendar/adapters/photo.ts b/src/lib/calendar/adapters/photo.ts new file mode 100644 index 0000000..f9250f7 --- /dev/null +++ b/src/lib/calendar/adapters/photo.ts @@ -0,0 +1,25 @@ +/** + * 사진(스크린샷) → 표준 CalendarEvent 어댑터 — 스텁 + * + * 후속 이슈(AI 사진 가용시간 추출)에서 구현한다. 본 파일은 어댑터 자리만 + * 잡아 두기 위한 placeholder다. AI 추출 결과의 입력 타입은 후속 PR에서 + * 확정한다(예: `{ slots: { startAt, endAt }[] }`). + * + * @see Issue #15 후속 작업 + */ + +import type { CalendarAdapter } from "../adapter"; + +export interface PhotoExtractionResult { + /** 모델이 사진에서 추출한 busy 슬롯 (절대 시각). */ + busy: { startAt: Date; endAt: Date; title?: string }[]; +} + +export const photoAdapter: CalendarAdapter = { + source: "photo", + toCalendarEvents(_raw) { + throw new Error( + "photoAdapter.toCalendarEvents: 후속 이슈(AI 사진 추출)에서 구현 예정", + ); + }, +}; diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..6dcb582 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,92 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; + +// AES-256-GCM: 키 32바이트, IV 12바이트, authTag 16바이트 +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; + +export interface EncryptedData { + /** base64 인코딩된 AES-GCM 암호문 */ + ciphertext: string; + /** base64 인코딩된 12-byte IV */ + iv: string; + /** base64 인코딩된 16-byte authTag */ + authTag: string; +} + +/** + * 환경변수 ENCRYPTION_SECRET에서 32-byte 키를 로드한다. + * .env.local에 ENCRYPTION_SECRET=<64자리 hex> 형태로 설정 필요. + * 생성: openssl rand -hex 32 + */ +function getKey(): Buffer { + const secret = process.env.ENCRYPTION_SECRET; + if (!secret || secret.length !== 64) { + throw new Error( + "ENCRYPTION_SECRET 환경변수가 없거나 올바르지 않습니다. (64자리 hex 필요)", + ); + } + return Buffer.from(secret, "hex"); +} + +/** 평문 문자열을 AES-256-GCM으로 암호화한다. */ +export function encrypt(plaintext: string): EncryptedData { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + return { + ciphertext: encrypted.toString("base64"), + iv: iv.toString("base64"), + authTag: authTag.toString("base64"), + }; +} + +/** AES-256-GCM 암호문을 복호화해 평문 문자열을 반환한다. */ +export function decrypt(data: EncryptedData): string { + const key = getKey(); + const iv = Buffer.from(data.iv, "base64"); + const authTag = Buffer.from(data.authTag, "base64"); + const ciphertext = Buffer.from(data.ciphertext, "base64"); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + return Buffer.concat([ + decipher.update(ciphertext), + decipher.final(), + ]).toString("utf8"); +} + +/** + * DB 저장 형식으로 직렬화한다: "ciphertext:authTag" + * IV는 별도 컬럼에 저장하므로 여기엔 포함하지 않는다. + */ +export function serializeEncrypted(data: EncryptedData): string { + return `${data.ciphertext}:${data.authTag}`; +} + +/** DB에서 읽어온 "ciphertext:authTag" 문자열과 IV로 EncryptedData를 복원한다. */ +export function deserializeEncrypted( + serialized: string, + iv: string, +): EncryptedData { + const colonIdx = serialized.lastIndexOf(":"); + return { + ciphertext: serialized.slice(0, colonIdx), + authTag: serialized.slice(colonIdx + 1), + iv, + }; +} + +/** 로그 마스킹: "user@icloud.com" → "u***@icloud.com" */ +export function maskEmail(email: string): string { + const atIdx = email.indexOf("@"); + if (atIdx <= 0) return "***"; + return `${email[0]}***${email.slice(atIdx)}`; +} diff --git a/src/lib/everytime/__tests__/auth.test.ts b/src/lib/everytime/__tests__/auth.test.ts new file mode 100644 index 0000000..13ff0c8 --- /dev/null +++ b/src/lib/everytime/__tests__/auth.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EverytimeAuthError, parseLoginResponse } from "../auth"; + +describe("parseLoginResponse", () => { + it("정상 응답 JSON에서 token과 userIdx를 반환한다", () => { + const data = { status: "ok", token: "abc123", idx: 99999 }; + const session = parseLoginResponse(data); + expect(session.token).toBe("abc123"); + expect(session.userIdx).toBe("99999"); + }); + + it("status가 ok가 아니면 EverytimeAuthError를 throw한다", () => { + const data = { status: "not_exists_user" }; + expect(() => parseLoginResponse(data)).toThrow(EverytimeAuthError); + }); + + it("token이 없으면 EverytimeAuthError를 throw한다", () => { + const data = { status: "ok", idx: 99999 }; + expect(() => parseLoginResponse(data)).toThrow(EverytimeAuthError); + }); + + it("idx가 없으면 EverytimeAuthError를 throw한다", () => { + const data = { status: "ok", token: "abc123" }; + expect(() => parseLoginResponse(data)).toThrow(EverytimeAuthError); + }); + + it("token이 빈 문자열이면 EverytimeAuthError를 throw한다", () => { + const data = { status: "ok", token: "", idx: 99999 }; + expect(() => parseLoginResponse(data)).toThrow(EverytimeAuthError); + }); + + it("idx가 number 타입일 때 string으로 변환된다", () => { + const data = { status: "ok", token: "abc123", idx: 12345 }; + const session = parseLoginResponse(data); + expect(session.userIdx).toBe("12345"); + expect(typeof session.userIdx).toBe("string"); + }); + + it("null을 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse(null)).toThrow(EverytimeAuthError); + }); + + it("undefined를 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse(undefined)).toThrow(EverytimeAuthError); + }); + + it("문자열을 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse("string")).toThrow(EverytimeAuthError); + }); + + it("숫자를 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse(123)).toThrow(EverytimeAuthError); + }); + + it("배열을 전달하면 EverytimeAuthError를 throw한다", () => { + expect(() => parseLoginResponse([])).toThrow(EverytimeAuthError); + }); +}); + +describe("loginToEverytime (HTTP)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("세션 쿠키 획득 후 로그인 성공 시 세션을 반환한다", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + // 1번째 호출: 로그인 페이지 GET (쿠키 획득) + .mockResolvedValueOnce({ + headers: { + getSetCookie: () => [ + "etsid=session123; Path=/", + "x-et-device=device456; Path=/", + ], + get: () => null, + }, + redirect: "follow", + }) + // 2번째 호출: 로그인 POST + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ status: "ok", token: "tok-xyz", idx: 12345 }), + }), + ); + + const { loginToEverytime } = await import("../auth"); + const session = await loginToEverytime({ id: "user1", password: "pass1" }); + + expect(session.token).toBe("tok-xyz"); + expect(session.userIdx).toBe("12345"); + }); + + it("서버 오류(5xx)이면 EverytimeAuthError를 throw한다", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + headers: { getSetCookie: () => [], get: () => null }, + }) + .mockResolvedValueOnce({ ok: false, status: 500 }), + ); + + const { loginToEverytime, EverytimeAuthError: AuthErr } = + await import("../auth"); + await expect( + loginToEverytime({ id: "user1", password: "pass1" }), + ).rejects.toThrow(AuthErr); + }); + + it("잘못된 자격증명이면 EverytimeAuthError를 throw한다", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + headers: { getSetCookie: () => [], get: () => null }, + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ status: "not_exists_user" }), + }), + ); + + const { loginToEverytime, EverytimeAuthError: AuthErr } = + await import("../auth"); + await expect( + loginToEverytime({ id: "wrong", password: "wrong" }), + ).rejects.toThrow(AuthErr); + }); + + it("세션 쿠키 요청이 네트워크 오류로 실패하면 에러를 throw한다", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("Network error")), + ); + + const { loginToEverytime } = await import("../auth"); + await expect( + loginToEverytime({ id: "user1", password: "pass1" }), + ).rejects.toThrow(); + }); +}); diff --git a/src/lib/everytime/__tests__/converter.test.ts b/src/lib/everytime/__tests__/converter.test.ts new file mode 100644 index 0000000..3f8c5eb --- /dev/null +++ b/src/lib/everytime/__tests__/converter.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { timetableToFreeSlots } from "../converter"; +import type { EverytimeTimetable } from "@/types/everytime"; + +describe("timetableToFreeSlots", () => { + it("수업이 없으면 후보 시간 전체가 빈 시간으로 반환된다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 12, + }); + expect(result).toEqual([{ day: "MON", startHour: 9, endHour: 12 }]); + }); + + it("수업 시간이 빈 시간에서 제외된다", () => { + // MON 09:00-10:30 수업 → 9시(9:00-10:00), 10시(10:00-11:00) 블록 불가 + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "수학", + times: [{ day: 0, startMinute: 540, endMinute: 630 }], + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 13, + }); + expect(result).toEqual([{ day: "MON", startHour: 11, endHour: 13 }]); + }); + + it("여러 수업 사이 시간이 각각 빈 시간으로 반환된다", () => { + // MON 09:00-10:30, MON 13:00-14:30 + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "수학", + times: [{ day: 0, startMinute: 540, endMinute: 630 }], + }, + { + name: "영어", + times: [{ day: 0, startMinute: 780, endMinute: 870 }], + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 18, + }); + expect(result).toEqual([ + { day: "MON", startHour: 11, endHour: 13 }, + { day: "MON", startHour: 15, endHour: 18 }, + ]); + }); + + it("다른 요일 수업은 해당 요일 빈 시간에 영향을 주지 않는다", () => { + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "수학", + times: [{ day: 1, startMinute: 540, endMinute: 630 }], // 화요일 + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 12, + }); + expect(result).toEqual([{ day: "MON", startHour: 9, endHour: 12 }]); + }); + + it("여러 요일에 걸친 시간표를 한 번에 처리한다", () => { + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "데이터구조", + times: [ + { day: 1, startMinute: 540, endMinute: 630 }, // 화 09:00-10:30 + { day: 3, startMinute: 540, endMinute: 630 }, // 목 09:00-10:30 + ], + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["TUE", "THU"], + candidateStartHour: 9, + candidateEndHour: 12, + }); + expect(result).toEqual([ + { day: "TUE", startHour: 11, endHour: 12 }, + { day: "THU", startHour: 11, endHour: 12 }, + ]); + }); + + it("정각에 시작하고 끝나는 수업은 해당 시간 블록만 제외된다", () => { + // MON 10:00-12:00 → 10시, 11시 블록 불가 + const timetable: EverytimeTimetable = { + lectures: [ + { + name: "알고리즘", + times: [{ day: 0, startMinute: 600, endMinute: 720 }], + }, + ], + }; + const result = timetableToFreeSlots(timetable, { + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 14, + }); + expect(result).toEqual([ + { day: "MON", startHour: 9, endHour: 10 }, + { day: "MON", startHour: 12, endHour: 14 }, + ]); + }); + + it("candidateStartHour >= candidateEndHour이면 에러를 throw한다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + expect(() => + timetableToFreeSlots(timetable, { + candidateStartHour: 12, + candidateEndHour: 9, + }), + ).toThrow(); + }); + + it("candidateStartHour가 음수이면 에러를 throw한다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + expect(() => + timetableToFreeSlots(timetable, { + candidateStartHour: -1, + candidateEndHour: 22, + }), + ).toThrow(); + }); + + it("candidateEndHour가 24 초과이면 에러를 throw한다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + expect(() => + timetableToFreeSlots(timetable, { + candidateStartHour: 9, + candidateEndHour: 25, + }), + ).toThrow(); + }); + + it("빈 candidateDays이면 빈 배열을 반환한다", () => { + const timetable: EverytimeTimetable = { lectures: [] }; + const result = timetableToFreeSlots(timetable, { + candidateDays: [], + candidateStartHour: 9, + candidateEndHour: 22, + }); + expect(result).toEqual([]); + }); +}); diff --git a/src/lib/everytime/__tests__/ics-converter.test.ts b/src/lib/everytime/__tests__/ics-converter.test.ts new file mode 100644 index 0000000..f022b9a --- /dev/null +++ b/src/lib/everytime/__tests__/ics-converter.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { parseTimetableFromIcs } from "../ics-converter"; + +// Asia/Seoul UTC+9 기준 ICS 샘플 (에브리타임 내보내기 형식) +const SAMPLE_ICS = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Everytime//Timetable//EN +BEGIN:VEVENT +UID:ds-mon@everytime +SUMMARY:데이터구조 +DTSTART;TZID=Asia/Seoul:20250303T090000 +DTEND;TZID=Asia/Seoul:20250303T103000 +RRULE:FREQ=WEEKLY;UNTIL=20250620T145900Z +LOCATION:공학관 301 +END:VEVENT +BEGIN:VEVENT +UID:ds-wed@everytime +SUMMARY:데이터구조 +DTSTART;TZID=Asia/Seoul:20250305T090000 +DTEND;TZID=Asia/Seoul:20250305T103000 +RRULE:FREQ=WEEKLY;UNTIL=20250620T145900Z +LOCATION:공학관 301 +END:VEVENT +BEGIN:VEVENT +UID:algo-tue@everytime +SUMMARY:알고리즘 +DTSTART;TZID=Asia/Seoul:20250304T130000 +DTEND;TZID=Asia/Seoul:20250304T143000 +RRULE:FREQ=WEEKLY;UNTIL=20250620T145900Z +LOCATION:공학관 201 +END:VEVENT +END:VCALENDAR`; + +describe("parseTimetableFromIcs", () => { + it("같은 과목명의 여러 시간대를 하나의 lecture로 병합한다", () => { + const timetable = parseTimetableFromIcs(SAMPLE_ICS); + const ds = timetable.lectures.find((l) => l.name === "데이터구조"); + + expect(ds).toBeDefined(); + expect(ds!.times).toHaveLength(2); + }); + + it("요일을 월요일 기준(0=월)으로 정확히 변환한다", () => { + const timetable = parseTimetableFromIcs(SAMPLE_ICS); + const ds = timetable.lectures.find((l) => l.name === "데이터구조")!; + + const days = ds.times.map((t) => t.day).sort(); + // 2025-03-03=월(0), 2025-03-05=수(2) + expect(days).toEqual([0, 2]); + }); + + it("시작/종료 시각을 분 단위로 정확히 변환한다", () => { + const timetable = parseTimetableFromIcs(SAMPLE_ICS); + const ds = timetable.lectures.find((l) => l.name === "데이터구조")!; + const mon = ds.times.find((t) => t.day === 0)!; + + // 09:00 → 540분, 10:30 → 630분 + expect(mon.startMinute).toBe(540); + expect(mon.endMinute).toBe(630); + }); + + it("여러 과목을 각각 별도 lecture로 반환한다", () => { + const timetable = parseTimetableFromIcs(SAMPLE_ICS); + expect(timetable.lectures).toHaveLength(2); + + const names = timetable.lectures.map((l) => l.name); + expect(names).toContain("데이터구조"); + expect(names).toContain("알고리즘"); + }); + + it("동일한 (과목, 요일, 시작분) 조합이 중복 포함되지 않는다", () => { + // 에브리타임이 RRULE 없이 매주 개별 VEVENT로 내보내는 경우 시뮬레이션 + const icsWithDuplicates = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:ds-mon-week1@everytime +SUMMARY:데이터구조 +DTSTART;TZID=Asia/Seoul:20250303T090000 +DTEND;TZID=Asia/Seoul:20250303T103000 +END:VEVENT +BEGIN:VEVENT +UID:ds-mon-week2@everytime +SUMMARY:데이터구조 +DTSTART;TZID=Asia/Seoul:20250310T090000 +DTEND;TZID=Asia/Seoul:20250310T103000 +END:VEVENT +END:VCALENDAR`; + + const timetable = parseTimetableFromIcs(icsWithDuplicates); + const ds = timetable.lectures.find((l) => l.name === "데이터구조")!; + // 같은 요일·시작분이므로 1개만 남아야 함 + expect(ds.times).toHaveLength(1); + }); + + it("종일 이벤트는 무시한다", () => { + const icsWithAllDay = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:holiday@test +SUMMARY:공휴일 +DTSTART;VALUE=DATE:20250303 +DTEND;VALUE=DATE:20250304 +END:VEVENT +BEGIN:VEVENT +UID:class@test +SUMMARY:수학 +DTSTART;TZID=Asia/Seoul:20250303T100000 +DTEND;TZID=Asia/Seoul:20250303T120000 +END:VEVENT +END:VCALENDAR`; + + const timetable = parseTimetableFromIcs(icsWithAllDay); + expect(timetable.lectures).toHaveLength(1); + expect(timetable.lectures[0].name).toBe("수학"); + }); + + it("빈 ICS는 빈 timetable을 반환한다", () => { + const timetable = parseTimetableFromIcs("BEGIN:VCALENDAR\nEND:VCALENDAR"); + expect(timetable.lectures).toEqual([]); + }); + + it("빈 과목명(SUMMARY)은 무시된다", () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:empty@test +SUMMARY: +DTSTART;TZID=Asia/Seoul:20250303T090000 +DTEND;TZID=Asia/Seoul:20250303T103000 +END:VEVENT +END:VCALENDAR`; + const timetable = parseTimetableFromIcs(ics); + expect(timetable.lectures).toHaveLength(0); + }); + + it("DTSTART >= DTEND인 이벤트는 무시된다", () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:invalid@test +SUMMARY:역순수업 +DTSTART;TZID=Asia/Seoul:20250303T103000 +DTEND;TZID=Asia/Seoul:20250303T090000 +END:VEVENT +END:VCALENDAR`; + const timetable = parseTimetableFromIcs(ics); + expect(timetable.lectures).toHaveLength(0); + }); + + it("비정상적인 시간 범위(시간 범위를 벗어남 등)는 무시한다", () => { + const ics = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:outofbounds@test +SUMMARY:자정넘김수업 +DTSTART;TZID=Asia/Seoul:20250303T230000 +DTEND;TZID=Asia/Seoul:20250304T010000 +END:VEVENT +END:VCALENDAR`; + const timetable = parseTimetableFromIcs(ics); + expect(timetable.lectures).toHaveLength(0); + }); +}); diff --git a/src/lib/everytime/__tests__/timetable.test.ts b/src/lib/everytime/__tests__/timetable.test.ts new file mode 100644 index 0000000..f053cdc --- /dev/null +++ b/src/lib/everytime/__tests__/timetable.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { parseSemesterResponse, parseSubjectListResponse } from "../timetable"; +import { EverytimeFetchError } from "../timetable"; + +describe("parseSemesterResponse", () => { + it("정상 응답 XML에서 semester id를 반환한다", () => { + const xml = ``; + expect(parseSemesterResponse(xml)).toBe("100"); + }); + + it("status가 ok가 아니면 EverytimeFetchError를 throw한다", () => { + const xml = ``; + expect(() => parseSemesterResponse(xml)).toThrow(EverytimeFetchError); + }); + + it("semester id가 없으면 EverytimeFetchError를 throw한다", () => { + const xml = ``; + expect(() => parseSemesterResponse(xml)).toThrow(EverytimeFetchError); + }); + + it("잘못된 XML 형식이면 EverytimeFetchError를 throw한다", () => { + expect(() => parseSemesterResponse("not valid xml <<<<")).toThrow( + EverytimeFetchError, + ); + }); +}); + +describe("parseSubjectListResponse", () => { + it("강의 목록 XML을 파싱해 lectures 배열을 반환한다", () => { + const xml = ` + + + 데이터구조 + + + 알고리즘 + + + `; + const timetable = parseSubjectListResponse(xml); + + expect(timetable.lectures).toHaveLength(2); + + const ds = timetable.lectures[0]; + expect(ds.name).toBe("데이터구조"); + expect(ds.times).toHaveLength(2); + expect(ds.times[0]).toEqual({ day: 1, startMinute: 540, endMinute: 630 }); + expect(ds.times[1]).toEqual({ day: 3, startMinute: 540, endMinute: 630 }); + + const algo = timetable.lectures[1]; + expect(algo.name).toBe("알고리즘"); + expect(algo.times[0]).toEqual({ day: 2, startMinute: 780, endMinute: 870 }); + }); + + it("강의가 없으면 빈 배열을 반환한다", () => { + const xml = ``; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures).toEqual([]); + }); + + it("status가 ok가 아니면 EverytimeFetchError를 throw한다", () => { + const xml = ``; + expect(() => parseSubjectListResponse(xml)).toThrow(EverytimeFetchError); + }); + + it("유효하지 않은 time 속성은 필터링된다", () => { + const xml = ` + + + 테스트 + + + `; + const timetable = parseSubjectListResponse(xml); + // day=8은 유효하지 않으므로 필터링 + expect(timetable.lectures[0].times).toHaveLength(1); + }); + + it("day=0(월요일)과 day=6(일요일) 경계값을 허용한다", () => { + const xml = ` + + + 월요일수업 + + + 일요일수업 + + + `; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures[0].times[0].day).toBe(0); + expect(timetable.lectures[1].times[0].day).toBe(6); + }); + + it("startMinute >= endMinute인 시간은 필터링된다", () => { + const xml = ` + + + 테스트 + + + `; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures[0].times).toHaveLength(0); + }); + + it("endMinute > 1440인 시간은 필터링된다", () => { + const xml = ` + + + 테스트 + + + `; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures[0].times).toHaveLength(0); + }); + + it("startMinute < 0인 시간은 필터링된다", () => { + const xml = ` + + + 테스트 + + + `; + const timetable = parseSubjectListResponse(xml); + expect(timetable.lectures[0].times).toHaveLength(0); + }); +}); diff --git a/src/lib/everytime/__tests__/url-scraper.test.ts b/src/lib/everytime/__tests__/url-scraper.test.ts new file mode 100644 index 0000000..a37527e --- /dev/null +++ b/src/lib/everytime/__tests__/url-scraper.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { EverytimeScrapeError, parseShareResponse } from "../url-scraper"; + +const SAMPLE_XML = ` + + + + + + + + + + + + +
+
`; + +describe("parseShareResponse", () => { + it("XML에서 과목 목록을 파싱한다", () => { + const timetable = parseShareResponse(SAMPLE_XML); + expect(timetable.lectures).toHaveLength(2); + }); + + it("과목명을 정확히 파싱한다", () => { + const timetable = parseShareResponse(SAMPLE_XML); + const names = timetable.lectures.map((l) => l.name); + expect(names).toContain("오픈소스를활용한실전창업"); + expect(names).toContain("프로그래밍언어"); + }); + + it("starttime × 5 = 자정 기준 분으로 변환한다", () => { + const timetable = parseShareResponse(SAMPLE_XML); + const open = timetable.lectures.find( + (l) => l.name === "오픈소스를활용한실전창업", + )!; + // starttime=108 → 108×5=540분 = 09:00 + // endtime=141 → 141×5=705분 = 11:45 + expect(open.times[0].startMinute).toBe(540); + expect(open.times[0].endMinute).toBe(705); + }); + + it("요일(day)을 그대로 반환한다 (0=월)", () => { + const timetable = parseShareResponse(SAMPLE_XML); + const open = timetable.lectures.find( + (l) => l.name === "오픈소스를활용한실전창업", + )!; + expect(open.times[0].day).toBe(0); + }); + + it("여러 요일 수업을 각각 times 항목으로 반환한다", () => { + const timetable = parseShareResponse(SAMPLE_XML); + const prog = timetable.lectures.find((l) => l.name === "프로그래밍언어")!; + expect(prog.times).toHaveLength(2); + const days = prog.times.map((t) => t.day).sort(); + expect(days).toEqual([2, 4]); // 수=2, 금=4 + }); + + it("table이 없으면 EverytimeScrapeError를 throw한다", () => { + const xml = ``; + expect(() => parseShareResponse(xml)).toThrow(EverytimeScrapeError); + }); + + it("비정상적인 시간 데이터(day 범위 초과, 정수가 아님, 범위 오류 등)는 무시한다", () => { + const invalidXml = ` + + + + + + + + + + + + + + +
+
`; + const timetable = parseShareResponse(invalidXml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("빈 시간표(subject 없음)를 처리한다", () => { + const xml = ` + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("음수 starttime은 필터링된다", () => { + const xml = ` + + + + + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("day가 0-6 범위를 벗어나면 필터링된다", () => { + const xml = ` + + + + + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("starttime >= endtime이면 필터링된다", () => { + const xml = ` + + + + + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("endtime × 5 > 1440인 시간은 필터링된다", () => { + // endtime=300 → 300×5=1500 > 1440 + const xml = ` + + + + + + +
+
`; + const timetable = parseShareResponse(xml); + expect(timetable.lectures).toHaveLength(0); + }); + + it("잘못된 XML 형식이면 EverytimeScrapeError를 throw한다", () => { + expect(() => parseShareResponse("not valid xml <<<<")).toThrow( + EverytimeScrapeError, + ); + }); +}); diff --git a/src/lib/everytime/auth.ts b/src/lib/everytime/auth.ts new file mode 100644 index 0000000..5b0d7aa --- /dev/null +++ b/src/lib/everytime/auth.ts @@ -0,0 +1,120 @@ +import type { EverytimeCredentials, EverytimeSession } from "@/types/everytime"; + +// ============================================================ +// 에브리타임 인증 모듈 — 서버 전용 (credentials 노출 금지) +// ============================================================ + +const LOGIN_PAGE_URL = "https://account.everytime.kr/login"; +const LOGIN_API_URL = "https://account.everytime.kr/api/authenticate/login"; + +const BASE_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Origin: "https://account.everytime.kr", + Referer: "https://account.everytime.kr/login", +}; + +export class EverytimeAuthError extends Error { + constructor(message: string) { + super(message); + this.name = "EverytimeAuthError"; + } +} + +/** + * 에브리타임에 로그인하고 세션 토큰을 반환한다. + * + * 1단계: 로그인 페이지 GET → 세션 쿠키 획득 + * 2단계: 쿠키 + 자격증명으로 POST → 토큰 반환 + * + * 자격증명은 서버 → 에브리타임 API로만 전송되며 저장되지 않는다. + * + * @note 프로덕션 플로우에서는 reCAPTCHA v3로 인해 실제 로그인이 차단되므로 사용되지 않는다. + * 테스트·개발 목적으로만 유지한다. + */ +export async function loginToEverytime( + credentials: EverytimeCredentials, +): Promise { + const cookies = await fetchSessionCookies(); + + const response = await fetch(LOGIN_API_URL, { + method: "POST", + headers: { + ...BASE_HEADERS, + "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XMLHttpRequest", + Cookie: cookies, + }, + body: new URLSearchParams({ + id: credentials.id, + password: credentials.password, + keep: "false", + recaptchaToken: "", + }), + }); + + if (!response.ok) { + throw new EverytimeAuthError( + `에브리타임 서버 응답 오류 (${response.status})`, + ); + } + + const data = (await response.json()) as unknown; + return parseLoginResponse(data); +} + +/** + * 로그인 페이지를 GET해서 세션 쿠키를 가져온다. + * etsid(세션 ID)와 x-et-device(기기 식별자)가 포함된다. + */ +async function fetchSessionCookies(): Promise { + const response = await fetch(LOGIN_PAGE_URL, { + headers: { "User-Agent": BASE_HEADERS["User-Agent"] }, + redirect: "follow", + }); + + const headers = response.headers as unknown as { + getSetCookie?: () => string[]; + get: (name: string) => string | null; + }; + + // Node 18+: getSetCookie()로 복수의 Set-Cookie 헤더를 배열로 가져옴 + const setCookies: string[] = + typeof headers.getSetCookie === "function" + ? headers.getSetCookie() + : [headers.get("set-cookie") ?? ""].filter(Boolean); + + // "name=value; Path=...; ..." → "name=value" 만 추출해서 합침 + return setCookies + .map((c) => c.split(";")[0]) + .filter(Boolean) + .join("; "); +} + +/** 로그인 응답 JSON을 파싱한다. */ +export function parseLoginResponse(data: unknown): EverytimeSession { + // 성공: { "status": "ok", "token": "TOKEN", "idx": 12345 } + // 실패: { "status": "not_exists_user" } + if (typeof data !== "object" || data === null || Array.isArray(data)) { + throw new EverytimeAuthError("로그인 응답 형식이 올바르지 않습니다."); + } + const res = data as Record; + + if (res.status !== "ok") { + throw new EverytimeAuthError("아이디 또는 비밀번호가 올바르지 않습니다."); + } + + const token = String(res.token ?? ""); + const userIdx = String(res.idx ?? ""); + + if (!token) { + throw new EverytimeAuthError("로그인 응답에서 토큰을 찾을 수 없습니다."); + } + if (!userIdx) { + throw new EverytimeAuthError( + "로그인 응답에서 사용자 ID를 찾을 수 없습니다.", + ); + } + + return { token, userIdx }; +} diff --git a/src/lib/everytime/converter.ts b/src/lib/everytime/converter.ts new file mode 100644 index 0000000..b6fa71b --- /dev/null +++ b/src/lib/everytime/converter.ts @@ -0,0 +1,113 @@ +import type { DayCode, TimeSlot } from "@/types/schedule"; +import type { EverytimeTimetable } from "@/types/everytime"; + +// ============================================================ +// 에브리타임 시간표 → 빈 시간(TimeSlot[]) 변환기 +// +// 에브리타임 시간표는 "수업 시간(바쁜 시간)"이다. +// MOIM의 findCommonSlots는 "가능한 시간(빈 시간)"을 입력받으므로 +// 후보 시간대에서 수업 시간을 제외한 블록을 반환한다. +// ============================================================ + +const DAY_CODE_MAP: Record = { + 0: "MON", + 1: "TUE", + 2: "WED", + 3: "THU", + 4: "FRI", + 5: "SAT", + 6: "SUN", +}; + +const CODE_TO_DAY_NUM: Record = { + MON: 0, + TUE: 1, + WED: 2, + THU: 3, + FRI: 4, + SAT: 5, + SUN: 6, +}; + +export interface TimetableConvertOptions { + /** 후보 요일 (기본: 월~금) */ + candidateDays?: DayCode[]; + /** 후보 시작 시각 (기본: 9시) */ + candidateStartHour?: number; + /** 후보 종료 시각 (기본: 22시) */ + candidateEndHour?: number; +} + +/** + * 에브리타임 시간표를 빈 시간 목록으로 변환한다. + * + * 수업이 없는 1시간 단위 블록을 추출하고, 연속된 블록은 하나의 TimeSlot으로 병합한다. + * + * @example + * // MON 09:00-10:30 수업 → MON 11:00-22:00 빈 시간 + * timetableToFreeSlots(timetable, { candidateDays: ["MON"], candidateStartHour: 9, candidateEndHour: 22 }) + * // → [{ day: "MON", startHour: 11, endHour: 22 }] + */ +export function timetableToFreeSlots( + timetable: EverytimeTimetable, + options: TimetableConvertOptions = {}, +): TimeSlot[] { + const { + candidateDays = ["MON", "TUE", "WED", "THU", "FRI"], + candidateStartHour = 9, + candidateEndHour = 22, + } = options; + + if (candidateStartHour < 0 || candidateEndHour > 24) { + throw new Error("시간 범위는 0~24 사이여야 합니다."); + } + if (candidateStartHour >= candidateEndHour) { + throw new Error("candidateStartHour는 candidateEndHour보다 작아야 합니다."); + } + + const result: TimeSlot[] = []; + + for (const day of candidateDays) { + const dayNum = CODE_TO_DAY_NUM[day]; + + const busyRanges = timetable.lectures + .flatMap((l) => l.times) + .filter((t) => t.day === dayNum); + + const freeHours: number[] = []; + for (let h = candidateStartHour; h < candidateEndHour; h++) { + const hourStartMin = h * 60; + const hourEndMin = (h + 1) * 60; + const isBusy = busyRanges.some( + (b) => b.startMinute < hourEndMin && b.endMinute > hourStartMin, + ); + if (!isBusy) freeHours.push(h); + } + + result.push(...groupConsecutiveHours(day, freeHours)); + } + + return result; +} + +/** 오름차순으로 정렬된 정수 시간 배열을 TimeSlot 블록으로 병합한다. */ +function groupConsecutiveHours(day: DayCode, hours: number[]): TimeSlot[] { + if (hours.length === 0) return []; + + const slots: TimeSlot[] = []; + let blockStart = hours[0]; + let prev = hours[0]; + + for (let i = 1; i < hours.length; i++) { + if (hours[i] !== prev + 1) { + slots.push({ day, startHour: blockStart, endHour: prev + 1 }); + blockStart = hours[i]; + } + prev = hours[i]; + } + slots.push({ day, startHour: blockStart, endHour: prev + 1 }); + + return slots; +} + +export { DAY_CODE_MAP }; diff --git a/src/lib/everytime/ics-converter.ts b/src/lib/everytime/ics-converter.ts new file mode 100644 index 0000000..b93235b --- /dev/null +++ b/src/lib/everytime/ics-converter.ts @@ -0,0 +1,87 @@ +import { parseIcsToEvents } from "@/lib/ics/parser"; +import type { + EverytimeLectureTime, + EverytimeTimetable, +} from "@/types/everytime"; + +// ============================================================ +// 에브리타임 ICS → EverytimeTimetable 변환기 +// +// 에브리타임 앱의 "시간표 내보내기(ICS)"로 받은 파일을 파싱한다. +// 각 VEVENT의 DTSTART/DTEND에서 요일·시간을 추출하고, +// 같은 과목명의 여러 시간대(월/수, 화/목 등)를 하나로 병합한다. +// ============================================================ + +// 에브리타임 ICS는 Asia/Seoul(UTC+9) 기준으로 작성됨 +const KST_OFFSET_MS = 9 * 60 * 60_000; + +/** + * JavaScript Date.getUTCDay() 기준(0=일) → 에브리타임 day(0=월) 변환 + * (jsDay + 6) % 7 : 일=6, 월=0, 화=1, 수=2, 목=3, 금=4, 토=5 + */ +function toMondayBasedDay(jsDay: number): EverytimeLectureTime["day"] { + return ((jsDay + 6) % 7) as EverytimeLectureTime["day"]; +} + +/** + * 에브리타임 ICS 텍스트를 EverytimeTimetable로 변환한다. + * + * - 종일 이벤트는 무시한다 (수업 시간은 항상 시작/종료 시각이 있다). + * - 같은 과목명끼리 times 배열로 합산한다. + * - (과목명, 요일, 시작분) 기준으로 중복을 제거한다. + * (RRULE이 있는 경우 단일 VEVENT이므로 중복이 없고, + * RRULE 없이 매주 개별 VEVENT로 내보내는 경우 중복 제거가 필요하다.) + */ +export function parseTimetableFromIcs(icsText: string): EverytimeTimetable { + const events = parseIcsToEvents(icsText); + + const lectureMap = new Map>(); + + for (const event of events) { + if (event.isAllDay) continue; + + const startKst = new Date(event.startAt.getTime() + KST_OFFSET_MS); + const endKst = new Date(event.endAt.getTime() + KST_OFFSET_MS); + + const day = toMondayBasedDay(startKst.getUTCDay()); + const startMinute = startKst.getUTCHours() * 60 + startKst.getUTCMinutes(); + const endMinute = endKst.getUTCHours() * 60 + endKst.getUTCMinutes(); + + // 정수 및 범위 검증 추가 + if ( + !Number.isInteger(day) || + day < 0 || + day > 6 || + !Number.isInteger(startMinute) || + !Number.isInteger(endMinute) || + startMinute < 0 || + endMinute > 1440 || + startMinute >= endMinute + ) { + continue; + } + const name = event.title; + if (!name.trim()) continue; + + if (startMinute >= endMinute) { + console.warn( + `[ics-converter] 잘못된 시간 범위 무시: ${name} (${startMinute}-${endMinute})`, + ); + continue; + } + // (요일, 시작분) 조합을 키로 중복 제거 + const slotKey = `${day}-${startMinute}`; + + if (!lectureMap.has(name)) { + lectureMap.set(name, new Map()); + } + lectureMap.get(name)!.set(slotKey, { day, startMinute, endMinute }); + } + + const lectures = Array.from(lectureMap.entries()).map(([name, slotMap]) => ({ + name, + times: Array.from(slotMap.values()), + })); + + return { lectures }; +} diff --git a/src/lib/everytime/timetable.ts b/src/lib/everytime/timetable.ts new file mode 100644 index 0000000..4e83761 --- /dev/null +++ b/src/lib/everytime/timetable.ts @@ -0,0 +1,160 @@ +import { XMLParser } from "fast-xml-parser"; +import type { + EverytimeLecture, + EverytimeLectureTime, + EverytimeSession, + EverytimeTimetable, +} from "@/types/everytime"; + +// ============================================================ +// 에브리타임 시간표 조회 모듈 — 서버 전용 +// ============================================================ + +const BASE_URL = "https://api.everytime.kr"; +const SEMESTER_SUGGEST_URL = `${BASE_URL}/find/timetable/semester/suggest`; + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + isArray: (name) => name === "subject" || name === "time", +}); + +export class EverytimeFetchError extends Error { + constructor(message: string) { + super(message); + this.name = "EverytimeFetchError"; + } +} + +/** + * 현재 학기의 시간표를 가져온다. + * + * 1단계: 현재 학기 ID 조회 + * 2단계: 해당 학기의 강의 목록 조회 + */ +export async function fetchCurrentTimetable( + session: EverytimeSession, +): Promise { + const semesterId = await fetchCurrentSemesterId(session); + return fetchTimetableBySemester(session, semesterId); +} + +async function fetchCurrentSemesterId( + session: EverytimeSession, +): Promise { + const response = await fetch(SEMESTER_SUGGEST_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: session.token }), + }); + + if (!response.ok) { + throw new EverytimeFetchError(`학기 조회 실패 (${response.status})`); + } + + const xml = await response.text(); + return parseSemesterResponse(xml); +} + +async function fetchTimetableBySemester( + session: EverytimeSession, + semesterId: string, +): Promise { + const url = `${BASE_URL}/find/timetable/subject/list/semester/id/${semesterId}`; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: session.token }), + }); + + if (!response.ok) { + throw new EverytimeFetchError(`시간표 조회 실패 (${response.status})`); + } + + const xml = await response.text(); + return parseSubjectListResponse(xml); +} + +/** 학기 응답 XML에서 semester id를 파싱한다. */ +export function parseSemesterResponse(xml: string): string { + // 예상 응답: + // + // + // + let parsed: ReturnType; + try { + parsed = xmlParser.parse(xml); + } catch { + throw new EverytimeFetchError("XML 파싱에 실패했습니다."); + } + const response = parsed?.response; + + if (response?.["@_status"] !== "ok") { + throw new EverytimeFetchError("학기 정보를 가져오지 못했습니다."); + } + + const rawId = response?.semester?.["@_id"]; + if (typeof rawId !== "string" && typeof rawId !== "number") { + throw new EverytimeFetchError("학기 ID를 찾을 수 없습니다."); + } + const id = String(rawId); + if (!id) throw new EverytimeFetchError("학기 ID를 찾을 수 없습니다."); + + return id; +} + +/** 강의 목록 응답 XML을 파싱한다. */ +export function parseSubjectListResponse(xml: string): EverytimeTimetable { + // 예상 응답: + // + // + // 데이터구조 + // + // + let parsed: ReturnType; + try { + parsed = xmlParser.parse(xml); + } catch { + throw new EverytimeFetchError("XML 파싱에 실패했습니다."); + } + const response = parsed?.response; + + if (response?.["@_status"] !== "ok") { + throw new EverytimeFetchError("시간표 정보를 가져오지 못했습니다."); + } + + const subjects: unknown[] = response?.subject ?? []; + const lectures: EverytimeLecture[] = subjects.map(parseSubject); + + return { lectures }; +} + +function parseSubject(subject: unknown): EverytimeLecture { + const s = subject as Record; + const name = String(s?.name ?? ""); + + const rawTimes = (s?.time ?? []) as unknown[]; + const times: EverytimeLectureTime[] = rawTimes + .map((t) => { + const time = t as Record; + const day = Number(time?.["@_day"]); + const startMinute = Number(time?.["@_start"]); + const endMinute = Number(time?.["@_end"]); + return { day, startMinute, endMinute }; + }) + .filter( + (t): t is EverytimeLectureTime => + Number.isInteger(t.day) && + t.day >= 0 && + t.day <= 6 && + Number.isInteger(t.startMinute) && + Number.isInteger(t.endMinute) && + t.startMinute >= 0 && + t.endMinute <= 1440 && + t.startMinute < t.endMinute, + ); + + return { name, times }; +} diff --git a/src/lib/everytime/url-scraper.ts b/src/lib/everytime/url-scraper.ts new file mode 100644 index 0000000..60d65a4 --- /dev/null +++ b/src/lib/everytime/url-scraper.ts @@ -0,0 +1,177 @@ +import { XMLParser } from "fast-xml-parser"; +import type { + EverytimeLectureTime, + EverytimeTimetable, +} from "@/types/everytime"; + +// ============================================================ +// 에브리타임 공유 URL → 시간표 추출 +// +// https://everytime.kr/@XXXX 형식의 공유 링크에서 identifier를 추출하고 +// api.everytime.kr/find/timetable/table/friend API를 호출해 XML로 받아온다. +// +// 시간 단위: starttime/endtime 값 × 5 = 자정 기준 분 +// 예) 108 × 5 = 540분 = 09:00 +// ============================================================ + +const API_URL = "https://api.everytime.kr/find/timetable/table/friend"; +const TIMEOUT_MS = 10_000; +const SHARE_URL_PATTERN = /^https:\/\/everytime\.kr\/@([\w-]+)$/; + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + isArray: (name) => name === "subject" || name === "data", +}); + +export class EverytimeScrapeError extends Error { + constructor(message: string) { + super(message); + this.name = "EverytimeScrapeError"; + } +} + +/** + * 에브리타임 공유 URL에서 시간표를 가져온다. + * + * @param url - https://everytime.kr/@XXXX 형식 + */ +export async function fetchTimetableFromUrl( + url: string, +): Promise { + const match = url.match(SHARE_URL_PATTERN); + if (!match) { + throw new EverytimeScrapeError( + "유효하지 않은 에브리타임 URL입니다. (예: https://everytime.kr/@XXXX)", + ); + } + + const identifier = match[1]; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(API_URL, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Origin: "https://everytime.kr", + Referer: `https://everytime.kr/@${identifier}`, + }, + body: new URLSearchParams({ identifier }), + signal: controller.signal, + }); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") { + throw new EverytimeScrapeError("에브리타임 API 요청 시간 초과"); + } + throw err; + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) { + throw new EverytimeScrapeError( + `에브리타임 API 응답 오류 (${response.status})`, + ); + } + + const xml = await response.text(); + return parseShareResponse(xml); +} + +/** 공유 API XML 응답을 EverytimeTimetable로 파싱한다. */ +export function parseShareResponse(xml: string): EverytimeTimetable { + // 응답 형식: + // + // + // + // + // + // + //
+ //
+ + let parsed: ReturnType; + try { + parsed = xmlParser.parse(xml); + } catch (err) { + throw new EverytimeScrapeError( + `XML 파싱 실패: ${err instanceof Error ? err.message : String(err)}`, + ); + } + const table = parsed?.response?.table; + + if (!table) { + throw new EverytimeScrapeError("시간표 응답을 파싱할 수 없습니다."); + } + + const subjects: unknown[] = table.subject ?? []; + const lectures = subjects + .map((s) => parseSubject(s)) + .filter( + (l): l is NonNullable => l !== null && l.times.length > 0, + ); + + return { lectures }; +} + +function parseSubject( + subject: unknown, +): { name: string; times: EverytimeLectureTime[] } | null { + const s = subject as Record; + + const nameNode = s?.name as Record | undefined; + const name = String(nameNode?.["@_value"] ?? "").trim(); + if (!name) return null; + + const timeNode = s?.time as Record | undefined; + const dataItems: unknown[] = (timeNode?.data as unknown[]) ?? []; + + const times: EverytimeLectureTime[] = dataItems + .map((d) => parseDataItem(d)) + .filter((t): t is EverytimeLectureTime => t !== null); + + return { name, times }; +} + +function parseDataItem(d: unknown): EverytimeLectureTime | null { + const item = d as Record; + + const rawDay = Number(item["@_day"]); + const rawStart = Number(item["@_starttime"]); + const rawEnd = Number(item["@_endtime"]); + + // finite number, 정수 검증 + if ( + !Number.isInteger(rawDay) || + !Number.isInteger(rawStart) || + !Number.isInteger(rawEnd) + ) { + return null; + } + + const day = rawDay; + // starttime/endtime은 5분 단위 → ×5 하면 자정 기준 분 + const startMinute = rawStart * 5; + const endMinute = rawEnd * 5; + + // 요일 범위: 0~6, 시간 범위: 0~1440 + if ( + day < 0 || + day > 6 || + startMinute < 0 || + endMinute > 1440 || + startMinute >= endMinute + ) { + return null; + } + + return { day: day as EverytimeLectureTime["day"], startMinute, endMinute }; +} diff --git a/src/lib/google/__tests__/auth.test.ts b/src/lib/google/__tests__/auth.test.ts new file mode 100644 index 0000000..51d03ad --- /dev/null +++ b/src/lib/google/__tests__/auth.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; + +// next/headers의 cookies를 모킹 +const mockCookieStore = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), +}; + +vi.mock("next/headers", () => ({ + cookies: () => Promise.resolve(mockCookieStore), +})); + +import { + buildAuthUrl, + exchangeCodeForTokens, + refreshAccessToken, +} from "../auth"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); + mockCookieStore.get.mockReset(); + mockCookieStore.set.mockReset(); + process.env.GOOGLE_CALENDAR_CLIENT_ID = "test-client-id"; + process.env.GOOGLE_CALENDAR_CLIENT_SECRET = "test-client-secret"; + process.env.NEXT_PUBLIC_BASE_URL = "http://localhost:3000"; +}); + +describe("buildAuthUrl", () => { + test("Google OAuth URL을 올바르게 생성한다", () => { + const url = buildAuthUrl(); + + expect(url).toContain("https://accounts.google.com/o/oauth2/v2/auth"); + expect(url).toContain("client_id=test-client-id"); + expect(url).toContain( + "redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fgoogle%2Fcallback", + ); + expect(url).toContain("response_type=code"); + expect(url).toContain("access_type=offline"); + expect(url).toContain("prompt=consent"); + }); + + test("calendar 스코프가 포함된다", () => { + const url = buildAuthUrl(); + + expect(url).toContain("calendar"); + expect(url).toContain("userinfo.email"); + }); + + test("state 파라미터를 전달할 수 있다", () => { + const url = buildAuthUrl("my-state-value"); + + expect(url).toContain("state=my-state-value"); + }); +}); + +describe("exchangeCodeForTokens", () => { + test("authorization code로 토큰을 교환한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: "access-123", + refresh_token: "refresh-456", + expires_in: 3600, + }), + }); + + const tokens = await exchangeCodeForTokens("auth-code-789"); + + expect(tokens.accessToken).toBe("access-123"); + expect(tokens.refreshToken).toBe("refresh-456"); + expect(tokens.expiresAt).toBeGreaterThan(Date.now()); + + // POST 요청 검증 + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe("https://oauth2.googleapis.com/token"); + expect(options.method).toBe("POST"); + expect(options.body.toString()).toContain("code=auth-code-789"); + expect(options.body.toString()).toContain("grant_type=authorization_code"); + }); + + test("실패 시 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + text: () => Promise.resolve("invalid_grant"), + }); + + await expect(exchangeCodeForTokens("bad-code")).rejects.toThrow( + "토큰 교환 실패", + ); + }); +}); + +describe("refreshAccessToken", () => { + test("refresh token으로 새 access token을 발급받는다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + access_token: "new-access-token", + expires_in: 3600, + }), + }); + + const result = await refreshAccessToken("my-refresh-token"); + + expect(result.accessToken).toBe("new-access-token"); + expect(result.expiresAt).toBeGreaterThan(Date.now()); + + const body = mockFetch.mock.calls[0][1].body.toString(); + expect(body).toContain("refresh_token=my-refresh-token"); + expect(body).toContain("grant_type=refresh_token"); + }); + + test("실패 시 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("token_revoked"), + }); + + await expect(refreshAccessToken("revoked-token")).rejects.toThrow( + "토큰 갱신 실패", + ); + }); +}); diff --git a/src/lib/google/__tests__/calendars.test.ts b/src/lib/google/__tests__/calendars.test.ts new file mode 100644 index 0000000..dbb16cb --- /dev/null +++ b/src/lib/google/__tests__/calendars.test.ts @@ -0,0 +1,95 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { listCalendars } from "../calendars"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("listCalendars", () => { + test("캘린더 목록을 올바르게 파싱한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + items: [ + { + id: "primary", + summary: "내 캘린더", + backgroundColor: "#4285f4", + primary: true, + accessRole: "owner", + }, + { + id: "work@gmail.com", + summary: "업무", + description: "업무 일정", + backgroundColor: "#0b8043", + accessRole: "writer", + }, + ], + }), + }); + + const calendars = await listCalendars("test-access-token"); + + expect(calendars).toHaveLength(2); + expect(calendars[0]).toMatchObject({ + id: "primary", + summary: "내 캘린더", + primary: true, + }); + expect(calendars[1]).toMatchObject({ + id: "work@gmail.com", + summary: "업무", + description: "업무 일정", + }); + }); + + test("Authorization 헤더를 올바르게 설정한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + await listCalendars("my-token"); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers["Authorization"]).toBe("Bearer my-token"); + }); + + test("minAccessRole=writer로 요청한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + await listCalendars("token"); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain("minAccessRole=writer"); + }); + + test("401 응답 시 인증 만료 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + }); + + await expect(listCalendars("expired-token")).rejects.toThrow("만료"); + }); + + test("items가 없으면 빈 배열을 반환한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + const calendars = await listCalendars("token"); + expect(calendars).toEqual([]); + }); +}); diff --git a/src/lib/google/__tests__/events.test.ts b/src/lib/google/__tests__/events.test.ts new file mode 100644 index 0000000..ef75893 --- /dev/null +++ b/src/lib/google/__tests__/events.test.ts @@ -0,0 +1,261 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { queryEvents, createEvent } from "../events"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("queryEvents", () => { + test("기간별 이벤트를 조회한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + items: [ + { + id: "event-1", + summary: "팀 미팅", + start: { dateTime: "2026-04-15T10:00:00+09:00" }, + end: { dateTime: "2026-04-15T11:00:00+09:00" }, + status: "confirmed", + htmlLink: "https://calendar.google.com/event?id=1", + }, + { + id: "event-2", + summary: "점심", + start: { dateTime: "2026-04-15T12:00:00+09:00" }, + end: { dateTime: "2026-04-15T13:00:00+09:00" }, + status: "confirmed", + }, + ], + }), + }); + + const events = await queryEvents( + "token", + "primary", + new Date("2026-04-15T00:00:00Z"), + new Date("2026-04-15T23:59:59Z"), + ); + + expect(events).toHaveLength(2); + expect(events[0].summary).toBe("팀 미팅"); + expect(events[1].summary).toBe("점심"); + }); + + test("취소된 이벤트를 필터링한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + items: [ + { + id: "active", + summary: "활성 이벤트", + start: { dateTime: "2026-04-15T10:00:00Z" }, + end: { dateTime: "2026-04-15T11:00:00Z" }, + status: "confirmed", + }, + { + id: "cancelled", + summary: "취소된 이벤트", + start: { dateTime: "2026-04-15T14:00:00Z" }, + end: { dateTime: "2026-04-15T15:00:00Z" }, + status: "cancelled", + }, + ], + }), + }); + + const events = await queryEvents( + "token", + "primary", + new Date("2026-04-15T00:00:00Z"), + new Date("2026-04-15T23:59:59Z"), + ); + + expect(events).toHaveLength(1); + expect(events[0].id).toBe("active"); + }); + + test("singleEvents=true와 orderBy=startTime으로 요청한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + await queryEvents( + "token", + "primary", + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain("singleEvents=true"); + expect(url).toContain("orderBy=startTime"); + }); + + test("calendarId를 URL 인코딩한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ items: [] }), + }); + + await queryEvents( + "token", + "user@gmail.com", + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain("user%40gmail.com"); + }); + + test("401 응답 시 인증 만료 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + }); + + await expect( + queryEvents( + "expired", + "primary", + new Date("2026-04-01T00:00:00Z"), + new Date("2026-04-30T23:59:59Z"), + ), + ).rejects.toThrow("만료"); + }); + + test("제목이 없는 이벤트는 '(제목 없음)'으로 표시한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + items: [ + { + id: "no-title", + start: { dateTime: "2026-04-15T10:00:00Z" }, + end: { dateTime: "2026-04-15T11:00:00Z" }, + status: "confirmed", + }, + ], + }), + }); + + const events = await queryEvents( + "token", + "primary", + new Date("2026-04-15T00:00:00Z"), + new Date("2026-04-15T23:59:59Z"), + ); + + expect(events[0].summary).toBe("(제목 없음)"); + }); +}); + +describe("createEvent", () => { + test("새 이벤트를 생성한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "new-event-id", + summary: "새 일정", + start: { dateTime: "2026-04-16T14:00:00+09:00" }, + end: { dateTime: "2026-04-16T15:00:00+09:00" }, + status: "confirmed", + htmlLink: "https://calendar.google.com/event?id=new", + }), + }); + + const event = await createEvent("token", "primary", { + summary: "새 일정", + startDateTime: "2026-04-16T14:00:00+09:00", + endDateTime: "2026-04-16T15:00:00+09:00", + }); + + expect(event.id).toBe("new-event-id"); + expect(event.summary).toBe("새 일정"); + }); + + test("POST 메서드와 JSON body로 요청한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "e", + summary: "test", + start: {}, + end: {}, + status: "confirmed", + }), + }); + + await createEvent("token", "primary", { + summary: "테스트", + startDateTime: "2026-04-16T14:00:00+09:00", + endDateTime: "2026-04-16T15:00:00+09:00", + location: "강남역", + description: "설명", + timeZone: "Asia/Seoul", + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toContain("/calendars/primary/events"); + expect(options.method).toBe("POST"); + expect(options.headers["Content-Type"]).toBe("application/json"); + + const body = JSON.parse(options.body); + expect(body.summary).toBe("테스트"); + expect(body.location).toBe("강남역"); + expect(body.description).toBe("설명"); + expect(body.start.timeZone).toBe("Asia/Seoul"); + }); + + test("timeZone 미지정 시 Asia/Seoul을 기본값으로 사용한다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: "e", + summary: "t", + start: {}, + end: {}, + status: "confirmed", + }), + }); + + await createEvent("token", "primary", { + summary: "기본 타임존", + startDateTime: "2026-04-16T14:00:00+09:00", + endDateTime: "2026-04-16T15:00:00+09:00", + }); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.start.timeZone).toBe("Asia/Seoul"); + expect(body.end.timeZone).toBe("Asia/Seoul"); + }); + + test("401 응답 시 인증 만료 에러를 던진다", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: () => Promise.resolve("Unauthorized"), + }); + + await expect( + createEvent("expired", "primary", { + summary: "실패", + startDateTime: "2026-04-16T14:00:00Z", + endDateTime: "2026-04-16T15:00:00Z", + }), + ).rejects.toThrow("만료"); + }); +}); diff --git a/src/lib/google/auth.ts b/src/lib/google/auth.ts new file mode 100644 index 0000000..b2bbe34 --- /dev/null +++ b/src/lib/google/auth.ts @@ -0,0 +1,189 @@ +import { cookies } from "next/headers"; +import type { GoogleTokens } from "@/types/google-calendar"; + +// ============================================================ +// Google OAuth 2.0 인증 모듈 +// ============================================================ + +const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + +// 캘린더 읽기/쓰기 + 사용자 이메일 조회 +const SCOPES = [ + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/userinfo.email", +].join(" "); + +const TOKEN_COOKIE_NAME = "google_tokens"; + +function getClientId(): string { + const id = process.env.GOOGLE_CALENDAR_CLIENT_ID; + if (!id) + throw new Error( + "GOOGLE_CALENDAR_CLIENT_ID 환경변수가 설정되지 않았습니다.", + ); + return id; +} + +function getClientSecret(): string { + const secret = process.env.GOOGLE_CALENDAR_CLIENT_SECRET; + if (!secret) + throw new Error( + "GOOGLE_CALENDAR_CLIENT_SECRET 환경변수가 설정되지 않았습니다.", + ); + return secret; +} + +function getRedirectUri(): string { + const base = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"; + return `${base}/api/google/callback`; +} + +/** + * Google OAuth 인증 URL을 생성한다. + * 사용자를 이 URL로 리다이렉트하면 Google 동의 화면이 표시된다. + */ +export function buildAuthUrl(state?: string): string { + const params = new URLSearchParams({ + client_id: getClientId(), + redirect_uri: getRedirectUri(), + response_type: "code", + scope: SCOPES, + access_type: "offline", // refresh_token 발급 + prompt: "consent", // 항상 동의 화면 표시 (refresh_token 보장) + }); + if (state) params.set("state", state); + + return `${GOOGLE_AUTH_URL}?${params.toString()}`; +} + +/** + * Authorization code를 access_token + refresh_token으로 교환한다. + */ +export async function exchangeCodeForTokens( + code: string, +): Promise { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + code, + client_id: getClientId(), + client_secret: getClientSecret(), + redirect_uri: getRedirectUri(), + grant_type: "authorization_code", + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`토큰 교환 실패 (${response.status}): ${error}`); + } + + const data = await response.json(); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +/** + * refresh_token으로 새 access_token을 발급받는다. + */ +export async function refreshAccessToken( + refreshToken: string, +): Promise<{ accessToken: string; expiresAt: number }> { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + refresh_token: refreshToken, + client_id: getClientId(), + client_secret: getClientSecret(), + grant_type: "refresh_token", + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`토큰 갱신 실패 (${response.status}): ${error}`); + } + + const data = await response.json(); + + return { + accessToken: data.access_token, + expiresAt: Date.now() + data.expires_in * 1000, + }; +} + +/** + * Google 사용자 이메일을 조회한다. + */ +export async function getUserEmail(accessToken: string): Promise { + const response = await fetch( + "https://www.googleapis.com/oauth2/v2/userinfo", + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (!response.ok) throw new Error("사용자 정보 조회 실패"); + + const data = await response.json(); + return data.email; +} + +// ============================================================ +// 쿠키 기반 토큰 저장/조회 +// Supabase DB 연결 전까지 임시 저장소로 사용 +// ============================================================ + +/** 토큰을 HttpOnly 쿠키에 저장한다. */ +export async function saveTokensToCookie(tokens: GoogleTokens): Promise { + const cookieStore = await cookies(); + cookieStore.set(TOKEN_COOKIE_NAME, JSON.stringify(tokens), { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 30 * 24 * 60 * 60, // 30일 + }); +} + +/** 쿠키에서 토큰을 읽고, 만료됐으면 자동 갱신한다. */ +export async function getValidTokens(): Promise { + const cookieStore = await cookies(); + const raw = cookieStore.get(TOKEN_COOKIE_NAME)?.value; + if (!raw) return null; + + let tokens: GoogleTokens; + try { + tokens = JSON.parse(raw); + } catch { + return null; + } + + // 만료 5분 전이면 갱신 + if (tokens.expiresAt - Date.now() < 5 * 60 * 1000) { + try { + const refreshed = await refreshAccessToken(tokens.refreshToken); + tokens = { + ...tokens, + accessToken: refreshed.accessToken, + expiresAt: refreshed.expiresAt, + }; + await saveTokensToCookie(tokens); + } catch { + return null; // 갱신 실패 → 재로그인 필요 + } + } + + return tokens; +} + +/** 토큰 쿠키를 삭제한다. */ +export async function clearTokensCookie(): Promise { + const cookieStore = await cookies(); + cookieStore.delete(TOKEN_COOKIE_NAME); +} diff --git a/src/lib/google/calendars.ts b/src/lib/google/calendars.ts new file mode 100644 index 0000000..b51748b --- /dev/null +++ b/src/lib/google/calendars.ts @@ -0,0 +1,37 @@ +import type { GoogleCalendar } from "@/types/google-calendar"; + +const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"; + +/** + * 사용자의 Google Calendar 목록을 조회한다. + */ +export async function listCalendars( + accessToken: string, +): Promise { + const response = await fetch( + `${CALENDAR_API_BASE}/users/me/calendarList?minAccessRole=writer`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (response.status === 401) { + throw new Error("Google 인증이 만료되었습니다."); + } + if (!response.ok) { + const error = await response.text(); + throw new Error(`캘린더 목록 조회 실패 (${response.status}): ${error}`); + } + + const data = await response.json(); + + return (data.items ?? []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (item: any): GoogleCalendar => ({ + id: item.id, + summary: item.summary, + description: item.description, + backgroundColor: item.backgroundColor, + primary: item.primary ?? false, + accessRole: item.accessRole, + }), + ); +} diff --git a/src/lib/google/events.ts b/src/lib/google/events.ts new file mode 100644 index 0000000..aa8efba --- /dev/null +++ b/src/lib/google/events.ts @@ -0,0 +1,109 @@ +import type { GoogleEvent, GoogleEventInput } from "@/types/google-calendar"; + +const CALENDAR_API_BASE = "https://www.googleapis.com/calendar/v3"; + +/** + * 특정 캘린더의 이벤트를 기간별로 조회한다. + */ +export async function queryEvents( + accessToken: string, + calendarId: string, + timeMin: Date, + timeMax: Date, +): Promise { + const params = new URLSearchParams({ + timeMin: timeMin.toISOString(), + timeMax: timeMax.toISOString(), + singleEvents: "true", // 반복 이벤트를 개별 인스턴스로 확장 + orderBy: "startTime", + maxResults: "250", + }); + + const response = await fetch( + `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events?${params}`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (response.status === 401) { + throw new Error("Google 인증이 만료되었습니다."); + } + if (!response.ok) { + const error = await response.text(); + throw new Error(`일정 조회 실패 (${response.status}): ${error}`); + } + + const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const items: any[] = data.items ?? []; + + return items + .filter((item) => item.status !== "cancelled") + .map( + (item): GoogleEvent => ({ + id: item.id, + summary: item.summary ?? "(제목 없음)", + description: item.description, + location: item.location, + start: item.start, + end: item.end, + status: item.status, + htmlLink: item.htmlLink, + }), + ); +} + +/** + * 캘린더에 새 이벤트를 생성한다. + */ +export async function createEvent( + accessToken: string, + calendarId: string, + input: GoogleEventInput, +): Promise { + const body = { + summary: input.summary, + description: input.description, + location: input.location, + start: { + dateTime: input.startDateTime, + timeZone: input.timeZone ?? "Asia/Seoul", + }, + end: { + dateTime: input.endDateTime, + timeZone: input.timeZone ?? "Asia/Seoul", + }, + }; + + const response = await fetch( + `${CALENDAR_API_BASE}/calendars/${encodeURIComponent(calendarId)}/events`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + ); + + if (response.status === 401) { + throw new Error("Google 인증이 만료되었습니다."); + } + if (!response.ok) { + const error = await response.text(); + throw new Error(`일정 생성 실패 (${response.status}): ${error}`); + } + + const item = await response.json(); + + return { + id: item.id, + summary: item.summary, + description: item.description, + location: item.location, + start: item.start, + end: item.end, + status: item.status, + htmlLink: item.htmlLink, + }; +} diff --git a/src/lib/ics/__tests__/builder.test.ts b/src/lib/ics/__tests__/builder.test.ts new file mode 100644 index 0000000..850e6b1 --- /dev/null +++ b/src/lib/ics/__tests__/builder.test.ts @@ -0,0 +1,129 @@ +import { describe, test, expect } from "vitest"; +import { buildIcs } from "../builder"; + +describe("buildIcs - ICS 빌더", () => { + const baseInput = { + title: "팀 미팅", + startAt: new Date("2026-04-15T10:00:00Z"), + endAt: new Date("2026-04-15T11:00:00Z"), + }; + + test("유효한 VCALENDAR/VEVENT 구조를 생성한다", () => { + const { icsContent } = buildIcs(baseInput); + + expect(icsContent).toContain("BEGIN:VCALENDAR"); + expect(icsContent).toContain("END:VCALENDAR"); + expect(icsContent).toContain("BEGIN:VEVENT"); + expect(icsContent).toContain("END:VEVENT"); + expect(icsContent).toContain("VERSION:2.0"); + expect(icsContent).toContain("PRODID:-//Moim//Moim CalDAV//KO"); + }); + + test("UID를 지정하지 않으면 자동 생성한다 (@moim.app 접미사)", () => { + const { uid, icsContent } = buildIcs(baseInput); + + expect(uid).toMatch(/@moim\.app$/); + expect(icsContent).toContain(`UID:${uid}`); + }); + + test("UID를 지정하면 해당 UID를 사용한다", () => { + const { uid, icsContent } = buildIcs({ + ...baseInput, + uid: "custom-uid@test.com", + }); + + expect(uid).toBe("custom-uid@test.com"); + expect(icsContent).toContain("UID:custom-uid@test.com"); + }); + + test("날짜를 ICS UTC 형식(YYYYMMDDTHHmmssZ)으로 변환한다", () => { + const { icsContent } = buildIcs(baseInput); + + expect(icsContent).toContain("DTSTART:20260415T100000Z"); + expect(icsContent).toContain("DTEND:20260415T110000Z"); + }); + + test("SUMMARY에 제목이 포함된다", () => { + const { icsContent } = buildIcs(baseInput); + + expect(icsContent).toContain("SUMMARY:팀 미팅"); + }); + + test("location이 있으면 LOCATION 속성을 추가한다", () => { + const { icsContent } = buildIcs({ + ...baseInput, + location: "회의실 A", + }); + + expect(icsContent).toContain("LOCATION:회의실 A"); + }); + + test("description이 있으면 DESCRIPTION 속성을 추가한다", () => { + const { icsContent } = buildIcs({ + ...baseInput, + description: "주간 회의입니다", + }); + + expect(icsContent).toContain("DESCRIPTION:주간 회의입니다"); + }); + + test("location과 description이 없으면 해당 속성을 생략한다", () => { + const { icsContent } = buildIcs(baseInput); + + expect(icsContent).not.toContain("LOCATION:"); + expect(icsContent).not.toContain("DESCRIPTION:"); + }); + + test("특수문자를 이스케이프한다 (세미콜론, 쉼표, 백슬래시, 개행)", () => { + const { icsContent } = buildIcs({ + ...baseInput, + title: "회의; 중요, 필수\\참석\n필독", + }); + + expect(icsContent).toContain("SUMMARY:회의\\; 중요\\, 필수\\\\참석\\n필독"); + }); + + test("모든 줄이 CRLF로 끝난다", () => { + const { icsContent } = buildIcs(baseInput); + const lines = icsContent.split("\r\n"); + + // 마지막은 빈 줄 (CRLF로 끝나므로 split 시 빈 문자열) + expect(lines[lines.length - 1]).toBe(""); + // LF만 사용한 줄이 없어야 한다 + expect(icsContent.replace(/\r\n/g, "")).not.toContain("\n"); + }); +}); + +describe("buildIcs - line folding", () => { + test("75바이트 이하인 줄은 접히지 않는다", () => { + const { icsContent } = buildIcs({ + ...{ + title: "Short", + startAt: new Date("2026-04-15T10:00:00Z"), + endAt: new Date("2026-04-15T11:00:00Z"), + }, + }); + + // SUMMARY:Short는 75바이트 이하 → 접히지 않아야 함 + expect(icsContent).toContain("SUMMARY:Short"); + }); + + test("75바이트를 초과하는 줄은 CRLF+공백으로 접힌다", () => { + const longTitle = "A".repeat(100); + const { icsContent } = buildIcs({ + title: longTitle, + startAt: new Date("2026-04-15T10:00:00Z"), + endAt: new Date("2026-04-15T11:00:00Z"), + }); + + // SUMMARY:AAA... 는 108바이트 → 접혀야 함 + const summaryLine = icsContent + .split("\r\n") + .find((l) => l.startsWith("SUMMARY:")); + expect(summaryLine).toBeDefined(); + // 접힌 줄의 연속행이 있어야 함 + const idx = icsContent.indexOf("SUMMARY:"); + const afterSummary = icsContent.slice(idx); + expect(afterSummary).toContain("\r\n "); + }); +}); diff --git a/src/lib/ics/__tests__/parser.test.ts b/src/lib/ics/__tests__/parser.test.ts new file mode 100644 index 0000000..b376135 --- /dev/null +++ b/src/lib/ics/__tests__/parser.test.ts @@ -0,0 +1,231 @@ +import { describe, test, expect } from "vitest"; +import { parseIcsToEvents, parseSingleIcs } from "../parser"; + +const BASIC_ICS = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:test-uid-1@moim.app +DTSTART:20260415T100000Z +DTEND:20260415T110000Z +SUMMARY:팀 미팅 +LOCATION:회의실 A +DESCRIPTION:주간 회의 +END:VEVENT +END:VCALENDAR`; + +describe("parseIcsToEvents - 기본 파싱", () => { + test("기본 VEVENT를 올바르게 파싱한다", () => { + const events = parseIcsToEvents(BASIC_ICS); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + uid: "test-uid-1@moim.app", + title: "팀 미팅", + location: "회의실 A", + description: "주간 회의", + isAllDay: false, + }); + expect(events[0].startAt).toEqual(new Date("2026-04-15T10:00:00Z")); + expect(events[0].endAt).toEqual(new Date("2026-04-15T11:00:00Z")); + }); + + test("여러 VEVENT를 포함한 ICS를 파싱한다", () => { + const multiIcs = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event-1@moim.app +DTSTART:20260415T100000Z +DTEND:20260415T110000Z +SUMMARY:이벤트 1 +END:VEVENT +BEGIN:VEVENT +UID:event-2@moim.app +DTSTART:20260416T140000Z +DTEND:20260416T150000Z +SUMMARY:이벤트 2 +END:VEVENT +END:VCALENDAR`; + + const events = parseIcsToEvents(multiIcs); + + expect(events).toHaveLength(2); + expect(events[0].uid).toBe("event-1@moim.app"); + expect(events[1].uid).toBe("event-2@moim.app"); + }); + + test("LOCATION과 DESCRIPTION이 없는 이벤트를 파싱한다", () => { + const minimalIcs = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:minimal@moim.app +DTSTART:20260415T100000Z +DTEND:20260415T110000Z +SUMMARY:최소 이벤트 +END:VEVENT +END:VCALENDAR`; + + const events = parseIcsToEvents(minimalIcs); + + expect(events).toHaveLength(1); + expect(events[0].location).toBeUndefined(); + expect(events[0].description).toBeUndefined(); + }); + + test("DTEND가 없으면 1시간 후를 기본값으로 사용한다", () => { + const noDtendIcs = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:no-end@moim.app +DTSTART:20260415T100000Z +SUMMARY:끝 시간 없음 +END:VEVENT +END:VCALENDAR`; + + const events = parseIcsToEvents(noDtendIcs); + + expect(events[0].startAt).toEqual(new Date("2026-04-15T10:00:00Z")); + expect(events[0].endAt).toEqual(new Date("2026-04-15T11:00:00Z")); + }); + + test("빈 문자열은 빈 배열을 반환한다", () => { + expect(parseIcsToEvents("")).toEqual([]); + }); + + test("VEVENT가 없는 ICS는 빈 배열을 반환한다", () => { + const noEvent = `BEGIN:VCALENDAR +VERSION:2.0 +END:VCALENDAR`; + expect(parseIcsToEvents(noEvent)).toEqual([]); + }); + + test("필수 필드(UID, SUMMARY, DTSTART)가 없으면 건너뛴다", () => { + const incomplete = `BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20260415T100000Z +SUMMARY:UID 없음 +END:VEVENT +BEGIN:VEVENT +UID:no-summary@test +DTSTART:20260415T100000Z +END:VEVENT +END:VCALENDAR`; + + const events = parseIcsToEvents(incomplete); + expect(events).toHaveLength(0); + }); +}); + +describe("parseIcsToEvents - 종일 이벤트", () => { + test("VALUE=DATE 형식의 종일 이벤트를 파싱한다", () => { + const allDayIcs = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:allday@moim.app +DTSTART;VALUE=DATE:20260415 +DTEND;VALUE=DATE:20260416 +SUMMARY:종일 이벤트 +END:VEVENT +END:VCALENDAR`; + + const events = parseIcsToEvents(allDayIcs); + + expect(events).toHaveLength(1); + expect(events[0].isAllDay).toBe(true); + expect(events[0].startAt).toEqual(new Date("2026-04-15T00:00:00Z")); + expect(events[0].endAt).toEqual(new Date("2026-04-16T00:00:00Z")); + }); +}); + +describe("parseIcsToEvents - TZID 타임존", () => { + test("TZID=Asia/Seoul 타임존을 UTC로 변환한다", () => { + const tzIcs = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:tz-test@moim.app +DTSTART;TZID=Asia/Seoul:20260415T190000 +DTEND;TZID=Asia/Seoul:20260415T200000 +SUMMARY:서울 시간 이벤트 +END:VEVENT +END:VCALENDAR`; + + const events = parseIcsToEvents(tzIcs); + + expect(events).toHaveLength(1); + // Asia/Seoul = UTC+9 → 19:00 KST = 10:00 UTC + expect(events[0].startAt).toEqual(new Date("2026-04-15T10:00:00Z")); + expect(events[0].endAt).toEqual(new Date("2026-04-15T11:00:00Z")); + }); +}); + +describe("parseIcsToEvents - line unfolding", () => { + test("CRLF+공백으로 접힌 줄을 펼친다", () => { + const foldedIcs = + "BEGIN:VCALENDAR\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:fold-test@moim.app\r\n" + + "DTSTART:20260415T100000Z\r\n" + + "DTEND:20260415T110000Z\r\n" + + "SUMMARY:접힌 제\r\n" + + " 목 테스트\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n"; + + const events = parseIcsToEvents(foldedIcs); + + expect(events).toHaveLength(1); + expect(events[0].title).toBe("접힌 제목 테스트"); + }); +}); + +describe("parseIcsToEvents - 이스케이프 처리", () => { + test("ICS 이스케이프 시퀀스를 올바르게 언이스케이프한다", () => { + const escapedIcs = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:escape@moim.app +DTSTART:20260415T100000Z +DTEND:20260415T110000Z +SUMMARY:회의\\; 중요\\, 필수\\\\참석\\n필독 +END:VEVENT +END:VCALENDAR`; + + const events = parseIcsToEvents(escapedIcs); + + expect(events[0].title).toBe("회의; 중요, 필수\\참석\n필독"); + }); +}); + +describe("parseSingleIcs", () => { + test("첫 번째 VEVENT만 반환한다", () => { + const event = parseSingleIcs(BASIC_ICS); + + expect(event).not.toBeNull(); + expect(event!.uid).toBe("test-uid-1@moim.app"); + }); + + test("VEVENT가 없으면 null을 반환한다", () => { + expect(parseSingleIcs("")).toBeNull(); + }); +}); + +describe("buildIcs → parseIcsToEvents 라운드트립", () => { + test("빌드한 ICS를 파싱하면 원본 데이터가 복원된다", async () => { + // builder는 별도 모듈이므로 동적 import + const { buildIcs } = await import("../builder"); + + const input = { + title: "라운드트립 테스트", + startAt: new Date("2026-04-15T10:00:00Z"), + endAt: new Date("2026-04-15T11:30:00Z"), + location: "서울 강남구", + description: "빌더→파서 검증", + }; + + const { uid, icsContent } = buildIcs(input); + const events = parseIcsToEvents(icsContent); + + expect(events).toHaveLength(1); + expect(events[0].uid).toBe(uid); + expect(events[0].title).toBe(input.title); + expect(events[0].startAt).toEqual(input.startAt); + expect(events[0].endAt).toEqual(input.endAt); + expect(events[0].location).toBe(input.location); + expect(events[0].description).toBe(input.description); + expect(events[0].isAllDay).toBe(false); + }); +}); diff --git a/src/lib/ics/builder.ts b/src/lib/ics/builder.ts new file mode 100644 index 0000000..06a93fa --- /dev/null +++ b/src/lib/ics/builder.ts @@ -0,0 +1,90 @@ +import { randomUUID } from "crypto"; +import type { EventInput, BuiltEvent } from "@/types/icloud"; + +// ============================================================ +// RFC 5545 ICS 빌더 +// VCALENDAR + VEVENT 생성 +// ============================================================ + +/** Date를 ICS UTC 형식(YYYYMMDDTHHmmssZ)으로 변환한다. */ +function toIcsUtc(date: Date): string { + return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; +} + +/** + * ICS 텍스트 값 이스케이프. + * RFC 5545: backslash, semicolon, comma, newline을 이스케이프한다. + */ +function escapeIcsText(text: string): string { + return text + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\n/g, "\\n"); +} + +/** + * 75바이트 초과 시 ICS line folding(CRLF + 공백)을 적용한다. + * RFC 5545 §3.1 참고. + */ +function foldLine(line: string): string { + const bytes = Buffer.byteLength(line, "utf8"); + if (bytes <= 75) return line; + + // 75 옥텟 단위로 자르기 (멀티바이트 문자 경계 고려) + const parts: string[] = []; + let remaining = line; + let first = true; + + while (Buffer.byteLength(remaining, "utf8") > 75) { + const limit = first ? 75 : 74; // 첫 줄 이후엔 앞에 공백 1자가 붙으므로 74 + let cut = limit; + // 멀티바이트 문자 중간을 자르지 않도록 조정 + while (Buffer.byteLength(remaining.slice(0, cut), "utf8") > limit) cut--; + parts.push(remaining.slice(0, cut)); + remaining = remaining.slice(cut); + first = false; + } + parts.push(remaining); + + return parts.join("\r\n "); +} + +/** + * EventInput으로부터 RFC 5545 ICS 문자열과 UID를 반환한다. + * + * 주의: 모든 시간은 UTC로 저장한다 (DTSTART:...Z 형식). + * 입력 Date 객체는 이미 UTC 기준이어야 한다. + */ +export function buildIcs(input: EventInput): BuiltEvent { + const uid = input.uid ?? `${randomUUID()}@moim.app`; + const now = toIcsUtc(new Date()); + + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Moim//Moim CalDAV//KO", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "BEGIN:VEVENT", + foldLine(`UID:${uid}`), + `DTSTAMP:${now}`, + `DTSTART:${toIcsUtc(input.startAt)}`, + `DTEND:${toIcsUtc(input.endAt)}`, + foldLine(`SUMMARY:${escapeIcsText(input.title)}`), + ]; + + if (input.location) { + lines.push(foldLine(`LOCATION:${escapeIcsText(input.location)}`)); + } + if (input.description) { + lines.push(foldLine(`DESCRIPTION:${escapeIcsText(input.description)}`)); + } + + lines.push("END:VEVENT", "END:VCALENDAR"); + + // RFC 5545: 각 줄은 CRLF로 끝나야 한다 + const icsContent = lines.join("\r\n") + "\r\n"; + + return { uid, icsContent }; +} diff --git a/src/lib/ics/parser.ts b/src/lib/ics/parser.ts new file mode 100644 index 0000000..c00b9f9 --- /dev/null +++ b/src/lib/ics/parser.ts @@ -0,0 +1,198 @@ +import type { ParsedEvent } from "@/types/icloud"; + +// ============================================================ +// 외부 의존성 없는 ICS 파서 +// RFC 5545 핵심 필드(UID, SUMMARY, DTSTART, DTEND, +// LOCATION, DESCRIPTION)를 파싱한다. +// ============================================================ + +/** ICS의 "line folding"을 풀어준다 (연속행 합치기). */ +function unfoldLines(ics: string): string { + return ics.replace(/\r?\n[ \t]/g, ""); +} + +/** + * ICS 날짜/시간 문자열을 JavaScript Date로 변환한다. + * + * 지원 형식: + * 20260414T100000Z → UTC 시간 + * 20260414T100000 → TZID 파라미터 없이 로컬 시간(UTC로 취급) + * 20260414 → 종일 이벤트 + * TZID=Asia/Seoul:20260414T100000 → 타임존 오프셋 적용 + */ +function parseIcsDate( + value: string, + params: string, +): { date: Date; isAllDay: boolean } { + // VALUE=DATE → 종일 이벤트 + if (params.includes("VALUE=DATE") || /^\d{8}$/.test(value)) { + const [year, month, day] = [ + parseInt(value.slice(0, 4)), + parseInt(value.slice(4, 6)) - 1, + parseInt(value.slice(6, 8)), + ]; + return { date: new Date(Date.UTC(year, month, day)), isAllDay: true }; + } + + // UTC 시간 (Z suffix) + if (value.endsWith("Z")) { + const iso = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(9, 11)}:${value.slice(11, 13)}:${value.slice(13, 15)}Z`; + return { date: new Date(iso), isAllDay: false }; + } + + // TZID 파라미터가 있는 경우 + const tzidMatch = params.match(/TZID=([^;:]+)/); + if (tzidMatch) { + const tzid = tzidMatch[1]; + const iso = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(9, 11)}:${value.slice(11, 13)}:${value.slice(13, 15)}`; + try { + // iso 문자열을 UTC 기준으로 파싱한 뒤 타임존 오프셋을 적용 + const tempDate = new Date(iso + "Z"); + const utcDate = convertTzToUtc(tempDate, tzid); + return { date: utcDate, isAllDay: false }; + } catch { + // 타임존 변환 실패 시 UTC로 취급 + return { date: new Date(iso + "Z"), isAllDay: false }; + } + } + + // 타임존 정보 없는 로컬 시간 (UTC로 취급) + const iso = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(9, 11)}:${value.slice(11, 13)}:${value.slice(13, 15)}Z`; + return { date: new Date(iso), isAllDay: false }; +} + +/** + * TZID 타임존의 로컬 시간을 UTC Date로 변환한다. + * Node.js 22+의 Intl.DateTimeFormat + timeZone 옵션을 활용한다. + */ +function convertTzToUtc(localDate: Date, tzid: string): Date { + // "2026-04-14T10:00:00" 형태의 문자열을 TZID 타임존으로 해석해 UTC 변환 + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone: tzid, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + timeZoneName: "shortOffset", + }); + const parts = fmt.formatToParts(localDate); + const get = (type: string) => + parts.find((p) => p.type === type)?.value ?? "0"; + + // timeZoneName에서 UTC 오프셋 추출 (예: "GMT+9" → +540분) + const tzName = parts.find((p) => p.type === "timeZoneName")?.value ?? ""; + const offsetMatch = tzName.match(/GMT([+-])(\d+)(?::(\d+))?/); + if (!offsetMatch) return localDate; + + const sign = offsetMatch[1] === "+" ? 1 : -1; + const offsetMs = + sign * + (parseInt(get("hour")) * 3600_000 - // 실제로는 오프셋만 필요 + 0); // 복잡하므로 아래 단순 계산으로 대체 + void offsetMs; + + const offsetHours = parseInt(offsetMatch[2]); + const offsetMinutes = parseInt(offsetMatch[3] ?? "0"); + const totalOffsetMs = sign * (offsetHours * 60 + offsetMinutes) * 60_000; + + return new Date(localDate.getTime() - totalOffsetMs); +} + +/** ICS 텍스트 값에서 이스케이프를 제거한다. */ +function unescapeIcsText(value: string): string { + return value + .replace(/\\n/g, "\n") + .replace(/\\;/g, ";") + .replace(/\\,/g, ",") + .replace(/\\\\/g, "\\"); +} + +/** + * ICS 문자열에서 VEVENT 블록을 파싱해 ParsedEvent 배열을 반환한다. + * 하나의 ICS 파일에 여러 VEVENT가 포함될 수 있다. + */ +export function parseIcsToEvents(icsText: string): ParsedEvent[] { + const unfolded = unfoldLines(icsText); + const lines = unfolded.split(/\r?\n/); + + const events: ParsedEvent[] = []; + let inVEvent = false; + let props: Record = {}; + + for (const line of lines) { + if (line === "BEGIN:VEVENT") { + inVEvent = true; + props = {}; + continue; + } + + if (line === "END:VEVENT") { + inVEvent = false; + const event = buildEvent(props); + if (event) events.push(event); + continue; + } + + if (!inVEvent) continue; + + // "PROPERTY;PARAM=VAL:value" 형태 파싱 + const colonIdx = line.indexOf(":"); + if (colonIdx === -1) continue; + + const keyPart = line.slice(0, colonIdx); + const value = line.slice(colonIdx + 1); + const semicolonIdx = keyPart.indexOf(";"); + + const key = semicolonIdx === -1 ? keyPart : keyPart.slice(0, semicolonIdx); + const params = semicolonIdx === -1 ? "" : keyPart.slice(semicolonIdx); + + // 같은 프로퍼티가 여러 번 나오면 첫 번째만 사용 + if (!(key in props)) { + props[key] = { value, params }; + } + } + + return events; +} + +/** props 맵으로부터 ParsedEvent를 빌드한다. */ +function buildEvent( + props: Record, +): ParsedEvent | null { + const uid = props["UID"]?.value; + const summaryRaw = props["SUMMARY"]?.value; + const dtStartProp = props["DTSTART"]; + const dtEndProp = props["DTEND"]; + + if (!uid || !summaryRaw || !dtStartProp) return null; + + const { date: startAt, isAllDay } = parseIcsDate( + dtStartProp.value, + dtStartProp.params, + ); + const { date: endAt } = dtEndProp + ? parseIcsDate(dtEndProp.value, dtEndProp.params) + : { date: new Date(startAt.getTime() + 3_600_000) }; // DTEND 없으면 1시간 후 + + return { + uid, + title: unescapeIcsText(summaryRaw), + startAt, + endAt, + isAllDay, + location: props["LOCATION"] + ? unescapeIcsText(props["LOCATION"].value) + : undefined, + description: props["DESCRIPTION"] + ? unescapeIcsText(props["DESCRIPTION"].value) + : undefined, + }; +} + +/** 단일 VEVENT 파싱 (CalDAV REPORT 응답의 개별 이벤트용). */ +export function parseSingleIcs(icsText: string): ParsedEvent | null { + return parseIcsToEvents(icsText)[0] ?? null; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..d7fd4bb --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from "@prisma/client"; + +if (process.env.NODE_ENV !== "production") { + process.env.DATABASE_URL ||= "file:./dev.db"; +} + +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; + +export const prisma = globalForPrisma.prisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} diff --git a/src/lib/schedules/__tests__/store.test.ts b/src/lib/schedules/__tests__/store.test.ts new file mode 100644 index 0000000..90e6fa9 --- /dev/null +++ b/src/lib/schedules/__tests__/store.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, test, afterEach } from "vitest"; +import type { TimeSlot } from "@/types/schedule"; +import { + addParticipantAvailability, + clearSchedules, + confirmSchedule, + createSchedule, + getScheduleForHost, + getSchedulePublic, +} from "../store"; + +describe("schedule store", () => { + afterEach(async () => { + await clearSchedules(); + }); + + test("creates an unguessable schedule id and keeps the host token out of public data", async () => { + const created = await createSchedule({ + title: "제품 회의", + durationMinutes: 60, + candidateDays: ["MON", "TUE"], + candidateStartHour: 9, + candidateEndHour: 18, + }); + + expect(created.id).toMatch(/^[A-Za-z0-9_-]{20,}$/); + expect(created.hostToken).toMatch(/^[A-Za-z0-9_-]{32,}$/); + + const publicSchedule = await getSchedulePublic(created.id); + + expect(publicSchedule).toMatchObject({ + id: created.id, + title: "제품 회의", + participantCount: 0, + }); + expect(publicSchedule).not.toHaveProperty("hostToken"); + expect(publicSchedule).not.toHaveProperty("participants"); + }); + + test("persists schedules across fresh reads", async () => { + const created = await createSchedule({ + title: "저장 확인", + durationMinutes: 45, + candidateDays: ["WED"], + candidateStartHour: 13, + candidateEndHour: 17, + }); + + const firstRead = await getSchedulePublic(created.id); + const secondRead = await getSchedulePublic(created.id); + + expect(firstRead?.title).toBe("저장 확인"); + expect(secondRead).toEqual(firstRead); + }); + + test("requires the host token before returning participant details and common slots", async () => { + const created = await createSchedule({ + title: "인터뷰 일정", + durationMinutes: 60, + candidateDays: ["MON"], + candidateStartHour: 9, + candidateEndHour: 18, + }); + + await addParticipantAvailability(created.id, { + name: "민지", + available: [{ day: "MON", startHour: 10, endHour: 14 }], + }); + await addParticipantAvailability(created.id, { + name: "준호", + available: [{ day: "MON", startHour: 12, endHour: 16 }], + }); + + expect(await getScheduleForHost(created.id, "wrong-token")).toBeNull(); + + const hostSchedule = await getScheduleForHost( + created.id, + created.hostToken, + ); + + expect(hostSchedule?.participants.map((p) => p.name)).toEqual([ + "민지", + "준호", + ]); + expect(hostSchedule?.commonSlots).toEqual([ + { day: "MON", startHour: 12, endHour: 14 }, + ]); + expect(hostSchedule).not.toHaveProperty("hostToken"); + }); + + test("rejects participant slots outside the schedule candidate window", async () => { + const created = await createSchedule({ + title: "저녁 약속", + durationMinutes: 60, + candidateDays: ["FRI"], + candidateStartHour: 18, + candidateEndHour: 22, + }); + const invalidSlot: TimeSlot = { + day: "THU", + startHour: 18, + endHour: 20, + }; + + await expect( + addParticipantAvailability(created.id, { + name: "수아", + available: [invalidSlot], + }), + ).rejects.toThrow("candidate window"); + }); + + test("rejects empty participant names", async () => { + const created = await createSchedule({ + title: "스터디", + durationMinutes: 60, + candidateDays: ["SAT"], + candidateStartHour: 10, + candidateEndHour: 14, + }); + + await expect( + addParticipantAvailability(created.id, { + name: "", + available: [{ day: "SAT", startHour: 10, endHour: 11 }], + }), + ).rejects.toThrow("name"); + }); + + test("rejects empty availability", async () => { + const created = await createSchedule({ + title: "스터디", + durationMinutes: 60, + candidateDays: ["SAT"], + candidateStartHour: 10, + candidateEndHour: 14, + }); + + await expect( + addParticipantAvailability(created.id, { + name: "하린", + available: [], + }), + ).rejects.toThrow("availability"); + }); + + test("lets the host confirm one of the common slots", async () => { + const created = await createSchedule({ + title: "주간 회의", + durationMinutes: 60, + candidateDays: ["FRI"], + candidateStartHour: 18, + candidateEndHour: 22, + }); + + await addParticipantAvailability(created.id, { + name: "민지", + available: [{ day: "FRI", startHour: 19, endHour: 21 }], + }); + await addParticipantAvailability(created.id, { + name: "준호", + available: [{ day: "FRI", startHour: 19, endHour: 22 }], + }); + + const confirmed = await confirmSchedule(created.id, created.hostToken, { + day: "FRI", + startHour: 19, + endHour: 20, + }); + + expect(confirmed.status).toBe("confirmed"); + expect(confirmed.confirmedSlot).toEqual({ + day: "FRI", + startHour: 19, + endHour: 20, + }); + expect((await getSchedulePublic(created.id))?.status).toBe("confirmed"); + }); + + test("rejects participant availability after the schedule is confirmed", async () => { + const created = await createSchedule({ + title: "confirmed schedule", + durationMinutes: 60, + candidateDays: ["FRI"], + candidateStartHour: 18, + candidateEndHour: 22, + }); + + await addParticipantAvailability(created.id, { + name: "first", + available: [{ day: "FRI", startHour: 19, endHour: 21 }], + }); + await confirmSchedule(created.id, created.hostToken, { + day: "FRI", + startHour: 19, + endHour: 20, + }); + + await expect( + addParticipantAvailability(created.id, { + name: "late", + available: [{ day: "FRI", startHour: 19, endHour: 20 }], + }), + ).rejects.toThrow("not open"); + }); + + test("rejects confirming an already confirmed schedule", async () => { + const created = await createSchedule({ + title: "single confirmation", + durationMinutes: 60, + candidateDays: ["FRI"], + candidateStartHour: 18, + candidateEndHour: 22, + }); + + await addParticipantAvailability(created.id, { + name: "first", + available: [{ day: "FRI", startHour: 19, endHour: 21 }], + }); + await confirmSchedule(created.id, created.hostToken, { + day: "FRI", + startHour: 19, + endHour: 20, + }); + + await expect( + confirmSchedule(created.id, created.hostToken, { + day: "FRI", + startHour: 19, + endHour: 20, + }), + ).rejects.toThrow("not open"); + }); + + test("rejects confirmation with an invalid host token", async () => { + const created = await createSchedule({ + title: "주간 회의", + durationMinutes: 60, + candidateDays: ["FRI"], + candidateStartHour: 18, + candidateEndHour: 22, + }); + + await expect( + confirmSchedule(created.id, "wrong-token", { + day: "FRI", + startHour: 19, + endHour: 20, + }), + ).rejects.toThrow("invalid host token"); + }); + + test("rejects confirmation outside the current common slots", async () => { + const created = await createSchedule({ + title: "주간 회의", + durationMinutes: 60, + candidateDays: ["FRI"], + candidateStartHour: 18, + candidateEndHour: 22, + }); + + await addParticipantAvailability(created.id, { + name: "민지", + available: [{ day: "FRI", startHour: 19, endHour: 20 }], + }); + await addParticipantAvailability(created.id, { + name: "준호", + available: [{ day: "FRI", startHour: 20, endHour: 21 }], + }); + + await expect( + confirmSchedule(created.id, created.hostToken, { + day: "FRI", + startHour: 19, + endHour: 20, + }), + ).rejects.toThrow("common slots"); + }); + + test("rejects schedule creation if duration exceeds candidate range", async () => { + await expect( + createSchedule({ + title: "오버플로우 일정", + durationMinutes: 120, + candidateDays: ["MON"], + candidateStartHour: 10, + candidateEndHour: 11, + }), + ).rejects.toThrow( + "durationMinutes must not exceed the candidate time range", + ); + }); +}); diff --git a/src/lib/schedules/host-cookie.ts b/src/lib/schedules/host-cookie.ts new file mode 100644 index 0000000..6b884ba --- /dev/null +++ b/src/lib/schedules/host-cookie.ts @@ -0,0 +1,5 @@ +export const HOST_TOKEN_MAX_AGE = 60 * 60 * 24 * 30; + +export function getHostTokenCookieName(scheduleId: string): string { + return `moim_host_${scheduleId}`; +} diff --git a/src/lib/schedules/store.ts b/src/lib/schedules/store.ts new file mode 100644 index 0000000..65dc7a9 --- /dev/null +++ b/src/lib/schedules/store.ts @@ -0,0 +1,525 @@ +import { createHash, randomBytes, timingSafeEqual } from "crypto"; +import { Prisma } from "@prisma/client"; +import type { + DayCode, + ParticipantAvailability, + TimeSlot, +} from "@/types/schedule"; +import { prisma } from "@/lib/prisma"; +import { findCommonSlots } from "@/lib/scheduling/availability"; +import { mergeOverlapping, sortSlots } from "@/lib/scheduling/time-slot"; + +export interface CreateScheduleInput { + title: string; + durationMinutes: number; + candidateDays: DayCode[]; + candidateStartHour: number; + candidateEndHour: number; + creatorId?: string | null; +} + +export interface AddParticipantAvailabilityInput { + name: string; + available: TimeSlot[]; +} + +export interface CreatedSchedule { + id: string; + hostToken: string; +} + +export interface PublicSchedule { + id: string; + title: string; + durationMinutes: number; + candidateDays: DayCode[]; + candidateStartHour: number; + candidateEndHour: number; + participantCount: number; + status: "open" | "confirmed"; + confirmedSlot?: TimeSlot; + createdAt: string; + creatorId?: string | null; +} + +export interface ScheduleParticipant { + id: string; + name: string; + available: TimeSlot[]; + submittedAt: string; +} + +export interface HostSchedule extends PublicSchedule { + participants: ScheduleParticipant[]; + commonSlots: TimeSlot[]; +} + +type ScheduleWithParticipants = Prisma.ScheduleGetPayload<{ + include: { participants: true }; +}>; + +type ScheduleWithParticipantCount = Prisma.ScheduleGetPayload<{ + include: { _count: { select: { participants: true } } }; +}>; + +type NormalizedScheduleInput = Omit & { + candidateDays: DayCode[]; +}; + +const VALID_DAYS: DayCode[] = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; + +export async function createSchedule( + input: CreateScheduleInput, +): Promise { + const schedule = normalizeScheduleInput(input); + const id = createToken(18); + const hostToken = createToken(32); + + await prisma.schedule.create({ + data: { + ...schedule, + id, + hostTokenHash: hashToken(hostToken), + candidateDays: JSON.stringify(schedule.candidateDays), + }, + }); + + return { id, hostToken }; +} + +export async function getSchedulePublic( + id: string, +): Promise { + const schedule = await prisma.schedule.findUnique({ + where: { id }, + include: { _count: { select: { participants: true } } }, + }); + if (!schedule) return null; + return toPublicSchedule(schedule); +} + +export async function getScheduleForHost( + id: string, + hostToken: string, +): Promise { + const schedule = await prisma.schedule.findUnique({ + where: { id }, + include: { participants: true }, + }); + if (!schedule || !tokenMatches(schedule.hostTokenHash, hostToken)) { + return null; + } + + return toHostSchedule(schedule); +} + +export async function getScheduleForCreator( + id: string, + creatorId: string, +): Promise { + const schedule = await prisma.schedule.findUnique({ + where: { id }, + include: { participants: true }, + }); + if (!schedule || schedule.creatorId !== creatorId) { + return null; + } + + return toHostSchedule(schedule); +} + +export async function addParticipantAvailability( + scheduleId: string, + input: AddParticipantAvailabilityInput, +): Promise { + const participant = await prisma.$transaction( + async (tx) => { + const schedule = await tx.schedule.findUnique({ + where: { id: scheduleId }, + }); + if (!schedule) throw new Error("schedule not found"); + if (schedule.status !== "open") { + throw new Error("schedule is not open"); + } + + const updateResult = await tx.schedule.updateMany({ + where: { id: scheduleId, status: "open" }, + data: { status: "open" }, + }); + if (updateResult.count !== 1) { + throw new Error("schedule is not open"); + } + + const normName = normalizeParticipantName(input.name); + const existing = await tx.scheduleParticipant.findFirst({ + where: { scheduleId, name: normName }, + }); + + if (existing) { + return tx.scheduleParticipant.update({ + where: { id: existing.id }, + data: { + available: JSON.stringify( + normalizeAvailability(schedule, input.available), + ), + submittedAt: new Date(), + }, + }); + } + + return tx.scheduleParticipant.create({ + data: { + id: createToken(12), + scheduleId, + name: normName, + available: JSON.stringify( + normalizeAvailability(schedule, input.available), + ), + }, + }); + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }, + ); + + return toScheduleParticipant(participant); +} + +export async function confirmSchedule( + id: string, + hostToken: string, + confirmedSlot: TimeSlot, +): Promise { + const updated = await prisma.$transaction( + async (tx) => { + const schedule = await tx.schedule.findUnique({ + where: { id }, + include: { participants: true }, + }); + if (!schedule) throw new Error("schedule not found"); + if (!tokenMatches(schedule.hostTokenHash, hostToken)) { + throw new Error("invalid host token"); + } + if (schedule.status !== "open") { + throw new Error("schedule is not open"); + } + + const normalizedSlot = normalizeConfirmedSlot(schedule, confirmedSlot); + const updateResult = await tx.schedule.updateMany({ + where: { id, status: "open" }, + data: { + status: "confirmed", + confirmedSlot: JSON.stringify(normalizedSlot), + }, + }); + if (updateResult.count !== 1) { + throw new Error("schedule is not open"); + } + + return tx.schedule.findUnique({ + where: { id }, + include: { participants: true }, + }); + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }, + ); + + if (!updated) throw new Error("schedule not found"); + return toHostSchedule(updated); +} + +export async function confirmScheduleByCreator( + id: string, + creatorId: string, + confirmedSlot: TimeSlot, +): Promise { + const updated = await prisma.$transaction( + async (tx) => { + const schedule = await tx.schedule.findUnique({ + where: { id }, + include: { participants: true }, + }); + if (!schedule) throw new Error("schedule not found"); + if (schedule.creatorId !== creatorId) { + throw new Error("invalid host token"); + } + if (schedule.status !== "open") { + throw new Error("schedule is not open"); + } + + const normalizedSlot = normalizeConfirmedSlot(schedule, confirmedSlot); + const updateResult = await tx.schedule.updateMany({ + where: { id, status: "open" }, + data: { + status: "confirmed", + confirmedSlot: JSON.stringify(normalizedSlot), + }, + }); + if (updateResult.count !== 1) { + throw new Error("schedule is not open"); + } + + return tx.schedule.findUnique({ + where: { id }, + include: { participants: true }, + }); + }, + { + isolationLevel: Prisma.TransactionIsolationLevel.Serializable, + }, + ); + + if (!updated) throw new Error("schedule not found"); + return toHostSchedule(updated); +} + +export async function clearSchedules() { + await prisma.$transaction([ + prisma.scheduleParticipant.deleteMany(), + prisma.schedule.deleteMany(), + ]); +} + +function normalizeScheduleInput( + input: CreateScheduleInput, +): NormalizedScheduleInput { + const title = input.title.trim(); + if (title.length < 2 || title.length > 80) { + throw new Error("title must be between 2 and 80 characters"); + } + + if ( + !Number.isInteger(input.durationMinutes) || + input.durationMinutes < 15 || + input.durationMinutes > 480 + ) { + throw new Error("durationMinutes must be between 15 and 480"); + } + + const candidateDays = input.candidateDays.filter( + (day, index, days) => days.indexOf(day) === index, + ); + if (candidateDays.length === 0) { + throw new Error("candidateDays must not be empty"); + } + for (const day of candidateDays) { + if (!VALID_DAYS.includes(day)) { + throw new Error("candidateDays contains an invalid day"); + } + } + + validateHourRange(input.candidateStartHour, input.candidateEndHour); + + const candidateWindowMinutes = + (input.candidateEndHour - input.candidateStartHour) * 60; + if (candidateWindowMinutes < input.durationMinutes) { + throw new Error("durationMinutes must not exceed the candidate time range"); + } + + return { + title, + durationMinutes: input.durationMinutes, + candidateDays, + candidateStartHour: input.candidateStartHour, + candidateEndHour: input.candidateEndHour, + creatorId: input.creatorId, + }; +} + +function normalizeParticipantName(name: string): string { + const trimmed = name.trim(); + if (trimmed.length < 1 || trimmed.length > 40) { + throw new Error("participant name must be between 1 and 40 characters"); + } + return trimmed; +} + +function normalizeAvailability( + schedule: { + candidateDays: string; + candidateStartHour: number; + candidateEndHour: number; + }, + available: TimeSlot[], +): TimeSlot[] { + if (available.length === 0) { + throw new Error("availability must not be empty"); + } + + const candidateDays = parseDayCodes(schedule.candidateDays); + for (const slot of available) { + validateHourRange(slot.startHour, slot.endHour); + if ( + !candidateDays.includes(slot.day) || + slot.startHour < schedule.candidateStartHour || + slot.endHour > schedule.candidateEndHour + ) { + throw new Error("availability must stay inside the candidate window"); + } + } + + return mergeOverlapping(sortSlots(available)).map((slot) => ({ ...slot })); +} + +function normalizeConfirmedSlot( + schedule: ScheduleWithParticipants, + confirmedSlot: TimeSlot, +): TimeSlot { + validateHourRange(confirmedSlot.startHour, confirmedSlot.endHour); + const normalizedSlot = { ...confirmedSlot }; + const candidateDays = parseDayCodes(schedule.candidateDays); + if ( + !candidateDays.includes(normalizedSlot.day) || + normalizedSlot.startHour < schedule.candidateStartHour || + normalizedSlot.endHour > schedule.candidateEndHour + ) { + throw new Error("confirmed slot must stay inside the candidate window"); + } + + const participants = schedule.participants.map(toScheduleParticipant); + const commonSlots = findCommonSlots( + participants.map((participant) => ({ + userId: participant.id, + available: participant.available, + })), + ); + if (!commonSlots.some((slot) => containsSlot(slot, normalizedSlot))) { + throw new Error("confirmed slot must be inside the current common slots"); + } + + return normalizedSlot; +} + +function containsSlot(container: TimeSlot, target: TimeSlot): boolean { + return ( + container.day === target.day && + container.startHour <= target.startHour && + container.endHour >= target.endHour + ); +} + +function validateHourRange(startHour: number, endHour: number) { + if ( + !Number.isInteger(startHour) || + !Number.isInteger(endHour) || + startHour < 0 || + endHour > 24 || + startHour >= endHour + ) { + throw new Error("hour range must be an integer range between 0 and 24"); + } +} + +function toPublicSchedule( + schedule: ScheduleWithParticipants | ScheduleWithParticipantCount, +): PublicSchedule { + return { + id: schedule.id, + title: schedule.title, + durationMinutes: schedule.durationMinutes, + candidateDays: parseDayCodes(schedule.candidateDays), + candidateStartHour: schedule.candidateStartHour, + candidateEndHour: schedule.candidateEndHour, + participantCount: + "participants" in schedule + ? schedule.participants.length + : schedule._count.participants, + status: schedule.status === "confirmed" ? "confirmed" : "open", + confirmedSlot: parseOptionalTimeSlot(schedule.confirmedSlot), + createdAt: schedule.createdAt.toISOString(), + creatorId: schedule.creatorId, + }; +} + +function toHostSchedule(schedule: ScheduleWithParticipants): HostSchedule { + const participants = schedule.participants.map(toScheduleParticipant); + const participantAvailability: ParticipantAvailability[] = participants.map( + (participant) => ({ + userId: participant.id, + available: participant.available, + }), + ); + + return { + ...toPublicSchedule(schedule), + participants, + commonSlots: findCommonSlots(participantAvailability), + }; +} + +function toScheduleParticipant(participant: { + id: string; + name: string; + available: string; + submittedAt: Date; +}): ScheduleParticipant { + return { + id: participant.id, + name: participant.name, + available: parseTimeSlots(participant.available), + submittedAt: participant.submittedAt.toISOString(), + }; +} + +function parseDayCodes(value: string): DayCode[] { + const parsed = parseJson(value); + if (!Array.isArray(parsed)) return []; + return parsed.filter((day): day is DayCode => + VALID_DAYS.includes(day as DayCode), + ); +} + +function parseTimeSlots(value: string): TimeSlot[] { + const parsed = parseJson(value); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((slot): slot is TimeSlot => isTimeSlot(slot)) + .map((slot) => ({ ...slot })); +} + +function parseOptionalTimeSlot(value: string | null): TimeSlot | undefined { + if (!value) return undefined; + const parsed = parseJson(value); + return isTimeSlot(parsed) ? { ...parsed } : undefined; +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +function isTimeSlot(value: unknown): value is TimeSlot { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const slot = value as Partial; + return ( + typeof slot.day === "string" && + VALID_DAYS.includes(slot.day as DayCode) && + Number.isInteger(slot.startHour) && + Number.isInteger(slot.endHour) + ); +} + +function createToken(byteLength: number): string { + return randomBytes(byteLength).toString("base64url"); +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("base64url"); +} + +function tokenMatches(expectedHash: string, receivedToken: string): boolean { + const expectedBuffer = Buffer.from(expectedHash, "base64url"); + const receivedBuffer = Buffer.from(hashToken(receivedToken), "base64url"); + return ( + expectedBuffer.length === receivedBuffer.length && + timingSafeEqual(expectedBuffer, receivedBuffer) + ); +} diff --git a/src/lib/scheduling/__tests__/availability-date.test.ts b/src/lib/scheduling/__tests__/availability-date.test.ts new file mode 100644 index 0000000..fd95d04 --- /dev/null +++ b/src/lib/scheduling/__tests__/availability-date.test.ts @@ -0,0 +1,146 @@ +import { describe, test, expect } from "vitest"; +import { findCommonDateSlots } from "../availability"; +import type { ParticipantDateAvailability } from "@/types/schedule"; + +const iso = (s: string) => new Date(s); + +const slot = (startAt: string, endAt: string) => ({ + startAt: iso(startAt), + endAt: iso(endAt), +}); + +describe("findCommonDateSlots — Date 기반 공통 가용시간", () => { + test("두 참여자의 겹치는 Date 시간대를 반환한다", () => { + const userA: ParticipantDateAvailability = { + userId: "a", + available: [slot("2026-05-10T10:00:00Z", "2026-05-10T15:00:00Z")], + }; + const userB: ParticipantDateAvailability = { + userId: "b", + available: [slot("2026-05-10T13:00:00Z", "2026-05-10T18:00:00Z")], + }; + + const result = findCommonDateSlots([userA, userB]); + + expect(result).toEqual([ + { + startAt: iso("2026-05-10T13:00:00Z"), + endAt: iso("2026-05-10T15:00:00Z"), + }, + ]); + }); + + test("durationMinutes 미만의 슬롯은 제외한다", () => { + const userA: ParticipantDateAvailability = { + userId: "a", + available: [slot("2026-05-10T10:00:00Z", "2026-05-10T11:00:00Z")], + }; + const userB: ParticipantDateAvailability = { + userId: "b", + available: [slot("2026-05-10T10:30:00Z", "2026-05-10T11:00:00Z")], + }; + + // 교집합 30분이지만 60분 회의는 불가 + const result = findCommonDateSlots([userA, userB], { + durationMinutes: 60, + }); + + expect(result).toEqual([]); + }); + + test("durationMinutes 충족 슬롯만 반환한다", () => { + const userA: ParticipantDateAvailability = { + userId: "a", + available: [slot("2026-05-10T10:00:00Z", "2026-05-10T13:00:00Z")], + }; + const userB: ParticipantDateAvailability = { + userId: "b", + available: [slot("2026-05-10T11:00:00Z", "2026-05-10T13:30:00Z")], + }; + + const result = findCommonDateSlots([userA, userB], { + durationMinutes: 90, + }); + + expect(result).toEqual([ + { + startAt: iso("2026-05-10T11:00:00Z"), + endAt: iso("2026-05-10T13:00:00Z"), + }, + ]); + }); + + test("겹치는 시간대가 없으면 빈 배열", () => { + const userA: ParticipantDateAvailability = { + userId: "a", + available: [slot("2026-05-10T09:00:00Z", "2026-05-10T11:00:00Z")], + }; + const userB: ParticipantDateAvailability = { + userId: "b", + available: [slot("2026-05-10T13:00:00Z", "2026-05-10T15:00:00Z")], + }; + + expect(findCommonDateSlots([userA, userB])).toEqual([]); + }); + + test("참여자 0명이면 빈 배열, 1명이면 본인 가용시간 그대로", () => { + expect(findCommonDateSlots([])).toEqual([]); + + const solo: ParticipantDateAvailability = { + userId: "solo", + available: [slot("2026-05-10T10:00:00Z", "2026-05-10T12:00:00Z")], + }; + expect(findCommonDateSlots([solo])).toEqual(solo.available); + }); + + test("3명 이상의 교집합", () => { + const a: ParticipantDateAvailability = { + userId: "a", + available: [slot("2026-05-10T09:00:00Z", "2026-05-10T17:00:00Z")], + }; + const b: ParticipantDateAvailability = { + userId: "b", + available: [slot("2026-05-10T12:00:00Z", "2026-05-10T20:00:00Z")], + }; + const c: ParticipantDateAvailability = { + userId: "c", + available: [slot("2026-05-10T14:00:00Z", "2026-05-10T18:00:00Z")], + }; + + const result = findCommonDateSlots([a, b, c]); + + expect(result).toEqual([ + { + startAt: iso("2026-05-10T14:00:00Z"), + endAt: iso("2026-05-10T17:00:00Z"), + }, + ]); + }); + + test("한 명이 여러 슬롯을 가진 경우도 모두 처리한다", () => { + const a: ParticipantDateAvailability = { + userId: "a", + available: [ + slot("2026-05-10T09:00:00Z", "2026-05-10T11:00:00Z"), + slot("2026-05-10T14:00:00Z", "2026-05-10T16:00:00Z"), + ], + }; + const b: ParticipantDateAvailability = { + userId: "b", + available: [slot("2026-05-10T10:00:00Z", "2026-05-10T15:00:00Z")], + }; + + const result = findCommonDateSlots([a, b]); + + expect(result).toEqual([ + { + startAt: iso("2026-05-10T10:00:00Z"), + endAt: iso("2026-05-10T11:00:00Z"), + }, + { + startAt: iso("2026-05-10T14:00:00Z"), + endAt: iso("2026-05-10T15:00:00Z"), + }, + ]); + }); +}); diff --git a/src/lib/scheduling/__tests__/availability.test.ts b/src/lib/scheduling/__tests__/availability.test.ts new file mode 100644 index 0000000..67a225c --- /dev/null +++ b/src/lib/scheduling/__tests__/availability.test.ts @@ -0,0 +1,138 @@ +/** + * findCommonSlots 테스트 + * + * TDD Red→Green→Refactor 사이클의 "Red" 단계에서 먼저 작성된 테스트. + * 이 테스트들이 실패하는 상태에서 availability.ts를 구현하여 통과시켰다. + * + * 테스트 파일이 대상 코드 바로 옆(__tests__/)에 있는 이유: + * - 관련 파일을 한눈에 볼 수 있어 유지보수가 쉬움 + * - AI에게 "이 파일 테스트 작성해줘"라고 할 때 컨텍스트 파악이 빠름 + * - pytest의 test_*.py 파일이 대상 모듈 옆에 있는 것과 같은 패턴 + */ + +import { describe, test, expect } from "vitest"; +import { findCommonSlots } from "../availability"; +import type { ParticipantAvailability } from "@/types/schedule"; + +describe("findCommonSlots - 공통 가용시간 교집합", () => { + test("두 참여자의 겹치는 시간대를 반환한다", () => { + const userA: ParticipantAvailability = { + userId: "a", + available: [{ day: "MON", startHour: 10, endHour: 15 }], + }; + const userB: ParticipantAvailability = { + userId: "b", + available: [{ day: "MON", startHour: 13, endHour: 18 }], + }; + + const result = findCommonSlots([userA, userB]); + + expect(result).toEqual([{ day: "MON", startHour: 13, endHour: 15 }]); + }); + + test("겹치는 시간이 없으면 빈 배열을 반환한다", () => { + const userA: ParticipantAvailability = { + userId: "a", + available: [{ day: "MON", startHour: 9, endHour: 12 }], + }; + const userB: ParticipantAvailability = { + userId: "b", + available: [{ day: "MON", startHour: 14, endHour: 17 }], + }; + + const result = findCommonSlots([userA, userB]); + + expect(result).toEqual([]); + }); + + test("참여자가 한 명이면 그 사람의 가용시간을 그대로 반환한다", () => { + const solo: ParticipantAvailability = { + userId: "solo", + available: [{ day: "TUE", startHour: 10, endHour: 14 }], + }; + + const result = findCommonSlots([solo]); + + expect(result).toEqual([{ day: "TUE", startHour: 10, endHour: 14 }]); + }); + + test("빈 참여자 배열이면 빈 배열을 반환한다", () => { + const result = findCommonSlots([]); + expect(result).toEqual([]); + }); + + test("세 명 이상의 교집합을 계산한다", () => { + const userA: ParticipantAvailability = { + userId: "a", + available: [{ day: "WED", startHour: 9, endHour: 17 }], + }; + const userB: ParticipantAvailability = { + userId: "b", + available: [{ day: "WED", startHour: 12, endHour: 20 }], + }; + const userC: ParticipantAvailability = { + userId: "c", + available: [{ day: "WED", startHour: 14, endHour: 18 }], + }; + + const result = findCommonSlots([userA, userB, userC]); + + // 9~17 ∩ 12~20 = 12~17, 12~17 ∩ 14~18 = 14~17 + expect(result).toEqual([{ day: "WED", startHour: 14, endHour: 17 }]); + }); + + test("서로 다른 요일은 교집합이 없다", () => { + const userA: ParticipantAvailability = { + userId: "a", + available: [{ day: "MON", startHour: 10, endHour: 15 }], + }; + const userB: ParticipantAvailability = { + userId: "b", + available: [{ day: "TUE", startHour: 10, endHour: 15 }], + }; + + const result = findCommonSlots([userA, userB]); + + expect(result).toEqual([]); + }); + + test("한 요일에 여러 슬롯이 있는 경우를 제대로 처리한다", () => { + const userA: ParticipantAvailability = { + userId: "a", + available: [ + { day: "MON", startHour: 10, endHour: 12 }, + { day: "MON", startHour: 14, endHour: 16 }, + ], + }; + const userB: ParticipantAvailability = { + userId: "b", + available: [{ day: "MON", startHour: 11, endHour: 15 }], + }; + + const result = findCommonSlots([userA, userB]); + + // A: 10~12, 14~16 + // B: 11~15 + // 교집합: 11~12, 14~15 + expect(result).toEqual([ + { day: "MON", startHour: 11, endHour: 12 }, + { day: "MON", startHour: 14, endHour: 15 }, + ]); + }); + + test("시작 시간과 종료 시간이 같은 슬롯은 교집합에서 무시하거나 빈 범위를 만든다", () => { + const userA: ParticipantAvailability = { + userId: "a", + available: [{ day: "MON", startHour: 10, endHour: 10 }], + }; + const userB: ParticipantAvailability = { + userId: "b", + available: [{ day: "MON", startHour: 9, endHour: 11 }], + }; + + const result = findCommonSlots([userA, userB]); + + // 교집합은 10~10으로 크기가 0이므로 실제 유효 슬롯이 아님 (하지만 방어 로직대로 동작) + expect(result).toEqual([]); + }); +}); diff --git a/src/lib/scheduling/__tests__/free-slots.test.ts b/src/lib/scheduling/__tests__/free-slots.test.ts new file mode 100644 index 0000000..0d90265 --- /dev/null +++ b/src/lib/scheduling/__tests__/free-slots.test.ts @@ -0,0 +1,135 @@ +import { describe, test, expect } from "vitest"; +import { busyEventsToFree, eventsToBusyRanges } from "../free-slots"; +import type { CalendarEvent, SearchWindow } from "@/types/calendar-event"; + +const iso = (s: string) => new Date(s); + +const evt = ( + startAt: string, + endAt: string, + overrides: Partial = {}, +): CalendarEvent => ({ + id: overrides.id ?? `${startAt}-${endAt}`, + title: overrides.title ?? "busy", + startAt: iso(startAt), + endAt: iso(endAt), + isAllDay: overrides.isAllDay ?? false, + source: overrides.source ?? "icloud", + ...overrides, +}); + +describe("eventsToBusyRanges — 이벤트를 busy 시간 범위로 정규화", () => { + test("창(window) 밖 이벤트는 잘라낸다", () => { + const window: SearchWindow = { + startAt: iso("2026-05-10T09:00:00Z"), + endAt: iso("2026-05-10T18:00:00Z"), + }; + const events: CalendarEvent[] = [ + evt("2026-05-10T07:00:00Z", "2026-05-10T10:00:00Z"), + evt("2026-05-10T17:00:00Z", "2026-05-10T20:00:00Z"), + evt("2026-05-11T08:00:00Z", "2026-05-11T09:00:00Z"), + ]; + + const result = eventsToBusyRanges(events, window); + + expect(result).toEqual([ + { startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T10:00:00Z") }, + { startAt: iso("2026-05-10T17:00:00Z"), endAt: iso("2026-05-10T18:00:00Z") }, + ]); + }); + + test("겹치는 busy 이벤트는 병합한다", () => { + const window: SearchWindow = { + startAt: iso("2026-05-10T00:00:00Z"), + endAt: iso("2026-05-10T23:59:59Z"), + }; + const events: CalendarEvent[] = [ + evt("2026-05-10T09:00:00Z", "2026-05-10T11:00:00Z"), + evt("2026-05-10T10:30:00Z", "2026-05-10T12:00:00Z"), + ]; + + const result = eventsToBusyRanges(events, window); + + expect(result).toEqual([ + { startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T12:00:00Z") }, + ]); + }); +}); + +describe("busyEventsToFree — busy → free 반전", () => { + test("이벤트가 없으면 윈도우 전체가 free", () => { + const window: SearchWindow = { + startAt: iso("2026-05-10T09:00:00Z"), + endAt: iso("2026-05-10T18:00:00Z"), + }; + + const result = busyEventsToFree([], window); + + expect(result).toEqual([ + { startAt: window.startAt, endAt: window.endAt }, + ]); + }); + + test("중간에 한 개의 busy 이벤트가 있으면 앞·뒤 두 개의 free 슬롯이 생긴다", () => { + const window: SearchWindow = { + startAt: iso("2026-05-10T09:00:00Z"), + endAt: iso("2026-05-10T18:00:00Z"), + }; + const events: CalendarEvent[] = [ + evt("2026-05-10T12:00:00Z", "2026-05-10T13:00:00Z"), + ]; + + const result = busyEventsToFree(events, window); + + expect(result).toEqual([ + { startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T12:00:00Z") }, + { startAt: iso("2026-05-10T13:00:00Z"), endAt: iso("2026-05-10T18:00:00Z") }, + ]); + }); + + test("이벤트가 윈도우 시작과 정확히 맞물리면 앞 슬롯은 생기지 않는다", () => { + const window: SearchWindow = { + startAt: iso("2026-05-10T09:00:00Z"), + endAt: iso("2026-05-10T18:00:00Z"), + }; + const events: CalendarEvent[] = [ + evt("2026-05-10T09:00:00Z", "2026-05-10T11:00:00Z"), + ]; + + const result = busyEventsToFree(events, window); + + expect(result).toEqual([ + { startAt: iso("2026-05-10T11:00:00Z"), endAt: iso("2026-05-10T18:00:00Z") }, + ]); + }); + + test("윈도우 전체를 덮는 busy면 free는 빈 배열", () => { + const window: SearchWindow = { + startAt: iso("2026-05-10T09:00:00Z"), + endAt: iso("2026-05-10T18:00:00Z"), + }; + const events: CalendarEvent[] = [ + evt("2026-05-10T08:00:00Z", "2026-05-10T19:00:00Z"), + ]; + + expect(busyEventsToFree(events, window)).toEqual([]); + }); + + test("연속된 busy 이벤트는 병합 후 반전된다", () => { + const window: SearchWindow = { + startAt: iso("2026-05-10T09:00:00Z"), + endAt: iso("2026-05-10T18:00:00Z"), + }; + const events: CalendarEvent[] = [ + evt("2026-05-10T10:00:00Z", "2026-05-10T11:30:00Z"), + evt("2026-05-10T11:00:00Z", "2026-05-10T12:00:00Z"), + ]; + + const result = busyEventsToFree(events, window); + + expect(result).toEqual([ + { startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T10:00:00Z") }, + { startAt: iso("2026-05-10T12:00:00Z"), endAt: iso("2026-05-10T18:00:00Z") }, + ]); + }); +}); diff --git a/src/lib/scheduling/__tests__/ics-parser.test.ts b/src/lib/scheduling/__tests__/ics-parser.test.ts new file mode 100644 index 0000000..bb4e953 --- /dev/null +++ b/src/lib/scheduling/__tests__/ics-parser.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "vitest"; +import { parseIcsToSlots } from "../ics-parser"; + +describe("parseIcsToSlots", () => { + test("converts UTC VEVENT DTSTART and DTEND into a weekday time slot", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART:20260504T090000Z", + "DTEND:20260504T110000Z", + "SUMMARY:team meeting", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); + + expect(parseIcsToSlots(ics)).toEqual([ + { day: "MON", startHour: 9, endHour: 11 }, + ]); + }); + + test("returns multiple VEVENT slots sorted by time", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART:20260506T140000Z", + "DTEND:20260506T150000Z", + "END:VEVENT", + "BEGIN:VEVENT", + "DTSTART:20260504T100000Z", + "DTEND:20260504T120000Z", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + expect(parseIcsToSlots(ics)).toEqual([ + { day: "MON", startHour: 10, endHour: 12 }, + { day: "WED", startHour: 14, endHour: 15 }, + ]); + }); + + test("converts date-only VEVENT into a full-day busy slot", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART;VALUE=DATE:20260508", + "DTEND;VALUE=DATE:20260509", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + expect(parseIcsToSlots(ics)).toEqual([ + { day: "FRI", startHour: 0, endHour: 24 }, + ]); + }); + + test("returns an empty array for empty ICS or calendars without VEVENT", () => { + expect(parseIcsToSlots("")).toEqual([]); + expect(parseIcsToSlots("BEGIN:VCALENDAR\nEND:VCALENDAR")).toEqual([]); + }); + + test("ignores VEVENT entries without a valid DTSTART", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTEND:20260504T110000Z", + "END:VEVENT", + "BEGIN:VEVENT", + "DTSTART:not-a-date", + "DTEND:20260504T110000Z", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + expect(parseIcsToSlots(ics)).toEqual([]); + }); + + test("ignores floating datetime values without UTC suffix", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART:20260504T090000", + "DTEND:20260504T110000", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + expect(parseIcsToSlots(ics)).toEqual([]); + }); + + test("ignores zero-length events", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART:20260504T090000Z", + "DTEND:20260504T090000Z", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + expect(parseIcsToSlots(ics)).toEqual([]); + }); + + test("handles folded lines", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART:20260504T000000Z", + "DTEND:20260504T020000Z", + "SUMMARY:folded", + " title", + "END:VEVENT", + "END:VCALENDAR", + ].join("\r\n"); + + expect(parseIcsToSlots(ics)).toEqual([ + { day: "MON", startHour: 0, endHour: 2 }, + ]); + }); + + test("cross-midnight VEVENT is split into daily busy slots", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART:20260504T220000Z", + "DTEND:20260505T020000Z", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + expect(parseIcsToSlots(ics)).toEqual([ + { day: "MON", startHour: 22, endHour: 24 }, + { day: "TUE", startHour: 0, endHour: 2 }, + ]); + }); + + test("rejects normalized invalid ICS dates", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART:20260230T090000Z", + "DTEND:20260230T110000Z", + "END:VEVENT", + "BEGIN:VEVENT", + "DTSTART:20260504T250000Z", + "DTEND:20260504T260000Z", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + expect(parseIcsToSlots(ics)).toEqual([]); + }); + + test("ceils endHour for non-zero minutes or seconds to prevent slot loss", () => { + const ics = [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "DTSTART:20260504T090000Z", + "DTEND:20260504T101500Z", + "END:VEVENT", + "END:VCALENDAR", + ].join("\n"); + + expect(parseIcsToSlots(ics)).toEqual([ + { day: "MON", startHour: 9, endHour: 11 }, + ]); + }); +}); diff --git a/src/lib/scheduling/__tests__/time-slot.test.ts b/src/lib/scheduling/__tests__/time-slot.test.ts new file mode 100644 index 0000000..aebb868 --- /dev/null +++ b/src/lib/scheduling/__tests__/time-slot.test.ts @@ -0,0 +1,128 @@ +import { describe, test, expect } from "vitest"; +import { + sortSlots, + mergeOverlapping, + sortDateSlots, + mergeOverlappingDateSlots, +} from "../time-slot"; +import type { TimeSlot, DateTimeSlot } from "@/types/schedule"; + +const iso = (s: string) => new Date(s); + +describe("sortSlots — 요일·시작시간 순 정렬", () => { + test("요일 우선, 같은 요일은 시작 시간 오름차순으로 정렬한다", () => { + const input: TimeSlot[] = [ + { day: "WED", startHour: 9, endHour: 11 }, + { day: "MON", startHour: 14, endHour: 16 }, + { day: "MON", startHour: 9, endHour: 12 }, + ]; + + expect(sortSlots(input)).toEqual([ + { day: "MON", startHour: 9, endHour: 12 }, + { day: "MON", startHour: 14, endHour: 16 }, + { day: "WED", startHour: 9, endHour: 11 }, + ]); + }); + + test("입력 배열을 변형하지 않는다", () => { + const input: TimeSlot[] = [ + { day: "TUE", startHour: 10, endHour: 12 }, + { day: "MON", startHour: 9, endHour: 10 }, + ]; + const snapshot = JSON.parse(JSON.stringify(input)); + + sortSlots(input); + + expect(input).toEqual(snapshot); + }); +}); + +describe("mergeOverlapping — 겹치거나 인접한 슬롯 병합", () => { + test("같은 요일에서 겹치는 슬롯은 하나로 합친다", () => { + const input: TimeSlot[] = [ + { day: "MON", startHour: 9, endHour: 12 }, + { day: "MON", startHour: 11, endHour: 14 }, + ]; + + expect(mergeOverlapping(input)).toEqual([ + { day: "MON", startHour: 9, endHour: 14 }, + ]); + }); + + test("같은 요일에서 인접한(end===start) 슬롯도 병합한다", () => { + const input: TimeSlot[] = [ + { day: "MON", startHour: 9, endHour: 11 }, + { day: "MON", startHour: 11, endHour: 13 }, + ]; + + expect(mergeOverlapping(input)).toEqual([ + { day: "MON", startHour: 9, endHour: 13 }, + ]); + }); + + test("서로 다른 요일은 병합하지 않는다", () => { + const input: TimeSlot[] = [ + { day: "MON", startHour: 9, endHour: 12 }, + { day: "TUE", startHour: 9, endHour: 12 }, + ]; + + expect(mergeOverlapping(input)).toEqual([ + { day: "MON", startHour: 9, endHour: 12 }, + { day: "TUE", startHour: 9, endHour: 12 }, + ]); + }); + + test("빈 배열은 빈 배열을 반환한다", () => { + expect(mergeOverlapping([])).toEqual([]); + }); +}); + +describe("sortDateSlots — Date 기반 정렬", () => { + test("startAt 오름차순으로 정렬한다", () => { + const input: DateTimeSlot[] = [ + { startAt: iso("2026-05-10T14:00:00Z"), endAt: iso("2026-05-10T15:00:00Z") }, + { startAt: iso("2026-05-09T10:00:00Z"), endAt: iso("2026-05-09T11:00:00Z") }, + ]; + + const result = sortDateSlots(input); + + expect(result[0].startAt.toISOString()).toBe("2026-05-09T10:00:00.000Z"); + expect(result[1].startAt.toISOString()).toBe("2026-05-10T14:00:00.000Z"); + }); +}); + +describe("mergeOverlappingDateSlots — Date 기반 병합", () => { + test("겹치는 시간 범위를 하나로 합친다", () => { + const input: DateTimeSlot[] = [ + { startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T11:00:00Z") }, + { startAt: iso("2026-05-10T10:30:00Z"), endAt: iso("2026-05-10T12:00:00Z") }, + ]; + + const result = mergeOverlappingDateSlots(input); + + expect(result).toHaveLength(1); + expect(result[0].startAt.toISOString()).toBe("2026-05-10T09:00:00.000Z"); + expect(result[0].endAt.toISOString()).toBe("2026-05-10T12:00:00.000Z"); + }); + + test("인접(end===start)한 슬롯도 병합한다", () => { + const input: DateTimeSlot[] = [ + { startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T10:00:00Z") }, + { startAt: iso("2026-05-10T10:00:00Z"), endAt: iso("2026-05-10T11:00:00Z") }, + ]; + + const result = mergeOverlappingDateSlots(input); + + expect(result).toHaveLength(1); + expect(result[0].endAt.toISOString()).toBe("2026-05-10T11:00:00.000Z"); + }); + + test("겹치지 않으면 그대로 둔다", () => { + const input: DateTimeSlot[] = [ + { startAt: iso("2026-05-10T09:00:00Z"), endAt: iso("2026-05-10T10:00:00Z") }, + { startAt: iso("2026-05-10T11:00:00Z"), endAt: iso("2026-05-10T12:00:00Z") }, + ]; + + expect(mergeOverlappingDateSlots(input)).toHaveLength(2); + }); +}); diff --git a/src/lib/scheduling/availability.ts b/src/lib/scheduling/availability.ts new file mode 100644 index 0000000..52735ba --- /dev/null +++ b/src/lib/scheduling/availability.ts @@ -0,0 +1,105 @@ +/** + * 공통 가용시간 산출 — 요일+시간 / Date 기반 두 형태 모두 지원 + * + * MOIM 서비스의 핵심 알고리즘. PRD 4.6 "통합 가용시간 산출 로직"의 구현체. + * + * 두 진입점: + * - `findCommonSlots` : 기존 `TimeSlot`(요일+정수 시간) 기반. 호환용 래퍼. + * - `findCommonDateSlots` : 절대 시각(`DateTimeSlot`) 기반. Google/iCloud/AI 등 + * 모든 신규 입력 경로의 표준 진입점. + * + * 두 함수 모두 입력 → 출력만 검증되는 순수 함수이므로 React/Next.js 환경 없이 + * 단독 테스트가 가능하다. + */ + +import type { + DateTimeSlot, + ParticipantAvailability, + ParticipantDateAvailability, + TimeSlot, +} from "@/types/schedule"; + +export function findCommonSlots( + participants: ParticipantAvailability[], +): TimeSlot[] { + if (participants.length === 0) return []; + if (participants.length === 1) return participants[0].available; + + let commonSlots = participants[0].available; + + for (let i = 1; i < participants.length; i++) { + commonSlots = intersectSlots(commonSlots, participants[i].available); + if (commonSlots.length === 0) return []; + } + + return commonSlots; +} + +function intersectSlots(slotsA: TimeSlot[], slotsB: TimeSlot[]): TimeSlot[] { + const result: TimeSlot[] = []; + + for (const a of slotsA) { + for (const b of slotsB) { + if (a.day !== b.day) continue; + + const start = Math.max(a.startHour, b.startHour); + const end = Math.min(a.endHour, b.endHour); + + if (start < end) { + result.push({ day: a.day, startHour: start, endHour: end }); + } + } + } + + return result; +} + +export interface FindCommonDateSlotsOptions { + /** 회의 최소 길이(분). 교집합이 이 길이 미만이면 결과에서 제외한다. */ + durationMinutes?: number; +} + +export function findCommonDateSlots( + participants: ParticipantDateAvailability[], + options: FindCommonDateSlotsOptions = {}, +): DateTimeSlot[] { + if (participants.length === 0) return []; + + const { durationMinutes = 0 } = options; + const minMs = durationMinutes * 60_000; + + let common = participants[0].available; + + for (let i = 1; i < participants.length; i++) { + common = intersectDateSlots(common, participants[i].available); + if (common.length === 0) return []; + } + + if (minMs > 0) { + common = common.filter( + (slot) => slot.endAt.getTime() - slot.startAt.getTime() >= minMs, + ); + } + + return common; +} + +function intersectDateSlots( + slotsA: DateTimeSlot[], + slotsB: DateTimeSlot[], +): DateTimeSlot[] { + const result: DateTimeSlot[] = []; + + for (const a of slotsA) { + for (const b of slotsB) { + const start = Math.max(a.startAt.getTime(), b.startAt.getTime()); + const end = Math.min(a.endAt.getTime(), b.endAt.getTime()); + + if (start < end) { + result.push({ startAt: new Date(start), endAt: new Date(end) }); + } + } + } + + return result; +} diff --git a/src/lib/scheduling/free-slots.ts b/src/lib/scheduling/free-slots.ts new file mode 100644 index 0000000..cecbc26 --- /dev/null +++ b/src/lib/scheduling/free-slots.ts @@ -0,0 +1,76 @@ +/** + * busy 이벤트 ↔ free 슬롯 변환 + * + * 외부 캘린더에서 가져온 일정은 일반적으로 "사용자가 점유 중(busy)"이라는 + * 의미다. 모임 가능 시간을 찾으려면 검색 윈도우에서 busy 구간을 빼야 하며, + * 본 모듈은 그 변환을 담당한다. + * + * 출력은 항상 시간 오름차순으로 정렬되어 있고, 인접한 free 구간은 병합되지 + * 않는다(busy 이벤트 사이의 경계가 그대로 노출되어야 후속 단계에서 회의 + * 길이 필터를 적용할 수 있다). + */ + +import type { + CalendarEvent, + SearchWindow, + TimeRange, +} from "@/types/calendar-event"; +import { mergeOverlappingDateSlots } from "./time-slot"; + +export function eventsToBusyRanges( + events: CalendarEvent[], + window: SearchWindow, +): TimeRange[] { + const windowStart = window.startAt.getTime(); + const windowEnd = window.endAt.getTime(); + + const clipped: TimeRange[] = []; + for (const event of events) { + const start = Math.max(event.startAt.getTime(), windowStart); + const end = Math.min(event.endAt.getTime(), windowEnd); + if (start < end) { + clipped.push({ startAt: new Date(start), endAt: new Date(end) }); + } + } + + return mergeOverlappingDateSlots(clipped); +} + +export function busyEventsToFree( + events: CalendarEvent[], + window: SearchWindow, +): TimeRange[] { + const busy = eventsToBusyRanges(events, window); + if (busy.length === 0) { + return [ + { + startAt: new Date(window.startAt.getTime()), + endAt: new Date(window.endAt.getTime()), + }, + ]; + } + + const free: TimeRange[] = []; + let cursor = window.startAt.getTime(); + const windowEnd = window.endAt.getTime(); + + for (const range of busy) { + const rangeStart = range.startAt.getTime(); + if (cursor < rangeStart) { + free.push({ + startAt: new Date(cursor), + endAt: new Date(rangeStart), + }); + } + cursor = Math.max(cursor, range.endAt.getTime()); + } + + if (cursor < windowEnd) { + free.push({ + startAt: new Date(cursor), + endAt: new Date(windowEnd), + }); + } + + return free; +} diff --git a/src/lib/scheduling/ics-parser.ts b/src/lib/scheduling/ics-parser.ts new file mode 100644 index 0000000..336ea23 --- /dev/null +++ b/src/lib/scheduling/ics-parser.ts @@ -0,0 +1,195 @@ +import type { TimeSlot } from "@/types/schedule"; +import { sortSlots } from "./time-slot"; + +/** + * .ics 파일 텍스트에서 VEVENT의 DTSTART/DTEND를 busy TimeSlot 배열로 변환한다. + * + * 현재 MVP는 단일 VEVENT와 종일 이벤트를 지원한다. 반복 일정(RRULE)은 후속 + * 이슈에서 별도 확장한다. + */ +export function parseIcsToSlots(icsContent: string): TimeSlot[] { + return sortSlots(extractEvents(icsContent).flatMap(eventToSlots)); +} + +interface IcsEvent { + start?: IcsDate; + end?: IcsDate; +} + +interface IcsDate { + date: Date; + isDateOnly: boolean; +} + +function extractEvents(icsContent: string): IcsEvent[] { + const lines = unfoldLines(icsContent); + const events: IcsEvent[] = []; + let current: IcsEvent | null = null; + + for (const line of lines) { + if (line === "BEGIN:VEVENT") { + current = {}; + continue; + } + + if (line === "END:VEVENT") { + if (current) events.push(current); + current = null; + continue; + } + + if (!current) continue; + + const separatorIndex = line.indexOf(":"); + if (separatorIndex < 0) continue; + + const key = line.slice(0, separatorIndex).split(";")[0]; + const value = line.slice(separatorIndex + 1); + + if (key === "DTSTART") current.start = parseIcsDate(value); + if (key === "DTEND") current.end = parseIcsDate(value); + } + + return events; +} + +function unfoldLines(icsContent: string): string[] { + return icsContent + .replace(/\r?\n[ \t]/g, "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function eventToSlots(event: IcsEvent): TimeSlot[] { + if (!event.start || !event.end) return []; + + const { start, end } = event; + if (end.date <= start.date) return []; + + const slots: TimeSlot[] = []; + let currentDay = startOfUtcDay(start.date); + const finalDay = startOfUtcDay(end.date); + + while (currentDay <= finalDay) { + const isFirstDay = isSameUtcDay(currentDay, start.date); + const isLastDay = isSameUtcDay(currentDay, end.date); + const startHour = + isFirstDay && !start.isDateOnly ? start.date.getUTCHours() : 0; + const endHour = isLastDay + ? end.isDateOnly + ? 0 + : end.date.getUTCMinutes() > 0 || end.date.getUTCSeconds() > 0 + ? end.date.getUTCHours() + 1 + : end.date.getUTCHours() + : 24; + + if (startHour < endHour) { + slots.push({ + day: toDayCode(currentDay.getUTCDay()), + startHour, + endHour, + }); + } + + currentDay = addUtcDays(currentDay, 1); + } + + return slots; +} + +function parseIcsDate(value: string): IcsDate | undefined { + if (/^\d{8}$/.test(value)) { + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(4, 6)); + const day = Number(value.slice(6, 8)); + const date = createUtcDate(year, month, day); + if (!date) return undefined; + + return { + date, + isDateOnly: true, + }; + } + + const match = value.match( + /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/, + ); + if (!match) return undefined; + + const [, year, month, day, hour, minute, second, utcSuffix] = match; + if (!utcSuffix) return undefined; + const date = createUtcDate( + Number(year), + Number(month), + Number(day), + Number(hour), + Number(minute), + Number(second), + ); + if (!date) return undefined; + + return { + date, + isDateOnly: false, + }; +} + +function createUtcDate( + year: number, + month: number, + day: number, + hour = 0, + minute = 0, + second = 0, +): Date | undefined { + const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second + ) { + return undefined; + } + return date; +} + +function startOfUtcDay(date: Date): Date { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); +} + +function isSameUtcDay(left: Date, right: Date): boolean { + return ( + left.getUTCFullYear() === right.getUTCFullYear() && + left.getUTCMonth() === right.getUTCMonth() && + left.getUTCDate() === right.getUTCDate() + ); +} + +function addUtcDays(date: Date, days: number): Date { + return new Date( + Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate() + days, + ), + ); +} + +function toDayCode(day: number): TimeSlot["day"] { + const days: TimeSlot["day"][] = [ + "SUN", + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + ]; + return days[day]; +} diff --git a/src/lib/scheduling/time-slot.ts b/src/lib/scheduling/time-slot.ts new file mode 100644 index 0000000..0b30a95 --- /dev/null +++ b/src/lib/scheduling/time-slot.ts @@ -0,0 +1,74 @@ +/** + * 시간 슬롯 유틸리티 — 정렬과 병합 + * + * 요일+정수 시간(`TimeSlot`)과 절대 시각(`DateTimeSlot`) 두 형태 모두를 지원한다. + * 두 표현 사이의 변환은 어댑터 계층(`@/lib/calendar/adapters`)에서 수행하며, + * 본 모듈은 입력 형태를 그대로 보존한 채 정렬·병합만 책임진다. + */ + +import type { DayCode, DateTimeSlot, TimeSlot } from "@/types/schedule"; + +const DAY_ORDER: Record = { + MON: 0, + TUE: 1, + WED: 2, + THU: 3, + FRI: 4, + SAT: 5, + SUN: 6, +}; + +export function sortSlots(slots: TimeSlot[]): TimeSlot[] { + return [...slots].sort((a, b) => { + const dayDiff = DAY_ORDER[a.day] - DAY_ORDER[b.day]; + if (dayDiff !== 0) return dayDiff; + return a.startHour - b.startHour; + }); +} + +export function mergeOverlapping(slots: TimeSlot[]): TimeSlot[] { + if (slots.length === 0) return []; + + const sorted = sortSlots(slots); + const result: TimeSlot[] = []; + + for (const slot of sorted) { + const last = result[result.length - 1]; + if (last && last.day === slot.day && slot.startHour <= last.endHour) { + last.endHour = Math.max(last.endHour, slot.endHour); + } else { + result.push({ ...slot }); + } + } + + return result; +} + +export function sortDateSlots(slots: DateTimeSlot[]): DateTimeSlot[] { + return [...slots].sort((a, b) => a.startAt.getTime() - b.startAt.getTime()); +} + +export function mergeOverlappingDateSlots( + slots: DateTimeSlot[], +): DateTimeSlot[] { + if (slots.length === 0) return []; + + const sorted = sortDateSlots(slots); + const result: DateTimeSlot[] = []; + + for (const slot of sorted) { + const last = result[result.length - 1]; + if (last && slot.startAt.getTime() <= last.endAt.getTime()) { + if (slot.endAt.getTime() > last.endAt.getTime()) { + last.endAt = new Date(slot.endAt.getTime()); + } + } else { + result.push({ + startAt: new Date(slot.startAt.getTime()), + endAt: new Date(slot.endAt.getTime()), + }); + } + } + + return result; +} diff --git a/src/lib/supabase/__tests__/supabase.test.ts b/src/lib/supabase/__tests__/supabase.test.ts new file mode 100644 index 0000000..907d52c --- /dev/null +++ b/src/lib/supabase/__tests__/supabase.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createClient as createBrowserClient } from "../client"; +import { createClient as createServerClient } from "../server"; + +// Next.js cookies API 모킹 +vi.mock("next/headers", () => ({ + cookies: () => ({ + getAll: vi.fn().mockReturnValue([]), + set: vi.fn(), + }), +})); + +describe("Supabase 클라이언트 인스턴스 생성 검증", () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { + ...originalEnv, + NEXT_PUBLIC_SUPABASE_URL: "https://example-project.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "test-anon-key", + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("createBrowserClient가 정상적으로 Supabase 브라우저 클라이언트를 반환하는지 테스트", () => { + const client = createBrowserClient(); + expect(client).toBeDefined(); + expect(client.auth).toBeDefined(); + }); + + it("createServerClient가 정상적으로 Supabase 서버 클라이언트를 반환하는지 테스트", async () => { + const client = await createServerClient(); + expect(client).toBeDefined(); + expect(client.auth).toBeDefined(); + }); + + it("Supabase URL 환경변수가 없으면 명확한 에러를 던진다", () => { + delete process.env.NEXT_PUBLIC_SUPABASE_URL; + + expect(() => createBrowserClient()).toThrow("NEXT_PUBLIC_SUPABASE_URL"); + }); + + it("Supabase URL 환경변수가 공백이면 명확한 에러를 던진다", () => { + process.env.NEXT_PUBLIC_SUPABASE_URL = " "; + + expect(() => createBrowserClient()).toThrow("NEXT_PUBLIC_SUPABASE_URL"); + }); + + it("Supabase anon key 환경변수가 없으면 명확한 에러를 던진다", () => { + delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + expect(() => createBrowserClient()).toThrow( + "NEXT_PUBLIC_SUPABASE_ANON_KEY", + ); + }); + + it("Supabase anon key 환경변수가 공백이면 명확한 에러를 던진다", () => { + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = " "; + + expect(() => createBrowserClient()).toThrow( + "NEXT_PUBLIC_SUPABASE_ANON_KEY", + ); + }); + + it("createServerClient도 Supabase URL 누락을 같은 계약으로 검증한다", async () => { + delete process.env.NEXT_PUBLIC_SUPABASE_URL; + + await expect(createServerClient()).rejects.toThrow( + "NEXT_PUBLIC_SUPABASE_URL", + ); + }); + + it("createServerClient도 Supabase URL 공백을 같은 계약으로 검증한다", async () => { + process.env.NEXT_PUBLIC_SUPABASE_URL = " "; + + await expect(createServerClient()).rejects.toThrow( + "NEXT_PUBLIC_SUPABASE_URL", + ); + }); + + it("createServerClient도 Supabase anon key 누락을 같은 계약으로 검증한다", async () => { + delete process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + + await expect(createServerClient()).rejects.toThrow( + "NEXT_PUBLIC_SUPABASE_ANON_KEY", + ); + }); + + it("createServerClient도 Supabase anon key 공백을 같은 계약으로 검증한다", async () => { + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = " "; + + await expect(createServerClient()).rejects.toThrow( + "NEXT_PUBLIC_SUPABASE_ANON_KEY", + ); + }); +}); diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts new file mode 100644 index 0000000..53351ae --- /dev/null +++ b/src/lib/supabase/admin.ts @@ -0,0 +1,33 @@ +/** + * Supabase 관리자(service role) 클라이언트 + * + * service_role 키를 사용하므로 RLS를 우회한다. 절대 클라이언트(브라우저)로 + * 노출하면 안 되며, 서버 사이드(Route Handler)에서만 사용한다. + * + * 용도: + * - 로그인 시 닉네임 → 이메일 조회 (세션 없이 profiles 읽기) + * - 그 외 인증 부수 처리 + */ + +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +let cached: SupabaseClient | null = null; + +export function createAdminClient(): SupabaseClient { + if (cached) return cached; + + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!url || !serviceRoleKey) { + throw new Error( + "Supabase 관리자 클라이언트 설정이 누락되었습니다 (NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY).", + ); + } + + cached = createClient(url, serviceRoleKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); + + return cached; +} diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts new file mode 100644 index 0000000..cd25fa7 --- /dev/null +++ b/src/lib/supabase/client.ts @@ -0,0 +1,13 @@ +/** + * Supabase 브라우저 클라이언트. + * + * React 클라이언트 컴포넌트에서 현재 사용자 세션과 Supabase API에 접근할 때 사용한다. + */ + +import { createBrowserClient } from "@supabase/ssr"; +import { getSupabaseConfig } from "./env"; + +export function createClient() { + const { url, anonKey } = getSupabaseConfig(); + return createBrowserClient(url, anonKey); +} diff --git a/src/lib/supabase/env.ts b/src/lib/supabase/env.ts new file mode 100644 index 0000000..dd67b43 --- /dev/null +++ b/src/lib/supabase/env.ts @@ -0,0 +1,21 @@ +export interface SupabaseConfig { + url: string; + anonKey: string; +} + +export function getSupabaseConfig(): SupabaseConfig { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim(); + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim(); + + if (!url) { + throw new Error("NEXT_PUBLIC_SUPABASE_URL 환경변수가 설정되지 않았습니다."); + } + + if (!anonKey) { + throw new Error( + "NEXT_PUBLIC_SUPABASE_ANON_KEY 환경변수가 설정되지 않았습니다.", + ); + } + + return { url, anonKey }; +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts new file mode 100644 index 0000000..c06ea92 --- /dev/null +++ b/src/lib/supabase/server.ts @@ -0,0 +1,113 @@ +/** + * Supabase 서버 클라이언트. + * + * Server Components와 API Routes에서 쿠키 기반 사용자 세션을 유지하며 Supabase에 + * 접근할 때 사용한다. + */ + +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { getSupabaseConfig } from "./env"; + +export async function createClient() { + const cookieStore = await cookies(); + const { url, anonKey } = getSupabaseConfig(); + + const client = createServerClient(url, anonKey, { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options), + ); + } catch { + // The `setAll` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + }); + + if ( + process.env.E2E_TEST === "true" || + process.env.NODE_ENV === "development" + ) { + const mockUid = cookieStore.get("e2e_mock_uid")?.value; + const mockEmail = cookieStore.get("e2e_mock_email")?.value; + const mockNickname = + cookieStore.get("e2e_mock_nickname")?.value || "e2e_user"; + + if (process.env.E2E_TEST === "true" || (mockUid && mockEmail)) { + const authMock = { + getUser: async () => { + if (!mockEmail || !mockUid) { + return { + data: { user: null }, + error: new Error("No E2E mock session"), + }; + } + return { + data: { + user: { + id: mockUid, + email: mockEmail, + user_metadata: { nickname: mockNickname }, + }, + }, + error: null, + }; + }, + signUp: async ({ + email, + options, + }: { + email: string; + options?: { data?: Record }; + }) => { + const uid = `e2e_uid_${Date.now()}`; + return { + data: { + user: { + id: uid, + email, + user_metadata: options?.data || {}, + }, + }, + error: null, + }; + }, + signInWithPassword: async ({ email }: { email: string }) => { + return { + data: { + user: { + id: mockUid, + email, + user_metadata: { nickname: mockNickname }, + }, + session: { access_token: "mock_jwt_token" }, + }, + error: null, + }; + }, + signOut: async () => { + return { error: null }; + }, + }; + + return new Proxy(client, { + get(target, prop, receiver) { + if (prop === "auth") { + return authMock; + } + return Reflect.get(target, prop, receiver); + }, + }); + } + } + + return client; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391..a5ef193 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/src/middleware.ts b/src/middleware.ts index 9a079bc..9fabd81 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,8 +1,78 @@ -import { type NextRequest } from "next/server"; -import { updateSession } from "@/shared/lib/supabase/middleware"; +import { createServerClient } from "@supabase/ssr"; +import { NextResponse, type NextRequest } from "next/server"; +import { getSupabaseConfig } from "@/lib/supabase/env"; export async function middleware(request: NextRequest) { - return await updateSession(request); + const { url, anonKey } = getSupabaseConfig(); + let supabaseResponse = NextResponse.next({ + request, + }); + + const supabase = createServerClient(url, anonKey, { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => + request.cookies.set(name, value), + ); + supabaseResponse = NextResponse.next({ + request, + }); + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options), + ); + }, + }, + }); + + // This will refresh session if expired + let hasSupabaseUser = false; + const mockUid = request.cookies.get("e2e_mock_uid")?.value; + const mockEmail = request.cookies.get("e2e_mock_email")?.value; + + if ( + process.env.E2E_TEST === "true" || + (process.env.NODE_ENV === "development" && mockUid && mockEmail) + ) { + hasSupabaseUser = !!mockUid && !!mockEmail; + } else { + const supabaseUserRes = await supabase.auth.getUser(); + hasSupabaseUser = !!supabaseUserRes.data.user; + } + + // 보호 대상 경로 리스트 + const PROTECTED_ROUTES = ["/schedule/create", "/calendar/connect"]; + const isProtectedRoute = PROTECTED_ROUTES.some((route) => + request.nextUrl.pathname.startsWith(route), + ); + + if (isProtectedRoute) { + if (!hasSupabaseUser) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("next", request.nextUrl.pathname); + + const redirectResponse = NextResponse.redirect(loginUrl); + + // Supabase 쿠키 세팅이 중간에 일어났다면 리다이렉트 헤더에 전파 + supabaseResponse.cookies.getAll().forEach((cookie) => { + redirectResponse.cookies.set(cookie.name, cookie.value, { + path: cookie.path, + domain: cookie.domain, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + expires: cookie.expires, + maxAge: cookie.maxAge, + }); + }); + + return redirectResponse; + } + } + + return supabaseResponse; } export const config = { diff --git a/src/shared/lib/supabase/client.ts b/src/shared/lib/supabase/client.ts deleted file mode 100644 index 9f2891b..0000000 --- a/src/shared/lib/supabase/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createBrowserClient } from "@supabase/ssr"; - -export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ); -} diff --git a/src/shared/lib/supabase/middleware.ts b/src/shared/lib/supabase/middleware.ts deleted file mode 100644 index ac58b73..0000000 --- a/src/shared/lib/supabase/middleware.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { createServerClient } from "@supabase/ssr"; -import { NextResponse, type NextRequest } from "next/server"; - -export async function updateSession(request: NextRequest) { - let supabaseResponse = NextResponse.next({ - request, - }); - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return request.cookies.getAll(); - }, - setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value }) => - request.cookies.set(name, value) - ); - supabaseResponse = NextResponse.next({ - request, - }); - cookiesToSet.forEach(({ name, value, options }) => - supabaseResponse.cookies.set(name, value, options) - ); - }, - }, - } - ); - - // Do not run code between createServerClient and supabase.auth.getUser(). - // A simple mistake could make it very hard to debug issues with users being - // randomly logged out. - await supabase.auth.getUser(); - - return supabaseResponse; -} diff --git a/src/shared/lib/supabase/server.ts b/src/shared/lib/supabase/server.ts deleted file mode 100644 index 07ce742..0000000 --- a/src/shared/lib/supabase/server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { createServerClient } from "@supabase/ssr"; -import { cookies } from "next/headers"; - -export async function createClient() { - const cookieStore = await cookies(); - - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - getAll() { - return cookieStore.getAll(); - }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set(name, value, options) - ); - } catch { - // The `setAll` method was called from a Server Component. - // This can be ignored if you have middleware refreshing user sessions. - } - }, - }, - } - ); -} diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..687810d --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,9 @@ +// test-setup.ts +// 모든 테스트 파일 실행 전에 자동으로 로드되는 전역 설정 +// +// @testing-library/jest-dom은 DOM 요소에 대한 커스텀 매칭 함수를 제공한다. +// 예: expect(element).toBeInTheDocument() +// expect(element).toHaveTextContent('hello') +// +// Python으로 비유하면 conftest.py에서 공통 fixture를 설정하는 것과 같다. +import "@testing-library/jest-dom"; diff --git a/src/types/calendar-event.ts b/src/types/calendar-event.ts new file mode 100644 index 0000000..56aee6a --- /dev/null +++ b/src/types/calendar-event.ts @@ -0,0 +1,47 @@ +/** + * Provider-agnostic 캘린더 이벤트 타입 + * + * iCloud(`ParsedEvent`), Google(`GoogleEvent`), 수동 입력, AI 사진 추출 등 + * 모든 입력 경로의 출력이 이 타입으로 통일된다. 가용시간 산출 로직(`free-slots`, + * `availability`)은 provider 구분 없이 이 타입만 다룬다. + */ + +/** 이벤트 출처 — 추후 디버깅·UI 라벨링·신뢰도 가중치에 사용 */ +export type CalendarEventSource = + | "icloud" + | "google" + | "manual" + | "photo" + | "ics"; + +/** + * 표준 캘린더 이벤트 + * + * `startAt`/`endAt`은 항상 절대 시각(`Date`). 시간대는 변환 시점에 해석되며 + * 여기서는 보존하지 않는다(필요 시 `metadata`에 둔다). + */ +export interface CalendarEvent { + /** provider별 고유 ID와 별개로, 시스템 내부에서 유일하게 식별하는 키 */ + id: string; + title: string; + startAt: Date; + endAt: Date; + isAllDay: boolean; + source: CalendarEventSource; + location?: string; + description?: string; + /** 원본 provider ID (디버깅·중복 제거용) */ + externalId?: string; +} + +/** 절대 시각 기반 시간 범위 — busy 또는 free 슬롯 모두 표현 */ +export interface TimeRange { + startAt: Date; + endAt: Date; +} + +/** 가용시간 계산 대상이 되는 검색 윈도우 (예: 호스트가 지정한 후보 기간) */ +export interface SearchWindow { + startAt: Date; + endAt: Date; +} diff --git a/src/types/everytime.ts b/src/types/everytime.ts new file mode 100644 index 0000000..dec8d11 --- /dev/null +++ b/src/types/everytime.ts @@ -0,0 +1,37 @@ +/** 에브리타임 로그인 자격증명 — 서버 사이드에서만 사용 */ +export interface EverytimeCredentials { + id: string; + password: string; +} + +/** 로그인 성공 후 받는 세션 정보 */ +export interface EverytimeSession { + token: string; + userIdx: string; +} + +/** + * 강의 시간 (분 단위) + * + * 에브리타임 API가 반환하는 start/end 값은 자정 기준 분. + * 예: startMinute=540 → 09:00, endMinute=630 → 10:30 + */ +export interface EverytimeLectureTime { + /** 0=월, 1=화, 2=수, 3=목, 4=금, 5=토, 6=일 */ + day: 0 | 1 | 2 | 3 | 4 | 5 | 6; + /** 자정 기준 분 (0–1439). startMinute < endMinute 보장. */ + startMinute: number; + /** 자정 기준 분 (1–1440). startMinute < endMinute 보장. */ + endMinute: number; +} + +/** 강의 단건 */ +export interface EverytimeLecture { + name: string; + times: EverytimeLectureTime[]; +} + +/** 한 학기의 시간표 전체 */ +export interface EverytimeTimetable { + lectures: EverytimeLecture[]; +} diff --git a/src/types/google-calendar.ts b/src/types/google-calendar.ts new file mode 100644 index 0000000..0899434 --- /dev/null +++ b/src/types/google-calendar.ts @@ -0,0 +1,72 @@ +// ============================================================ +// Google Calendar 연동 도메인 타입 +// ============================================================ + +/** Google OAuth 토큰 */ +export interface GoogleTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; // Unix timestamp (ms) +} + +/** Google Calendar 목록 항목 */ +export interface GoogleCalendar { + id: string; + summary: string; + description?: string; + backgroundColor?: string; + primary?: boolean; + accessRole: string; +} + +/** Google Calendar 이벤트 */ +export interface GoogleEvent { + id: string; + summary: string; + description?: string; + location?: string; + start: { + dateTime?: string; // ISO 8601 + date?: string; // YYYY-MM-DD (종일 이벤트) + timeZone?: string; + }; + end: { + dateTime?: string; + date?: string; + timeZone?: string; + }; + status: string; + htmlLink?: string; +} + +/** 일정 생성 입력 */ +export interface GoogleEventInput { + summary: string; + description?: string; + location?: string; + startDateTime: string; // ISO 8601 + endDateTime: string; // ISO 8601 + timeZone?: string; +} + +/** Google Calendar API 에러 응답 */ +export interface GoogleApiError { + error: { + code: number; + message: string; + status: string; + }; +} + +/** DB의 google_connections 행 */ +export interface GoogleConnectionRow { + id: string; + profile_id: string; + google_email: string; + encrypted_refresh_token: string; + encryption_iv: string; + is_active: boolean; + last_synced_at: string | null; + created_at: string; + updated_at: string; +} diff --git a/src/types/icloud.ts b/src/types/icloud.ts new file mode 100644 index 0000000..499d0af --- /dev/null +++ b/src/types/icloud.ts @@ -0,0 +1,64 @@ +// ============================================================ +// iCloud CalDAV 도메인 공통 타입 +// ============================================================ + +/** CalDAV 인증 자격증명 */ +export interface CalDAVAuth { + username: string; + password: string; +} + +/** CalDAV discovery 결과 */ +export interface DiscoveryResult { + principalUrl: string; + calendarHomeUrl: string; + calendars: CalendarInfo[]; +} + +/** 캘린더 컬렉션 정보 */ +export interface CalendarInfo { + url: string; + displayName: string; + color?: string; + ctag?: string; +} + +/** CalDAV 서버에서 받아온 raw 이벤트 데이터 */ +export interface RawEventData { + href: string; + etag: string; + icsData: string; +} + +/** ICS에서 파싱된 이벤트 */ +export interface ParsedEvent { + uid: string; + title: string; + startAt: Date; + endAt: Date; + location?: string; + description?: string; + isAllDay: boolean; +} + +/** 일정 생성 입력 */ +export interface EventInput { + title: string; + startAt: Date; + endAt: Date; + location?: string; + description?: string; + uid?: string; +} + +/** ICS 빌드 결과 */ +export interface BuiltEvent { + uid: string; + icsContent: string; +} + +/** PUT 일정 생성 결과 */ +export interface CreateEventResult { + href: string; + etag: string; +} diff --git a/src/types/schedule.ts b/src/types/schedule.ts new file mode 100644 index 0000000..d189ab8 --- /dev/null +++ b/src/types/schedule.ts @@ -0,0 +1,77 @@ +/** + * 스케줄링 도메인 타입 정의 + * + * TDD에서 가장 먼저 작성하는 것이 타입(인터페이스)이다. + * "이 시스템이 어떤 데이터를 다루는가?"를 코드로 선언하면, + * 테스트와 구현 모두 이 타입을 기준으로 작성할 수 있다. + * + * Python의 Pydantic 모델이나 dataclass와 같은 역할. + */ + +/** 요일 코드 */ +export type DayCode = "MON" | "TUE" | "WED" | "THU" | "FRI" | "SAT" | "SUN"; + +/** + * 시간 슬롯 — 하루 중 특정 시간대를 나타냄 + * + * 예: { day: 'MON', startHour: 10, endHour: 15 } + * → 월요일 10시~15시 + */ +export interface TimeSlot { + day: DayCode; + startHour: number; // 0-23 + endHour: number; // 0-23, startHour보다 커야 함 +} + +/** + * 참여자의 가용시간 정보 + * + * 각 참여자가 "나는 이 시간에 가능합니다"라고 입력한 결과. + * 캘린더 연동, 수동 입력, 이미지 분석 등 모든 입력 방식의 + * 최종 출력이 이 형태로 통일된다. + */ +export interface ParticipantAvailability { + userId: string; + available: TimeSlot[]; +} + +/** + * 절대 시각(Date) 기반 시간 슬롯 + * + * 외부 캘린더(Google/iCloud)와 AI 추출 결과는 모두 실제 Date를 갖는다. + * 요일+정수 시간(`TimeSlot`)으로 환원하면 30분 단위·시간대(TZ)·날짜 정보가 + * 손실되므로, Date 기반 파이프라인에서는 본 타입을 사용한다. + */ +export interface DateTimeSlot { + startAt: Date; + endAt: Date; +} + +/** + * Date 기반 참여자 가용시간 + * + * `ParticipantAvailability`의 Date 버전. 두 형태는 어댑터/유틸을 통해 상호 변환한다. + */ +export interface ParticipantDateAvailability { + userId: string; + available: DateTimeSlot[]; +} + +/** + * 스케줄링 세션 — 호스트가 생성하는 "일정 잡기" + * + * PRD 3.1의 "일정 잡기 생성" 결과물. + */ +export interface ScheduleSession { + id: string; + title: string; + hostId: string; + durationMinutes: number; // 예상 소요시간 + candidateDays: DayCode[]; // 후보 요일 + candidateStartHour: number; // 후보 시간 범위 시작 + candidateEndHour: number; // 후보 시간 범위 끝 + participants: ParticipantAvailability[]; + status: "open" | "confirmed" | "cancelled"; + confirmedSlot?: TimeSlot; // 확정된 시간 (확정 후) + createdAt: string; +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..8130746 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,18 @@ +/** + * 사용자/인증 관련 타입 정의 + * + * PRD 3.1의 소셜 로그인(카카오/구글) 결과 사용자 정보. + */ + +/** 소셜 로그인 제공자 */ +export type AuthProvider = "kakao" | "google" | "apple" | "naver"; + +/** 사용자 프로필 */ +export interface User { + id: string; + email: string; + displayName: string; + avatarUrl?: string; + provider: AuthProvider; + createdAt: string; +} diff --git a/supabase/migrations/20260406000000_initial_schema.sql b/supabase/migrations/20260406000000_initial_schema.sql deleted file mode 100644 index 68715a7..0000000 --- a/supabase/migrations/20260406000000_initial_schema.sql +++ /dev/null @@ -1,165 +0,0 @@ --- ============================================================ --- WhenToMeet MVP 1차 — 초기 스키마 --- PRD v2.2 기반: 소셜 로그인 + 게스트 하이브리드 --- ============================================================ - --- 커스텀 타입 정의 -create type scheduling_status as enum ('open', 'confirmed', 'cancelled'); -create type participant_status as enum ('pending', 'completed'); -create type input_method as enum ('calendar', 'timetable', 'manual', 'ics'); -create type calendar_provider as enum ('google', 'apple'); -create type slot_source as enum ('calendar', 'timetable', 'manual', 'ics'); -create type analysis_status as enum ('pending', 'processing', 'completed', 'failed'); - --- ============================================================ --- 1. profiles: auth.users 확장 + 게스트 지원 --- ============================================================ --- 소셜 로그인 사용자: id = auth.users.id, is_guest = false --- 게스트 사용자: id = 자체 생성 UUID, is_guest = true --- 게스트→회원 전환: anonymous_id로 매칭 후 profile 머지 - -create table profiles ( - id uuid primary key default gen_random_uuid(), - anonymous_id uuid unique, -- 게스트 세션 UUID (브라우저 발급) - display_name text, - avatar_url text, - is_guest boolean not null default true, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -comment on table profiles is '사용자 프로필 (소셜 로그인 + 게스트 하이브리드)'; -comment on column profiles.anonymous_id is '게스트 세션 식별용 UUID. 회원 전환 시 머지 키로 사용'; - --- ============================================================ --- 2. scheduling_groups: 일정 잡기 그룹 --- ============================================================ --- 호스트가 생성, share_code로 참여자 초대 --- PRD: 제목, 예상 소요시간, 후보 날짜 범위 - -create table scheduling_groups ( - id uuid primary key default gen_random_uuid(), - host_id uuid not null references profiles(id) on delete cascade, - title text not null, - description text, - duration_minutes int not null default 60, -- 예상 소요시간 - date_range_start date not null, -- 후보 날짜 시작 - date_range_end date not null, -- 후보 날짜 끝 - time_range_start time not null default '09:00', -- 하루 중 검색 시작 - time_range_end time not null default '22:00', -- 하루 중 검색 끝 - share_code text not null unique default encode(gen_random_bytes(6), 'hex'), - status scheduling_status not null default 'open', - confirmed_start timestamptz, -- 확정된 시작 시간 - confirmed_end timestamptz, -- 확정된 종료 시간 - created_at timestamptz not null default now(), - - constraint valid_date_range check (date_range_end >= date_range_start), - constraint valid_time_range check (time_range_end > time_range_start) -); - -comment on table scheduling_groups is 'PRD 3.1: 호스트가 생성하는 일정 잡기 그룹'; - --- ============================================================ --- 3. participants: 그룹 참여자 --- ============================================================ --- PRD 3.2/3.3: 연동 참여자 + 비연동(수동) 참여자 모두 포함 - -create table participants ( - id uuid primary key default gen_random_uuid(), - group_id uuid not null references scheduling_groups(id) on delete cascade, - profile_id uuid not null references profiles(id) on delete cascade, - status participant_status not null default 'pending', - input_method input_method, -- 어떤 방식으로 가용시간 입력했는지 - joined_at timestamptz not null default now(), - - unique (group_id, profile_id) -- 한 그룹에 같은 사용자 중복 참여 방지 -); - -comment on table participants is 'PRD 5.2: 참여자별 연동 상태 추적 (완료/대기 중)'; - --- ============================================================ --- 4. calendar_connections: OAuth 토큰 저장 --- ============================================================ --- Google OAuth 통합 방식: Supabase Auth 로그인 시 provider_refresh_token 캡처 --- PRD 4.1: 캘린더 내용은 저장하지 않고, 가용/불가용 여부만 추출 - -create table calendar_connections ( - id uuid primary key default gen_random_uuid(), - profile_id uuid not null references profiles(id) on delete cascade, - provider calendar_provider not null, - access_token text not null, - refresh_token text, - token_expires_at timestamptz, - calendar_id text default 'primary', -- 연동된 캘린더 ID - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - - unique (profile_id, provider) -- 프로바이더당 1개 연동 -); - -comment on table calendar_connections is 'PRD 4.1: OAuth 토큰. 캘린더 내용 자체는 저장 안 함'; - --- ============================================================ --- 5. availability_slots: 가용/불가 시간 슬롯 --- ============================================================ --- PRD 4.6: 모든 소스(캘린더/시간표/수동/.ics)의 통합 가용시간 --- is_available: calendar/timetable/ics → false(막힌 시간), manual → true(가능한 시간) - -create table availability_slots ( - id uuid primary key default gen_random_uuid(), - participant_id uuid not null references participants(id) on delete cascade, - slot_start timestamptz not null, - slot_end timestamptz not null, - is_available boolean not null, -- true=가능, false=불가 - source slot_source not null, - created_at timestamptz not null default now(), - - constraint valid_slot check (slot_end > slot_start) -); - -comment on table availability_slots is 'PRD 4.6: 통합 가용시간. 교집합 = 공통 가능 시간대'; - --- 매칭 쿼리 성능을 위한 인덱스 -create index idx_slots_participant on availability_slots(participant_id); -create index idx_slots_time_range on availability_slots(slot_start, slot_end); - --- ============================================================ --- 6. timetable_images: 에브리타임 등 이미지 업로드 --- ============================================================ --- PRD 4.2/4.3: 이미지 업로드 → Gemini Vision 분석 → 블록 인식 --- 에브리타임뿐 아니라 학원·근무표·알바 등 모든 시간표 이미지 범용 지원 - -create table timetable_images ( - id uuid primary key default gen_random_uuid(), - profile_id uuid not null references profiles(id) on delete cascade, - storage_path text not null, -- Supabase Storage 경로 - analysis_status analysis_status not null default 'pending', - analysis_result jsonb, -- Gemini 분석 결과 (블록 위치) - created_at timestamptz not null default now() -); - -comment on table timetable_images is 'PRD 4.2: 시간표 이미지. 학기 중 1회 업로드 후 재사용'; - --- ============================================================ --- 유틸: updated_at 자동 갱신 트리거 --- ============================================================ - -create or replace function update_updated_at() -returns trigger as $$ -begin - new.updated_at = now(); - return new; -end; -$$ language plpgsql; - -create trigger profiles_updated_at - before update on profiles - for each row execute function update_updated_at(); - -create trigger calendar_connections_updated_at - before update on calendar_connections - for each row execute function update_updated_at(); - -create trigger scheduling_groups_updated_at - before update on scheduling_groups - for each row execute function update_updated_at(); diff --git a/supabase/migrations/20260406000001_rls_policies.sql b/supabase/migrations/20260406000001_rls_policies.sql deleted file mode 100644 index 51746a0..0000000 --- a/supabase/migrations/20260406000001_rls_policies.sql +++ /dev/null @@ -1,222 +0,0 @@ --- ============================================================ --- RLS 정책 --- 원칙: 모든 테이블 RLS 활성화, anon/authenticated 역할별 정의 --- ============================================================ - --- RLS 활성화 -alter table profiles enable row level security; -alter table scheduling_groups enable row level security; -alter table participants enable row level security; -alter table calendar_connections enable row level security; -alter table availability_slots enable row level security; -alter table timetable_images enable row level security; - --- ============================================================ --- profiles --- ============================================================ - --- 자기 프로필 조회 (로그인 사용자) -create policy "profiles_select_own" - on profiles for select - to authenticated - using (id = auth.uid()); - --- 같은 그룹 참여자의 display_name, avatar_url 조회 (실시간 현황용) -create policy "profiles_select_group_members" - on profiles for select - to authenticated - using ( - id in ( - select p.profile_id from participants p - where p.group_id in ( - select p2.group_id from participants p2 - where p2.profile_id = auth.uid() - ) - ) - ); - --- 자기 프로필 수정 -create policy "profiles_update_own" - on profiles for update - to authenticated - using (id = auth.uid()) - with check (id = auth.uid()); - --- 게스트 프로필 생성 (anonymous_id 기반) -create policy "profiles_insert_anon" - on profiles for insert - to anon - with check (is_guest = true and anonymous_id is not null); - --- 게스트 자기 프로필 조회 -create policy "profiles_select_anon" - on profiles for select - to anon - using (is_guest = true); - --- ============================================================ --- scheduling_groups --- ============================================================ - --- 누구나 share_code로 그룹 조회 (링크 공유) -create policy "groups_select_by_share_code" - on scheduling_groups for select - to anon, authenticated - using (true); - --- 로그인 사용자만 그룹 생성 -create policy "groups_insert_authenticated" - on scheduling_groups for insert - to authenticated - with check (host_id = auth.uid()); - --- 호스트만 그룹 수정 (확정, 취소) -create policy "groups_update_host" - on scheduling_groups for update - to authenticated - using (host_id = auth.uid()) - with check (host_id = auth.uid()); - --- 호스트만 그룹 삭제 -create policy "groups_delete_host" - on scheduling_groups for delete - to authenticated - using (host_id = auth.uid()); - --- ============================================================ --- participants --- ============================================================ - --- 같은 그룹 참여자끼리 조회 (실시간 현황: "5명 중 3명 완료") -create policy "participants_select_group" - on participants for select - to anon, authenticated - using (true); - --- 로그인 사용자 참여 -create policy "participants_insert_authenticated" - on participants for insert - to authenticated - with check (profile_id = auth.uid()); - --- 게스트 참여 -create policy "participants_insert_anon" - on participants for insert - to anon - with check ( - profile_id in ( - select id from profiles where is_guest = true - ) - ); - --- 자기 참여 정보 수정 (status 업데이트) -create policy "participants_update_own" - on participants for update - to authenticated - using (profile_id = auth.uid()) - with check (profile_id = auth.uid()); - --- ============================================================ --- calendar_connections --- ============================================================ - --- 자기 연동 정보만 조회 -create policy "calendar_select_own" - on calendar_connections for select - to authenticated - using (profile_id = auth.uid()); - --- 자기 연동 정보 생성 -create policy "calendar_insert_own" - on calendar_connections for insert - to authenticated - with check (profile_id = auth.uid()); - --- 자기 연동 정보 수정 (토큰 갱신) -create policy "calendar_update_own" - on calendar_connections for update - to authenticated - using (profile_id = auth.uid()) - with check (profile_id = auth.uid()); - --- 자기 연동 정보 삭제 (연동 해제) -create policy "calendar_delete_own" - on calendar_connections for delete - to authenticated - using (profile_id = auth.uid()); - --- ============================================================ --- availability_slots --- ============================================================ - --- 같은 그룹 참여자의 슬롯 조회 (매칭 계산용) -create policy "slots_select_group" - on availability_slots for select - to anon, authenticated - using ( - participant_id in ( - select id from participants - ) - ); - --- 자기 슬롯 생성 (로그인) -create policy "slots_insert_authenticated" - on availability_slots for insert - to authenticated - with check ( - participant_id in ( - select id from participants where profile_id = auth.uid() - ) - ); - --- 게스트 슬롯 생성 -create policy "slots_insert_anon" - on availability_slots for insert - to anon - with check ( - participant_id in ( - select id from participants p - join profiles pr on p.profile_id = pr.id - where pr.is_guest = true - ) - ); - --- 자기 슬롯 삭제 (재입력 시) -create policy "slots_delete_own" - on availability_slots for delete - to authenticated - using ( - participant_id in ( - select id from participants where profile_id = auth.uid() - ) - ); - --- ============================================================ --- timetable_images --- ============================================================ - --- 자기 이미지만 조회 -create policy "timetable_select_own" - on timetable_images for select - to authenticated - using (profile_id = auth.uid()); - --- 자기 이미지 업로드 -create policy "timetable_insert_own" - on timetable_images for insert - to authenticated - with check (profile_id = auth.uid()); - --- 자기 이미지 수정 (분석 결과 업데이트는 Edge Function에서 service_role로) -create policy "timetable_update_own" - on timetable_images for update - to authenticated - using (profile_id = auth.uid()) - with check (profile_id = auth.uid()); - --- ============================================================ --- Realtime 활성화 (PRD 5: 실시간 현황 표시) --- ============================================================ - -alter publication supabase_realtime add table participants; -alter publication supabase_realtime add table availability_slots; diff --git a/supabase/migrations/20260413000000_init_profiles.sql b/supabase/migrations/20260413000000_init_profiles.sql new file mode 100644 index 0000000..60188b5 --- /dev/null +++ b/supabase/migrations/20260413000000_init_profiles.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- Profiles 테이블 및 사용자 트리거 초기 설정 +-- ============================================================ + +-- 공용 updated_at 트리거 함수 정의 +create or replace function public.update_updated_at() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +-- Profiles 테이블 정의 +create table public.profiles ( + id uuid references auth.users on delete cascade primary key, + email text, + nickname text, + avatar_url text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table public.profiles is '사용자 프로필 정보 (Supabase Auth와 자동 연동)'; + +-- RLS 활성화 +alter table public.profiles enable row level security; + +-- RLS 정책 설정 +create policy "Public profiles are viewable by everyone" + on public.profiles for select + using (true); + +create policy "Users can update their own profile" + on public.profiles for update + using (auth.uid() = id); + +-- 회원가입 시 public.profiles에 자동으로 프로필을 연동해 주는 트리거 함수 정의 +create or replace function public.handle_new_user() +returns trigger as $$ +begin + insert into public.profiles (id, email, nickname, avatar_url) + values ( + new.id, + new.email, + coalesce(new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'name', new.email), + new.raw_user_meta_data->>'avatar_url' + ); + return new; +end; +$$ language plpgsql security definer; + +-- 트리거 설정 +create or replace trigger on_auth_user_created + after insert on auth.users + for each row execute procedure public.handle_new_user(); + +-- profiles updated_at 트리거 등록 +create trigger profiles_updated_at + before update on public.profiles + for each row execute function public.update_updated_at(); diff --git a/supabase/migrations/20260414000000_icloud_connections.sql b/supabase/migrations/20260414000000_icloud_connections.sql new file mode 100644 index 0000000..e79b9ad --- /dev/null +++ b/supabase/migrations/20260414000000_icloud_connections.sql @@ -0,0 +1,121 @@ +-- ============================================================ +-- iCloud CalDAV 연동 테이블 +-- ============================================================ + +-- iCloud 연결 정보 (Apple ID + 암호화된 앱 전용 암호 + CalDAV 엔드포인트) +create table icloud_connections ( + id uuid primary key default gen_random_uuid(), + profile_id uuid not null references profiles(id) on delete cascade, + apple_id text not null, + -- AES-GCM 암호화: "base64(ciphertext):base64(authTag)" 형태로 저장 + encrypted_password text not null, + encryption_iv text not null, -- base64(12-byte IV) + principal_url text, -- CalDAV discovery 결과 + calendar_home_url text, -- calendar-home-set URL + is_active boolean not null default true, + last_verified_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + unique (profile_id, apple_id) +); + +comment on table icloud_connections is 'iCloud CalDAV 연동 정보. 앱 전용 암호는 AES-GCM으로 암호화 저장'; +comment on column icloud_connections.encrypted_password is 'AES-256-GCM ciphertext:authTag (base64 인코딩)'; +comment on column icloud_connections.encryption_iv is 'AES-GCM IV (base64, 12 bytes)'; + +create index idx_icloud_connections_profile on icloud_connections(profile_id); + +-- 캘린더 컬렉션 목록 (discovery로 조회한 캘린더) +create table icloud_calendars ( + id uuid primary key default gen_random_uuid(), + connection_id uuid not null references icloud_connections(id) on delete cascade, + display_name text not null, + calendar_url text not null, -- CalDAV collection URL + color text, + ctag text, -- 변경 감지용 캐시 태그 + synced_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + unique (connection_id, calendar_url) +); + +comment on table icloud_calendars is '연동된 iCloud 캘린더 컬렉션 목록'; + +create index idx_icloud_calendars_connection on icloud_calendars(connection_id); + +-- updated_at 트리거 +create trigger icloud_connections_updated_at + before update on icloud_connections + for each row execute function update_updated_at(); + +create trigger icloud_calendars_updated_at + before update on icloud_calendars + for each row execute function update_updated_at(); + +-- ============================================================ +-- RLS 정책 +-- ============================================================ + +alter table icloud_connections enable row level security; +alter table icloud_calendars enable row level security; + +-- icloud_connections: 자기 연결만 접근 +create policy "icloud_connections_select_own" + on icloud_connections for select + to authenticated + using (profile_id = auth.uid()); + +create policy "icloud_connections_insert_own" + on icloud_connections for insert + to authenticated + with check (profile_id = auth.uid()); + +create policy "icloud_connections_update_own" + on icloud_connections for update + to authenticated + using (profile_id = auth.uid()) + with check (profile_id = auth.uid()); + +create policy "icloud_connections_delete_own" + on icloud_connections for delete + to authenticated + using (profile_id = auth.uid()); + +-- icloud_calendars: 자기 연결에 속한 캘린더만 접근 +create policy "icloud_calendars_select_own" + on icloud_calendars for select + to authenticated + using ( + connection_id in ( + select id from icloud_connections where profile_id = auth.uid() + ) + ); + +create policy "icloud_calendars_insert_own" + on icloud_calendars for insert + to authenticated + with check ( + connection_id in ( + select id from icloud_connections where profile_id = auth.uid() + ) + ); + +create policy "icloud_calendars_update_own" + on icloud_calendars for update + to authenticated + using ( + connection_id in ( + select id from icloud_connections where profile_id = auth.uid() + ) + ); + +create policy "icloud_calendars_delete_own" + on icloud_calendars for delete + to authenticated + using ( + connection_id in ( + select id from icloud_connections where profile_id = auth.uid() + ) + ); diff --git a/supabase/migrations/20260415000000_google_connections.sql b/supabase/migrations/20260415000000_google_connections.sql new file mode 100644 index 0000000..6818ce6 --- /dev/null +++ b/supabase/migrations/20260415000000_google_connections.sql @@ -0,0 +1,68 @@ +-- ============================================================ +-- Google Calendar 연동 테이블 +-- ============================================================ + +-- Google OAuth 연결 정보 (refresh_token 암호화 저장) +CREATE TABLE IF NOT EXISTS google_connections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + google_email TEXT NOT NULL, + + -- refresh_token 암호화 저장 (AES-256-GCM) + encrypted_refresh_token TEXT NOT NULL, + encryption_iv TEXT NOT NULL, + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_synced_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- 같은 사용자가 같은 Google 계정을 중복 연결하지 않도록 + UNIQUE (profile_id, google_email) +); + +-- updated_at 자동 갱신 트리거 +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_google_connections_updated_at + BEFORE UPDATE ON google_connections + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- ============================================================ +-- RLS (Row Level Security) 정책 +-- ============================================================ + +ALTER TABLE google_connections ENABLE ROW LEVEL SECURITY; + +-- 자신의 연결만 조회 가능 +CREATE POLICY google_connections_select_own + ON google_connections FOR SELECT + USING (profile_id = auth.uid()); + +-- 자신의 연결만 삽입 가능 +CREATE POLICY google_connections_insert_own + ON google_connections FOR INSERT + WITH CHECK (profile_id = auth.uid()); + +-- 자신의 연결만 수정 가능 +CREATE POLICY google_connections_update_own + ON google_connections FOR UPDATE + USING (profile_id = auth.uid()); + +-- 자신의 연결만 삭제 가능 +CREATE POLICY google_connections_delete_own + ON google_connections FOR DELETE + USING (profile_id = auth.uid()); + +-- ============================================================ +-- 인덱스 +-- ============================================================ + +CREATE INDEX idx_google_connections_profile_id + ON google_connections(profile_id); diff --git a/supabase/migrations/20260416000000_scheduling_sessions.sql b/supabase/migrations/20260416000000_scheduling_sessions.sql new file mode 100644 index 0000000..a5dc16b --- /dev/null +++ b/supabase/migrations/20260416000000_scheduling_sessions.sql @@ -0,0 +1,93 @@ +-- ============================================================ +-- 스케줄링 세션 및 참여자 가용 시간 테이블 +-- ============================================================ + +-- schedule_sessions 테이블 정의 +create table public.schedule_sessions ( + id uuid primary key default gen_random_uuid(), + title text not null, + host_id uuid not null references public.profiles(id) on delete cascade, + duration_minutes integer not null, + candidate_days text[] not null, + candidate_start_hour integer not null, + candidate_end_hour integer not null, + status text not null default 'open', + confirmed_slot jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +comment on table public.schedule_sessions is '일정 잡기 세션 정보'; + +-- RLS 활성화 +alter table public.schedule_sessions enable row level security; + +-- schedule_sessions RLS 정책 +create policy "Anyone can select schedule sessions" + on public.schedule_sessions for select + using (true); + +create policy "Authenticated users can create schedule sessions" + on public.schedule_sessions for insert + to authenticated + with check (host_id = auth.uid()); + +create policy "Hosts can update their own schedule sessions" + on public.schedule_sessions for update + to authenticated + using (host_id = auth.uid()) + with check (host_id = auth.uid()); + +create policy "Hosts can delete their own schedule sessions" + on public.schedule_sessions for delete + to authenticated + using (host_id = auth.uid()); + +-- participant_availabilities 테이블 정의 +create table public.participant_availabilities ( + id uuid primary key default gen_random_uuid(), + session_id uuid not null references public.schedule_sessions(id) on delete cascade, + user_id uuid references public.profiles(id) on delete cascade, + nickname text not null, + available_slots jsonb not null, -- TimeSlot[] 형태의 json 배열 + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + -- 한 사용자가 한 세션에 가용시간을 하나만 등록할 수 있도록 유니크 제약 + unique (session_id, user_id) +); + +comment on table public.participant_availabilities is '참여자별 가용 시간 정보'; + +-- RLS 활성화 +alter table public.participant_availabilities enable row level security; + +-- participant_availabilities RLS 정책 +create policy "Anyone can select participant availabilities" + on public.participant_availabilities for select + using (true); + +create policy "Authenticated users can insert their own availability" + on public.participant_availabilities for insert + to authenticated + with check (user_id = auth.uid()); + +create policy "Users can update their own availability" + on public.participant_availabilities for update + to authenticated + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy "Users can delete their own availability" + on public.participant_availabilities for delete + to authenticated + using (user_id = auth.uid()); + +-- updated_at 트리거 등록 +create trigger schedule_sessions_updated_at + before update on public.schedule_sessions + for each row execute function public.update_updated_at(); + +create trigger participant_availabilities_updated_at + before update on public.participant_availabilities + for each row execute function public.update_updated_at(); diff --git a/supabase/migrations/20260607000000_profiles_auth_fields.sql b/supabase/migrations/20260607000000_profiles_auth_fields.sql new file mode 100644 index 0000000..4c0eeba --- /dev/null +++ b/supabase/migrations/20260607000000_profiles_auth_fields.sql @@ -0,0 +1,65 @@ +-- ============================================================ +-- profiles 확장: 이메일/비밀번호 회원가입 부가 정보 컬럼 추가 +-- (자체 JWT+Prisma 인증 → Supabase Auth 통합, #45) +-- ============================================================ +-- +-- 기존 자체 회원가입(Prisma User)이 수집하던 부가 필드를 +-- public.profiles 로 옮긴다. 가입 시 supabase.auth.signUp 의 +-- options.data(user_metadata)로 전달된 값을 handle_new_user +-- 트리거가 그대로 채운다. + +-- 1) 컬럼 추가 +alter table public.profiles + add column if not exists phone_number text, + add column if not exists is_age_over_14 boolean, + add column if not exists terms_agreed_at timestamptz, + add column if not exists privacy_agreed_at timestamptz, + add column if not exists marketing_agreed boolean not null default false, + add column if not exists event_sms_agreed boolean not null default false; + +-- 전화번호는 가입자별 고유 (Prisma의 @unique 대응) +create unique index if not exists profiles_phone_number_key + on public.profiles (phone_number) + where phone_number is not null; + +-- 닉네임도 고유 보장 (Prisma의 nickname @unique 대응) +create unique index if not exists profiles_nickname_key + on public.profiles (nickname) + where nickname is not null; + +-- 2) 신규 가입 트리거를 확장 필드까지 채우도록 교체 +create or replace function public.handle_new_user() +returns trigger as $$ +begin + insert into public.profiles ( + id, + email, + nickname, + avatar_url, + phone_number, + is_age_over_14, + terms_agreed_at, + privacy_agreed_at, + marketing_agreed, + event_sms_agreed + ) + values ( + new.id, + new.email, + coalesce( + new.raw_user_meta_data->>'nickname', + new.raw_user_meta_data->>'full_name', + new.raw_user_meta_data->>'name', + new.email + ), + new.raw_user_meta_data->>'avatar_url', + new.raw_user_meta_data->>'phone_number', + (new.raw_user_meta_data->>'is_age_over_14')::boolean, + (new.raw_user_meta_data->>'terms_agreed_at')::timestamptz, + (new.raw_user_meta_data->>'privacy_agreed_at')::timestamptz, + coalesce((new.raw_user_meta_data->>'marketing_agreed')::boolean, false), + coalesce((new.raw_user_meta_data->>'event_sms_agreed')::boolean, false) + ); + return new; +end; +$$ language plpgsql security definer; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..c4fda14 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,126 @@ +import type { Config } from "tailwindcss"; + +/** + * Tailwind CSS v3 + shadcn/ui 설정 + * + * shadcn/ui는 CSS 변수를 통해 테마 색상을 관리한다. + * globals.css에서 --background, --foreground 등을 HSL 값으로 정의하고, + * 여기서 hsl(var(--변수명)) 형태로 참조한다. + * + * 왜 이렇게 하나? + * - Tailwind의 opacity modifier(bg-primary/50)가 동작하려면 + * 색상을 hsl() 함수로 감싸야 함 + * - oklch는 Tailwind v3의 opacity modifier와 호환 안 됨 + */ +const config: Config = { + darkMode: ["class"], + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + brand: { + purple: { + DEFAULT: "hsl(var(--brand-purple))", + dark: "hsl(var(--brand-purple-dark))", + light: "hsl(var(--brand-purple-light))", + bg: "hsl(var(--brand-purple-bg))", + hover: "hsl(var(--brand-purple-hover))", + accent: "hsl(var(--brand-purple-accent))", + ring: "hsl(var(--brand-purple-ring))", + }, + border: { + muted: "hsl(var(--brand-border-muted))", + gray: "hsl(var(--brand-border-gray))", + }, + bg: { + light: "hsl(var(--brand-bg-light))", + muted: "hsl(var(--brand-bg-muted))", + }, + text: { + primary: "hsl(var(--brand-text-primary))", + secondary: "hsl(var(--brand-text-secondary))", + muted: "hsl(var(--brand-text-muted))", + light: "hsl(var(--brand-text-light))", + }, + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + fontFamily: { + sans: [ + "var(--font-pretendard)", + "ui-sans-serif", + "system-ui", + "sans-serif", + ], + mono: [ + "var(--font-geist-mono)", + "ui-monospace", + "SFMono-Regular", + "monospace", + ], + }, + keyframes: { + fadeIn: { + from: { opacity: "0" }, + to: { opacity: "1" }, + }, + scaleIn: { + from: { transform: "scale(0.95)", opacity: "0" }, + to: { transform: "scale(1)", opacity: "1" }, + }, + bounceOnce: { + "0%, 100%": { transform: "scale(1)" }, + "50%": { transform: "scale(1.05)" }, + }, + }, + animation: { + fadeIn: "fadeIn 0.25s ease-out forwards", + scaleIn: "scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards", + bounceOnce: "bounceOnce 0.4s ease-out 1", + }, + }, + }, + plugins: [], +}; +export default config; diff --git a/tsconfig.json b/tsconfig.json index cf9c65d..76940de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,34 +1,28 @@ { "compilerOptions": { - "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, - "strict": true, + "strict": false, "noEmit": true, - "esModuleInterop": true, + "incremental": true, "module": "esnext", - "moduleResolution": "bundler", + "esModuleInterop": true, + "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, + "jsx": "preserve", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, "plugins": [ { "name": "next" } ], - "paths": { - "@/*": ["./src/*"] - } + "target": "ES2017" }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - "exclude": ["node_modules"] + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..ed3c827 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + test: { + // jsdom: 실제 브라우저 없이 DOM을 시뮬레이션하는 환경 + // pytest에서 mock 객체를 쓰듯이, jsdom이 가상 브라우저 역할을 한다 + environment: "jsdom", + + // globals: true로 하면 describe, test, expect를 import 없이 사용 가능 + // pytest처럼 함수명만으로 바로 사용하는 것과 같은 편의성 + globals: true, + + // 모든 테스트 파일 실행 전에 먼저 로드되는 설정 파일 + setupFiles: "./src/test-setup.ts", + + // src 폴더 내의 .test.ts, .test.tsx 파일만 테스트 대상으로 인식 + include: ["src/**/*.test.{ts,tsx}"], + + coverage: { + provider: "v8", + reporter: ["text", "html"], + thresholds: { + lines: 70, + functions: 70, + branches: 70, + }, + }, + }, + resolve: { + alias: { + // tsconfig.json의 paths와 동기화 — @/로 시작하는 경로를 src/로 매핑 + "@": path.resolve(__dirname, "./src"), + }, + }, +});