Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
110 changes: 110 additions & 0 deletions src/apis/likes.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { apiClient, normalizeApiError } from './common';

import type {
LikedItemCategory,
LikedItemQueryCategory,
} from '../pages/likes/types';

export interface LikedItemsParams {
category: LikedItemQueryCategory;
keyword?: string;
sort?: 'LATEST' | 'OLDEST';
cursorCreatedAt?: string;
cursorId?: number;
size?: number;
latitude?: number;
longitude?: number;
}

/** 서버 쿼리 파라미터 표기. EVENT는 서버에서 CONTENT로 받는다. */
const LIKED_ITEM_QUERY_CATEGORY_PARAM: Record<LikedItemQueryCategory, string> =
{
ALL: 'ALL',
COURSE: 'COURSE',
EVENT: 'CONTENT',
PLACE: 'PLACE',
};

/** 서버 응답 category 표기. CONTENT는 앱에서 EVENT로 다룬다. */
type LikedItemResponseCategory = 'COURSE' | 'CONTENT' | 'PLACE';

const LIKED_ITEM_RESPONSE_CATEGORY: Record<
LikedItemResponseCategory,
LikedItemCategory
> = {
COURSE: 'COURSE',
CONTENT: 'EVENT',
PLACE: 'PLACE',
};

interface RawLikedItemResponse {
id: number;
category: LikedItemResponseCategory;
title: string;
externalPlaceId?: string;
thumbnailImage: string | null;
duration: string | null;
startDate: string | null;
endDate: string | null;
location: string;
distance: number | null;
hashtags: string[];
likedAt: string;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

interface RawGetLikedItemsResponse {
items: RawLikedItemResponse[];
cursorValue: string | null;
cursorId: number | null;
hasNext: boolean;
}

export interface LikedItemResponse {
id: number;
category: LikedItemCategory;
title: string;
externalPlaceId?: string;
thumbnailImage: string | null;
duration: string | null;
startDate: string | null;
endDate: string | null;
location: string;
distance: number | null;
hashtags: string[];
likedAt: string;
}

export interface GetLikedItemsResponse {
items: LikedItemResponse[];
cursorValue: string | null;
cursorId: number | null;
hasNext: boolean;
}

export async function getLikedItems(
params: LikedItemsParams,
signal?: AbortSignal
): Promise<GetLikedItemsResponse> {
try {
const { data } = await apiClient.get<RawGetLikedItemsResponse>(
'/users/me/likes',
{
params: {
...params,
category: LIKED_ITEM_QUERY_CATEGORY_PARAM[params.category],
},
signal,
}
);

return {
...data,
items: data.items.map((item) => ({
...item,
category: LIKED_ITEM_RESPONSE_CATEGORY[item.category],
})),
};
} catch (error) {
throw normalizeApiError(error);
}
}
32 changes: 32 additions & 0 deletions src/components/kakaomap/utils/kakaoMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,38 @@ export function getCurrentMapCoordinates(
});
}

const EARTH_RADIUS_METERS = 6371000;
const METERS_PER_KILOMETER = 1000;

function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}

