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
42 changes: 42 additions & 0 deletions reports/completion/TSK-014-01-mobile-map-location-sheet-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# TSK-014-01 Mobile map location and tourism sheet layout fix

## Metadata

- Scope-ID: `TSK-014-01`
- Issue: `#426`
- Parent Issue: `#425`
- Branch: `mobile-map-location-sheet-fix`
- Status: ready for PR

## Summary

모바일 지도 화면의 내 위치 찾기와 KTO 관광정보 바텀시트 UX 회귀를 복구했다. 위치 확인 메시지가 지도 상태 컴포넌트까지 전달되도록 고쳤고, `watchPosition` 콜백이 동기 실행될 때 `watchId` 초기화 전에 접근하던 결함을 제거했다. KTO 정보 시트는 공통 `MapBottomSheet` wrapper로 조립하고 기본 full 상태로 열어 이미지 이후 본문, 위치, 출처, 상세 링크까지 접근 가능하게 했다.

## Architecture Boundary Gate

- Responsibility map: `geolocation.ts`는 브라우저 위치 API를 promise 경계로 변환하고, `NaverMapStatus`는 지도 위치 버튼과 피드백 표시를 담당하며, `MapBottomSheet`는 지도 바텀시트 공통 shell을 담당한다.
- Dependency direction: map stage -> Naver map/status -> geolocation action 흐름을 유지한다. KTO 시트는 `TourismPlaceItem` presentation만 담당하고 KTO/OpenAPI 또는 Worker를 직접 호출하지 않는다.
- Test seam: geolocation unit test는 browser API mock으로 성공/권한 거부를 검증하고, NaverMapStatus unit test는 버튼/메시지 public UI를 검증하며, TourismInfoSheet unit test는 공통 bottom sheet shell과 full 상태를 검증한다.
- Scope map: frontend map UX 복구만 변경했다. API path, response shape, DB schema, OAuth flow, Worker/Admin KTO import flow는 변경하지 않았다.
- Architecture risk: full sheet와 bottom nav z-index가 겹치는 구조가 남아 있어 full sheet content padding으로 접근성을 보강했다. bottom tab hide 정책은 별도 앱 셸 상태 설계가 필요하면 후속 이슈로 분리한다.

## Validation

- `npm.cmd run typecheck` passed.
- `npm.cmd run test:unit -- geolocation naver-map-status tourism-info-sheet map-config` passed.
- `npm.cmd run check:numeric-literals` passed.
- `npm.cmd run lint` 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.
- UTF-8 strict read passed for changed source/test files.

## Remote Evidence

- PR: TBD
- Main merge SHA: TBD
- CI: TBD
- production-smoke: TBD
- CodeQL / Code Quality: TBD
16 changes: 13 additions & 3 deletions src/components/AppMapStageView.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
/*
* File: AppMapStageView.tsx
* Purpose: Compose the map tab stage from coordinator-provided map data and actions.
* Primary Responsibility: Adapt app-level map props into the MapTabStage contract.
* Design Intent: Keep the app shell coordinator separate from map-stage presentation wiring.
* Non-Goals: This component does not fetch map data, own Naver SDK state, or implement sheet internals.
* Dependencies: React memo, MapTabStage, map category strip, and app domain DTOs.
*/
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 { ApiStatus, Category, DrawerState, FestivalItem, Place, ReviewMood, RoutePreview } from '../types/core';
import type { BootstrapResponse } from '../types/review';
import { MapTabStage } from './MapTabStage';
import { MapStageCategoryStrip } from './map-stage/MapStageCategoryStrip';

