diff --git a/.agents/skills/feature-issue/SKILL.md b/.agents/skills/feature-issue/SKILL.md new file mode 100644 index 0000000..4a8ac93 --- /dev/null +++ b/.agents/skills/feature-issue/SKILL.md @@ -0,0 +1,65 @@ +--- +name: feature-issue +description: 구현 설계서의 Task를 바탕으로 GitHub Feature Issue를 작성합니다. +--- + +# GitHub Feature Issue + +구현 설계서의 Task를 GitHub Feature Issue로 변환합니다. + +구현 설계서를 먼저 읽고 작업의 목적과 배경을 이해한 뒤 Issue를 작성합니다. + +## 사용 시점 + +다음 상황에서 사용합니다. + +- 구현 설계서에서 GitHub Issue를 생성할 때 +- 새로운 기능 구현을 시작할 때 +- 리팩토링 작업을 시작할 때 + +다음 상황에서는 사용하지 않습니다. + +- Bug Issue 작성 +- Pull Request 작성 +- Commit Message 작성 + +--- + +## 작업 원칙 + +- 구현 설계서의 Task 하나를 하나의 GitHub Issue로 작성합니다. +- 하나의 GitHub Issue는 하나의 Pull Request로 구현 가능한 크기를 유지합니다. +- 구현 설계서의 목적과 배경을 충분히 이해한 후 Issue를 작성합니다. +- 구현 설계서에 없는 내용을 추측하여 작성하지 않습니다. +- 필요한 정보가 부족한 경우 `확인 필요`로 표시합니다. + +--- + +## GitHub 설정 + +Issue를 생성할 때 아래 설정을 함께 적용합니다. + +- Assignee는 `hm1n`으로 고정합니다. +- Label은 Issue 제목의 라벨과 동일한 GitHub Label을 추가합니다. + - 예: Issue 제목이 `[setting-66/ai-agent] Agent 주도 개발 환경 도입`이면 `Setting` 라벨을 추가합니다. + - Issue 제목의 라벨이 GitHub Label과 정확히 일치하지 않으면, 기존 Label 목록에서 의미가 같은 라벨을 확인해 사용합니다. +- Project는 `KANBAN`으로 지정합니다. +- Project Status는 `TO-DO`로 지정합니다. + +--- + +## 출력 형식 + +이슈를 생성할 때는 `.github/ISSUE_TEMPLATE`에 있는 `feature_request.md`를 우선 사용합니다. +별도의 템플릿을 새로 만들거나 Skill의 assets에 중복 저장하지 않습니다. + +--- + +## 품질 기준 + +Issue를 작성하기 전에 아래 내용을 확인합니다. + +- 구현 설계서의 목적을 충분히 이해했는가? +- 현재 Task가 하나의 Issue로 적절한 크기인가? +- 하나의 PR로 구현 가능한가? +- 구현 설계서에 없는 내용을 추측하지 않았는가? diff --git a/.agents/skills/feature-issue/references/issue-title-convention.md b/.agents/skills/feature-issue/references/issue-title-convention.md new file mode 100644 index 0000000..5dec5dc --- /dev/null +++ b/.agents/skills/feature-issue/references/issue-title-convention.md @@ -0,0 +1,28 @@ +## Issue 제목 규칙 + +Issue 제목은 아래 컨벤션을 따릅니다. + +```text +[{라벨}-{작업번호}/{도메인}] 이슈명 +``` + +예시 + +```text +[feature-66/react-query] React Query Provider 추가 +[refactor-67/chat] ChatRoom 책임 분리 +[performance-68/chat] 채팅 렌더링 성능 개선 +[test-69/invite] 링크 초대 E2E 테스트 추가 +``` + +제목 생성 규칙은 다음과 같습니다. + +- `{라벨}`은 현재 레포지토리에 등록된 GitHub Label의 영문 이름을 그대로 사용합니다. +- `{작업번호}`는 **GitHub Issue 번호가 아닌 프로젝트 작업번호**입니다. +- 기존 Issue 제목의 컨벤션을 참고하여 가장 최근 작업번호를 찾고, 다음 번호를 사용합니다. +- `{도메인}`은 작업 대상이 되는 기능 또는 영역을 대표하는 영문 키워드를 사용합니다. (예: `chat`, `invite`, `auth`, `react-query`) +- `이슈명`은 작업 내용을 이해하기 쉬운 한국어로 작성합니다. + +작업번호는 GitHub Issue 번호와 별도로 관리되는 프로젝트 번호이며, 반드시 기존 Issue 제목을 참고하여 연속성을 유지합니다. + +--- diff --git a/.agents/skills/production-release/SKILL.md b/.agents/skills/production-release/SKILL.md new file mode 100644 index 0000000..688788d --- /dev/null +++ b/.agents/skills/production-release/SKILL.md @@ -0,0 +1,196 @@ +--- +name: production-release +description: 엔빵의 프로덕션 릴리즈를 준비하고 develop에서 main으로 릴리즈 PR을 생성합니다. +--- + +# Production Release + +프로덕션 릴리즈를 준비할 때 사용하는 스킬입니다. + +## Source of Truth + +릴리즈 정보는 다음 우선순위를 기준으로 판단합니다. + +1. Git 태그 +2. Pull Request +3. Git History + +`package.json`의 `version`은 릴리즈 버전 판단에 사용하지 않습니다. + +## Workflow + +1. 원격 저장소의 `origin/develop`, `origin/main`, Git 태그를 최신 상태로 동기화합니다. +2. 릴리즈 기준이 되는 최신 `vX.Y.Z` 태그를 확인합니다. +3. 최신 태그 이후 현재 `origin/develop`에 포함된 변경사항을 확인합니다. +4. 해당 변경 범위에 포함된 병합 PR을 수집합니다. +5. 각 PR의 번호, 제목, 라벨, `Summary`를 읽습니다. +6. `Summary`만으로 사용자 영향이 불명확한 경우에만 `Impact`를 확인합니다. +7. 다음 경우에만 제한적으로 PR diff를 확인합니다. + - `Summary`가 없는 경우 + - 제목과 `Summary`가 충돌하는 경우 + - 보안, 데이터 마이그레이션, 호환성 파괴 변경인 경우 + - 버전 판단에 필요한 정보가 부족한 경우 +8. 관련 PR을 기능 또는 목적 단위로 묶어 릴리즈 노트를 작성합니다. +9. 최근 태그와 변경 유형을 기준으로 다음 버전을 제안합니다. +10. 버전 중복과 변경사항 누락 여부를 검사합니다. +11. `references/release-pr-template.md`를 사용해 릴리즈 PR 본문을 작성합니다. +12. `[release] vX.Y.Z` 형식으로 `develop → main` 릴리즈 PR을 생성합니다. + +## Latest Release Tag + +릴리즈 기준 태그는 `origin/main` 릴리즈 계보에서 접근 가능한 태그 중 다음 조건을 모두 만족하고 버전이 가장 높은 태그를 사용합니다. + +- 정확히 `vX.Y.Z` 형식인가 +- Semantic Versioning 형식인가 +- 버전 순으로 가장 높은 태그인가 +- 태그가 가리키는 커밋이 `origin/main` 이력에 포함되어 있는가 + +다음 태그는 릴리즈 기준으로 사용하지 않습니다. + +- `release-*` +- 테스트 또는 임시 태그 +- prerelease 태그 +- `vX.Y.Z` 형식이 아닌 태그 + +릴리즈 기준 태그가 없으면 버전을 자동으로 결정하지 않고 사용자에게 확인을 요청합니다. + +전역에서 가장 높은 버전 태그가 있더라도 `origin/main` 릴리즈 계보에서 접근할 수 없다면 릴리즈 기준으로 사용하지 않습니다. + +## Release Range + +릴리즈 대상 변경사항은 다음 범위를 기준으로 판단합니다. + +- 최신 릴리즈 태그가 가리키는 커밋 이후 +- 현재 `origin/develop`에 포함된 변경사항 + +기본 비교 범위는 다음과 같습니다. + +latest_tag..origin/develop + +최신 태그가 없으면 다음 범위를 참고하되, 자동으로 릴리즈 PR을 생성하지 않습니다. + +origin/main..origin/develop + +릴리즈 대상 PR은 다음 조건을 만족해야 합니다. + +- `develop`에 병합된 PR인가 +- 비교 범위의 커밋이 `origin/develop`에 포함되어 있는가 +- 릴리즈 PR이나 merge-only PR이 아닌가 +- 동일한 PR이 중복 수집되지 않았는가 + +Git 커밋 범위와 GitHub PR 목록을 함께 확인합니다. + +1. `latest_tag..origin/develop`의 커밋을 확인합니다. +2. 해당 커밋과 연결된 병합 PR을 수집합니다. +3. PR의 merge commit 또는 포함 커밋이 비교 범위에 존재하는지 확인합니다. +4. 비교 범위에 포함되지 않은 PR은 제외합니다. +5. PR과 연결되지 않은 커밋이 있으면 누락 가능성으로 보고합니다. + +릴리즈 대상 변경사항이나 병합 PR이 없다면 릴리즈 PR을 생성하지 않습니다. + +## Pull Request Information + +각 PR에서 다음 정보를 우선적으로 확인합니다. + +1. PR 번호 +2. PR 제목 +3. PR 라벨 +4. `Summary` +5. 필요한 경우 `Impact` +6. 예외적으로 PR diff + +Merge Commit 메시지는 릴리즈 노트의 직접적인 입력으로 사용하지 않습니다. + +## Version Rules + +버전은 `vX.Y.Z` 형식의 Semantic Versioning을 사용합니다. + +### Major + +- 기존 기능과 호환되지 않는 변경 +- 주요 사용자 흐름 또는 공개 인터페이스의 파괴적 변경 + +### Minor + +- 새로운 사용자 기능 추가 +- 기존 기능의 의미 있는 확장 +- 새로운 공개 페이지 또는 사용자 흐름 추가 + +### Patch + +- 버그 수정 +- 기존 기능 개선 +- 성능, 접근성, SEO, 내부 안정성 개선 + +여러 변경 유형이 포함되면 가장 높은 버전 수준을 선택합니다. + +major > minor > patch + +버전 수준을 신뢰성 있게 판단하기 어렵다면 자동으로 결정하지 않고 사용자에게 확인을 요청합니다. + +다음 조건을 확인합니다. + +- 제안한 버전 태그가 이미 존재하지 않는가 +- 최신 릴리즈 태그보다 높은 버전인가 +- PR 제목과 PR 본문의 버전이 일치하는가 +- 동일 버전의 열린 릴리즈 PR이 존재하지 않는가 +- 동일한 `develop → main` 릴리즈 PR이 이미 열려 있지 않은가 + +## Missing Change Check + +수집한 모든 PR은 다음 중 하나로 처리합니다. + +1. 주요 변경사항에 직접 반영 +2. 다른 PR과 기능 또는 목적 단위로 통합하여 반영 +3. 내부 변경, 중복 또는 릴리즈 제외 대상으로 분류 + +반영 또는 제외 사유가 없는 PR이 있다면 릴리즈 PR을 생성하지 않습니다. + +`포함된 Pull Request` 섹션에는 릴리즈 대상 PR을 모두 작성합니다. + +다음 항목도 확인합니다. + +- 비교 범위에는 있으나 PR 목록에 없는 커밋이 있는가 +- 수집한 PR 중 릴리즈 노트와 포함 PR 목록에서 누락된 항목이 있는가 +- 동일 PR이 두 번 이상 포함되어 있는가 +- 최신 태그 이전 변경사항이 잘못 포함되어 있는가 + +## Pull Request + +- Base branch: `main` +- Head branch: `develop` +- Title: `[release] vX.Y.Z` +- Body: `references/release-pr-template.md` 사용 + +다음 조건 중 하나라도 만족하면 새 릴리즈 PR을 생성하지 않습니다. + +- 릴리즈 대상 변경사항이 없음 +- 동일 버전의 릴리즈 PR이 존재함 +- 동일한 `develop → main` 열린 PR이 존재함 +- 제안 버전 태그가 이미 존재함 +- 변경사항 누락 검사가 실패함 + +PR 생성 전 다음을 확인합니다. + +- `origin/develop`과 로컬 `develop`이 일치하는가 +- `origin/main`과 로컬 `main`이 일치하는가 +- 릴리즈할 변경사항이 존재하는가 +- 동일한 버전의 릴리즈 PR이 존재하지 않는가 +- 동일한 base와 head를 사용하는 열린 PR이 존재하지 않는가 + +## Constraints + +- `package.json` 버전은 변경하지 않습니다. +- 릴리즈 브랜치를 생성하지 않습니다. +- 릴리즈 이슈를 생성하지 않습니다. +- Git 태그를 생성하지 않습니다. +- GitHub Release를 생성하지 않습니다. +- 프로덕션 배포를 직접 수행하지 않습니다. +- GitHub 자동 릴리즈 노트와 Release Drafter를 사용하지 않습니다. +- 코드 diff 전체 분석은 기본 동작에서 제외합니다. +- 구현 세부사항보다 기능과 사용자 영향을 우선 설명합니다. + +## References + +- `references/release-note.md` +- `references/release-pr-template.md` diff --git a/.agents/skills/production-release/agents/openai.yaml b/.agents/skills/production-release/agents/openai.yaml new file mode 100644 index 0000000..008cca7 --- /dev/null +++ b/.agents/skills/production-release/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Production Release" + short_description: "엔빵의 프로덕션 릴리즈 PR을 준비하고 생성합니다." + default_prompt: "이전 릴리즈 이후 변경사항을 확인하고 릴리즈 노트를 작성한 뒤 develop에서 main으로 프로덕션 릴리즈 PR을 생성해 주세요." diff --git a/.agents/skills/production-release/references/release-note.md b/.agents/skills/production-release/references/release-note.md new file mode 100644 index 0000000..72a8de9 --- /dev/null +++ b/.agents/skills/production-release/references/release-note.md @@ -0,0 +1,167 @@ +# Release Note Guide + +이 문서는 릴리즈 PR과 GitHub Release에 사용할 릴리즈 노트 작성 규칙을 정의합니다. + +릴리즈 PR의 제목과 전체 본문 구조는 `references/release-pr-template.md`를 따릅니다. + +## 목적 + +릴리즈 노트는 단순한 개발 기록이 아니라 사용자와 개발자가 이번 릴리즈에서 무엇이 변경되었는지 빠르게 이해할 수 있는 문서입니다. + +구현 방법보다 변경 결과와 사용자 영향을 우선 설명합니다. + +## 정보 조회 우선순위 + +각 PR의 다음 정보를 순서대로 사용합니다. + +1. PR `Summary` +2. PR 제목 +3. PR `Impact` +4. PR diff + +`Summary`만으로 변경사항을 이해할 수 있다면 `Impact`와 diff는 확인하지 않습니다. + +## Summary 활용 규칙 + +PR의 `Summary`를 그대로 나열하거나 복사하지 않습니다. + +수집한 Summary를 비교해 다음 작업을 수행합니다. + +- 중복 내용 제거 +- 관련 PR 통합 +- 기능 또는 목적 단위로 재구성 +- 사용자 또는 운영 관점으로 재작성 + +여러 PR이 하나의 기능을 구성한다면 각각의 Summary를 한 개의 변경사항으로 통합합니다. + +Summary에서 확인할 수 없는 효과나 결과는 추측하지 않습니다. + +## 작성 원칙 + +### 사용자 관점에서 작성합니다 + +좋은 예 + +- 비회원도 서비스 기능을 확인할 수 있도록 공개 랜딩페이지를 추가했습니다. +- 검색엔진이 페이지를 더 정확하게 이해할 수 있도록 SEO 설정을 개선했습니다. + +좋지 않은 예 + +- `app/page.tsx`를 수정했습니다. +- `generateMetadata`를 공통 함수로 분리했습니다. + +### 관련 PR을 하나의 변경사항으로 묶습니다 + +여러 PR이 하나의 기능이나 목적을 구성한다면 하나의 항목으로 통합합니다. + +예시 PR + +- 랜딩페이지 구현 +- metadata 적용 +- sitemap 추가 + +예시 릴리즈 노트 + +### 공개 페이지와 검색 노출 개선 + +- 비회원용 공개 랜딩페이지를 추가했습니다. +- 검색엔진이 페이지를 올바르게 인식하도록 메타데이터와 검색 노출 설정을 개선했습니다. + +### 구현 세부사항은 최소화합니다 + +다음 내용은 특별한 이유가 없다면 작성하지 않습니다. + +- 함수명 +- 컴포넌트명 +- 파일 경로 +- 리팩터링 과정 +- 내부 구현 방식 + +필요한 경우 변경 목적과 결과만 설명합니다. + +### 내부 작업은 필요한 경우만 포함합니다 + +사용자에게 영향이 없는 작업은 릴리즈 노트에서 제외할 수 있습니다. + +예시 + +- 코드 스타일 수정 +- lint 설정 변경 +- 단순 내부 리팩터링 +- 영향이 없는 의존성 업데이트 +- merge-only 작업 + +운영 안정성이나 개발 생산성에 의미 있는 변화가 있다면 `내부 개선`으로 작성합니다. + +## 버전 표기 규칙 + +- PR 제목과 본문의 버전은 모두 `vX.Y.Z` 형식을 사용합니다. +- PR 제목과 본문의 버전은 반드시 일치해야 합니다. +- 최신 릴리즈 태그보다 낮거나 같은 버전을 작성하지 않습니다. +- 이미 존재하는 태그 또는 열린 릴리즈 PR의 버전을 다시 사용하지 않습니다. +- `package.json`의 버전은 릴리즈 버전 판단에 사용하지 않습니다. + +## 포함된 Pull Request 작성 규칙 + +- 릴리즈 대상 PR을 모두 나열합니다. +- 주요 변경사항에서 여러 PR을 통합했더라도 각 PR은 목록에 별도로 작성합니다. +- 내부 변경으로 제외한 PR도 릴리즈 범위에 포함된다면 목록에 작성할 수 있습니다. +- 릴리즈 PR 자체는 포함하지 않습니다. +- 중복 PR은 한 번만 작성합니다. +- 최신 태그 이후 비교 범위에 포함된 PR만 작성합니다. + +## 작성 규칙 + +- 사용자 또는 운영 관점의 변화를 설명합니다. +- 구현 방법보다 변경 목적과 결과를 작성합니다. +- 같은 의미의 변경사항은 하나로 묶습니다. +- 중복 설명을 작성하지 않습니다. +- 릴리즈 요약은 2~3문장으로 작성합니다. +- 각 변경사항은 가능한 한 한두 문장으로 작성합니다. +- 실제 PR 내용에서 확인할 수 없는 효과를 추측하지 않습니다. +- Merge Commit 메시지를 그대로 릴리즈 노트에 사용하지 않습니다. + +## 제외 대상 + +다음 내용은 기본적으로 주요 변경사항에서 제외합니다. + +- Merge Commit +- Release PR +- Version Update +- 단순 GitHub Actions 수정 +- 단순 CI 설정 변경 +- Chore 성격의 작업 +- 사용자가 인지할 수 없는 내부 수정 + +단, 사용자 경험, 운영 안정성 또는 개발 생산성에 의미 있는 영향이 있다면 포함할 수 있습니다. + +## Quality Rules + +릴리즈 노트는 단순한 변경 로그가 아니라 프로젝트의 변경 이력을 설명하는 문서입니다. + +GitHub Release뿐 아니라 포트폴리오, 프로젝트 문서, 변경 이력으로도 활용할 수 있는 품질을 목표로 합니다. + +릴리즈 노트를 작성할 때 아래 질문에 모두 답할 수 있어야 합니다. + +- 이번 릴리즈에서 무엇이 달라졌는가 +- 왜 이 변경을 했는가 +- 사용자는 어떤 변화를 경험하는가 + +위 질문에 답할 수 없는 내용은 릴리즈 노트에 포함하지 않습니다. + +### 체크리스트 + +릴리즈 노트를 작성한 후 아래 항목을 확인합니다. + +- 변경사항이 사용자 관점에서 작성되었는가 +- 구현 방법보다 변경 결과를 설명하고 있는가 +- 여러 PR을 하나의 기능 단위로 적절히 묶었는가 +- Summary를 그대로 복사하지 않고 재구성했는가 +- 수집한 모든 PR이 반영되거나 제외 사유를 가지고 있는가 +- 비교 범위 밖의 PR이 포함되지 않았는가 +- 중복되거나 불필요한 설명이 없는가 +- PR 제목과 본문의 버전이 일치하는가 +- 릴리즈 전체를 2~3분 안에 이해할 수 있는가 +- GitHub Release로 그대로 게시해도 될 정도의 품질인가 + +모든 항목을 만족하지 못하면 릴리즈 노트를 다시 수정합니다. diff --git a/.agents/skills/production-release/references/release-pr-template.md b/.agents/skills/production-release/references/release-pr-template.md new file mode 100644 index 0000000..ffe67ac --- /dev/null +++ b/.agents/skills/production-release/references/release-pr-template.md @@ -0,0 +1,41 @@ +# Release PR Template + +## Title + +```text +[release] vX.Y.Z +``` + +## Body + +```markdown +## 릴리즈 버전 + +`vX.Y.Z` + + + +## 릴리즈 요약 + +이번 릴리즈의 핵심 변화와 목적을 사용자 또는 운영 관점에서 2~3문장으로 작성합니다. + +## 주요 변경사항 + +### 기능 또는 목표 단위 제목 + +- 사용자 또는 운영 관점에서 달라진 내용을 작성합니다. +- 여러 PR이 하나의 기능을 구성한다면 하나의 변경사항으로 통합합니다. + +## 포함된 Pull Request + +- #PR번호 PR 제목 + + + +``` + +## Rules + +- PR 제목은 반드시 `[release] vX.Y.Z` 형식을 사용합니다. +- PR 본문의 버전과 제목의 버전은 반드시 일치해야 합니다. +- `release-note:start`와 `release-note:end` 사이의 내용은 이후 GitHub Release 본문으로 사용합니다. diff --git a/.agents/skills/pull-request/SKILL.md b/.agents/skills/pull-request/SKILL.md new file mode 100644 index 0000000..a5c5ffd --- /dev/null +++ b/.agents/skills/pull-request/SKILL.md @@ -0,0 +1,74 @@ +--- +name: pull-request +description: GitHub Issue와 Git diff를 바탕으로 사람이 빠르게 이해할 수 있는 Pull Request를 작성합니다. +--- + +# Pull Request + +프로젝트의 GitHub Pull Request Template에 맞춰 Pull Request를 작성합니다. + +Pull Request는 변경 내역이 아니라 **의사결정 문서**입니다. + +## 사용 시점 + +다음 상황에서 사용합니다. + +- GitHub Issue 구현이 완료되었을 때 +- Pull Request를 생성할 때 +- 변경 사항, 영향 범위, 리뷰 포인트를 정리할 때 + +다음 상황에서는 사용하지 않습니다. + +- GitHub Issue 작성 +- Commit Message 작성 +- Bug Report 작성 + +--- + +## 작업 원칙 + +- 하나의 GitHub Issue는 하나의 Pull Request로 구현합니다. +- 연결된 GitHub Issue의 Why, Goal, Constraints를 먼저 이해한 후 Pull Request를 작성합니다. +- Git diff를 확인하여 실제 변경 사항만 작성합니다. +- PR 본문은 한국어로 작성합니다. +- PR은 `Why → Decision → Changes → Impact → Review Points → Validation` 흐름으로 작성합니다. +- 변경 사항을 나열하기보다 왜 그렇게 구현했는지 설명합니다. +- 확인하지 않은 테스트는 실행했다고 작성하지 않습니다. +- 불확실한 내용은 `확인 필요`로 표시합니다. +- 리뷰어가 집중해서 봐야 할 위험 영역을 명확히 표시합니다. +- Review Points는 가능하면 3개 이내로 제한합니다. +- 연결된 GitHub Issue가 없다면 먼저 Issue 생성을 제안합니다. + +--- + +## GitHub 설정 + +PR을 생성할 때 아래 설정을 함께 적용합니다. + +- Assignee는 `hm1n`으로 고정합니다. +- Label은 PR 제목의 라벨과 동일한 GitHub Label을 추가합니다. + - 예: PR 제목이 `[setting-66/ai-agent] Agent 주도 개발 환경 도입`이면 `Setting` 라벨을 추가합니다. + - PR 제목의 라벨이 GitHub Label과 정확히 일치하지 않으면, 기존 Label 목록에서 의미가 같은 라벨을 확인해 사용합니다. + +--- + +## 출력 형식 + +프로젝트의 `.github/pull_request_template.md`를 사용합니다. + +Pull Request 제목은 연결된 GitHub Issue 제목과 동일하게 작성합니다. + +별도의 템플릿을 생성하거나 Skill의 `assets`에 중복 저장하지 않습니다. + +--- + +## 품질 기준 + +PR을 작성하기 전에 아래 내용을 확인합니다. + +- 관련 Issue의 Why와 Goal이 PR에 반영되었는가? +- 구현 방식의 선택 이유가 설명되어 있는가? +- 실제 Git diff에 없는 내용을 작성하지 않았는가? +- 기존 기능에 미치는 영향이 정리되었는가? +- 한계와 트레이드오프가 있다면 명시했는가? +- Review Points와 Validation이 실제 구현 내용과 일치하는가? diff --git a/.agents/skills/workflow.md b/.agents/skills/workflow.md new file mode 100644 index 0000000..6566bcd --- /dev/null +++ b/.agents/skills/workflow.md @@ -0,0 +1,31 @@ +# AI Workflow + +## 전체 흐름 + +구현 설계서 → GitHub Issue → Codex 구현 → Commit → Pull Request → Codex Review → Merge + +## 역할 분리 + +- ChatGPT: 구현 설계서 작성, Task 분해, Issue 초안 작성 +- Codex: Issue 구현, Commit, Pull Request 작성, Code Review 대응 +- GitHub: Issue / PR / Review 관리 +- Notion: 구현 설계서 관리 + +## 원칙 + +- 구현 설계서의 Task 1개는 GitHub Issue 1개로 변환한다. +- GitHub Issue 1개는 Pull Request 1개로 구현한다. +- Pull Request 제목은 연결된 Issue 제목과 동일하게 작성한다. +- PR은 변경사항 나열이 아니라 의사결정 문서로 작성한다. +- Codex Review는 P0/P1 수준의 위험 요소를 확인하는 용도로 사용한다. + +## 작업 순서 + +1. Notion에서 구현 설계서를 작성한다. +2. 구현 설계서의 Task를 기준으로 GitHub Issue를 생성한다. +3. Codex가 Issue 단위로 구현한다. +4. 변경 사항을 논리적인 작업 단위로 Commit한다. +5. Issue 제목과 동일한 제목으로 PR을 생성한다. +6. `@codex review`로 리뷰를 요청한다. +7. 필요 시 `@codex fix`로 수정 작업을 요청한다. +8. 최종 확인 후 Merge한다. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 66e31bf..c8fe369 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,16 +6,73 @@ labels: '' assignees: '' --- -## 어떤 버그인가요? +## Summary -> 어떤 버그인지 간결하게 설명해주세요 +> 3줄 이내로 버그를 요약합니다. -## 어떤 상황에서 발생한 버그인가요? +--- + +## Problem + +### 어떤 문제가 발생했나요? + +- 현재 발생하는 현상 +- 사용자에게 미치는 영향 +- 발생 빈도(항상/간헐적) + +--- + +## Expected + +### 원래 어떻게 동작해야 하나요? -> (가능하면) Given-When-Then 형식으로 서술해주세요 +- 기대하는 동작 + +--- + +## Reproduction + +### 어떻게 재현할 수 있나요? + +1. +2. +3. + +--- -## 예상 결과 +## Scope + +### 영향을 받는 범위 + +- 기능 +- 페이지 +- 컴포넌트 +- API +- DB + +--- + +## Suspected Cause (Optional) + +### 예상 원인 + +- 의심되는 코드 +- 의심되는 로직 +- 확실하지 않으면 비워둠 + +--- + +## Constraints + +### 수정 시 고려할 사항 + +- 기존 동작 유지 +- 변경하면 안 되는 부분 + +--- -> 예상했던 정상적인 결과가 어떤 것이었는지 설명해주세요 +## Definition of Done -## 참고할만한 자료(선택) +- [ ] 버그 재현되지 않음 +- [ ] 기존 기능 정상 동작 +- [ ] 회귀 테스트 완료 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 07f6d8d..c679e8d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -6,14 +6,60 @@ labels: '' assignees: '' --- -## 어떤 기능인가요? +## Summary -> 추가하려는 기능에 대해 간결하게 설명해주세요 +> 3줄 이내로 이번 작업을 요약합니다. -## 작업 상세 내용 +--- + +## Why + +### 해결하려는 문제가 무엇인가요? + +- 현재 어떤 문제가 있는가? +- 왜 지금 해결해야 하는가? +- 해결하지 않으면 어떤 문제가 발생하는가? + +--- + +## Goal + +### 완료되면 무엇이 달라지나요? + +- 이번 작업의 목표 +- 이번 작업에서 해결하지 않는 범위(Non-goal) + +--- + +## Approach + +### 어떻게 해결할 계획인가요? + +- 전체 구현 방향 +- 주요 변경 사항 +- 검토한 대안과 선택 이유 + +--- + +## Tasks + +- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 + +--- + +## Constraints + +- 반드시 유지해야 하는 동작 +- 변경하면 안 되는 사항 +- 기술적 제약사항 + +--- -- [ ] TODO -- [ ] TODO -- [ ] TODO +## Definition of Done -## 참고할만한 자료(선택) +- [ ] 기능 동작 확인 +- [ ] 테스트 완료 +- [ ] lint / typecheck 통과 +- [ ] 문서 업데이트 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 435f85d..36a56fc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,15 +1,102 @@ -## #️⃣연관된 이슈 +## Summary -> ex) #이슈번호, #이슈번호 +> 3줄 이내로 이번 PR을 요약합니다. -## 📝작업 내용 +--- -> 이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능) +## Why -### 스크린샷 (선택) +### 왜 이 작업을 진행했나요? -## 💬리뷰 요구사항(선택) +- 어떤 문제를 해결하기 위한 PR인가요? +- 왜 지금 필요한 작업인가요? -> 리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요 -> -> ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요? +--- + +## Decision + +### 어떤 구현 방식을 선택했나요? + +- 선택한 구현 방식 +- 검토한 대안 +- 선택 이유 + +--- + +## Changes + +### 무엇이 변경되었나요? + +#### Feature + +- + +#### Fix + +- + +#### Refactor + +- + +#### Test + +- + +#### Docs + +- *** + +## Trade-off + +### 한계와 트레이드오프 + +- 현재 구현의 한계 +- 이후 개선 가능한 부분 + +--- + +## Impact + +### 기존 기능에 미치는 영향 + +- 영향받는 기능 +- 회귀 가능성이 있는 영역 +- 배포 시 확인할 사항 + +--- + +## Edge Cases + +### Edge Case 및 실패 시나리오 + +- 고려한 예외 상황 +- 실패 가능성이 있는 시나리오 + +--- + +## Review Points + +### 리뷰어가 집중해서 봐야 할 부분 + +#### 🔴 High + +- + +#### 🟡 Medium + +- + +#### 🟢 Low + +- *** + +## Validation + +### 어떻게 검증했나요? + +- [ ] 단위 테스트 +- [ ] E2E 테스트 +- [ ] 수동 테스트 +- [ ] lint +- [ ] typecheck diff --git a/.github/workflows/create-release-note.yml b/.github/workflows/create-release-note.yml new file mode 100644 index 0000000..1b15ce0 --- /dev/null +++ b/.github/workflows/create-release-note.yml @@ -0,0 +1,218 @@ +name: Create production release + +on: + status: + +permissions: + contents: write + pull-requests: read + +concurrency: + group: production-release-${{ github.event.sha }}-${{ github.event.context }}-${{ github.event.state }} + cancel-in-progress: false + +jobs: + create-release: + if: >- + github.event.context == 'Vercel' && + github.event.state == 'success' && + github.event.sender.login == 'vercel[bot]' && + startsWith(github.event.target_url, 'https://vercel.com/hm1n/nbread/') + runs-on: ubuntu-latest + env: + EVENT_SHA: ${{ github.event.sha }} + GH_TOKEN: ${{ github.token }} + + steps: + - name: Checkout main + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: main + + - name: Verify latest main deployment + id: main + shell: bash + run: | + set -euo pipefail + + git fetch origin main --tags + + MAIN_SHA="$(git rev-parse origin/main)" + if [[ "$MAIN_SHA" != "$EVENT_SHA" ]]; then + echo "현재 main 최신 커밋의 배포가 아니므로 릴리즈를 생성하지 않습니다." + echo "event_sha=$EVENT_SHA" + echo "main_sha=$MAIN_SHA" + echo "eligible=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Vercel 성공 상태가 현재 main 최신 커밋과 일치합니다: $EVENT_SHA" + echo "eligible=true" >> "$GITHUB_OUTPUT" + + - name: Resolve release pull request + if: steps.main.outputs.eligible == 'true' + id: release + shell: bash + run: | + set -euo pipefail + + PULLS_JSON="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${EVENT_SHA}/pulls?per_page=100" + )" + + MATCHING_PULLS="$( + jq \ + --arg repository "$GITHUB_REPOSITORY" \ + '[ + .[] + | select( + .merged_at != null + and .base.ref == "main" + and .head.ref == "develop" + and .head.repo.full_name == $repository + and (.title | test("^\\[release\\] v[0-9]+\\.[0-9]+\\.[0-9]+$")) + ) + ]' \ + <<< "$PULLS_JSON" + )" + + PULL_COUNT="$(jq 'length' <<< "$MATCHING_PULLS")" + if [[ "$PULL_COUNT" -ne 1 ]]; then + echo "배포 SHA에 연결된 유효한 릴리즈 PR은 정확히 1개여야 합니다. 발견: $PULL_COUNT" >&2 + jq -r '.[] | "- #\(.number) \(.title) (\(.head.ref) -> \(.base.ref))"' <<< "$MATCHING_PULLS" >&2 + exit 1 + fi + + PR_NUMBER="$(jq -r '.[0].number' <<< "$MATCHING_PULLS")" + PR_TITLE="$(jq -r '.[0].title' <<< "$MATCHING_PULLS")" + PR_BODY="$(jq -r '.[0].body // ""' <<< "$MATCHING_PULLS")" + + if [[ "$PR_TITLE" =~ ^\[release\]\ (v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + else + echo "릴리즈 PR 제목 형식이 올바르지 않습니다: $PR_TITLE" >&2 + exit 1 + fi + + BODY_VERSION="$( + awk ' + /^## 릴리즈 버전[[:space:]]*$/ { in_version_section = 1; next } + in_version_section && /^## / { exit } + in_version_section && match($0, /`v[0-9]+\.[0-9]+\.[0-9]+`/) { + print substr($0, RSTART + 1, RLENGTH - 2) + exit + } + ' <<< "$PR_BODY" + )" + + if [[ "$BODY_VERSION" != "$VERSION" ]]; then + echo "릴리즈 PR 제목과 본문의 버전이 일치하지 않습니다." >&2 + echo "title_version=$VERSION" >&2 + echo "body_version=${BODY_VERSION:-missing}" >&2 + exit 1 + fi + + START_COUNT="$(grep -cF '' <<< "$PR_BODY" || true)" + END_COUNT="$(grep -cF '' <<< "$PR_BODY" || true)" + if [[ "$START_COUNT" -ne 1 || "$END_COUNT" -ne 1 ]]; then + echo "릴리즈 노트 시작/종료 마커가 각각 정확히 1개 필요합니다." >&2 + echo "start_count=$START_COUNT" >&2 + echo "end_count=$END_COUNT" >&2 + exit 1 + fi + + START_LINE="$(grep -nF '' <<< "$PR_BODY" | cut -d: -f1)" + END_LINE="$(grep -nF '' <<< "$PR_BODY" | cut -d: -f1)" + if [[ "$START_LINE" -ge "$END_LINE" ]]; then + echo "릴리즈 노트 마커의 순서가 올바르지 않습니다." >&2 + exit 1 + fi + + if [[ "$END_LINE" -le "$((START_LINE + 1))" ]]; then + echo "릴리즈 노트 마커 사이에 본문이 없습니다." >&2 + exit 1 + fi + + sed -n "$((START_LINE + 1)),$((END_LINE - 1))p" <<< "$PR_BODY" > release-notes.md + if ! grep -q '[^[:space:]]' release-notes.md; then + echo "릴리즈 PR에서 추출한 릴리즈 노트에 비공백 내용이 없습니다." >&2 + exit 1 + fi + + echo "릴리즈 PR을 확인했습니다: #$PR_NUMBER $PR_TITLE" + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Create or recover production release + if: steps.main.outputs.eligible == 'true' + shell: bash + env: + PR_NUMBER: ${{ steps.release.outputs.pr_number }} + VERSION: ${{ steps.release.outputs.version }} + run: | + set -euo pipefail + + TAG_REF="$(git ls-remote --tags origin "refs/tags/${VERSION}")" + if [[ -n "$TAG_REF" ]]; then + TAG_EXISTS=true + else + TAG_EXISTS=false + fi + + if gh api --silent "repos/${GITHUB_REPOSITORY}/releases/tags/${VERSION}" 2> release-error.txt; then + RELEASE_EXISTS=true + elif grep -q 'HTTP 404' release-error.txt; then + RELEASE_EXISTS=false + else + echo "GitHub Release 존재 여부를 확인하지 못했습니다." >&2 + cat release-error.txt >&2 + exit 1 + fi + + if [[ "$TAG_EXISTS" == true ]]; then + git fetch origin "refs/tags/${VERSION}:refs/tags/${VERSION}" + TAG_SHA="$(git rev-list -n 1 "$VERSION")" + if [[ "$TAG_SHA" != "$EVENT_SHA" ]]; then + echo "기존 태그가 현재 배포 SHA와 다른 커밋을 가리킵니다." >&2 + echo "version=$VERSION" >&2 + echo "tag_sha=$TAG_SHA" >&2 + echo "event_sha=$EVENT_SHA" >&2 + exit 1 + fi + fi + + if [[ "$TAG_EXISTS" == true && "$RELEASE_EXISTS" == true ]]; then + echo "태그와 GitHub Release가 이미 존재합니다: $VERSION" + exit 0 + fi + + if [[ "$TAG_EXISTS" == false && "$RELEASE_EXISTS" == true ]]; then + echo "GitHub Release는 존재하지만 대응하는 Git 태그가 없습니다: $VERSION" >&2 + exit 1 + fi + + if [[ "$TAG_EXISTS" == false ]]; then + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag -a "$VERSION" "$EVENT_SHA" -m "$VERSION" + git push origin "refs/tags/${VERSION}" + echo "배포 SHA에 태그를 생성했습니다: $VERSION -> $EVENT_SHA" + else + echo "기존 태그를 사용해 GitHub Release 생성을 재시도합니다: $VERSION" + fi + + gh release create "$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$VERSION" \ + --notes-file release-notes.md \ + --verify-tag + + { + echo "### Production release" + echo "- Version: \`$VERSION\`" + echo "- Commit: \`$EVENT_SHA\`" + echo "- Pull Request: #$PR_NUMBER" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 4f8eb38..ada04d4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,9 +40,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -# github -.github - # pwa public/sw.js* public/workbox-*.js* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aef83aa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,116 @@ +# Repository Guideline + +## 프로젝트 개요 + +이 프로젝트는 구독 공유 통합 관리 서비스 **엔빵**의 프론트엔드 저장소입니다. + +- Framework: Next.js (App Router) +- Language: TypeScript (Strict Mode) +- Backend: Supabase +- State Management: Zustand +- Styling: Tailwind CSS + +--- + +## 개발 원칙 + +AI는 아래 원칙을 항상 우선적으로 따릅니다. + +- 기존 기능이 정상 동작하는 것을 최우선으로 합니다. +- 필요한 범위만 최소한으로 수정합니다. +- 큰 변경보다 작은 변경을 선호합니다. +- 추측하여 구현하지 않습니다. +- 기존 코드 스타일과 프로젝트 구조를 최대한 유지합니다. + +--- + +## 작업 방식 + +모든 개발 작업은 아래 순서를 따릅니다. + +``` +구현 설계서 + ↓ +GitHub Issue + ↓ +구현 + ↓ +Pull Request + ↓ +Code Review +``` + +- 구현 전에 작업 목적과 범위를 먼저 이해합니다. +- 하나의 Issue는 가능한 하나의 PR로 구현합니다. +- PR이 너무 커질 경우 Task 단위로 작업을 다시 분리합니다. +- 구현보다 작업 분해와 영향 범위를 먼저 검토합니다. + +--- + +## 코드 작성 규칙 + +- TypeScript Strict Mode를 유지합니다. +- `any` 사용은 지양합니다. +- 기존 프로젝트의 코드 스타일을 따릅니다. +- 새로운 구조를 만들기보다 기존 패턴을 우선 재사용합니다. +- UI 컴포넌트에서 비즈니스 로직을 직접 구현하지 않습니다. +- 기존 Helper 또는 Lib가 있다면 우선 활용합니다. + +--- + +## 테스트 + +작업 완료 후 아래 내용을 확인합니다. + +- `npm run lint` +- `npm run build` + +Playwright 테스트가 존재하는 경우 영향을 받은 기능을 함께 검증합니다. + +--- + +## Review Guidelines + +리뷰의 목적은 코드 스타일을 지적하는 것이 아니라, 변경 사항의 위험 요소와 의사결정을 검증하는 것입니다. + +리뷰 시 아래 순서를 우선합니다. + +1. Why + - 이 작업을 왜 해야 하는가? + - 지금 해결해야 하는 이유가 충분한가? + +2. Decision + - 구현 방식이 문제를 해결하는 데 적절한가? + - 더 단순하거나 안전한 대안은 없는가? + +3. Impact + - 기존 기능에 영향을 주지 않는가? + - 회귀(Regression), 데이터 정합성, 성능 저하 가능성은 없는가? + +4. Review Points + - 작성자가 지정한 위험 영역을 우선 검토한다. + +코드 스타일보다 기능 안정성과 사용자 영향도를 우선하며, P0/P1 수준의 문제에 집중합니다. + +--- + +## 보안 + +- Secret 또는 `.env` 파일은 절대 커밋하지 않습니다. +- Supabase Schema 변경 시 Type을 최신 상태로 유지합니다. +- RLS 관련 변경은 특히 신중하게 검토합니다. +- 운영 환경에서만 사용하는 설정은 유지합니다. + +--- + +## AI 작업 원칙 + +작업 시 다음 내용을 항상 명확히 설명합니다. + +- 왜 이 작업이 필요한가 +- 어떤 방식을 선택했는가 +- 어떤 대안을 검토했는가 +- 어떤 영향 범위를 가지는가 +- 어떤 리스크가 존재하는가 + +불필요한 리팩토링이나 구조 변경은 제안하지 않습니다. diff --git a/next-sitemap.config.js b/next-sitemap.config.js index 9657d56..95c22d8 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -1,6 +1,6 @@ /** @type {import('next-sitemap').IConfig} */ module.exports = { - siteUrl: process.env.SITE_URL || 'https://nbread-nbread.vercel.app', + siteUrl: 'https://www.nbread.co.kr', generateRobotsTxt: true, changefreq: 'weekly', priority: 0.7, @@ -9,8 +9,11 @@ module.exports = { '/auth/*', '/calendar', '/friendList', + '/home', '/icon.ico', - '/inviteAccept', + '/invite/*', + '/invites', + '/login', '/manifest.json', '/mypage', '/mypage/*', @@ -30,7 +33,8 @@ module.exports = { '/auth/', '/calendar', '/friendList', - '/inviteAccept', + '/home', + '/invites', '/mypage', '/nbread/', '/notification', diff --git a/package-lock.json b/package-lock.json index 40d5e02..a38a982 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "andbread", - "version": "0.1.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "andbread", - "version": "0.1.0", + "version": "2.1.0", "dependencies": { "@microsoft/clarity": "^1.0.2", "@sentry/nextjs": "^10.53.1", diff --git a/public/assets/logo/nbread-logo.png b/public/assets/logo/nbread-logo.png new file mode 100644 index 0000000..e6dd982 Binary files /dev/null and b/public/assets/logo/nbread-logo.png differ diff --git a/public/image/landing/home.png b/public/image/landing/home.png new file mode 100644 index 0000000..90d15ac Binary files /dev/null and b/public/image/landing/home.png differ diff --git a/public/robots.txt b/public/robots.txt index 223dccc..799d608 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -7,14 +7,15 @@ User-agent: * Disallow: /auth/ Disallow: /calendar Disallow: /friendList -Disallow: /inviteAccept +Disallow: /home +Disallow: /invites Disallow: /mypage Disallow: /nbread/ Disallow: /notification Disallow: /terms-agreement # Host -Host: https://nbread-nbread.vercel.app +Host: https://www.nbread.co.kr # Sitemaps -Sitemap: https://nbread-nbread.vercel.app/sitemap.xml +Sitemap: https://www.nbread.co.kr/sitemap.xml diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index 1ada202..5c01073 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,8 +1,7 @@ -https://nbread-nbread.vercel.app/ios-guide2026-06-04T11:49:35.254Zweekly0.7 -https://nbread-nbread.vercel.app/terms-of-service2026-06-04T11:49:35.257Zweekly0.7 -https://nbread-nbread.vercel.app2026-06-04T11:49:35.257Zweekly0.7 -https://nbread-nbread.vercel.app/privacy-policy2026-06-04T11:49:35.257Zweekly0.7 -https://nbread-nbread.vercel.app/login2026-06-04T11:49:35.257Zweekly0.7 +https://www.nbread.co.kr/ios-guide2026-07-10T09:10:58.779Zweekly0.7 +https://www.nbread.co.kr2026-07-10T09:10:58.780Zweekly0.7 +https://www.nbread.co.kr/privacy-policy2026-07-10T09:10:58.780Zweekly0.7 +https://www.nbread.co.kr/terms-of-service2026-07-10T09:10:58.780Zweekly0.7 \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml index 0cff401..2d62498 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,4 +1,4 @@ -https://nbread-nbread.vercel.app/sitemap-0.xml +https://www.nbread.co.kr/sitemap-0.xml \ No newline at end of file diff --git a/src/app/home/page.tsx b/src/app/home/page.tsx new file mode 100644 index 0000000..5da6e5b --- /dev/null +++ b/src/app/home/page.tsx @@ -0,0 +1,113 @@ +'use client' +import { useEffect, useState } from 'react' +import { getUserNbreads } from '@/lib/nbread' +import Header from '@/components/home/Header' +import MonthlyNbread from '@/components/home/MonthlyNbread' +import MyNbread from '@/components/home/MyNbread' +import { Nbread } from '@/types/nbread' +import useUserStore from '@/stores/useAuthStore' +import { getParticipants } from '@/lib/participant' +import Spinner from '@/components/common/spinner/Spinner' +import NotificationPermissionModal from '@/components/common/modal/NotificationPermissionModal' +import NotificationDeniedModal from '@/components/common/modal/NotificationDeniedModal' +import ReceivedInviteBanner from '@/components/home/ReceivedInviteBanner' +import { getPendingInvites } from '@/lib/invite/getPendingInvites' +import type { PendingInvite } from '@/lib/invite/getPendingInvites' + +// [ ] OS 알림 허용 후 모달 닫힘 +// [ ] OS 알림 허용 상태 확인해서 모달 열기 + +const HomePage = () => { + const user = useUserStore((state) => state.user) + const [monthlyNbreadList, setMonthlyNbreadList] = useState([]) + const [myNbreadList, setMyNbreadList] = useState([]) + const [pendingInvites, setPendingInvites] = useState([]) + const [totalAmount, setTotalAmount] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [isNotificationDeniedModalOpen, setIsNotificationDeniedModalOpen] = + useState(false) + const currentMonth = new Date().getMonth() + 1 + + // Nbread 및 Participant 정보를 DB로부터 fetch + const fetchNbreads = async (userId: string) => { + try { + const [{ monthlyNbreads, myNbreads }, invites] = await Promise.all([ + getUserNbreads(userId), + // 초대 조회 실패가 기존 홈 엔빵 조회까지 막지 않도록 빈 목록으로 처리한다. + getPendingInvites(userId).catch(() => []), + ]) + + const myNbreadsWithParticipants = await Promise.all( + myNbreads.map(async (nbread) => { + const participants = await getParticipants(nbread.id) + return { ...nbread, participants } + }), + ) + const nbreadsById = new Map( + myNbreadsWithParticipants.map((nbread) => [nbread.id, nbread]), + ) + const monthlyNbreadsWithParticipants = monthlyNbreads.map( + (nbread) => nbreadsById.get(nbread.id) ?? nbread, + ) + + setMonthlyNbreadList(monthlyNbreadsWithParticipants) + setMyNbreadList(myNbreadsWithParticipants) + setPendingInvites(invites) + } catch { + setMonthlyNbreadList([]) + setMyNbreadList([]) + setPendingInvites([]) + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + if (!user) return + fetchNbreads(user.id) + // testSendPaymentNotification() + // test() + }, [user]) + + // NbreadList가 업데이트된 후 totalAmount 계산 + useEffect(() => { + const total = monthlyNbreadList.reduce( + (sum: number, nbread: Nbread) => + sum + Math.floor(nbread.amount / Math.max(nbread.participantCount, 1)), + 0, + ) + setTotalAmount(total) + }, [monthlyNbreadList]) + + return ( +
+
+
+ {isLoading ? ( + + ) : ( + <> + + + + + )} +
+ {/* iOS 알림 권한 요청 모달 */} + setIsNotificationDeniedModalOpen(true)} + /> + setIsNotificationDeniedModalOpen(false)} + /> +
+ ) +} + +export default HomePage diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx index 1db604a..7555fa6 100644 --- a/src/app/invite/[token]/page.tsx +++ b/src/app/invite/[token]/page.tsx @@ -1,4 +1,7 @@ import InvitePageClient from '@/components/invite/InvitePageClient' +import { NO_INDEX_METADATA } from '@/lib/seo' + +export const metadata = NO_INDEX_METADATA interface InvitePageProps { params: Promise<{ token: string }> diff --git a/src/app/ios-guide/page.tsx b/src/app/ios-guide/page.tsx index e482dda..f1e1ee3 100644 --- a/src/app/ios-guide/page.tsx +++ b/src/app/ios-guide/page.tsx @@ -1,7 +1,13 @@ -'use client' - import DetailHeader from '@/components/common/header/DetailHeader' import Image from 'next/image' +import { createPageMetadata } from '@/lib/seo' + +export const metadata = createPageMetadata({ + title: 'iOS 홈 화면 추가 가이드', + description: + 'iPhone Safari에서 엔빵을 홈 화면에 추가하고 앱처럼 실행하는 방법을 단계별로 안내합니다.', + path: '/ios-guide', +}) const addToHomeSteps = [ { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 60171a5..a0b7a06 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,9 +6,10 @@ import GoogleAnalytics from '@/lib/analytics/GoogleAnalytics' import PageViewTracker from '@/lib/analytics/PageViewTracker' import ClarityProvider from '@/lib/analytics/ClarityProvider' import Footer from '@/components/common/Footer' +import { SITE_URL } from '@/lib/seo' export const metadata: Metadata = { - metadataBase: new URL('https://nbread-nbread.vercel.app'), + metadataBase: new URL(SITE_URL), title: { default: '엔빵', template: '%s | 엔빵', @@ -26,9 +27,6 @@ export const metadata: Metadata = { ], applicationName: '엔빵', manifest: '/manifest.json', - alternates: { - canonical: '/', - }, robots: { index: true, follow: true, diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 245b49c..bcf018b 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,7 +1,11 @@ import NbreadsImage from '@/components/common/nbreadImage/NbreadsImage' import LoginButton from '@/components/user/LoginButton' +import LoginRedirectGuard from '@/components/auth/LoginRedirectGuard' import NbreadText from '@/assets/logo/nbread-text.svg' import { getSafeRedirectPath } from '@/lib/authRedirect' +import { NO_INDEX_METADATA } from '@/lib/seo' + +export const metadata = NO_INDEX_METADATA interface LoginPageProps { searchParams: Promise<{ redirect?: string | string[] }> @@ -14,17 +18,21 @@ const LoginPage = async ({ searchParams }: LoginPageProps) => { ) return ( -
-
- - -

구독 공유 관리 서비스

-
-
- - + +
+
+ + +

구독 공유 관리 서비스

+
+
+ + +
-
+ ) } export default LoginPage diff --git a/src/app/nbread/preview/page.tsx b/src/app/nbread/preview/page.tsx index 25b6828..1661f8b 100644 --- a/src/app/nbread/preview/page.tsx +++ b/src/app/nbread/preview/page.tsx @@ -55,7 +55,7 @@ const Page = () => { clearNbread() setPreviewNbreadData(null) useToast.success('엔빵 만들기에 성공했어요.') - router.push('/') + router.push('/home') } catch (error) { useToast.error('엔빵 만들기에 실패했어요. 다시 시도해주세요.') console.error(error) diff --git a/src/app/page.tsx b/src/app/page.tsx index 5da6e5b..cea5da3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,113 +1,376 @@ -'use client' -import { useEffect, useState } from 'react' -import { getUserNbreads } from '@/lib/nbread' -import Header from '@/components/home/Header' -import MonthlyNbread from '@/components/home/MonthlyNbread' -import MyNbread from '@/components/home/MyNbread' -import { Nbread } from '@/types/nbread' -import useUserStore from '@/stores/useAuthStore' -import { getParticipants } from '@/lib/participant' -import Spinner from '@/components/common/spinner/Spinner' -import NotificationPermissionModal from '@/components/common/modal/NotificationPermissionModal' -import NotificationDeniedModal from '@/components/common/modal/NotificationDeniedModal' -import ReceivedInviteBanner from '@/components/home/ReceivedInviteBanner' -import { getPendingInvites } from '@/lib/invite/getPendingInvites' -import type { PendingInvite } from '@/lib/invite/getPendingInvites' - -// [ ] OS 알림 허용 후 모달 닫힘 -// [ ] OS 알림 허용 상태 확인해서 모달 열기 - -const HomePage = () => { - const user = useUserStore((state) => state.user) - const [monthlyNbreadList, setMonthlyNbreadList] = useState([]) - const [myNbreadList, setMyNbreadList] = useState([]) - const [pendingInvites, setPendingInvites] = useState([]) - const [totalAmount, setTotalAmount] = useState(0) - const [isLoading, setIsLoading] = useState(true) - const [isNotificationDeniedModalOpen, setIsNotificationDeniedModalOpen] = - useState(false) - const currentMonth = new Date().getMonth() + 1 - - // Nbread 및 Participant 정보를 DB로부터 fetch - const fetchNbreads = async (userId: string) => { - try { - const [{ monthlyNbreads, myNbreads }, invites] = await Promise.all([ - getUserNbreads(userId), - // 초대 조회 실패가 기존 홈 엔빵 조회까지 막지 않도록 빈 목록으로 처리한다. - getPendingInvites(userId).catch(() => []), - ]) - - const myNbreadsWithParticipants = await Promise.all( - myNbreads.map(async (nbread) => { - const participants = await getParticipants(nbread.id) - return { ...nbread, participants } - }), - ) - const nbreadsById = new Map( - myNbreadsWithParticipants.map((nbread) => [nbread.id, nbread]), - ) - const monthlyNbreadsWithParticipants = monthlyNbreads.map( - (nbread) => nbreadsById.get(nbread.id) ?? nbread, - ) - - setMonthlyNbreadList(monthlyNbreadsWithParticipants) - setMyNbreadList(myNbreadsWithParticipants) - setPendingInvites(invites) - } catch { - setMonthlyNbreadList([]) - setMyNbreadList([]) - setPendingInvites([]) - } finally { - setIsLoading(false) - } - } - - useEffect(() => { - if (!user) return - fetchNbreads(user.id) - // testSendPaymentNotification() - // test() - }, [user]) - - // NbreadList가 업데이트된 후 totalAmount 계산 - useEffect(() => { - const total = monthlyNbreadList.reduce( - (sum: number, nbread: Nbread) => - sum + Math.floor(nbread.amount / Math.max(nbread.participantCount, 1)), - 0, - ) - setTotalAmount(total) - }, [monthlyNbreadList]) +import Link from 'next/link' +import Image from 'next/image' +import type { ReactNode } from 'react' +import Icon, { type IconType } from '@/components/common/icon/Icon' +import NbreadsImage from '@/components/common/nbreadImage/NbreadsImage' +import RevealOnScroll from '@/components/landing/RevealOnScroll' +import JsonLdScript from '@/components/seo/JsonLdScript' +import NbreadLogo from '@/assets/logo/nbread-logo-text.svg' +import { landingPageJsonLd } from '@/lib/jsonLd' +import { createPageMetadata } from '@/lib/seo' +export const metadata = createPageMetadata({ + title: '엔빵', + description: + '친구와 가족과 나누는 구독 서비스의 결제일과 정산 현황을 한 곳에서 관리하는 구독 공유 관리 서비스', + path: '/', + absoluteTitle: true, +}) + +const painCards: { + icon: IconType + title: string + description: string +}[] = [ + { + icon: 'warning', + title: '어디서 얼마 내는지 기억이 안 나요', + description: + '넷플릭스, 유튜브, 왓챠, 라프텔... 공유 중인 구독이 많아질수록 어디서 얼마를 내는지 헷갈리기 시작해요.', + }, + { + icon: 'calendar', + title: '결제일을 깜빡해서 구독 취소된 적 있어요', + description: + '매번 결제일을 따로 확인해야 해서 놓치기 쉬워요. 자동으로 정리된 결제 흐름이 필요해요.', + }, + { + icon: 'search', + title: '누가 얼마 보냈는지 일일이 확인해야 해요', + description: + '카카오페이 내역, 계좌 이체 내역, 메시지 내용을 뒤져야 하는 정산 확인. 그 번거로움이 싫어서 그냥 포기하게 돼요.', + }, +] + +const solutionPoints = [ + { + title: '그룹 단위 관리', + description: + '구독 서비스별로 그룹을 만들고, 결제 정보를 체계적으로 정리할 수 있어요.', + }, + { + title: '1/N 정산 계산기', + description: '인원 수에 맞게 자동으로 나눠진 금액을 바로 확인할 수 있어요.', + }, + { + title: '정산 현황 추적', + description: + '누가 정산을 완료했는지, 완료하지 않았는지 실시간으로 한 눈에 확인할 수 있어요.', + }, +] + +const featureCards: { + icon: string + title: string + description: string + wide?: boolean +}[] = [ + { + icon: '👥', + title: '그룹 관리', + description: '구독 서비스별로\n그룹을 만들고\n멤버와 월 요금을\n한 눈에', + }, + { + icon: '🔗', + title: '간편 초대', + description: `초대 링크만\n카톡으로 보내면\n누구나 쉽게\n초대 가능`, + }, + { + icon: '✅', + title: '정산 추적', + description: '참여 중인 사람들이\n모두 냈는지\n정산 여부\n바로 확인 가능', + }, + { + icon: '📢', + title: '채팅 및 공지', + description: '계정 정보나\n중요한 공지를\n채팅과 게시글로\n간편하게 공유', + }, + { + icon: '🔔', + title: '주요 알림', + description: + '결제일 알림, 결제 완료 알림, 채팅 알림 등 정산에 관한 중요 정보 푸시 알림', + wide: true, + }, +] + +const steps = [ + { + label: 'STEP 1', + title: '그룹 만들기', + description: + '구독 서비스 이름, 월/연 요금, 결제일만 입력하면 1분만에 그룹을 만들 수 있어요.', + }, + { + label: 'STEP 2', + title: '멤버 초대하기', + description: + '초대 링크를 카톡으로 공유해 멤버를 초대해보세요. 자동으로 1/N 금액이 계산돼요.', + }, + { + label: 'STEP 3', + title: '정산 현황 확인', + description: + '정산을 완료한 멤버는 정산 완료를 직접 체크해요. 그룹장이 직접 체크할 수도 있어요.', + }, +] + +const SectionHeading = ({ + eyebrow, + title, +}: { + eyebrow: string + title: ReactNode +}) => ( +
+

{eyebrow}

+

+ {title} +

+
+) + +export default function LandingPage() { return ( -
-
-
- {isLoading ? ( - - ) : ( - <> - - + +
+
+
+ + + 시작하기 + +
+ + +

+ 구독 나눔, 이제 쉽게 +

+

+ 구독 공유, +
+ 헷갈리게 쓰지 말고 +
+ 엔빵으로 딱 정리 +

+

+ 넷플릭스, 유튜브 프리미엄, 왓챠 등 친구, 가족과 나누는 구독 + 서비스의 결제일과 정산 현황을 한 곳에서 관리할 수 있어요. +

+
+ + + + 지금 시작하기 + + + + + 엔빵 홈 화면 미리보기 + +
+ +
+ +
+

+ 이런 경험 있으신가요? +

+
+

+ 매달 반복되는 +
+ 정산하기 +

+

+ 구독 공유는 편리하지만, +
+ 매달 정산이 번거롭고 헷갈려요. +

+
+ +
+ {painCards.map((card, index) => ( + + +

+ {card.title} +

+

+ {card.description} +

+
+ ))} +
+
+ +
+ + + 구독 공유의 모든 것을 +
한 곳에서 + + } + /> +

+ 그룹 생성, 친구 초대, 이달의 구독료 정산까지 엔빵에서 모두 끝낼 수 + 있어요. +

+
+ + + {solutionPoints.map((point, index) => ( +
+
+ +

+ {point.title} +

+
+

+ {point.description} +

+
+ ))} +
+
+ +
+ + + 불필요한 건 빼고 +
+ 필요한 것만 넣었어요 + + } /> - - - )} +

+ 구독 공유에 필요한 기능만 모았어요. +

+
+ +
+ {featureCards.map((feature, index) => ( + +

+ + {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
+ +
+ + + 3단계로 +
+ 바로 시작하기 + + } + /> +

+ 복잡한 설정 필요 없이 +
+ 3단계면 충분해요. +

+
+ + + {steps.map((step, index) => ( +
+

+ {step.label} +

+

+ {step.title} +

+

+ {step.description} +

+
+ ))} +
+
+ +
+ +
+ +
+

+ 지금 바로 시작하고 +
+ 정산 스트레스에서 벗어나세요 +

+ + 지금 시작하기 + +
+
- {/* iOS 알림 권한 요청 모달 */} - setIsNotificationDeniedModalOpen(true)} - /> - setIsNotificationDeniedModalOpen(false)} - /> -
+ ) } - -export default HomePage diff --git a/src/app/privacy-policy/page.tsx b/src/app/privacy-policy/page.tsx index 2e873dd..b557c4f 100644 --- a/src/app/privacy-policy/page.tsx +++ b/src/app/privacy-policy/page.tsx @@ -1,9 +1,12 @@ import DetailHeader from '@/components/common/header/DetailHeader' -import type { Metadata } from 'next' +import { createPageMetadata } from '@/lib/seo' -export const metadata: Metadata = { +export const metadata = createPageMetadata({ title: '개인정보처리방침', -} + description: + '엔빵의 개인정보 수집 항목, 이용 목적, 보유 기간, 처리 위탁, 이용자 권리와 보호 조치를 안내합니다.', + path: '/privacy-policy', +}) const sectionTitleClass = 'text-heading04 text-gray-800 mt-16' const bodyClass = 'mt-8 whitespace-pre-line text-paragraph text-gray-700' @@ -115,7 +118,7 @@ export default function PrivacyPolicyPage() {

10. 개인정보 보호책임자 및 문의

서비스명: 엔빵(N빵) - {'\n'}서비스 URL: https://nbread-nbread.vercel.app + {'\n'}서비스 URL: https://www.nbread.co.kr {'\n'}팀명: 엔빵(andbread) {'\n'}문의: GitHub Issues (https://github.com/andbread/Andbread_Frontend/issues) diff --git a/src/app/protectRoute.tsx b/src/app/protectRoute.tsx index 6c66166..d621b0f 100644 --- a/src/app/protectRoute.tsx +++ b/src/app/protectRoute.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import { usePathname, useRouter } from 'next/navigation' -import LoginConfirmModal from '@/components/common/modal/LoginConfirmModal' import { setSentryUser } from '@/lib/sentry/sentry' import useUserStore from '@/stores/useAuthStore' import { @@ -11,7 +10,15 @@ import { } from '@/lib/termsAgreement' import { hasPersistedUser } from '@/lib/authStorage' -const publicRoutes = ['/login', '/auth/callback', '/terms-agreement'] +const publicRoutes = [ + '/', + '/login', + '/auth/callback', + '/terms-agreement', + '/terms-of-service', + '/privacy-policy', + '/ios-guide', +] const termsAgreementExemptRoutes = [ ...publicRoutes, '/terms-of-service', @@ -26,7 +33,8 @@ export default function ProtectRoute({ const router = useRouter() const pathname = usePathname() const user = useUserStore((state) => state.user) - const [isProtectedRoute, setIsProtectedRoute] = useState(false) + const [isRedirectingToLanding, setIsRedirectingToLanding] = + useState(false) useEffect(() => { setSentryUser(user ? { id: user.id } : null) @@ -41,9 +49,13 @@ export default function ProtectRoute({ publicRoutes.includes(pathname) || pathname.startsWith('/invite/') if (!hasStoredUser && !isPublicRoute && !isNotFoundPage) { - setIsProtectedRoute(true) + setIsRedirectingToLanding(true) + router.replace('/') + return } - }, [pathname]) + + setIsRedirectingToLanding(false) + }, [pathname, router]) useEffect(() => { if (!user?.id || termsAgreementExemptRoutes.includes(pathname)) return @@ -65,15 +77,7 @@ export default function ProtectRoute({ return (

- {children} - setIsProtectedRoute(false)} - onSubmit={() => { - router.replace('/login') - setIsProtectedRoute(false) - }} - /> + {isRedirectingToLanding ? null : children}
) } diff --git a/src/app/terms-agreement/page.tsx b/src/app/terms-agreement/page.tsx index fdf8f67..3f0ac0e 100644 --- a/src/app/terms-agreement/page.tsx +++ b/src/app/terms-agreement/page.tsx @@ -1,5 +1,8 @@ import TermsAgreementPageClient from '@/components/auth/TermsAgreementPageClient' import { getSafeRedirectPath } from '@/lib/authRedirect' +import { NO_INDEX_METADATA } from '@/lib/seo' + +export const metadata = NO_INDEX_METADATA interface TermsAgreementPageProps { searchParams: Promise<{ next?: string | string[] }> diff --git a/src/app/terms-of-service/page.tsx b/src/app/terms-of-service/page.tsx index 9c02f6f..6bfc416 100644 --- a/src/app/terms-of-service/page.tsx +++ b/src/app/terms-of-service/page.tsx @@ -1,9 +1,12 @@ import DetailHeader from '@/components/common/header/DetailHeader' -import type { Metadata } from 'next' - -export const metadata: Metadata = { - title: '서비스이용약관', -} +import { createPageMetadata } from '@/lib/seo' + +export const metadata = createPageMetadata({ + title: '서비스 이용약관', + description: + '엔빵 구독 공유 관리 서비스의 이용 조건, 이용자 의무, 서비스 제공 범위와 책임 기준을 안내합니다.', + path: '/terms-of-service', +}) const sectionTitleClass = 'text-heading04 text-gray-800 mt-16' const bodyClass = 'mt-8 whitespace-pre-line text-paragraph text-gray-700' diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg index 1b196c0..d55ed12 100644 --- a/src/assets/icons/search.svg +++ b/src/assets/icons/search.svg @@ -1,3 +1,10 @@ - + + + + + + + + diff --git a/src/components/auth/LoginRedirectGuard.tsx b/src/components/auth/LoginRedirectGuard.tsx new file mode 100644 index 0000000..bfe6409 --- /dev/null +++ b/src/components/auth/LoginRedirectGuard.tsx @@ -0,0 +1,31 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { hasPersistedUser } from '@/lib/authStorage' + +interface LoginRedirectGuardProps { + children: React.ReactNode +} + +const LoginRedirectGuard = ({ children }: LoginRedirectGuardProps) => { + const router = useRouter() + const [shouldRenderLogin, setShouldRenderLogin] = useState(false) + + useEffect(() => { + if (hasPersistedUser()) { + router.replace('/home') + return + } + + setShouldRenderLogin(true) + }, [router]) + + if (!shouldRenderLogin) { + return null + } + + return children +} + +export default LoginRedirectGuard diff --git a/src/components/common/icon/Icon.tsx b/src/components/common/icon/Icon.tsx index 9739dbd..a045f37 100644 --- a/src/components/common/icon/Icon.tsx +++ b/src/components/common/icon/Icon.tsx @@ -42,10 +42,20 @@ interface IconProps { width: number | string height: number | string fill?: string + ariaHidden?: boolean + ariaLabel?: string onClick?: () => void } -const Icon = ({ type, width, height, fill = '', onClick }: IconProps) => { +const Icon = ({ + type, + width, + height, + fill = '', + ariaHidden, + ariaLabel, + onClick, +}: IconProps) => { const SelectedIcon = iconMap[type] return ( @@ -53,7 +63,8 @@ const Icon = ({ type, width, height, fill = '', onClick }: IconProps) => { width={width} height={height} className={`fill-current ${fill}`} - aria-label={type} + aria-hidden={ariaHidden} + aria-label={ariaHidden ? undefined : ariaLabel || type} viewBox="0 0 24 24" onClick={onClick} /> diff --git a/src/components/home/Header.tsx b/src/components/home/Header.tsx index bd6bb9c..e2643d3 100644 --- a/src/components/home/Header.tsx +++ b/src/components/home/Header.tsx @@ -34,7 +34,7 @@ const Header = () => { return (
-
router.replace('/')}> +
router.replace('/home')}>
diff --git a/src/components/landing/RevealOnScroll.tsx b/src/components/landing/RevealOnScroll.tsx new file mode 100644 index 0000000..a99815b --- /dev/null +++ b/src/components/landing/RevealOnScroll.tsx @@ -0,0 +1,75 @@ +'use client' + +import { + type CSSProperties, + type ElementType, + type ReactNode, + useEffect, + useRef, + useState, +} from 'react' + +type RevealOnScrollProps = { + as?: ElementType + children: ReactNode + className?: string + delay?: number + threshold?: number +} + +const RevealOnScroll = ({ + as: Component = 'div', + children, + className = '', + delay = 0, + threshold = 0.18, +}: RevealOnScrollProps) => { + const [isVisible, setIsVisible] = useState(false) + const ref = useRef(null) + + useEffect(() => { + const element = ref.current + + if (!element) return + + if (!('IntersectionObserver' in window)) { + setIsVisible(true) + return + } + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return + + setIsVisible(true) + observer.unobserve(entry.target) + }, + { + rootMargin: '0px 0px -8% 0px', + threshold, + }, + ) + + observer.observe(element) + + return () => { + observer.disconnect() + } + }, [threshold]) + + const transitionClass = isVisible + ? 'translate-y-0 opacity-100' + : 'translate-y-18 opacity-0' + + return ( + + {children} + + ) +} + +export default RevealOnScroll diff --git a/src/components/nbread/NbreadDetail.tsx b/src/components/nbread/NbreadDetail.tsx index 1d77981..4b46693 100644 --- a/src/components/nbread/NbreadDetail.tsx +++ b/src/components/nbread/NbreadDetail.tsx @@ -89,7 +89,7 @@ const nbreadDetail = ({ nbreadData, setNbreadData }: nbreadDetailProps) => { await deleteNbread(nbreadId) setIsNbreadDeleteModalOpen(false) useToast.success('엔빵이 삭제되었어요.') - router.push('/') + router.push('/home') } catch (error) { console.error(error) useToast.error('엔빵 삭제에 실패했어요. 다시 시도해주세요.') @@ -117,7 +117,7 @@ const nbreadDetail = ({ nbreadData, setNbreadData }: nbreadDetailProps) => { await deleteParticipants(userData?.id!, nbreadData!.id) setIsQuitNbreadModalOpen(false) useToast.success('엔빵 나가기에 성공했어요.') - router.replace('/') + router.replace('/home') } catch (error) { useToast.success('엔빵 나가기에 실패했어요.') } diff --git a/src/components/seo/JsonLdScript.tsx b/src/components/seo/JsonLdScript.tsx new file mode 100644 index 0000000..f57e961 --- /dev/null +++ b/src/components/seo/JsonLdScript.tsx @@ -0,0 +1,18 @@ +import type { JsonLdObject } from '@/lib/jsonLd' + +interface JsonLdScriptProps { + data: JsonLdObject +} + +const JsonLdScript = ({ data }: JsonLdScriptProps) => { + const json = JSON.stringify(data).replace(/ + ) +} + +export default JsonLdScript diff --git a/src/lib/sentry/instrumentation-client.ts b/src/instrumentation-client.ts similarity index 100% rename from src/lib/sentry/instrumentation-client.ts rename to src/instrumentation-client.ts diff --git a/src/lib/sentry/instrumentation.ts b/src/instrumentation.ts similarity index 100% rename from src/lib/sentry/instrumentation.ts rename to src/instrumentation.ts diff --git a/src/lib/authRedirect.ts b/src/lib/authRedirect.ts index 5985440..e19032b 100644 --- a/src/lib/authRedirect.ts +++ b/src/lib/authRedirect.ts @@ -1,4 +1,4 @@ -const DEFAULT_REDIRECT_PATH = '/' +const DEFAULT_REDIRECT_PATH = '/home' export const getSafeRedirectPath = (next: string | null | undefined) => { if (!next) { @@ -12,8 +12,8 @@ export const getSafeRedirectPath = (next: string | null | undefined) => { return DEFAULT_REDIRECT_PATH } - // 현재 로그인 후 리다이렉트는 토큰 초대 페이지 경로만 허용한다. - const isAllowedPath = next.startsWith('/invite/') + // 현재 로그인 후 리다이렉트는 홈과 토큰 초대 페이지 경로만 허용한다. + const isAllowedPath = next === '/home' || next.startsWith('/invite/') if (!isAllowedPath) { return DEFAULT_REDIRECT_PATH diff --git a/src/lib/jsonLd.ts b/src/lib/jsonLd.ts new file mode 100644 index 0000000..1f2be84 --- /dev/null +++ b/src/lib/jsonLd.ts @@ -0,0 +1,61 @@ +import { SITE_URL } from '@/lib/seo' + +type JsonLdValue = + | string + | number + | boolean + | null + | JsonLdObject + | JsonLdValue[] + +export interface JsonLdObject { + [key: string]: JsonLdValue +} + +const SITE_NAME = '엔빵' +const SITE_DESCRIPTION = + '친구와 가족과 나누는 구독 서비스의 결제일과 정산 현황을 한 곳에서 관리하는 구독 공유 관리 서비스' + +const getAbsoluteUrl = (path: string) => new URL(path, SITE_URL).toString() + +export const landingPageJsonLd: JsonLdObject = { + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'Organization', + '@id': `${SITE_URL}/#organization`, + name: SITE_NAME, + url: SITE_URL, + logo: { + '@type': 'ImageObject', + url: getAbsoluteUrl('/assets/logo/nbread-logo.png'), + width: 512, + height: 512, + }, + }, + { + '@type': 'WebSite', + '@id': `${SITE_URL}/#website`, + name: SITE_NAME, + url: SITE_URL, + description: SITE_DESCRIPTION, + inLanguage: 'ko-KR', + publisher: { + '@id': `${SITE_URL}/#organization`, + }, + }, + { + '@type': 'WebApplication', + '@id': `${SITE_URL}/#web-application`, + name: SITE_NAME, + url: SITE_URL, + description: SITE_DESCRIPTION, + inLanguage: 'ko-KR', + applicationCategory: 'UtilitiesApplication', + operatingSystem: 'Web', + publisher: { + '@id': `${SITE_URL}/#organization`, + }, + }, + ], +} diff --git a/src/lib/sentry/sentry.ts b/src/lib/sentry/sentry.ts index 198d57c..d97c735 100644 --- a/src/lib/sentry/sentry.ts +++ b/src/lib/sentry/sentry.ts @@ -59,6 +59,53 @@ const sanitizeValue = ( ) } +const getErrorMessage = (error: unknown) => { + if (error instanceof Error) { + return error.message + } + + if ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' + ) { + return error.message + } + + if (typeof error === 'string') { + return error + } + + return 'Unknown application error' +} + +const toError = (error: unknown) => { + if (error instanceof Error) { + return error + } + + return new Error(getErrorMessage(error)) +} + +const getErrorField = (error: unknown, field: string) => { + if (typeof error !== 'object' || error === null || !(field in error)) { + return undefined + } + + const value = (error as Record)[field] + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + + return undefined +} + export const beforeSend = (event: ErrorEvent, _hint: EventHint) => { if (event.user) { event.user = { id: event.user.id } @@ -99,14 +146,23 @@ export const captureAppError = ( }, ) => { Sentry.withScope((scope) => { + const capturedError = toError(error) + scope.setTag('action', context.action) + const errorCode = getErrorField(error, 'code') + if (errorCode !== undefined) { + scope.setTag('error.code', String(errorCode)) + } + Object.entries(context.tags ?? {}).forEach(([key, value]) => { if (value !== undefined) { scope.setTag(key, String(value)) } }) + scope.setExtra('originalError', sanitizeValue(error)) + if (context.extra) { scope.setExtras(sanitizeValue(context.extra) as Record) } @@ -115,6 +171,6 @@ export const captureAppError = ( scope.setLevel(context.level) } - Sentry.captureException(error) + Sentry.captureException(capturedError) }) } diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..d5a71ea --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,61 @@ +import type { Metadata } from 'next' + +export const SITE_URL = 'https://www.nbread.co.kr' + +export const NO_INDEX_METADATA: Metadata = { + robots: { + index: false, + follow: false, + googleBot: { + index: false, + follow: false, + }, + }, +} + +const SITE_NAME = '엔빵' +const OG_IMAGE = { + url: '/assets/logo/open-graph-logo.png', + width: 1200, + height: 630, + alt: '엔빵 로고', +} + +interface PageMetadataParams { + title: string + description: string + path: string + absoluteTitle?: boolean +} + +export const createPageMetadata = ({ + title, + description, + path, + absoluteTitle = false, +}: PageMetadataParams): Metadata => { + const socialTitle = absoluteTitle ? title : `${title} | ${SITE_NAME}` + + return { + title: absoluteTitle ? { absolute: title } : title, + description, + alternates: { + canonical: path, + }, + openGraph: { + title: socialTitle, + description, + url: path, + siteName: SITE_NAME, + locale: 'ko_KR', + type: 'website', + images: [OG_IMAGE], + }, + twitter: { + card: 'summary_large_image', + title: socialTitle, + description, + images: [OG_IMAGE.url], + }, + } +} diff --git a/src/lib/sentry/sentry.edge.config.ts b/src/sentry.edge.config.ts similarity index 100% rename from src/lib/sentry/sentry.edge.config.ts rename to src/sentry.edge.config.ts diff --git a/src/lib/sentry/sentry.server.config.ts b/src/sentry.server.config.ts similarity index 100% rename from src/lib/sentry/sentry.server.config.ts rename to src/sentry.server.config.ts diff --git a/supabase/migrations/20260702083718_secure_invite_response_rpc.sql b/supabase/migrations/20260702083718_secure_invite_response_rpc.sql new file mode 100644 index 0000000..79b4730 --- /dev/null +++ b/supabase/migrations/20260702083718_secure_invite_response_rpc.sql @@ -0,0 +1,122 @@ +create or replace function public.respond_to_nbread_invite( + p_invite_token text, + p_response text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_invite public.nbread_invite%rowtype; + v_user_id uuid := auth.uid(); + v_participant_limit integer; + v_participant_count bigint; + v_participant_inserted boolean := false; + v_already_participant boolean := false; +begin + if p_response not in ('accepted', 'rejected') then + raise exception 'INVALID_INVITE_RESPONSE'; + end if; + + -- 동일 초대에 대한 동시 응답을 직렬화해 상태 변경을 한 번만 처리한다. + select * + into v_invite + from public.nbread_invite + where invite_token = p_invite_token + for update; + + if not found then + raise exception 'INVITE_NOT_FOUND'; + end if; + + if v_invite.status = 'accepted' then + raise exception 'INVITE_ALREADY_ACCEPTED'; + elsif v_invite.status = 'rejected' then + raise exception 'INVITE_ALREADY_REJECTED'; + elsif v_invite.status = 'expired' then + raise exception 'INVITE_EXPIRED'; + end if; + + if v_user_id is null then + raise exception 'LOGIN_REQUIRED'; + end if; + + if v_invite.target_user_id is not null + and v_user_id <> v_invite.target_user_id then + raise exception 'INVITE_TARGET_MISMATCH'; + end if; + + if p_response = 'accepted' then + if v_invite.target_user_id is null then + -- 링크 초대는 수락 시점의 로그인 사용자를 초대 대상자로 연결한다. + update public.nbread_invite + set target_user_id = v_user_id + where id = v_invite.id; + + v_invite.target_user_id := v_user_id; + end if; + + select exists ( + select 1 + from public.participant + where nbread_id = v_invite.nbread_id + and user_id = v_invite.target_user_id + ) + into v_already_participant; + + if not v_already_participant then + -- 서로 다른 초대를 동시에 수락해도 정원 확인과 참여자 생성을 직렬화한다. + select participant_count + into v_participant_limit + from public.nbread + where id = v_invite.nbread_id + for update; + + if not found then + raise exception 'NBREAD_NOT_FOUND'; + end if; + + select count(*) + into v_participant_count + from public.participant + where nbread_id = v_invite.nbread_id; + + if v_participant_count >= v_participant_limit then + raise exception 'INVITE_EXPIRED'; + end if; + + -- 함수 내부 검증을 통과한 초대 응답만 참여자로 등록한다. + insert into public.participant (nbread_id, user_id, is_leader) + values (v_invite.nbread_id, v_invite.target_user_id, false) + on conflict (nbread_id, user_id) do nothing; + + v_participant_inserted := found; + end if; + end if; + + update public.nbread_invite + set status = p_response + where id = v_invite.id; + + return jsonb_build_object( + 'invite_id', v_invite.id, + 'nbread_id', v_invite.nbread_id, + 'status', p_response, + 'outcome', + case + when p_response = 'rejected' then 'rejected' + when v_participant_inserted then 'joined' + else 'already_participant' + end + ); +end; +$$; + +comment on function public.respond_to_nbread_invite(text, text) is + '초대 상태와 대상을 검증하고 RLS로 보호된 참여자 생성을 제한적으로 처리'; + +revoke execute on function public.respond_to_nbread_invite(text, text) + from public, anon; +grant execute on function public.respond_to_nbread_invite(text, text) + to authenticated; diff --git a/tailwind.config.ts b/tailwind.config.ts index cfcda8f..237d5e3 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -67,8 +67,8 @@ export default { }, /* box shadow */ boxShadow: { - avatar: '0px 0px 1px rgba(0,0,0,0.12))', - card: '0px 0px 4px rgba(0,0,0,0.12))', + avatar: '0px 0px 1px rgba(0,0,0,0.12)', + card: '0px 0px 4px rgba(0,0,0,0.12)', modal: '0px 0px 8px rgba(0,0,0,0.12)', }, },