From 1fd665a33ce8d8f718bbc26e7769eaa462dc67d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Mon, 6 Jul 2026 16:15:08 +0900 Subject: [PATCH 001/112] =?UTF-8?q?chore:=20=EC=9D=B4=EC=8A=88=20=EB=B0=8F?= =?UTF-8?q?=20pr=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=9E=91=EC=84=B1,=20?= =?UTF-8?q?=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMAPLTE/task.md | 23 ++ .github/PULL_REQUEST_TEMPLATE.md | 17 ++ CLAUDE.md | 52 +++++ rules.md | 385 +++++++++++++++++++++++++++++++ 4 files changed, 477 insertions(+) create mode 100644 .github/ISSUE_TEMAPLTE/task.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CLAUDE.md create mode 100644 rules.md diff --git a/.github/ISSUE_TEMAPLTE/task.md b/.github/ISSUE_TEMAPLTE/task.md new file mode 100644 index 0000000..ac0a961 --- /dev/null +++ b/.github/ISSUE_TEMAPLTE/task.md @@ -0,0 +1,23 @@ +--- +name: 작업 이슈 +about: 구현, 수정, 리팩토링, 테스트 작업을 간단히 정리합니다. +title: "" +labels: [] +assignees: [] +--- + +## 배경 + + + +## 작업 범위 + + + +- + +## 검증 + +- [ ] 작은 범위 테스트 실행 +- [ ] `corepack pnpm run lint` 실행 +- [ ] `corepack pnpm run build` 실행 \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7b0eaeb --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,17 @@ +## 변경 내용 + + + +- + +## 검증 + + + +- + +## 연결 이슈 + + + +Closes # \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..fc01f8d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# CLAUDE.md + +Claude Code 또는 유사한 코딩 에이전트는 이 레포에서 작업할 때 반드시 [`rules.md`](./rules.md)를 먼저 읽고 그대로 따른다. + +## Non-negotiable rules + +- Follow `rules.md` before making code changes. +- Use the issue-first workflow: create GitHub Issue → create issue-numbered branch → commit → open PR → verify → merge. +- Keep the FSD structure: `app`, `pages`, `widgets`, `features`, `entities`, `shared`. +- Do not break FSD dependency direction: `app → pages → widgets → features → entities → shared`. +- Use the Container/Presenter pattern for new or significantly changed features. +- Separate feature logic into custom hooks under `model`. +- Presenters must render from props; they must not own API calls, storage side effects, or complex state transitions. +- Always follow the design system in `src/index.css` and existing UI patterns. +- Do not introduce arbitrary colors, spacing, radius, shadows, or fonts when tokens/patterns already exist. +- Use strict React and TypeScript conventions. Do not add `any`, unnecessary `as`, or `@ts-ignore`. +- Separate API DTOs from UI models and use mapper functions. +- Handle loading, error, empty, and fallback states. +- Check basic accessibility: semantic HTML, button type, aria labels, focus behavior, and safe external links. +- Run verification before claiming completion. + +## Git workflow convention + +Branch naming: + +```text +feat/{issue-number}-{short-name} +fix/{issue-number}-{short-name} +docs/{issue-number}-{short-name} +refac/{issue-number}-{short-name} +``` + +Commit format: + +```text +type: subject +``` + +Allowed types: `feat`, `fix`, `docs`, `refac`, `test`, `chore`. + +PR body must include change summary, test results, screenshots/logs when relevant, and linked issue using `Closes #issue-number`. + +## Required verification + +```bash +corepack prepare pnpm@11.9.0 --activate +corepack pnpm install --frozen-lockfile +corepack pnpm run lint +corepack pnpm run build +``` + +If a task changes Dockerfile, Compose, or runtime wiring, verify `docker compose config` and local build/smoke when Docker is available. If Docker is unavailable in the execution environment, state that explicitly in the PR verification result. \ No newline at end of file diff --git a/rules.md b/rules.md new file mode 100644 index 0000000..93263ef --- /dev/null +++ b/rules.md @@ -0,0 +1,385 @@ +# BOP Front-end Agent Rules + +이 문서는 `front-end-proto`에서 작업하는 모든 에이전트와 개발자가 따라야 하는 프론트엔드 구현 규칙이다. 새 기능을 만들거나 기존 코드를 수정하기 전에 반드시 이 파일을 먼저 읽고, 이 규칙과 충돌하는 구현은 하지 않는다. + +## 1. 작업 흐름 + +- 모든 작업은 GitHub Issue를 먼저 만들고, issue 번호가 포함된 브랜치에서 진행한다. +- 브랜치 형식은 `feat/{issue-number}-{short-name}`, `fix/{issue-number}-{short-name}`, `docs/{issue-number}-{short-name}`, `refac/{issue-number}-{short-name}`를 따른다. +- 구현 후에는 `pnpm run lint`, `pnpm run build`를 직접 실행하고 PR에 실제 결과를 적는다. +- 단순 설명이나 스텁으로 끝내지 않는다. 동작하는 코드와 검증 결과를 남긴다. +- 백엔드 API/기획/API 명세와 연결되는 변경은 docs 레포의 기획/API 문서를 먼저 확인하고 필요하면 문서도 갱신한다. + +## 2. 기술 기준 + +- React 19를 사용한다. +- TypeScript를 사용한다. +- Vite를 사용한다. +- Tailwind CSS 4를 사용한다. +- package manager는 pnpm을 사용한다. +- 프론트는 Vite 정적 SPA로 빌드하고, production smoke는 Vite preview 서버(`pnpm start`)로 확인한다. 별도 Node 서버를 추가하지 않는다. + +기본 검증 명령은 다음과 같다. + +```bash +corepack prepare pnpm@11.9.0 --activate +corepack pnpm install --frozen-lockfile +corepack pnpm run lint +corepack pnpm run build +``` + +Docker/Compose 관련 변경은 로컬에서 가능한 범위로 `docker compose config`, build, runtime smoke를 검증한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. + +## 3. FSD 폴더 구조 + +패키지 구조는 Feature-Sliced Design(FSD)을 따른다. 새 코드는 `app`, `pages`, `widgets`, `features`, `entities`, `shared` 레이어 중 책임에 맞는 위치에 둔다. + +현재 기준 구조는 다음과 같다. + +```text +src +├── app +├── pages +├── widgets +├── features +├── entities +└── shared +``` + +각 레이어 책임은 다음과 같다. + +- `app`: 앱 초기화, 전역 provider, 라우팅, 전역 스타일 연결 등 앱 조립 책임 +- `pages`: 라우트/page 단위 화면 조립 책임 +- `widgets`: 여러 feature/entity를 조합한 독립 UI 블록 +- `features`: 사용자가 수행하는 행위, use case 단위 기능 +- `entities`: 비즈니스 엔티티의 타입, API, model, UI +- `shared`: 도메인에 의존하지 않는 공통 UI, lib, api, config, types + +레이어 의존 방향은 아래를 따른다. + +```text +app → pages → widgets → features → entities → shared +``` + +하위 레이어가 상위 레이어를 import하지 않는다. 예를 들어 `entities`는 `features`, `widgets`, `pages`, `app`을 import하지 않는다. + +## 4. Slice 내부 구조 + +각 slice는 필요한 디렉터리만 만든다. + +```text +features/policy-filter +├── api +├── model +├── lib +├── ui +└── index.ts +``` + +역할은 다음과 같다. + +- `api`: HTTP 요청 함수, API DTO, endpoint 호출 +- `model`: 상태, custom hook, slice type, mock data +- `lib`: 순수 함수, mapper, formatter, 계산 로직 +- `ui`: Presenter component, 시각적 컴포넌트 +- `index.ts`: public API export + +외부 slice에서 내부 파일을 깊게 import하지 않는다. 가능한 `@features/policy-filter`처럼 public API를 통해 import한다. + +금지 예시: + +```ts +import { FilterBar } from "@features/policy-filter/ui/FilterBar"; +``` + +권장 예시: + +```ts +import { FilterBar } from "@features/policy-filter"; +``` + +## 5. Container / Presenter 패턴 + +화면 기능은 Container와 Presenter를 분리한다. + +- Container는 상태, side effect, API 호출, event handler 조립을 담당한다. +- Presenter는 props를 받아 렌더링만 담당한다. +- Presenter 내부에서 API 호출, localStorage 접근, 복잡한 상태 전이를 직접 하지 않는다. +- 큰 컴포넌트 하나에 상태와 UI를 모두 넣지 않는다. 기존 큰 파일은 기능 변경 시 점진적으로 hook과 Presenter로 분리한다. + +권장 구조: + +```text +features/tracker-management +├── model +│ └── use-tracker-management.ts +├── ui +│ ├── TrackerDashboardContainer.tsx +│ └── TrackerDashboardPresenter.tsx +└── index.ts +``` + +Container 예시: + +```tsx +export function TrackerDashboardContainer() { + const trackerState = useTrackerManagement(); + + return ; +} +``` + +Presenter 예시: + +```tsx +interface TrackerDashboardPresenterProps { + isLoading: boolean; + onAddItem: () => void; +} + +export function TrackerDashboardPresenter({ isLoading, onAddItem }: TrackerDashboardPresenterProps) { + return ( +
+ +
+ ); +} +``` + +## 6. Custom Hook 규칙 + +기능 로직은 custom hook으로 분리한다. + +- hook 이름은 반드시 `use`로 시작한다. +- hook은 `model` 디렉터리에 둔다. +- hook은 화면 상태, derived state, event handler, API loading/error 상태를 캡슐화한다. +- hook 내부에서 사용하는 effect는 cleanup을 명확히 작성한다. +- hook return 값은 Presenter가 바로 사용할 수 있는 형태로 구성한다. +- 비동기 요청 hook은 `isLoading`, `errorMessage`, `isFallback`처럼 UI가 상태를 명확히 표현할 수 있는 값을 제공한다. + +금지: + +```tsx +// Presenter에서 직접 fetch/localStorage/window side effect 수행 금지 +function PolicyCard() { + useEffect(() => { + fetch("/api/policies"); + }, []); +} +``` + +권장: + +```tsx +function PolicyListContainer() { + const { policies, isLoading, errorMessage, reload } = usePolicies(); + return ; +} +``` + +## 7. 디자인 시스템 규칙 + +디자인은 반드시 현재 디자인 시스템을 따른다. 임의 색상, 임의 radius, 임의 그림자, 임의 폰트를 남발하지 않는다. + +현재 디자인 토큰은 `src/index.css`에 정의되어 있다. + +- Tailwind `@theme` + - `--font-sans` + - `--font-display` + - `--color-primary` + - `--color-primary-foreground` + - `--color-primary-light` + - `--color-brand-secondary` + - `--color-secondary` + - `--color-accent` + - `--color-success` + - `--color-border` +- CSS variables + - `--background` + - `--surface` + - `--foreground` + - `--card` + - `--muted` + - `--destructive` + - `--warning` + - `--ring` + - `--radius` + +우선 사용 기준: + +- 브랜드 CTA: `bg-primary`, `text-primary`, `from-primary to-brand-secondary` +- 카드/패널: `rounded-2xl` 또는 `rounded-3xl`, `bg-white`, `border border-slate-100`, `shadow-sm` +- 보조 배경: `bg-slate-50`, `bg-primary/5`, `bg-primary-light` +- 상태 색상: success/warning/destructive 계열 토큰 또는 Tailwind semantic 색상을 일관되게 사용 +- 폰트: `font-sans`, 필요한 display 영역만 `font-display` + +금지: + +- 기존 토큰과 무관한 hex 색상 직접 추가 +- 같은 의미의 버튼/카드 스타일을 매번 새로 만드는 것 +- contrast 확인 없이 흐린 텍스트/CTA 색상 추가 +- 디자인 시스템과 다른 radius/shadow/spacing을 임의 도입 + +새 공통 UI가 필요하면 `shared/ui`에 만들고, feature 내부에서만 쓰는 UI는 해당 slice의 `ui`에 둔다. + +## 8. React 코딩 컨벤션 + +React 코드는 공식 React 권장 패턴과 커뮤니티 표준을 따른다. + +- 함수 컴포넌트를 사용한다. +- component 이름은 PascalCase를 사용한다. +- hook 이름은 camelCase + `use` prefix를 사용한다. +- props type은 component 근처에 `interface {ComponentName}Props`로 둔다. +- props는 가능한 명시적으로 선언하고, `React.FC`는 기본으로 사용하지 않는다. +- list key는 stable id를 사용한다. 동적 list에서 index key는 금지한다. +- event handler 이름은 `handleX`, props callback 이름은 `onX`를 사용한다. +- 조건부 렌더링은 early return 또는 명확한 분기로 작성한다. +- `useEffect`는 외부 시스템 동기화에만 사용한다. 단순 계산은 render 또는 `useMemo`로 처리한다. +- effect에는 cleanup이 필요한지 확인한다. +- `useMemo`, `useCallback`은 성능 문제가 있거나 memoized child에 전달할 때만 사용한다. +- 접근성을 위해 semantic HTML, `button type="button"`, `aria-label`, focus 가능 상태를 고려한다. +- `target="_blank"` 링크에는 `rel="noopener noreferrer"`를 붙인다. + +## 9. TypeScript 코딩 컨벤션 + +TypeScript는 strict한 타입 설계를 지향한다. + +- `any`는 금지한다. 알 수 없는 값은 `unknown`을 사용하고 type guard로 좁힌다. +- `@ts-ignore`는 금지한다. 정말 필요한 경우 `@ts-expect-error`와 이유를 주석으로 남긴다. +- API 응답 DTO와 UI model을 분리한다. +- API DTO → UI model 변환은 `lib/*-mapper.ts`에 둔다. +- type-only import는 `import type`을 사용한다. +- union state는 string union 또는 discriminated union을 사용한다. +- nullable 값은 명시적으로 처리한다. +- public API에 노출되는 함수/컴포넌트 props는 타입을 명확히 선언한다. +- 타입 단언 `as`는 런타임 검증이 있거나 라이브러리 경계에서 불가피할 때만 사용한다. + +권장: + +```ts +type AsyncState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; data: T } + | { status: "error"; message: string }; +``` + +금지: + +```ts +const data = response as Policy[]; +function parse(value: any) {} +``` + +## 10. API 연동 규칙 + +- API 호출 함수는 slice의 `api` 디렉터리에 둔다. +- API 응답 DTO는 UI model과 분리한다. +- mapper는 `lib`에 둔다. +- API 실패, timeout, 서버 장애, 빈 응답 상태를 UI가 표현할 수 있게 hook에서 상태를 제공한다. +- mock fallback이 필요한 기능은 fallback 여부를 `isFallback` 등으로 노출한다. +- API key 같은 secret은 클라이언트 번들에 직접 노출하지 않는다. +- 브라우저 저장소에는 민감정보를 저장하지 않는다. + +### 10.1 API 에러코드 처리 규칙 + +백엔드 검증 오류와 비즈니스 오류는 HTTP status만 보지 않고, 응답 body의 `code`를 기준으로 처리한다. 백엔드는 검증 오류를 `C001` 같은 `ErrorCode`로 내려주며, 프론트는 이 코드를 사용자 메시지로 매핑한다. + +표준 에러 응답 예시는 다음과 같다. + +```ts +interface ApiErrorResponse { + code: string; + message: string; + details?: Array<{ + field?: string; + value?: unknown; + reason?: string; + }>; +} +``` + +프론트 처리 규칙: + +- API client는 실패 응답에서 `code`, `message`, `details`를 파싱해 hook 또는 caller가 사용할 수 있게 보존한다. +- UI에 표시할 문구는 서버 raw message를 그대로 뿌리지 말고, `code` → 사용자 친화 메시지 매핑을 우선 사용한다. +- 검증 오류 `C001`은 가능한 경우 `details[].reason`을 함께 보여주되, 여러 field 오류는 대표 메시지 + 세부 항목 형태로 정리한다. +- 알 수 없는 `code`는 공통 fallback 메시지(예: `요청 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.`)로 처리한다. +- HTTP status, exception text, stack trace, SQL, 내부 endpoint, secret 값은 사용자에게 직접 노출하지 않는다. +- 새 백엔드 `ErrorCode`가 추가되면 프론트의 code-message map 갱신 여부를 확인한다. + +권장 예시: + +```ts +const ERROR_MESSAGE_BY_CODE: Record = { + C001: "입력값을 다시 확인해 주세요.", + A001: "로그인이 필요한 기능입니다.", + P001: "정책 정보를 찾을 수 없습니다.", +}; + +function getUserMessage(error: ApiErrorResponse): string { + return ERROR_MESSAGE_BY_CODE[error.code] + ?? "요청 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요."; +} +``` + +금지 예시: + +```ts +// 서버 내부 message/exception을 그대로 노출하지 않는다. +showToast(error.message); + +// status만 보고 모든 400을 같은 메시지로 처리하지 않는다. +if (response.status === 400) showToast("잘못된 요청입니다."); +``` + +## 11. 상태 관리 규칙 + +- 컴포넌트 내부에서만 쓰는 UI 상태는 `useState`로 관리한다. +- feature use case 상태는 custom hook으로 분리한다. +- 여러 slice가 공유하는 domain data는 `entities/*/model`에서 관리한다. +- derived state는 가능하면 원본 state에서 계산한다. 중복 state로 저장하지 않는다. +- localStorage 접근은 `lib` 또는 hook 내부로 캡슐화한다. + +## 12. 파일/이름 규칙 + +- React component 파일은 PascalCase: `PolicyCard.tsx` +- hook 파일은 kebab-case 또는 camelCase 중 기존 slice 관례를 따른다. 현재는 `use-tracker-management.ts`처럼 kebab-case를 우선 사용한다. +- type 파일은 `types.ts` +- mock data 파일은 `mock-*.ts` +- mapper 파일은 `*-mapper.ts` +- barrel export는 각 slice의 `index.ts`에서 public API만 노출한다. + +## 13. 테스트/검증 규칙 + +현재 기본 검증은 다음이다. + +```bash +pnpm run lint +pnpm run build +``` + +작업 유형별로 추가 확인한다. + +- UI 변경: 주요 화면 수동 확인 또는 screenshot 첨부 +- API 연동: 성공/빈 상태/오류 상태 확인 +- 상태 로직 변경: hook 또는 순수 함수 단위 테스트 추가를 우선 고려 +- Docker 변경: `docker compose config`와 가능한 build/smoke 확인 + +## 14. PR 전 체크리스트 + +- [ ] GitHub Issue를 만들고 issue 번호 브랜치에서 작업했다. +- [ ] FSD 레이어 의존 방향을 지켰다. +- [ ] Container/Presenter 역할을 분리했다. +- [ ] 기능 로직을 custom hook으로 분리했다. +- [ ] 디자인 시스템 토큰과 기존 UI 패턴을 따랐다. +- [ ] `any`, 불필요한 `as`, `@ts-ignore`를 추가하지 않았다. +- [ ] API DTO와 UI model을 분리했다. +- [ ] API 오류는 백엔드 `code` 기준으로 사용자 메시지를 매핑하고, raw exception/message를 그대로 노출하지 않는다. +- [ ] 새 백엔드 에러코드가 있다면 프론트 code-message map 갱신 필요 여부를 확인했다. +- [ ] loading/error/empty 상태를 처리했다. +- [ ] 접근성 기본 요소를 확인했다. +- [ ] `pnpm run lint`를 실행했다. +- [ ] `pnpm run build`를 실행했다. \ No newline at end of file From fb6e1ea5d8b52392c437ade21228c47e5e0a8ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=EC=9B=90?= Date: Mon, 6 Jul 2026 16:25:58 +0900 Subject: [PATCH 002/112] =?UTF-8?q?chore:=20=EC=9D=B4=EC=8A=88=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A7=A4=EB=8B=88?= =?UTF-8?q?=EC=A0=80=20=EA=B3=A0=EC=A0=95=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 이슈 템플릿 오타 수정 * chore: pnpm 고정 --- .github/{ISSUE_TEMAPLTE => ISSUE_TEMPLATE}/task.md | 0 package.json | 2 ++ 2 files changed, 2 insertions(+) rename .github/{ISSUE_TEMAPLTE => ISSUE_TEMPLATE}/task.md (100%) diff --git a/.github/ISSUE_TEMAPLTE/task.md b/.github/ISSUE_TEMPLATE/task.md similarity index 100% rename from .github/ISSUE_TEMAPLTE/task.md rename to .github/ISSUE_TEMPLATE/task.md diff --git a/package.json b/package.json index 55128b8..186f1e7 100755 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@11.9.0", "scripts": { + "preinstall": "node -e \"if (!process.env.npm_config_user_agent?.includes('pnpm')) { console.error('Use pnpm instead of npm or yarn.'); process.exit(1); }\"", "dev": "tsx server.ts", "build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs", "start": "node dist/server.cjs", From d2e238afde09ecce2377d1859546712c0353dc02 Mon Sep 17 00:00:00 2001 From: Chaeyeon Han <91395969+imi21123@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:15:13 +0900 Subject: [PATCH 003/112] =?UTF-8?q?refac:=20FSD=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=A0=81=EC=9A=A9=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: CLAUDE 및 rules 문서 업데이트 및 내용 정리 * chore: 라우팅·상태관리 라이브러리 설치 Co-Authored-By: Claude Fable 5 * refac: 경로 alias를 src 기준으로 정비하고 shared 레이어 골격 도입 Co-Authored-By: Claude Fable 5 * refac: policy·user 도메인을 entities 레이어로 승격하고 DTO-모델 mapper 분리 Co-Authored-By: Claude Fable 5 * refac: 검색·찜·비교 기능을 feature 슬라이스로 분리 Co-Authored-By: Claude Fable 5 * refac: 추천·신청관리 기능을 feature 슬라이스로 분리 Co-Authored-By: Claude Fable 5 * refac: 인증·프로필·챗봇·관리자 기능을 feature 슬라이스로 분리 Co-Authored-By: Claude Fable 5 * refac: 복수 feature 조합 UI를 widgets 레이어로 구성 Co-Authored-By: Claude Fable 5 * refac: React Router 기반 pages·app 레이어로 화면 조립 전환 Co-Authored-By: Claude Fable 5 * refac: App.tsx 모놀리스 및 레거시 components 디렉터리 제거 Co-Authored-By: Claude Fable 5 * refac: rules.md에 따라 dev 서버를 vite 정적 SPA로 전환 server.ts(Express) 및 챗봇 프록시용 서버 의존성(express, dotenv, @google/genai, tsx, esbuild)을 제거하고 dev/build/start 스크립트를 vite 명령으로 교체한다. Co-Authored-By: Claude Fable 5 * refac: shared 공용 유틸과 ErrorState 컴포넌트 추가 Co-Authored-By: Claude Fable 5 * fix: 신청 목표일 ISO 정규화 및 randomUUID insecure context 폴백 Co-Authored-By: Claude Fable 5 * fix: 생활·상태 필터가 실제 검색 결과에 반영되도록 구현 Co-Authored-By: Claude Fable 5 * fix: 검색 디바운스와 이전 결과 유지, 한글 IME Enter 가드 적용 Co-Authored-By: Claude Fable 5 * fix: 최초 로그인 프로필 마법사 분기 복구 및 redirect 경로 보존 Co-Authored-By: Claude Fable 5 * fix: 관리자 라우트 role 가드 적용 및 라우트 지연 로딩 분할 Co-Authored-By: Claude Fable 5 * fix: 신청관리 시작 실패 경로 처리 및 에러 카드 ErrorState 통합 Co-Authored-By: Claude Fable 5 * refac: 카테고리 배지 로직 통합 및 불필요한 정책 조회 제거 Co-Authored-By: Claude Fable 5 * refac: 미사용 쿼리 훅과 apiError 재수출 제거 Co-Authored-By: Claude Fable 5 * fix: 관리자 동기화 요약 지표를 하드코딩에서 쿼리 조회로 전환 Co-Authored-By: Claude Fable 5 * refac: 공용 상수 통합 및 rules.md 컨벤션 정리 Co-Authored-By: Claude Fable 5 * fix: 관리자 메뉴 링크를 admin role에만 노출하도록 조건부 렌더링 Co-Authored-By: Claude Opus 4.8 (1M context) * fix: 검색 지역 필터에 '전국' 옵션 추가로 controlled select 불일치 해소 showNationwideOnly가 region="전국"을 세팅하지만 REGIONS 옵션에 없어 select 값이 빈 표시되던 문제를 검색 전용 REGION_FILTER_OPTIONS로 해결. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: 신청관리 목록의 policies 쿼리 로딩·에러 게이트 추가 trackers·policies mock 딜레이 차로 policies가 pending/실패인 동안 목록은 비고 탭 카운트는 raw trackers 기준이라 불일치가 생기던 문제를, 두 쿼리를 함께 로딩/에러 게이트해 일관된 UI로 해소. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: 마이페이지 저장 카운트 조인 기준 통일 및 로딩·에러 처리 - '관심 정책 저장' 카드를 raw savedPolicyIds.length에서 조인된 savedPolicies.length로 변경해 p_missing 등으로 인한 4 vs 3 불일치 해소 - bookmark·trackers·policies 세 쿼리를 함께 로딩(Skeleton)/에러(ErrorState) 게이트해 카운트-목록 레이스 제거 - useBookmark가 refetch를 노출하도록 추가 Co-Authored-By: Claude Opus 4.8 (1M context) * fix: 프로필 마법사 키워드 중복 추가 방지 같은 키워드 추가 시 key 충돌과 removeKeyword의 복사본 일괄 삭제를 유발하던 문제를, addKeyword에 중복 체크를 추가해 해소. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: 홈 맞춤 추천 섹션 로딩·에러 상태 처리 추천 쿼리가 pending/실패일 때 로그인 사용자에게 빈 섹션과 '0건'만 노출되던 문제를, useRecommendations의 isLoading/isError/reload를 사용해 Skeleton·ErrorState로 처리(정책 목록 섹션과 일관). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: imi21123 Co-authored-by: Claude Fable 5 --- .env.example | 9 - CLAUDE.md | 6 +- index.html | 2 +- package.json | 24 +- pnpm-lock.yaml | 958 ++------ pnpm-workspace.yaml | 2 + rules.md | 534 +++-- server.ts | 141 -- src/App.tsx | 2094 ----------------- src/app/layouts/RootLayout.tsx | 41 + src/app/providers/AppProviders.tsx | 12 + src/app/providers/index.ts | 1 + src/app/router/ProtectedRoute.tsx | 50 + src/app/router/index.ts | 1 + src/app/router/router.tsx | 47 + src/components/ChatbotAssistant.tsx | 243 -- src/components/CompareModule.tsx | 206 -- src/components/FilterBar.tsx | 133 -- src/components/Header.tsx | 125 - src/components/RecentlyViewed.tsx | 78 - src/entities/policy/api/policy.dto.ts | 22 + src/entities/policy/api/policyApi.ts | 77 + .../policy/api/policyMockData.ts} | 57 +- src/entities/policy/index.ts | 31 + src/entities/policy/model/policy.types.ts | 27 + src/entities/policy/model/policyCategories.ts | 16 + .../policy/model/policyDetailStore.ts | 13 + src/entities/policy/model/policyMapper.ts | 57 + src/entities/policy/model/policyQueries.ts | 44 + src/entities/policy/model/policyStatuses.ts | 9 + .../policy/ui}/PolicyCard.tsx | 81 +- .../policy/ui/PolicyCategoryBadge.tsx | 30 + src/entities/user/index.ts | 4 + src/entities/user/model/authStore.ts | 17 + src/entities/user/model/profileStore.ts | 24 + src/entities/user/model/user.types.ts | 16 + src/entities/user/ui/ProfileSummaryCard.tsx | 54 + src/features/admin-sync/api/adminApi.ts | 43 + .../admin-sync/components/AdminQaNote.tsx | 10 + .../components/SyncHistoryTable.tsx | 49 + .../components/SyncSummaryCards.tsx | 73 + src/features/admin-sync/hooks/useRunSync.ts | 24 + .../admin-sync/hooks/useSyncHistory.ts | 22 + src/features/admin-sync/index.ts | 6 + .../admin-sync/types/adminSync.types.ts | 17 + .../auth/components/AccountDangerZone.tsx | 70 + .../auth/components/SocialLoginPanel.tsx | 85 + src/features/auth/hooks/useDeleteAccount.ts | 19 + src/features/auth/hooks/useLogout.ts | 19 + src/features/auth/hooks/useSocialLogin.ts | 40 + src/features/auth/index.ts | 5 + src/features/chatbot/api/chatApi.ts | 36 + .../chatbot/components/ChatDrawer.tsx | 104 + .../chatbot/components/ChatMessageBubble.tsx | 40 + .../chatbot/components/ChatbotContainer.tsx | 33 + .../chatbot/components/ChatbotWidget.tsx | 59 + src/features/chatbot/hooks/useChatbot.ts | 62 + src/features/chatbot/index.ts | 2 + src/features/chatbot/types/chat.types.ts | 6 + .../policy-bookmark/api/bookmarkApi.ts | 21 + .../SavedPolicyList/SavedPolicyItem.tsx | 99 + .../SavedPolicyList/SavedPolicyList.tsx | 43 + .../components/SavedPolicyList/index.ts | 1 + .../policy-bookmark/hooks/useBookmark.ts | 47 + src/features/policy-bookmark/index.ts | 2 + .../components/CompareDetailDialog.tsx | 97 + .../components/ComparePanelContainer.tsx | 20 + .../components/ComparePanelPresenter.tsx | 141 ++ .../policy-compare/hooks/useCompare.ts | 43 + src/features/policy-compare/index.ts | 2 + .../policy-compare/store/compareStore.ts | 17 + .../policy-recommendation/api/recommendApi.ts | 33 + .../components/ProfileBriefing.tsx | 42 + .../components/RecommendationCard.tsx | 109 + .../components/RecommendationPreview.tsx | 94 + .../hooks/useRecommendations.ts | 19 + src/features/policy-recommendation/index.ts | 8 + .../types/recommendation.types.ts | 10 + .../components/PolicyFilterBar.tsx | 87 + .../components/PolicySearchBar.tsx | 36 + .../components/SearchEmptyState.tsx | 31 + .../policy-search/hooks/usePolicySearch.ts | 42 + .../policy-search/hooks/useSearchFilters.ts | 72 + src/features/policy-search/index.ts | 6 + .../policy-search/policySearchOptions.ts | 10 + .../policy-search/types/policySearch.types.ts | 8 + src/features/policy-tracker/api/trackerApi.ts | 158 ++ .../components/TrackerContainer.tsx | 126 + .../TrackerDetailPanel/TrackerChecklist.tsx | 121 + .../TrackerDeleteConfirm.tsx | 38 + .../TrackerDetailPanel/TrackerDetailPanel.tsx | 143 ++ .../TrackerDetailPanel/TrackerMemoEditor.tsx | 42 + .../components/TrackerDetailPanel/index.ts | 1 + .../TrackerListPanel/TrackerCard.tsx | 71 + .../TrackerListPanel/TrackerListPanel.tsx | 58 + .../TrackerListPanel/TrackerStatusTabs.tsx | 44 + .../components/TrackerListPanel/index.ts | 2 + .../hooks/useTrackerMutations.ts | 108 + .../hooks/useTrackerSelection.ts | 31 + .../hooks/useTrackerStartParam.ts | 71 + .../policy-tracker/hooks/useTrackers.ts | 14 + src/features/policy-tracker/index.ts | 3 + .../policy-tracker/types/tracker.types.ts | 17 + .../components/ProfileSetupContainer.tsx | 34 + .../components/ProfileSetupPresenter.tsx | 96 + .../components/WizardStepBasic.tsx | 87 + .../components/WizardStepInterest.tsx | 107 + .../components/WizardStepStatus.tsx | 87 + .../hooks/useProfileSetupWizard.ts | 113 + src/features/profile-setup/index.ts | 1 + src/main.tsx | 17 +- src/pages/admin/AdminPage.tsx | 63 + src/pages/admin/index.ts | 1 + src/pages/home/HomePage.tsx | 105 + .../home/components/CategoryQuickLinks.tsx | 58 + .../home/components/GuestRecommendCta.tsx | 38 + src/pages/home/index.ts | 1 + src/pages/login/LoginPage.tsx | 15 + src/pages/login/index.ts | 1 + src/pages/my/MyPage.tsx | 152 ++ src/pages/my/index.ts | 1 + src/pages/profile-setup/ProfileSetupPage.tsx | 9 + src/pages/profile-setup/index.ts | 1 + src/pages/recommend/RecommendPage.tsx | 51 + src/pages/recommend/index.ts | 1 + src/pages/search/SearchPage.tsx | 100 + src/pages/search/index.ts | 1 + src/pages/tracker/TrackerPage.tsx | 9 + src/pages/tracker/index.ts | 1 + src/shared/api/apiClient.ts | 6 + src/shared/api/index.ts | 2 + src/shared/api/queryClient.ts | 10 + src/shared/constants/api.ts | 2 + src/shared/constants/compare.ts | 1 + src/shared/constants/index.ts | 5 + src/shared/constants/regions.ts | 13 + src/shared/constants/routes.ts | 12 + src/shared/hooks/index.ts | 1 + src/shared/hooks/useDebouncedValue.ts | 12 + src/shared/ui/ConfirmDialog.tsx | 56 + src/shared/ui/EmptyState.tsx | 21 + src/shared/ui/ErrorState.tsx | 22 + src/shared/ui/Skeleton.tsx | 7 + src/shared/ui/index.ts | 6 + src/shared/ui/toast/ToastContainer.tsx | 41 + src/shared/ui/toast/index.ts | 3 + src/shared/ui/toast/toastStore.ts | 32 + src/shared/ui/toast/useToast.ts | 6 + src/shared/utils/date.ts | 10 + src/shared/utils/delay.ts | 3 + src/shared/utils/index.ts | 4 + src/shared/utils/navigation.ts | 6 + src/shared/utils/uuid.ts | 13 + src/types.ts | 54 - src/vite-env.d.ts | 1 + src/widgets/footer/Footer.tsx | 28 + src/widgets/footer/NoticeBanner.tsx | 16 + src/widgets/footer/index.ts | 2 + src/widgets/header/Header.tsx | 146 ++ src/widgets/header/index.ts | 1 + .../hero-banner}/HeroBanner.tsx | 87 +- src/widgets/hero-banner/index.ts | 1 + src/widgets/mobile-nav/MobileNav.tsx | 37 + src/widgets/mobile-nav/index.ts | 1 + .../policy-card-grid/PolicyCardGrid.tsx | 34 + src/widgets/policy-card-grid/index.ts | 1 + .../PolicyDetailModalContainer.tsx | 51 + .../PolicyDetailModalPresenter.tsx} | 126 +- src/widgets/policy-detail-modal/index.ts | 1 + .../recently-viewed/RecentlyViewed.tsx | 77 + src/widgets/recently-viewed/index.ts | 1 + .../RecommendationFeed.tsx | 72 + src/widgets/recommendation-feed/index.ts | 1 + tsconfig.json | 2 +- vite.config.ts | 2 +- 175 files changed, 6200 insertions(+), 4284 deletions(-) delete mode 100755 .env.example create mode 100644 pnpm-workspace.yaml delete mode 100755 server.ts delete mode 100755 src/App.tsx create mode 100644 src/app/layouts/RootLayout.tsx create mode 100644 src/app/providers/AppProviders.tsx create mode 100644 src/app/providers/index.ts create mode 100644 src/app/router/ProtectedRoute.tsx create mode 100644 src/app/router/index.ts create mode 100644 src/app/router/router.tsx delete mode 100755 src/components/ChatbotAssistant.tsx delete mode 100755 src/components/CompareModule.tsx delete mode 100755 src/components/FilterBar.tsx delete mode 100755 src/components/Header.tsx delete mode 100755 src/components/RecentlyViewed.tsx create mode 100644 src/entities/policy/api/policy.dto.ts create mode 100644 src/entities/policy/api/policyApi.ts rename src/{data.ts => entities/policy/api/policyMockData.ts} (91%) mode change 100755 => 100644 create mode 100644 src/entities/policy/index.ts create mode 100644 src/entities/policy/model/policy.types.ts create mode 100644 src/entities/policy/model/policyCategories.ts create mode 100644 src/entities/policy/model/policyDetailStore.ts create mode 100644 src/entities/policy/model/policyMapper.ts create mode 100644 src/entities/policy/model/policyQueries.ts create mode 100644 src/entities/policy/model/policyStatuses.ts rename src/{components => entities/policy/ui}/PolicyCard.tsx (60%) mode change 100755 => 100644 create mode 100644 src/entities/policy/ui/PolicyCategoryBadge.tsx create mode 100644 src/entities/user/index.ts create mode 100644 src/entities/user/model/authStore.ts create mode 100644 src/entities/user/model/profileStore.ts create mode 100644 src/entities/user/model/user.types.ts create mode 100644 src/entities/user/ui/ProfileSummaryCard.tsx create mode 100644 src/features/admin-sync/api/adminApi.ts create mode 100644 src/features/admin-sync/components/AdminQaNote.tsx create mode 100644 src/features/admin-sync/components/SyncHistoryTable.tsx create mode 100644 src/features/admin-sync/components/SyncSummaryCards.tsx create mode 100644 src/features/admin-sync/hooks/useRunSync.ts create mode 100644 src/features/admin-sync/hooks/useSyncHistory.ts create mode 100644 src/features/admin-sync/index.ts create mode 100644 src/features/admin-sync/types/adminSync.types.ts create mode 100644 src/features/auth/components/AccountDangerZone.tsx create mode 100644 src/features/auth/components/SocialLoginPanel.tsx create mode 100644 src/features/auth/hooks/useDeleteAccount.ts create mode 100644 src/features/auth/hooks/useLogout.ts create mode 100644 src/features/auth/hooks/useSocialLogin.ts create mode 100644 src/features/auth/index.ts create mode 100644 src/features/chatbot/api/chatApi.ts create mode 100644 src/features/chatbot/components/ChatDrawer.tsx create mode 100644 src/features/chatbot/components/ChatMessageBubble.tsx create mode 100644 src/features/chatbot/components/ChatbotContainer.tsx create mode 100644 src/features/chatbot/components/ChatbotWidget.tsx create mode 100644 src/features/chatbot/hooks/useChatbot.ts create mode 100644 src/features/chatbot/index.ts create mode 100644 src/features/chatbot/types/chat.types.ts create mode 100644 src/features/policy-bookmark/api/bookmarkApi.ts create mode 100644 src/features/policy-bookmark/components/SavedPolicyList/SavedPolicyItem.tsx create mode 100644 src/features/policy-bookmark/components/SavedPolicyList/SavedPolicyList.tsx create mode 100644 src/features/policy-bookmark/components/SavedPolicyList/index.ts create mode 100644 src/features/policy-bookmark/hooks/useBookmark.ts create mode 100644 src/features/policy-bookmark/index.ts create mode 100644 src/features/policy-compare/components/CompareDetailDialog.tsx create mode 100644 src/features/policy-compare/components/ComparePanelContainer.tsx create mode 100644 src/features/policy-compare/components/ComparePanelPresenter.tsx create mode 100644 src/features/policy-compare/hooks/useCompare.ts create mode 100644 src/features/policy-compare/index.ts create mode 100644 src/features/policy-compare/store/compareStore.ts create mode 100644 src/features/policy-recommendation/api/recommendApi.ts create mode 100644 src/features/policy-recommendation/components/ProfileBriefing.tsx create mode 100644 src/features/policy-recommendation/components/RecommendationCard.tsx create mode 100644 src/features/policy-recommendation/components/RecommendationPreview.tsx create mode 100644 src/features/policy-recommendation/hooks/useRecommendations.ts create mode 100644 src/features/policy-recommendation/index.ts create mode 100644 src/features/policy-recommendation/types/recommendation.types.ts create mode 100644 src/features/policy-search/components/PolicyFilterBar.tsx create mode 100644 src/features/policy-search/components/PolicySearchBar.tsx create mode 100644 src/features/policy-search/components/SearchEmptyState.tsx create mode 100644 src/features/policy-search/hooks/usePolicySearch.ts create mode 100644 src/features/policy-search/hooks/useSearchFilters.ts create mode 100644 src/features/policy-search/index.ts create mode 100644 src/features/policy-search/policySearchOptions.ts create mode 100644 src/features/policy-search/types/policySearch.types.ts create mode 100644 src/features/policy-tracker/api/trackerApi.ts create mode 100644 src/features/policy-tracker/components/TrackerContainer.tsx create mode 100644 src/features/policy-tracker/components/TrackerDetailPanel/TrackerChecklist.tsx create mode 100644 src/features/policy-tracker/components/TrackerDetailPanel/TrackerDeleteConfirm.tsx create mode 100644 src/features/policy-tracker/components/TrackerDetailPanel/TrackerDetailPanel.tsx create mode 100644 src/features/policy-tracker/components/TrackerDetailPanel/TrackerMemoEditor.tsx create mode 100644 src/features/policy-tracker/components/TrackerDetailPanel/index.ts create mode 100644 src/features/policy-tracker/components/TrackerListPanel/TrackerCard.tsx create mode 100644 src/features/policy-tracker/components/TrackerListPanel/TrackerListPanel.tsx create mode 100644 src/features/policy-tracker/components/TrackerListPanel/TrackerStatusTabs.tsx create mode 100644 src/features/policy-tracker/components/TrackerListPanel/index.ts create mode 100644 src/features/policy-tracker/hooks/useTrackerMutations.ts create mode 100644 src/features/policy-tracker/hooks/useTrackerSelection.ts create mode 100644 src/features/policy-tracker/hooks/useTrackerStartParam.ts create mode 100644 src/features/policy-tracker/hooks/useTrackers.ts create mode 100644 src/features/policy-tracker/index.ts create mode 100644 src/features/policy-tracker/types/tracker.types.ts create mode 100644 src/features/profile-setup/components/ProfileSetupContainer.tsx create mode 100644 src/features/profile-setup/components/ProfileSetupPresenter.tsx create mode 100644 src/features/profile-setup/components/WizardStepBasic.tsx create mode 100644 src/features/profile-setup/components/WizardStepInterest.tsx create mode 100644 src/features/profile-setup/components/WizardStepStatus.tsx create mode 100644 src/features/profile-setup/hooks/useProfileSetupWizard.ts create mode 100644 src/features/profile-setup/index.ts create mode 100644 src/pages/admin/AdminPage.tsx create mode 100644 src/pages/admin/index.ts create mode 100644 src/pages/home/HomePage.tsx create mode 100644 src/pages/home/components/CategoryQuickLinks.tsx create mode 100644 src/pages/home/components/GuestRecommendCta.tsx create mode 100644 src/pages/home/index.ts create mode 100644 src/pages/login/LoginPage.tsx create mode 100644 src/pages/login/index.ts create mode 100644 src/pages/my/MyPage.tsx create mode 100644 src/pages/my/index.ts create mode 100644 src/pages/profile-setup/ProfileSetupPage.tsx create mode 100644 src/pages/profile-setup/index.ts create mode 100644 src/pages/recommend/RecommendPage.tsx create mode 100644 src/pages/recommend/index.ts create mode 100644 src/pages/search/SearchPage.tsx create mode 100644 src/pages/search/index.ts create mode 100644 src/pages/tracker/TrackerPage.tsx create mode 100644 src/pages/tracker/index.ts create mode 100644 src/shared/api/apiClient.ts create mode 100644 src/shared/api/index.ts create mode 100644 src/shared/api/queryClient.ts create mode 100644 src/shared/constants/api.ts create mode 100644 src/shared/constants/compare.ts create mode 100644 src/shared/constants/index.ts create mode 100644 src/shared/constants/regions.ts create mode 100644 src/shared/constants/routes.ts create mode 100644 src/shared/hooks/index.ts create mode 100644 src/shared/hooks/useDebouncedValue.ts create mode 100644 src/shared/ui/ConfirmDialog.tsx create mode 100644 src/shared/ui/EmptyState.tsx create mode 100644 src/shared/ui/ErrorState.tsx create mode 100644 src/shared/ui/Skeleton.tsx create mode 100644 src/shared/ui/index.ts create mode 100644 src/shared/ui/toast/ToastContainer.tsx create mode 100644 src/shared/ui/toast/index.ts create mode 100644 src/shared/ui/toast/toastStore.ts create mode 100644 src/shared/ui/toast/useToast.ts create mode 100644 src/shared/utils/date.ts create mode 100644 src/shared/utils/delay.ts create mode 100644 src/shared/utils/index.ts create mode 100644 src/shared/utils/navigation.ts create mode 100644 src/shared/utils/uuid.ts delete mode 100755 src/types.ts create mode 100644 src/vite-env.d.ts create mode 100644 src/widgets/footer/Footer.tsx create mode 100644 src/widgets/footer/NoticeBanner.tsx create mode 100644 src/widgets/footer/index.ts create mode 100644 src/widgets/header/Header.tsx create mode 100644 src/widgets/header/index.ts rename src/{components => widgets/hero-banner}/HeroBanner.tsx (79%) mode change 100755 => 100644 create mode 100644 src/widgets/hero-banner/index.ts create mode 100644 src/widgets/mobile-nav/MobileNav.tsx create mode 100644 src/widgets/mobile-nav/index.ts create mode 100644 src/widgets/policy-card-grid/PolicyCardGrid.tsx create mode 100644 src/widgets/policy-card-grid/index.ts create mode 100644 src/widgets/policy-detail-modal/PolicyDetailModalContainer.tsx rename src/{components/DetailModal.tsx => widgets/policy-detail-modal/PolicyDetailModalPresenter.tsx} (68%) mode change 100755 => 100644 create mode 100644 src/widgets/policy-detail-modal/index.ts create mode 100644 src/widgets/recently-viewed/RecentlyViewed.tsx create mode 100644 src/widgets/recently-viewed/index.ts create mode 100644 src/widgets/recommendation-feed/RecommendationFeed.tsx create mode 100644 src/widgets/recommendation-feed/index.ts diff --git a/.env.example b/.env.example deleted file mode 100755 index 7a550fe..0000000 --- a/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -# GEMINI_API_KEY: Required for Gemini AI API calls. -# AI Studio automatically injects this at runtime from user secrets. -# Users configure this via the Secrets panel in the AI Studio UI. -GEMINI_API_KEY="MY_GEMINI_API_KEY" - -# APP_URL: The URL where this applet is hosted. -# AI Studio automatically injects this at runtime with the Cloud Run service URL. -# Used for self-referential links, OAuth callbacks, and API endpoints. -APP_URL="MY_APP_URL" diff --git a/CLAUDE.md b/CLAUDE.md index fc01f8d..5a59360 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,9 +7,11 @@ Claude Code 또는 유사한 코딩 에이전트는 이 레포에서 작업할 - Follow `rules.md` before making code changes. - Use the issue-first workflow: create GitHub Issue → create issue-numbered branch → commit → open PR → verify → merge. - Keep the FSD structure: `app`, `pages`, `widgets`, `features`, `entities`, `shared`. -- Do not break FSD dependency direction: `app → pages → widgets → features → entities → shared`. +- Do not break the dependency direction: `app → pages → widgets → features → entities → shared`, and never import feature → feature. +- Promote a domain model to `entities/{domain}` once two or more features share it; keep it feature-local otherwise. - Use the Container/Presenter pattern for new or significantly changed features. -- Separate feature logic into custom hooks under `model`. +- Separate feature logic into custom hooks under `hooks`. +- Use TanStack Query for server state and Zustand only for client/UI/auth state. - Presenters must render from props; they must not own API calls, storage side effects, or complex state transitions. - Always follow the design system in `src/index.css` and existing UI patterns. - Do not introduce arbitrary colors, spacing, radius, shadows, or fonts when tokens/patterns already exist. diff --git a/index.html b/index.html index 21dfe69..d10dad6 100755 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - My Google AI Studio App + YouthPick
diff --git a/package.json b/package.json index 186f1e7..42ce58d 100755 --- a/package.json +++ b/package.json @@ -6,32 +6,32 @@ "packageManager": "pnpm@11.9.0", "scripts": { "preinstall": "node -e \"if (!process.env.npm_config_user_agent?.includes('pnpm')) { console.error('Use pnpm instead of npm or yarn.'); process.exit(1); }\"", - "dev": "tsx server.ts", - "build": "vite build && esbuild server.ts --bundle --platform=node --format=cjs --packages=external --sourcemap --outfile=dist/server.cjs", - "start": "node dist/server.cjs", - "clean": "rm -rf dist server.js", + "dev": "vite", + "build": "vite build", + "start": "vite preview", + "clean": "rm -rf dist", "lint": "tsc --noEmit" }, "dependencies": { - "@google/genai": "^2.4.0", "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-query": "^5.101.2", "@vitejs/plugin-react": "^5.0.4", + "axios": "^1.18.1", "lucide-react": "^0.546.0", + "motion": "^12.23.24", "react": "^19.0.1", "react-dom": "^19.0.1", + "react-router": "^8.1.0", "vite": "^6.2.3", - "express": "^4.21.2", - "dotenv": "^17.2.3", - "motion": "^12.23.24" + "zustand": "^5.0.14" }, "devDependencies": { "@types/node": "^22.14.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "autoprefixer": "^10.4.21", - "esbuild": "^0.25.0", "tailwindcss": "^4.1.14", - "tsx": "^4.21.0", "typescript": "~5.8.2", - "vite": "^6.2.3", - "@types/express": "^4.17.21" + "vite": "^6.2.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf71d62..3c62bc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,21 +8,18 @@ importers: .: dependencies: - '@google/genai': - specifier: ^2.4.0 - version: 2.10.0 '@tailwindcss/vite': specifier: ^4.1.14 version: 4.3.1(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)) + '@tanstack/react-query': + specifier: ^5.101.2 + version: 5.101.2(react@19.2.7) '@vitejs/plugin-react': specifier: ^5.0.4 version: 5.2.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)) - dotenv: - specifier: ^17.2.3 - version: 17.4.2 - express: - specifier: ^4.21.2 - version: 4.22.2 + axios: + specifier: ^1.18.1 + version: 1.18.1 lucide-react: specifier: ^0.546.0 version: 0.546.0(react@19.2.7) @@ -35,28 +32,31 @@ importers: react-dom: specifier: ^19.0.1 version: 19.2.7(react@19.2.7) + react-router: + specifier: ^8.1.0 + version: 8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) vite: specifier: ^6.2.3 version: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7) devDependencies: - '@types/express': - specifier: ^4.17.21 - version: 4.17.25 '@types/node': specifier: ^22.14.0 version: 22.20.0 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) autoprefixer: specifier: ^10.4.21 version: 10.5.2(postcss@8.5.15) - esbuild: - specifier: ^0.25.0 - version: 0.25.12 tailwindcss: specifier: ^4.1.14 version: 4.3.1 - tsx: - specifier: ^4.21.0 - version: 4.22.4 typescript: specifier: ~5.8.2 version: 5.8.3 @@ -458,15 +458,6 @@ packages: cpu: [x64] os: [win32] - '@google/genai@2.10.0': - resolution: {integrity: sha512-e4cFxj3tiuMtsgOT4G9c1hXyGJhg7/Buj7VVeBacRY3fRtkRZZ59Q3nuVp2xbq8BGQXLXCDB253qMhklMOeUDg==} - engines: {node: '>=20.0.0'} - peerDependencies: - '@modelcontextprotocol/sdk': ^1.25.2 - peerDependenciesMeta: - '@modelcontextprotocol/sdk': - optional: true - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -483,33 +474,6 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@protobufjs/aspromise@1.1.2': - resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} - - '@protobufjs/base64@1.1.2': - resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} - - '@protobufjs/codegen@2.0.5': - resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} - - '@protobufjs/eventemitter@1.1.1': - resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} - - '@protobufjs/fetch@1.1.1': - resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} - - '@protobufjs/float@1.0.2': - resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - - '@protobufjs/path@1.1.2': - resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} - - '@protobufjs/pool@1.1.0': - resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} - - '@protobufjs/utf8@1.1.1': - resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} - '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -745,6 +709,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -757,47 +729,19 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/body-parser@1.19.6': - resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - - '@types/connect@3.4.38': - resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/express-serve-static-core@4.19.8': - resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==} - - '@types/express@4.17.25': - resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} - - '@types/http-errors@2.0.5': - resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} - - '@types/mime@1.3.5': - resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/node@22.20.0': resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} - '@types/qs@6.15.1': - resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} - - '@types/range-parser@1.2.7': - resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - - '@types/retry@0.12.0': - resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} - - '@types/send@0.17.6': - resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} - - '@types/send@1.2.1': - resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 - '@types/serve-static@1.15.10': - resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} @@ -805,16 +749,12 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - - agent-base@7.1.4: - resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} - engines: {node: '>= 14'} + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} autoprefixer@10.5.2: resolution: {integrity: sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==} @@ -823,73 +763,38 @@ packages: peerDependencies: postcss: ^8.1.0 - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} baseline-browser-mapping@2.10.40: resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} engines: {node: '>=6.0.0'} hasBin: true - bignumber.js@9.3.1: - resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - - body-parser@1.20.5: - resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - browserslist@4.28.4: resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - buffer-equal-constant-time@1.0.1: - resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - - 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-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -900,39 +805,21 @@ packages: supports-color: optional: true - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - ecdsa-sig-formatter@1.0.11: - resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.379: resolution: {integrity: sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==} - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - enhanced-resolve@5.21.6: resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} @@ -949,6 +836,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -963,20 +854,6 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - express@4.22.2: - resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} - engines: {node: '>= 0.10.0'} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -986,21 +863,18 @@ packages: picomatch: optional: true - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} - - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1019,10 +893,6 @@ packages: react-dom: optional: true - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1031,14 +901,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - gaxios@7.1.5: - resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} - engines: {node: '>=18'} - - gcp-metadata@8.1.2: - resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} - engines: {node: '>=18'} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1051,14 +913,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - google-auth-library@10.9.0: - resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} - engines: {node: '>=18'} - - google-logging-utils@1.1.3: - resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} - engines: {node: '>=14'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1070,28 +924,17 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - 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'} - - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} @@ -1105,20 +948,11 @@ packages: engines: {node: '>=6'} hasBin: true - json-bigint@1.0.0: - resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true - jwa@2.0.1: - resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} - - jws@4.0.1: - resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1193,9 +1027,6 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} - long@5.3.2: - resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1211,17 +1042,6 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1230,11 +1050,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - motion-dom@12.42.0: resolution: {integrity: sha512-M63h4n8R+quJdNhBwuLlgxM+OLYa9+I/T2pzDRboB9fLXRdbou+Gw7Zury+SkpaCyACP1JHSjHgZ1EgTkBr30w==} @@ -1255,9 +1070,6 @@ packages: react-dom: optional: true - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1266,42 +1078,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - - 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-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.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - p-retry@4.6.2: - resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} - engines: {node: '>=8'} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-to-regexp@0.1.13: - resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1316,25 +1096,9 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} - protobufjs@7.6.4: - resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} - engines: {node: '>=12.0.0'} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} - engines: {node: '>=0.6'} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} @@ -1345,25 +1109,25 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-router@8.1.0: + resolution: {integrity: sha512-Mdfi61uObuvWNN9OhChOC0HV6YWOIfKRzEWOvCHRSuQg8IM+Nv10edaM/2HE8ZixBpUTdQbruyWqC3sDkkh9vw==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} - retry@0.13.1: - resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} - engines: {node: '>= 4'} - rollup@4.62.2: resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1371,41 +1135,10 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - side-channel-list@1.0.1: - resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} - 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.1: - resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} - engines: {node: '>= 0.4'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - tailwindcss@4.3.1: resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} @@ -1417,10 +1150,6 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1429,10 +1158,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -1441,24 +1166,12 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - vite@6.4.3: resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1499,24 +1212,26 @@ packages: yaml: optional: true - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} - engines: {node: '>=10.0.0'} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' peerDependenciesMeta: - bufferutil: + '@types/react': optional: true - utf-8-validate: + immer: + optional: true + react: + optional: true + use-sync-external-store: optional: true - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} snapshots: @@ -1788,17 +1503,6 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@google/genai@2.10.0': - dependencies: - google-auth-library: 10.9.0 - p-retry: 4.6.2 - protobufjs: 7.6.4 - ws: 8.21.0 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1818,26 +1522,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@protobufjs/aspromise@1.1.2': {} - - '@protobufjs/base64@1.1.2': {} - - '@protobufjs/codegen@2.0.5': {} - - '@protobufjs/eventemitter@1.1.1': {} - - '@protobufjs/fetch@1.1.1': - dependencies: - '@protobufjs/aspromise': 1.1.2 - - '@protobufjs/float@1.0.2': {} - - '@protobufjs/path@1.1.2': {} - - '@protobufjs/pool@1.1.0': {} - - '@protobufjs/utf8@1.1.1': {} - '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.62.2': @@ -1983,6 +1667,13 @@ snapshots: tailwindcss: 4.3.1 vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4) + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -2004,59 +1695,19 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@types/body-parser@1.19.6': - dependencies: - '@types/connect': 3.4.38 - '@types/node': 22.20.0 - - '@types/connect@3.4.38': - dependencies: - '@types/node': 22.20.0 - '@types/estree@1.0.9': {} - '@types/express-serve-static-core@4.19.8': - dependencies: - '@types/node': 22.20.0 - '@types/qs': 6.15.1 - '@types/range-parser': 1.2.7 - '@types/send': 1.2.1 - - '@types/express@4.17.25': - dependencies: - '@types/body-parser': 1.19.6 - '@types/express-serve-static-core': 4.19.8 - '@types/qs': 6.15.1 - '@types/serve-static': 1.15.10 - - '@types/http-errors@2.0.5': {} - - '@types/mime@1.3.5': {} - '@types/node@22.20.0': dependencies: undici-types: 6.21.0 - '@types/qs@6.15.1': {} - - '@types/range-parser@1.2.7': {} - - '@types/retry@0.12.0': {} - - '@types/send@0.17.6': - dependencies: - '@types/mime': 1.3.5 - '@types/node': 22.20.0 - - '@types/send@1.2.1': + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: - '@types/node': 22.20.0 + '@types/react': 19.2.17 - '@types/serve-static@1.15.10': + '@types/react@19.2.17': dependencies: - '@types/http-errors': 2.0.5 - '@types/node': 22.20.0 - '@types/send': 0.17.6 + csstype: 3.2.3 '@vitejs/plugin-react@5.2.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4))': dependencies: @@ -2070,14 +1721,13 @@ snapshots: transitivePeerDependencies: - supports-color - accepts@1.3.8: + agent-base@6.0.2: dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - - agent-base@7.1.4: {} + debug: 4.4.3 + transitivePeerDependencies: + - supports-color - array-flatten@1.1.1: {} + asynckit@0.4.0: {} autoprefixer@10.5.2(postcss@8.5.15): dependencies: @@ -2088,29 +1738,18 @@ snapshots: postcss: 8.5.15 postcss-value-parser: 4.2.0 - base64-js@1.5.1: {} - - baseline-browser-mapping@2.10.40: {} - - bignumber.js@9.3.1: {} - - body-parser@1.20.5: + axios@1.18.1: dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.15.3 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: + - debug - supports-color + baseline-browser-mapping@2.10.40: {} + browserslist@4.28.4: dependencies: baseline-browser-mapping: 2.10.40 @@ -2119,68 +1758,39 @@ snapshots: node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) - buffer-equal-constant-time@1.0.1: {} - - bytes@3.1.2: {} - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - caniuse-lite@1.0.30001799: {} - content-disposition@0.5.4: + combined-stream@1.0.8: dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} + delayed-stream: 1.0.0 convert-source-map@2.0.0: {} - cookie-signature@1.0.7: {} + cookie-es@3.1.1: {} - cookie@0.7.2: {} - - data-uri-to-buffer@4.0.1: {} - - debug@2.6.9: - dependencies: - ms: 2.0.0 + csstype@3.2.3: {} debug@4.4.3: dependencies: ms: 2.1.3 - depd@2.0.0: {} - - destroy@1.2.0: {} + delayed-stream@1.0.0: {} detect-libc@2.1.2: {} - dotenv@17.4.2: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 - ecdsa-sig-formatter@1.0.11: - dependencies: - safe-buffer: 5.2.1 - - ee-first@1.1.1: {} - electron-to-chromium@1.5.379: {} - encodeurl@2.0.0: {} - enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 @@ -2194,6 +1804,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -2251,77 +1868,23 @@ snapshots: '@esbuild/win32-arm64': 0.28.1 '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + optional: true escalade@3.2.0: {} - escape-html@1.0.3: {} - - etag@1.8.1: {} - - express@4.22.2: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.5 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.13 - proxy-addr: 2.0.7 - qs: 6.15.3 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - extend@3.0.2: {} - 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 + follow-redirects@1.16.0: {} - finalhandler@1.3.2: + form-data@4.0.6: dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 - - forwarded@0.2.0: {} + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 fraction.js@5.3.4: {} @@ -2334,29 +1897,11 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - fresh@0.5.2: {} - fsevents@2.3.3: optional: true function-bind@1.1.2: {} - gaxios@7.1.5: - dependencies: - extend: 3.0.2 - https-proxy-agent: 7.0.6 - node-fetch: 3.3.2 - transitivePeerDependencies: - - supports-color - - gcp-metadata@8.1.2: - dependencies: - gaxios: 7.1.5 - google-logging-utils: 1.1.3 - json-bigint: 1.0.0 - transitivePeerDependencies: - - supports-color - gensync@1.0.0-beta.2: {} get-intrinsic@1.3.0: @@ -2377,75 +1922,35 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 - google-auth-library@10.9.0: - dependencies: - base64-js: 1.5.1 - ecdsa-sig-formatter: 1.0.11 - gaxios: 7.1.5 - gcp-metadata: 8.1.2 - google-logging-utils: 1.1.3 - jws: 4.0.1 - transitivePeerDependencies: - - supports-color - - google-logging-utils@1.1.3: {} - gopd@1.2.0: {} graceful-fs@4.2.11: {} has-symbols@1.1.0: {} - hasown@2.0.4: + has-tostringtag@1.0.2: dependencies: - function-bind: 1.1.2 + has-symbols: 1.1.0 - http-errors@2.0.1: + hasown@2.0.4: dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 + function-bind: 1.1.2 - https-proxy-agent@7.0.6: + https-proxy-agent@5.0.1: dependencies: - agent-base: 7.1.4 + agent-base: 6.0.2 debug: 4.4.3 transitivePeerDependencies: - supports-color - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - inherits@2.0.4: {} - - ipaddr.js@1.9.1: {} - jiti@2.7.0: {} js-tokens@4.0.0: {} jsesc@3.1.0: {} - json-bigint@1.0.0: - dependencies: - bignumber.js: 9.3.1 - json5@2.2.3: {} - jwa@2.0.1: - dependencies: - buffer-equal-constant-time: 1.0.1 - ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.2.1 - - jws@4.0.1: - dependencies: - jwa: 2.0.1 - safe-buffer: 5.2.1 - lightningcss-android-arm64@1.32.0: optional: true @@ -2495,8 +2000,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 - long@5.3.2: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -2511,20 +2014,12 @@ snapshots: math-intrinsics@1.1.0: {} - media-typer@0.3.0: {} - - merge-descriptors@1.0.3: {} - - methods@1.1.2: {} - mime-db@1.52.0: {} mime-types@2.1.35: dependencies: mime-db: 1.52.0 - mime@1.6.0: {} - motion-dom@12.42.0: dependencies: motion-utils: 12.39.0 @@ -2539,39 +2034,12 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - ms@2.0.0: {} - ms@2.1.3: {} nanoid@3.3.15: {} - negotiator@0.6.3: {} - - node-domexception@1.0.0: {} - - 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.50: {} - object-inspect@1.13.4: {} - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - p-retry@4.6.2: - dependencies: - '@types/retry': 0.12.0 - retry: 0.13.1 - - parseurl@1.3.3: {} - - path-to-regexp@0.1.13: {} - picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -2584,38 +2052,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - protobufjs@7.6.4: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.1 - '@protobufjs/fetch': 1.1.1 - '@protobufjs/float': 1.0.2 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.1 - '@types/node': 22.20.0 - long: 5.3.2 - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - qs@6.15.3: - dependencies: - es-define-property: 1.0.1 - side-channel: 1.1.1 - - range-parser@1.2.1: {} - - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 + proxy-from-env@2.1.0: {} react-dom@19.2.7(react@19.2.7): dependencies: @@ -2624,9 +2061,14 @@ snapshots: react-refresh@0.18.0: {} - react@19.2.7: {} + react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie-es: 3.1.1 + react: 19.2.7 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) - retry@0.13.1: {} + react@19.2.7: {} rollup@4.62.2: dependencies: @@ -2659,75 +2101,12 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 - safe-buffer@5.2.1: {} - - safer-buffer@2.1.2: {} - scheduler@0.27.0: {} semver@6.3.1: {} - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - - setprototypeof@1.2.0: {} - - side-channel-list@1.0.1: - 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.1: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.1 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - source-map-js@1.2.1: {} - statuses@2.0.2: {} - tailwindcss@4.3.1: {} tapable@2.3.3: {} @@ -2737,8 +2116,6 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - toidentifier@1.0.1: {} - tslib@2.8.1: {} tsx@4.22.4: @@ -2746,28 +2123,18 @@ snapshots: esbuild: 0.28.1 optionalDependencies: fsevents: 2.3.3 - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 + optional: true typescript@5.8.3: {} undici-types@6.21.0: {} - unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.4): dependencies: browserslist: 4.28.4 escalade: 3.2.0 picocolors: 1.1.1 - utils-merge@1.0.1: {} - - vary@1.1.2: {} - vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4): dependencies: esbuild: 0.25.12 @@ -2783,8 +2150,9 @@ snapshots: lightningcss: 1.32.0 tsx: 4.22.4 - web-streams-polyfill@3.3.3: {} - - ws@8.21.0: {} - yallist@3.1.1: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/rules.md b/rules.md index 93263ef..0fb42b4 100644 --- a/rules.md +++ b/rules.md @@ -1,95 +1,102 @@ # BOP Front-end Agent Rules -이 문서는 `front-end-proto`에서 작업하는 모든 에이전트와 개발자가 따라야 하는 프론트엔드 구현 규칙이다. 새 기능을 만들거나 기존 코드를 수정하기 전에 반드시 이 파일을 먼저 읽고, 이 규칙과 충돌하는 구현은 하지 않는다. +이 문서는 이 레포에서 작업하는 모든 에이전트와 개발자가 따라야 하는 프론트엔드 구현 규칙이다. 새 기능을 만들거나 기존 코드를 수정하기 전에 반드시 이 파일을 먼저 읽고, 이 규칙과 충돌하는 구현은 하지 않는다. -## 1. 작업 흐름 +핵심 목표는 사용자가 현재 상태를 명확하게 이해할 수 있고, API 장애 상황에서도 서비스 흐름이 끊기지 않는 UI를 만드는 것이다. -- 모든 작업은 GitHub Issue를 먼저 만들고, issue 번호가 포함된 브랜치에서 진행한다. -- 브랜치 형식은 `feat/{issue-number}-{short-name}`, `fix/{issue-number}-{short-name}`, `docs/{issue-number}-{short-name}`, `refac/{issue-number}-{short-name}`를 따른다. -- 구현 후에는 `pnpm run lint`, `pnpm run build`를 직접 실행하고 PR에 실제 결과를 적는다. -- 단순 설명이나 스텁으로 끝내지 않는다. 동작하는 코드와 검증 결과를 남긴다. -- 백엔드 API/기획/API 명세와 연결되는 변경은 docs 레포의 기획/API 문서를 먼저 확인하고 필요하면 문서도 갱신한다. - -## 2. 기술 기준 +## 1. 기술 스택 -- React 19를 사용한다. -- TypeScript를 사용한다. -- Vite를 사용한다. -- Tailwind CSS 4를 사용한다. -- package manager는 pnpm을 사용한다. -- 프론트는 Vite 정적 SPA로 빌드하고, production smoke는 Vite preview 서버(`pnpm start`)로 확인한다. 별도 Node 서버를 추가하지 않는다. +- Core: React 19, TypeScript, Vite +- Routing: React Router +- Server State: TanStack Query +- Client State: Zustand +- API Client: Axios +- Styling: Tailwind CSS 4, shadcn/ui +- Form: React Hook Form, Zod +- Notification: Sonner +- Code Quality: Biome +- Package Manager: pnpm -기본 검증 명령은 다음과 같다. +프론트는 Vite 정적 SPA로 빌드하고, production smoke는 Vite preview 서버(`pnpm start`)로 확인한다. 별도 Node 서버를 추가하지 않는다. -```bash -corepack prepare pnpm@11.9.0 --activate -corepack pnpm install --frozen-lockfile -corepack pnpm run lint -corepack pnpm run build -``` +## 2. 작업 흐름 -Docker/Compose 관련 변경은 로컬에서 가능한 범위로 `docker compose config`, build, runtime smoke를 검증한다. 실행 환경에 Docker가 없으면 PR 검증 결과에 명시한다. +- 모든 작업은 GitHub Issue를 먼저 만들고, issue 번호가 포함된 브랜치에서 진행한다. +- 브랜치 형식은 `feat/{issue-number}-{short-name}`, `fix/{issue-number}-{short-name}`, `docs/{issue-number}-{short-name}`, `refac/{issue-number}-{short-name}`를 따른다. +- 커밋 형식은 `type: subject`이며, 허용 type은 `feat`, `fix`, `docs`, `refac`, `test`, `chore`이다. +- PR 본문에는 변경 요약, 테스트 결과, 필요한 경우 스크린샷/로그, 그리고 `Closes #issue-number`로 연결한 이슈를 포함한다. +- 구현 후에는 검증 명령을 직접 실행하고 PR에 실제 결과를 적는다. +- 단순 설명이나 스텁으로 끝내지 않는다. 동작하는 코드와 검증 결과를 남긴다. +- 백엔드 API/기획/API 명세와 연결되는 변경은 docs 레포의 기획/API 문서를 먼저 확인하고 필요하면 문서도 갱신한다. ## 3. FSD 폴더 구조 -패키지 구조는 Feature-Sliced Design(FSD)을 따른다. 새 코드는 `app`, `pages`, `widgets`, `features`, `entities`, `shared` 레이어 중 책임에 맞는 위치에 둔다. - -현재 기준 구조는 다음과 같다. +패키지 구조는 Feature-Sliced Design(FSD) 아키텍처를 따른다. `app`, `pages`, `widgets`, `features`, `entities`, `shared` 6개 레이어로 구성하고, 새 코드는 FSD 레이어 책임에 맞는 위치에 둔다. ```text src ├── app +│ ├── router +│ ├── providers +│ └── store ├── pages ├── widgets ├── features +│ └── {feature} +│ ├── api # HTTP 요청 함수, API DTO, endpoint 호출 +│ ├── hooks # custom hook (use 접두) +│ ├── components # Container / Presenter component +│ ├── store # feature 전용 Zustand store (필요할 때만) +│ ├── types # feature 타입 +│ └── index.ts # public API export ├── entities -└── shared +│ └── {domain} # 여러 feature가 공유하는 도메인 (policy, user 등) +│ ├── api # 도메인 DTO, 조회 함수 +│ ├── model # 도메인 타입, mapper, 공용 hook/store +│ ├── ui # 도메인 표현 컴포넌트 (PolicyCard 등) +│ └── index.ts # public API export +├── shared +│ ├── api # axios instance, queryClient +│ ├── ui # 공통 UI (Button, Modal, Input, Skeleton, EmptyState) +│ ├── hooks # 공통 hook (useDebounce 등) +│ ├── utils # date, format, storage 등 순수 함수 +│ ├── constants +│ └── types +└── assets ``` -각 레이어 책임은 다음과 같다. - -- `app`: 앱 초기화, 전역 provider, 라우팅, 전역 스타일 연결 등 앱 조립 책임 -- `pages`: 라우트/page 단위 화면 조립 책임 -- `widgets`: 여러 feature/entity를 조합한 독립 UI 블록 -- `features`: 사용자가 수행하는 행위, use case 단위 기능 -- `entities`: 비즈니스 엔티티의 타입, API, model, UI -- `shared`: 도메인에 의존하지 않는 공통 UI, lib, api, config, types - -레이어 의존 방향은 아래를 따른다. +레이어 의존 방향은 아래를 따르며, 하위 레이어가 상위 레이어를 import하지 않는다. 같은 레이어 안의 slice끼리도 서로 import하지 않는다(특히 feature → feature 금지). ```text app → pages → widgets → features → entities → shared ``` -하위 레이어가 상위 레이어를 import하지 않는다. 예를 들어 `entities`는 `features`, `widgets`, `pages`, `app`을 import하지 않는다. +각 레이어 책임: -## 4. Slice 내부 구조 +- `app`: 앱 초기화, 전역 provider, 라우팅, 전역 store, 전역 스타일 연결 등 앱 조립 책임 +- `pages`: 라우트/page 단위 화면 조립 책임. 화면 조립만 수행한다. +- `widgets`: 여러 feature/entity를 조합한 독립 UI 블록 (Header, Footer, PolicyList, SearchFilter 등) +- `features`: 사용자가 수행하는 행위, use case 단위 비즈니스 기능. API, hook, component를 관리한다. +- `entities`: 여러 feature가 공유하는 비즈니스 도메인의 타입, API, model, 표현 UI +- `shared`: 도메인에 의존하지 않는 공통 UI, hook, util, api, config, types -각 slice는 필요한 디렉터리만 만든다. +slice 내부 세그먼트는 팀 컨벤션을 따른다. `features`는 `api / hooks / components / store / types`, `entities`는 FSD 관례대로 `api / model / ui`를 사용한다. -```text -features/policy-filter -├── api -├── model -├── lib -├── ui -└── index.ts -``` +### entities 승격 규칙 (실용 적용) -역할은 다음과 같다. +빈 entity slice를 미리 만들지 않는다. 도메인 코드는 다음 기준으로 배치한다. -- `api`: HTTP 요청 함수, API DTO, endpoint 호출 -- `model`: 상태, custom hook, slice type, mock data -- `lib`: 순수 함수, mapper, formatter, 계산 로직 -- `ui`: Presenter component, 시각적 컴포넌트 -- `index.ts`: public API export +- 도메인 모델(타입 · 표현 UI · mapper)이 **한 feature 안에서만** 쓰이면 그 feature 안에 둔다. +- 동일 도메인 모델을 **2개 이상 feature가 공유하게 되면** `entities/{domain}`으로 승격한다. +- 이 프로젝트에서 `Policy`는 `policy-search`, `policy-read`, `compare`, `bookmark`, `recommendation`, `admin`이 공유하므로 `entities/policy`에 둔다. `User`, `BoardPost` 등도 교차 사용되면 같은 기준으로 승격한다. +- feature가 다른 feature의 코드를 직접 import해야 할 상황이 오면, 그건 해당 코드를 `entities`(또는 `shared`)로 올리라는 신호다. feature → feature import로 회피하지 않는다. -외부 slice에서 내부 파일을 깊게 import하지 않는다. 가능한 `@features/policy-filter`처럼 public API를 통해 import한다. +외부 slice에서 내부 파일을 깊게 import하지 않는다. 가능한 slice의 public API(`index.ts`)를 통해 import한다. 금지 예시: ```ts -import { FilterBar } from "@features/policy-filter/ui/FilterBar"; +import { FilterBar } from "@features/policy-filter/components/FilterBar"; ``` 권장 예시: @@ -98,6 +105,43 @@ import { FilterBar } from "@features/policy-filter/ui/FilterBar"; import { FilterBar } from "@features/policy-filter"; ``` +## 4. 컴포넌트 작성 원칙 + +### 페이지는 조립만 담당 + +```tsx +function SearchPage() { + return ( + <> + + + + ); +} +``` + +### 비즈니스 로직은 feature 내부 + +기능 로직은 `features/*/hooks`의 custom hook에 둔다. + +```text +features/policy-search/hooks/usePolicySearch.ts +``` + +### 공통 UI는 shared/ui + +`Button`, `Modal`, `Input`, `EmptyState`, `Skeleton` 등 도메인 비의존 UI는 `shared/ui`에 둔다. feature 내부에서만 쓰는 UI는 해당 feature의 `components`에 둔다. + +### Props 이름은 역할이 드러나게 작성 + +```tsx +// 좋은 예 + + +// 나쁜 예 + +``` + ## 5. Container / Presenter 패턴 화면 기능은 Container와 Presenter를 분리한다. @@ -110,52 +154,87 @@ import { FilterBar } from "@features/policy-filter"; 권장 구조: ```text -features/tracker-management -├── model -│ └── use-tracker-management.ts -├── ui -│ ├── TrackerDashboardContainer.tsx -│ └── TrackerDashboardPresenter.tsx +features/policy-search +├── hooks +│ └── usePolicySearch.ts +├── components +│ ├── PolicySearchContainer.tsx +│ └── PolicySearchPresenter.tsx └── index.ts ``` Container 예시: ```tsx -export function TrackerDashboardContainer() { - const trackerState = useTrackerManagement(); +export function PolicySearchContainer() { + const searchState = usePolicySearch(); - return ; + return ; } ``` Presenter 예시: ```tsx -interface TrackerDashboardPresenterProps { +interface PolicySearchPresenterProps { isLoading: boolean; - onAddItem: () => void; + onSubmit: () => void; } -export function TrackerDashboardPresenter({ isLoading, onAddItem }: TrackerDashboardPresenterProps) { +export function PolicySearchPresenter({ isLoading, onSubmit }: PolicySearchPresenterProps) { return ( -
-
); } ``` -## 6. Custom Hook 규칙 +### 컴포넌트 조직 규칙 + +`components` 안을 뎁스 상관없이 flat하게 다 넣지 않는다. **폴더는 렌더 깊이가 아니라 "재사용 범위(누가 쓰는가)"로 나눈다.** 하나의 큰 flat을 만드는 대신, 경계로 쪼갠 작은 flat 여러 개로 구성한다. + +배치 규칙 (재사용 범위 순): -기능 로직은 custom hook으로 분리한다. +- **한 부모에서만 쓰는 자식**은 그 부모와 같은 블록 폴더에 colocate한다. +- **같은 feature 내 여러 컴포넌트가 공유**하면 `components/common`에 둔다. +- **여러 feature가 공유**하면 feature 밖으로 승격한다. 도메인 표현 UI는 `entities/{domain}/ui`, 여러 feature를 조합한 큰 블록은 `widgets`, 도메인 무관 범용 UI는 `shared/ui`로 올린다. "다른 feature의 컴포넌트를 import하고 싶다"는 순간이 곧 승격 신호다. + +폴더/파일 판단 기준: + +- 자식 컴포넌트가 있는 **블록만 폴더**로 묶는다. 자식 없는 단일 컴포넌트는 `.tsx` 파일 하나로 둔다. +- 폴더 이름은 대표(조립) 컴포넌트 이름과 같게 하고, 폴더 안의 대표 컴포넌트가 자식들을 조립한다. +- 처음부터 폴더를 만들지 않는다. 자식이 분리되어 관련 컴포넌트가 생기는 순간 파일 → 폴더로 승격한다. +- 블록 폴더 안은 flat을 유지하고, 대개 **한 단계**만 내려간다. 자식이 또 여러 자식을 가지면 그 자식이 자기 블록 폴더가 되는 것이지, 폴더를 깊게 중첩하지 않는다. +- Container는 feature 최상위에만 둔다. 중첩 자식은 모두 props를 받는 Presenter로 작성한다. +- 각 블록 폴더는 `index.ts`로 대표 컴포넌트만 노출하고, 외부에서 폴더 내부 파일을 깊게 import하지 않는다. + +권장 구조: + +```text +features/policy-search/components +├── PolicySearchContainer.tsx # 진입점 Container (feature당 1~소수) +├── PolicySearchPresenter.tsx # 최상위 Presenter (하위 블록 조립) +├── PolicyFilter/ # 자식이 있는 블록 → 폴더 +│ ├── PolicyFilter.tsx # 대표(조립) 컴포넌트 +│ ├── PolicyFilterChip.tsx # PolicyFilter 전용 자식 +│ └── index.ts # export { PolicyFilter } +├── PolicyResultList/ +│ ├── PolicyResultList.tsx +│ ├── PolicyResultItem.tsx +│ └── index.ts +└── common/ # feature 내 2곳 이상 공유하는 단일 조각 + └── EmptyResult.tsx +``` + +## 6. Custom Hook 규칙 - hook 이름은 반드시 `use`로 시작한다. -- hook은 `model` 디렉터리에 둔다. +- hook은 feature의 `hooks` 디렉터리에 둔다. - hook은 화면 상태, derived state, event handler, API loading/error 상태를 캡슐화한다. -- hook 내부에서 사용하는 effect는 cleanup을 명확히 작성한다. +- hook 내부 effect는 cleanup이 필요한지 확인하고 명확히 작성한다. - hook return 값은 Presenter가 바로 사용할 수 있는 형태로 구성한다. - 비동기 요청 hook은 `isLoading`, `errorMessage`, `isFallback`처럼 UI가 상태를 명확히 표현할 수 있는 값을 제공한다. @@ -165,7 +244,7 @@ export function TrackerDashboardPresenter({ isLoading, onAddItem }: TrackerDashb // Presenter에서 직접 fetch/localStorage/window side effect 수행 금지 function PolicyCard() { useEffect(() => { - fetch("/api/policies"); + axios.get("/api/policies"); }, []); } ``` @@ -179,115 +258,82 @@ function PolicyListContainer() { } ``` -## 7. 디자인 시스템 규칙 +## 7. 상태 관리 규칙 -디자인은 반드시 현재 디자인 시스템을 따른다. 임의 색상, 임의 radius, 임의 그림자, 임의 폰트를 남발하지 않는다. +서버 상태는 TanStack Query, 클라이언트 상태는 Zustand로 관리한다. -현재 디자인 토큰은 `src/index.css`에 정의되어 있다. +### TanStack Query (서버 상태) -- Tailwind `@theme` - - `--font-sans` - - `--font-display` - - `--color-primary` - - `--color-primary-foreground` - - `--color-primary-light` - - `--color-brand-secondary` - - `--color-secondary` - - `--color-accent` - - `--color-success` - - `--color-border` -- CSS variables - - `--background` - - `--surface` - - `--foreground` - - `--card` - - `--muted` - - `--destructive` - - `--warning` - - `--ring` - - `--radius` +정책 검색, 정책 상세, 게시판, 댓글, 관리자 데이터, 추천 정책, 챗봇 응답, 읽음 처리 등 **서버에서 오는 모든 데이터**는 TanStack Query를 우선 사용한다. -우선 사용 기준: +```tsx +const { data, isLoading, isError } = useQuery(...); +const mutation = useMutation(...); +``` -- 브랜드 CTA: `bg-primary`, `text-primary`, `from-primary to-brand-secondary` -- 카드/패널: `rounded-2xl` 또는 `rounded-3xl`, `bg-white`, `border border-slate-100`, `shadow-sm` -- 보조 배경: `bg-slate-50`, `bg-primary/5`, `bg-primary-light` -- 상태 색상: success/warning/destructive 계열 토큰 또는 Tailwind semantic 색상을 일관되게 사용 -- 폰트: `font-sans`, 필요한 display 영역만 `font-display` +### Zustand (클라이언트 상태) -금지: +Zustand는 아래 용도에만 사용한다. -- 기존 토큰과 무관한 hex 색상 직접 추가 -- 같은 의미의 버튼/카드 스타일을 매번 새로 만드는 것 -- contrast 확인 없이 흐린 텍스트/CTA 색상 추가 -- 디자인 시스템과 다른 radius/shadow/spacing을 임의 도입 +- 인증 상태: `user`, `isAuthenticated`, `role` +- 사용자 선호 정보: `categories`, `region`, `employmentStatus` +- 정책 비교함: `policyIds` +- UI 상태: `theme`, `sidebarOpen`, `modalOpen` -새 공통 UI가 필요하면 `shared/ui`에 만들고, feature 내부에서만 쓰는 UI는 해당 slice의 `ui`에 둔다. +다음은 **Zustand에 저장 금지**한다 (서버 데이터이므로 TanStack Query로 관리): +`policyList`, `policyDetail`, `boardList`, `commentList`, `searchResult`, 관리자 `users`/`logs`/`policies`. -## 8. React 코딩 컨벤션 +### 기타 -React 코드는 공식 React 권장 패턴과 커뮤니티 표준을 따른다. +- 컴포넌트 내부에서만 쓰는 UI 상태는 `useState`로 관리한다. +- feature use case 상태는 custom hook으로 분리한다. +- derived state는 원본 state에서 계산한다. 중복 state로 저장하지 않는다. +- localStorage 접근은 `shared/utils/storage` 또는 hook 내부로 캡슐화한다. -- 함수 컴포넌트를 사용한다. -- component 이름은 PascalCase를 사용한다. -- hook 이름은 camelCase + `use` prefix를 사용한다. -- props type은 component 근처에 `interface {ComponentName}Props`로 둔다. -- props는 가능한 명시적으로 선언하고, `React.FC`는 기본으로 사용하지 않는다. -- list key는 stable id를 사용한다. 동적 list에서 index key는 금지한다. -- event handler 이름은 `handleX`, props callback 이름은 `onX`를 사용한다. -- 조건부 렌더링은 early return 또는 명확한 분기로 작성한다. -- `useEffect`는 외부 시스템 동기화에만 사용한다. 단순 계산은 render 또는 `useMemo`로 처리한다. -- effect에는 cleanup이 필요한지 확인한다. -- `useMemo`, `useCallback`은 성능 문제가 있거나 memoized child에 전달할 때만 사용한다. -- 접근성을 위해 semantic HTML, `button type="button"`, `aria-label`, focus 가능 상태를 고려한다. -- `target="_blank"` 링크에는 `rel="noopener noreferrer"`를 붙인다. +## 8. API 연동 규칙 -## 9. TypeScript 코딩 컨벤션 +- API 호출 함수는 `shared/api` 또는 feature의 `api` 디렉터리에만 둔다. 컴포넌트에서 직접 axios를 호출하지 않는다. +- API 응답 DTO는 UI model과 분리한다. +- DTO → UI model 변환은 mapper 함수로 처리한다. 공유 도메인이면 `entities/{domain}/model`, feature 전용이면 feature `api` 근처에 둔다. +- API 실패, timeout, 서버 장애, 빈 응답 상태를 UI가 표현할 수 있게 hook에서 상태를 제공한다. +- mock fallback이 필요한 기능은 fallback 여부를 `isFallback` 등으로 노출한다. +- API key 같은 secret은 클라이언트 번들에 직접 노출하지 않는다. +- 브라우저 저장소에는 민감정보를 저장하지 않는다. -TypeScript는 strict한 타입 설계를 지향한다. +나쁜 예: -- `any`는 금지한다. 알 수 없는 값은 `unknown`을 사용하고 type guard로 좁힌다. -- `@ts-ignore`는 금지한다. 정말 필요한 경우 `@ts-expect-error`와 이유를 주석으로 남긴다. -- API 응답 DTO와 UI model을 분리한다. -- API DTO → UI model 변환은 `lib/*-mapper.ts`에 둔다. -- type-only import는 `import type`을 사용한다. -- union state는 string union 또는 discriminated union을 사용한다. -- nullable 값은 명시적으로 처리한다. -- public API에 노출되는 함수/컴포넌트 props는 타입을 명확히 선언한다. -- 타입 단언 `as`는 런타임 검증이 있거나 라이브러리 경계에서 불가피할 때만 사용한다. +```tsx +useEffect(() => { + axios.get(...); +}, []); +``` -권장: +좋은 예: -```ts -type AsyncState = - | { status: "idle" } - | { status: "loading" } - | { status: "success"; data: T } - | { status: "error"; message: string }; +```tsx +const policies = await getPolicies(); ``` -금지: +## 9. HTTP 상태 코드 & 에러코드 처리 -```ts -const data = response as Policy[]; -function parse(value: any) {} -``` +HTTP status는 **화면 흐름 분기**에 사용하고, 사용자에게 보여줄 **메시지**는 응답 body의 `code`를 기준으로 매핑한다. status만 보고 모든 오류를 같은 메시지로 처리하지 않는다. -## 10. API 연동 규칙 +### 9.1 HTTP status 기준 흐름 처리 -- API 호출 함수는 slice의 `api` 디렉터리에 둔다. -- API 응답 DTO는 UI model과 분리한다. -- mapper는 `lib`에 둔다. -- API 실패, timeout, 서버 장애, 빈 응답 상태를 UI가 표현할 수 있게 hook에서 상태를 제공한다. -- mock fallback이 필요한 기능은 fallback 여부를 `isFallback` 등으로 노출한다. -- API key 같은 secret은 클라이언트 번들에 직접 노출하지 않는다. -- 브라우저 저장소에는 민감정보를 저장하지 않는다. +| 상태 코드 | 의미 | 프론트 처리 | +| --- | --- | --- | +| 401 | 인증 만료 / 비로그인 | 로그인 페이지 이동 또는 로그인 모달 표시 | +| 403 | 권한 없음 | 접근 불가 화면 표시 | +| 404 | 데이터 없음 | Empty UI 또는 "존재하지 않는 데이터" 안내 | +| 409 | 데이터 충돌 | 최신 데이터 재조회 | +| 429 | 요청 과다 | 잠시 후 다시 시도 안내 | +| 500 | 서버 오류 | 에러 페이지 또는 재시도 버튼 표시 | -### 10.1 API 에러코드 처리 규칙 +### 9.2 에러코드(body `code`) 기준 메시지 처리 -백엔드 검증 오류와 비즈니스 오류는 HTTP status만 보지 않고, 응답 body의 `code`를 기준으로 처리한다. 백엔드는 검증 오류를 `C001` 같은 `ErrorCode`로 내려주며, 프론트는 이 코드를 사용자 메시지로 매핑한다. +백엔드는 검증/비즈니스 오류를 `C001` 같은 `ErrorCode`로 내려준다. 프론트는 이 코드를 사용자 메시지로 매핑한다. -표준 에러 응답 예시는 다음과 같다. +표준 에러 응답: ```ts interface ApiErrorResponse { @@ -301,12 +347,12 @@ interface ApiErrorResponse { } ``` -프론트 처리 규칙: +처리 규칙: -- API client는 실패 응답에서 `code`, `message`, `details`를 파싱해 hook 또는 caller가 사용할 수 있게 보존한다. -- UI에 표시할 문구는 서버 raw message를 그대로 뿌리지 말고, `code` → 사용자 친화 메시지 매핑을 우선 사용한다. -- 검증 오류 `C001`은 가능한 경우 `details[].reason`을 함께 보여주되, 여러 field 오류는 대표 메시지 + 세부 항목 형태로 정리한다. -- 알 수 없는 `code`는 공통 fallback 메시지(예: `요청 처리 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.`)로 처리한다. +- API client는 실패 응답에서 `code`, `message`, `details`를 파싱해 caller가 사용할 수 있게 보존한다. +- UI 문구는 서버 raw message를 그대로 뿌리지 말고 `code` → 사용자 친화 메시지 매핑을 우선 사용한다. +- 검증 오류 `C001`은 가능하면 `details[].reason`을 함께 보여주되, 여러 field 오류는 대표 메시지 + 세부 항목 형태로 정리한다. +- 알 수 없는 `code`는 공통 fallback 메시지로 처리한다. - HTTP status, exception text, stack trace, SQL, 내부 endpoint, secret 값은 사용자에게 직접 노출하지 않는다. - 새 백엔드 `ErrorCode`가 추가되면 프론트의 code-message map 갱신 여부를 확인한다. @@ -335,51 +381,151 @@ showToast(error.message); if (response.status === 400) showToast("잘못된 요청입니다."); ``` -## 11. 상태 관리 규칙 +## 10. 화면 상태 규칙 -- 컴포넌트 내부에서만 쓰는 UI 상태는 `useState`로 관리한다. -- feature use case 상태는 custom hook으로 분리한다. -- 여러 slice가 공유하는 domain data는 `entities/*/model`에서 관리한다. -- derived state는 가능하면 원본 state에서 계산한다. 중복 state로 저장하지 않는다. -- localStorage 접근은 `lib` 또는 hook 내부로 캡슐화한다. +모든 목록 및 상세 화면은 아래 상태를 반드시 고려한다. -## 12. 파일/이름 규칙 +| 상태 | 설명 | UI 예시 | +| --- | --- | --- | +| Loading | 데이터 조회 중 | Skeleton, Spinner | +| Empty | 데이터 없음 | "조회된 정책이 없습니다." | +| Error | 복구 불가 오류 | 에러 메시지 + 재시도 버튼 | +| Degraded | 일부 기능 장애 | 추천 비활성화, 기본 검색 결과 제공 | +| Stale | 최신 상태 재확인 필요 | 새로고침 배너, 데이터 갱신 안내 | + +체크 포인트: + +- 목록 화면: Loading / Empty / Error 필수 +- 상세 화면: Loading / Error 필수 +- 챗봇: Degraded 고려 (챗봇 응답 실패 → 정책 검색 기능만 제공) +- 추천 정책: Degraded 고려 (추천 엔진 장애 → 최신 정책 목록 제공) +- 관리자 페이지: Stale 고려 (정책 데이터 갱신됨 → 새로고침 안내) + +## 11. React 코딩 컨벤션 + +- 함수 컴포넌트를 사용한다. +- component 이름은 PascalCase, hook 이름은 `use` + camelCase를 사용한다. +- props type은 component 근처에 `interface {ComponentName}Props`로 둔다. +- props는 명시적으로 선언하고, `React.FC`는 기본으로 사용하지 않는다. +- list key는 stable id를 사용한다. 동적 list에서 index key는 금지한다. +- event handler 이름은 `handleX`, props callback 이름은 `onX`를 사용한다. +- 조건부 렌더링은 early return 또는 명확한 분기로 작성한다. +- `useEffect`는 외부 시스템 동기화에만 사용한다. 단순 계산은 render 또는 `useMemo`로 처리한다. +- `useMemo`, `useCallback`은 성능 문제가 있거나 memoized child에 전달할 때만 사용한다. +- 접근성을 위해 semantic HTML, `button type="button"`, `aria-label`, focus 가능 상태를 고려한다. +- `target="_blank"` 링크에는 `rel="noopener noreferrer"`를 붙인다. + +## 12. TypeScript 코딩 컨벤션 + +- `any`는 금지한다. 알 수 없는 값은 `unknown`을 사용하고 type guard로 좁힌다. +- `@ts-ignore`는 금지한다. 정말 필요하면 `@ts-expect-error`와 이유 주석을 남긴다. +- API 응답 DTO와 UI model을 분리하고, 변환은 mapper 함수로 처리한다. +- type-only import는 `import type`을 사용한다. +- union state는 string union 또는 discriminated union을 사용한다. +- nullable 값은 명시적으로 처리한다. +- public API로 노출되는 함수/컴포넌트 props는 타입을 명확히 선언한다. +- 타입 단언 `as`는 런타임 검증이 있거나 라이브러리 경계에서 불가피할 때만 사용한다. + +권장: -- React component 파일은 PascalCase: `PolicyCard.tsx` -- hook 파일은 kebab-case 또는 camelCase 중 기존 slice 관례를 따른다. 현재는 `use-tracker-management.ts`처럼 kebab-case를 우선 사용한다. -- type 파일은 `types.ts` -- mock data 파일은 `mock-*.ts` -- mapper 파일은 `*-mapper.ts` +```ts +type AsyncState = + | { status: "idle" } + | { status: "loading" } + | { status: "success"; data: T } + | { status: "error"; message: string }; +``` + +금지: + +```ts +const data = response as Policy[]; +function parse(value: any) {} +``` + +## 13. 디자인 시스템 규칙 + +디자인은 반드시 현재 디자인 시스템을 따른다. 임의 색상, 임의 radius, 임의 그림자, 임의 폰트를 남발하지 않는다. 공통 UI는 shadcn/ui와 `shared/ui`를 우선 활용한다. + +현재 디자인 토큰은 `src/index.css`에 정의되어 있다. + +- Tailwind `@theme`: `--font-sans`, `--font-display`, `--color-primary`, `--color-primary-foreground`, `--color-primary-light`, `--color-brand-secondary`, `--color-secondary`, `--color-accent`, `--color-success`, `--color-border` +- CSS variables: `--background`, `--surface`, `--foreground`, `--card`, `--muted`, `--destructive`, `--warning`, `--ring`, `--radius` + +우선 사용 기준: + +- 브랜드 CTA: `bg-primary`, `text-primary`, `from-primary to-brand-secondary` +- 카드/패널: `rounded-2xl` 또는 `rounded-3xl`, `bg-white`, `border border-slate-100`, `shadow-sm` +- 보조 배경: `bg-slate-50`, `bg-primary/5`, `bg-primary-light` +- 상태 색상: success/warning/destructive 계열 토큰을 일관되게 사용 +- 폰트: `font-sans`, 필요한 display 영역만 `font-display` + +금지: + +- 기존 토큰과 무관한 hex 색상 직접 추가 +- 같은 의미의 버튼/카드 스타일을 매번 새로 만드는 것 +- contrast 확인 없이 흐린 텍스트/CTA 색상 추가 +- 디자인 시스템과 다른 radius/shadow/spacing을 임의 도입 + +## 14. 네이밍 규칙 + +| 종류 | 형식 | 예시 | +| --- | --- | --- | +| Component | PascalCase.tsx, `[도메인][역할]` | `PolicyCard.tsx`, `PolicyList.tsx`, `CommentForm.tsx` | +| Hook | camelCase.ts, `use` + 기능명 | `usePolicySearch.ts`, `useBookmark.ts` | +| Store | camelCase.ts, `[기능]Store` | `authStore.ts`, `compareStore.ts` | +| API | camelCase.ts, `[기능]Api` | `policyApi.ts`, `boardApi.ts` | +| Mapper | camelCase.ts, `[기능]Mapper` | `policyMapper.ts` | +| Utils | camelCase.ts | `formatDate.ts` | +| Types | camelCase.ts, `[기능].types` | `policy.types.ts` | +| Constants | camelCase.ts (값은 UPPER_SNAKE_CASE) | `MAX_COMPARE_COUNT`, `DEFAULT_PAGE_SIZE` | + +- Type / Interface 이름은 PascalCase 명사: `type Policy`, `interface BoardPost` +- Zustand store type은 PascalCase `[기능]Store`: `type AuthStore`, `type CompareStore` +- Enum은 PascalCase: `enum UserRole`, `enum PolicyCategory` - barrel export는 각 slice의 `index.ts`에서 public API만 노출한다. -## 13. 테스트/검증 규칙 +금지: `card.tsx`, `list.tsx`, `data.ts`, `temp.ts`, `util.ts`, `test2.ts` + +## 15. 반응형 기준 + +필수 확인 해상도: `390px`, `768px`, `1440px`. 최소 390px 모바일 환경에서 깨지지 않아야 한다. -현재 기본 검증은 다음이다. +## 16. 테스트 / 검증 규칙 + +기본 검증 명령: ```bash -pnpm run lint -pnpm run build +corepack prepare pnpm@11.9.0 --activate +corepack pnpm install --frozen-lockfile +corepack pnpm run lint +corepack pnpm run build ``` -작업 유형별로 추가 확인한다. +작업 유형별 추가 확인: - UI 변경: 주요 화면 수동 확인 또는 screenshot 첨부 -- API 연동: 성공/빈 상태/오류 상태 확인 -- 상태 로직 변경: hook 또는 순수 함수 단위 테스트 추가를 우선 고려 -- Docker 변경: `docker compose config`와 가능한 build/smoke 확인 +- API 연동: 성공 / 빈 상태 / 오류 상태 확인 +- 상태 로직 변경: hook 또는 순수 함수 단위 테스트 추가 우선 고려 +- Docker/Compose 관련 변경: `docker compose config`, build, runtime smoke를 로컬 가능 범위로 검증. Docker가 없으면 PR 검증 결과에 명시한다. -## 14. PR 전 체크리스트 +## 17. PR 전 체크리스트 - [ ] GitHub Issue를 만들고 issue 번호 브랜치에서 작업했다. -- [ ] FSD 레이어 의존 방향을 지켰다. +- [ ] 레이어 의존 방향(`app → pages → widgets → features → entities → shared`)을 지켰고, feature → feature import를 만들지 않았다. +- [ ] 2개 이상 feature가 공유하는 도메인 모델은 `entities`로 승격했다. - [ ] Container/Presenter 역할을 분리했다. - [ ] 기능 로직을 custom hook으로 분리했다. +- [ ] 서버 데이터는 TanStack Query, 클라이언트 상태만 Zustand로 관리했다. +- [ ] API 호출을 컴포넌트 내부에서 직접 수행하지 않았다. +- [ ] API DTO와 UI model을 분리하고 mapper로 변환했다. +- [ ] API 오류는 body `code` 기준으로 사용자 메시지를 매핑하고, raw exception/message를 그대로 노출하지 않았다. +- [ ] 새 백엔드 에러코드가 있다면 프론트 code-message map 갱신 필요 여부를 확인했다. +- [ ] Loading / Empty / Error 상태를 구현했다. (챗봇·추천은 Degraded, 관리자는 Stale 고려) - [ ] 디자인 시스템 토큰과 기존 UI 패턴을 따랐다. +- [ ] 모바일 390px에서 확인했다. +- [ ] 비로그인 사용자의 흐름을 확인했다. - [ ] `any`, 불필요한 `as`, `@ts-ignore`를 추가하지 않았다. -- [ ] API DTO와 UI model을 분리했다. -- [ ] API 오류는 백엔드 `code` 기준으로 사용자 메시지를 매핑하고, raw exception/message를 그대로 노출하지 않는다. -- [ ] 새 백엔드 에러코드가 있다면 프론트 code-message map 갱신 필요 여부를 확인했다. -- [ ] loading/error/empty 상태를 처리했다. -- [ ] 접근성 기본 요소를 확인했다. +- [ ] 접근성 기본 요소(semantic HTML, button type, aria-label, focus, 안전한 외부 링크)를 확인했다. - [ ] `pnpm run lint`를 실행했다. -- [ ] `pnpm run build`를 실행했다. \ No newline at end of file +- [ ] `pnpm run build`를 실행했다. diff --git a/server.ts b/server.ts deleted file mode 100755 index 6fbe47f..0000000 --- a/server.ts +++ /dev/null @@ -1,141 +0,0 @@ -import express from "express"; -import path from "path"; -import { createServer as createViteServer } from "vite"; -import { GoogleGenAI } from "@google/genai"; -import dotenv from "dotenv"; - -dotenv.config(); - -const app = express(); -const PORT = 4000; - -// Initialize Gemini client safely -const apiKey = process.env.GEMINI_API_KEY; -let ai: GoogleGenAI | null = null; - -if (apiKey) { - ai = new GoogleGenAI({ - apiKey: apiKey, - httpOptions: { - headers: { - "User-Agent": "aistudio-build", - }, - }, - }); -} else { - console.warn( - "GEMINI_API_KEY environment variable is not defined. Chatbot will run in fallback mock mode.", - ); -} - -app.use(express.json()); - -// API route for chatbot helper -app.post("/api/chat", async (req, res) => { - try { - const { message, history } = req.body; - - if (!message) { - return res.status(400).json({ error: "Message is required." }); - } - - if (!ai) { - // Return a simulated high-quality response if Gemini is not initialized - return res.json({ - text: `[알림] API 키가 설정되지 않아 데모 모드로 응답합니다.\n\n질문하신 "${message}"에 대한 맞춤 답변입니다:\n청년정책 서비스 **YouthPick**에서는 일자리, 주거, 교육, 복지 등의 카테고리별로 정책을 맞춤 설계하고 있습니다. 대표적으로 서울시 '청년 월세 한시 특별지원'(월 최대 20만원 지원) 및 '청년 디지털 일자리 지원 사업'을 통해 일과 주거 안정을 도모하실 수 있습니다. 상세 상담을 위해 API 키를 Secrets에 입력해 주세요!`, - }); - } - - // System instruction detailing the brand and specific mock policies - const systemInstruction = ` -You are the expert Youth Policy AI Assistant named "정책 탐색 도우미" for the website "YouthPick" (청년정책 맞춤형 서비스). -Your goal is to help Korean youth (ages 19-34) find policies that suit their needs. - -Here is the knowledge base of policies on YouthPick: -1. 일자리 (Job): - - 청년 디지털 일자리 지원 사업: IT 분야 실무 경험과 교육을 제공하고 취업 연계를 지원하는 사업. 대상: 만 19세 ~ 34세 미취업 청년. - - 청년내일채움공제: 중소기업 취업 청년의 자산 형성을 지원하는 제도. 2년간 300만원을 적립하면 정부와 기업이 공동 적립하여 1,200만원으로 환급. - - 국민취업지원제도: 구직자에게 취업지원서비스를 종합적으로 제공하고 저소득 구직자에게는 생계안정을 위한 구직촉진수당(월 50만원씩 6개월) 지급. - -2. 주거 (Housing): - - 청년 월세 한시 특별지원: 청년들의 주거비 부담 경감을 위해 월세를 실지급액 기준 최대 20만원까지 최대 12개월 동안 지원하는 사업. 대상: 만 19세~34세 무주택 청년 (부모와 별도 거주). - - 청년 주거급여 분리지급: 주거급여 수급가구 내 20대 미혼 자녀가 학업이나 구직 등을 이유로 부모와 다른 시·군에서 거주하는 경우 별도로 주거급여를 지급하는 제도. - - 청년 버팀목 전세자금대출: 청년들을 위한 저금리(연 1.5%~2.1%) 전세자금 대출 지원 사업. - -3. 교육 (Education): - - K-디지털 트레이닝 (국비지원): 디지털, 신기술 분야 실무 중심 교육을 통해 취업 역량을 높여주는 과정. 교육비 전액 정부 지원. - - 국민내일배움카드: 스스로 역량 개발을 할 수 있도록 훈련비를 지원하는 카드 (1인당 300만~500만원 지원). - - 서울시 청년수당: 서울에 거주하는 미취업 청년(만 19~34세)에게 월 50만원씩 최대 6개월간 활동 지원금을 지급하는 사업. - -4. 복지·문화 (Welfare & Culture): - - 청년내일저축계좌: 일하는 저소득층 청년의 자산형성을 지원하는 계좌. 본인 저축액(10만원) 대비 정부지원금을 10만~30만원 매칭 적립. - - 청년문화예술패스: 성년이 되는 청년(만 19세)에게 문화예술 관람비(연 최대 15만원) 지원. - -5. 참여·권리 (Participation & Rights): - - 청년보좌역 및 청년인턴: 정부 부처의 정책 결정 과정에 청년의 목소리를 반영하기 위한 참여 기회 제공. - -Guidelines: -- Answer in Korean in a polite, friendly, and enthusiastic tone ("~해요", "~드립니다"). -- If the user asks for specific policies, explain them clearly based on the knowledge base above. -- Recommend corresponding policies based on their status or region (primarily Seoul/서울특별시). -- Organize the output with clear bullet points, bold text, and neat sections. -- Keep responses relatively concise but informative. Do not use markdown features not supported. -`; - - // Format chat history for Gemini API - const contents = []; - if (history && Array.isArray(history)) { - for (const h of history) { - contents.push({ - role: h.role === "user" ? "user" : "model", - parts: [{ text: h.text }], - }); - } - } - contents.push({ - role: "user", - parts: [{ text: message }], - }); - - const response = await ai.models.generateContent({ - model: "gemini-3.5-flash", - contents: contents, - config: { - systemInstruction: systemInstruction, - temperature: 0.7, - }, - }); - - const replyText = - response.text || "죄송합니다. 답변을 생성하지 못했습니다."; - res.json({ text: replyText }); - } catch (error: any) { - console.error("Error communicating with Gemini API:", error); - res - .status(500) - .json({ error: "서버 오류가 발생하여 챗봇 응답에 실패했습니다." }); - } -}); - -// Configure Vite or serve static assets -async function startServer() { - if (process.env.NODE_ENV !== "production") { - const vite = await createViteServer({ - server: { middlewareMode: true }, - appType: "spa", - }); - app.use(vite.middlewares); - } else { - const distPath = path.join(process.cwd(), "dist"); - app.use(express.static(distPath)); - app.get("*", (req, res) => { - res.sendFile(path.join(distPath, "index.html")); - }); - } - - app.listen(PORT, "0.0.0.0", () => { - console.log(`[YouthPick] Server running on port ${PORT}`); - }); -} - -startServer(); diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100755 index 615fafc..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,2094 +0,0 @@ -import { useState, useEffect, useMemo } from "react"; -import { - Briefcase, - Home as HomeIcon, - GraduationCap, - Heart, - Sparkles, - Info, - Facebook, - Instagram, - Youtube, - ArrowRight, - Hand, - Compass, - Search, - Calendar, - User, - Loader2, - Check, - AlertTriangle, - Plus, - Trash2, - Edit3, - CheckCircle2, - CheckSquare, - Square, - ChevronRight, - RefreshCw, - LogOut, - ShieldAlert, - FileText, - LayoutDashboard, - ArrowLeft, - X -} from "lucide-react"; -import { motion, AnimatePresence } from "motion/react"; - -import { Policy, FilterState, UserProfile, TrackerItem, TrackerChecklistItem } from "./types"; -import { MOCK_POLICIES } from "./data"; - -import Header from "./components/Header"; -import HeroBanner from "./components/HeroBanner"; -import FilterBar from "./components/FilterBar"; -import PolicyCard from "./components/PolicyCard"; -import CompareModule from "./components/CompareModule"; -import RecentlyViewed from "./components/RecentlyViewed"; -import ChatbotAssistant from "./components/ChatbotAssistant"; -import DetailModal from "./components/DetailModal"; - -// Toast Interface -interface Toast { - id: string; - message: string; - type: "success" | "info" | "warning"; -} - -export default function App() { - // Views: 'home' | 'search' | 'recommend' | 'tracker' | 'mypage' | 'admin' | 'login' | 'profile_setup' - const [activeView, setActiveView] = useState("home"); - const [previousView, setPreviousView] = useState("home"); - - // Authentication State - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [userName, setUserName] = useState("민지"); - - // Custom User Profile State (PROFILE-01 ~ 03) - const [userProfile, setUserProfile] = useState({ - birthYear: 1998, - region: "서울특별시", - subRegion: "마포구", - employmentStatus: "미취업·구직", - educationStatus: "대학 졸업", - interests: ["일자리", "교육"], - keywords: ["직무교육", "면접비"] - }); - - // Profile setup wizard steps (1 | 2 | 3) - const [profileSetupStep, setProfileSetupStep] = useState(1); - const [wizardProfile, setWizardProfile] = useState({ - birthYear: 1998, - region: "서울특별시", - subRegion: "마포구", - employmentStatus: "미취업·구직", - educationStatus: "대학 졸업", - interests: ["일자리", "교육"], - keywords: ["직무교육", "면접비"] - }); - const [newKeywordInput, setNewKeywordInput] = useState(""); - - // Application Tracker State (TRACKER-01 ~ 02) - const [trackers, setTrackers] = useState([ - { - policyId: "p1", - status: "준비중", - targetDate: "2026-06-25", - checklist: [ - { id: "c1", text: "지원서 작성", completed: true }, - { id: "c2", text: "자기소개서 초안 작성", completed: true }, - { id: "c3", text: "구직상태 확인서 발급", completed: false }, - { id: "c4", text: "최종 제출 완료", completed: false } - ], - memo: "교육 일정과 면접 일정이 겹치는지 확인할 것" - }, - { - policyId: "p2", - status: "관심", - targetDate: "2026-07-10", - checklist: [ - { id: "c5", text: "주민등록등본 발급", completed: false }, - { id: "c6", text: "임대차계약서 사본 준비", completed: false } - ], - memo: "월세 이체 내역 3개월분 미리 출력해두기" - }, - { - policyId: "p5", - status: "결과대기", - targetDate: "2026-06-20", - checklist: [ - { id: "c7", text: "서류 업로드", completed: true }, - { id: "c8", text: "심사 대기 확인", completed: true } - ], - memo: "결과 발표는 공식 홈페이지 고시 예정" - } - ]); - - // Selected tracker policy ID for TRACKER-02 detail view - const [selectedTrackerPolicyId, setSelectedTrackerPolicyId] = useState(null); - - // Search & Filter State (POLICY-01) - const [searchQuery, setSearchQuery] = useState(""); - const [filters, setFilters] = useState({ - region: "전체", - status: "전체", - category: "전체", - age: "전체" - }); - - // Saved bookmarks (Favorite policies) - const [savedPolicyIds, setSavedPolicyIds] = useState(["p1", "p2", "p5", "p_missing"]); - - // Comparing policies - const [comparingPolicies, setComparingPolicies] = useState([]); - - // Selected policy in detail modal - const [selectedPolicy, setSelectedPolicy] = useState(null); - - // Tracker interactive states - const [trackerTab, setTrackerTab] = useState("전체"); - const [showAddChecklistItem, setShowAddChecklistItem] = useState(false); - const [newChecklistItemText, setNewChecklistItemText] = useState(""); - const [tempMemoText, setTempMemoText] = useState(""); - - // Confirmation Modals - const [showDeleteTrackerConfirm, setShowDeleteTrackerConfirm] = useState(null); - const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); - - // Admin sync states - const [isAdminSyncing, setIsAdminSyncing] = useState(false); - const [syncHistory, setSyncHistory] = useState([ - { date: "06.24 06:00", status: "SUCCESS", newCount: 18, editCount: 44, missingCount: 2, errorCount: 0 }, - { date: "06.23 06:00", status: "PARTIAL", newCount: 12, editCount: 31, missingCount: 4, errorCount: 2 } - ]); - - // Custom Toast state - const [toasts, setToasts] = useState([]); - - const showToast = (message: string, type: "success" | "info" | "warning" = "success") => { - const id = Date.now().toString(); - setToasts((prev) => [...prev, { id, message, type }]); - setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }, 3000); - }; - - // View navigation router - const navigateTo = (view: string) => { - // Check if member view is requested but user is logged out - if ((view === "recommend" || view === "tracker" || view === "mypage") && !isLoggedIn) { - setPreviousView(view); - setActiveView("login"); - showToast("이 서비스는 로그인이 필요합니다. 회원 화면으로 안내합니다.", "info"); - return; - } - setPreviousView(activeView); - setActiveView(view); - // Auto sync scroll - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - - // Social Login Execution - const handleSocialLogin = (provider: string) => { - setIsLoggedIn(true); - setUserName("민지"); - showToast(`🎉 ${provider} 계정으로 환영합니다! 맞춤 청년 정책 매칭이 활성화되었습니다.`, "success"); - - // First-time simulation: if user has no interests, direct to profile setup - if (userProfile.interests.length === 0) { - setProfileSetupStep(1); - setWizardProfile({ ...userProfile }); - setActiveView("profile_setup"); - } else { - // Go back to previous or targeted page - if (previousView === "recommend") { - setActiveView("recommend"); - } else if (previousView === "tracker") { - setActiveView("tracker"); - } else if (previousView === "mypage") { - setActiveView("mypage"); - } else { - setActiveView("home"); - } - } - }; - - const handleLogout = () => { - setIsLoggedIn(false); - setActiveView("home"); - showToast("안전하게 로그아웃 되었습니다.", "info"); - }; - - const handleDeleteAccount = () => { - setIsLoggedIn(false); - setShowDeleteAccountConfirm(false); - setActiveView("home"); - showToast("회원 탈퇴가 안전하게 처리되었습니다. 이용해주셔서 감사합니다.", "warning"); - }; - - // Toggle Save bookmark (favorite) - const handleToggleSave = (id: string) => { - if (savedPolicyIds.includes(id)) { - setSavedPolicyIds((prev) => prev.filter((pId) => pId !== id)); - showToast("관심 정책 목록에서 해제되었습니다.", "info"); - } else { - setSavedPolicyIds((prev) => [...prev, id]); - showToast("관심 정책으로 보관되었습니다! [신청관리]에서 일정을 추가해 보세요.", "success"); - } - }; - - // Compare Policy Controls - const handleToggleCompare = (policy: Policy) => { - if (comparingPolicies.some((p) => p.id === policy.id)) { - setComparingPolicies((prev) => prev.filter((p) => p.id !== policy.id)); - } else { - if (comparingPolicies.length >= 2) { - showToast("비교 분석은 한 번에 최대 2개의 정책만 가능합니다.", "warning"); - return; - } - setComparingPolicies((prev) => [...prev, policy]); - showToast(`${policy.title}이 비교 슬롯에 등록되었습니다.`, "success"); - } - }; - - const handleClearCompare = () => { - setComparingPolicies([]); - }; - - const handleRemoveCompare = (policy: Policy) => { - setComparingPolicies((prev) => prev.filter((p) => p.id !== policy.id)); - }; - - // Profile setup wizard buttons - const handleWizardNext = () => { - if (profileSetupStep < 3) { - setProfileSetupStep((prev) => prev + 1); - } else { - // Step 3 finished - setUserProfile({ ...wizardProfile }); - setActiveView("recommend"); - showToast("✨ 맞춤 프로필 설정 완료! 실시간 추천 결과 28건이 연계되었습니다.", "success"); - } - }; - - const handleWizardPrev = () => { - if (profileSetupStep > 1) { - setProfileSetupStep((prev) => prev - 1); - } - }; - - const handleAddKeyword = () => { - if (newKeywordInput.trim() === "") return; - if (wizardProfile.keywords.length >= 5) { - showToast("관심 키워드는 최대 5개까지 추가할 수 있습니다.", "warning"); - return; - } - setWizardProfile((prev) => ({ - ...prev, - keywords: [...prev.keywords, newKeywordInput.trim()] - })); - setNewKeywordInput(""); - }; - - const handleRemoveKeyword = (kw: string) => { - setWizardProfile((prev) => ({ - ...prev, - keywords: prev.keywords.filter((k) => k !== kw) - })); - }; - - const handleToggleInterest = (interest: string) => { - if (wizardProfile.interests.includes(interest)) { - setWizardProfile((prev) => ({ - ...prev, - interests: prev.interests.filter((i) => i !== interest) - })); - } else { - if (wizardProfile.interests.length >= 3) { - showToast("관심 분야는 최대 3개까지 선택할 수 있습니다.", "warning"); - return; - } - setWizardProfile((prev) => ({ - ...prev, - interests: [...prev.interests, interest] - })); - } - }; - - // Policy Search Listings Filtered - const filteredPolicies = useMemo(() => { - return MOCK_POLICIES.filter((policy) => { - // Search Query - if (searchQuery.trim() !== "") { - const query = searchQuery.toLowerCase(); - const matchesQuery = - policy.title.toLowerCase().includes(query) || - policy.category.toLowerCase().includes(query) || - policy.description.toLowerCase().includes(query); - if (!matchesQuery) return false; - } - - // Region Filter - if (filters.region !== "전체" && policy.region !== "전국") { - if (policy.region !== filters.region) return false; - } - - // Category Filter - if (filters.category !== "전체" && policy.category !== filters.category) { - if (filters.category === "참여・권리" && policy.category === "참여·권리") { - // ignore slightly different characters - } else { - return false; - } - } - - // Status/Age Filter matching - if (filters.age !== "전체" && policy.target !== "전체" && policy.target !== filters.age) { - return false; - } - - return true; - }); - }, [filters, searchQuery]); - - // Recommended Matching Engine based on Active Profile - const recommendedPolicies = useMemo(() => { - return MOCK_POLICIES.map((p, index) => { - // Calculate a highly realistic match score based on user Profile - let score = 70; - if (p.region === userProfile.region || p.region === "전국") score += 15; - if (userProfile.interests.includes(p.category)) score += 10; - if (p.target === "전체" || p.target === "만 19~34세") score += 5; - // Add small mock variance - score += (index % 3) * 4; - return { - policy: p, - score: Math.min(score, 98), - reliability: "MEDIUM" as const, - reasons: [ - `거주지역이 ${userProfile.region} 조건과 일치합니다.`, - `관심 분야인 ${p.category} 카테고리에 속합니다.`, - `취업상태 조건이 정책 자격에 근접합니다.` - ] - }; - }).sort((a, b) => b.score - a.score); - }, [userProfile]); - - // Start Tracker Control - const handleStartTracker = (policy: Policy) => { - if (!isLoggedIn) { - setPreviousView("tracker"); - setActiveView("login"); - showToast("신청관리 일정 추가를 위해 먼저 로그인해 주세요.", "info"); - return; - } - - const exists = trackers.find((t) => t.policyId === policy.id); - if (exists) { - setSelectedTrackerPolicyId(policy.id); - setActiveView("tracker"); - showToast("이미 등록된 신청 정보가 존재하여, 관리 상세로 연결되었습니다.", "info"); - } else { - const newTracker: TrackerItem = { - policyId: policy.id, - status: "준비중", - targetDate: policy.deadline !== "원본확인불가" && policy.deadline !== "상시모집" ? policy.deadline : "2026-06-30", - checklist: [ - { id: Math.random().toString(), text: "기본 제출 서류 취합", completed: false }, - { id: Math.random().toString(), text: "공고 상세 자격요건 검증", completed: false }, - { id: Math.random().toString(), text: "지원서 및 온라인 제출 완료", completed: false } - ], - memo: "" - }; - setTrackers((prev) => [...prev, newTracker]); - setSelectedTrackerPolicyId(policy.id); - setActiveView("tracker"); - showToast(`🎉 '${policy.title}' 신청관리 상태가 새로 시작되었습니다!`, "success"); - } - }; - - // Selected tracker item for detail - const activeTrackerItem = useMemo(() => { - return trackers.find((t) => t.policyId === selectedTrackerPolicyId) || null; - }, [trackers, selectedTrackerPolicyId]); - - // Selected tracker policy reference - const activeTrackerPolicy = useMemo(() => { - if (!activeTrackerItem) return null; - return MOCK_POLICIES.find((p) => p.id === activeTrackerItem.policyId) || null; - }, [activeTrackerItem]); - - // Memo temp editor synchronization - useEffect(() => { - if (activeTrackerItem) { - setTempMemoText(activeTrackerItem.memo); - } - }, [selectedTrackerPolicyId, activeTrackerItem]); - - const handleSaveMemo = () => { - if (!selectedTrackerPolicyId) return; - setTrackers((prev) => prev.map((t) => { - if (t.policyId === selectedTrackerPolicyId) { - return { ...t, memo: tempMemoText }; - } - return t; - })); - showToast("📝 개인 기록 메모가 성공적으로 저장되었습니다.", "success"); - }; - - const handleAddChecklistItemAction = () => { - if (newChecklistItemText.trim() === "" || !selectedTrackerPolicyId) return; - setTrackers((prev) => prev.map((t) => { - if (t.policyId === selectedTrackerPolicyId) { - return { - ...t, - checklist: [ - ...t.checklist, - { id: Math.random().toString(), text: newChecklistItemText.trim(), completed: false } - ] - }; - } - return t; - })); - setNewChecklistItemText(""); - setShowAddChecklistItem(false); - showToast("체크리스트 준비 일감이 추가되었습니다.", "success"); - }; - - const handleToggleChecklistItem = (policyId: string, itemId: string) => { - setTrackers((prev) => prev.map((t) => { - if (t.policyId === policyId) { - return { - ...t, - checklist: t.checklist.map((item) => { - if (item.id === itemId) { - return { ...item, completed: !item.completed }; - } - return item; - }) - }; - } - return t; - })); - }; - - const handleDeleteChecklistItem = (policyId: string, itemId: string) => { - setTrackers((prev) => prev.map((t) => { - if (t.policyId === policyId) { - return { - ...t, - checklist: t.checklist.filter((item) => item.id !== itemId) - }; - } - return t; - })); - showToast("준비할 일감이 삭제되었습니다.", "info"); - }; - - const handleUpdateTrackerStatus = (policyId: string, newStatus: TrackerItem["status"]) => { - setTrackers((prev) => prev.map((t) => { - if (t.policyId === policyId) { - return { ...t, status: newStatus }; - } - return t; - })); - showToast(`신청관리 상태가 [${newStatus}]로 갱신되었습니다.`, "success"); - }; - - const handleUpdateTrackerDate = (policyId: string, date: string) => { - setTrackers((prev) => prev.map((t) => { - if (t.policyId === policyId) { - return { ...t, targetDate: date }; - } - return t; - })); - showToast(`제출 마감일정이 변경되었습니다: ${date}`, "info"); - }; - - const handleDeleteTracker = (policyId: string) => { - setTrackers((prev) => prev.filter((t) => t.policyId !== policyId)); - setSelectedTrackerPolicyId(null); - setShowDeleteTrackerConfirm(null); - showToast("신청관리 목록에서 안전하게 삭제되었습니다.", "warning"); - }; - - // Run admin database synchronizer - const runAdminSync = () => { - setIsAdminSyncing(true); - setTimeout(() => { - setIsAdminSyncing(false); - const newLog = { - date: "오늘 " + new Date().toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" }), - status: "SUCCESS", - newCount: Math.floor(Math.random() * 20) + 5, - editCount: Math.floor(Math.random() * 30) + 10, - missingCount: Math.floor(Math.random() * 3), - errorCount: 0 - }; - setSyncHistory((prev) => [newLog, ...prev]); - showToast("공공 API 연동 및 청년정책 정보 수동 동기화가 완료되었습니다!", "success"); - }, 2000); - }; - - // Category selection handler from home - const handleCategoryCardClick = (catKey: string) => { - setFilters((prev) => ({ - ...prev, - category: catKey - })); - setActiveView("search"); - }; - - return ( -
- - {/* Toast Notification Container */} -
- - {toasts.map((toast) => ( - - {toast.type === "success" && } - {toast.type === "warning" && } - {toast.type === "info" && } - {toast.message} - - ))} - -
- - {/* Header component */} -
navigateTo("login")} - userName={userName} - /> - - {/* Main Content Router */} -
- - {/* VIEW 1: HOME PAGE (Guest HOME-01 / Member HOME-02) */} - {activeView === "home" && ( -
- {/* Custom Hero Banner */} - navigateTo("search")} - /> - - {/* Category Quicklinks Section */} -
-
-

청년정책 분야 바로가기

-

원하시는 주요 분야별 정책 카테고리를 눌러 빠르게 상세 필터 탐색을 시작해 보세요.

-
-
- {[ - { key: "일자리", title: "일자리", subtitle: "취업・창업 지원", bg: "bg-primary/10 text-primary hover:bg-primary/20", icon: Briefcase }, - { key: "주거", title: "주거", subtitle: "주거・금융 지원", bg: "bg-blue-50 text-blue-600 hover:bg-blue-100/50", icon: HomeIcon }, - { key: "교육", title: "교육", subtitle: "역량・자기개발", bg: "bg-indigo-50 text-indigo-600 hover:bg-indigo-100/50", icon: GraduationCap }, - { key: "복지·문화", title: "복지・문화", subtitle: "생활・건강・문화", bg: "bg-rose-50 text-rose-600 hover:bg-rose-100/50", icon: Heart }, - { key: "참여·권리", title: "참여・권리", subtitle: "참여・권익 보호", bg: "bg-amber-50 text-amber-600 hover:bg-amber-100/50", icon: Hand } - ].map((item) => { - const IconComp = item.icon; - return ( - - ); - })} -
-
- - {/* Profile configuration invite / Recommendation Overview Widget */} - {!isLoggedIn ? ( -
-
- - 스마트 맞춤 추천 서비스 - -

- 프로필을 딱 한 번만 입력하고, 나에게 꼭 맞는 청년 정책을 받아보세요! -

-

- 거주지역, 연령뿐만 아니라 상세한 취업상태, 학력, 관심 키워드 조건을 대조하여 복잡한 수령자격을 자동으로 비교 분석해 드립니다. -

-
-
- - -
-
- ) : ( - /* MEMBER HOME-02: LOGGED-IN CUSTOM OVERVIEW */ -
-
-
-

- - {userName}님과 일치 확률이 가장 높은 추천 정책 -

-

- 현재 프로필: {userProfile.region} {userProfile.subRegion} · {userProfile.employmentStatus} · {userProfile.educationStatus} -

-
- -
- -
- {recommendedPolicies.slice(0, 3).map(({ policy, score }) => ( -
-
-
- - 유사도 {score}점 - - {policy.region} -
-

{policy.title}

-

{policy.description}

-
-
- 마감 {policy.deadline} - -
-
- ))} -
- -
- -
-
- )} - - {/* Recent & Grid Policies Split */} -
-
-
-
-

최근 화제인 신규 정책 소식

-

전국 및 서울 등 청년들이 가장 집중해서 조회하는 핵심 소식입니다.

-
- -
- -
- {MOCK_POLICIES.slice(0, 4).map((policy) => ( - p.id === policy.id)} - onToggleCompare={handleToggleCompare} - /> - ))} -
-
- - {/* Sidebar items */} -
- - -
-
-
- )} - - {/* VIEW 2: POLICY SEARCH (POLICY-01) */} - {activeView === "search" && ( -
-
- YouthPick > 통합 검색 -

실시간 정책 검색기

-

조건 필터와 지역 정보를 조합하여 나에게 딱 맞는 혜택 사업을 세밀하게 타겟팅해 보세요.

-
- - {/* Big Search Input */} -
-
- setSearchQuery(e.target.value)} - className="w-full rounded-2xl border border-slate-200 bg-white py-3 pl-5 pr-12 text-xs transition-colors focus:border-primary focus:outline-none shadow-sm" - id="search-main-input" - /> - -
- -
- - {/* Interactive Filters */} - - - {/* Results row info */} -
-
- - 총 {filteredPolicies.length}건의 청년 정책이 정밀 필터링되었습니다. - -
-
- 보기 구분: - -
-
- - {/* Core Card Grid or Empty State */} - {filteredPolicies.length > 0 ? ( -
-
-
- {filteredPolicies.map((policy) => ( - p.id === policy.id)} - onToggleCompare={handleToggleCompare} - /> - ))} -
-
- -
- - -
-
- ) : ( - /* EMPTY STATE VARIANT (5.4) */ -
- 🔍 -

일치하는 검색 결과가 전혀 없습니다

-

- 선택한 시·도 필터 조건이 맞지 않거나, 특정 카테고리에 너무 협소한 연령 조건이 지정되었을 수 있습니다. -

-
- - -
-
- )} -
- )} - - {/* VIEW 3: USER CUSTOM RECOMMENDATIONS (REC-01) */} - {activeView === "recommend" && ( -
-
-
- AI 종합 분석 스마트 팩트 매칭 -

나만을 위한 1:1 맞춤 청년정책 추천

-

회원님의 맞춤 프로필 가중치를 기반으로 상위 일치하는 자격을 자동 순위화했습니다.

-
- -
- - {/* Profile Briefing Plate */} -
-
- 현재 대조 프로필 조건 -

- {userProfile.region} {userProfile.subRegion} · {userProfile.birthYear}년생 · {userProfile.employmentStatus} · {userProfile.educationStatus} -

-
- {userProfile.interests.map((it) => ( - - #{it} - - ))} - {userProfile.keywords.map((it) => ( - - #{it} - - ))} -
-
-
- 통합 매칭 수 - 총 28건 추천 -
-
- - {/* Recommendation Cards Flow (REC-01 / Section 7) */} -
-
- {recommendedPolicies.slice(0, 8).map(({ policy, score, reliability, reasons }) => ( -
- {/* Corner ribbon matching score */} -
- 유사도 {score}점 -
- -
-
- - {policy.category} - - {policy.region} -
-

{policy.title}

-
- -

{policy.description}

- - {/* Reasons and Warning Section (REC-01 Section 7 requirement) */} -
-
- 추천 및 우대 조건 매칭 -
-

{reasons[0]} (+25)

-

{reasons[1]} (+20)

-

{reasons[2]} (+15)

-
-
- -
- 자가진단 및 공식공고 대조 필요 -

- - 세부 건강보험 소득요건 및 기업 규모별 제한사항은 공식 페이지 상세조회가 필요합니다. -

-
-
- - {/* Card Footer Actions */} -
- - - -
-
- ))} -
- -
- - -
-
-
- )} - - {/* VIEW 4: APPLICATION TRACKER (TRACKER-01 & TRACKER-02) */} - {activeView === "tracker" && ( -
- - {/* Split layout between List and Details */} -
- - {/* TRACKER-01: LEFT COLUMN (Tracker list with Tabs) */} -
-
- YouthPick > 나의 보관함 -

신청 준비 일정 관리

-

관심 등록한 정책의 제출 기한, 구비 서류 체크리스트를 놓치지 않게 체계적으로 추적합니다.

-
- - {/* Tracker Status filter buttons (TRACKER-01 layout) */} -
- {["전체", "관심", "준비중", "신청완료", "결과대기", "종료"].map((tab) => { - const count = tab === "전체" - ? trackers.length - : trackers.filter((t) => t.status === tab).length; - return ( - - ); - })} -
- - {/* Listing Tracker Cards */} -
- {trackers - .filter((t) => trackerTab === "전체" || t.status === trackerTab) - .map((track) => { - const policyRef = MOCK_POLICIES.find((p) => p.id === track.policyId); - if (!policyRef) return null; - - // Calculate completion percentage - const totalItems = track.checklist.length; - const completedItems = track.checklist.filter((c) => c.completed).length; - const pct = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; - - const isSelected = selectedTrackerPolicyId === track.policyId; - - return ( -
setSelectedTrackerPolicyId(track.policyId)} - className={`rounded-2xl border p-4 transition-all cursor-pointer text-left ${ - isSelected - ? "border-primary bg-primary/[0.01] shadow-sm ring-2 ring-primary/10" - : "border-slate-100 bg-white hover:border-slate-200" - }`} - > -
- - {track.status} - - 목표: {track.targetDate} -
- -

{policyRef.title}

- - {/* Progress bar */} -
-
- 체크리스트 {completedItems}/{totalItems} 완료 - {pct}% -
-
-
-
-
- -
- 수정기록: 2026.06.23 - - 관리하기 - - -
-
- ); - })} - - {trackers.filter((t) => trackerTab === "전체" || t.status === trackerTab).length === 0 && ( -
- 이 카테고리 상태에 배정된 신청 일감이 없습니다. -
- )} -
-
- - {/* TRACKER-02: RIGHT COLUMN (Selected Tracker detailed checks & memos) */} -
- {activeTrackerItem && activeTrackerPolicy ? ( -
- {/* Header bar */} -
-
- - {activeTrackerPolicy.category} - -

{activeTrackerPolicy.title}

-
- - 공식 공고 ↗ - -
- - {/* Interactive inputs: Status, Target Date */} -
-
- - -
- -
- - handleUpdateTrackerDate(activeTrackerItem.policyId, e.target.value)} - className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-bold text-slate-700 outline-none focus:bg-white focus:border-primary" - /> -
-
- - {/* Required documents brief (From wireframe Section 10) */} -
-

정책 제출 서류 기본 안내

-

- • 신청서 및 자기소개서, 구직상태 자격 소명 확인서 -

-

- * 주관 부처 마감 이전에 제출 서류 목록이 변동되었는지 공식 안내 고시를 반드시 교차 검토하세요. -

-
- - {/* Interactive checklist area with deletion option */} -
-
-

- - 준비 작업 체크리스트 -

- -
- - {/* Checklist overlay modal */} - {showAddChecklistItem && ( -
- setNewChecklistItemText(e.target.value)} - className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs focus:outline-none focus:border-primary" - /> -
- - -
-
- )} - -
- {activeTrackerItem.checklist.map((item) => ( -
- - -
- ))} -
-
- - {/* Memo textarea (Character monitoring limit 2000 chars) */} -
- -