diff --git a/.env.example b/.env.example index 19b93fac..1898ac4e 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ VITE_API_BASE_URL=https://api.yeogido.kr/api/v1 VITE_KAKAO_MAP_API_KEY=your_kakao_javascript_key KAKAO_REST_API_KEY=your_kakao_rest_api_key VITE_NAVER_CLIENT_ID=your_naver_client_id +GOOGLE_MAPS_API_KEY=발급받은_키 \ No newline at end of file diff --git a/api/google-places/hours.ts b/api/google-places/hours.ts new file mode 100644 index 00000000..093b2960 --- /dev/null +++ b/api/google-places/hours.ts @@ -0,0 +1,88 @@ +import { + InvalidPlaceHoursRequestError, + lookupPlaceHours, + type PlaceHoursRequest, +} from './placeHours'; + +export const config = { runtime: 'edge' }; + +const CACHE_TTL_MS = 1000 * 60 * 5; +const RATE_LIMIT_WINDOW_MS = 1000 * 60; +const RATE_LIMIT_MAX_REQUESTS = 30; + +const responseCache = new Map< + string, + { readonly expiresAt: number; readonly body: Awaited> } +>(); +const rateLimitEntries = new Map(); + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +} + +function getClientId(request: Request) { + return ( + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? + request.headers.get('x-real-ip') ?? + 'unknown' + ); +} + +function isRateLimited(clientId: string, now: number) { + const entry = rateLimitEntries.get(clientId); + + if (!entry || entry.resetAt <= now) { + rateLimitEntries.set(clientId, { + count: 1, + resetAt: now + RATE_LIMIT_WINDOW_MS, + }); + return false; + } + + entry.count += 1; + return entry.count > RATE_LIMIT_MAX_REQUESTS; +} + +export default async function handler(request: Request): Promise { + if (request.method !== 'POST') { + return json({ message: 'Method not allowed.' }, 405); + } + + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) { + return json({ message: 'Google Places API is not configured.' }, 503); + } + + let requestBody: PlaceHoursRequest; + try { + requestBody = (await request.json()) as PlaceHoursRequest; + } catch { + return json({ message: 'Invalid request body.' }, 400); + } + + const now = Date.now(); + const cacheKey = JSON.stringify(requestBody); + const cached = responseCache.get(cacheKey); + if (cached && cached.expiresAt > now) { + return json(cached.body); + } + + if (isRateLimited(getClientId(request), now)) { + return json({ message: 'Too many requests.' }, 429); + } + + try { + const body = await lookupPlaceHours(requestBody, apiKey); + responseCache.set(cacheKey, { body, expiresAt: now + CACHE_TTL_MS }); + return json(body); + } catch (error) { + if (error instanceof InvalidPlaceHoursRequestError) { + return json({ message: error.message }, 400); + } + + return json({ message: 'Google Places request failed.' }, 502); + } +} diff --git a/api/google-places/placeHours.ts b/api/google-places/placeHours.ts new file mode 100644 index 00000000..0221cf2f --- /dev/null +++ b/api/google-places/placeHours.ts @@ -0,0 +1,122 @@ +export interface PlaceHoursRequest { + readonly name?: unknown; + readonly address?: unknown; + readonly latitude?: unknown; + readonly longitude?: unknown; +} +export interface PlaceHoursResponse { + readonly currentWeekdayDescriptions: readonly string[]; + readonly regularWeekdayDescriptions: readonly string[]; + readonly openNow?: boolean; + readonly nextOpenTime?: string; + readonly nextCloseTime?: string; +} + +interface GoogleOpeningHours { + readonly weekdayDescriptions?: readonly string[]; + readonly openNow?: boolean; + readonly nextOpenTime?: string; + readonly nextCloseTime?: string; +} + +interface GooglePlaceDetails { + readonly currentOpeningHours?: GoogleOpeningHours; + readonly regularOpeningHours?: GoogleOpeningHours; +} + +interface GoogleTextSearchResponse { + readonly places?: readonly { readonly id?: string }[]; +} + +export class InvalidPlaceHoursRequestError extends Error {} + +function toOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function toOptionalCoordinate(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +export async function lookupPlaceHours( + requestBody: PlaceHoursRequest, + apiKey: string +): Promise { + const name = toOptionalString(requestBody.name); + const address = toOptionalString(requestBody.address); + const latitude = toOptionalCoordinate(requestBody.latitude); + const longitude = toOptionalCoordinate(requestBody.longitude); + + if (!name) { + throw new InvalidPlaceHoursRequestError('Place name is required.'); + } + + const textSearchBody: Record = { + textQuery: [name, address].filter(Boolean).join(' '), + languageCode: 'ko', + }; + + if (latitude !== undefined && longitude !== undefined) { + textSearchBody.locationBias = { + circle: { + center: { latitude, longitude }, + radius: 1_000, + }, + }; + } + + const searchResponse = await fetch( + 'https://places.googleapis.com/v1/places:searchText', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'places.id', + }, + body: JSON.stringify(textSearchBody), + } + ); + + if (!searchResponse.ok) { + throw new Error('Google place search failed.'); + } + + const searchResult = + (await searchResponse.json()) as GoogleTextSearchResponse; + const placeId = searchResult.places?.[0]?.id; + + if (!placeId) { + return null; + } + + const detailsResponse = await fetch( + `https://places.googleapis.com/v1/places/${encodeURIComponent(placeId)}?languageCode=ko`, + { + headers: { + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'currentOpeningHours,regularOpeningHours', + }, + } + ); + + if (!detailsResponse.ok) { + throw new Error('Google place details failed.'); + } + + const details = (await detailsResponse.json()) as GooglePlaceDetails; + const currentHours = details.currentOpeningHours; + const regularHours = details.regularOpeningHours; + + if (!currentHours && !regularHours) { + return null; + } + + return { + currentWeekdayDescriptions: currentHours?.weekdayDescriptions ?? [], + regularWeekdayDescriptions: regularHours?.weekdayDescriptions ?? [], + openNow: currentHours?.openNow, + nextOpenTime: currentHours?.nextOpenTime, + nextCloseTime: currentHours?.nextCloseTime, + }; +} diff --git a/src/apis/googlePlacesHours.ts b/src/apis/googlePlacesHours.ts new file mode 100644 index 00000000..f1b57abc --- /dev/null +++ b/src/apis/googlePlacesHours.ts @@ -0,0 +1,31 @@ +export interface PlaceHoursLookup { + readonly name: string; + readonly address?: string; + readonly latitude?: number; + readonly longitude?: number; +} +export interface PlaceHours { + readonly currentWeekdayDescriptions: readonly string[]; + readonly regularWeekdayDescriptions: readonly string[]; + readonly openNow?: boolean; + readonly nextOpenTime?: string; + readonly nextCloseTime?: string; +} + +export async function getPlaceHours( + lookup: PlaceHoursLookup, + signal?: AbortSignal +): Promise { + const response = await fetch('/google-places/hours', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(lookup), + signal, + }); + + if (!response.ok) { + throw new Error('Failed to retrieve place opening hours.'); + } + + return (await response.json()) as PlaceHours | null; +} diff --git a/src/apis/likes.api.ts b/src/apis/likes.api.ts new file mode 100644 index 00000000..ac23661e --- /dev/null +++ b/src/apis/likes.api.ts @@ -0,0 +1,114 @@ +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 = + { + 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; + transportType?: string | null; + companionType?: string | null; + distance: number | null; + hashtags: string[]; + likedAt: string; +} + +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; + transportType?: string | null; + companionType?: string | null; + 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 { + try { + const { data } = await apiClient.get( + '/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); + } +} diff --git a/src/assets/icons/near.svg b/src/assets/icons/near.svg new file mode 100644 index 00000000..be4c584d --- /dev/null +++ b/src/assets/icons/near.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/common/ContentCard.tsx b/src/components/common/ContentCard.tsx index 21795577..15096291 100644 --- a/src/components/common/ContentCard.tsx +++ b/src/components/common/ContentCard.tsx @@ -1,8 +1,16 @@ import { useLayoutEffect, useRef, useState } from 'react'; +import { + FaDog, + FaHeart, + FaPeopleGroup, + FaPeopleRoof, + FaUser, +} from 'react-icons/fa6'; import calendar from '../../assets/icons/calendar.svg'; import heart from '../../assets/icons/heart.svg'; import location from '../../assets/icons/location.svg'; +import near from '../../assets/icons/near.svg'; import oheart from '../../assets/icons/oheart.svg'; import people from '../../assets/icons/people.svg'; @@ -10,6 +18,34 @@ import { useGlobalScale } from '../../hooks/useGlobalScale'; import TagChip, { type TagType } from './TagChip'; +function getCompanionIcon(label?: string | null) { + if (!label) return null; + + const key = label.trim().toUpperCase(); + + if (key === 'SOLO' || key === 'ALONE' || label.includes('혼자')) { + return FaUser; + } + if (key === 'FRIEND' || label.includes('친구')) { + return FaPeopleGroup; + } + if (key === 'COUPLE' || label.includes('연인')) { + return FaHeart; + } + if (key === 'FAMILY' || label.includes('가족')) { + return FaPeopleRoof; + } + if ( + key === 'PET' || + label.includes('반려동물') || + label.includes('반려견') + ) { + return FaDog; + } + + return null; +} + // 모든 수치는 Figma 390 디자인 기준(카드 자체 폭 163 기준) 리터럴 px const CARD_DESIGN_WIDTH = 163; const CARD_HEIGHT = 222; @@ -18,7 +54,14 @@ const CONTENT_HEIGHT = 107; const CONTENT_PADDING = 8; const TITLE_SIZE = 14; const INFO_SIZE = 12; +/** 아이콘 슬롯. 아이콘은 원본 비율 그대로 이 슬롯 가운데에 놓는다(중심 x = 15). */ const ICON_SIZE = 14; +const ICON_GAP = 2; +/** 태그 줄이 없는 카드(장소)는 남는 높이만큼 정보 줄을 넓게 벌린다. */ +const INFO_MARGIN_TOP = 8; +const INFO_ROW_GAP = 4; +const INFO_MARGIN_TOP_WIDE = 17; +const INFO_ROW_GAP_WIDE = 8; const HEART_SIZE = 16; const HEART_TOP = 8; const HEART_RIGHT = 8; @@ -32,6 +75,8 @@ interface ContentCardProps { secondInfo: string; /** 두 번째 정보 줄에 함께 붙는 보조 정보(예: 동행 유형). 없으면 렌더링하지 않는다. */ thirdInfo?: string; + /** 현 위치 기준 거리 줄(예: '현위치와 314KM'). 없으면 렌더링하지 않는다. */ + distanceInfo?: string; liked?: boolean; className?: string; tags?: TagType[]; @@ -101,12 +146,26 @@ function useResponsiveTagCount(tags: TagType[] | undefined) { return { visibleContainerRef, hiddenContainerRef, visibleCount }; } +/** 아이콘은 늘리지 않고 원본 크기 그대로 14px 슬롯 가운데에 놓는다. */ +function InfoIcon({ src }: { src: string }) { + return ( + + ); +} + function ContentCard({ image, title, firstInfo, secondInfo, thirdInfo, + distanceInfo, liked = false, className = '', tags, @@ -118,6 +177,7 @@ function ContentCard({ const { visibleContainerRef, hiddenContainerRef, visibleCount } = useResponsiveTagCount(tags); + const hasTags = Boolean(tags && tags.length > 0); const isClickable = Boolean(onClick); const hasCustomWidth = /(?:^|\s)(?:w-|min-w|max-w)/.test(className); @@ -131,6 +191,14 @@ function ContentCard({ >
{ + if (!onClick || (event.key !== 'Enter' && event.key !== ' ')) { + return; + } + + event.preventDefault(); + onClick(); + }} role={isClickable ? 'button' : undefined} tabIndex={isClickable ? 0 : undefined} className={`flex flex-col overflow-hidden rounded-xl bg-[#F9F9F9] shadow-[0_1px_5px_rgba(0,0,0,0.07)] ${isClickable ? 'cursor-pointer' : ''} `} @@ -188,15 +256,18 @@ function ContentCard({ {/* Info */} -
-
- +
+
+
-
- +
+ - + {(() => { + const CompanionIcon = getCompanionIcon(thirdInfo); + return CompanionIcon ? ( + + ) : ( + + ); + })()} ) : null}
+ + {distanceInfo ? ( +
+ + + + {distanceInfo} + +
+ ) : null}
{/* Tags */} - {tags && tags.length > 0 && ( + {hasTags && tags && ( <>
- {metaItems.slice(0, visibleMetaCount).map((item) => ( -
- - - - {item.label} - -
- ))} + {metaItems.slice(0, visibleMetaCount).map((item) => { + const CompanionIcon = + item.key === 'companion' ? getCompanionIcon(item.label) : null; + + return ( +
+ {CompanionIcon ? ( + + ) : ( + + )} + + + {item.label} + +
+ ); + })}
{/* Tags: 측정 전용 hidden 영역 */} diff --git a/src/components/kakaomap/utils/kakaoMap.ts b/src/components/kakaomap/utils/kakaoMap.ts index 2a5222c8..060dbcee 100644 --- a/src/components/kakaomap/utils/kakaoMap.ts +++ b/src/components/kakaomap/utils/kakaoMap.ts @@ -38,6 +38,7 @@ export function getCurrentMapCoordinates( }); } + export function loadKakaoMapsSdk(appKey: string): Promise { if (typeof window === 'undefined' || typeof document === 'undefined') { return Promise.reject( diff --git a/src/hooks/usePlaceOpeningHours.ts b/src/hooks/usePlaceOpeningHours.ts new file mode 100644 index 00000000..d7db9fcf --- /dev/null +++ b/src/hooks/usePlaceOpeningHours.ts @@ -0,0 +1,113 @@ +import { useQueries } from '@tanstack/react-query'; + +import { + getPlaceHours, + type PlaceHours, + type PlaceHoursLookup, +} from '../apis/googlePlacesHours'; + +export interface PlaceHoursLookupItem extends PlaceHoursLookup { + readonly id: string | number; +} + +const PLACE_HOURS_STALE_TIME = 1000 * 60 * 5; +const PLACE_HOURS_GC_TIME = 1000 * 60 * 30; +const PLACE_HOURS_CONCURRENCY = 4; +let activePlaceHoursRequests = 0; +const pendingPlaceHoursRequests: Array<() => void> = []; + +function runNextPlaceHoursRequest() { + if (activePlaceHoursRequests >= PLACE_HOURS_CONCURRENCY) { + return; + } + + pendingPlaceHoursRequests.shift()?.(); +} + +function queuePlaceHoursRequest(request: () => Promise): Promise { + return new Promise((resolve, reject) => { + pendingPlaceHoursRequests.push(() => { + activePlaceHoursRequests += 1; + void request().then(resolve, reject).finally(() => { + activePlaceHoursRequests -= 1; + runNextPlaceHoursRequest(); + }); + }); + runNextPlaceHoursRequest(); + }); +} + +export function usePlaceOpeningHours( + places: readonly PlaceHoursLookupItem[] +): ReadonlyMap { + const queries = useQueries({ + queries: places.map((place) => ({ + queryKey: [ + 'placeOpeningHours', + place.name, + place.address ?? '', + place.latitude ?? null, + place.longitude ?? null, + ], + queryFn: ({ signal }: { signal: AbortSignal }) => + queuePlaceHoursRequest(() => getPlaceHours(place, signal)), + staleTime: PLACE_HOURS_STALE_TIME, + gcTime: PLACE_HOURS_GC_TIME, + retry: false, + })), + }); + + return new Map( + places.flatMap((place, index) => { + const hours = queries[index]?.data; + + return hours ? [[place.id, hours] as const] : []; + }) + ); +} + +const KOREAN_WEEKDAY_LABELS = [ + '일요일', + '월요일', + '화요일', + '수요일', + '목요일', + '금요일', + '토요일', +] as const; + +function getKoreanWeekdayLabel() { + const weekday = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Seoul', + weekday: 'short', + }).format(new Date()); + + const index = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf( + weekday + ); + + return index === -1 ? undefined : KOREAN_WEEKDAY_LABELS[index]; +} + +export function formatTodayOpeningHours(hours: PlaceHours): string | undefined { + const descriptions = + hours.currentWeekdayDescriptions.length > 0 + ? hours.currentWeekdayDescriptions + : hours.regularWeekdayDescriptions; + const weekday = getKoreanWeekdayLabel(); + const description = + descriptions.find((item) => weekday && item.startsWith(weekday)) ?? + descriptions[0]; + + if (!description) { + return undefined; + } + + if (/(?:^|:\s*)(?:closed|휴무)$/i.test(description.trim())) { + return '휴무'; + } + + return description + .replace(/^[^:]+:\s*/, '') + .replace(/[~–—]/g, ' - '); +} diff --git a/src/pages/detail/components/CourseDetailLayout.tsx b/src/pages/detail/components/CourseDetailLayout.tsx index 73ebcde0..2b058249 100644 --- a/src/pages/detail/components/CourseDetailLayout.tsx +++ b/src/pages/detail/components/CourseDetailLayout.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { CourseInfoBadgesCard } from './CourseInfoBadgesCard'; @@ -25,6 +25,10 @@ import { } from '../../../components/common'; import { useGlobalScale } from '../../../hooks/useGlobalScale'; import { useLoginModal } from '../../../hooks/useLoginModal'; +import { + formatTodayOpeningHours, + usePlaceOpeningHours, +} from '../../../hooks/usePlaceOpeningHours'; import { useCourseReviewPreviews, useMyReviewIds, @@ -122,6 +126,33 @@ function CourseDetailLayoutContent({ const [isLiked, setIsLiked] = useState(course.liked); const [stops, setStops] = useState(course.stops); const { copied, isToastVisible, handleShare } = useShareToast(); + const openingHoursByStopId = usePlaceOpeningHours( + stops.flatMap((stop) => + stop.placeId !== undefined + ? [ + { + id: stop.id, + name: stop.name, + address: stop.address, + latitude: stop.location?.latitude, + longitude: stop.location?.longitude, + }, + ] + : [] + ) + ); + const stopsWithOpeningHours = useMemo( + () => + stops.map((stop) => { + const hours = openingHoursByStopId.get(stop.id); + const formattedHours = hours && formatTodayOpeningHours(hours); + + return stop.placeId !== undefined + ? { ...stop, hours: formattedHours ?? '영업시간 정보 없음' } + : stop; + }), + [openingHoursByStopId, stops] + ); const handleStopLikeToggle = (stopId: number) => { if (!isAuthenticated || !accessToken) { @@ -278,7 +309,7 @@ function CourseDetailLayoutContent({ marginTop: MAP_MARGIN_TOP * scale, }} > - +
{/* 6. 코스 장소 리스트 */} @@ -288,7 +319,7 @@ function CourseDetailLayoutContent({ }} > { if (!content) return; @@ -176,7 +201,7 @@ function FestivalDetailContent({ contentId }: { contentId: number }) {
diff --git a/src/pages/likes/constants/filters.ts b/src/pages/likes/constants/filters.ts index b34109f0..a5d098cd 100644 --- a/src/pages/likes/constants/filters.ts +++ b/src/pages/likes/constants/filters.ts @@ -10,24 +10,6 @@ export const LIKED_CATEGORY_OPTIONS = [ '장소', ] as const; -/** 2번째 필터: 1번째 필터가 '행사'일 때 */ -export const LIKED_EVENT_DETAIL_OPTIONS = [ - ALL_FILTER_OPTION, - '체험', - '전시', - '공연', - '축제', -] as const; - -/** 2번째 필터: 1번째 필터가 '장소'일 때 */ -export const LIKED_PLACE_DETAIL_OPTIONS = [ - ALL_FILTER_OPTION, - '음식점', - '카페 및 베이커리', - '지역명소', - '체험 및 활동', -] as const; - /** 3번째 필터: 정렬 */ export const LIKED_SORT_OPTIONS = ['최신순', '오래된 순'] as const; diff --git a/src/pages/likes/constants/mockLikedItems.ts b/src/pages/likes/constants/mockLikedItems.ts deleted file mode 100644 index 3bcc0909..00000000 --- a/src/pages/likes/constants/mockLikedItems.ts +++ /dev/null @@ -1,190 +0,0 @@ -import courseImage from '../../yeogido-course/assets/courseimage.svg'; - -import type { LikedItem } from '../types'; - -/** - * 좋아요 목록 API(GET /api/v1/users/me/likes) 연동 전까지 화면 확인용으로 쓰는 - * 임시 데이터. 연동 시 이 파일을 지우고 응답을 LikedItem으로 매핑하면 된다. - */ -export const MOCK_LIKED_ITEMS: LikedItem[] = [ - { - id: 1, - category: 'COURSE', - title: '묵호 혼자 여행 코스', - thumbnailUrl: courseImage, - duration: '2박 3일', - startDate: null, - endDate: null, - location: '뚜벅이', - companion: '혼자', - region: '부산', - detailType: null, - hashtags: ['summer', 'nature', 'sea'], - likedAt: '2026-07-24T20:15:00', - }, - { - id: 2, - category: 'COURSE', - title: '묵호 혼자 여행 코스', - thumbnailUrl: courseImage, - duration: '2박 3일', - startDate: null, - endDate: null, - location: '뚜벅이', - companion: '혼자', - region: '순천', - detailType: null, - hashtags: ['summer', 'nature', 'sea'], - likedAt: '2026-07-23T18:02:00', - }, - { - id: 3, - category: 'COURSE', - title: '묵호 혼자 여행 코스', - thumbnailUrl: courseImage, - duration: '2박 3일', - startDate: null, - endDate: null, - location: '뚜벅이', - companion: '혼자', - region: '부여', - detailType: null, - hashtags: ['summer', 'nature', 'sea'], - likedAt: '2026-07-22T09:41:00', - }, - { - id: 4, - category: 'COURSE', - title: '묵호 혼자 여행 코스', - thumbnailUrl: courseImage, - duration: '2박 3일', - startDate: null, - endDate: null, - location: '뚜벅이', - companion: '혼자', - region: '대구', - detailType: null, - hashtags: ['summer', 'nature', 'sea'], - likedAt: '2026-07-21T13:27:00', - }, - { - id: 5, - category: 'EVENT', - title: '양평수박축제', - thumbnailUrl: null, - duration: null, - startDate: '2026.07', - endDate: '2026.07', - location: '경기도 양평군', - companion: null, - region: null, - detailType: '축제', - hashtags: ['summer', 'nature', 'experience'], - likedAt: '2026-07-24T11:08:00', - }, - { - id: 6, - category: 'EVENT', - title: '양평수박축제', - thumbnailUrl: null, - duration: null, - startDate: '2026.07', - endDate: '2026.07', - location: '경기도 양평군', - companion: null, - region: null, - detailType: '체험', - hashtags: ['summer', 'nature', 'experience'], - likedAt: '2026-07-20T15:33:00', - }, - { - id: 7, - category: 'EVENT', - title: '양평수박축제', - thumbnailUrl: null, - duration: null, - startDate: '2026.07', - endDate: '2026.07', - location: '경기도 양평군', - companion: null, - region: null, - detailType: '전시', - hashtags: ['summer', 'nature', 'experience'], - likedAt: '2026-07-19T10:12:00', - }, - { - id: 8, - category: 'EVENT', - title: '양평수박축제', - thumbnailUrl: null, - duration: null, - startDate: '2026.07', - endDate: '2026.07', - location: '경기도 양평군', - companion: null, - region: null, - detailType: '공연', - hashtags: ['summer', 'nature', 'experience'], - likedAt: '2026-07-18T21:45:00', - }, - { - id: 9, - category: 'PLACE', - title: '양평수박축제', - thumbnailUrl: null, - duration: null, - startDate: '2026.07', - endDate: '2026.07', - location: '경기도 양평군', - companion: null, - region: null, - detailType: '음식점', - hashtags: ['summer', 'nature', 'experience'], - likedAt: '2026-07-17T12:20:00', - }, - { - id: 10, - category: 'PLACE', - title: '양평수박축제', - thumbnailUrl: null, - duration: null, - startDate: '2026.07', - endDate: '2026.07', - location: '경기도 양평군', - companion: null, - region: null, - detailType: '카페 및 베이커리', - hashtags: ['summer', 'nature', 'experience'], - likedAt: '2026-07-16T08:55:00', - }, - { - id: 11, - category: 'PLACE', - title: '양평수박축제', - thumbnailUrl: null, - duration: null, - startDate: '2026.07', - endDate: '2026.07', - location: '경기도 양평군', - companion: null, - region: null, - detailType: '지역명소', - hashtags: ['summer', 'nature', 'experience'], - likedAt: '2026-07-15T19:30:00', - }, - { - id: 12, - category: 'PLACE', - title: '양평수박축제', - thumbnailUrl: null, - duration: null, - startDate: '2026.07', - endDate: '2026.07', - location: '경기도 양평군', - companion: null, - region: null, - detailType: '체험 및 활동', - hashtags: ['summer', 'nature', 'experience'], - likedAt: '2026-07-14T14:05:00', - }, -]; diff --git a/src/pages/likes/hooks/useLikedItems.ts b/src/pages/likes/hooks/useLikedItems.ts new file mode 100644 index 00000000..0e4585c5 --- /dev/null +++ b/src/pages/likes/hooks/useLikedItems.ts @@ -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, + }); +} diff --git a/src/pages/likes/index.tsx b/src/pages/likes/index.tsx index 8d6448d5..4db80b19 100644 --- a/src/pages/likes/index.tsx +++ b/src/pages/likes/index.tsx @@ -1,29 +1,48 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; 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 { useNavigateToCourseDetail } from '../../hooks/useCourses'; import { useGlobalScale } from '../../hooks/useGlobalScale'; +import useInfiniteScroll from '../../hooks/useInfiniteScroll'; +import { + formatTodayOpeningHours, + usePlaceOpeningHours, +} from '../../hooks/usePlaceOpeningHours'; +import { toContentTagIds } from '../../utils/contentTags'; +import { buildFestivalDetailPath } from '../../utils/routes'; 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; @@ -39,11 +58,15 @@ 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 navigate = useNavigate(); const scale = useGlobalScale(); + const { showToast } = useToast(); + const { goToCourseDetail } = useNavigateToCourseDetail(); const [keyword, setKeyword] = useState(''); - // ponytail: 좋아요 해제는 화면 상태로만 반영한다. API 연동 시 뮤테이션으로 교체. const [unlikedIds, setUnlikedIds] = useState>(new Set()); const { @@ -54,12 +77,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( @@ -94,21 +133,56 @@ function LikesPage() { ), [activeLikedItems, keyword, selectedFilters] ); + const openingHoursByItemId = usePlaceOpeningHours( + likedItems.flatMap((item) => + item.category === 'PLACE' + ? [{ id: item.id, name: item.title, address: item.location }] + : [] + ) + ); - const toggleLike = (itemKey: string) => { - setUnlikedIds((currentIds) => { - const nextIds = new Set(currentIds); + const handleCardClick = useCallback( + (item: LikedItem) => { + if (item.category === 'COURSE') { + void goToCourseDetail(item.id); + } else if (item.category === 'EVENT') { + navigate(buildFestivalDetailPath(item.id)); + } + }, + [goToCourseDetail, navigate] + ); - 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 (
- 마음에 들었던 여행을 다시 만나보세요 + 마음에 들었던 여행을 다시 만나보세요.

{likedItems.map((item) => { const itemKey = `${item.category}-${item.id}`; - const { firstInfo, secondInfo, thirdInfo } = + const { firstInfo, secondInfo, thirdInfo, distanceInfo } = toLikedItemInfoLines(item); + const placeHours = + item.category === 'PLACE' + ? openingHoursByItemId.get(item.id) + : undefined; + const openingHours = + placeHours && formatTodayOpeningHours(placeHours); return ( toggleLike(itemKey)} + onClick={ + item.category === 'PLACE' + ? undefined + : () => handleCardClick(item) + } + onLikeClick={() => void handleUnlike(item)} /> ); })}
- ) : ( + ) : null} + + {hasEmptyResult ? (

좋아요한 항목이 없습니다.

- )} + ) : null} + + {isError ? ( +

+ 좋아요 목록을 불러오지 못했어요. +

+ ) : null} + +