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
14 changes: 8 additions & 6 deletions reports/completion/TSK-013-01-tourism-info-data-consumption.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@
- Scope-ID: `TSK-013-01`
- Issue: `#421`
- Parent Issue: `#420`
- Branch: `tourism-info-data-consumption-fix`
- Status: merged
- Branch: `tourism-info-data-consumption-fix`, `tourism-contract-field-consumption`
- Status: corrective PR ready

## Summary

KTO tourism place data is now consumed by the information sheet beyond title/address. The sheet renders image, summary, description, category, district, address, source, and homepage link when those fields are present. The tourism places request also receives an abort signal so a stalled `/api/tourism/places` response cannot leave the UI in an endless loading state.
KTO tourism place data is now consumed by the information sheet beyond name/address. The sheet renders image, summary, description, KTO content type label, district, address/roadAddress, source, and sourcePageUrl when those fields are present. The tourism places request also receives an abort signal so a stalled `/api/tourism/places` response cannot leave the UI in an endless loading state.

## Architecture Boundary Gate

- Responsibility map: `tourismClient` owns the Worker consumer request; `useAppCoordinatorEffects` owns UI request lifecycle and timeout; `TourismInfoSheet` owns presentation of already-normalized `TourismPlaceItem` data.
- Dependency direction: UI effect -> API client -> `fetchJson`; presentation component -> `TourismPlaceItem`; no browser code calls KTO/OpenAPI, Supabase, or admin import APIs directly.
- Test seam: unit tests assert rendered KTO fields at component boundary and `RequestInit.signal` forwarding at API client boundary.
- Test seam: unit tests assert rendered KTO contract fields at component boundary and `RequestInit.signal` forwarding at API client boundary.
- Scope map: frontend data consumption and timeout guard only. API path, response shape, DB schema, OAuth flow, and curated place review/stamp flow are unchanged.
- Architecture risk: adding UI fields can accidentally turn KTO information pins into curated place actions. The component continues to render information-only content without stamp/review/feed actions.

Expand All @@ -36,8 +36,10 @@ KTO tourism place data is now consumed by the information sheet beyond title/add

## Remote Evidence

