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
5 changes: 5 additions & 0 deletions apps/web/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
RedirectAuthenticated,
} from '@/features/auth/session/AuthRouteGuards';
import { MapPage } from '@/features/map/MapPage';
import { PlacePostsPage } from '@/features/map/PlacePostsPage';
import { ContactPage } from '@/features/my/ContactPage';
import { MyPage } from '@/features/my/MyPage';
import { PrivacyPolicyPage } from '@/features/my/policy/PrivacyPolicyPage';
Expand Down Expand Up @@ -98,6 +99,10 @@ export const router = createBrowserRouter([
path: 'post/:postId',
element: <PostDetailPage />,
},
{
path: 'place/:placeId/posts',
element: <PlacePostsPage />,
},
{
path: 'my',
element: <MyPage />,
Expand Down
17 changes: 3 additions & 14 deletions apps/web/src/features/archive/ArchiveDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { useBottomMenuVisibility } from '@/app/bottom-menu-visibility';
Expand All @@ -7,6 +7,7 @@ import { useIsAuthenticated } from '@/features/auth/session/AuthSessionProvider'
import { PlaceCard } from '@/features/place';
import { ShareSheet } from '@/features/share/components/ShareSheet';
import { buildShareUrl } from '@/features/share/lib/shareUrl';
import { useInfiniteScrollSentinel } from '@/shared/lib/useInfiniteScrollSentinel';
import { cn } from '@/shared/lib/utils';
import { useToast } from '@/shared/toast';
import {
Expand Down Expand Up @@ -115,19 +116,7 @@ export function ArchiveDetailPage() {
};

// 그리드/목록 끝(sentinel)이 화면에 들어오면 활성 탭의 다음 페이지를 당긴다.
const activeQuery = activeTab === 'posts' ? postsQuery : placesQuery;
const { fetchNextPage, hasNextPage, isFetchingNextPage } = activeQuery;
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasNextPage || isFetchingNextPage) return;

const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) fetchNextPage();
});
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const sentinelRef = useInfiniteScrollSentinel(activeTab === 'posts' ? postsQuery : placesQuery);

if (isAuthenticated && isPending) return null;

Expand Down
43 changes: 43 additions & 0 deletions apps/web/src/features/map/PlacePostsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useNavigate, useParams } from 'react-router-dom';
import { useHideBottomMenu } from '@/app/bottom-menu-visibility';
import { PinnedHeaderLayout } from '@/app/layouts/PinnedHeaderLayout';
import { toDisplayPost } from '@/features/map/lib/placePost';
import { SavedPostCard } from '@/features/post';
import { usePostDetails } from '@/features/post/api/queries';
import { useInfiniteScrollSentinel } from '@/shared/lib/useInfiniteScrollSentinel';
import { BackButton, Header } from '@/shared/ui';
import { usePlacePosts } from './api/queries';

export function PlacePostsPage() {
const { placeId: placeIdParam } = useParams();
const placeId = placeIdParam ? Number(placeIdParam) : null;
const navigate = useNavigate();
useHideBottomMenu();

const postsQuery = usePlacePosts(placeId !== null && Number.isFinite(placeId) ? placeId : null);
const posts = postsQuery.data?.posts ?? [];
const postDetailQueries = usePostDetails(posts.map((post) => post.id));
const sentinelRef = useInfiniteScrollSentinel(postsQuery);

return (
<PinnedHeaderLayout header={<Header left={<BackButton />} title="저장된 게시물" />}>
{postsQuery.isError ? (
<p className="pt-10 text-center text-b2 text-gray-60">게시물을 불러오지 못했어요</p>
) : (
<div className="flex flex-col gap-1.5 bg-gray-10">
{posts.map((post, index) => (
<div key={post.id} className="bg-gray-0 px-4">
<SavedPostCard
title={null}
post={toDisplayPost(post, postDetailQueries[index]?.data)}
archives={postDetailQueries[index]?.data?.archives ?? []}
onArchiveClick={(archiveId) => navigate(`/archive/${archiveId}`)}
/>
</div>
))}
<div ref={sentinelRef} aria-hidden="true" className="h-1" />
</div>
)}
</PinnedHeaderLayout>
);
}
109 changes: 106 additions & 3 deletions apps/web/src/features/map/api/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { describe, expect, it } from 'vitest';
import type { SavedPlaceSearchPageResponse } from '@/shared/api';
import { toSavedPlaceSearchPage } from '.';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const endpoints = vi.hoisted(() => ({
getDetail: vi.fn(),
}));