/** 두 좌표 사이의 직선 거리(m). 카카오맵 SDK엔 거리 계산 API가 없어 하버사인 공식으로 직접 구한다. */
export function getDistanceInMeters(
from: MapCoordinates,
to: MapCoordinates
): number {
const deltaLat = toRadians(to.latitude - from.latitude);
const deltaLng = toRadians(to.longitude - from.longitude);

const a =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(toRadians(from.latitude)) *
Math.cos(toRadians(to.latitude)) *
Math.sin(deltaLng / 2) ** 2;

return EARTH_RADIUS_METERS * 2 * Math.asin(Math.sqrt(a));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

export function formatDistance(meters: number): string {
if (meters >= METERS_PER_KILOMETER) {
return `${(meters / METERS_PER_KILOMETER).toFixed(1)}km`;
}

return `${Math.round(meters)}m`;
}

export function loadKakaoMapsSdk(appKey: string): Promise<void> {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return Promise.reject(
Expand Down
45 changes: 45 additions & 0 deletions src/pages/likes/hooks/useLikedItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useInfiniteQuery } from '@tanstack/react-query';

import { getLikedItems } from '../../../apis/likes.api';
import type { LikedItemQueryCategory } from '../types';

interface UseLikedItemsParams {
category: LikedItemQueryCategory;
keyword?: string;
sort: 'LATEST' | 'OLDEST';
}

interface LikedItemsPageParam {
cursorCreatedAt?: string;
cursorId?: number;
}

export function useLikedItems({
category,
keyword,
sort,
}: UseLikedItemsParams) {
return useInfiniteQuery({
queryKey: ['likedItems', { category, keyword, sort }],
queryFn: ({ pageParam, signal }) =>
getLikedItems(
{
category,
keyword,
sort,
cursorCreatedAt: pageParam.cursorCreatedAt,
cursorId: pageParam.cursorId,
size: 10,
},
signal
),
initialPageParam: {} as LikedItemsPageParam,
getNextPageParam: (lastPage) =>
lastPage.hasNext && lastPage.cursorValue && lastPage.cursorId !== null
? {
cursorCreatedAt: lastPage.cursorValue,
cursorId: lastPage.cursorId,
}
: undefined,
});
}
113 changes: 90 additions & 23 deletions src/pages/likes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';

import {
ContentCard,
CourseFilterBar,
SearchBar,
} from '../../components/common';
import { useToast } from '../../components/toast';
import {
LIKED_ITEM_FILTER_GRID_CLASS_NAME,
getLikedItemFilterColumnClassName,
} from '../../constants/courseFilterLayout';
import {
removeContentLike,
removeCourseLike,
removePlaceLike,
} from '../../apis/courses';
import { useGlobalScale } from '../../hooks/useGlobalScale';
import useInfiniteScroll from '../../hooks/useInfiniteScroll';
import { toContentTagIds } from '../../utils/contentTags';

import {
LIKED_CATEGORY_OPTIONS,
LIKED_SORT_LATEST,
LIKED_SORT_OPTIONS,
likedCategoryByLabel,
type LikedItemFilterKey,
} from './constants/filters';
import { MOCK_LIKED_ITEMS } from './constants/mockLikedItems';
import useLikedItemFilters from './hooks/useLikedItemFilters';
import { useLikedItems } from './hooks/useLikedItems';
import {
filterLikedItems,
getDetailFilterOptions,
mapLikedItemResponse,
sortLikedItems,
toLikedItemInfoLines,
} from './utils/likedItems';
import type { LikedItem } from './types';

const PAGE_PADDING_X = 24;
const PAGE_PADDING_TOP = 12;
Expand All @@ -39,11 +51,13 @@ const LIST_MARGIN_TOP = 24;
const LIST_GAP = 16;
const EMPTY_MARGIN_TOP = 40;
const EMPTY_TEXT_SIZE = 13;
const ERROR_MARGIN_TOP = 24;
const LOAD_MORE_HEIGHT = 40;

function LikesPage() {
const scale = useGlobalScale();
const { showToast } = useToast();
const [keyword, setKeyword] = useState('');
// ponytail: 좋아요 해제는 화면 상태로만 반영한다. API 연동 시 뮤테이션으로 교체.
const [unlikedIds, setUnlikedIds] = useState<ReadonlySet<string>>(new Set());

const {
Expand All @@ -54,12 +68,28 @@ function LikesPage() {
handleFilterSelect,
} = useLikedItemFilters();

const category = likedCategoryByLabel[selectedFilters.category] ?? 'ALL';
const sort = selectedFilters.sort === LIKED_SORT_LATEST ? 'LATEST' : 'OLDEST';
const {
data,
fetchNextPage,
hasNextPage,
isError,
isFetchingNextPage,
isPending,
} = useLikedItems({
category,
keyword: keyword.trim() || undefined,
sort,
});

const activeLikedItems = useMemo(
() =>
MOCK_LIKED_ITEMS.filter(
(item) => !unlikedIds.has(`${item.category}-${item.id}`)
),
[unlikedIds]
(
data?.pages.flatMap((page) => page.items).map(mapLikedItemResponse) ??
[]
).filter((item) => !unlikedIds.has(`${item.category}-${item.id}`)),
[data, unlikedIds]
);

const filterGroups = useMemo(
Expand Down Expand Up @@ -95,20 +125,37 @@ function LikesPage() {
[activeLikedItems, keyword, selectedFilters]
);

const toggleLike = (itemKey: string) => {
setUnlikedIds((currentIds) => {
const nextIds = new Set(currentIds);

if (nextIds.has(itemKey)) {
nextIds.delete(itemKey);
const handleUnlike = async (item: LikedItem) => {
try {
if (item.category === 'COURSE') {
await removeCourseLike(item.id);
} else if (item.category === 'PLACE') {
await removePlaceLike(item.id);
} else {
nextIds.add(itemKey);
await removeContentLike(item.id);
}

return nextIds;
});
setUnlikedIds((currentIds) =>
new Set(currentIds).add(`${item.category}-${item.id}`)
);
} catch {
showToast('좋아요 취소에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
};

const handleIntersect = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);

const loadMoreRef = useInfiniteScroll({
enabled: Boolean(hasNextPage) && !isPending,
onIntersect: handleIntersect,
});

const hasEmptyResult = !isPending && !isError && likedItems.length === 0;

return (
<section
className="mx-auto flex min-h-screen w-full flex-col"
Expand Down Expand Up @@ -137,13 +184,13 @@ function LikesPage() {
lineHeight: `${DESCRIPTION_LINE_HEIGHT * scale}px`,
}}
>
마음에 들었던 여행을 다시 만나보세요
마음에 들었던 여행을 다시 만나보세요.
</p>
</div>

<div style={{ marginTop: SEARCH_MARGIN_TOP * scale }}>
<SearchBar
placeholder="좋아요 누른 코스나 장소를 검색해 보세요"
placeholder="좋아요한 코스와 장소를 검색해 보세요"
label="좋아요 목록 검색"
onQueryChange={setKeyword}
onSearch={setKeyword}
Expand Down Expand Up @@ -184,15 +231,17 @@ function LikesPage() {
firstInfo={firstInfo}
secondInfo={secondInfo}
thirdInfo={thirdInfo}
tags={item.hashtags}
liked={!unlikedIds.has(itemKey)}
tags={toContentTagIds(item.hashtags)}
liked
className="w-full"
onLikeClick={() => toggleLike(itemKey)}
onLikeClick={() => void handleUnlike(item)}
/>
);
})}
</div>
) : (
) : null}

{hasEmptyResult ? (
<p
className="text-gray-4 text-center font-medium"
style={{
Expand All @@ -202,7 +251,25 @@ function LikesPage() {
>
좋아요한 항목이 없습니다.
</p>
)}
) : null}

{isError ? (
<p
className="text-main-5 text-center font-medium"
style={{
marginTop: ERROR_MARGIN_TOP * scale,
fontSize: EMPTY_TEXT_SIZE * scale,
}}
>
좋아요 목록을 불러오지 못했어요.
</p>
) : null}

<div
ref={loadMoreRef}
style={{ height: LOAD_MORE_HEIGHT * scale }}
aria-hidden="true"
/>
</section>
);
}
Expand Down
Loading