;
+ required?: boolean;
+ error?: string;
+}) {
+ return (
+
+ );
+}
+
+function LabelText({ label, required = false }: { label: string; required?: boolean }) {
+ return (
+
+ {label}
+ {required ? * : null}
+
+ );
+}
diff --git a/src/features/community/ui/CommunityFeedCard.tsx b/src/features/community/ui/CommunityFeedCard.tsx
new file mode 100644
index 0000000..8ee72ec
--- /dev/null
+++ b/src/features/community/ui/CommunityFeedCard.tsx
@@ -0,0 +1,302 @@
+import { Link } from 'react-router-dom';
+
+interface CommunityFeedCardProps {
+ title: string;
+ preview?: string | null;
+ imageUrl?: string | null;
+ nickname?: string | null;
+ likes: number;
+ comments: number;
+ views?: number;
+ createdAt?: string | null;
+ to?: string;
+ badge?: string;
+ variant?: 'card' | 'list';
+}
+
+export function CommunityFeedCard({
+ title,
+ preview,
+ imageUrl,
+ nickname,
+ likes,
+ comments,
+ views,
+ createdAt,
+ to,
+ badge,
+ variant = 'card',
+}: CommunityFeedCardProps) {
+ const formattedDate = createdAt ? formatRelativeDate(createdAt) : null;
+
+ const content =
+ variant === 'list' ? (
+
+ ) : (
+
+ );
+
+ if (to) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return {content}
;
+}
+
+function CardFeedItem({
+ title,
+ preview,
+ imageUrl,
+ nickname,
+ likes,
+ comments,
+ views,
+ formattedDate,
+ badge,
+}: {
+ title: string;
+ preview?: string | null;
+ imageUrl?: string | null;
+ nickname?: string | null;
+ likes: number;
+ comments: number;
+ views?: number;
+ formattedDate: string | null;
+ badge?: string;
+}) {
+ return (
+
+
+ {imageUrl ? (
+

+ ) : (
+
+
+
+ )}
+
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+
+
+
+
{title}
+ {preview ?
{preview}
: null}
+
+
+ {nickname ?
{nickname} :
}
+
+
+
+ {views !== undefined ? : null}
+
+
+
+ {formattedDate ?
{formattedDate}
: null}
+
+
+ );
+}
+
+function ListFeedItem({
+ title,
+ preview,
+ imageUrl,
+ nickname,
+ likes,
+ comments,
+ views,
+ formattedDate,
+ badge,
+}: {
+ title: string;
+ preview?: string | null;
+ imageUrl?: string | null;
+ nickname?: string | null;
+ likes: number;
+ comments: number;
+ views?: number;
+ formattedDate: string | null;
+ badge?: string;
+}) {
+ return (
+
+
+
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+
+
+ {title}
+
+
+ {preview ? (
+
{preview}
+ ) : null}
+
+
+
+
+ {likes}
+
+
+
+ {comments}
+
+ {formattedDate ? : null}
+ {formattedDate ? {formattedDate} : null}
+ {nickname ? : null}
+ {nickname ? {nickname} : null}
+ {views !== undefined ? : null}
+ {views !== undefined ? 조회 {views} : null}
+
+
+
+ {imageUrl ? (
+
+

+
+ ) : null}
+
+
+
+
+ );
+}
+
+function MetaDivider() {
+ return |;
+}
+
+function SocialStat({ kind, value }: { kind: 'like' | 'comment' | 'view'; value: number }) {
+ return (
+
+ {kind === 'like' ? (
+
+ ) : kind === 'comment' ? (
+
+ ) : (
+
+ )}
+ {value}
+
+ );
+}
+
+function formatRelativeDate(iso: string): string {
+ const now = Date.now();
+ const date = new Date(iso).getTime();
+
+ if (Number.isNaN(date)) {
+ return '';
+ }
+
+ const diff = now - date;
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+
+ if (minutes < 1) return '방금 전';
+ if (minutes < 60) return `${minutes}분 전`;
+ if (hours < 24) return `${hours}시간 전`;
+ if (days < 7) return `${days}일 전`;
+
+ return new Date(iso).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
+}
+
+function ImagePlaceholderIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function HeartIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function ThumbsUpIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function CommentIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function CommentOutlineIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function EyeIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/src/features/community/ui/CommunityLayout.tsx b/src/features/community/ui/CommunityLayout.tsx
new file mode 100644
index 0000000..488e756
--- /dev/null
+++ b/src/features/community/ui/CommunityLayout.tsx
@@ -0,0 +1,31 @@
+import type { ReactNode } from 'react';
+
+interface CommunityLayoutProps {
+ eyebrow?: string;
+ title?: string;
+ description?: string;
+ sidebar?: ReactNode;
+ content: ReactNode;
+}
+
+export function CommunityLayout({ eyebrow, title, description, sidebar, content }: CommunityLayoutProps) {
+ const hasHeader = Boolean(eyebrow || title || description);
+ const contentGridClass = sidebar ? 'lg:grid-cols-[minmax(0,1fr)_240px]' : 'lg:grid-cols-1';
+
+ return (
+
+ {hasHeader ? (
+
+ {eyebrow ?
{eyebrow}
: null}
+ {title ?
{title}
: null}
+ {description ?
{description}
: null}
+
+ ) : null}
+
+
+
{content}
+ {sidebar ?
: null}
+
+
+ );
+}
diff --git a/src/features/community/ui/CommunityProfileCard.tsx b/src/features/community/ui/CommunityProfileCard.tsx
new file mode 100644
index 0000000..87d14d6
--- /dev/null
+++ b/src/features/community/ui/CommunityProfileCard.tsx
@@ -0,0 +1,55 @@
+import { Link } from 'react-router-dom';
+
+import profileDefaultIllustration from '@/features/auth/assets/profile-default.svg';
+
+interface CommunityProfileCardProps {
+ profileUrl: string | null;
+ nickname: string | null;
+}
+
+const PROFILE_COPY = {
+ fallbackName: '도도 친구',
+ description: '이야기를 글로 기록해보세요.',
+ write: '글쓰기',
+ myActivity: '내 활동',
+};
+
+export function CommunityProfileCard({ profileUrl, nickname }: CommunityProfileCardProps) {
+ const resolvedName = nickname?.trim() || PROFILE_COPY.fallbackName;
+
+ return (
+
+
+
+
)
{
+ event.currentTarget.src = profileDefaultIllustration;
+ }}
+ />
+
+
+
{resolvedName}
+
{PROFILE_COPY.description}
+
+
+
+
+
+ {PROFILE_COPY.write}
+
+
+ {PROFILE_COPY.myActivity}
+
+
+
+ );
+}
diff --git a/src/features/community/ui/CommunitySidebarPanel.tsx b/src/features/community/ui/CommunitySidebarPanel.tsx
new file mode 100644
index 0000000..084223f
--- /dev/null
+++ b/src/features/community/ui/CommunitySidebarPanel.tsx
@@ -0,0 +1,14 @@
+import { CommunityProfileCard } from './CommunityProfileCard';
+
+interface CommunitySidebarPanelProps {
+ profileUrl: string | null;
+ nickname: string | null;
+}
+
+export function CommunitySidebarPanel({ profileUrl, nickname }: CommunitySidebarPanelProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/features/community/ui/DeleteBoardDialog.tsx b/src/features/community/ui/DeleteBoardDialog.tsx
new file mode 100644
index 0000000..80b4617
--- /dev/null
+++ b/src/features/community/ui/DeleteBoardDialog.tsx
@@ -0,0 +1,46 @@
+import { Modal } from '@/shared/ui';
+
+interface DeleteBoardDialogProps {
+ open: boolean;
+ isPending: boolean;
+ errorMessage: string;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export function DeleteBoardDialog({ open, isPending, errorMessage, onClose, onConfirm }: DeleteBoardDialogProps) {
+ return (
+
+
+
DELETE POST
+
+ {'이 게시글을 삭제할까요?'}
+
+
+ {'삭제 후에는 다시 복구할 수 없어요. 정말 삭제할지 한 번 더 확인해주세요.'}
+
+
+ {errorMessage ?
{errorMessage}
: null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/community/BoardCreatePage.tsx b/src/pages/community/BoardCreatePage.tsx
new file mode 100644
index 0000000..7744ad5
--- /dev/null
+++ b/src/pages/community/BoardCreatePage.tsx
@@ -0,0 +1,99 @@
+import type { ReactNode } from 'react';
+
+import { BoardEditorForm, useBoardEditorForm } from '@/features/community';
+import { LoadingSpinner } from '@/shared/ui';
+
+export function BoardCreatePage() {
+ const editor = useBoardEditorForm({ mode: 'create' });
+
+ if (editor.isInitialLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (editor.isInitialLoadError) {
+ return (
+
+ void editor.retryInitialLoad()}
+ onReset={() => editor.resetStoredDraft()}
+ />
+
+ );
+ }
+
+ return (
+
+ void editor.handleTempSave()}
+ onSubmit={editor.handleSubmit}
+ mode="create"
+ />
+
+ );
+}
+
+function PageShell({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function LoadingState() {
+ return (
+
+
+
{'임시 저장한 게시글을 확인하고 있어요.'}
+
+ );
+}
+
+function ErrorState({
+ title,
+ description,
+ onRetry,
+ onReset,
+}: {
+ title: string;
+ description: string;
+ onRetry: () => void;
+ onReset: () => void;
+}) {
+ return (
+
+
{title}
+
{description}
+
+
+
+
+
+ );
+}
diff --git a/src/pages/community/BoardDetailPage.tsx b/src/pages/community/BoardDetailPage.tsx
new file mode 100644
index 0000000..a0dda2c
--- /dev/null
+++ b/src/pages/community/BoardDetailPage.tsx
@@ -0,0 +1,155 @@
+import type { ReactNode } from 'react';
+import { useMemo, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { useCurrentUser } from '@/features/auth';
+import {
+ BOARD_DETAIL_STATUS_MESSAGES,
+ BOARD_MUTATION_STATUS_MESSAGES,
+ BoardDetailContent,
+ CommunityLayout,
+ DeleteBoardDialog,
+ useBoardDetail,
+ useDeleteBoard,
+} from '@/features/community';
+import { getApiErrorMessage } from '@/shared/lib/api/errorMessage';
+import { LoadingSpinner } from '@/shared/ui';
+
+const DETAIL_PAGE_COPY = {
+ invalidTitle: '올바르지 않은 게시글 경로예요.',
+ invalidDescription: '게시글 주소를 다시 확인한 뒤 재시도해주세요.',
+ loading: '게시글을 불러오는 중...',
+ loadFailedTitle: '게시글을 불러오지 못했어요.',
+ loadFailedFallback: '게시글 조회에 실패했어요. 잠시 후 다시 시도해주세요.',
+ retry: '다시 시도',
+ deleteFailedFallback: '게시글을 삭제하지 못했어요. 잠시 후 다시 시도해주세요.',
+};
+
+function parseBoardId(value: string | undefined): number | null {
+ if (!value) return null;
+
+ const parsed = Number(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function BoardDetailPage() {
+ const navigate = useNavigate();
+ const params = useParams();
+ const boardId = useMemo(() => parseBoardId(params.boardId), [params.boardId]);
+ const { data: board, isLoading, isError, error, refetch } = useBoardDetail(boardId);
+ const { nickname, profileUrl } = useCurrentUser();
+ const { mutateAsync: deleteBoard, isPending: isDeleting } = useDeleteBoard();
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deleteError, setDeleteError] = useState('');
+
+ const canManage = Boolean(board && nickname && board.nickname.trim() === nickname.trim());
+
+ const handleDelete = async () => {
+ if (boardId === null) return;
+
+ setDeleteError('');
+
+ try {
+ await deleteBoard({ boardId });
+ void navigate('/community');
+ } catch (deleteActionError) {
+ setDeleteError(
+ getApiErrorMessage(deleteActionError, DETAIL_PAGE_COPY.deleteFailedFallback, BOARD_MUTATION_STATUS_MESSAGES),
+ );
+ }
+ };
+
+ if (boardId === null) {
+ return (
+
+
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (isError || !board) {
+ return (
+
+ void refetch()}
+ />
+
+ );
+ }
+
+ return (
+
+ setDeleteDialogOpen(true)}
+ />
+ }
+ />
+
+ {
+ if (isDeleting) return;
+ setDeleteDialogOpen(false);
+ setDeleteError('');
+ }}
+ onConfirm={() => void handleDelete()}
+ />
+
+ );
+}
+
+function PageShell({ children }: { children: ReactNode }) {
+ return <>{children}>;
+}
+
+function InvalidBoardState() {
+ return (
+
+
{DETAIL_PAGE_COPY.invalidTitle}
+
{DETAIL_PAGE_COPY.invalidDescription}
+
+ );
+}
+
+function LoadingState() {
+ return (
+
+
+
{DETAIL_PAGE_COPY.loading}
+
+ );
+}
+
+function ErrorState({ description, onRetry }: { description: string; onRetry: () => void }) {
+ return (
+
+
{DETAIL_PAGE_COPY.loadFailedTitle}
+
{description}
+
+
+
+
+ );
+}
diff --git a/src/pages/community/BoardEditPage.tsx b/src/pages/community/BoardEditPage.tsx
new file mode 100644
index 0000000..f8b6f8b
--- /dev/null
+++ b/src/pages/community/BoardEditPage.tsx
@@ -0,0 +1,124 @@
+import type { ReactNode } from 'react';
+import { useMemo } from 'react';
+import { Link, useParams } from 'react-router-dom';
+
+import { BoardEditorForm, useBoardEditorForm } from '@/features/community';
+import { LoadingSpinner } from '@/shared/ui';
+
+function parseBoardId(value: string | undefined): number | null {
+ if (!value) {
+ return null;
+ }
+
+ const parsed = Number(value);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function BoardEditPage() {
+ const params = useParams();
+ const boardId = useMemo(() => parseBoardId(params.boardId), [params.boardId]);
+ const editor = useBoardEditorForm({ mode: 'edit', boardId });
+
+ if (boardId === null) {
+ return (
+
+
+
+ );
+ }
+
+ if (editor.isInitialLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (editor.isInitialLoadError) {
+ return (
+
+ void editor.retryInitialLoad()}
+ />
+
+ );
+ }
+
+ return (
+
+ void editor.handleTempSave()}
+ onSubmit={editor.handleSubmit}
+ mode="edit"
+ cancelTo={`/community/${boardId}`}
+ />
+
+ );
+}
+
+function PageShell({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function InvalidBoardState() {
+ return (
+
+
{'잘못된 게시글 경로예요.'}
+
{'수정할 게시글 정보를 다시 확인해주세요.'}
+
+ {'커뮤니티로 이동'}
+
+
+ );
+}
+
+function LoadingState() {
+ return (
+
+
+
{'게시글 정보를 불러오고 있어요.'}
+
+ );
+}
+
+function ErrorState({ boardId, description, onRetry }: { boardId: number; description: string; onRetry: () => void }) {
+ return (
+
+
{'게시글 수정 준비에 실패했어요.'}
+
{description}
+
+
+
+ {'상세로 돌아가기'}
+
+
+
+ );
+}
diff --git a/src/pages/community/CommunityMyActivityPage.tsx b/src/pages/community/CommunityMyActivityPage.tsx
new file mode 100644
index 0000000..7fb0540
--- /dev/null
+++ b/src/pages/community/CommunityMyActivityPage.tsx
@@ -0,0 +1,148 @@
+import type { ReactNode } from 'react';
+import { Link, useSearchParams } from 'react-router-dom';
+
+import { useCurrentUser } from '@/features/auth';
+import { CommunityLayout, CommunitySidebarPanel } from '@/features/community';
+
+type ActivityTab = 'posts' | 'comments';
+
+const ACTIVITY_COPY = {
+ heading: '내 활동',
+ description: '내가 남긴 게시글과 댓글 활동을 한곳에서 확인해보세요.',
+ myPosts: '내 게시글',
+ myComments: '내 댓글',
+ emptyPostsTitle: '아직 작성한 게시글이 없어요.',
+ emptyPostsDescription: '첫 번째 반려생활 이야기를 커뮤니티에 공유해보세요.',
+ emptyCommentsTitle: '아직 작성한 댓글이 없어요.',
+ emptyCommentsDescription: '다른 친구들의 게시글에 댓글을 남기며 소통해보세요.',
+ writePost: '글쓰기',
+ browseCommunity: '커뮤니티 둘러보기',
+};
+
+function getTabFromSearch(value: string | null): ActivityTab {
+ if (value === 'comments') return 'comments';
+ return 'posts';
+}
+
+export function CommunityMyActivityPage() {
+ const [searchParams] = useSearchParams();
+ const activeTab = getTabFromSearch(searchParams.get('tab'));
+ const { profileUrl, nickname } = useCurrentUser();
+
+ return (
+ }
+ content={
+
+
+
+
+ {ACTIVITY_COPY.myPosts}
+
+
+ {ACTIVITY_COPY.myComments}
+
+
+
+
{activeTab === 'posts' ? : }
+
+
+ }
+ />
+ );
+}
+
+function TabButton({ to, isActive, children }: { to: string; isActive: boolean; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function MyPostsTab() {
+ return (
+ }
+ message={ACTIVITY_COPY.emptyPostsTitle}
+ description={ACTIVITY_COPY.emptyPostsDescription}
+ action={
+
+ {ACTIVITY_COPY.writePost}
+
+ }
+ />
+ );
+}
+
+function MyCommentsTab() {
+ return (
+ }
+ message={ACTIVITY_COPY.emptyCommentsTitle}
+ description={ACTIVITY_COPY.emptyCommentsDescription}
+ action={
+
+ {ACTIVITY_COPY.browseCommunity}
+
+ }
+ />
+ );
+}
+
+function EmptyState({
+ icon,
+ message,
+ description,
+ action,
+}: {
+ icon: ReactNode;
+ message: string;
+ description: string;
+ action?: ReactNode;
+}) {
+ return (
+
+ {icon}
+
{message}
+
{description}
+ {action ?
{action}
: null}
+
+ );
+}
+
+function PostIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function CommentIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/src/pages/community/CommunityPage.tsx b/src/pages/community/CommunityPage.tsx
index 5aa0a4f..935ce95 100644
--- a/src/pages/community/CommunityPage.tsx
+++ b/src/pages/community/CommunityPage.tsx
@@ -1,3 +1,231 @@
+import { useMemo } from 'react';
+import { Link } from 'react-router-dom';
+
+import { useCurrentUser } from '@/features/auth';
+import {
+ type BoardListItem,
+ CommunityFeedCard,
+ CommunityLayout,
+ CommunitySidebarPanel,
+ useBoardList,
+} from '@/features/community';
+import { LoadingSpinner } from '@/shared/ui';
+
+const COMMUNITY_COPY = {
+ popularTitle: '인기 게시물',
+ popularDescription: '지금 커뮤니티에서 반응이 좋은 이야기를 먼저 만나보세요.',
+ recentTitle: '최근 게시물',
+ recentDescription: '반려생활 속 소소한 기록부터 유용한 팁까지 한눈에 둘러보세요.',
+ loadMore: '더 보기',
+ loadingMore: '게시글을 불러오는 중...',
+ loadErrorTitle: '게시글을 불러오지 못했어요.',
+ loadErrorDescription: '잠시 후 다시 시도해주세요.',
+ emptyPopular: '아직 인기 게시물이 없어요.',
+ emptyRecentTitle: '아직 등록된 게시글이 없어요.',
+ emptyRecentDescription: '첫 번째 반려생활 이야기를 남겨보세요.',
+ writePost: '글쓰기',
+};
+
export function CommunityPage() {
- return 커뮤니티 (뼈대)
;
+ const { profileUrl, nickname } = useCurrentUser();
+ const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useBoardList();
+
+ const boards = useMemo(() => data?.pages.flatMap((page) => page.boards) ?? [], [data]);
+ const popularBoards = useMemo(() => [...boards].sort((a, b) => b.likeCount - a.likeCount).slice(0, 3), [boards]);
+
+ return (
+ }
+ content={
+
+
+
void fetchNextPage()}
+ />
+
+ }
+ />
+ );
+}
+
+function PopularSection({
+ boards,
+ isLoading,
+ isError,
+}: {
+ boards: BoardListItem[];
+ isLoading: boolean;
+ isError: boolean;
+}) {
+ return (
+
+
+
+
POPULAR PICKS
+
+ {COMMUNITY_COPY.popularTitle}
+
+
{COMMUNITY_COPY.popularDescription}
+
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ ) : isError ? (
+
+ ) : boards.length === 0 ? (
+
+ ) : (
+
+ {boards.map((board) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function RecentSection({
+ boards,
+ isLoading,
+ isError,
+ hasNextPage,
+ isFetchingNextPage,
+ onLoadMore,
+}: {
+ boards: BoardListItem[];
+ isLoading: boolean;
+ isError: boolean;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+ onLoadMore: () => void;
+}) {
+ return (
+
+
+
COMMUNITY BOARD
+
+ {COMMUNITY_COPY.recentTitle}
+
+
{COMMUNITY_COPY.recentDescription}
+
+
+ {isLoading ? (
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ ))}
+
+ ) : isError ? (
+
+ ) : boards.length === 0 ? (
+
+ ) : (
+
+
+ {boards.map((board) => (
+
+ ))}
+
+
+ {hasNextPage ? (
+
+
+
+ ) : null}
+
+ )}
+
+ );
+}
+
+function ErrorState() {
+ return (
+
+
{COMMUNITY_COPY.loadErrorTitle}
+
{COMMUNITY_COPY.loadErrorDescription}
+
+ );
+}
+
+function EmptyPopularState() {
+ return (
+
+
{COMMUNITY_COPY.emptyPopular}
+
+ );
+}
+
+function EmptyRecentState() {
+ return (
+
+
{COMMUNITY_COPY.emptyRecentTitle}
+
{COMMUNITY_COPY.emptyRecentDescription}
+
+ {COMMUNITY_COPY.writePost}
+
+
+ );
+}
+
+function ListRowSkeleton() {
+ return (
+
+ );
}
diff --git a/src/shared/lib/react-query/queryKey.ts b/src/shared/lib/react-query/queryKey.ts
index 44d7cb0..bcf1133 100644
--- a/src/shared/lib/react-query/queryKey.ts
+++ b/src/shared/lib/react-query/queryKey.ts
@@ -1,4 +1,11 @@
export const queryKeys = {
+ boards: {
+ list: (params?: { page?: number; size?: number }) =>
+ ['boards', 'list', params?.page ?? 0, params?.size ?? 12] as const,
+ listInfinite: () => ['boards', 'list-infinite'] as const,
+ detail: (boardId: number) => ['boards', boardId, 'detail'] as const,
+ tempSaved: (sessionKey: string) => ['boards', 'temp-save', sessionKey] as const,
+ },
pets: {
list: (params?: { page?: number; size?: number; sort?: string }) =>
['pets', 'list', params?.page ?? 0, params?.size ?? 10, params?.sort ?? 'registrationCreatedAt,desc'] as const,