vi.mock('@/shared/api', async (importOriginal) => ({
...(await importOriginal<typeof import('@/shared/api')>()),
...endpoints,
}));

import type { PlaceDetailResponse, SavedPlaceSearchPageResponse } from '@/shared/api';
import { fetchPlacePosts, toPlaceDetail, toSavedPlaceSearchPage } from '.';

const PAGE: SavedPlaceSearchPageResponse = {
groups: [{ id: 3, name: '성수 카페', color: 'MINT', matchedPlaceCount: 2 }],
Expand Down Expand Up @@ -87,3 +97,96 @@ describe('toSavedPlaceSearchPage', () => {
expect(page.items[0]?.region).toBeUndefined();
});
});

/** 서버 공통 envelope — `unwrapApiResponse` 가 이 모양을 기대한다. */
function ok(success: PlaceDetailResponse) {
return { resultType: 'SUCCESS' as const, error: null, success };
}

/** 장소 상세 응답 중 이 테스트가 보는 부분만 채운다(나머지는 매핑 대상이 아니다). */
function placeDetailResponse(posts: {
items: { postId: number; title?: string; savedAt: string }[];
hasNext: boolean;
totalElements: number;
}): PlaceDetailResponse {
return {
id: 9,
name: '아이소',
address: '서울 어딘가',
latitude: 37.5,
longitude: 127,
bookmarked: false,
photoUrls: [],
tags: [],
posts: {
page: 0,
size: 20,
totalPages: 1,
...posts,
items: posts.items.map((item) => ({ ...item, groups: [] })),
},
// 매핑 대상은 아니지만 응답 타입상 필수인 값들
externalPlaceId: 'kakao-9',
provider: 'kakao',
thumbnailParsingStatus: 'COMPLETED' as const,
};
}

describe('fetchPlacePosts', () => {
beforeEach(() => {
endpoints.getDetail.mockReset();
});

it('요청한 페이지 번호와 고정 페이지 크기로 장소 상세를 부른다', async () => {
endpoints.getDetail.mockResolvedValue(
ok(placeDetailResponse({ items: [], hasNext: false, totalElements: 0 })),
);

await fetchPlacePosts(9, 2);

expect(endpoints.getDetail).toHaveBeenCalledWith(
9,
{ page: 2, size: 20 },
{ auth: 'required' },
);
});

it('hasNext 가 true 면 다음 페이지 번호를, false 면 undefined 를 돌려준다', async () => {
endpoints.getDetail.mockResolvedValue(
ok(
placeDetailResponse({
items: [{ postId: 11, title: '게시물 A', savedAt: '2026-08-19' }],
hasNext: true,
totalElements: 25,
}),
),
);

const page = await fetchPlacePosts(9, 2);

expect(page.nextPage).toBe(3);
expect(page.totalElements).toBe(25);
expect(page.posts).toEqual([expect.objectContaining({ id: 11, title: '게시물 A' })]);

endpoints.getDetail.mockResolvedValue(
ok(placeDetailResponse({ items: [], hasNext: false, totalElements: 25 })),
);

expect((await fetchPlacePosts(9, 3)).nextPage).toBeUndefined();
});
});

describe('toPlaceDetail', () => {
it('게시물 총 개수는 첫 페이지 건수가 아니라 totalElements 를 쓴다', () => {
const place = toPlaceDetail(
placeDetailResponse({
items: [{ postId: 11, savedAt: '2026-08-19' }],
hasNext: true,
totalElements: 25,
}),
);

expect(place.posts).toHaveLength(1);
expect(place.postsTotal).toBe(25);
});
});
31 changes: 31 additions & 0 deletions apps/web/src/features/map/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ export async function fetchSavedPlaceSearch(
return toSavedPlaceSearchPage(response);
}

/** 장소 상세의 게시물 페이지 크기. 서버 기본값(20)과 같고, 아카이브 목록과도 같다. */
const POSTS_PAGE_SIZE = 20;

