Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
Loading