+
{work.releaseYear && {work.releaseYear}}
+
+ {RELATION_LABELS[relation.relationType]}
+
diff --git a/src/features/k-culture-discovery/presentation/components/PlaceDetailView.tsx b/src/features/k-culture-discovery/presentation/components/PlaceDetailView.tsx
index aa6ed3df..d92291b4 100644
--- a/src/features/k-culture-discovery/presentation/components/PlaceDetailView.tsx
+++ b/src/features/k-culture-discovery/presentation/components/PlaceDetailView.tsx
@@ -10,11 +10,15 @@ import {
MapPinIcon,
Building04Icon,
HeadphonesIcon,
+ BookmarkAdd01Icon,
+ BookmarkRemove01Icon,
} from '@hugeicons/core-free-icons';
import { meok, lightPalette, fontSize } from '@/design-system/tokens';
import KContentRelationCard from './KContentRelationCard';
import { useDiscoveryPlaceDetail } from '../hooks/useDiscoveryPlaceDetail';
import { useKContentRelations } from '../hooks/useKContentRelations';
+import { usePlaceSave } from '../hooks/usePlaceSave';
+import { useTourApiImage } from '../hooks/useTourApiImage';
import type { TopicCode } from '../../domain/types';
const shimmerAnim = keyframes`
@@ -23,37 +27,60 @@ const shimmerAnim = keyframes`
`;
const Page = styled.article`
- max-width: 680px;
+ width: min(calc(100% - 40px), 1140px);
+ max-width: 1140px;
margin: 0 auto;
- padding: 0 0 60px;
+ padding-bottom: 80px;
+
+ padding-top: 72px;
+
+ @media (min-width: 768px) {
+ padding-top: 80px;
+ }
+
+ @media (max-width: 1024px) {
+ width: calc(100% - 28px);
+ }
+
+ @media (max-width: 640px) {
+ width: calc(100% - 24px);
+ }
`;
const BackLink = styled(Link)`
display: inline-flex;
align-items: center;
- gap: 6px;
- font-size: ${fontSize.sm};
+ gap: 5px;
+ font-size: ${fontSize.xs};
font-weight: 600;
- color: ${meok[600]};
+ color: ${meok[400]};
text-decoration: none;
- margin: 16px 0 0;
- transition: color 0.15s ease;
+ padding: 20px 0 16px;
- &:hover {
- color: ${lightPalette.juhong[500]};
+ &:hover { color: ${meok[700]}; }
+
+ [data-theme='dark'] & {
+ color: rgba(255,255,255,0.25);
+ &:hover { color: rgba(255,255,255,0.55); }
}
`;
-const Hero = styled.div`
+/* ─── cinematic hero ─── */
+const HeroWrap = styled.div`
position: relative;
- aspect-ratio: 16 / 9;
border-radius: 18px;
overflow: hidden;
- margin: 12px 0 20px;
+ aspect-ratio: 16 / 9;
background: ${meok[200]};
+ margin-bottom: 20px;
+
+ @media (max-width: 640px) {
+ border-radius: 14px;
+ aspect-ratio: 4 / 3;
+ }
[data-theme='dark'] & {
- background: ${meok[700]};
+ background: #141822;
}
`;
@@ -69,108 +96,254 @@ const HeroFallback = styled.div`
display: flex;
align-items: center;
justify-content: center;
+ background: linear-gradient(155deg, #ede8de 0%, #ddd7c9 100%);
color: ${meok[400]};
- background: ${meok[200]};
[data-theme='dark'] & {
- color: ${meok[600]};
- background: ${meok[700]};
+ background: linear-gradient(155deg, #181e2a 0%, #101420 100%);
+ color: rgba(255,255,255,0.2);
}
`;
-const Name = styled.h1`
- font-family: var(--font-hanok);
- font-size: clamp(22px, 4vw, 32px);
- font-weight: 700;
- color: ${meok[900]};
- margin: 0 0 8px;
- letter-spacing: -0.03em;
- line-height: 1.2;
+const HeroScrim = styled.div`
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ to bottom,
+ rgba(0,0,0,0.08) 0%,
+ transparent 35%,
+ rgba(0,0,0,0.62) 100%
+ );
+ pointer-events: none;
+`;
- [data-theme='dark'] & {
- color: ${meok[100]};
- }
+const HeroOverlay = styled.div`
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 20px 22px 22px;
+ z-index: 2;
`;
-const MetaRow = styled.div`
+const HeroName = styled.h1`
+ font-family: var(--font-hanok), serif;
+ font-size: clamp(20px, 3.5vw, 30px);
+ font-weight: 800;
+ color: #ffffff;
+ margin: 0 0 6px;
+ letter-spacing: -0.035em;
+ line-height: 1.2;
+ text-shadow: 0 2px 12px rgba(0,0,0,0.45);
+`;
+
+const HeroMeta = styled.div`
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
flex-wrap: wrap;
- margin-bottom: 12px;
`;
-const Pill = styled.span`
+const HeroPill = styled.span`
display: inline-flex;
align-items: center;
gap: 4px;
- padding: 4px 10px;
+ padding: 3px 9px;
+ border-radius: 6px;
+ background: rgba(255,255,255,0.18);
+ backdrop-filter: blur(8px);
+ color: rgba(255,255,255,0.9);
+ font-size: 11px;
+ font-weight: 600;
+ border: 1px solid rgba(255,255,255,0.2);
+`;
+
+/* ─── action bar ─── */
+const ActionBar = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+`;
+
+const SaveBtn = styled.button<{ $saved: boolean }>`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 18px;
border-radius: 9999px;
- background: ${meok[100]};
- color: ${meok[600]};
+ border: 1.5px solid ${({ $saved }) => $saved ? lightPalette.juhong[400] : 'rgba(0,0,0,0.1)'};
+ background: ${({ $saved }) => $saved ? lightPalette.juhong[50] : 'transparent'};
+ color: ${({ $saved }) => $saved ? lightPalette.juhong[600] : meok[600]};
font-size: ${fontSize.xs};
- font-weight: 500;
+ font-weight: 700;
+ cursor: pointer;
+ font-family: inherit;
+ transition: all 0.15s ease;
- [data-theme='dark'] & {
- background: ${meok[700]};
- color: ${meok[300]};
+ &:hover {
+ background: ${({ $saved }) => $saved ? lightPalette.juhong[100] : 'rgba(0,0,0,0.04)'};
+ border-color: ${({ $saved }) => $saved ? lightPalette.juhong[500] : 'rgba(0,0,0,0.18)'};
}
-`;
-const SourceLabel = styled.span`
- font-size: ${fontSize.xs};
- color: ${meok[400]};
+ &:active { transform: scale(0.96); }
+ &:disabled { opacity: 0.4; cursor: default; }
+
+ [data-theme='dark'] & {
+ border-color: ${({ $saved }) => $saved ? lightPalette.juhong[500] : 'rgba(255,255,255,0.12)'};
+ background: ${({ $saved }) => $saved ? 'rgba(255,85,0,0.14)' : 'transparent'};
+ color: ${({ $saved }) => $saved ? lightPalette.juhong[400] : 'rgba(255,255,255,0.55)'};
+
+ &:hover {
+ background: ${({ $saved }) => $saved ? 'rgba(255,85,0,0.22)' : 'rgba(255,255,255,0.06)'};
+ border-color: ${({ $saved }) => $saved ? lightPalette.juhong[400] : 'rgba(255,255,255,0.2)'};
+ }
+ }
`;
-const OdiiLink = styled(Link)`
+const OdiiBtn = styled(Link)`
display: inline-flex;
align-items: center;
- gap: 5px;
- padding: 6px 14px;
+ gap: 6px;
+ padding: 8px 18px;
border-radius: 9999px;
- border: none;
- background: ${lightPalette.cheongrok[50]};
+ border: 1.5px solid rgba(0,0,0,0.1);
+ background: transparent;
color: ${lightPalette.cheongrok[700]};
font-size: ${fontSize.xs};
- font-weight: 600;
+ font-weight: 700;
text-decoration: none;
- transition: background 0.18s ease;
+ transition: all 0.15s ease;
&:hover {
- background: ${lightPalette.cheongrok[100]};
+ background: rgba(0,160,100,0.06);
+ border-color: ${lightPalette.cheongrok[400]};
}
[data-theme='dark'] & {
- background: rgba(0, 196, 113, 0.12);
- color: ${lightPalette.cheongrok[300]};
+ border-color: rgba(255,255,255,0.12);
+ color: ${lightPalette.cheongrok[400]};
&:hover {
- background: rgba(0, 196, 113, 0.20);
+ background: rgba(0,196,113,0.1);
+ border-color: ${lightPalette.cheongrok[500]};
}
}
`;
+/* ─── body content ─── */
+const AddressRow = styled.div`
+ display: flex;
+ align-items: flex-start;
+ gap: 5px;
+ font-size: ${fontSize.xs};
+ color: ${meok[400]};
+ margin-bottom: 16px;
+ line-height: 1.5;
+
+ [data-theme='dark'] & {
+ color: rgba(255,255,255,0.3);
+ }
+`;
+
+const TopicChips = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-bottom: 16px;
+`;
+
+const TopicChip = styled.span`
+ padding: 3px 10px;
+ border-radius: 6px;
+ background: rgba(0,0,0,0.04);
+ color: ${meok[600]};
+ font-size: 11px;
+ font-weight: 500;
+ border: 1px solid rgba(0,0,0,0.06);
+
+ [data-theme='dark'] & {
+ background: rgba(255,255,255,0.06);
+ color: rgba(255,255,255,0.5);
+ border-color: rgba(255,255,255,0.08);
+ }
+`;
+
+const SummaryPoints = styled.ul`
+ margin: 0 0 20px;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+`;
+
+const SummaryPoint = styled.li`
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 5px 12px;
+ border-radius: 9999px;
+ background: rgba(0,0,0,0.04);
+ color: ${meok[700]};
+ font-size: ${fontSize.xs};
+ font-weight: 500;
+ border: 1px solid rgba(0,0,0,0.06);
+
+ [data-theme='dark'] & {
+ background: rgba(255,255,255,0.06);
+ color: rgba(255,255,255,0.6);
+ border-color: rgba(255,255,255,0.08);
+ }
+`;
+
const Description = styled.p`
font-size: ${fontSize.sm};
- line-height: 1.75;
+ line-height: 1.8;
color: ${meok[700]};
- margin: 0 0 24px;
+ margin: 0 0 28px;
+ max-width: 680px;
[data-theme='dark'] & {
- color: ${meok[300]};
+ color: rgba(255,255,255,0.55);
+ }
+`;
+
+/* ─── K-Contents section ─── */
+const SectionHeader = styled.div`
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin-bottom: 14px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid rgba(0,0,0,0.06);
+
+ [data-theme='dark'] & {
+ border-color: rgba(255,255,255,0.07);
}
`;
const SectionTitle = styled.h2`
font-family: var(--font-hanok);
- font-size: ${fontSize.lg};
+ font-size: ${fontSize.base};
font-weight: 700;
color: ${meok[900]};
- margin: 0 0 12px;
+ margin: 0;
letter-spacing: -0.02em;
[data-theme='dark'] & {
- color: ${meok[100]};
+ color: #ffffff;
+ }
+`;
+
+const SectionCount = styled.span`
+ font-size: 11px;
+ font-weight: 600;
+ color: ${meok[400]};
+
+ [data-theme='dark'] & {
+ color: rgba(255,255,255,0.3);
}
`;
@@ -182,29 +355,12 @@ const RelationList = styled.div`
const EmptyRelations = styled.p`
font-size: ${fontSize.sm};
- color: ${meok[500]};
- padding: 20px 0;
+ color: ${meok[400]};
+ padding: 24px 0;
text-align: center;
-`;
-
-const TopicChips = styled.div`
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-bottom: 16px;
-`;
-
-const TopicChip = styled.span`
- padding: 4px 10px;
- border-radius: 6px;
- background: ${meok[100]};
- color: ${meok[600]};
- font-size: 11px;
- font-weight: 500;
[data-theme='dark'] & {
- background: ${meok[700]};
- color: ${meok[300]};
+ color: rgba(255,255,255,0.25);
}
`;
@@ -215,6 +371,7 @@ const TOPIC_LABELS: Record
= {
K_DRAMA: 'K-드라마',
K_MOVIE: 'K-영화',
K_POP_MV: 'K-POP MV',
+ K_ARTIST: 'K-아티스트',
};
const SkeletonBlock = styled.div<{ $h?: string; $w?: string; $mb?: string }>`
@@ -222,12 +379,12 @@ const SkeletonBlock = styled.div<{ $h?: string; $w?: string; $mb?: string }>`
width: ${({ $w }) => $w ?? '100%'};
border-radius: 8px;
margin-bottom: ${({ $mb }) => $mb ?? '0'};
- background: linear-gradient(90deg, #e4e4e2 25%, #dcdcd9 50%, #e4e4e2 75%);
+ background: linear-gradient(90deg, #e8e6e2 25%, #dedad5 50%, #e8e6e2 75%);
background-size: 200% 100%;
animation: ${shimmerAnim} 1.6s infinite ease-in-out;
[data-theme='dark'] & {
- background: linear-gradient(90deg, #32302d 25%, #3d3a36 50%, #32302d 75%);
+ background: linear-gradient(90deg, #1e2330 25%, #252c3a 50%, #1e2330 75%);
background-size: 200% 100%;
}
`;
@@ -247,20 +404,25 @@ export interface PlaceDetailViewProps {
export default function PlaceDetailView({ placeId, backHref = '/discover' }: PlaceDetailViewProps) {
const { place, isLoading, isNotFound, isError } = useDiscoveryPlaceDetail(placeId);
const { relations, isLoading: relLoading } = useKContentRelations(placeId);
+ const { savedByMe, isSaving, toggle } = usePlaceSave({
+ placeId,
+ saveAvailable: place?.saveAvailable ?? false,
+ initialSavedByMe: place?.savedByMe ?? null,
+ });
+ const primaryImage = useTourApiImage(placeId, place?.images[0] ?? null);
if (isLoading) {
return (
-
+
목록으로
-
-
-
+
+
+
-
-
+
);
}
@@ -269,7 +431,7 @@ export default function PlaceDetailView({ placeId, backHref = '/discover' }: Pla
return (
-
+
목록으로
@@ -281,41 +443,73 @@ export default function PlaceDetailView({ placeId, backHref = '/discover' }: Pla
);
}
- const primaryImage = place.images[0] ?? null;
-
return (
-
+
목록으로
-
+ {/* ─── cinematic hero ─── */}
+
{primaryImage ? (
) : (
-
+
)}
-
-
- {place.name}
-
-
-
-
- {place.region.nameKo}
-
- {place.placeRoles.map((role) => (
-
-
- {role.labelKo}
-
- ))}
- {place.sourceTaxonomy.labelKo}
-
+
+
+ {place.name}
+
+
+
+ {place.region.nameKo}
+
+ {place.placeRoles.slice(0, 2).map((role) => (
+ {role.labelKo}
+ ))}
+
+
+
+
+ {/* ─── action bar ─── */}
+
+ {place.saveAvailable && (
+
+
+ {savedByMe ? '저장됨' : '저장'}
+
+ )}
+ {place.odii.linked && (
+
+
+ 소리마루 오디오{place.odii.storyCount > 0 ? ` ${place.odii.storyCount}편` : ''}
+
+ )}
+
+
+ {/* ─── address ─── */}
+ {place.address && (
+
+
+ {place.address}
+
+ )}
+ {/* ─── topics ─── */}
{place.topics.length > 0 && (
{place.topics.map((code) => (
@@ -324,25 +518,34 @@ export default function PlaceDetailView({ placeId, backHref = '/discover' }: Pla
)}
- {place.description && {place.description}}
-
- {place.odii.linked && (
-
-
- 소리마루 오디오 가이드 {place.odii.storyCount > 0 ? `${place.odii.storyCount}편` : ''}
-
+ {/* ─── summary points ─── */}
+ {place.placeSummaryPoints.length > 0 && (
+
+ {place.placeSummaryPoints.map((point) => (
+ {point}
+ ))}
+
)}
- 촬영 작품 및 근거
+ {/* ─── description ─── */}
+ {place.description && {place.description}}
+
+ {/* ─── K-Contents 이력 ─── */}
+
+ 촬영·방문 이력
+ {!relLoading && relations.length > 0 && (
+ {relations.length}건
+ )}
+
{relLoading ? (
{[1, 2].map((i) => (
-
+
))}
) : relations.length === 0 ? (
- 확인된 촬영 관계가 없습니다.
+ 확인된 촬영·방문 기록이 없습니다.
) : (
{relations.map((rel) => (
diff --git a/src/features/k-culture-discovery/presentation/components/TopicTabBar.tsx b/src/features/k-culture-discovery/presentation/components/TopicTabBar.tsx
index cc0183e7..61de20e8 100644
--- a/src/features/k-culture-discovery/presentation/components/TopicTabBar.tsx
+++ b/src/features/k-culture-discovery/presentation/components/TopicTabBar.tsx
@@ -1,33 +1,14 @@
'use client';
-import type { ComponentProps } from 'react';
import styled from '@emotion/styled';
-import { HugeiconsIcon } from '@hugeicons/react';
-import {
- Building04Icon,
- HandsClappingIcon,
- Restaurant01Icon,
- ClapperboardIcon,
- Film01Icon,
- Music02Icon,
-} from '@hugeicons/core-free-icons';
-import { lightPalette, meok, ringShadow, fontSize } from '@/design-system/tokens';
+import { meok, ringShadow, fontSize } from '@/design-system/tokens';
import type { DiscoveryTopic, TopicCode } from '../../domain/types';
-const TOPIC_ICONS: Record['icon']> = {
- TRADITIONAL_SPACE_HERITAGE: Building04Icon,
- TRADITIONAL_EXPERIENCE: HandsClappingIcon,
- TRADITIONAL_FOOD_TEA: Restaurant01Icon,
- K_DRAMA: ClapperboardIcon,
- K_MOVIE: Film01Icon,
- K_POP_MV: Music02Icon,
-};
-
const ScrollRow = styled.div`
display: flex;
- gap: 8px;
+ gap: 4px;
overflow-x: auto;
- padding: 4px 0 12px;
+ padding: 2px 0 16px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
@@ -39,56 +20,69 @@ const Tab = styled.button<{ $active: boolean }>`
align-items: center;
gap: 6px;
padding: 8px 16px;
- border-radius: 9999px;
+ border-radius: 999px;
border: none;
- background: ${({ $active }) =>
- $active ? lightPalette.juhong[500] : meok[200]};
- color: ${({ $active }) => ($active ? '#ffffff' : meok[700])};
- box-shadow: ${({ $active }) =>
- $active ? ringShadow.light.chipActive : 'none'};
- font-size: ${fontSize.sm};
+ font-size: ${fontSize.xs};
font-weight: 600;
cursor: pointer;
- transition: background 0.18s ease, box-shadow 0.18s ease, color 0.18s ease;
font-family: inherit;
white-space: nowrap;
+ letter-spacing: -0.01em;
+ transition: background 0.18s cubic-bezier(0.4,0,0.2,1), color 0.18s ease, transform 0.12s ease, box-shadow 0.18s ease;
+
+ background: ${({ $active }) => $active
+ ? meok[900]
+ : 'rgba(0,0,0,0.04)'};
+ color: ${({ $active }) => $active ? '#ffffff' : meok[500]};
+ box-shadow: ${({ $active }) => $active
+ ? ringShadow.light.button
+ : 'none'};
&:hover {
- background: ${({ $active }) =>
- $active ? lightPalette.juhong[400] : meok[300]};
- box-shadow: ${({ $active }) =>
- $active ? ringShadow.light.chipActive : ringShadow.light.chipHover};
+ background: ${({ $active }) => $active ? meok[800] : 'rgba(0,0,0,0.07)'};
+ color: ${({ $active }) => $active ? '#ffffff' : meok[800]};
+ transform: ${({ $active }) => $active ? 'none' : 'translateY(-1px)'};
+ box-shadow: ${({ $active }) => $active
+ ? ringShadow.light.buttonHover
+ : 'none'};
}
+ &:active { transform: scale(0.96); }
+
[data-theme='dark'] & {
- background: ${({ $active }) =>
- $active ? lightPalette.juhong[500] : meok[700]};
- color: ${({ $active }) => ($active ? '#ffffff' : meok[300])};
- box-shadow: ${({ $active }) =>
- $active ? ringShadow.dark.chipActive : 'none'};
+ background: ${({ $active }) => $active
+ ? '#ffffff'
+ : 'rgba(255,255,255,0.07)'};
+ color: ${({ $active }) => $active ? meok[900] : 'rgba(255,255,255,0.5)'};
+ box-shadow: ${({ $active }) => $active
+ ? ringShadow.dark.button
+ : 'none'};
&:hover {
- background: ${({ $active }) =>
- $active ? lightPalette.juhong[400] : meok[600]};
+ background: ${({ $active }) => $active ? 'rgba(255,255,255,0.92)' : 'rgba(255,255,255,0.11)'};
+ color: ${({ $active }) => $active ? meok[900] : 'rgba(255,255,255,0.8)'};
+ box-shadow: ${({ $active }) => $active
+ ? ringShadow.dark.buttonHover
+ : 'none'};
}
}
`;
-const Count = styled.span`
- font-size: 11px;
- font-weight: 500;
- opacity: 0.72;
+const Count = styled.span<{ $active: boolean }>`
+ font-size: 10px;
+ font-weight: 700;
+ opacity: ${({ $active }) => $active ? 0.65 : 0.45};
+ letter-spacing: 0;
`;
const SkeletonTab = styled.div`
flex-shrink: 0;
- width: 88px;
height: 36px;
- border-radius: 9999px;
- background: ${meok[200]};
+ border-radius: 999px;
+ background: rgba(0,0,0,0.04);
[data-theme='dark'] & {
- background: #2d2b28;
+ background: rgba(255,255,255,0.05);
}
`;
@@ -99,16 +93,11 @@ export interface TopicTabBarProps {
onChange: (code: TopicCode | 'all') => void;
}
-export default function TopicTabBar({
- topics,
- activeCode,
- isLoading,
- onChange,
-}: TopicTabBarProps) {
+export default function TopicTabBar({ topics, activeCode, isLoading, onChange }: TopicTabBarProps) {
if (isLoading) {
return (
- {[80, 96, 80, 72, 80, 72, 96].map((w, i) => (
+ {[52, 80, 104, 76, 92, 72, 88, 104].map((w, i) => (
))}
@@ -128,19 +117,18 @@ export default function TopicTabBar({
{topics.map((topic) => {
- const Icon = TOPIC_ICONS[topic.code];
+ const isActive = activeCode === topic.code;
return (
onChange(topic.code)}
>
-
{topic.labelKo}
- {topic.placeCount}
+ {topic.placeCount}
);
})}
diff --git a/src/features/k-culture-discovery/presentation/components/WorkDetailView.tsx b/src/features/k-culture-discovery/presentation/components/WorkDetailView.tsx
index e977eba6..938ddc8a 100644
--- a/src/features/k-culture-discovery/presentation/components/WorkDetailView.tsx
+++ b/src/features/k-culture-discovery/presentation/components/WorkDetailView.tsx
@@ -22,24 +22,33 @@ const shimmerAnim = keyframes`
`;
const Page = styled.article`
- max-width: 680px;
+ max-width: 1140px;
margin: 0 auto;
- padding: 0 0 60px;
+ padding: 0 16px 64px;
+
+ @media (min-width: 768px) {
+ padding-top: 66px;
+ }
`;
const BackLink = styled(Link)`
display: inline-flex;
align-items: center;
- gap: 6px;
- font-size: ${fontSize.sm};
+ gap: 5px;
+ font-size: ${fontSize.xs};
font-weight: 600;
- color: ${meok[600]};
+ color: ${meok[400]};
text-decoration: none;
- margin: 16px 0 0;
- transition: color 0.15s ease;
+ margin: 20px 0 0;
+ transition: color 0.14s ease;
&:hover {
- color: ${lightPalette.juhong[500]};
+ color: ${meok[700]};
+ }
+
+ [data-theme='dark'] & {
+ color: rgba(255,255,255,0.28);
+ &:hover { color: rgba(255,255,255,0.60); }
}
`;
diff --git a/src/features/k-culture-discovery/presentation/components/WorkTypeBadge.tsx b/src/features/k-culture-discovery/presentation/components/WorkTypeBadge.tsx
index 61f48575..0a627291 100644
--- a/src/features/k-culture-discovery/presentation/components/WorkTypeBadge.tsx
+++ b/src/features/k-culture-discovery/presentation/components/WorkTypeBadge.tsx
@@ -8,6 +8,7 @@ import {
Film01Icon,
Tv01Icon,
Music02Icon,
+ HeadphonesIcon,
} from '@hugeicons/core-free-icons';
import { fontSize } from '@/design-system/tokens';
import type { WorkType } from '../../domain/types';
@@ -17,6 +18,7 @@ const WORK_TYPE_META: Record`
diff --git a/src/features/k-culture-discovery/presentation/hooks/useDiscoveryPlaceDetail.ts b/src/features/k-culture-discovery/presentation/hooks/useDiscoveryPlaceDetail.ts
index 777f75d2..5f1d9313 100644
--- a/src/features/k-culture-discovery/presentation/hooks/useDiscoveryPlaceDetail.ts
+++ b/src/features/k-culture-discovery/presentation/hooks/useDiscoveryPlaceDetail.ts
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { getDiscoveryPlaceDetail } from '../../application/useCases';
+import { isOnmaruApiError } from '@/lib/api/errors';
import type { DiscoveryPlaceDetail } from '../../domain/types';
export interface UseDiscoveryPlaceDetailResult {
@@ -33,8 +34,14 @@ export function useDiscoveryPlaceDetail(placeId: string): UseDiscoveryPlaceDetai
}
}
})
- .catch(() => {
- if (!ignore) setIsError(true);
+ .catch((err) => {
+ if (!ignore) {
+ if (isOnmaruApiError(err) && err.status === 404) {
+ setIsNotFound(true);
+ } else {
+ setIsError(true);
+ }
+ }
})
.finally(() => {
if (!ignore) setIsLoading(false);
diff --git a/src/features/k-culture-discovery/presentation/hooks/useDiscoveryPlaces.ts b/src/features/k-culture-discovery/presentation/hooks/useDiscoveryPlaces.ts
index c45cee55..15f231fa 100644
--- a/src/features/k-culture-discovery/presentation/hooks/useDiscoveryPlaces.ts
+++ b/src/features/k-culture-discovery/presentation/hooks/useDiscoveryPlaces.ts
@@ -1,16 +1,19 @@
import { useState, useEffect, useCallback } from 'react';
import { getDiscoveryPlaces } from '../../application/useCases';
+import { isOnmaruApiError } from '@/lib/api/errors';
import type {
DiscoveryPlaceSummary,
DiscoveryFacets,
DiscoveryPlacesParams,
TopicCode,
+ WorkType,
} from '../../domain/types';
export interface UseDiscoveryPlacesOptions {
topic?: TopicCode;
regionCode?: string;
placeRole?: string;
+ type?: WorkType;
workId?: string;
limit?: number;
}
@@ -25,8 +28,17 @@ export interface UseDiscoveryPlacesResult {
loadMore: () => void;
}
+function isCursorError(err: unknown): boolean {
+ if (!isOnmaruApiError(err)) return false;
+ return (
+ err.code === 'CURSOR_INVALID' ||
+ err.code === 'CURSOR_EXPIRED' ||
+ err.status === 410
+ );
+}
+
export function useDiscoveryPlaces(options: UseDiscoveryPlacesOptions = {}): UseDiscoveryPlacesResult {
- const { topic, regionCode, placeRole, workId, limit = 20 } = options;
+ const { topic, regionCode, placeRole, type, workId, limit = 20 } = options;
const [items, setItems] = useState([]);
const [facets, setFacets] = useState(null);
@@ -36,7 +48,7 @@ export function useDiscoveryPlaces(options: UseDiscoveryPlacesOptions = {}): Use
const [hasMore, setHasMore] = useState(false);
const [nextCursor, setNextCursor] = useState(null);
- const params: DiscoveryPlacesParams = { topic, regionCode, placeRole, workId, limit };
+ const params: DiscoveryPlacesParams = { topic, regionCode, placeRole, type, workId, limit };
const paramsKey = JSON.stringify(params);
useEffect(() => {
@@ -46,7 +58,7 @@ export function useDiscoveryPlaces(options: UseDiscoveryPlacesOptions = {}): Use
setItems([]);
setNextCursor(null);
- getDiscoveryPlaces({ topic, regionCode, placeRole, workId, limit })
+ getDiscoveryPlaces({ topic, regionCode, placeRole, type, workId, limit })
.then((page) => {
if (!ignore) {
setItems(page.items);
@@ -71,13 +83,32 @@ export function useDiscoveryPlaces(options: UseDiscoveryPlacesOptions = {}): Use
if (!hasMore || !nextCursor || isLoading) return;
setIsLoading(true);
- getDiscoveryPlaces({ topic, regionCode, placeRole, workId, limit, cursor: nextCursor })
+ getDiscoveryPlaces({ topic, regionCode, placeRole, type, workId, limit, cursor: nextCursor })
.then((page) => {
setItems((prev) => [...prev, ...page.items]);
+ setFacets(page.facets);
setHasMore(page.hasMore);
setNextCursor(page.nextCursor);
})
- .catch(() => setIsError(true))
+ .catch((err) => {
+ if (isCursorError(err)) {
+ // CURSOR_INVALID / CURSOR_EXPIRED: 커서 버리고 처음부터 재조회
+ setNextCursor(null);
+ setItems([]);
+ getDiscoveryPlaces({ topic, regionCode, placeRole, type, workId, limit })
+ .then((page) => {
+ setItems(page.items);
+ setFacets(page.facets);
+ setMatchingCount(page.matchingPlaceCount);
+ setHasMore(page.hasMore);
+ setNextCursor(page.nextCursor);
+ })
+ .catch(() => setIsError(true))
+ .finally(() => setIsLoading(false));
+ return;
+ }
+ setIsError(true);
+ })
.finally(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasMore, nextCursor, isLoading, paramsKey]);
diff --git a/src/features/k-culture-discovery/presentation/hooks/useDiscoveryWorkDetail.ts b/src/features/k-culture-discovery/presentation/hooks/useDiscoveryWorkDetail.ts
index c2a7e6e4..b78a4fa1 100644
--- a/src/features/k-culture-discovery/presentation/hooks/useDiscoveryWorkDetail.ts
+++ b/src/features/k-culture-discovery/presentation/hooks/useDiscoveryWorkDetail.ts
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { getDiscoveryWorkDetail } from '../../application/useCases';
+import { isOnmaruApiError } from '@/lib/api/errors';
import type { WorkDetail } from '../../domain/types';
export interface UseDiscoveryWorkDetailResult {
@@ -33,8 +34,14 @@ export function useDiscoveryWorkDetail(workId: string): UseDiscoveryWorkDetailRe
}
}
})
- .catch(() => {
- if (!ignore) setIsError(true);
+ .catch((err) => {
+ if (!ignore) {
+ if (isOnmaruApiError(err) && err.status === 404) {
+ setIsNotFound(true);
+ } else {
+ setIsError(true);
+ }
+ }
})
.finally(() => {
if (!ignore) setIsLoading(false);
diff --git a/src/features/k-culture-discovery/presentation/hooks/useDiscoveryWorks.ts b/src/features/k-culture-discovery/presentation/hooks/useDiscoveryWorks.ts
index 0e34a7c8..677ec066 100644
--- a/src/features/k-culture-discovery/presentation/hooks/useDiscoveryWorks.ts
+++ b/src/features/k-culture-discovery/presentation/hooks/useDiscoveryWorks.ts
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { getDiscoveryWorks } from '../../application/useCases';
+import { isOnmaruApiError } from '@/lib/api/errors';
import type { WorkSummary, DiscoveryWorksParams, WorkType } from '../../domain/types';
export interface UseDiscoveryWorksOptions {
@@ -17,6 +18,15 @@ export interface UseDiscoveryWorksResult {
loadMore: () => void;
}
+function isCursorError(err: unknown): boolean {
+ if (!isOnmaruApiError(err)) return false;
+ return (
+ err.code === 'CURSOR_INVALID' ||
+ err.code === 'CURSOR_EXPIRED' ||
+ err.status === 410
+ );
+}
+
export function useDiscoveryWorks(options: UseDiscoveryWorksOptions = {}): UseDiscoveryWorksResult {
const { type, q, limit = 20 } = options;
@@ -67,7 +77,24 @@ export function useDiscoveryWorks(options: UseDiscoveryWorksOptions = {}): UseDi
setHasMore(page.hasMore);
setNextCursor(page.nextCursor);
})
- .catch(() => setIsError(true))
+ .catch((err) => {
+ if (isCursorError(err)) {
+ // CURSOR_INVALID / CURSOR_EXPIRED: 커서 버리고 처음부터 재조회
+ setNextCursor(null);
+ setItems([]);
+ getDiscoveryWorks({ type, q, limit })
+ .then((page) => {
+ setItems(page.items);
+ setMatchingCount(page.matchingWorkCount);
+ setHasMore(page.hasMore);
+ setNextCursor(page.nextCursor);
+ })
+ .catch(() => setIsError(true))
+ .finally(() => setIsLoading(false));
+ return;
+ }
+ setIsError(true);
+ })
.finally(() => setIsLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasMore, nextCursor, isLoading, paramsKey]);
diff --git a/src/features/k-culture-discovery/presentation/hooks/useKContentRelations.ts b/src/features/k-culture-discovery/presentation/hooks/useKContentRelations.ts
index 07d9f9f0..7f18bf6f 100644
--- a/src/features/k-culture-discovery/presentation/hooks/useKContentRelations.ts
+++ b/src/features/k-culture-discovery/presentation/hooks/useKContentRelations.ts
@@ -22,7 +22,7 @@ export function useKContentRelations(placeId: string): UseKContentRelationsResul
getKContents(placeId)
.then((page) => {
- if (!ignore) setRelations(page.items.filter((r) => r.relationType === 'FILMING_LOCATION'));
+ if (!ignore) setRelations(page.items);
})
.catch(() => {
if (!ignore) setIsError(true);
diff --git a/src/features/k-culture-discovery/presentation/hooks/usePlaceSave.ts b/src/features/k-culture-discovery/presentation/hooks/usePlaceSave.ts
new file mode 100644
index 00000000..afb52fc9
--- /dev/null
+++ b/src/features/k-culture-discovery/presentation/hooks/usePlaceSave.ts
@@ -0,0 +1,43 @@
+import { useState, useCallback, useEffect } from 'react';
+import { togglePlaceSave } from '../../application/useCases';
+
+export interface UsePlaceSaveOptions {
+ placeId: string;
+ saveAvailable: boolean;
+ initialSavedByMe: boolean | null;
+}
+
+export interface UsePlaceSaveResult {
+ savedByMe: boolean | null;
+ isSaving: boolean;
+ toggle: () => void;
+}
+
+export function usePlaceSave({
+ placeId,
+ saveAvailable,
+ initialSavedByMe,
+}: UsePlaceSaveOptions): UsePlaceSaveResult {
+ const [savedByMe, setSavedByMe] = useState(initialSavedByMe);
+ const [isSaving, setIsSaving] = useState(false);
+
+ useEffect(() => {
+ if (!isSaving) setSavedByMe(initialSavedByMe);
+ }, [placeId, initialSavedByMe]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const toggle = useCallback(() => {
+ if (!saveAvailable || isSaving || savedByMe === null) return;
+
+ const next = !savedByMe;
+ setSavedByMe(next); // optimistic
+ setIsSaving(true);
+
+ togglePlaceSave(placeId, savedByMe)
+ .catch(() => {
+ setSavedByMe(savedByMe); // rollback
+ })
+ .finally(() => setIsSaving(false));
+ }, [placeId, saveAvailable, savedByMe, isSaving]);
+
+ return { savedByMe, isSaving, toggle };
+}
diff --git a/src/features/k-culture-discovery/presentation/hooks/useTourApiImage.ts b/src/features/k-culture-discovery/presentation/hooks/useTourApiImage.ts
new file mode 100644
index 00000000..d6cb8e84
--- /dev/null
+++ b/src/features/k-culture-discovery/presentation/hooks/useTourApiImage.ts
@@ -0,0 +1,33 @@
+import { useState, useEffect } from 'react';
+
+function extractContentId(placeId: string): string | null {
+ if (!placeId.startsWith('p-tourapi-')) return null;
+ const id = placeId.slice('p-tourapi-'.length);
+ return /^\d+$/.test(id) ? id : null;
+}
+
+export function useTourApiImage(placeId: string, existingImage: string | null): string | null {
+ const [imageUrl, setImageUrl] = useState(existingImage);
+
+ useEffect(() => {
+ setImageUrl(null);
+ if (existingImage) {
+ setImageUrl(existingImage);
+ return;
+ }
+ const contentId = extractContentId(placeId);
+ if (!contentId) return;
+
+ let cancelled = false;
+ fetch(`/api/discovery/place-image?contentId=${encodeURIComponent(contentId)}`)
+ .then((r) => r.json())
+ .then((d) => {
+ if (!cancelled && d.imageUrl) setImageUrl(d.imageUrl);
+ })
+ .catch(() => {});
+
+ return () => { cancelled = true; };
+ }, [placeId, existingImage]);
+
+ return imageUrl;
+}
diff --git a/src/features/map/components/MapMobileTabs.tsx b/src/features/map/components/MapMobileTabs.tsx
index cd8ccd3e..42ec6dfb 100644
--- a/src/features/map/components/MapMobileTabs.tsx
+++ b/src/features/map/components/MapMobileTabs.tsx
@@ -1,9 +1,9 @@
'use client';
-import { useRouter } from 'next/navigation';
+import Link from 'next/link';
import styled from '@emotion/styled';
import { HugeiconsIcon } from '@hugeicons/react'
-import { Home01Icon, UsersIcon, MapPinIcon, HeadphonesIcon, Bookmark01Icon, SearchAreaIcon } from '@hugeicons/core-free-icons'
+import { BookOpen01Icon, Bookmark01Icon, Film01Icon, HeadphonesIcon, Home01Icon, MapIcon } from '@hugeicons/core-free-icons';
import { lightPalette, meok, fontSize } from '@/design-system/tokens';
import { useMapStore } from '@/features/map/hooks/useMapStore';
import { useBookmarkStore } from '@/features/map/hooks/useBookmarkStore';
@@ -49,6 +49,31 @@ const TabButton = styled.button<{ $active?: boolean }>`
}
`;
+const TabLink = styled(Link)`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ min-width: 0;
+ padding: 0;
+ color: rgba(33, 30, 25, 0.68);
+ font-family: 'Spoqa Han Sans Neo', sans-serif;
+ font-size: ${fontSize.micro};
+ font-weight: 400;
+ letter-spacing: -0.02em;
+ text-decoration: none;
+ transition: color 180ms ease, transform 180ms ease;
+
+ &:active { transform: scale(0.94); }
+
+ [data-theme='dark'] & { color: ${meok[400]}; }
+
+ @media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']) & { color: ${meok[400]}; }
+ }
+`;
+
const IconSlot = styled.span`
position: relative;
display: inline-flex;
@@ -84,9 +109,7 @@ const CountBadge = styled.span`
line-height: 1;
`;
-
export default function MapMobileTabs() {
- const router = useRouter();
const mode = useMapStore((s) => s.mode);
const setMode = useMapStore((s) => s.setMode);
const category = useMapStore((s) => s.category);
@@ -94,33 +117,38 @@ export default function MapMobileTabs() {
const setSheetSnap = useMapStore((s) => s.setSheetSnap);
const bookmarkCount = useBookmarkStore((s) => s.bookmarks.length);
- const isCommunityActive = mode === 'warmth';
const isMapActive = mode === 'info' && category !== 'bookmark';
const isBookmarkActive = category === 'bookmark';
return (
diff --git a/src/features/seasons/presentation/SeasonScrollGuide.tsx b/src/features/seasons/presentation/SeasonScrollGuide.tsx
index 3a3dfad2..1b74c2e0 100644
--- a/src/features/seasons/presentation/SeasonScrollGuide.tsx
+++ b/src/features/seasons/presentation/SeasonScrollGuide.tsx
@@ -3,7 +3,8 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import Image from 'next/image';
import Link from 'next/link';
-import { ArrowDown, ArrowLeft, ArrowRight, ArrowUpRight } from 'lucide-react';
+import { HugeiconsIcon } from '@hugeicons/react';
+import { ArrowDown01Icon, ArrowLeft01Icon, ArrowRight01Icon, ArrowUpRight01Icon } from '@hugeicons/core-free-icons';
import { getSeasonParticles } from '@/features/hanok-year/presentation/seasonParticles';
import type { SeasonPlace, SeasonScene } from '../application/buildSeasonsGuide';
import type { SeasonId } from '../domain/seasons';
@@ -49,7 +50,7 @@ function PlaceCard({ place, focus, onPreview }: { place: SeasonPlace; focus?: st