function toPlaceDetailPost(dto: PlacePostResponse): PlaceDetailPost {
return {
id: dto.postId,
Expand Down Expand Up @@ -159,6 +162,7 @@ export function toPlaceDetail(dto: PlaceDetailResponse): PlaceDetail {
openingHours: dto.openingHours ?? undefined,
memo: dto.memo ?? undefined,
posts: (dto.posts?.items ?? []).map(toPlaceDetailPost),
postsTotal: dto.posts?.totalElements ?? dto.posts?.items?.length ?? 0,
};
}

Expand All @@ -181,6 +185,33 @@ export async function fetchSharedPlaceDetail(token: string, placeId: number): Pr
return toPlaceDetail(dto);
}

/** `fetchArchivePosts` 와 같은 페이지 형태. */
export interface PlacePostPage {
posts: PlaceDetailPost[];
/** 다음 페이지 번호. 없으면 마지막 페이지다. */
nextPage?: number;
totalElements: number;
}

/**
* 장소에 저장된 게시물 한 페이지.
*
* 장소별 게시물 전용 엔드포인트가 없어서 장소 상세를 `page` 파라미터와 함께 다시 호출한다 —
* 사진·태그·영업시간까지 매 페이지 같이 실려 온다. 전용 엔드포인트가 생기면 여기만 바꾸면 된다.
*/
export async function fetchPlacePosts(placeId: number, page = 0): Promise<PlacePostPage> {
const response = unwrapApiResponse(
await getPlaceDetailEndpoint(placeId, { page, size: POSTS_PAGE_SIZE }, { auth: 'required' }),
);
if (!response) throw new Error('장소 상세 응답이 비어 있습니다.');

return {
posts: (response.posts?.items ?? []).map(toPlaceDetailPost),
nextPage: response.posts?.hasNext ? page + 1 : undefined,
totalElements: response.posts?.totalElements ?? 0,
};
}

export async function updatePlaceBookmark(placeId: number, bookmarked: boolean): Promise<void> {
await updateBookmarkEndpoint(placeId, { bookmarked }, { auth: 'required' });
}
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/features/map/api/queries.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { useIsAuthenticated } from '@/features/auth/session/AuthSessionProvider';
import type { MapBounds, SavedPlaceSearchPage } from '../types';
import {
disconnectPostPlace,
fetchMapPins,
fetchPlaceDetail,
fetchPlacePosts,
fetchRecentPlaces,
fetchSavedPlaceSearch,
fetchSharedPlaceDetail,
Expand All @@ -22,6 +29,10 @@ export const mapQueryKeys = {
shareToken
? (['shared', shareToken, 'places', placeId] as const)
: (['map', 'detail', placeId] as const),
// 내 장소 상세 키의 접두사 아래에 두는 게 의도적이다 — 북마크·메모·연결끊기가 무효화하는
// `detail(placeId)` 가 접두사 매칭으로 이 목록까지 함께 갱신한다. 공유 진입에는 쓰지 않는다
// (모아보기 페이지는 내 API 만 쓴다).
posts: (placeId: number) => ['map', 'detail', placeId, 'posts'] as const,
search: (query: string, groupId: number | null) => ['map', 'search', query, groupId] as const,
};

Expand Down Expand Up @@ -96,6 +107,28 @@ export function usePlaceDetail(placeId: number | null, shareToken?: string | nul
});
}

/**
* 장소에 저장된 게시물 전체 — `/place/{placeId}/posts` 페이지의 무한 스크롤.
*
* 시트는 `usePlaceDetail` 이 준 첫 페이지만 쓰므로 쿼리를 나눴다(그쪽을 무한 쿼리로 바꾸면
* 같은 캐시를 보는 지도 핀·이동까지 파급된다).
*/
export function usePlacePosts(placeId: number | null) {
const isAuthenticated = useIsAuthenticated();

return useInfiniteQuery({
queryKey: mapQueryKeys.posts(placeId ?? -1),
queryFn: ({ pageParam }) => fetchPlacePosts(placeId as number, pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextPage,
select: (data) => ({
posts: data.pages.flatMap((page) => page.posts),
totalElements: data.pages[0]?.totalElements ?? 0,
}),
enabled: isAuthenticated && placeId !== null,
});
}

/**
* 북마크 토글. 성공하면 상세 쿼리를 무효화하고(낙관적 갱신 없음, archive/post 와 동일 컨벤션)
* `/places/map`이 북마크된 장소만 내려주므로 지도 핀도 함께 무효화한다 — 그래야 이 화면을
Expand Down
Loading
Loading