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
14 changes: 14 additions & 0 deletions scripts/deploy-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/')) {
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 13 additions & 3 deletions src/app/[locale]/HomeFeedClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<string, ProfilePostSocialRecord>>({});
const requestedSocialIdsRef = useRef(new Set<string>());
const feedShellRef = useRef<HTMLElement | null>(null);
Expand Down
15 changes: 9 additions & 6 deletions src/app/[locale]/following/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
11 changes: 8 additions & 3 deletions src/app/[locale]/hall-of-fame/HallOfFameClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<number | null>(null);

// 로딩 상태 - 스켈레톤 UI 표시
if (isLoading) {
if (isLoading && !data) {
return (
<div className={styles.container}>
<LoadingSkeleton />
Expand Down
4 changes: 3 additions & 1 deletion src/app/[locale]/hall-of-fame/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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',
Expand All @@ -71,7 +73,7 @@ export default async function HallOfFamePage({ params }: HallOfFamePageProps) {
/>

{/* CSR 챔피언 데이터 영역 */}
<HallOfFameClient />
<HallOfFameClient initialData={initialData} />

{/* 명예의 전당 하단 광고 */}
<AdBanner adSlot={AD_SLOTS.HOF_BOTTOM} adFormat="leaderboard" />
Expand Down
23 changes: 13 additions & 10 deletions src/app/[locale]/notice/NoticeClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,29 @@ import styles from './page.module.scss';

interface NoticeClientProps {
locale: string;
initialNotices?: AnnouncementListItem[];
}

/**
* 공지사항 목록 클라이언트 컴포넌트
* 목록 조회 + 카테고리 필터링
* 상세보기는 /notice/[id] 페이지로 이동
*/
export default function NoticeClient({ locale }: NoticeClientProps) {
export default function NoticeClient({ locale, initialNotices }: NoticeClientProps) {
const t = useTranslations('Notice');

const [notices, setNotices] = useState<AnnouncementListItem[]>([]);
const [notices, setNotices] = useState<AnnouncementListItem[]>(initialNotices ?? []);
const [commentCounts, setCommentCounts] = useState<Record<string, number>>({});
const [selectedCategory, setSelectedCategory] = useState<AnnouncementCategory | undefined>(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);
Expand All @@ -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) => {
Expand All @@ -67,7 +70,7 @@ export default function NoticeClient({ locale }: NoticeClientProps) {
/>

{/* 로딩 상태 */}
{loading && (
{loading && notices.length === 0 && (
<div className={styles.statusMessage}>
<p>{t('loading')}</p>
</div>
Expand All @@ -77,14 +80,14 @@ export default function NoticeClient({ locale }: NoticeClientProps) {
{error && !loading && (
<div className={styles.statusMessage}>
<p>{t('error')}</p>
<button type="button" className={styles.retryButton} onClick={fetchNotices}>
<button type="button" className={styles.retryButton} onClick={() => void fetchNotices()}>
{t('retry')}
</button>
</div>
)}

{/* 공지사항 목록 */}
{!loading && !error && (
{!error && (
<>
{notices.length === 0 ? (
<div className={styles.statusMessage}>
Expand Down
62 changes: 44 additions & 18 deletions src/app/[locale]/notice/[id]/NoticeDetailClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Announcement | null>(null);
const [loading, setLoading] = useState(true);
const [notice, setNotice] = useState<Announcement | null>(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) => {
Expand Down Expand Up @@ -90,14 +110,14 @@ export default function NoticeDetailClient({ locale, noticeId }: NoticeDetailCli
</Link>

{/* 로딩 상태 */}
{loading && (
{loading && !notice && (
<div className={styles.statusMessage}>
<p>{t('loading')}</p>
</div>
)}

{/* 에러 상태 */}
{error && !loading && (
{error && !notice && (
<div className={styles.statusMessage}>
<p>{t('error')}</p>
<Link href={`/${locale}/notice`} className={styles.retryButton}>
Expand All @@ -107,7 +127,7 @@ export default function NoticeDetailClient({ locale, noticeId }: NoticeDetailCli
)}

{/* 상세 내용 */}
{notice && !loading && (
{notice && (
<>
{/* 공지 헤더 */}
<div className={styles.detailHeader}>
Expand All @@ -123,12 +143,18 @@ export default function NoticeDetailClient({ locale, noticeId }: NoticeDetailCli

{/* 본문 (Quill HTML 콘텐츠) */}
{/* XSS 방지: DOMPurify로 HTML sanitize 처리 */}
<div
className={`${styles.detailContent} ql-editor`}
dangerouslySetInnerHTML={{
__html: notice.content,
}}
/>
{isInitialContent ? (
<div className={`${styles.detailContent} ql-editor`}>
<p>{getNoticePlainText(notice.content)}</p>
</div>
) : (
<div
className={`${styles.detailContent} ql-editor`}
dangerouslySetInnerHTML={{
__html: notice.content,
}}
/>
)}

{/* JSON-LD 구조화 데이터 (Article 스키마) */}
<JsonLd
Expand Down
4 changes: 3 additions & 1 deletion src/app/[locale]/notice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getAnnouncementAvailableLocales,
getAnnouncementSeoLocale,
getPublishedAnnouncementIds,
getServerAnnouncementById,
} from '@/lib/api/announcements';
import NoticeDetailClient from './NoticeDetailClient';

Expand Down Expand Up @@ -57,6 +58,7 @@ export async function generateMetadata({ params }: NoticeDetailPageProps): Promi
export default async function NoticeDetailPage({ params }: NoticeDetailPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
const initialNotice = await getServerAnnouncementById(id, locale);

return <NoticeDetailClient locale={locale} noticeId={id} />;
return <NoticeDetailClient locale={locale} noticeId={id} initialNotice={initialNotice} />;
}
4 changes: 3 additions & 1 deletion src/app/[locale]/notice/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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',
Expand All @@ -60,7 +62,7 @@ export default async function NoticePage({ params }: { params: Promise<{ locale:
/>

{/* CSR 공지사항 목록 */}
<NoticeClient locale={locale} />
<NoticeClient locale={locale} initialNotices={initialNotices} />

{/* 공지사항 목록 하단 광고 */}
<AdBanner adSlot={AD_SLOTS.NOTICE_LIST_BOTTOM} adFormat="leaderboard" />
Expand Down
Loading
Loading