interface AppMapStageViewProps {
mapData: {
Expand All @@ -16,6 +24,7 @@ interface AppMapStageViewProps {
selectedTourismPlace: TourismPlaceItem | null;
currentPosition: { latitude: number; longitude: number } | null;
mapLocationStatus: ApiStatus;
mapLocationMessage: string | null;
mapLocationFocusKey: number;
drawerState: DrawerState;
sessionUser: SessionUser | null;
Expand Down Expand Up @@ -101,6 +110,7 @@ export const AppMapStageView = memo(function AppMapStageView({
tourismPlaces: mapData.showTourismInfo ? mapData.tourismPlaces : [],
currentPosition: mapData.currentPosition,
mapLocationStatus: mapData.mapLocationStatus,
mapLocationMessage: mapData.mapLocationMessage,
mapLocationFocusKey: mapData.mapLocationFocusKey,
routePreviewPlaces: mapData.routePreviewPlaces,
}}
Expand Down
98 changes: 46 additions & 52 deletions src/components/TourismInfoSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
* 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.
* Design Intent: Reuse the shared map bottom-sheet shell 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.
* Dependencies: TourismPlaceItem DTO and MapBottomSheet.
*/
import type { TourismPlaceItem } from '../tourismTypes';
import type { DrawerState } from '../types/core';
import { buildMapSheetClassName } from './map-stage/mapSheetState';
import { MapBottomSheet } from './map-stage/MapBottomSheet';
import type { MapSheetState } from './map-stage/mapSheetState';

export type TourismInfoSheetState = 'partial' | 'full';
Expand Down Expand Up @@ -52,7 +52,6 @@ export function TourismInfoSheet({

const drawerState: DrawerState = sheetState === 'full' ? 'full' : 'partial';
const mapSheetState: MapSheetState = sheetState === 'full' ? 'full' : 'peek';
const sheetClassName = buildMapSheetClassName('place-drawer', mapSheetState, drawerState);
const title = getTourismPlaceTitle(place);
const address = getTourismPlaceAddress(place);
const categoryLabel = getTourismPlaceCategoryLabel(place);
Expand All @@ -61,61 +60,56 @@ export function TourismInfoSheet({
const summary = place.summary || primaryDescription || '관광지 기본 정보를 확인할 수 있어요.';

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>
<MapBottomSheet
ariaLabel="관광정보 시트"
drawerState={drawerState}
sheetState={mapSheetState}
onCollapse={onCollapse}
onExpand={onExpand}
>
{place.imageUrl ? (
<figure className="tourism-info-sheet__media">
<img src={place.imageUrl} alt={`${title} 관광정보 이미지`} loading="lazy" />
</figure>
) : null}

<div className="place-drawer__content">
{place.imageUrl ? (
<figure className="tourism-info-sheet__media">
<img src={place.imageUrl} alt={`${title} 관광정보 이미지`} loading="lazy" />
</figure>
) : null}

<div className="place-drawer__header">
<div>
<p className="eyebrow">KTO INFO</p>
<h2>{title}</h2>
<p className="place-drawer__summary">{summary}</p>
</div>
<button type="button" className="text-button" onClick={onClose}>
닫기
</button>
<div className="place-drawer__header">
<div>
<p className="eyebrow">KTO INFO</p>
<h2>{title}</h2>
<p className="place-drawer__summary">{summary}</p>
</div>
<button type="button" className="text-button" onClick={onClose}>
닫기
</button>
</div>

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

<div className="sheet-card stack-gap">
{primaryDescription ? (
<div>
<strong>소개</strong>
<p>{primaryDescription}</p>
</div>
) : null}
<div>
<strong>위치</strong>
<p>{address || '주소 정보가 아직 제공되지 않았어요.'}</p>
</div>
<div className="sheet-card stack-gap">
{primaryDescription ? (
<div>
<strong>출처</strong>
<p>{place.sourceName || 'KTO 관광정보'}</p>
<strong>소개</strong>
<p>{primaryDescription}</p>
</div>
{sourceUrl ? (
<a className="primary-button primary-button--block" href={sourceUrl} target="_blank" rel="noreferrer">
자세히 보기
</a>
) : null}
) : null}
<div>
<strong>위치</strong>
<p>{address || '주소 정보가 아직 제공되지 않았어요.'}</p>
</div>
<div>
<strong>출처</strong>
<p>{place.sourceName || 'KTO 관광정보'}</p>
</div>
{sourceUrl ? (
<a className="primary-button primary-button--block" href={sourceUrl} target="_blank" rel="noreferrer">
자세히 보기
</a>
) : null}
</div>
</section>
</MapBottomSheet>
);
}
47 changes: 47 additions & 0 deletions src/components/map-stage/MapBottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* File: MapBottomSheet.tsx
* Purpose: Provide the shared map bottom-sheet shell used by map detail surfaces.
* Primary Responsibility: Own the common section, drag handle, state classes, and scrollable content slot.
* Design Intent: Keep place, festival, and tourism sheets visually consistent while preserving domain-specific body content.
* Non-Goals: This component does not fetch data, own selection state, or render domain-specific copy/actions.
* Dependencies: React children, DrawerState, and map sheet state class helper.
*/
import type { ReactNode } from 'react';
import type { DrawerState } from '../../types/core';
import { buildMapSheetClassName, type MapSheetState } from './mapSheetState';

interface MapBottomSheetProps {
ariaLabel: string;
children: ReactNode;
drawerState: DrawerState;
sheetState: MapSheetState;
onCollapse: () => void;
onExpand: () => void;
}

export function MapBottomSheet({
ariaLabel,
children,
drawerState,
sheetState,
onCollapse,
onExpand,
}: MapBottomSheetProps) {
const sheetClassName = buildMapSheetClassName('place-drawer', sheetState, drawerState);

return (
<section className={sheetClassName} data-map-sheet-state={sheetState} aria-label={ariaLabel}>
<button
type="button"
className="place-drawer__handle"
aria-label="시트 높이 조절"
onClick={drawerState === 'partial' ? onExpand : onCollapse}
>
<span />
</button>
<div className="place-drawer__content map-bottom-sheet__content">
{children}
</div>
</section>
);
}
2 changes: 1 addition & 1 deletion src/components/map-stage/MapStageMapSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function MapStageMapSurface({
onSelectTourismPlace={tourismActions.onOpenTourismPlace}
currentPosition={mapData.currentPosition}
currentLocationStatus={mapData.mapLocationStatus}
currentLocationMessage={null}
currentLocationMessage={mapData.mapLocationMessage}
focusCurrentLocationKey={mapData.mapLocationFocusKey}
onLocateCurrentPosition={viewportData.onLocateCurrentPosition}
initialCenter={viewportData.initialMapCenter}
Expand Down
1 change: 1 addition & 0 deletions src/components/map-stage/mapTabStageTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface MapTabStageProps {
tourismPlaces: TourismPlaceItem[];
currentPosition: { latitude: number; longitude: number } | null;
mapLocationStatus: ApiStatus;
mapLocationMessage: string | null;
mapLocationFocusKey: number;
routePreviewPlaces: Place[];
};
Expand Down
10 changes: 9 additions & 1 deletion src/components/naver-map/NaverMapStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
/*
* File: NaverMapStatus.tsx
* Purpose: Render map loading, SDK error, and current-location action feedback.
* Primary Responsibility: Own user-visible map status controls around the Naver map surface.
* Design Intent: Keep location feedback visible at the map UI boundary instead of hiding it in app-level banners only.
* Non-Goals: This component does not request geolocation or mutate map viewport state directly.
* Dependencies: ApiStatus and callbacks supplied by the map stage.
*/
import type { ApiStatus } from '../../types/core';

type NaverMapStatusProps = {
Expand Down Expand Up @@ -32,7 +40,7 @@ export function NaverMapStatus({
<>
{status === 'loading' && (
<div className="map-status-card map-status-card--overlay">
<strong>대전 지도를 준비하고 있어요</strong>
<strong>대전 지도를 준비하고 있어요.</strong>
<p>잠시만 기다리면 지도와 마커를 바로 보여드릴게요.</p>
</div>
)}
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/app-stage-props/useMapStageProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function useMapStageProps(state: AppShellCoordinatorState) {
festivals,
initialMapViewport,
mapLocationFocusKey,
mapLocationMessage,
mapLocationStatus,
mapStageActions,
reviewActions,
Expand Down Expand Up @@ -45,6 +46,7 @@ export function useMapStageProps(state: AppShellCoordinatorState) {
selectedTourismPlace,
currentPosition,
mapLocationStatus,
mapLocationMessage,
mapLocationFocusKey,
drawerState: state.drawerState,
sessionUser,
Expand Down Expand Up @@ -79,7 +81,7 @@ export function useMapStageProps(state: AppShellCoordinatorState) {
return;
}
setSelectedTourismPlaceId(tourismPlaceId);
setTourismSheetState('partial');
setTourismSheetState('full');
},
onCloseTourismInfoSheet: () => setSelectedTourismPlaceId(null),
onExpandTourismInfoSheet: () => setTourismSheetState('full'),
Expand Down
4 changes: 4 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,10 @@ textarea {
scrollbar-width: none;
}

.place-drawer--full .place-drawer__content {
padding-bottom: calc(var(--bottom-nav-offset) + 18px);
}

.place-drawer__content::-webkit-scrollbar {
width: 0;
height: 0;
Expand Down
Loading
Loading