diff --git a/scripts/deploy-browser-smoke.mjs b/scripts/deploy-browser-smoke.mjs index 0246fe27..d5319410 100644 --- a/scripts/deploy-browser-smoke.mjs +++ b/scripts/deploy-browser-smoke.mjs @@ -199,6 +199,9 @@ async function main() { return route.continue(); }); + const hasProfileFeedResponse = () => + supabaseResponses.some(({ url }) => url.includes('/rest/v1/profile_posts')); + page.on('response', (response) => { const url = response.url(); if (url.includes('/rest/v1/')) { @@ -233,6 +236,17 @@ async function main() { undefined, { timeout: 20_000 }, ); + // SSR may render the first cards before hydration starts the client refresh. + // Wait for that refresh response when it has not arrived yet so the smoke + // does not race the response listener and report a false negative. + if (!hasProfileFeedResponse()) { + await page + .waitForResponse( + (response) => response.url().includes('/rest/v1/profile_posts'), + { timeout: 20_000 }, + ) + .catch(() => undefined); + } const profileFeedCount = await page.locator('[data-testid="profile-feed-card"]').count(); const profileFeedResponses = supabaseResponses.filter(({ url }) => url.includes('/rest/v1/profile_posts')); assert( diff --git a/src/app/[locale]/HomeFeedClient.tsx b/src/app/[locale]/HomeFeedClient.tsx index bb4270b6..5ba6cd39 100644 --- a/src/app/[locale]/HomeFeedClient.tsx +++ b/src/app/[locale]/HomeFeedClient.tsx @@ -9,7 +9,10 @@ import { Clock3, Share2, } from 'lucide-react'; -import type { PublicProfilePostRecord } from '@/lib/api/profile-content'; +import type { + PublicProfileFeedPage, + PublicProfilePostRecord, +} from '@/lib/api/profile-content'; import { getProfilePostSocial, type ProfilePostSocialRecord, @@ -292,10 +295,17 @@ function FeedCard({ ); } -export default function HomeFeedClient() { +export default function HomeFeedClient({ + initialFeed = null, +}: { + initialFeed?: PublicProfileFeedPage | null; +}) { const locale = useLocale(); const t = useTranslations('Home'); - const { posts, isLoading, hasMore, error, loadMore, reload } = usePublicProfileFeed(); + const { posts, isLoading, hasMore, error, loadMore, reload } = usePublicProfileFeed( + undefined, + initialFeed, + ); const [socialByPostId, setSocialByPostId] = useState>({}); const requestedSocialIdsRef = useRef(new Set()); const feedShellRef = useRef(null); diff --git a/src/app/[locale]/following/page.tsx b/src/app/[locale]/following/page.tsx index 1d4111ff..6a4cdc06 100644 --- a/src/app/[locale]/following/page.tsx +++ b/src/app/[locale]/following/page.tsx @@ -19,12 +19,15 @@ export async function generateMetadata({ params }: FollowingPageProps): Promise< const { locale } = await params; const t = await getTranslations({ locale, namespace: 'Following' }); - return generatePageMetadata({ - locale, - pathname: '/following', - title: `${t('title')} | ${BRAND_NAME}`, - description: t('subtitle'), - }); + return { + ...generatePageMetadata({ + locale, + pathname: '/following', + title: `${t('title')} | ${BRAND_NAME}`, + description: t('subtitle'), + }), + robots: { index: false, follow: false }, + }; } export default async function FollowingPage({ params }: FollowingPageProps) { diff --git a/src/app/[locale]/hall-of-fame/HallOfFameClient.tsx b/src/app/[locale]/hall-of-fame/HallOfFameClient.tsx index a76f0eaa..c94e4580 100644 --- a/src/app/[locale]/hall-of-fame/HallOfFameClient.tsx +++ b/src/app/[locale]/hall-of-fame/HallOfFameClient.tsx @@ -12,6 +12,7 @@ import { useState } from 'react'; import { useTranslations } from 'next-intl'; import { useHallOfFame } from '@/hooks/useHallOfFame'; +import type { HallOfFameData } from '@/types/hall-of-fame'; // Feature Components import GrandChampionCard from '@/components/features/hall-of-fame/GrandChampionCard'; @@ -87,14 +88,18 @@ function EmptyState() { /** * 명예의 전당 클라이언트 컴포넌트 */ -export default function HallOfFameClient() { - const { data, isLoading, error, getMonthlyChampions, getYearlyRace, refresh } = useHallOfFame(); +export default function HallOfFameClient({ + initialData, +}: { + initialData?: HallOfFameData | null; +}) { + const { data, isLoading, error, getMonthlyChampions, getYearlyRace, refresh } = useHallOfFame(initialData); // null이면 데이터가 제공하는 현재 연도를 그대로 사용한다. const [selectedYear, setSelectedYear] = useState(null); // 로딩 상태 - 스켈레톤 UI 표시 - if (isLoading) { + if (isLoading && !data) { return (
diff --git a/src/app/[locale]/hall-of-fame/page.tsx b/src/app/[locale]/hall-of-fame/page.tsx index 0478fd73..f58bf001 100644 --- a/src/app/[locale]/hall-of-fame/page.tsx +++ b/src/app/[locale]/hall-of-fame/page.tsx @@ -17,6 +17,7 @@ import { JsonLd } from '@/components/common/JsonLd'; import AdBanner from '@/components/common/AdBanner'; import { AD_SLOTS } from '@/types/ads'; import PageFrame, { PageHeader } from '@/components/layout/PageFrame'; +import { getInitialHallOfFameData } from '@/lib/server/public-page-data'; /** 지원하는 언어에 대해 정적 페이지 생성 */ export function generateStaticParams() { @@ -52,6 +53,7 @@ export default async function HallOfFamePage({ params }: HallOfFamePageProps) { const { locale } = await params; setRequestLocale(locale); const t = await getTranslations({ locale, namespace: 'HallOfFame' }); + const initialData = await getInitialHallOfFameData(); const hallOfFameJsonLd = { '@context': 'https://schema.org', '@type': 'ItemList', @@ -71,7 +73,7 @@ export default async function HallOfFamePage({ params }: HallOfFamePageProps) { /> {/* CSR 챔피언 데이터 영역 */} - + {/* 명예의 전당 하단 광고 */} diff --git a/src/app/[locale]/notice/NoticeClient.tsx b/src/app/[locale]/notice/NoticeClient.tsx index b5a12b0b..fa5df036 100644 --- a/src/app/[locale]/notice/NoticeClient.tsx +++ b/src/app/[locale]/notice/NoticeClient.tsx @@ -11,6 +11,7 @@ import styles from './page.module.scss'; interface NoticeClientProps { locale: string; + initialNotices?: AnnouncementListItem[]; } /** @@ -18,19 +19,21 @@ interface NoticeClientProps { * 목록 조회 + 카테고리 필터링 * 상세보기는 /notice/[id] 페이지로 이동 */ -export default function NoticeClient({ locale }: NoticeClientProps) { +export default function NoticeClient({ locale, initialNotices }: NoticeClientProps) { const t = useTranslations('Notice'); - const [notices, setNotices] = useState([]); + const [notices, setNotices] = useState(initialNotices ?? []); const [commentCounts, setCommentCounts] = useState>({}); const [selectedCategory, setSelectedCategory] = useState(undefined); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(initialNotices === undefined); const [error, setError] = useState(false); /** 공지사항 목록 조회 + 댓글 수 배치 조회 */ - const fetchNotices = useCallback(async () => { + const fetchNotices = useCallback(async (preserveVisibleContent = false) => { try { - setLoading(true); + if (!preserveVisibleContent) { + setLoading(true); + } setError(false); const data = await getAnnouncements(selectedCategory, locale); setNotices(data); @@ -49,8 +52,8 @@ export default function NoticeClient({ locale }: NoticeClientProps) { }, [selectedCategory, locale]); useEffect(() => { - fetchNotices(); - }, [fetchNotices]); + void fetchNotices(initialNotices !== undefined && selectedCategory === undefined); + }, [fetchNotices, initialNotices, selectedCategory]); /** 카테고리 필터 변경 */ const handleCategoryChange = (category?: AnnouncementCategory) => { @@ -67,7 +70,7 @@ export default function NoticeClient({ locale }: NoticeClientProps) { /> {/* 로딩 상태 */} - {loading && ( + {loading && notices.length === 0 && (

{t('loading')}

@@ -77,14 +80,14 @@ export default function NoticeClient({ locale }: NoticeClientProps) { {error && !loading && (

{t('error')}

-
)} {/* 공지사항 목록 */} - {!loading && !error && ( + {!error && ( <> {notices.length === 0 ? (
diff --git a/src/app/[locale]/notice/[id]/NoticeDetailClient.tsx b/src/app/[locale]/notice/[id]/NoticeDetailClient.tsx index 582cdc31..9885150e 100644 --- a/src/app/[locale]/notice/[id]/NoticeDetailClient.tsx +++ b/src/app/[locale]/notice/[id]/NoticeDetailClient.tsx @@ -10,6 +10,7 @@ import { incrementAnnouncementView, } from '@/lib/api/announcements'; import { sanitizeNoticeContent } from '@/lib/sanitizeNoticeContent'; +import { getNoticePlainText } from '@/lib/notice-content'; import { JsonLd } from '@/components/common/JsonLd'; import NoticeComments from '@/components/features/notice/NoticeComments'; import AdBanner from '@/components/common/AdBanner'; @@ -23,43 +24,62 @@ import styles from './page.module.scss'; interface NoticeDetailClientProps { locale: string; noticeId: string; + initialNotice?: Announcement | null; } /** * 공지사항 상세 클라이언트 컴포넌트 * 개별 공지사항 데이터를 Supabase에서 조회하여 렌더링 */ -export default function NoticeDetailClient({ locale, noticeId }: NoticeDetailClientProps) { +export default function NoticeDetailClient({ + locale, + noticeId, + initialNotice = null, +}: NoticeDetailClientProps) { const t = useTranslations('Notice'); - const [notice, setNotice] = useState(null); - const [loading, setLoading] = useState(true); + const [notice, setNotice] = useState(initialNotice); + const [loading, setLoading] = useState(!initialNotice); const [error, setError] = useState(false); + const [isInitialContent, setIsInitialContent] = useState(Boolean(initialNotice)); /** 공지사항 상세 데이터 조회 */ useEffect(() => { async function fetchNotice() { try { - setLoading(true); + if (!initialNotice) { + setLoading(true); + } setError(false); const data = await getAnnouncementById(noticeId, locale); if (data) { const sanitizedContent = await sanitizeNoticeContent(data.content); + if (!active) return; setNotice({ ...data, content: sanitizedContent }); + setIsInitialContent(false); // 조회수 증가 (fire-and-forget) incrementAnnouncementView(noticeId); - } else { + } else if (!initialNotice) { setError(true); } } catch { - setError(true); + if (!initialNotice) { + setError(true); + } } finally { - setLoading(false); + if (active) { + setLoading(false); + } } } - fetchNotice(); - }, [noticeId, locale]); + let active = true; + void fetchNotice(); + + return () => { + active = false; + }; + }, [initialNotice, locale, noticeId]); /** 날짜 포맷팅 */ const formatDate = (dateStr: string) => { @@ -90,14 +110,14 @@ export default function NoticeDetailClient({ locale, noticeId }: NoticeDetailCli {/* 로딩 상태 */} - {loading && ( + {loading && !notice && (

{t('loading')}

)} {/* 에러 상태 */} - {error && !loading && ( + {error && !notice && (

{t('error')}

@@ -107,7 +127,7 @@ export default function NoticeDetailClient({ locale, noticeId }: NoticeDetailCli )} {/* 상세 내용 */} - {notice && !loading && ( + {notice && ( <> {/* 공지 헤더 */}
@@ -123,12 +143,18 @@ export default function NoticeDetailClient({ locale, noticeId }: NoticeDetailCli {/* 본문 (Quill HTML 콘텐츠) */} {/* XSS 방지: DOMPurify로 HTML sanitize 처리 */} -
+ {isInitialContent ? ( +
+

{getNoticePlainText(notice.content)}

+
+ ) : ( +
+ )} {/* JSON-LD 구조화 데이터 (Article 스키마) */} ; + return ; } diff --git a/src/app/[locale]/notice/page.tsx b/src/app/[locale]/notice/page.tsx index 0fbfc843..4ea7a5ca 100644 --- a/src/app/[locale]/notice/page.tsx +++ b/src/app/[locale]/notice/page.tsx @@ -9,6 +9,7 @@ import AdBanner from '@/components/common/AdBanner'; import { AD_SLOTS } from '@/types/ads'; import styles from './notice-seo.module.scss'; import PageFrame, { PageHeader } from '@/components/layout/PageFrame'; +import { getServerAnnouncements } from '@/lib/api/announcements'; /** 지원하는 언어에 대해 정적 페이지 생성 */ export function generateStaticParams() { @@ -40,6 +41,7 @@ export default async function NoticePage({ params }: { params: Promise<{ locale: const { locale } = await params; setRequestLocale(locale); const t = await getTranslations({ locale, namespace: 'Notice' }); + const initialNotices = await getServerAnnouncements(undefined, locale); const noticeJsonLd = { '@context': 'https://schema.org', '@type': 'WebPage', @@ -60,7 +62,7 @@ export default async function NoticePage({ params }: { params: Promise<{ locale: /> {/* CSR 공지사항 목록 */} - + {/* 공지사항 목록 하단 광고 */} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 4b87ca41..163b4e73 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -15,6 +15,7 @@ import { generatePageMetadata } from '@/lib/seo'; import { FULL_URL, SUPPORTED_LOCALES } from '@/lib/constants'; import { BRAND_MARK_PATH, BRAND_NAME } from '@/lib/brand'; import { JsonLd } from '@/components/common/JsonLd'; +import { getInitialHomeFeed } from '@/lib/server/home-feed'; import styles from './seo-content.module.scss'; /** 지원하는 로케일에 대해 페이지 생성 */ @@ -52,7 +53,10 @@ export async function generateMetadata({ params }: HomePageProps): Promise - + {/* * 피드 아래의 실제 서비스 안내 콘텐츠. diff --git a/src/app/[locale]/profile/[username]/page.tsx b/src/app/[locale]/profile/[username]/page.tsx index 72b9e522..681d700f 100644 --- a/src/app/[locale]/profile/[username]/page.tsx +++ b/src/app/[locale]/profile/[username]/page.tsx @@ -45,12 +45,15 @@ export async function generateMetadata({ const username = decodeUsername(rawUsername); const t = await getTranslations({ locale, namespace: 'PublicProfile' }); - return generatePageMetadata({ - locale, - pathname: `/profile/${encodeURIComponent(username)}`, - title: `${username} | ${t('title_suffix')} | ${BRAND_NAME}`, - description: `${t('public')}: ${username}. ${t('posts')}. ${t('activities')}.`, - }); + return { + ...generatePageMetadata({ + locale, + pathname: `/profile/${encodeURIComponent(username)}`, + title: `${username} | ${t('title_suffix')} | ${BRAND_NAME}`, + description: `${t('public')}: ${username}. ${t('posts')}. ${t('activities')}.`, + }), + robots: { index: false, follow: false }, + }; } export default async function PublicProfilePage({ diff --git a/src/app/[locale]/ranking/RankingClient.tsx b/src/app/[locale]/ranking/RankingClient.tsx index a274ca9d..5f25c23b 100644 --- a/src/app/[locale]/ranking/RankingClient.tsx +++ b/src/app/[locale]/ranking/RankingClient.tsx @@ -1,5 +1,17 @@ 'use client'; +import type { CompaniesResponse } from '@/types/api'; + // Keep the existing vote/ranking implementation intact while moving it off // the community-first home route. -export { HomeClient as default, HomeClient } from '../HomeClient'; +import { HomeClient } from '../HomeClient'; + +export default function RankingClient({ + initialData, +}: { + initialData?: CompaniesResponse | null; +}) { + return ; +} + +export { HomeClient }; diff --git a/src/app/[locale]/ranking/page.tsx b/src/app/[locale]/ranking/page.tsx index 7ab0eac1..348b87fd 100644 --- a/src/app/[locale]/ranking/page.tsx +++ b/src/app/[locale]/ranking/page.tsx @@ -5,6 +5,7 @@ import { generatePageMetadata } from '@/lib/seo'; import { SUPPORTED_LOCALES } from '@/lib/constants'; import { BRAND_NAME } from '@/lib/brand'; import PageFrame, { PageHeader } from '@/components/layout/PageFrame'; +import { getInitialLeagueData } from '@/lib/server/public-page-data'; export function generateStaticParams() { return SUPPORTED_LOCALES.map((locale) => ({ locale })); @@ -31,6 +32,7 @@ export default async function RankingPage({ params }: RankingPageProps) { setRequestLocale(locale); const tNav = await getTranslations({ locale, namespace: 'Nav' }); const tHome = await getTranslations({ locale, namespace: 'Home' }); + const initialData = await getInitialLeagueData(); return ( @@ -39,7 +41,7 @@ export default async function RankingPage({ params }: RankingPageProps) { title={tNav('ranking')} description={tHome('title')} /> - + ); } diff --git a/src/app/embed/kpopface-vote/[locale]/EmbedVoteClient.tsx b/src/app/embed/kpopface-vote/[locale]/EmbedVoteClient.tsx index 1e560486..18ea7aa9 100644 --- a/src/app/embed/kpopface-vote/[locale]/EmbedVoteClient.tsx +++ b/src/app/embed/kpopface-vote/[locale]/EmbedVoteClient.tsx @@ -3,7 +3,20 @@ * canonical VoteBoard receives the Kpopface surface adapter by default. */ import VoteBoardEmbedClient from '../../vote-board/[locale]/VoteBoardEmbedClient'; +import type { CompaniesResponse } from '@/types/api'; -export default function EmbedVoteClient({ locale }: { locale: string }) { - return ; +export default function EmbedVoteClient({ + locale, + initialData, +}: { + locale: string; + initialData?: CompaniesResponse | null; +}) { + return ( + + ); } diff --git a/src/app/embed/kpopface-vote/[locale]/page.tsx b/src/app/embed/kpopface-vote/[locale]/page.tsx index ef5bed0b..f7dbf2b4 100644 --- a/src/app/embed/kpopface-vote/[locale]/page.tsx +++ b/src/app/embed/kpopface-vote/[locale]/page.tsx @@ -1,12 +1,18 @@ +import type { Metadata } from 'next'; import { getMessages, setRequestLocale } from 'next-intl/server'; import { NextIntlClientProvider } from 'next-intl'; import { notFound } from 'next/navigation'; import AuthProvider from '@/components/providers/AuthProvider'; import { SWRProvider } from '@/components/providers/SWRProvider'; import EmbedVoteClient from './EmbedVoteClient'; +import { getInitialLeagueData } from '@/lib/server/public-page-data'; const SUPPORTED_LOCALES = ['ko', 'en', 'ja', 'zh', 'es', 'fr', 'de'] as const; +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + export function generateStaticParams() { return SUPPORTED_LOCALES.map((locale) => ({ locale })); } @@ -24,12 +30,13 @@ export default async function KpopfaceVoteEmbedPage({ setRequestLocale(locale); const messages = await getMessages({ locale }); + const initialData = await getInitialLeagueData(); return ( - + diff --git a/src/app/embed/vote-board/[locale]/VoteBoardEmbedClient.tsx b/src/app/embed/vote-board/[locale]/VoteBoardEmbedClient.tsx index 32ddc2ee..0e1626c5 100644 --- a/src/app/embed/vote-board/[locale]/VoteBoardEmbedClient.tsx +++ b/src/app/embed/vote-board/[locale]/VoteBoardEmbedClient.tsx @@ -5,6 +5,7 @@ import { useLeagueData } from '@/hooks/useLeagueData'; import { useAuth } from '@/hooks/useAuth'; import { useVoteQuota } from '@/hooks/useVoteQuota'; import { useRefreshCountdown } from '@/hooks/useRefreshCountdown'; +import type { CompaniesResponse } from '@/types/api'; import BottomSheet from '@/components/ui/BottomSheet'; import VoteController from '@/components/features/VoteController'; import { @@ -90,9 +91,11 @@ function postEmbedMessage(message: Record) { export default function VoteBoardEmbedClient({ locale, surfaceOverride, + initialData, }: { locale: string; surfaceOverride?: VoteBoardSurface; + initialData?: CompaniesResponse | null; }) { const { premierLeague, @@ -101,7 +104,7 @@ export default function VoteBoardEmbedClient({ isLoading, error, refresh, - } = useLeagueData({ refreshInterval: 20000 }); + } = useLeagueData({ refreshInterval: 20000, fallbackData: initialData }); const { user } = useAuth(); const [options, setOptions] = useState({ surface: 'partner', diff --git a/src/app/embed/vote-board/[locale]/page.tsx b/src/app/embed/vote-board/[locale]/page.tsx index f5963bb1..0a56f419 100644 --- a/src/app/embed/vote-board/[locale]/page.tsx +++ b/src/app/embed/vote-board/[locale]/page.tsx @@ -1,12 +1,18 @@ +import type { Metadata } from 'next'; import { getMessages, setRequestLocale } from 'next-intl/server'; import { NextIntlClientProvider } from 'next-intl'; import { notFound } from 'next/navigation'; import AuthProvider from '@/components/providers/AuthProvider'; import { SWRProvider } from '@/components/providers/SWRProvider'; import VoteBoardEmbedClient from './VoteBoardEmbedClient'; +import { getInitialLeagueData } from '@/lib/server/public-page-data'; const SUPPORTED_LOCALES = ['ko', 'en', 'ja', 'zh', 'es', 'fr', 'de'] as const; +export const metadata: Metadata = { + robots: { index: false, follow: false }, +}; + export function generateStaticParams() { return SUPPORTED_LOCALES.map((locale) => ({ locale })); } @@ -24,12 +30,13 @@ export default async function VoteBoardEmbedPage({ setRequestLocale(locale); const messages = await getMessages({ locale }); + const initialData = await getInitialLeagueData(); return ( - + diff --git a/src/components/features/notice/NoticeComments/index.tsx b/src/components/features/notice/NoticeComments/index.tsx index 5179fff5..b27dd317 100644 --- a/src/components/features/notice/NoticeComments/index.tsx +++ b/src/components/features/notice/NoticeComments/index.tsx @@ -7,6 +7,7 @@ import { getNoticeComments, createNoticeComment, deleteNoticeComment, + supportsNoticeComments, type NoticeComment, } from '@/lib/api/notice-comments'; import { useAuth } from '@/hooks/useAuth'; @@ -26,6 +27,12 @@ const MIN_SUBMIT_INTERVAL = 5000; * NewsComments 컴포넌트 패턴 복제 (slug → announcementId) */ export default function NoticeComments({ announcementId }: NoticeCommentsProps) { + if (!supportsNoticeComments(announcementId)) return null; + + return ; +} + +function NoticeCommentsContent({ announcementId }: NoticeCommentsProps) { const t = useTranslations('NoticeComments'); const { profile } = useAuth(); diff --git a/src/hooks/useHallOfFame.test.tsx b/src/hooks/useHallOfFame.test.tsx index a39ba2a9..746be9d7 100644 --- a/src/hooks/useHallOfFame.test.tsx +++ b/src/hooks/useHallOfFame.test.tsx @@ -61,4 +61,14 @@ describe('useHallOfFame', () => { expect(result.current.dataSource).toBe('mock'); expect(result.current.getMonthlyChampions(2026)).toEqual([fixtures.januaryChampion]); }); + + it('keeps server-rendered records visible while the client refreshes them', () => { + fixtures.getHallOfFame.mockResolvedValue(fixtures.data); + + const { result } = renderHook(() => useHallOfFame(fixtures.data)); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual(fixtures.data); + expect(result.current.getMonthlyChampions(2026)).toEqual([fixtures.januaryChampion]); + }); }); diff --git a/src/hooks/useHallOfFame.ts b/src/hooks/useHallOfFame.ts index 76126c06..022534d1 100644 --- a/src/hooks/useHallOfFame.ts +++ b/src/hooks/useHallOfFame.ts @@ -85,18 +85,20 @@ const USE_MOCK = process.env.NEXT_PUBLIC_USE_MOCK_DATA === 'true'; * API가 준비되면 자동으로 API 데이터를 사용하고, * API 호출 실패 시 Mock 데이터로 폴백합니다. */ -export function useHallOfFame(): UseHallOfFameReturn { - const [data, setData] = useState(null); - const [isLoading, setIsLoading] = useState(true); +export function useHallOfFame(initialData: HallOfFameData | null = null): UseHallOfFameReturn { + const [data, setData] = useState(initialData); + const [isLoading, setIsLoading] = useState(!initialData); const [error, setError] = useState(null); - const [dataSource, setDataSource] = useState<'mock' | 'api'>('mock'); + const [dataSource, setDataSource] = useState<'mock' | 'api'>(initialData ? 'api' : 'mock'); /** 월간 챔피언 캐시 (연도별) */ const monthlyCache = useRef>(new Map()); /** 연간 경쟁현황 캐시 (연도별) */ const raceCache = useRef>(new Map()); /** 전체 월간 챔피언 캐시 (연도별 조회용) */ - const allChampionsRef = useRef([]); + const allChampionsRef = useRef(initialData?.currentYearMonthly ?? []); + /** Initial server data is already visible, so revalidation must not replace it with a skeleton. */ + const hasVisibleDataRef = useRef(Boolean(initialData)); /** * Supabase에서 데이터 가져오기 시도 @@ -144,7 +146,9 @@ export function useHallOfFame(): UseHallOfFameReturn { * 데이터 로드 (API 우선, Mock 폴백) */ const fetchData = useCallback(async () => { - setIsLoading(true); + if (!hasVisibleDataRef.current) { + setIsLoading(true); + } setError(null); try { @@ -167,6 +171,7 @@ export function useHallOfFame(): UseHallOfFameReturn { ]; setData(result); + hasVisibleDataRef.current = true; // 캐시 초기화 monthlyCache.current.clear(); diff --git a/src/hooks/usePublicProfileFeed.test.ts b/src/hooks/usePublicProfileFeed.test.ts index 7ffbf262..5199bf0e 100644 --- a/src/hooks/usePublicProfileFeed.test.ts +++ b/src/hooks/usePublicProfileFeed.test.ts @@ -52,6 +52,28 @@ describe('usePublicProfileFeed', () => { expect(result.current.posts).toEqual([post]); }); + it('keeps a server-rendered first page visible while it refreshes', async () => { + const serverPost = makePost('server-post', 'image'); + const refreshedPost = makePost('refreshed-post', 'image'); + let resolveRefresh!: (value: PublicProfileFeedPage) => void; + const refresh = new Promise((resolve) => { + resolveRefresh = resolve; + }); + fixtures.listPublicProfilePosts.mockReturnValue(refresh); + + const { result } = renderHook(() => usePublicProfileFeed(undefined, page([serverPost]))); + + expect(result.current.posts).toEqual([serverPost]); + await waitFor(() => expect(fixtures.listPublicProfilePosts).toHaveBeenCalledTimes(1)); + + await act(async () => { + resolveRefresh(page([refreshedPost])); + await refresh; + }); + + await waitFor(() => expect(result.current.posts).toEqual([refreshedPost])); + }); + it('restarts at the first page when the media filter changes', async () => { const imagePost = makePost('image-post', 'image'); const shortPost = makePost('short-post', 'short'); diff --git a/src/hooks/usePublicProfileFeed.ts b/src/hooks/usePublicProfileFeed.ts index 9089d6c8..0e25c3bd 100644 --- a/src/hooks/usePublicProfileFeed.ts +++ b/src/hooks/usePublicProfileFeed.ts @@ -6,6 +6,7 @@ import { PROFILE_FEED_PAGE_SIZE, type ProfileFeedCursor, type ProfileMediaType, + type PublicProfileFeedPage, type PublicProfilePostRecord, } from '@/lib/api/profile-content'; @@ -18,11 +19,16 @@ export interface UsePublicProfileFeedReturn { reload: () => Promise; } -export function usePublicProfileFeed(mediaType?: ProfileMediaType): UsePublicProfileFeedReturn { - const [posts, setPosts] = useState([]); - const [cursor, setCursor] = useState(null); - const [hasMore, setHasMore] = useState(true); - const [isLoading, setIsLoading] = useState(true); +export function usePublicProfileFeed( + mediaType?: ProfileMediaType, + initialPage?: PublicProfileFeedPage | null, +): UsePublicProfileFeedReturn { + const hasInitialPage = initialPage !== null && initialPage !== undefined; + const preserveInitialPageRef = useRef(hasInitialPage); + const [posts, setPosts] = useState(() => initialPage?.posts ?? []); + const [cursor, setCursor] = useState(() => initialPage?.nextCursor ?? null); + const [hasMore, setHasMore] = useState(() => hasInitialPage ? initialPage.nextCursor !== null : true); + const [isLoading, setIsLoading] = useState(!hasInitialPage); const [error, setError] = useState(null); const requestIdRef = useRef(0); @@ -58,9 +64,15 @@ export function usePublicProfileFeed(mediaType?: ProfileMediaType): UsePublicPro ); useEffect(() => { - setPosts([]); - setCursor(null); - setHasMore(true); + // A server-rendered first page must remain visible during the background + // refresh. Subsequent filter changes retain the existing reset behavior. + const preserveInitialPage = preserveInitialPageRef.current; + preserveInitialPageRef.current = false; + if (!preserveInitialPage) { + setPosts([]); + setCursor(null); + setHasMore(true); + } void loadPage(null, true); }, [loadPage]); diff --git a/src/lib/api/announcements.ts b/src/lib/api/announcements.ts index 85665e97..bd02bce4 100644 --- a/src/lib/api/announcements.ts +++ b/src/lib/api/announcements.ts @@ -8,6 +8,7 @@ import { createClient } from '@/lib/supabase/client'; import { BRAND_NAME } from '@/lib/brand'; import { SUPPORTED_LOCALES, SupportedLocale } from '@/lib/constants'; import type { Announcement, AnnouncementCategory, AnnouncementListItem } from '@/types/announcement'; +import type { SupabaseClient } from '@supabase/supabase-js'; export const SIGNUP_EVENT_NOTICE_ID = 'event-signup-double-votes-2026-02'; @@ -91,13 +92,25 @@ function createSignupEventNotice(locale = 'ko'): Announcement { }; } -/** 공지사항 목록 조회 (공개된 것만, RLS 적용) */ -export async function getAnnouncements( +function withSignupEventNotice( + notices: AnnouncementListItem[], + category: AnnouncementCategory | undefined, + locale: string, +): AnnouncementListItem[] { + if (category && category !== 'event') { + return notices; + } + + const eventNotice = createSignupEventNotice(locale); + const hasSameId = notices.some((notice) => notice.id === eventNotice.id); + return hasSameId ? notices : [eventNotice, ...notices]; +} + +async function queryAnnouncements( + supabase: SupabaseClient, category?: AnnouncementCategory, locale = 'ko', ): Promise { - const supabase = createClient(); - let query = supabase .from('announcements') .select('id, title, category, is_pinned, is_published, view_count, created_at, updated_at') @@ -116,25 +129,18 @@ export async function getAnnouncements( throw error; } - const notices = data || []; - - if (category && category !== 'event') { - return notices; - } - - const eventNotice = createSignupEventNotice(locale); - const hasSameId = notices.some((notice) => notice.id === eventNotice.id); - return hasSameId ? notices : [eventNotice, ...notices]; + return withSignupEventNotice((data || []) as AnnouncementListItem[], category, locale); } -/** 공지사항 상세 조회 */ -export async function getAnnouncementById(id: string, locale = 'ko'): Promise { +async function queryAnnouncementById( + supabase: SupabaseClient, + id: string, + locale = 'ko', +): Promise { if (id === SIGNUP_EVENT_NOTICE_ID) { return createSignupEventNotice(locale); } - const supabase = createClient(); - const { data, error } = await supabase .from('announcements') .select('*') @@ -147,7 +153,63 @@ export async function getAnnouncementById(id: string, locale = 'ko'): Promise { + return queryAnnouncements(createClient(), category, locale); +} + +/** 공지사항 상세 조회 */ +export async function getAnnouncementById(id: string, locale = 'ko'): Promise { + return queryAnnouncementById(createClient(), id, locale); +} + +/** + * Build-time/server equivalent of getAnnouncements. + * Only published fields are returned, so the result is safe to serialize into + * a public static page. + */ +export async function getServerAnnouncements( + category?: AnnouncementCategory, + locale = 'ko', +): Promise { + const { createServerClient } = await import('@/lib/supabase/server'); + const supabase = createServerClient(); + + if (!supabase) { + return withSignupEventNotice([], category, locale); + } + + try { + return await queryAnnouncements(supabase, category, locale); + } catch { + return withSignupEventNotice([], category, locale); + } +} + +/** Build-time/server equivalent of getAnnouncementById. */ +export async function getServerAnnouncementById( + id: string, + locale = 'ko', +): Promise { + if (id === SIGNUP_EVENT_NOTICE_ID) { + return createSignupEventNotice(locale); + } + + const { createServerClient } = await import('@/lib/supabase/server'); + const supabase = createServerClient(); + if (!supabase) return null; + + try { + return await queryAnnouncementById(supabase, id, locale); + } catch { + return null; + } } /** 조회수 증가 */ diff --git a/src/lib/api/notice-comments.test.ts b/src/lib/api/notice-comments.test.ts new file mode 100644 index 00000000..7af8e7a4 --- /dev/null +++ b/src/lib/api/notice-comments.test.ts @@ -0,0 +1,34 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createClient } from '@/lib/supabase/client'; +import { + createNoticeComment, + getNoticeComments, + supportsNoticeComments, +} from './notice-comments'; + +vi.mock('@/lib/supabase/client', () => ({ + createClient: vi.fn(), +})); + +describe('notice comments for static notices', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('recognizes only database UUIDs as comment-capable notices', () => { + expect(supportsNoticeComments('event-signup-double-votes-2026-02')).toBe(false); + expect(supportsNoticeComments('8dd0bf23-5b48-46b0-8ba6-564dc017fd3f')).toBe(true); + }); + + it('does not create a Supabase client for a static notice read or write', async () => { + await expect(getNoticeComments('event-signup-double-votes-2026-02')).resolves.toEqual([]); + await expect(createNoticeComment({ + announcement_id: 'event-signup-double-votes-2026-02', + author_name: 'test', + password: 'password', + content: 'test comment', + })).resolves.toBeNull(); + + expect(createClient).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/api/notice-comments.ts b/src/lib/api/notice-comments.ts index c18edeac..65702399 100644 --- a/src/lib/api/notice-comments.ts +++ b/src/lib/api/notice-comments.ts @@ -16,6 +16,11 @@ function sanitizePassword(pw: string): string { /** UUID v4 형식 검사 (이벤트 공지 등 비-UUID ID를 Supabase 쿼리에서 제외) */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** 댓글 테이블과 연결할 수 있는 DB 공지사항인지 확인한다. */ +export function supportsNoticeComments(announcementId: string): boolean { + return UUID_RE.test(announcementId); +} + /** 공지사항 댓글 타입 */ export interface NoticeComment { id: string; @@ -53,6 +58,8 @@ export async function getNoticeComments( limit: number = 50, offset: number = 0, ): Promise { + if (!supportsNoticeComments(announcementId)) return []; + const supabase = createClient(); const { data, error } = await supabase @@ -80,6 +87,8 @@ export async function getNoticeComments( export async function createNoticeComment( req: CreateNoticeCommentRequest, ): Promise { + if (!supportsNoticeComments(req.announcement_id)) return null; + const supabase = createClient(); const { data, error } = await supabase @@ -131,7 +140,7 @@ export async function deleteNoticeComment( */ export async function getNoticeCommentCount(announcementId: string): Promise { // 비-UUID ID(이벤트 공지 등)는 DB 조회 없이 0 반환 - if (!UUID_RE.test(announcementId)) return 0; + if (!supportsNoticeComments(announcementId)) return 0; const supabase = createClient(); const { data, error } = await supabase @@ -153,7 +162,7 @@ export async function getNoticeCommentCounts( ): Promise> { if (announcementIds.length === 0) return {}; // 이벤트 공지 등 비-UUID ID를 Supabase 쿼리에서 제외 (UUID 컬럼에 문자열 전달 시 에러 방지) - const uuidIds = announcementIds.filter((id) => UUID_RE.test(id)); + const uuidIds = announcementIds.filter(supportsNoticeComments); const counts: Record = {}; // UUID가 아닌 ID는 댓글 0으로 처리 if (uuidIds.length === 0) return counts; diff --git a/src/lib/notice-content.test.ts b/src/lib/notice-content.test.ts new file mode 100644 index 00000000..8daa0e1f --- /dev/null +++ b/src/lib/notice-content.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { getNoticePlainText } from './notice-content'; + +describe('getNoticePlainText', () => { + it('keeps readable text while discarding executable or embedded markup', () => { + expect( + getNoticePlainText( + '

MEARROW & update

Read more

', + ), + ).toBe('MEARROW & update Read more'); + }); +}); diff --git a/src/lib/notice-content.ts b/src/lib/notice-content.ts new file mode 100644 index 00000000..867242e8 --- /dev/null +++ b/src/lib/notice-content.ts @@ -0,0 +1,21 @@ +/** + * Produce a safe, readable server-rendered fallback from rich notice HTML. + * + * Full rich HTML is still sanitized in the browser before it is injected with + * dangerouslySetInnerHTML. This helper deliberately returns plain text so a + * static document can include meaningful notice content without evaluating or + * trusting unsanitized markup on the server/Worker bundle. + */ +export function getNoticePlainText(content: string): string { + return content + .replace(/<(script|style|iframe|object|embed|form|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ') + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'|'/gi, "'") + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/lib/server/home-feed.ts b/src/lib/server/home-feed.ts new file mode 100644 index 00000000..977b5660 --- /dev/null +++ b/src/lib/server/home-feed.ts @@ -0,0 +1,105 @@ +import { createServerClient } from '@/lib/supabase/server'; +import type { + ProfileMediaType, + PublicProfileAuthor, + PublicProfileFeedPage, + PublicProfilePostRecord, +} from '@/lib/api/profile-content'; + +/** Keep this aligned with the client feed's first-page size. */ +const INITIAL_HOME_FEED_PAGE_SIZE = 4; +const PROFILE_IMAGE_BUCKET = 'profile-images'; +const PROFILE_SHORTS_BUCKET = 'profile-shorts'; + +interface PublicProfilePostRow { + id: string; + user_id: string; + media_type: ProfileMediaType; + storage_path: string; + caption: string | null; + created_at: string; +} + +interface PublicProfileAuthorRow extends PublicProfileAuthor { + id: string; +} + +function getPublicMediaUrl( + mediaType: ProfileMediaType, + storagePath: string, + getUrl: (bucket: string, path: string) => string, +): string { + const bucket = mediaType === 'image' ? PROFILE_IMAGE_BUCKET : PROFILE_SHORTS_BUCKET; + return getUrl(bucket, storagePath); +} + +/** + * Reads the first public feed page on the server so the home route has real + * post text and links in its initial HTML. The browser still refreshes this + * page after hydration and owns the remaining infinite-scroll requests. + */ +export async function getInitialHomeFeed(): Promise { + const supabase = createServerClient(); + if (!supabase) return null; + + try { + const { data, error } = await supabase + .from('profile_posts') + .select('id, user_id, media_type, storage_path, caption, created_at') + .eq('status', 'published') + .eq('is_public', true) + .order('created_at', { ascending: false }) + .order('id', { ascending: false }) + .limit(INITIAL_HOME_FEED_PAGE_SIZE + 1); + + if (error) { + console.warn('[home-feed] public feed server preload is unavailable'); + return null; + } + + const records = (data || []) as PublicProfilePostRow[]; + const page = records.slice(0, INITIAL_HOME_FEED_PAGE_SIZE); + const authorIds = [...new Set(page.map((record) => record.user_id))]; + const authorsById = new Map(); + + if (authorIds.length > 0) { + const { data: authors, error: authorsError } = await supabase + .from('user_profiles') + .select('id, username, avatar_url') + .in('id', authorIds); + + if (authorsError) { + console.warn('[home-feed] public author server preload is unavailable'); + return null; + } + + for (const author of (authors || []) as PublicProfileAuthorRow[]) { + authorsById.set(author.id, author); + } + } + + const getUrl = (bucket: string, storagePath: string) => + supabase.storage.from(bucket).getPublicUrl(storagePath).data.publicUrl; + + const posts: PublicProfilePostRecord[] = page.map((record) => ({ + id: record.id, + media_type: record.media_type, + caption: record.caption, + created_at: record.created_at, + media_url: getPublicMediaUrl(record.media_type, record.storage_path, getUrl), + author: authorsById.get(record.user_id) ?? null, + })); + const last = page[page.length - 1]; + + return { + posts, + nextCursor: + records.length > INITIAL_HOME_FEED_PAGE_SIZE && last + ? { created_at: last.created_at, id: last.id } + : null, + }; + } catch { + console.warn('[home-feed] public feed server preload failed'); + return null; + } +} diff --git a/src/lib/server/public-page-data.ts b/src/lib/server/public-page-data.ts new file mode 100644 index 00000000..8acdbe9f --- /dev/null +++ b/src/lib/server/public-page-data.ts @@ -0,0 +1,33 @@ +import type { CompaniesResponse } from '@/types/api'; +import type { HallOfFameData } from '@/types/hall-of-fame'; +import { getCompanies } from '@/lib/api/companies'; +import { getHallOfFame } from '@/lib/api/hall-of-fame'; + +/** + * Public ranking data embedded in the statically generated HTML. + * + * The client keeps its existing SWR refresh afterwards, but a crawler and a + * first-time visitor receive the current ranking cards in the initial document. + */ +export async function getInitialLeagueData(): Promise { + try { + const { source, ...data } = await getCompanies(); + return source === 'db' ? data : null; + } catch { + return null; + } +} + +/** + * Hall-of-fame records for the statically generated first render. + * + * `getHallOfFame` already returns an explicit empty-record shape if no season + * has been decided, which is preferable to emitting a loading-only document. + */ +export async function getInitialHallOfFameData(): Promise { + try { + return await getHallOfFame(); + } catch { + return null; + } +}