diff --git a/reports/completion/TSK-012-05-kto-tourism-map-layer-infosheet.md b/reports/completion/TSK-012-05-kto-tourism-map-layer-infosheet.md new file mode 100644 index 0000000..80e1536 --- /dev/null +++ b/reports/completion/TSK-012-05-kto-tourism-map-layer-infosheet.md @@ -0,0 +1,59 @@ +# TSK-012-05 KTO Tourism Map Layer and InfoSheet Completion + +## Scope + +- Scope-ID: `TSK-012-05` +- Issue: `#409` +- Parent Issue: `#404` +- Branch: `kto-tourism-map-layer-infosheet` +- Status: local validation complete, PR pending + +## Responsibility Map + +- `src/api/tourismClient.ts`: Web Front consumer contract for `/api/tourism/places`. +- `src/tourismTypes.ts`: Web Front-owned KTO tourism DTO contract. +- `src/hooks/useTourismMapState.ts`: Map-stage tourism overlay UI state. +- `src/components/naver-map/useNaverTourismMarkers.ts`: Naver SDK marker mutation for non-curated tourism info items. +- `src/components/TourismInfoSheet.tsx`: Read-only information sheet for `isCurated: false` tourism places. +- `test/e2e/tourism-map-layer.spec.ts`: UIUX-017 regression for default OFF fetch behavior. + +## Dependency Direction + +- App coordinator -> `tourismClient` -> Worker API path. +- Map stage props -> map stage view -> Naver map owner hooks. +- `naver-map` internals own SDK `any`/marker mutation boundaries. +- Tourism DTOs stay in Web Front consumer contract and are not pushed into Worker provider code. + +## Test Seam + +- Unit: tourism client path generation and audit baseline contract. +- Integration: existing `MapTabStage` route preview contract updated with disabled tourism defaults. +- E2E: tourism layer remains OFF on initial map load and fetches only after the map subnav toggle. + +## Scope Map + +- Included: Web Front KTO tourism consumer contract, map toggle, optional marker layer, read-only info sheet, e2e fixture support. +- Excluded: provider/backend schema changes, KTO import/sync implementation, OAuth/session behavior, user review/stamp flows. + +## Architecture Risk + +- KTO marker click is not browser-E2E clickable in the default test environment because Naver SDK is unavailable without `PUBLIC_NAVER_MAP_CLIENT_ID`. +- Risk is mitigated by keeping SDK mutation inside `src/components/naver-map` and testing default fetch behavior through Playwright route interception. + +## Validation + +- `npm.cmd run check:numeric-literals`: passed +- `npm.cmd run lint`: passed +- `npm.cmd run typecheck`: passed +- `npm.cmd run test:unit`: passed +- `npm.cmd run test:integration`: passed +- `npm.cmd run test:regression`: passed +- `npm.cmd run test:e2e`: passed +- `npm.cmd run build`: passed +- `git diff --check`: passed, CRLF warnings only + +## Remote Evidence + +- PR: pending +- Merge SHA: pending +- CI URL: pending diff --git a/src/api/tourismClient.ts b/src/api/tourismClient.ts new file mode 100644 index 0000000..d72a82e --- /dev/null +++ b/src/api/tourismClient.ts @@ -0,0 +1,34 @@ +/* + * File: tourismClient.ts + * Purpose: Request KTO tourism places through the Worker consumer API. + * Primary Responsibility: Convert optional UI filters into the stable `/api/tourism/places` query string. + * Design Intent: Keep browser code behind the Worker contract and prevent direct KTO/OpenAPI, Supabase, or admin import calls. + * Non-Goals: This client does not normalize provider rows or perform admin import/sync operations. + * Dependencies: `fetchJson` API wrapper and `TourismPlacesResponse` DTO. + */ +import type { TourismPlacesQuery, TourismPlacesResponse } from '../tourismTypes'; +import { fetchJson } from './core'; + +function appendOptionalParam(params: URLSearchParams, key: string, value: string | number | null | undefined) { + if (value === null || value === undefined || value === '') { + return; + } + params.set(key, String(value)); +} + +export function buildTourismPlacesPath(query: TourismPlacesQuery = {}) { + const params = new URLSearchParams(); + + appendOptionalParam(params, 'category', query.category); + appendOptionalParam(params, 'district', query.district); + appendOptionalParam(params, 'ktoContentTypeId', query.ktoContentTypeId); + appendOptionalParam(params, 'ktoFacet', query.ktoFacet); + appendOptionalParam(params, 'limit', query.limit); + + const queryString = params.toString(); + return queryString ? `/api/tourism/places?${queryString}` : '/api/tourism/places'; +} + +export function getTourismPlaces(query: TourismPlacesQuery = {}) { + return fetchJson(buildTourismPlacesPath(query)); +} diff --git a/src/components/AppMapStageView.tsx b/src/components/AppMapStageView.tsx index f7dbb1e..ad0f947 100644 --- a/src/components/AppMapStageView.tsx +++ b/src/components/AppMapStageView.tsx @@ -1,6 +1,7 @@ import { memo } from 'react'; import { MapTabStage } from './MapTabStage'; import { MapStageCategoryStrip } from './map-stage/MapStageCategoryStrip'; +import type { TourismPlaceItem } from '../tourismTypes'; import type { ApiStatus, Category, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../types/core'; import type { SessionUser } from '../types/auth'; import type { BootstrapResponse } from '../types/review'; @@ -12,6 +13,7 @@ interface AppMapStageViewProps { festivals: FestivalItem[]; selectedPlace: Place | null; selectedFestival: FestivalItem | null; + selectedTourismPlace: TourismPlaceItem | null; currentPosition: { latitude: number; longitude: number } | null; mapLocationStatus: ApiStatus; mapLocationFocusKey: number; @@ -31,9 +33,20 @@ interface AppMapStageViewProps { canCreateReview: boolean; hasCreatedReviewToday: boolean; initialMapViewport: { lat: number; lng: number; zoom: number }; + showTourismInfo: boolean; + tourismPlaces: TourismPlaceItem[]; + tourismSourceReady: boolean; + tourismLoading: boolean; + tourismError: string | null; + tourismSheetState: 'partial' | 'full'; }; mapActions: { setActiveCategory: (category: Category) => void; + onToggleTourismInfo: () => void; + onOpenTourismPlace: (tourismPlaceId: string) => void; + onCloseTourismInfoSheet: () => void; + onExpandTourismInfoSheet: () => void; + onCollapseTourismInfoSheet: () => void; onOpenPlaceFeed: () => void; onOpenPlace: (placeId: string) => void; onOpenRoutePreviewPlace: (placeId: string) => void; @@ -59,10 +72,20 @@ export function AppMapStageSubNav({ mapActions, }: AppMapStageSubNavProps) { return ( - +
+ + +
); } @@ -75,6 +98,7 @@ export const AppMapStageView = memo(function AppMapStageView({ mapData={{ filteredPlaces: mapData.filteredPlaces, festivals: mapData.festivals, + tourismPlaces: mapData.showTourismInfo ? mapData.tourismPlaces : [], currentPosition: mapData.currentPosition, mapLocationStatus: mapData.mapLocationStatus, mapLocationFocusKey: mapData.mapLocationFocusKey, @@ -123,6 +147,20 @@ export const AppMapStageView = memo(function AppMapStageView({ onExpandFestivalDrawer: mapActions.onExpandFestivalDrawer, onCollapseFestivalDrawer: mapActions.onCollapseFestivalDrawer, }} + tourismSheet={{ + selectedTourismPlace: mapData.selectedTourismPlace, + sheetState: mapData.tourismSheetState, + sourceReady: mapData.tourismSourceReady, + loading: mapData.tourismLoading, + error: mapData.tourismError, + onClose: mapActions.onCloseTourismInfoSheet, + onExpand: mapActions.onExpandTourismInfoSheet, + onCollapse: mapActions.onCollapseTourismInfoSheet, + }} + tourismActions={{ + selectedTourismPlaceId: mapData.selectedTourismPlace?.id ?? null, + onOpenTourismPlace: mapActions.onOpenTourismPlace, + }} /> ); }); diff --git a/src/components/MapTabStage.tsx b/src/components/MapTabStage.tsx index aec9bb0..cf3d347 100644 --- a/src/components/MapTabStage.tsx +++ b/src/components/MapTabStage.tsx @@ -8,6 +8,8 @@ export function MapTabStage({ viewportData, placeSheet, festivalSheet, + tourismSheet, + tourismActions, }: MapTabStageProps) { return (
@@ -17,8 +19,9 @@ export function MapTabStage({ viewportData={viewportData} placeSheet={placeSheet} festivalSheet={festivalSheet} + tourismActions={tourismActions} /> - +
); } diff --git a/src/components/NaverMap.tsx b/src/components/NaverMap.tsx index 8b9d8bd..06ad399 100644 --- a/src/components/NaverMap.tsx +++ b/src/components/NaverMap.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react'; import { getClientConfig } from '../config'; +import type { TourismPlaceItem } from '../tourismTypes'; import type { ApiStatus, FestivalItem, Place } from '../types/core'; import { NaverMapStatus } from './naver-map/NaverMapStatus'; import { useNaverMapInstance } from './naver-map/useNaverMapInstance'; @@ -10,12 +11,15 @@ import { useNaverViewportSync } from './naver-map/useNaverViewportSync'; interface NaverMapProps { places: Place[]; festivals: FestivalItem[]; + tourismPlaces: TourismPlaceItem[]; selectedPlaceId: string | null; selectedFestivalId: string | null; + selectedTourismPlaceId: string | null; selectedPlace?: Place | null; selectedFestival?: FestivalItem | null; onSelectPlace: (placeId: string) => void; onSelectFestival: (festivalId: string) => void; + onSelectTourismPlace: (tourismPlaceId: string) => void; currentPosition: { latitude: number; longitude: number } | null; currentLocationStatus: ApiStatus; currentLocationMessage: string | null; @@ -31,12 +35,15 @@ interface NaverMapProps { export function NaverMap({ places, festivals, + tourismPlaces, selectedPlaceId, selectedFestivalId, + selectedTourismPlaceId, selectedPlace = null, selectedFestival = null, onSelectPlace, onSelectFestival, + onSelectTourismPlace, currentPosition, currentLocationStatus, currentLocationMessage, @@ -73,12 +80,15 @@ export function NaverMap({ mapElementRef, places, festivals, + tourismPlaces, selectedPlaceId, selectedFestivalId, + selectedTourismPlaceId, selectedPlace, selectedFestival, onSelectPlace, onSelectFestival, + onSelectTourismPlace, currentPosition, focusCurrentLocationKey, routePreviewPlaces, diff --git a/src/components/TourismInfoSheet.tsx b/src/components/TourismInfoSheet.tsx new file mode 100644 index 0000000..6389256 --- /dev/null +++ b/src/components/TourismInfoSheet.tsx @@ -0,0 +1,87 @@ +/* + * File: TourismInfoSheet.tsx + * Purpose: Present non-curated KTO tourism place information from the map layer. + * Primary Responsibility: Render a read-only information sheet without stamp, review, or feed actions. + * Design Intent: Reuse the existing map sheet visual language while keeping tourism info separate from curated place interactions. + * Non-Goals: This component does not allow stamping, review creation, or direct KTO/OpenAPI calls. + * Dependencies: TourismPlaceItem DTO and map sheet state class helper. + */ +import type { TourismPlaceItem } from '../tourismTypes'; +import type { DrawerState } from '../types/core'; +import { buildMapSheetClassName } from './map-stage/mapSheetState'; +import type { MapSheetState } from './map-stage/mapSheetState'; + +export type TourismInfoSheetState = 'partial' | 'full'; + +interface TourismInfoSheetProps { + place: TourismPlaceItem | null; + isOpen: boolean; + sheetState: TourismInfoSheetState; + onClose: () => void; + onExpand: () => void; + onCollapse: () => void; +} + +export function TourismInfoSheet({ + place, + isOpen, + sheetState, + onClose, + onExpand, + onCollapse, +}: TourismInfoSheetProps) { + if (!place || !isOpen) { + return null; + } + + const drawerState: DrawerState = sheetState === 'full' ? 'full' : 'partial'; + const mapSheetState: MapSheetState = sheetState === 'full' ? 'full' : 'peek'; + const sheetClassName = buildMapSheetClassName('place-drawer', mapSheetState, drawerState); + + return ( +
+ + +
+
+
+

KTO INFO

+

{place.title}

+

{place.summary || place.description || '관광지 기본 정보를 확인할 수 있어요.'}

+
+ +
+ +
+ {place.category ? {place.category} : null} + {place.district ? {place.district} : null} +
+ +
+
+ 주소 +

{place.address || '주소 정보가 아직 제공되지 않았어요.'}

+
+
+ 출처 +

{place.sourceName || 'KTO 관광정보'}

+
+ {place.homepageUrl ? ( + + 자세히 보기 + + ) : null} +
+
+
+ ); +} diff --git a/src/components/map-stage/MapStageMapSurface.tsx b/src/components/map-stage/MapStageMapSurface.tsx index bb5895c..265b25c 100644 --- a/src/components/map-stage/MapStageMapSurface.tsx +++ b/src/components/map-stage/MapStageMapSurface.tsx @@ -9,6 +9,7 @@ interface MapStageMapSurfaceProps { viewportData: MapTabStageProps['viewportData']; placeSheet: MapTabStageProps['placeSheet']; festivalSheet: MapTabStageProps['festivalSheet']; + tourismActions: MapTabStageProps['tourismActions']; } export function MapStageMapSurface({ @@ -17,6 +18,7 @@ export function MapStageMapSurface({ viewportData, placeSheet, festivalSheet, + tourismActions, }: MapStageMapSurfaceProps) { const showRoutePreview = !placeSheet.selectedPlace && !festivalSheet.selectedFestival; @@ -25,12 +27,15 @@ export function MapStageMapSurface({ @@ -49,6 +52,15 @@ export function MapStageSheets({ placeSheet, festivalSheet }: MapStageSheetsProp onExpand={festivalSheet.onExpandFestivalDrawer} onCollapse={festivalSheet.onCollapseFestivalDrawer} /> + + ); } diff --git a/src/components/map-stage/mapTabStageTypes.ts b/src/components/map-stage/mapTabStageTypes.ts index 9173696..d214fea 100644 --- a/src/components/map-stage/mapTabStageTypes.ts +++ b/src/components/map-stage/mapTabStageTypes.ts @@ -1,4 +1,5 @@ import type { ApiStatus, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../../types/core'; +import type { TourismPlaceItem } from '../../tourismTypes'; import type { SessionUser } from '../../types/auth'; import type { BootstrapResponse } from '../../types/review'; @@ -6,6 +7,7 @@ export interface MapTabStageProps { mapData: { filteredPlaces: Place[]; festivals: FestivalItem[]; + tourismPlaces: TourismPlaceItem[]; currentPosition: { latitude: number; longitude: number } | null; mapLocationStatus: ApiStatus; mapLocationFocusKey: number; @@ -54,4 +56,18 @@ export interface MapTabStageProps { onExpandFestivalDrawer: () => void; onCollapseFestivalDrawer: () => void; }; + tourismSheet: { + selectedTourismPlace: TourismPlaceItem | null; + sheetState: 'partial' | 'full'; + sourceReady: boolean; + loading: boolean; + error: string | null; + onClose: () => void; + onExpand: () => void; + onCollapse: () => void; + }; + tourismActions: { + selectedTourismPlaceId: string | null; + onOpenTourismPlace: (tourismPlaceId: string) => void; + }; } diff --git a/src/components/naver-map/markerContent.ts b/src/components/naver-map/markerContent.ts index 2a8e3be..a88042f 100644 --- a/src/components/naver-map/markerContent.ts +++ b/src/components/naver-map/markerContent.ts @@ -1,5 +1,6 @@ import { cssPx, UiNaverMarkerVisualConfig } from '../../config/uiTokenConfig'; import { categoryInfo } from '../../lib/categories'; +import type { TourismPlaceItem } from '../../tourismTypes'; import type { FestivalItem, Place } from '../../types/core'; type FestivalWithCoordinates = FestivalItem & { @@ -47,6 +48,26 @@ export function festivalMarkerContent(_festival: FestivalItem, isActive: boolean `; } +export function tourismMarkerContent(_place: TourismPlaceItem, isActive: boolean) { + const ring = isActive ? '#5f7ea8' : 'rgba(95, 126, 168, 0.24)'; + const scale = isActive ? UiNaverMarkerVisualConfig.activeFestivalScale : UiNaverMarkerVisualConfig.defaultScale; + + return ` +
+
+
i
+
+
+ `; +} + +export function hasTourismCoordinates(place: TourismPlaceItem): place is TourismPlaceItem & { latitude: number; longitude: number } { + return typeof place.latitude === 'number' + && Number.isFinite(place.latitude) + && typeof place.longitude === 'number' + && Number.isFinite(place.longitude); +} + export function hasFestivalCoordinates(festival: FestivalItem): festival is FestivalWithCoordinates { return typeof festival.latitude === 'number' && Number.isFinite(festival.latitude) diff --git a/src/components/naver-map/useNaverMapInteractions.ts b/src/components/naver-map/useNaverMapInteractions.ts index 524c899..0ddd372 100644 --- a/src/components/naver-map/useNaverMapInteractions.ts +++ b/src/components/naver-map/useNaverMapInteractions.ts @@ -1,9 +1,9 @@ import { useRef } from 'react'; +import type { TourismPlaceItem } from '../../tourismTypes'; import type { FestivalItem, Place } from '../../types/core'; import { useNaverCurrentLocationMarker } from './useNaverCurrentLocationMarker'; import { useNaverCurrentLocationFocus } from './useNaverCurrentLocationFocus'; -import { useNaverFestivalMarkers } from './useNaverFestivalMarkers'; -import { useNaverPlaceMarkers } from './useNaverPlaceMarkers'; +import { useNaverMarkerLayers } from './useNaverMarkerLayers'; import { useNaverRoutePreviewOverlay } from './useNaverRoutePreviewOverlay'; import { useNaverSelectionSync } from './useNaverSelectionSync'; import type { NaverMapInstance, NaverMapsApi, NaverMarkerInstance, NaverPolylineInstance } from './naverMapTypes'; @@ -15,12 +15,15 @@ type MapInteractionsArgs = { mapElementRef: React.MutableRefObject; places: Place[]; festivals: FestivalItem[]; + tourismPlaces: TourismPlaceItem[]; selectedPlaceId: string | null; selectedFestivalId: string | null; + selectedTourismPlaceId: string | null; selectedPlace?: Place | null; selectedFestival?: FestivalItem | null; onSelectPlace: (placeId: string) => void; onSelectFestival: (festivalId: string) => void; + onSelectTourismPlace: (tourismPlaceId: string) => void; currentPosition: { latitude: number; longitude: number } | null; focusCurrentLocationKey: number; routePreviewPlaces: Place[]; @@ -33,12 +36,15 @@ export function useNaverMapInteractions({ mapElementRef, places, festivals, + tourismPlaces, selectedPlaceId, selectedFestivalId, + selectedTourismPlaceId, selectedPlace, selectedFestival, onSelectPlace, onSelectFestival, + onSelectTourismPlace, currentPosition, focusCurrentLocationKey, routePreviewPlaces, @@ -47,22 +53,19 @@ export function useNaverMapInteractions({ const routeStepMarkersRef = useRef([]); const lastHandledCurrentLocationFocusKeyRef = useRef(0); - useNaverPlaceMarkers({ + useNaverMarkerLayers({ status, mapsApi, mapRef, places, - selectedPlaceId, - onSelectPlace, - }); - - useNaverFestivalMarkers({ - status, - mapsApi, - mapRef, festivals, + tourismPlaces, + selectedPlaceId, selectedFestivalId, + selectedTourismPlaceId, + onSelectPlace, onSelectFestival, + onSelectTourismPlace, }); useNaverCurrentLocationMarker({ diff --git a/src/components/naver-map/useNaverMarkerLayers.ts b/src/components/naver-map/useNaverMarkerLayers.ts new file mode 100644 index 0000000..6305a12 --- /dev/null +++ b/src/components/naver-map/useNaverMarkerLayers.ts @@ -0,0 +1,71 @@ +/* + * File: useNaverMarkerLayers.ts + * Purpose: Coordinate Naver map marker layer hooks for curated places, festivals, and KTO tourism items. + * Primary Responsibility: Keep marker-layer composition local to the naver-map owner folder. + * Design Intent: Let useNaverMapInteractions stay focused on high-level map orchestration. + * Non-Goals: This hook does not fetch marker data or decide selected sheet state. + * Dependencies: Naver marker hooks and typed app DTOs. + */ +import type { TourismPlaceItem } from '../../tourismTypes'; +import type { FestivalItem, Place } from '../../types/core'; +import { useNaverFestivalMarkers } from './useNaverFestivalMarkers'; +import { useNaverPlaceMarkers } from './useNaverPlaceMarkers'; +import { useNaverTourismMarkers } from './useNaverTourismMarkers'; +import type { NaverMapInstance, NaverMapsApi } from './naverMapTypes'; + +type MarkerLayersArgs = { + status: 'loading' | 'ready' | 'error'; + mapsApi: NaverMapsApi | undefined; + mapRef: React.MutableRefObject; + places: Place[]; + festivals: FestivalItem[]; + tourismPlaces: TourismPlaceItem[]; + selectedPlaceId: string | null; + selectedFestivalId: string | null; + selectedTourismPlaceId: string | null; + onSelectPlace: (placeId: string) => void; + onSelectFestival: (festivalId: string) => void; + onSelectTourismPlace: (tourismPlaceId: string) => void; +}; + +export function useNaverMarkerLayers({ + status, + mapsApi, + mapRef, + places, + festivals, + tourismPlaces, + selectedPlaceId, + selectedFestivalId, + selectedTourismPlaceId, + onSelectPlace, + onSelectFestival, + onSelectTourismPlace, +}: MarkerLayersArgs) { + useNaverPlaceMarkers({ + status, + mapsApi, + mapRef, + places, + selectedPlaceId, + onSelectPlace, + }); + + useNaverFestivalMarkers({ + status, + mapsApi, + mapRef, + festivals, + selectedFestivalId, + onSelectFestival, + }); + + useNaverTourismMarkers({ + status, + mapsApi, + mapRef, + tourismPlaces, + selectedTourismPlaceId, + onSelectTourismPlace, + }); +} diff --git a/src/components/naver-map/useNaverTourismMarkers.ts b/src/components/naver-map/useNaverTourismMarkers.ts new file mode 100644 index 0000000..6ed1d13 --- /dev/null +++ b/src/components/naver-map/useNaverTourismMarkers.ts @@ -0,0 +1,85 @@ +/* + * File: useNaverTourismMarkers.ts + * Purpose: Render optional KTO tourism information markers on the Naver map. + * Primary Responsibility: Synchronize tourism marker instances with the current tourism overlay items and selected marker id. + * Design Intent: Keep Naver SDK mutation isolated inside the naver-map owner folder while the rest of the app uses typed tourism DTOs. + * Non-Goals: This hook does not fetch tourism data or render the InfoSheet. + * Dependencies: Naver Maps SDK local contracts, tourism marker HTML helpers, and React effects. + */ +import { useEffect, useRef } from 'react'; +import type { MutableRefObject } from 'react'; +import { NaverMarkerConfig } from '../../config/mapConfig'; +import type { TourismPlaceItem } from '../../tourismTypes'; +import { hasTourismCoordinates, tourismMarkerContent } from './markerContent'; +import type { NaverMapInstance, NaverMapsApi, NaverMarkerInstance } from './naverMapTypes'; + +type TourismPlaceWithCoordinates = TourismPlaceItem & { + latitude: number; + longitude: number; +}; + +type TourismMarkersArgs = { + status: 'loading' | 'ready' | 'error'; + mapsApi: NaverMapsApi | undefined; + mapRef: MutableRefObject; + tourismPlaces: TourismPlaceItem[]; + selectedTourismPlaceId: string | null; + onSelectTourismPlace: (tourismPlaceId: string) => void; +}; + +export function useNaverTourismMarkers({ + status, + mapsApi, + mapRef, + tourismPlaces, + selectedTourismPlaceId, + onSelectTourismPlace, +}: TourismMarkersArgs) { + const tourismMarkersRef = useRef>(new Map()); + + useEffect(() => { + if (status !== 'ready' || !mapsApi || !mapRef.current) { + return; + } + + const visiblePlaces: TourismPlaceWithCoordinates[] = tourismPlaces + .filter(hasTourismCoordinates) + .filter((place) => !place.isCurated); + const nextIds = new Set(visiblePlaces.map((place) => place.id)); + const markerAnchor = new mapsApi.Point(NaverMarkerConfig.anchor.default.x, NaverMarkerConfig.anchor.default.y); + + for (const [placeId, marker] of tourismMarkersRef.current.entries()) { + if (!nextIds.has(placeId)) { + marker.setMap(null); + tourismMarkersRef.current.delete(placeId); + } + } + + visiblePlaces.forEach((place) => { + const existing = tourismMarkersRef.current.get(place.id); + const position = new mapsApi.LatLng(place.latitude, place.longitude); + if (existing) { + existing.setPosition(position); + existing.setIcon({ + content: tourismMarkerContent(place, place.id === selectedTourismPlaceId), + anchor: markerAnchor, + }); + existing.setZIndex(place.id === selectedTourismPlaceId ? NaverMarkerConfig.zIndex.festivalActive : NaverMarkerConfig.zIndex.festivalDefault); + return; + } + + const marker = new mapsApi.Marker({ + map: mapRef.current, + position, + title: '', + zIndex: place.id === selectedTourismPlaceId ? NaverMarkerConfig.zIndex.festivalActive : NaverMarkerConfig.zIndex.festivalDefault, + icon: { + content: tourismMarkerContent(place, place.id === selectedTourismPlaceId), + anchor: markerAnchor, + }, + }); + mapsApi.Event.addListener(marker, 'click', () => onSelectTourismPlace(place.id)); + tourismMarkersRef.current.set(place.id, marker); + }); + }, [mapRef, mapsApi, onSelectTourismPlace, selectedTourismPlaceId, status, tourismPlaces]); +} diff --git a/src/hooks/app-coordinator/useAppCoordinatorEffects.ts b/src/hooks/app-coordinator/useAppCoordinatorEffects.ts index 65b2b48..44d1ed1 100644 --- a/src/hooks/app-coordinator/useAppCoordinatorEffects.ts +++ b/src/hooks/app-coordinator/useAppCoordinatorEffects.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { getTourismPlaces } from '../../api/tourismClient'; import { FeedbackRuntimeConfig } from '../../config/runtimeLimitConfig'; import { getInitialNotice } from '../app-route/useAppRouteState'; import { useAppFeedbackEffects } from '../useAppFeedbackEffects'; @@ -32,6 +33,7 @@ export function useAppCoordinatorEffects({ const { activeTab, goToTab, selectedPlaceId } = routeState; const { auth: { sessionUser }, + map: { showTourismInfo, setSelectedTourismPlaceId }, myPage: { myPageTab }, } = domainState; const { mapLocationMessage, notice, setNotice } = shellRuntimeState; @@ -48,6 +50,11 @@ export function useAppCoordinatorEffects({ setPlaces, setSelectedPlaceReviews, setStampState, + setTourismError, + setTourismLoading, + setTourismPlaces, + setTourismSourceReady, + tourismPlaces, } = dataState; const { dataLoaders: { @@ -79,6 +86,52 @@ export function useAppCoordinatorEffects({ noticeDismissDelayMs: FeedbackRuntimeConfig.noticeDismissDelayMs, }); + useEffect(() => { + if (!showTourismInfo) { + setSelectedTourismPlaceId(null); + return; + } + if (tourismPlaces.length > 0) { + return; + } + + let isActive = true; + setTourismLoading(true); + setTourismError(null); + + getTourismPlaces() + .then((response) => { + if (!isActive) { + return; + } + setTourismPlaces(response.items); + setTourismSourceReady(response.sourceReady); + }) + .catch((error: unknown) => { + if (!isActive) { + return; + } + setTourismError(formatErrorMessage(error)); + }) + .finally(() => { + if (isActive) { + setTourismLoading(false); + } + }); + + return () => { + isActive = false; + }; + }, [ + setSelectedTourismPlaceId, + setTourismError, + setTourismLoading, + setTourismPlaces, + setTourismSourceReady, + showTourismInfo, + tourismPlaces.length, + ]); + useAppBootstrapLifecycle({ activeTab, selectedPlaceId, diff --git a/src/hooks/app-stage-props/useMapStageProps.ts b/src/hooks/app-stage-props/useMapStageProps.ts index 7757399..ef9e58f 100644 --- a/src/hooks/app-stage-props/useMapStageProps.ts +++ b/src/hooks/app-stage-props/useMapStageProps.ts @@ -21,8 +21,19 @@ export function useMapStageProps(state: AppShellCoordinatorState) { setActiveCategory, stampActionMessage, stampActionStatus, + setSelectedTourismPlaceId, + setShowTourismInfo, + setTourismSheetState, + selectedTourismPlaceId, + showTourismInfo, + tourismError, + tourismLoading, + tourismPlaces, + tourismSheetState, + tourismSourceReady, viewModels, } = state; + const selectedTourismPlace = tourismPlaces.find((place) => place.id === selectedTourismPlaceId) ?? null; return { mapData: { @@ -31,6 +42,7 @@ export function useMapStageProps(state: AppShellCoordinatorState) { festivals, selectedPlace: viewModels.selectedPlace, selectedFestival: viewModels.selectedFestival, + selectedTourismPlace, currentPosition, mapLocationStatus, mapLocationFocusKey, @@ -50,9 +62,28 @@ export function useMapStageProps(state: AppShellCoordinatorState) { canCreateReview: viewModels.canCreateReview, hasCreatedReviewToday: viewModels.hasCreatedReviewToday, initialMapViewport, + showTourismInfo, + tourismPlaces, + tourismSourceReady, + tourismLoading, + tourismError, + tourismSheetState, }, mapActions: { setActiveCategory, + onToggleTourismInfo: () => setShowTourismInfo((current) => !current), + onOpenTourismPlace: (tourismPlaceId: string) => { + const tourismPlace = tourismPlaces.find((place) => place.id === tourismPlaceId); + if (tourismPlace?.isCurated && tourismPlace.curatedPlace) { + mapStageActions.handleMapOpenPlace(tourismPlace.curatedPlace.id); + return; + } + setSelectedTourismPlaceId(tourismPlaceId); + setTourismSheetState('partial'); + }, + onCloseTourismInfoSheet: () => setSelectedTourismPlaceId(null), + onExpandTourismInfoSheet: () => setTourismSheetState('full'), + onCollapseTourismInfoSheet: () => setTourismSheetState('partial'), onOpenPlaceFeed: mapStageActions.handleMapOpenPlaceFeed, onOpenPlace: mapStageActions.handleMapOpenPlace, onOpenRoutePreviewPlace: mapStageActions.handleMapOpenRoutePreviewPlace, diff --git a/src/hooks/useAppDataState.ts b/src/hooks/useAppDataState.ts index 118177b..dcea8af 100644 --- a/src/hooks/useAppDataState.ts +++ b/src/hooks/useAppDataState.ts @@ -1,4 +1,5 @@ import { useState } from 'react'; +import type { TourismPlaceItem } from '../tourismTypes'; import type { FestivalItem } from '../types/core'; import type { BootstrapResponse } from '../types/review'; import type { MyPageResponse } from '../types/my-page'; @@ -9,6 +10,10 @@ import { useReviewCollectionState } from './app-data/useReviewCollectionState'; export function useAppDataState(selectedPlaceId: string | null) { const [places, setPlaces] = useState([]); const [festivals, setFestivals] = useState([]); + const [tourismPlaces, setTourismPlaces] = useState([]); + const [tourismSourceReady, setTourismSourceReady] = useState(false); + const [tourismLoading, setTourismLoading] = useState(false); + const [tourismError, setTourismError] = useState(null); const [courses, setCourses] = useState([]); const [stampState, setStampState] = useState({ collectedPlaceIds: [], @@ -28,6 +33,14 @@ export function useAppDataState(selectedPlaceId: string | null) { setPlaces, festivals, setFestivals, + tourismPlaces, + setTourismPlaces, + tourismSourceReady, + setTourismSourceReady, + tourismLoading, + setTourismLoading, + tourismError, + setTourismError, ...reviewCollectionState, courses, setCourses, diff --git a/src/hooks/useMapDomainState.ts b/src/hooks/useMapDomainState.ts index b3fe07f..a02b3a3 100644 --- a/src/hooks/useMapDomainState.ts +++ b/src/hooks/useMapDomainState.ts @@ -1,9 +1,11 @@ import { useMapCategoryState } from './useMapCategoryState'; import { useRoutePreviewState } from './useRoutePreviewState'; +import { useTourismMapState } from './useTourismMapState'; export function useMapDomainState() { return { ...useMapCategoryState(), ...useRoutePreviewState(), + ...useTourismMapState(), }; } diff --git a/src/hooks/useTourismMapState.ts b/src/hooks/useTourismMapState.ts new file mode 100644 index 0000000..8876d5c --- /dev/null +++ b/src/hooks/useTourismMapState.ts @@ -0,0 +1,24 @@ +/* + * File: useTourismMapState.ts + * Purpose: Store map-local KTO tourism overlay UI state. + * Primary Responsibility: Own the tourism visibility toggle, selected tourism item id, and sheet expansion state. + * Design Intent: Keep KTO map overlay state local to map domain instead of encoding it in route URLs before the flow is proven stable. + * Non-Goals: This hook does not fetch tourism data or own curated place navigation. + * Dependencies: React local state. + */ +import { useState } from 'react'; + +export function useTourismMapState() { + const [showTourismInfo, setShowTourismInfo] = useState(false); + const [selectedTourismPlaceId, setSelectedTourismPlaceId] = useState(null); + const [tourismSheetState, setTourismSheetState] = useState<'partial' | 'full'>('partial'); + + return { + showTourismInfo, + setShowTourismInfo, + selectedTourismPlaceId, + setSelectedTourismPlaceId, + tourismSheetState, + setTourismSheetState, + }; +} diff --git a/src/index.css b/src/index.css index f6d0564..6b9d0dc 100644 --- a/src/index.css +++ b/src/index.css @@ -300,6 +300,14 @@ textarea { backdrop-filter: blur(14px); } +.map-stage-subnav { + width: 100%; + height: 100%; + min-width: 0; + display: flex; + align-items: center; +} + .app-shell__sub-nav-slot .map-filter-strip { position: relative; top: auto; @@ -307,6 +315,8 @@ textarea { right: auto; z-index: auto; width: 100%; + min-width: 0; + flex: 1 1 auto; height: 100%; display: flex; align-items: center; @@ -331,6 +341,12 @@ textarea { display: none; } +.tourism-toggle-chip { + flex: 0 0 auto; + margin-left: 8px; + white-space: nowrap; +} + .app-shell__content-slot { position: absolute; inset: 0; diff --git a/src/tourismTypes.ts b/src/tourismTypes.ts new file mode 100644 index 0000000..054d505 --- /dev/null +++ b/src/tourismTypes.ts @@ -0,0 +1,60 @@ +/* + * File: tourismTypes.ts + * Purpose: Define the Web Front consumer contract for Worker-provided KTO tourism places. + * Primary Responsibility: Keep tourism API response and item shapes stable at the browser boundary. + * Design Intent: Store the public consumer DTO near the front-end API client instead of mixing it into map UI internals. + * Non-Goals: This file does not define admin import payloads, Supabase rows, or KTO/OpenAPI provider contracts. + * Dependencies: Worker endpoint `GET /api/tourism/places`. + */ + +export interface TourismFacetOption { + key: string; + label: string; + count: number; +} + +export interface TourismFacets { + categories: TourismFacetOption[]; + districts: TourismFacetOption[]; + contentTypes: TourismFacetOption[]; + ktoFacets: TourismFacetOption[]; +} + +export interface TourismCuratedPlaceLink { + id: string; + slug?: string | null; + name: string; +} + +export interface TourismPlaceItem { + id: string; + title: string; + category: string | null; + district: string | null; + address: string | null; + summary: string | null; + description: string | null; + latitude: number | null; + longitude: number | null; + imageUrl: string | null; + homepageUrl: string | null; + sourceName: string | null; + isCurated: boolean; + curatedPlace: TourismCuratedPlaceLink | null; +} + +export interface TourismPlacesResponse { + sourceReady: boolean; + sourceName: string | null; + importedAt: string | null; + facets: TourismFacets; + items: TourismPlaceItem[]; +} + +export interface TourismPlacesQuery { + category?: string | null; + district?: string | null; + ktoContentTypeId?: string | null; + ktoFacet?: string | null; + limit?: number | null; +} diff --git a/test/e2e/fixtures.ts b/test/e2e/fixtures.ts index ca2d1c2..d8e99f4 100644 --- a/test/e2e/fixtures.ts +++ b/test/e2e/fixtures.ts @@ -1,5 +1,6 @@ import type { Page, Route } from '@playwright/test'; import type { SessionUser } from '../../src/types/auth'; +import type { TourismPlaceItem, TourismPlacesResponse } from '../../src/tourismTypes'; import type { CommunityRouteSort, Course, Place } from '../../src/types/core'; import type { Comment, Review, StampLog, StampState, TravelSession, UserRoute } from '../../src/types/review'; import type { MyComment, MyPageResponse, UserNotification } from '../../src/types/my-page'; @@ -133,6 +134,7 @@ const curatedCourse: Course = { interface E2EStateOptions { authenticated?: boolean; reviews?: Review[]; + tourismPlaces?: TourismPlaceItem[]; } interface E2EAppState { @@ -144,6 +146,7 @@ interface E2EAppState { commentsByReviewId: Record; communityRoutesBySort: Record; notifications: UserNotification[]; + tourismPlaces: TourismPlaceItem[]; } function cloneReview(review: Review): Review { @@ -153,7 +156,7 @@ function cloneReview(review: Review): Review { }; } -export function createE2EAppState({ authenticated = true, reviews = [] }: E2EStateOptions = {}): E2EAppState { +export function createE2EAppState({ authenticated = true, reviews = [], tourismPlaces = [] }: E2EStateOptions = {}): E2EAppState { const clonedReviews = reviews.map(cloneReview); return { user: authenticated ? e2eUser : null, @@ -169,6 +172,7 @@ export function createE2EAppState({ authenticated = true, reviews = [] }: E2ESta latest: [latestRoute], }, notifications: [], + tourismPlaces, }; } @@ -264,6 +268,18 @@ async function handleApiRoute(route: Route, state: E2EAppState) { return; } + if (method === 'GET' && path === '/api/tourism/places') { + const response: TourismPlacesResponse = { + sourceReady: true, + sourceName: 'kto', + importedAt: '2026-06-13T00:00:00.000Z', + facets: { categories: [], districts: [], contentTypes: [], ktoFacets: [] }, + items: state.tourismPlaces, + }; + await fulfillJson(route, response); + return; + } + if (method === 'GET' && path === '/api/reviews') { const placeId = url.searchParams.get('placeId'); await fulfillJson(route, placeId ? state.reviews.filter((review) => review.placeId === placeId) : state.reviews); diff --git a/test/e2e/tourism-map-layer.spec.ts b/test/e2e/tourism-map-layer.spec.ts new file mode 100644 index 0000000..8181c3f --- /dev/null +++ b/test/e2e/tourism-map-layer.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test'; +import { createE2EAppState, installApiFixtures } from './fixtures'; +import type { TourismPlaceItem } from '../../src/tourismTypes'; + +const tourismPlace: TourismPlaceItem = { + id: 'tourism-1', + title: 'KTO 정보 장소', + category: '관광지', + district: '중구', + address: '대전 중구 테스트로 1', + summary: 'KTO 정보성 장소입니다.', + description: null, + latitude: 36.35, + longitude: 127.38, + imageUrl: null, + homepageUrl: 'https://example.com', + sourceName: 'KTO', + isCurated: false, + curatedPlace: null, +}; + +test('UIUX-017 keeps KTO tourism map layer OFF by default and fetches only after toggle', async ({ page }) => { + const tourismRequests: string[] = []; + page.on('request', (request) => { + const url = request.url(); + if (url.includes('/api/tourism/places')) { + tourismRequests.push(url); + } + }); + + await installApiFixtures(page, createE2EAppState({ + authenticated: false, + tourismPlaces: [tourismPlace], + })); + + await page.goto('/'); + + const tourismToggle = page.locator('[data-tourism-toggle="map"]'); + await expect(tourismToggle).toBeVisible(); + expect(tourismRequests).toEqual([]); + + await tourismToggle.click(); + + await expect(tourismToggle).toHaveClass(/is-active/); + await expect.poll(() => tourismRequests.length).toBe(1); +}); diff --git a/test/integration/map-route-preview-card.test.tsx b/test/integration/map-route-preview-card.test.tsx index 6960a76..32ffec3 100644 --- a/test/integration/map-route-preview-card.test.tsx +++ b/test/integration/map-route-preview-card.test.tsx @@ -38,6 +38,7 @@ describe('MapTabStage route preview card', () => { activeCategory: 'all', filteredPlaces: places, festivals: [], + tourismPlaces: [], currentPosition: null, mapLocationStatus: 'idle', mapLocationFocusKey: 0, @@ -84,6 +85,20 @@ describe('MapTabStage route preview card', () => { onExpandFestivalDrawer: vi.fn(), onCollapseFestivalDrawer: vi.fn(), }} + tourismSheet={{ + selectedTourismPlace: null, + sheetState: 'partial', + sourceReady: true, + loading: false, + error: null, + onClose: vi.fn(), + onExpand: vi.fn(), + onCollapse: vi.fn(), + }} + tourismActions={{ + selectedTourismPlaceId: null, + onOpenTourismPlace: vi.fn(), + }} mapActions={{ setActiveCategory: vi.fn(), }} diff --git a/test/unit/second-uiux-audit-baseline.test.ts b/test/unit/second-uiux-audit-baseline.test.ts index f2e76a1..4f4d967 100644 --- a/test/unit/second-uiux-audit-baseline.test.ts +++ b/test/unit/second-uiux-audit-baseline.test.ts @@ -46,9 +46,9 @@ describe('TSK-012-01 second UI/UX audit baseline', () => { expect(eventTab).not.toContain('event-segment'); }); - it('records that the KTO tourism consumer contract is not present on current main', () => { - expect(sourceExists('src/api/tourismClient.ts')).toBe(false); - expect(sourceExists('src/tourismTypes.ts')).toBe(false); + it('records that the KTO tourism consumer contract is present for the map layer child issue', () => { + expect(sourceExists('src/api/tourismClient.ts')).toBe(true); + expect(sourceExists('src/tourismTypes.ts')).toBe(true); }); it('records the remaining map overlay and surface CSS cleanup debt', () => { diff --git a/test/unit/tourism-client.test.ts b/test/unit/tourism-client.test.ts new file mode 100644 index 0000000..e336fec --- /dev/null +++ b/test/unit/tourism-client.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getTourismPlaces } from '../../src/api/tourismClient'; + +const fetchMock = vi.fn(); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + sourceReady: true, + sourceName: 'kto', + importedAt: '2026-06-13T00:00:00.000Z', + facets: { categories: [], districts: [], contentTypes: [], ktoFacets: [] }, + items: [], + }), + }); +}); + +describe('tourismClient', () => { + it('requests tourism places through the Worker consumer contract', async () => { + await getTourismPlaces({ category: 'cafe', district: '중구', limit: 12 }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestedUrl = String(fetchMock.mock.calls[0][0]); + + expect(requestedUrl).toContain('/api/tourism/places?'); + expect(requestedUrl).toContain('category=cafe'); + expect(requestedUrl).toContain('district=%EC%A4%91%EA%B5%AC'); + expect(requestedUrl).toContain('limit=12'); + }); + + it('omits empty optional filters from the tourism places query', async () => { + await getTourismPlaces({ category: '', district: undefined, limit: undefined }); + + expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/api\/tourism\/places$/); + }); +});