- PR: [#422](https://github.com/STH-1-Class-One-Group/JamIssue/pull/422)
- Main merge SHA: `729cbe8764f59f4ae7c9a3a845de9e0a6a53f83f`
- Initial PR: [#422](https://github.com/STH-1-Class-One-Group/JamIssue/pull/422)
- Initial main merge SHA: `729cbe8764f59f4ae7c9a3a845de9e0a6a53f83f`
- Corrective PR: TBD
- Corrective main merge SHA: TBD
- CI: [27458352463](https://github.com/STH-1-Class-One-Group/JamIssue/actions/runs/27458352463)
- production-smoke: [27458352467](https://github.com/STH-1-Class-One-Group/JamIssue/actions/runs/27458352467)
- CodeQL: [27458352230](https://github.com/STH-1-Class-One-Group/JamIssue/actions/runs/27458352230)
Expand Down
32 changes: 26 additions & 6 deletions src/components/TourismInfoSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ interface TourismInfoSheetProps {
onCollapse: () => void;
}

function getTourismPlaceTitle(place: TourismPlaceItem) {
return place.name || place.title || '관광정보';
}

function getTourismPlaceAddress(place: TourismPlaceItem) {
return place.address || place.roadAddress || null;
}

function getTourismPlaceCategoryLabel(place: TourismPlaceItem) {
return place.ktoContentTypeLabel || place.category || place.ktoFacet || null;
}

function getTourismPlaceSourceUrl(place: TourismPlaceItem) {
return place.sourcePageUrl || place.homepageUrl || null;
}

export function TourismInfoSheet({
place,
isOpen,
Expand All @@ -37,6 +53,10 @@ 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);
const sourceUrl = getTourismPlaceSourceUrl(place);
const primaryDescription = place.description && place.description !== place.summary ? place.description : null;
const summary = place.summary || primaryDescription || '관광지 기본 정보를 확인할 수 있어요.';

Expand All @@ -54,14 +74,14 @@ export function TourismInfoSheet({
<div className="place-drawer__content">
{place.imageUrl ? (
<figure className="tourism-info-sheet__media">
<img src={place.imageUrl} alt={`${place.title} 관광정보 이미지`} loading="lazy" />
<img src={place.imageUrl} alt={`${title} 관광정보 이미지`} loading="lazy" />
</figure>
) : null}

<div className="place-drawer__header">
<div>
<p className="eyebrow">KTO INFO</p>
<h2>{place.title}</h2>
<h2>{title}</h2>
<p className="place-drawer__summary">{summary}</p>
</div>
<button type="button" className="text-button" onClick={onClose}>
Expand All @@ -70,7 +90,7 @@ export function TourismInfoSheet({
</div>

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

Expand All @@ -83,14 +103,14 @@ export function TourismInfoSheet({
) : null}
<div>
<strong>위치</strong>
<p>{place.address || '주소 정보가 아직 제공되지 않았어요.'}</p>
<p>{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">
{sourceUrl ? (
<a className="primary-button primary-button--block" href={sourceUrl} target="_blank" rel="noreferrer">
자세히 보기
</a>
) : null}
Expand Down
15 changes: 12 additions & 3 deletions src/tourismTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
*/

export interface TourismFacetOption {
key: string;
key?: string;
id?: string;
name?: string;
label: string;
count: number;
}
Expand All @@ -28,16 +30,23 @@ export interface TourismCuratedPlaceLink {

export interface TourismPlaceItem {
id: string;
title: string;
name: string;
title?: string;
category: string | null;
ktoContentTypeId?: string | null;
ktoContentTypeLabel?: string | null;
ktoFacet?: string | null;
district: string | null;
address: string | null;
roadAddress?: string | null;
summary: string | null;
description: string | null;
latitude: number | null;
longitude: number | null;
imageUrl: string | null;
homepageUrl: string | null;
sourcePageUrl?: string | null;
homepageUrl?: string | null;
sourceUpdatedAt?: string | null;
sourceName: string | null;
isCurated: boolean;
curatedPlace: TourismCuratedPlaceLink | null;
Expand Down
15 changes: 10 additions & 5 deletions test/e2e/tourism-map-layer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { expect, test } from '@playwright/test';
import { createE2EAppState, installApiFixtures } from './fixtures';
import type { TourismPlaceItem } from '../../src/tourismTypes';
import { createE2EAppState, installApiFixtures } from './fixtures';

const tourismPlace: TourismPlaceItem = {
id: 'tourism-1',
title: 'KTO 정보 장소',
category: '관광지',
name: 'KTO 정보 장소',
category: 'tourism',
ktoContentTypeId: '12',
ktoContentTypeLabel: '관광지',
ktoFacet: 'tourism',
district: '중구',
address: '대전 중구 테스트로 1',
address: null,
roadAddress: '대전 중구 테스트로 1',
summary: 'KTO 정보성 장소입니다.',
description: null,
latitude: 36.35,
longitude: 127.38,
imageUrl: null,
homepageUrl: 'https://example.com',
sourcePageUrl: 'https://example.com',
sourceUpdatedAt: null,
sourceName: 'KTO',
isCurated: false,
curatedPlace: null,
Expand Down
46 changes: 26 additions & 20 deletions test/unit/tourism-info-sheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,29 @@ import { TourismInfoSheet } from '../../src/components/TourismInfoSheet';
import type { TourismPlaceItem } from '../../src/tourismTypes';

const tourismPlace: TourismPlaceItem = {
id: 'tourism-1',
title: '대전근현대사전시관',
category: '문화관광',
district: '중구',
address: '대전 중구 중앙로 101',
summary: '옛 충남도청 건물을 활용한 전시 공간입니다.',
description: '근현대 대전의 전시 변화를 전시와 사진 자료로 볼 수 있습니다.',
latitude: 36.327,
longitude: 127.421,
id: 'kto-content-1932079',
name: '굿모닝레지던스호텔휴',
category: 'lodging',
ktoContentTypeId: '32',
ktoContentTypeLabel: '숙박',
ktoFacet: 'lodging',
district: '서구',
address: null,
roadAddress: '대전 서구 둔산로73번길 21',
summary: '대전 서구에 있는 숙박 관광자원입니다.',
description: '객실과 편의시설을 갖춘 레지던스형 숙박시설입니다.',
latitude: 36.35,
longitude: 127.38,
imageUrl: 'https://example.com/tourism.jpg',
homepageUrl: 'https://example.com/tourism',
sourceName: 'KTO',
sourcePageUrl: 'https://example.com/tourism',
sourceUpdatedAt: '2025-09-22T01:56:03+00:00',
sourceName: 'KTO TourAPI Daejeon Tourism',
isCurated: false,
curatedPlace: null,
};

describe('TourismInfoSheet', () => {
it('renders the available KTO tourism data instead of only title and address', () => {
it('renders the available KTO tourism contract fields instead of only name and address', () => {
render(
<TourismInfoSheet
place={tourismPlace}
Expand All @@ -33,16 +38,17 @@ describe('TourismInfoSheet', () => {
/>,
);

expect(screen.getByRole('img', { name: '대전근현대사전시관 관광정보 이미지' })).toHaveAttribute(
expect(screen.getByRole('img', { name: '굿모닝레지던스호텔휴 관광정보 이미지' })).toHaveAttribute(
'src',
tourismPlace.imageUrl,
);
expect(screen.getByText('옛 충남도청 건물을 활용한 전시 공간입니다.')).toBeInTheDocument();
expect(screen.getByText('근현대 대전의 전시 변화를 전시와 사진 자료로 볼 수 있습니다.')).toBeInTheDocument();
expect(screen.getByText('문화관광')).toBeInTheDocument();
expect(screen.getByText('중구')).toBeInTheDocument();
expect(screen.getByText('대전 중구 중앙로 101')).toBeInTheDocument();
expect(screen.getByText('KTO')).toBeInTheDocument();
expect(screen.getByRole('link', { name: '자세히 보기' })).toHaveAttribute('href', tourismPlace.homepageUrl);
expect(screen.getByRole('heading', { name: '굿모닝레지던스호텔휴' })).toBeInTheDocument();
expect(screen.getByText('대전 서구에 있는 숙박 관광자원입니다.')).toBeInTheDocument();
expect(screen.getByText('객실과 편의시설을 갖춘 레지던스형 숙박시설입니다.')).toBeInTheDocument();
expect(screen.getByText('숙박')).toBeInTheDocument();
expect(screen.getByText('서구')).toBeInTheDocument();
expect(screen.getByText('대전 서구 둔산로73번길 21')).toBeInTheDocument();
expect(screen.getByText('KTO TourAPI Daejeon Tourism')).toBeInTheDocument();
expect(screen.getByRole('link', { name: '자세히 보기' })).toHaveAttribute('href', tourismPlace.sourcePageUrl);
});
});
Loading