Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
125 commits
Select commit Hold shift + click to select a range
1fd665a
chore: 이슈 및 pr 템플릿 작성, 에이전트 문서 작성 (#4)
sjungwon03 Jul 6, 2026
fb6e1ea
chore: 이슈템플릿 오타 수정 및 패키지매니저 고정 (#6)
sjungwon03 Jul 6, 2026
d2e238a
refac: FSD 폴더 구조 적용 (#8)
imi21123 Jul 8, 2026
2b24055
chore: 코드래빗 설정 파일 추가 (#12)
sjungwon03 Jul 8, 2026
0590055
chore: 브랜드 로고 및 아이콘 이미지 교체 (#15)
sjungwon03 Jul 8, 2026
d2febb2
chore: Claude 에이전트 하네스 구성 및 Biome 도입 (#13)
sjungwon03 Jul 8, 2026
40e4044
chore: 프론트엔드 CI 파이프라인 추가 (#19)
sjungwon03 Jul 8, 2026
7f7329b
refac: UI 수정 및 디테일 작업 (#16) (#20)
imi21123 Jul 9, 2026
543bc5d
refac: 온보딩 프로필 입력 항목 재정리 (정책 매칭 조건 기준) (#23)
imi21123 Jul 9, 2026
a1ddf8e
feat: 커뮤니티 게시판 목록/상세, 댓글·대댓글 기능 추가 (#24)
imi21123 Jul 9, 2026
56fe5bd
refac: 정책 카드 관심 표시 및 신청 관리 UI 정리 (#26)
imi21123 Jul 9, 2026
98b6571
refac: 정책/커뮤니티 검색 로직 공통화 (#28)
imi21123 Jul 10, 2026
31c29dc
refac: 마이페이지 UI 정리 및 '가중치' 워딩 변경 (#30)
imi21123 Jul 10, 2026
085f6bb
docs: rules.md를 .claude/로 이동하고 CLAUDE.md에서 자동 로드하도록 정리 (#33)
sjungwon03 Jul 11, 2026
10a2470
feat: 정책질문·정책후기 게시글에 정책 첨부 기능 추가 (#34)
imi21123 Jul 13, 2026
f3c5833
fix: 커뮤니티 게시글 댓글 수를 실제 댓글 개수로 표시 (#36)
imi21123 Jul 13, 2026
88156ad
fix: 정책 비교 독 페이지 이동 시 재슬라이드 및 접힘 상태 추가 미반영 수정 (#38)
imi21123 Jul 13, 2026
30a551a
feat: 관리자 페이지 공통 인프라 구축 (테이블 라이브러리, 서브 라우팅, 로그 엔티티 골격) (#47)
sjungwon03 Jul 13, 2026
358bc6e
fix: 줄바꿈 정규화를 위한 .gitattributes 추가 (#53)
sjungwon03 Jul 13, 2026
f74ee9e
fix: 비로그인 상태에서 관심 버튼 클릭 시 로그인 페이지로 이동 (#51)
imi21123 Jul 13, 2026
f87dcd5
feat: 좋아요한 글·내가 작성한 글 전용 목록 페이지 추가 (#54)
imi21123 Jul 13, 2026
27653ca
feat: 관리자 로그인 로그 뷰어 페이지 (#55)
sjungwon03 Jul 14, 2026
be6a74b
feat: 관리자 애플리케이션 로그(log4j) 뷰어 페이지 (#56)
sjungwon03 Jul 14, 2026
31b8c93
feat: 관리자 사용자 관리 페이지 (Users/UserProfiles) (#57)
sjungwon03 Jul 14, 2026
79f3052
feat: 관리자 정책 관리 페이지 (Policies/Regions/PolicyRegions) (#58)
sjungwon03 Jul 14, 2026
8afddfa
feat: 관리자 정책 신청 관리 페이지 (PolicyApplications/ApplicationChecklists) (#59)
sjungwon03 Jul 14, 2026
457343d
feat: 관리자 커뮤니티 관리 페이지 (Posts/Comments/Attachments) (#60)
sjungwon03 Jul 14, 2026
f52afa5
feat: 관리자 검색 로그 / 배치 작업 로그 뷰어 (#61)
sjungwon03 Jul 14, 2026
5a558f4
feat: 공용 Pagination 컴포넌트 추가 및 주요 목록 화면 적용 (#63)
sjungwon03 Jul 14, 2026
762f358
feat: 정책 상세 모달에 필드별 상세 정보 섹션 추가 (#67)
imi21123 Jul 15, 2026
e6b46ad
fix: 소셜 로그인 버튼 공식 브랜드 가이드 준수 개선 (#68)
sjungwon03 Jul 15, 2026
8d18aac
feat: 로그인/인증 처리 및 관리자 기능 모킹 제거, 실제 API 연동 (#71)
sjungwon03 Jul 15, 2026
8434c9c
feat: 정책 비교 화면에 주관기관/신청자격/참여제한사항 비교 필드 추가 (#72)
imi21123 Jul 15, 2026
3966084
fix: 회원 탈퇴를 실제 API(DELETE /v1/users) 연동으로 교체 (#74)
sjungwon03 Jul 16, 2026
6fc741d
fix: 새로고침 시 세션 복원 refresh 요청이 StrictMode로 중복 발생해 401 (#76)
sjungwon03 Jul 16, 2026
731b703
fix: OAuth 콜백 처리 후 StrictMode 이중 실행으로 화면 전환 안 되는 문제 수정 (#81)
sjungwon03 Jul 16, 2026
ae723fc
fix: 프로필 온보딩 상태가 새로고침/재로그인 시 초기화되는 문제 수정 (#84)
sjungwon03 Jul 16, 2026
eeb9547
fix: 관리자 정책 지역 선택지를 실제 API로 연동 (#86)
sjungwon03 Jul 18, 2026
7d09a8d
feat: 신청관리 체크리스트 메모 수정 UX 개선 및 마감일 검증 연동 (#88)
canfactor Jul 20, 2026
1579698
feat: 정책 목데이터 제거 및 실제 API 연동 — 홈 목록·상세·카테고리 필터 (#82) (#91)
myh7754 Jul 20, 2026
f5e3524
feat: 정책 상세 정책별 STOMP 채팅 연동 (#95)
sjungwon03 Jul 20, 2026
6771c99
fix: 로컬 dev 서버 프록시에 ws:true 추가해 정책 채팅 재연결 반복 문제 해결 (#99)
sjungwon03 Jul 20, 2026
f579773
fix: 정책 상세 팝업 확대 및 정책 채팅을 옆 컬럼으로 재배치 (#97)
sjungwon03 Jul 20, 2026
4dbf566
feat: 게시글 작성 WYSIWYG 에디터 적용 및 이미지 업로드 연동 (#100) (#101)
sjungwon03 Jul 20, 2026
c9c50af
feat: 온보딩 프로필 제출을 실제 백엔드 API 연동으로 완성 (#104)
sjungwon03 Jul 20, 2026
a65e44b
refac: 정책 카드 지역 라벨 조립을 상세와 공통 로직으로 통일 (#103)
sjungwon03 Jul 20, 2026
70e5770
fix: 정책 목업 데이터 제거 (#108)
sjungwon03 Jul 20, 2026
2ed6b77
fix: 마이페이지 프로필 카드가 서버 데이터를 표시하도록 수정 (#109)
sjungwon03 Jul 20, 2026
ce3a74c
feat: 정책 신청관리 API 연동 (#111)
sjungwon03 Jul 20, 2026
f2eb120
fix: 관심 정책 중복 요청 방지 (#113)
sjungwon03 Jul 20, 2026
26ca124
fix: 커뮤니티 이미지 업로드 연동 (#115)
sjungwon03 Jul 20, 2026
a99e393
feat: 정책 비교 API(POST /api/v1/policy-comparisons) 프론트엔드 연동 (#90)
imi21123 Jul 21, 2026
0674cde
feat: 커뮤니티 게시글 목록 조회를 실제 API에 연결 (#118)
sjungwon03 Jul 21, 2026
659d89e
fix: 검색 연령 필터를 ageMin/ageMax로 변환해 서버 전송 (#93) — dev 재적용 (#119)
myh7754 Jul 21, 2026
732ca22
feat: 커뮤니티 검색·카테고리 필터·정렬 UI를 실제 API에 연결 (#124)
sjungwon03 Jul 21, 2026
eaef678
fix: 게시글 상세 첨부 정책 미표시 및 수정 화면 본문 미표시 수정 (#128)
sjungwon03 Jul 21, 2026
7ac27b2
refac: 신청관리(policy-tracker) 코드 가독성 리팩토링 (#134)
canfactor Jul 21, 2026
cb65f8e
fix: 정책 비교의 카테고리·지역 표시를 목록·상세와 동일한 규칙으로 통일 (#123)
imi21123 Jul 21, 2026
04776a2
fix: 카테고리 뱃지에서 참여·기반과 기타가 같은 색으로 보이던 문제 수정 (#125)
imi21123 Jul 21, 2026
71de642
fix: 프로필 수정 화면에 기존 데이터가 반영되지 않고 저장도 되지 않는 문제 수정 (#140)
sjungwon03 Jul 21, 2026
41f6360
fix: 온보딩 완료 후에도 온보딩 페이지로 잘못 리다이렉트되는 문제 수정 (#138)
sjungwon03 Jul 21, 2026
354fce2
fix: 게시글 수정 화면에서 내용이 있어도 플레이스홀더가 겹쳐 보이는 문제 수정 (#136)
sjungwon03 Jul 21, 2026
be463b4
fix: 정책 목록·검색 페이지네이션 page 이중 변환 제거 (#126) (#129)
myh7754 Jul 21, 2026
e45715d
refac: 검색 지역 필터에서 '전국' 옵션 제거 (#130) (#131)
myh7754 Jul 21, 2026
845e37a
feat: 온보딩 완료 상태에서 온보딩 페이지 직접 접근 시 홈으로 리다이렉트 (#142)
sjungwon03 Jul 21, 2026
d39fb53
fix: 신청관리 시작 시 화면표시용 마감일을 API에 그대로 보내던 문제 수정 (#144)
canfactor Jul 21, 2026
e93d9e2
fix: 정책 링크 referenceUrl2 폴백 누락 및 트래커 상세의 깨진 공고 링크 수정 (#146)
sjungwon03 Jul 22, 2026
7ff7eee
fix: 검색 지역 선택지를 DB 시드와 정합 (10 → 16개 시도) (#92) (#133)
myh7754 Jul 22, 2026
235676d
feat: 검색 생활·상태 필터를 온보딩 취업상태 어휘로 교체하고 서버 연동 (#155) (#156)
myh7754 Jul 22, 2026
9f2bec0
feat: 맞춤정책 추천을 실제 API로 연동 (#150) (#152)
imi21123 Jul 22, 2026
e5d3d90
fix: 맞춤 정책 페이지 프로필을 서버 API와 연동 (#154)
sjungwon03 Jul 22, 2026
8448054
fix: 비교함 정책이 최신 100건 join 대신 상세 조회로 표시되도록 수정 (#158)
imi21123 Jul 22, 2026
c95a8cf
docs: 리드미 새로 작성 (#160)
sjungwon03 Jul 22, 2026
6ff4516
fix: 정책 채팅 입력창 Enter 전송, Shift+Enter 줄바꿈 지원
sjungwon03 Jul 22, 2026
50ccbae
fix: 정책 채팅 입력창 Enter 전송, Shift+Enter 줄바꿈 지원 (#179)
sjungwon03 Jul 22, 2026
227b05d
fix: 홈 분야 바로가기 카드 색상·문구를 정책 카테고리와 일치시킴 (#181)
imi21123 Jul 22, 2026
bfff83a
fix: 라우터 최상위 errorElement 추가로 흰 화면 방지
sjungwon03 Jul 22, 2026
028a954
fix: 채팅 receipt 타임아웃 오탐 제거 (#185)
sjungwon03 Jul 22, 2026
5a3b16e
test: RouteErrorBoundary fallback UI·무한 새로고침 가드 테스트 추가
sjungwon03 Jul 22, 2026
26abdc3
fix: 로그아웃·세션 만료 시 TanStack Query 캐시 정리
sjungwon03 Jul 22, 2026
1aa39ba
fix: 삭제된/없는 게시글 상세 404 시 not-found 안내 분기 추가
sjungwon03 Jul 22, 2026
84517a3
fix: RichTextEditor 이미지 업로드 흐름 정리 (#194)
sjungwon03 Jul 22, 2026
26f3c05
fix: 커뮤니티 좋아요 mock 의존과 중복 요청 정리 (#189)
sjungwon03 Jul 22, 2026
5451b59
fix: 커뮤니티 글 수정 접근 상태를 보호 (#193)
sjungwon03 Jul 22, 2026
585cd42
fix: 정책 상세 모달의 가짜 추천 근거를 제거 (#199)
sjungwon03 Jul 22, 2026
9815568
fix: 4xx 쿼리 재시도를 방지 (#201)
sjungwon03 Jul 22, 2026
3b8b42e
fix: 로그아웃 시 사용자 쿼리 캐시를 초기화 (#203)
sjungwon03 Jul 22, 2026
e70fed3
fix: OAuth 콜백 오류를 안전하게 안내 (#205)
sjungwon03 Jul 22, 2026
8b36332
fix: 신청관리 화면에 실제 정책 데이터를 표시 (#207)
sjungwon03 Jul 22, 2026
2f20cc2
fix: 프로필 출생연도 기준을 현재 연도로 계산 (#209)
sjungwon03 Jul 22, 2026
86dbf68
Merge branch 'fix/182-global-error-boundary' into dev
sjungwon03 Jul 22, 2026
dc68096
Merge branch 'fix/187-logout-cache-clear' into dev
sjungwon03 Jul 22, 2026
123d5fb
Merge branch 'fix/188-post-not-found' into dev
sjungwon03 Jul 22, 2026
f71525f
Merge branch 'fix/189-community-like-guards' into dev
sjungwon03 Jul 22, 2026
650008e
Merge branch 'fix/193-edit-page-guard' into dev
sjungwon03 Jul 22, 2026
738075e
Merge branch 'fix/194-richtext-editor-cleanup' into dev
sjungwon03 Jul 22, 2026
3ec4c23
Merge branch 'fix/199-policy-detail-recommendation' into dev
sjungwon03 Jul 22, 2026
b5bf3ac
Merge branch 'fix/201-query-retry-policy' into dev
sjungwon03 Jul 22, 2026
777ad71
Merge branch 'fix/203-clear-auth-cache' into dev
sjungwon03 Jul 22, 2026
03c2322
Merge branch 'fix/205-oauth-callback-errors' into dev
sjungwon03 Jul 22, 2026
47e64b9
Merge branch 'fix/207-tracker-policy-data' into dev
sjungwon03 Jul 22, 2026
df52350
Merge branch 'fix/209-dynamic-birth-years' into dev
sjungwon03 Jul 22, 2026
c9327d7
fix: remove duplicate queryClient.clear in authStore
sjungwon03 Jul 22, 2026
333b6ab
chore: tsconfig strict 활성화 및 템플릿 잔재 정리 (#191)
sjungwon03 Jul 22, 2026
6e2d67b
fix: 인증 refresh 흐름 개선 및 세션 오류 대응 (#211) (#212)
sjungwon03 Jul 22, 2026
957f5c8
fix: 북마크/트래커 API N+1 및 동기화 최적화 (#213) (#214)
sjungwon03 Jul 22, 2026
78dbdd4
fix: 추천 API 장애 fallback 및 최근 본 정책 UI 연동 (#215) (#216)
sjungwon03 Jul 22, 2026
51c9370
fix: 프로필 설정 Zod 검증 도입 및 UI 버그 수정 (#217) (#218)
sjungwon03 Jul 22, 2026
e75a86d
fix: 커뮤니티 댓글 성능 최적화 및 글 이탈 방지 가드 (#219) (#220)
sjungwon03 Jul 22, 2026
a277f71
fix: 관리자 필터 공통화(useUrlFilters), stale 상태 표시, ConfirmDialog 접근성 개선 (#2…
sjungwon03 Jul 22, 2026
f7343c4
fix: 온보딩 미완료 사용자의 홈 맞춤 추천 스켈레톤 고정 해소 (#227)
imi21123 Jul 23, 2026
2bcd802
fix: 맞춤 추천 피드 전체 목록 페이지네이션 노출 (#229)
imi21123 Jul 23, 2026
e9cec4d
fix: 로그아웃 직후 세션 만료 토스트 중복 노출 수정 (#232)
sjungwon03 Jul 23, 2026
9f192be
feat: 게시글 상세 정책 위치 변경 및 목록 정책/요약 노출 (#233)
sjungwon03 Jul 23, 2026
815a60c
fix: 맞춤 정책 고지 문구 정정 및 메뉴 탭 명칭 변경 (#239)
imi21123 Jul 23, 2026
e8385ac
fix: 게스트의 정상 401을 세션 만료로 오인해 토스트 노출하는 문제 수정 (#237)
sjungwon03 Jul 23, 2026
32ddf47
feat: 댓글 UI를 실제 백엔드 API에 연동 (대댓글/수정 포함) (#243)
sjungwon03 Jul 24, 2026
1b8809c
chore: docker compose 전체 스택 배포용 nginx Dockerfile 및 API 리버스 프록시 추가 (#241)
sjungwon03 Jul 24, 2026
e7867e2
feat: 관리자 배치 수동 동기화 버튼을 실제 백엔드 엔드포인트에 연동 (#235)
myh7754 Jul 24, 2026
b88d759
chore: nginx에 Swagger UI/OpenAPI 문서 프록시 경로 추가 (#245)
sjungwon03 Jul 24, 2026
269e013
fix: swagger-config 프록시 누락으로 Swagger UI가 petstore로 폴백되는 문제 수정 (#247)
sjungwon03 Jul 24, 2026
d97aa7e
feat: 푸터(Footer) 추가 (#250)
sjungwon03 Jul 24, 2026
99d6199
merge: main을 dev에 병합하여 #251 PR 컨플릭트 해결
sjungwon03 Jul 24, 2026
5e3dde0
fix: Footer 코드리뷰 반영 (alt 텍스트, 임의 폰트 크기) (#253)
sjungwon03 Jul 24, 2026
2e502a0
feat: sitemap/robots.txt 및 페이지별 SEO 메타 태그 반영 (#255)
sjungwon03 Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
<!doctype html>
<html lang="en">
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>YouthPick</title>
<title>YouthPick | 나에게 맞는 청년정책 추천</title>
<meta
name="description"
content="나에게 맞는 청년정책을 쉽고 빠르게 찾아주는 청년정책 맞춤형 서비스. 정책 검색, 맞춤 추천, 신청 관리, 청년 커뮤니티를 한곳에서 만나보세요."
/>
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://youthpick.samchon.cloud/" />

<meta property="og:type" content="website" />
<meta property="og:site_name" content="YouthPick" />
<meta property="og:title" content="YouthPick | 나에게 맞는 청년정책 추천" />
<meta
property="og:description"
content="나에게 맞는 청년정책을 쉽고 빠르게 찾아주는 청년정책 맞춤형 서비스"
/>
<meta property="og:url" content="https://youthpick.samchon.cloud/" />
<meta property="og:image" content="https://youthpick.samchon.cloud/favicon.png" />
<meta property="og:locale" content="ko_KR" />

<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="YouthPick | 나에게 맞는 청년정책 추천" />
<meta
name="twitter:description"
content="나에게 맞는 청년정책을 쉽고 빠르게 찾아주는 청년정책 맞춤형 서비스"
/>
<meta name="twitter:image" content="https://youthpick.samchon.cloud/favicon.png" />
</head>
<body>
<div id="root"></div>
Expand Down
10 changes: 10 additions & 0 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,16 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}

# sitemap.xml은 게시글처럼 동적인 URL을 담아야 해서 정적 파일이 아니라 backend가 매 요청 생성한다.
# sitemap 프로토콜상 사이트 루트에서 서빙해야 하므로 /api 프리픽스 없이 그대로 넘긴다.
location = /sitemap.xml {
proxy_pass http://backend:8080/sitemap.xml;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

# 정확히 일치(=)로 매칭하면 swagger-ui가 실제 스펙 위치를 알아내려고 추가로 요청하는
# /v3/api-docs/swagger-config 가 이 location을 안 타고 SPA fallback으로 떨어져
# swagger-ui가 기본 데모 스펙(petstore)으로 폴백된다. prefix로 하위 경로까지 잡는다.
Expand Down
13 changes: 13 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /my
Disallow: /tracker
Disallow: /recommend
Disallow: /login
Disallow: /oauth
Disallow: /profile/setup
Disallow: /community/write
Disallow: /community/*/edit

Sitemap: https://youthpick.samchon.cloud/sitemap.xml
4 changes: 3 additions & 1 deletion src/app/layouts/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Outlet, ScrollRestoration } from 'react-router';

import { CompareDockContainer } from '@/features/policy-compare';
import { Skeleton, ToastContainer } from '@/shared/ui';
import { NoticeBanner } from '@/widgets/footer';
import { Footer, NoticeBanner } from '@/widgets/footer';
import { Header } from '@/widgets/header';
import { MobileNav } from '@/widgets/mobile-nav';
import { PolicyDetailModalContainer } from '@/widgets/policy-detail-modal';
Expand All @@ -30,6 +30,8 @@ export function RootLayout() {
<NoticeBanner />
</main>

<Footer />

<MobileNav />

<PolicyDetailModalContainer />
Expand Down
7 changes: 7 additions & 0 deletions src/pages/community-detail/CommunityDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { useAuthStore } from '@/entities/user';
import { CommentListContainer, useCommunityComments } from '@/features/community-comment';
import { useCommunityLike } from '@/features/community-like';
import { buildCommunityEditPath, ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';
import { ConfirmDialog, EmptyState, ErrorState, Skeleton, useToast } from '@/shared/ui';
import { stripHtml } from '@/shared/utils';

export function CommunityDetailPage() {
const { postId } = useParams<{ postId: string }>();
Expand Down Expand Up @@ -47,6 +49,11 @@ interface CommunityDetailPageContentProps {

function CommunityDetailPageContent({ postId }: CommunityDetailPageContentProps) {
const { data: post, isLoading, isError, isNotFound, refetch } = useCommunityPostQuery(postId);
useSeo({
title: post?.title ?? '게시글',
description: post ? stripHtml(post.content).slice(0, 150) : undefined,
noindex: isNotFound || (!isLoading && !post),
});
// PostDetailResponse에는 policyId/policyTitle만 있고 category/deadline이 없어
// 게시글 상세 응답만으로는 첨부 정책 카드를 완성할 수 없다 — 정책 상세를 따로 조회해 채운다.
const { data: attachedPolicy } = usePolicyDetailQuery(post?.policyId ?? null);
Expand Down
2 changes: 2 additions & 0 deletions src/pages/community-edit/CommunityEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { Navigate, useParams } from 'react-router';

import { CommunityPostWriteContainer } from '@/features/community-write';
import { ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';

export function CommunityEditPage() {
useSeo({ title: '게시글 수정', noindex: true });
const { postId } = useParams<{ postId: string }>();

if (!postId) {
Expand Down
3 changes: 3 additions & 0 deletions src/pages/community-write/CommunityWritePage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { CommunityPostWriteContainer } from '@/features/community-write';
import { useSeo } from '@/shared/hooks';

export function CommunityWritePage() {
useSeo({ title: '게시글 작성', noindex: true });

return (
<div className="max-w-2xl mx-auto space-y-4 animate-in fade-in duration-300">
<div className="text-left space-y-1">
Expand Down
5 changes: 5 additions & 0 deletions src/pages/community/CommunityPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useCommunityBoard,
} from '@/features/community-board';
import { ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';
import { ErrorState, Pagination, Skeleton } from '@/shared/ui';
import {
COMMUNITY_POST_GRID_CLASS,
Expand All @@ -16,6 +17,10 @@ import {
} from '@/widgets/community-post-grid';

export function CommunityPage() {
useSeo({
title: '커뮤니티',
description: '정책에 대한 질문과 후기, 소소한 이야기를 자유롭게 나누는 청년들의 이야기 공간.',
});
const {
query,
category,
Expand Down
6 changes: 6 additions & 0 deletions src/pages/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useAuthStore } from '@/entities/user';
import { useMyProfile } from '@/features/my-profile';
import { useRecommendations } from '@/features/policy-recommendation';
import { ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';
import { ErrorState, Pagination, Skeleton } from '@/shared/ui';
import { HeroBanner } from '@/widgets/hero-banner';
import {
Expand All @@ -25,6 +26,11 @@ import { HomeRecommendSection } from './components/HomeRecommendSection';
const HOME_POLICY_COUNT = POLICY_GRID_SKELETON_COUNT;

export function HomePage() {
useSeo({
title: '나에게 맞는 청년정책 추천',
description:
'나에게 맞는 청년정책을 쉽고 빠르게 찾아주는 청년정책 맞춤형 서비스. 정책 검색, 맞춤 추천, 신청 관리, 청년 커뮤니티를 한곳에서 만나보세요.',
});
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const user = useAuthStore((state) => state.user);
const {
Expand Down
2 changes: 2 additions & 0 deletions src/pages/login/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { useNavigate } from 'react-router';

import { SocialLoginPanel, useOAuthLogin } from '@/features/auth';
import { ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';

export function LoginPage() {
useSeo({ title: '로그인', noindex: true });
const { startOAuthLogin, isRedirecting } = useOAuthLogin();
const navigate = useNavigate();

Expand Down
2 changes: 2 additions & 0 deletions src/pages/my-liked-posts/MyLikedPostsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useLikedCommunityPosts } from '@/features/community-like';
import { useSeo } from '@/shared/hooks';
import { CommunityPostListPage } from '@/widgets/community-post-list-page';

export function MyLikedPostsPage() {
useSeo({ title: '좋아요한 글', noindex: true });
const { likedPosts, isLoading, isError, refetch } = useLikedCommunityPosts();

return (
Expand Down
2 changes: 2 additions & 0 deletions src/pages/my-posts/MyPostsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { useMyCommunityPosts } from '@/features/community-my-posts';
import { useSeo } from '@/shared/hooks';
import { CommunityPostListPage } from '@/widgets/community-post-list-page';

export function MyPostsPage() {
useSeo({ title: '내가 작성한 글', noindex: true });
const { posts, isLoading, isError, refetch } = useMyCommunityPosts();

return (
Expand Down
2 changes: 2 additions & 0 deletions src/pages/my/MyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { AccountDangerZone, useDeleteAccount, useLogout } from '@/features/auth'
import { useMyProfile } from '@/features/my-profile';
import { useTrackers } from '@/features/policy-tracker';
import { ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';
import { EmptyState, ErrorState, Skeleton } from '@/shared/ui';
import { LikedCommunityPosts } from '@/widgets/liked-community-posts';
import { MyCommunityPosts } from '@/widgets/my-community-posts';
import { RecentlyViewed } from '@/widgets/recently-viewed';

export function MyPage() {
useSeo({ title: '마이페이지', noindex: true });
const user = useAuthStore((state) => state.user);
const {
profile,
Expand Down
2 changes: 2 additions & 0 deletions src/pages/oauth-callback/OAuthCallbackPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { useNavigate } from 'react-router';

import { useOAuthCallback } from '@/features/auth';
import { ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';
import { ErrorState, Skeleton } from '@/shared/ui';

export function OAuthCallbackPage() {
useSeo({ title: '로그인 처리 중', noindex: true });
const { errorMessage } = useOAuthCallback();
const navigate = useNavigate();

Expand Down
3 changes: 3 additions & 0 deletions src/pages/profile-setup/ProfileSetupPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ProfileSetupContainer } from '@/features/profile-setup';
import { useSeo } from '@/shared/hooks';

export function ProfileSetupPage() {
useSeo({ title: '프로필 설정', noindex: true });

return (
<div className="animate-in fade-in duration-300 max-w-xl mx-auto py-6">
<ProfileSetupContainer />
Expand Down
2 changes: 2 additions & 0 deletions src/pages/recommend/RecommendPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
useRecommendations,
} from '@/features/policy-recommendation';
import { ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';
import { EmptyState, Skeleton } from '@/shared/ui';
import { RecommendationFeed } from '@/widgets/recommendation-feed';

export function RecommendPage() {
useSeo({ title: '맞춤 추천', noindex: true });
const { recommendations, isFallback, isLoading, isError } = useRecommendations();
// 프로필은 서버가 원본이다 — store 값은 새로고침 후 빈 값이라 여기서 쓰면 안 된다.
const { profile, isLoading: isProfileLoading } = useMyProfile();
Expand Down
5 changes: 5 additions & 0 deletions src/pages/search/SearchPageContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { usePolicySearch } from '@/features/policy-search';
import { useSeo } from '@/shared/hooks';
import { SearchPagePresenter } from './SearchPagePresenter';

export function SearchPageContainer() {
useSeo({
title: '정책 검색',
description: '카테고리, 지역, 마감일로 청년정책을 검색하고 나에게 맞는 정책을 찾아보세요.',
});
const searchProps = usePolicySearch();

return <SearchPagePresenter {...searchProps} />;
Expand Down
3 changes: 3 additions & 0 deletions src/pages/tracker/TrackerPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { TrackerContainer } from '@/features/policy-tracker';
import { useSeo } from '@/shared/hooks';

export function TrackerPage() {
useSeo({ title: '신청 관리', noindex: true });

return (
<div className="space-y-6 animate-in fade-in duration-300">
<TrackerContainer />
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { DEFAULT_ADMIN_PAGE_SIZE } from './pagination';
export { REGIONS, type RegionName } from './regions';
export type { RoutePath } from './routes';
export { buildCommunityDetailPath, buildCommunityEditPath, ROUTES } from './routes';
export { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL } from './seo';
3 changes: 3 additions & 0 deletions src/shared/constants/seo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const SITE_URL = 'https://youthpick.samchon.cloud';
export const SITE_NAME = 'YouthPick';
export const DEFAULT_OG_IMAGE = `${SITE_URL}/favicon.png`;
1 change: 1 addition & 0 deletions src/shared/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { useBodyScrollLock } from './useBodyScrollLock';
export { usePagination } from './usePagination';
export { useSeo } from './useSeo';
export { useSubmittableUrlQuery } from './useSubmittableUrlQuery';
export { ALL_FILTER_VALUE, useUrlFilters } from './useUrlFilters';
57 changes: 57 additions & 0 deletions src/shared/hooks/useSeo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router';

import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL } from '@/shared/constants';

interface UseSeoOptions {
/** 페이지 고유 제목. 최종 title은 `{title} | ${SITE_NAME}` 형태로 조합된다. */
title: string;
description?: string;
/** 검색엔진 색인에서 제외해야 하는 페이지(로그인 필요 화면, 로그인/콜백 등)면 true. */
noindex?: boolean;
image?: string;
}

function upsertMetaTag(attribute: 'name' | 'property', key: string, content: string): void {
let element = document.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`);
if (!element) {
element = document.createElement('meta');
element.setAttribute(attribute, key);
document.head.appendChild(element);
}
element.setAttribute('content', content);
}

function upsertCanonicalLink(href: string): void {
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!element) {
element = document.createElement('link');
element.setAttribute('rel', 'canonical');
document.head.appendChild(element);
}
element.setAttribute('href', href);
}

// 이 앱은 SSR 없이 CSR로만 동작하는 SPA라 route 전환 시 <head>를 이 hook이 직접 갱신한다.
// 검색엔진 노출이 필요 없는 화면(로그인 필요 페이지 등)은 noindex를 true로 넘긴다.
export function useSeo({ title, description, noindex = false, image }: UseSeoOptions): void {
const location = useLocation();

useEffect(() => {
const fullTitle = `${title} | ${SITE_NAME}`;
document.title = fullTitle;

upsertMetaTag('name', 'robots', noindex ? 'noindex, nofollow' : 'index, follow');

if (description) {
upsertMetaTag('name', 'description', description);
upsertMetaTag('property', 'og:description', description);
}

const canonicalUrl = `${SITE_URL}${location.pathname}`;
upsertCanonicalLink(canonicalUrl);
upsertMetaTag('property', 'og:url', canonicalUrl);
upsertMetaTag('property', 'og:title', fullTitle);
upsertMetaTag('property', 'og:image', image ?? DEFAULT_OG_IMAGE);
}, [title, description, noindex, image, location.pathname]);
}
2 changes: 2 additions & 0 deletions src/widgets/admin-layout/AdminLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { NavLink, Outlet } from 'react-router';

import { ROUTES } from '@/shared/constants';
import { useSeo } from '@/shared/hooks';

const NAV_SECTIONS = [
{
Expand Down Expand Up @@ -41,6 +42,7 @@ const NAV_SECTIONS = [
] as const;

export function AdminLayout() {
useSeo({ title: '관리자', noindex: true });
const isFetching = useIsFetching();
const isMutating = useIsMutating();
const isStaleUpdating = isFetching > 0 || isMutating > 0;
Expand Down
17 changes: 17 additions & 0 deletions src/widgets/footer/Footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import youthPickLogo from '@/assets/images/youthpick-logo.png';
import { CURRENT_YEAR } from '@/shared/constants';

export function Footer() {
return (
<footer className="border-t border-slate-100 bg-white">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-3 px-4 py-8 text-center sm:px-6">
<img src={youthPickLogo} alt="BOP 로고" className="h-6 w-auto opacity-70" />
<p className="text-xs text-slate-400">
BOP는 청년을 위한 정책 정보를 제공하는 서비스이며, 신청 및 접수는 각 주관 기관 공식 창구를
통해 진행됩니다.
</p>
<p className="text-xs text-slate-300">&copy; {CURRENT_YEAR} BOP. All rights reserved.</p>
</div>
</footer>
);
}
1 change: 1 addition & 0 deletions src/widgets/footer/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { Footer } from './Footer';
export { NoticeBanner } from './NoticeBanner';
Loading