diff --git a/index.html b/index.html index becae66..546d49a 100755 --- a/index.html +++ b/index.html @@ -1,10 +1,35 @@ - + - YouthPick + YouthPick | 나에게 맞는 청년정책 추천 + + + + + + + + + + + + + + + +
diff --git a/nginx.conf b/nginx.conf index 3b8ef23..2a5ad58 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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로 하위 경로까지 잡는다. diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..733fdaa --- /dev/null +++ b/public/robots.txt @@ -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 diff --git a/src/pages/community-detail/CommunityDetailPage.tsx b/src/pages/community-detail/CommunityDetailPage.tsx index 7761132..5e0faf0 100644 --- a/src/pages/community-detail/CommunityDetailPage.tsx +++ b/src/pages/community-detail/CommunityDetailPage.tsx @@ -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 }>(); @@ -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); diff --git a/src/pages/community-edit/CommunityEditPage.tsx b/src/pages/community-edit/CommunityEditPage.tsx index a56235f..a347124 100644 --- a/src/pages/community-edit/CommunityEditPage.tsx +++ b/src/pages/community-edit/CommunityEditPage.tsx @@ -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) { diff --git a/src/pages/community-write/CommunityWritePage.tsx b/src/pages/community-write/CommunityWritePage.tsx index 493e7f9..9a1937a 100644 --- a/src/pages/community-write/CommunityWritePage.tsx +++ b/src/pages/community-write/CommunityWritePage.tsx @@ -1,6 +1,9 @@ import { CommunityPostWriteContainer } from '@/features/community-write'; +import { useSeo } from '@/shared/hooks'; export function CommunityWritePage() { + useSeo({ title: '게시글 작성', noindex: true }); + return (
diff --git a/src/pages/community/CommunityPage.tsx b/src/pages/community/CommunityPage.tsx index 61db3bc..59f90fb 100644 --- a/src/pages/community/CommunityPage.tsx +++ b/src/pages/community/CommunityPage.tsx @@ -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, @@ -16,6 +17,10 @@ import { } from '@/widgets/community-post-grid'; export function CommunityPage() { + useSeo({ + title: '커뮤니티', + description: '정책에 대한 질문과 후기, 소소한 이야기를 자유롭게 나누는 청년들의 이야기 공간.', + }); const { query, category, diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx index 9c278d0..5bb8aed 100644 --- a/src/pages/home/HomePage.tsx +++ b/src/pages/home/HomePage.tsx @@ -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 { @@ -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 { diff --git a/src/pages/login/LoginPage.tsx b/src/pages/login/LoginPage.tsx index db0d743..0d56efd 100644 --- a/src/pages/login/LoginPage.tsx +++ b/src/pages/login/LoginPage.tsx @@ -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(); diff --git a/src/pages/my-liked-posts/MyLikedPostsPage.tsx b/src/pages/my-liked-posts/MyLikedPostsPage.tsx index dc0c3a4..8caa0bf 100644 --- a/src/pages/my-liked-posts/MyLikedPostsPage.tsx +++ b/src/pages/my-liked-posts/MyLikedPostsPage.tsx @@ -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 ( diff --git a/src/pages/my-posts/MyPostsPage.tsx b/src/pages/my-posts/MyPostsPage.tsx index ee0723f..e7bb598 100644 --- a/src/pages/my-posts/MyPostsPage.tsx +++ b/src/pages/my-posts/MyPostsPage.tsx @@ -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 ( diff --git a/src/pages/my/MyPage.tsx b/src/pages/my/MyPage.tsx index f085b8e..2862d44 100644 --- a/src/pages/my/MyPage.tsx +++ b/src/pages/my/MyPage.tsx @@ -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, diff --git a/src/pages/oauth-callback/OAuthCallbackPage.tsx b/src/pages/oauth-callback/OAuthCallbackPage.tsx index c889dfb..3c4865d 100644 --- a/src/pages/oauth-callback/OAuthCallbackPage.tsx +++ b/src/pages/oauth-callback/OAuthCallbackPage.tsx @@ -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(); diff --git a/src/pages/profile-setup/ProfileSetupPage.tsx b/src/pages/profile-setup/ProfileSetupPage.tsx index 06ee011..73ecc70 100644 --- a/src/pages/profile-setup/ProfileSetupPage.tsx +++ b/src/pages/profile-setup/ProfileSetupPage.tsx @@ -1,6 +1,9 @@ import { ProfileSetupContainer } from '@/features/profile-setup'; +import { useSeo } from '@/shared/hooks'; export function ProfileSetupPage() { + useSeo({ title: '프로필 설정', noindex: true }); + return (
diff --git a/src/pages/recommend/RecommendPage.tsx b/src/pages/recommend/RecommendPage.tsx index 3d612ab..3142760 100644 --- a/src/pages/recommend/RecommendPage.tsx +++ b/src/pages/recommend/RecommendPage.tsx @@ -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(); diff --git a/src/pages/search/SearchPageContainer.tsx b/src/pages/search/SearchPageContainer.tsx index d8bb354..e1b345c 100644 --- a/src/pages/search/SearchPageContainer.tsx +++ b/src/pages/search/SearchPageContainer.tsx @@ -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 ; diff --git a/src/pages/tracker/TrackerPage.tsx b/src/pages/tracker/TrackerPage.tsx index e935b64..cd968be 100644 --- a/src/pages/tracker/TrackerPage.tsx +++ b/src/pages/tracker/TrackerPage.tsx @@ -1,6 +1,9 @@ import { TrackerContainer } from '@/features/policy-tracker'; +import { useSeo } from '@/shared/hooks'; export function TrackerPage() { + useSeo({ title: '신청 관리', noindex: true }); + return (
diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts index 8543b6c..a09dcaa 100644 --- a/src/shared/constants/index.ts +++ b/src/shared/constants/index.ts @@ -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'; diff --git a/src/shared/constants/seo.ts b/src/shared/constants/seo.ts new file mode 100644 index 0000000..2042012 --- /dev/null +++ b/src/shared/constants/seo.ts @@ -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`; diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 0c70757..d016bdb 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -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'; diff --git a/src/shared/hooks/useSeo.ts b/src/shared/hooks/useSeo.ts new file mode 100644 index 0000000..a4337e5 --- /dev/null +++ b/src/shared/hooks/useSeo.ts @@ -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(`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('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 전환 시 를 이 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]); +} diff --git a/src/widgets/admin-layout/AdminLayout.tsx b/src/widgets/admin-layout/AdminLayout.tsx index b6ecff3..4879900 100644 --- a/src/widgets/admin-layout/AdminLayout.tsx +++ b/src/widgets/admin-layout/AdminLayout.tsx @@ -14,6 +14,7 @@ import { import { NavLink, Outlet } from 'react-router'; import { ROUTES } from '@/shared/constants'; +import { useSeo } from '@/shared/hooks'; const NAV_SECTIONS = [ { @@ -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;