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
59 changes: 59 additions & 0 deletions reports/completion/TSK-012-05-kto-tourism-map-layer-infosheet.md
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions src/api/tourismClient.ts
Original file line number Diff line number Diff line change
@@ -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<TourismPlacesResponse>(buildTourismPlacesPath(query));
}
46 changes: 42 additions & 4 deletions src/components/AppMapStageView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -59,10 +72,20 @@ export function AppMapStageSubNav({
mapActions,
}: AppMapStageSubNavProps) {
return (
<MapStageCategoryStrip
activeCategory={mapData.activeCategory}
onSelectCategory={mapActions.setActiveCategory}
/>
<div className="map-stage-subnav">
<MapStageCategoryStrip
activeCategory={mapData.activeCategory}
onSelectCategory={mapActions.setActiveCategory}
/>
<button
type="button"
className={mapData.showTourismInfo ? 'chip map-filter-chip is-active tourism-toggle-chip' : 'chip map-filter-chip tourism-toggle-chip'}
data-tourism-toggle="map"
onClick={mapActions.onToggleTourismInfo}
>
관광정보
</button>
</div>
);
}

Expand All @@ -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,
Expand Down Expand Up @@ -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,
}}
/>
);
});
5 changes: 4 additions & 1 deletion src/components/MapTabStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export function MapTabStage({
viewportData,
placeSheet,
festivalSheet,
tourismSheet,
tourismActions,
}: MapTabStageProps) {
return (
<div className="map-stage">
Expand All @@ -17,8 +19,9 @@ export function MapTabStage({
viewportData={viewportData}
placeSheet={placeSheet}
festivalSheet={festivalSheet}
tourismActions={tourismActions}
/>
<MapStageSheets placeSheet={placeSheet} festivalSheet={festivalSheet} />
<MapStageSheets placeSheet={placeSheet} festivalSheet={festivalSheet} tourismSheet={tourismSheet} />
</div>
);
}
10 changes: 10 additions & 0 deletions src/components/NaverMap.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -73,12 +80,15 @@ export function NaverMap({
mapElementRef,
places,
festivals,
tourismPlaces,
selectedPlaceId,
selectedFestivalId,
selectedTourismPlaceId,
selectedPlace,
selectedFestival,
onSelectPlace,
onSelectFestival,
onSelectTourismPlace,
currentPosition,
focusCurrentLocationKey,
routePreviewPlaces,
Expand Down
87 changes: 87 additions & 0 deletions src/components/TourismInfoSheet.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className={sheetClassName} data-map-sheet-state={mapSheetState} aria-label="관광정보 시트">
<button
type="button"
className="place-drawer__handle"
aria-label="시트 높이 조절"
onClick={sheetState === 'partial' ? onExpand : onCollapse}
>
<span />
</button>

<div className="place-drawer__content">
<div className="place-drawer__header">
<div>
<p className="eyebrow">KTO INFO</p>
<h2>{place.title}</h2>
<p className="place-drawer__summary">{place.summary || place.description || '관광지 기본 정보를 확인할 수 있어요.'}</p>
</div>
<button type="button" className="text-button" onClick={onClose}>
닫기
</button>
</div>

<div className="place-drawer__badges">
{place.category ? <span className="counter-pill">{place.category}</span> : null}
{place.district ? <span className="counter-pill">{place.district}</span> : null}
</div>

<div className="sheet-card stack-gap">
<div>
<strong>주소</strong>
<p>{place.address || '주소 정보가 아직 제공되지 않았어요.'}</p>
</div>
<div>
<strong>출처</strong>
<p>{place.sourceName || 'KTO 관광정보'}</p>
</div>
{place.homepageUrl ? (
<a className="primary-button primary-button--block" href={place.homepageUrl} target="_blank" rel="noreferrer">
자세히 보기
</a>
) : null}
</div>
</div>
</section>
);
}
5 changes: 5 additions & 0 deletions src/components/map-stage/MapStageMapSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface MapStageMapSurfaceProps {
viewportData: MapTabStageProps['viewportData'];
placeSheet: MapTabStageProps['placeSheet'];
festivalSheet: MapTabStageProps['festivalSheet'];
tourismActions: MapTabStageProps['tourismActions'];
}

export function MapStageMapSurface({
Expand All @@ -17,6 +18,7 @@ export function MapStageMapSurface({
viewportData,
placeSheet,
festivalSheet,
tourismActions,
}: MapStageMapSurfaceProps) {
const showRoutePreview = !placeSheet.selectedPlace && !festivalSheet.selectedFestival;

Expand All @@ -25,12 +27,15 @@ export function MapStageMapSurface({
<NaverMap
places={mapData.filteredPlaces}
festivals={mapData.festivals}
tourismPlaces={mapData.tourismPlaces}
selectedPlaceId={placeSheet.selectedPlace?.id ?? null}
selectedFestivalId={festivalSheet.selectedFestival?.id ?? null}
selectedTourismPlaceId={tourismActions.selectedTourismPlaceId}
selectedPlace={placeSheet.selectedPlace ?? null}
selectedFestival={festivalSheet.selectedFestival ?? null}
onSelectPlace={placeSheet.onOpenPlace}
onSelectFestival={festivalSheet.onOpenFestival}
onSelectTourismPlace={tourismActions.onOpenTourismPlace}
currentPosition={mapData.currentPosition}
currentLocationStatus={mapData.mapLocationStatus}
currentLocationMessage={null}
Expand Down
Loading
Loading