diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index c223463..0e51a79 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -1,136 +1,5 @@
-import { Feather } from '@expo/vector-icons';
-import { Tabs, router } from 'expo-router';
-import { Alert, Platform, Pressable, View } from 'react-native';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
-
-import { colors } from '@/constants/colors';
-import { getTabBarHeight } from '@/constants/layout';
-import { useTravelSessionStore } from '@/store/travelSessionStore';
-
-type TabIconName = keyof typeof Feather.glyphMap;
-
-const webGlassTabBarStyle = {
- backdropFilter: 'blur(22px) saturate(150%)',
- WebkitBackdropFilter: 'blur(22px) saturate(150%)',
-};
-
-function TabIcon({ name, color }: { name: TabIconName; color: string }) {
- return ;
-}
-
-function CameraTabButton() {
- const { session, startSession } = useTravelSessionStore();
- const openCamera = () => router.push('/camera');
- const handlePress = () => {
- if (session.status === 'active') {
- openCamera();
- return;
- }
-
- Alert.alert('여행을 시작할까요?', '순간 저장은 현재 여행에 연결돼요.', [
- { style: 'cancel', text: '취소' },
- {
- onPress: () => {
- startSession();
- openCamera();
- },
- text: '시작하고 촬영',
- },
- ]);
- };
-
- return (
-
-
-
-
-
- );
-}
+import { BottomNavigation } from '@/components/navigation/BottomNavigation';
export default function TabsLayout() {
- const insets = useSafeAreaInsets();
-
- return (
-
- ,
- title: '홈',
- }}
- />
- ,
- title: 'Recap',
- }}
- />
- ,
- title: '기록',
- }}
- />
- ,
- title: '보관함',
- }}
- />
-
-
- ,
- title: '마이',
- }}
- />
-
- );
+ return ;
}
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 188644b..270924c 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,6 +1,6 @@
import { router } from 'expo-router';
-import { useCallback, useEffect, useState } from 'react';
-import { ScrollView } from 'react-native';
+import { useCallback, useEffect } from 'react';
+import { ScrollView, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
@@ -13,6 +13,7 @@ import { MiniPlayer } from '@/components/MiniPlayer';
import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection';
import {
HomeHeader,
+ HomeNavigationBar,
HomeTopFilterBar,
isHomeTopFilter,
} from '@/components/home/HomeHeader';
@@ -21,7 +22,6 @@ import {
isMoodRecommendationFilter,
} from '@/components/home/MoodRecommendationSection';
import { MusicLogSection } from '@/components/home/MusicLogSection';
-import { TravelModeSuggestionSheet } from '@/components/home/TravelModeSuggestionSheet';
import { TravelSessionCard } from '@/components/home/TravelSessionCard';
import { Screen } from '@/components/Screen';
import { getHomeContentBottomPadding } from '@/constants/layout';
@@ -40,8 +40,6 @@ import { createRecommendationEventContext } from '@/utils/recommendationEventCon
function HomeContent() {
const insets = useSafeAreaInsets();
- const [dismissedSuggestionPlaceId, setDismissedSuggestionPlaceId] =
- useState();
const {
selectedMoodFilter,
selectedTopFilter,
@@ -62,13 +60,11 @@ function HomeContent() {
recommendationMode,
selectedMode,
session,
- endSession,
+ resetSession,
setLocation,
setLocationStatus,
- setMode,
setPlace,
setRecommendationMode,
- startSession,
} = useTravelSessionStore();
const nearbyPlacesQuery = useNearbyPlacesQuery({
@@ -122,6 +118,17 @@ function HomeContent() {
}, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]);
const handleSelectRecommendation = (item: MoodRecommendation) => {
+ if (item.playlistId) {
+ router.push(`/playlist/${item.playlistId}`);
+ addRecommendationEvent({
+ context: createRecommendationEventContext(),
+ playlistId: item.playlistId,
+ type: 'playlist_open',
+ value: item.playlistId,
+ });
+ return;
+ }
+
setTrack(item.track);
addRecommendationEvent({
context: createRecommendationEventContext(),
@@ -219,50 +226,49 @@ function HomeContent() {
profile.locationRecommendationEnabled,
]);
- const shouldShowTravelModeSuggestion =
- Boolean(currentPlace) &&
- profile.locationRecommendationEnabled &&
- recommendationMode === 'everyday' &&
- dismissedSuggestionPlaceId !== currentPlace?.id;
-
return (
+
+
- router.push('/recap')}
- onSelectMode={setMode}
- onStartSession={startSession}
- selectedMode={selectedMode}
- startedAt={session.startedAt}
- status={session.status}
- />
+ {recommendationMode === 'travel' ? (
+
+ router.push('/recap')}
+ onOpenTravel={() => router.push('/travel')}
+ selectedMode={selectedMode}
+ startedAt={session.startedAt}
+ status={session.status}
+ />
+
+ ) : null}
-
+
+
+
void featuredPlaylistsQuery.refetch()}
/>
-
+
+
+
-
+
+
+
{currentTrack ? : null}
- {shouldShowTravelModeSuggestion && currentPlace ? (
- setDismissedSuggestionPlaceId(currentPlace.id)}
- onStartTravelMode={() => {
- handleSelectRecommendationMode('travel');
- setDismissedSuggestionPlaceId(currentPlace.id);
- }}
- place={currentPlace}
- />
- ) : null}
);
}
diff --git a/app/(tabs)/travel.tsx b/app/(tabs)/travel.tsx
new file mode 100644
index 0000000..96a5fbd
--- /dev/null
+++ b/app/(tabs)/travel.tsx
@@ -0,0 +1,5 @@
+import { TravelScreen } from '@/components/travel/TravelScreen';
+
+export default function TravelTabScreen() {
+ return ;
+}
diff --git a/assets/soundlog-logo.png b/assets/soundlog-logo.png
index 41e4cab..5efdc53 100644
Binary files a/assets/soundlog-logo.png and b/assets/soundlog-logo.png differ
diff --git a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md
new file mode 100644
index 0000000..de37588
--- /dev/null
+++ b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md
@@ -0,0 +1,45 @@
+# Soundlog Design System
+
+## Palette
+
+The Soundlog palette is built from the reference colors:
+
+| Token | Hex | Role |
+| --- | --- | --- |
+| `brand.pulseMagenta` | `#D718F1` | Personal taste, emotional highlights, everyday music energy |
+| `brand.electricViolet` | `#4F2AEC` | Selected states, primary brand surfaces, active chips |
+| `brand.signalPurple` | `#872BA8` | Supporting depth, recap and memory moments |
+| `brand.limeWave` | `#B7E628` | High-priority action, travel mode, focus borders |
+| `brand.deepIndigo` | `#3B11C4` | Deep anchors, player and navigation depth |
+
+## Color Roles
+
+Use role tokens from `src/constants/colors.ts` before adding screen-local colors.
+
+- Backgrounds use near-black violet tones so the bright palette feels musical instead of flat.
+- Surfaces use glassy purple cards and chips with white borders at low opacity.
+- Active selections use electric violet with lime focus where the action should feel immediate.
+- Primary CTAs and travel-state highlights use lime with `text.inverse`.
+- Magenta is reserved for taste, mood, and expressive music moments.
+
+## Tailwind Tokens
+
+NativeWind tokens live under `soundlog` in `tailwind.config.js`.
+
+- `bg-soundlog-bg`: app background
+- `bg-soundlog-card`: standard cards
+- `bg-soundlog-elevated`: emphasized panels
+- `bg-soundlog-chip`: unselected chips and compact controls
+- `bg-soundlog-selected`: selected chips and segmented controls
+- `bg-soundlog-lime`: high-priority actions
+- `border-soundlog-border`: standard purple border
+- `border-soundlog-focus`: focus or active emphasis
+- `text-soundlog-inverse`: text on lime or bright selected surfaces
+
+## Component Rules
+
+- Keep the camera capture action lime so it is recognizable across tabs.
+- Keep tab active state lime and inactive state muted white.
+- Chips should stay compact, rounded, and role-based: selected violet, unselected purple.
+- Do not use the five source colors evenly on every screen. Each screen should have one dominant role and one accent.
+- Avoid adding new hex values in components unless the value is data-driven, such as track artwork fallback colors.
diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx
index ec49a27..fc7aaa1 100644
--- a/src/components/MiniPlayer.tsx
+++ b/src/components/MiniPlayer.tsx
@@ -15,8 +15,8 @@ import { createRecommendationEventContext } from '@/utils/recommendationEventCon
import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals';
const webGlassPlayerStyle = {
- backdropFilter: 'blur(24px) saturate(155%)',
- WebkitBackdropFilter: 'blur(24px) saturate(155%)',
+ backdropFilter: 'blur(30px) saturate(170%)',
+ WebkitBackdropFilter: 'blur(30px) saturate(170%)',
};
export function MiniPlayer() {
@@ -155,9 +155,9 @@ export function MiniPlayer() {
return (
<>
-
+
setIsActionMenuVisible(true)}
+ disabled={!canSkip}
+ onPress={handlePlayNext}
+ style={{ opacity: canSkip ? 1 : 0.35 }}
>
-
+
diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx
index e0b8eee..5ac5e22 100644
--- a/src/components/home/HomeHeader.tsx
+++ b/src/components/home/HomeHeader.tsx
@@ -1,11 +1,10 @@
-import { Feather } from '@expo/vector-icons';
import { Pressable, ScrollView, View } from 'react-native';
import { AppText } from '@/components/AppText';
import { BrandLogo } from '@/components/BrandLogo';
import { Chip } from '@/components/Chip';
import { colors } from '@/constants/colors';
-import type { MusicRecommendationMode, PlaceContext } from '@/types/domain';
+import type { MusicRecommendationMode } from '@/types/domain';
const topFilters = ['전체', '근처', '지역 트렌드', '내 취향', '저장 많은'];
@@ -14,11 +13,8 @@ export function isHomeTopFilter(filter: string) {
}
type HomeHeaderProps = {
- currentPlace?: PlaceContext;
- isLocationLoading?: boolean;
recommendationMode: MusicRecommendationMode;
onSelectRecommendationMode: (mode: MusicRecommendationMode) => void;
- onSetCurrentLocation: () => void;
};
type HomeTopFilterBarProps = {
@@ -33,55 +29,34 @@ const musicModeOptions: Array<{
}> = [
{
accent: colors.accent.blue,
- label: 'Everyday',
+ label: '일상 모드',
value: 'everyday',
},
{
accent: colors.accent.lime,
- label: 'Travel',
+ label: '여행 모드',
value: 'travel',
},
];
+export function HomeNavigationBar() {
+ return (
+
+
+
+ Soundlog
+
+
+ );
+}
+
export function HomeHeader({
- currentPlace,
- isLocationLoading = false,
onSelectRecommendationMode,
- onSetCurrentLocation,
recommendationMode,
}: HomeHeaderProps) {
- const activeMode =
- musicModeOptions.find((mode) => mode.value === recommendationMode) ??
- musicModeOptions[0];
- const locationLabel = currentPlace?.title ?? '위치를 확인해 볼까요?';
-
return (
-
-
-
-
-
-
- Music Mode
-
-
- {recommendationMode === 'travel' ? '여행지에 맞는 음악' : '평소 취향 중심 추천'}
-
-
-
-
-
-
-
-
-
+
+
{musicModeOptions.map((mode) => {
const selected = recommendationMode === mode.value;
@@ -90,7 +65,7 @@ export function HomeHeader({
onSelectRecommendationMode(mode.value)}
style={{
@@ -99,8 +74,10 @@ export function HomeHeader({
}}
>
{mode.label}
@@ -109,27 +86,6 @@ export function HomeHeader({
})}
-
-
-
-
-
- {locationLabel}
-
-
-
-
-
- {isLocationLoading ? '확인 중' : '위치 설정'}
-
-
-
);
}
diff --git a/src/components/home/LocationContextCard.tsx b/src/components/home/LocationContextCard.tsx
index 1690eb6..967ff33 100644
--- a/src/components/home/LocationContextCard.tsx
+++ b/src/components/home/LocationContextCard.tsx
@@ -34,9 +34,9 @@ const statusCopy: Record
diff --git a/src/components/home/MusicLogCard.tsx b/src/components/home/MusicLogCard.tsx
index d9d57be..5dd77c8 100644
--- a/src/components/home/MusicLogCard.tsx
+++ b/src/components/home/MusicLogCard.tsx
@@ -1,65 +1,86 @@
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
-import { Pressable, StyleSheet, View } from 'react-native';
+import {
+ Animated,
+ Pressable,
+ StyleProp,
+ StyleSheet,
+ View,
+ ViewStyle,
+} from 'react-native';
import { AppText } from '@/components/AppText';
import { MusicLogItem } from '@/types/domain';
type MusicLogCardProps = {
+ animatedStyle?: StyleProp;
+ cardHeight?: number;
+ cardWidth?: number;
index: number;
item: MusicLogItem;
onPress?: () => void;
+ style?: StyleProp;
};
-export function MusicLogCard({ index, item, onPress }: MusicLogCardProps) {
+export function MusicLogCard({
+ animatedStyle,
+ cardHeight = 170,
+ cardWidth = 116,
+ item,
+ onPress,
+ style,
+}: MusicLogCardProps) {
const hasImage = Boolean(item.imageUrl);
return (
-
- {item.imageUrl ? (
- <>
-
-
- >
- ) : null}
+
+
+ {item.imageUrl ? (
+ <>
+
+
+ >
+ ) : null}
-
- LOG
-
+
+ LOG
+
-
- {item.placeName}
-
-
- {item.trackTitle}
-
-
+
+ {item.placeName}
+
+
+ {item.trackTitle}
+
+
+
);
}
diff --git a/src/components/home/MusicLogSection.tsx b/src/components/home/MusicLogSection.tsx
index 0ea0d0b..36a806e 100644
--- a/src/components/home/MusicLogSection.tsx
+++ b/src/components/home/MusicLogSection.tsx
@@ -1,4 +1,11 @@
-import { ScrollView, View } from 'react-native';
+import { useEffect, useRef, useState } from 'react';
+import {
+ Animated,
+ LayoutChangeEvent,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ View,
+} from 'react-native';
import { AppText } from '@/components/AppText';
import { MusicLogCard } from '@/components/home/MusicLogCard';
@@ -11,11 +18,17 @@ type MusicLogSectionProps = {
onSelectLog?: (item: MusicLogItem) => void;
};
+const MUSIC_LOG_CARD_WIDTH = 164;
+const MUSIC_LOG_CARD_HEIGHT = 216;
+const MUSIC_LOG_CARD_GAP = 14;
+const MUSIC_LOG_SNAP_INTERVAL = MUSIC_LOG_CARD_WIDTH + MUSIC_LOG_CARD_GAP;
+const MUSIC_LOG_INITIAL_INDEX = 1;
+
function MusicLogSkeleton() {
return (
-
+
{[0, 1, 2].map((item) => (
-
+
))}
);
@@ -27,9 +40,67 @@ export function MusicLogSection({
isLoading = false,
onSelectLog,
}: MusicLogSectionProps) {
+ const scrollRef = useRef(null);
+ const scrollX = useRef(new Animated.Value(MUSIC_LOG_INITIAL_INDEX * MUSIC_LOG_SNAP_INTERVAL)).current;
+ const [sectionWidth, setSectionWidth] = useState(0);
+ const carouselSidePadding =
+ sectionWidth > 0 ? Math.max(0, (sectionWidth - MUSIC_LOG_CARD_WIDTH) / 2) : 0;
+ const initialIndex = data.length > MUSIC_LOG_INITIAL_INDEX ? MUSIC_LOG_INITIAL_INDEX : 0;
+ const canLoop = data.length > 1;
+ const carouselData = canLoop ? [...data, ...data, ...data] : data;
+ const initialCarouselIndex = canLoop ? data.length + initialIndex : initialIndex;
+ const initialOffset = initialCarouselIndex * MUSIC_LOG_SNAP_INTERVAL;
+
+ const handleLayout = (event: LayoutChangeEvent) => {
+ setSectionWidth(event.nativeEvent.layout.width);
+ };
+
+ const resetLoopEdge = (event: NativeSyntheticEvent) => {
+ if (!canLoop) {
+ return;
+ }
+
+ const currentIndex = Math.round(event.nativeEvent.contentOffset.x / MUSIC_LOG_SNAP_INTERVAL);
+
+ if (currentIndex >= data.length && currentIndex < data.length * 2) {
+ return;
+ }
+
+ const realIndex = ((currentIndex % data.length) + data.length) % data.length;
+ const nextIndex = data.length + realIndex;
+
+ const nextOffset = nextIndex * MUSIC_LOG_SNAP_INTERVAL;
+
+ scrollX.setValue(nextOffset);
+ scrollRef.current?.scrollTo({
+ animated: false,
+ x: nextOffset,
+ y: 0,
+ });
+ };
+
+ useEffect(() => {
+ if (data.length === 0) {
+ return;
+ }
+
+ const nextIndex = data.length > MUSIC_LOG_INITIAL_INDEX ? MUSIC_LOG_INITIAL_INDEX : 0;
+ const nextCarouselIndex = canLoop ? data.length + nextIndex : nextIndex;
+ const nextOffset = nextCarouselIndex * MUSIC_LOG_SNAP_INTERVAL;
+
+ scrollX.setValue(nextOffset);
+ requestAnimationFrame(() => {
+ scrollRef.current?.scrollTo({
+ animated: false,
+ x: nextOffset,
+ y: 0,
+ });
+ });
+ }, [canLoop, data.length, scrollX]);
+
return (
-
- Music Log
+
+ Music Log
{isLoading ? (
@@ -46,18 +117,61 @@ export function MusicLogSection({
) : (
-
-
- {data.map((item, index) => (
+
+ {carouselData.map((item, index) => {
+ const inputRange = [
+ (index - 1) * MUSIC_LOG_SNAP_INTERVAL,
+ index * MUSIC_LOG_SNAP_INTERVAL,
+ (index + 1) * MUSIC_LOG_SNAP_INTERVAL,
+ ];
+ const rotate = scrollX.interpolate({
+ extrapolate: 'clamp',
+ inputRange,
+ outputRange: ['8deg', '0deg', '-8deg'],
+ });
+ const scale = scrollX.interpolate({
+ extrapolate: 'clamp',
+ inputRange,
+ outputRange: [0.9, 1, 0.9],
+ });
+
+ return (
onSelectLog(item) : undefined}
+ style={{ marginRight: index === carouselData.length - 1 ? 0 : MUSIC_LOG_CARD_GAP }}
/>
- ))}
-
-
+ );
+ })}
+
)}
);
diff --git a/src/components/home/TravelSessionCard.tsx b/src/components/home/TravelSessionCard.tsx
index 23fc86a..72cefb1 100644
--- a/src/components/home/TravelSessionCard.tsx
+++ b/src/components/home/TravelSessionCard.tsx
@@ -1,19 +1,18 @@
import { Feather } from '@expo/vector-icons';
-import { Pressable, ScrollView, View } from 'react-native';
+import { Pressable, View } from 'react-native';
import { AppText } from '@/components/AppText';
-import { Chip } from '@/components/Chip';
-import type { TravelMode } from '@/types/domain';
+import type { PlaceContext, TravelMode } from '@/types/domain';
import { formatRecapRecordedAt } from '@/utils/dateFormat';
type TravelSessionStatus = 'active' | 'ended' | 'idle';
type TravelSessionCardProps = {
endedAt?: string;
- onEndSession: () => void;
+ currentPlace?: PlaceContext;
+ onDismissEnded?: () => void;
onOpenRecap: () => void;
- onSelectMode: (mode: TravelMode) => void;
- onStartSession: () => void;
+ onOpenTravel: () => void;
selectedMode?: TravelMode;
startedAt?: string;
status: TravelSessionStatus;
@@ -38,19 +37,19 @@ const statusCopy: Record<
}
> = {
active: {
- cta: '여행 종료',
+ cta: '여행 보기',
description: '순간 저장과 Music Log가 지금 여행에 함께 묶이고 있어요.',
icon: 'radio',
title: '여행 기록 중',
},
ended: {
- cta: '새 여행 시작',
+ cta: '리캡 보기',
description: '저장한 순간은 Recap에서 다시 확인할 수 있어요.',
icon: 'check-circle',
title: '여행이 종료됐어요',
},
idle: {
- cta: '여행 시작',
+ cta: '여행 보기',
description: '현재 장소와 음악을 하나의 여정으로 묶어 기록해요.',
icon: 'play-circle',
title: '여행을 시작해볼까요?',
@@ -70,95 +69,93 @@ function formatSessionTime(status: TravelSessionStatus, startedAt?: string, ende
}
export function TravelSessionCard({
+ currentPlace,
endedAt,
- onEndSession,
+ onDismissEnded,
onOpenRecap,
- onSelectMode,
- onStartSession,
+ onOpenTravel,
selectedMode,
startedAt,
status,
}: TravelSessionCardProps) {
const copy = statusCopy[status];
- const onPrimaryPress = status === 'active' ? onEndSession : onStartSession;
+ const onPrimaryPress = status === 'ended' ? onOpenRecap : onOpenTravel;
const sessionTime = formatSessionTime(status, startedAt, endedAt);
const selectedModeLabel = travelModeOptions.find(
(mode) => mode.value === selectedMode,
)?.label;
+ const isActive = status === 'active';
+ const locationLabel = currentPlace?.title ?? '현재 위치 확인 중';
return (
-
-
-
-
+
+
+
+
-
-
- {copy.title}
+
+ {copy.title}
+
+ {status === 'idle' ? (
+
+ {sessionTime}
-
- {selectedModeLabel ? (
-
-
+ ) : null}
+ {status === 'active' && selectedModeLabel ? (
+
+
{selectedModeLabel}
) : null}
-
- {sessionTime}
-
+ {isActive ? (
+
+
+
+ {sessionTime}
+
+ ·
+
+
+ {locationLabel}
+
+
+ ) : status !== 'idle' ? (
+
+ {sessionTime}
+
+ ) : null}
{copy.cta}
-
- {status !== 'active' ? (
-
- {copy.description}
-
- ) : null}
-
-
- 여행 모드
-
-
- {travelModeOptions.map((mode) => (
- onSelectMode(mode.value)}
- selected={selectedMode === mode.value}
- />
- ))}
-
-
-
-
- {status === 'ended' ? (
-
+ {status === 'ended' && onDismissEnded ? (
- Recap 보기
+
-
- ) : null}
+ ) : null}
+
);
}
diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx
new file mode 100644
index 0000000..f9ee949
--- /dev/null
+++ b/src/components/navigation/BottomNavigation.tsx
@@ -0,0 +1,160 @@
+import { Feather } from '@expo/vector-icons';
+import { Tabs, router } from 'expo-router';
+import {
+ Alert,
+ Platform,
+ Pressable,
+ StyleProp,
+ View,
+ ViewStyle,
+} from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { colors } from '@/constants/colors';
+import { getTabBarHeight } from '@/constants/layout';
+import { useTravelSessionStore } from '@/store/travelSessionStore';
+
+type TabIconName = keyof typeof Feather.glyphMap;
+
+const webGlassTabBarStyle = {
+ backdropFilter: 'blur(22px) saturate(150%)',
+ WebkitBackdropFilter: 'blur(22px) saturate(150%)',
+};
+
+function TabIcon({ name, color }: { name: TabIconName; color: string }) {
+ return ;
+}
+
+function CameraTabButton({ style }: { style?: StyleProp }) {
+ const { session, startSession } = useTravelSessionStore();
+ const openCamera = () => router.push('/camera');
+ const handlePress = () => {
+ if (session.status === 'active') {
+ openCamera();
+ return;
+ }
+
+ Alert.alert('여행을 시작할까요?', '순간 저장은 현재 여행에 연결돼요.', [
+ { style: 'cancel', text: '취소' },
+ {
+ onPress: () => {
+ startSession();
+ openCamera();
+ },
+ text: '시작하고 촬영',
+ },
+ ]);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function BottomNavigation() {
+ const insets = useSafeAreaInsets();
+
+ return (
+
+ ,
+ title: '홈',
+ }}
+ />
+ ,
+ title: '여행',
+ }}
+ />
+ ,
+ title: '카메라',
+ }}
+ />
+ ,
+ title: '보관함',
+ }}
+ />
+ ,
+ title: '마이',
+ }}
+ />
+
+
+
+
+ );
+}
diff --git a/src/components/playlist/PlaylistBackground.tsx b/src/components/playlist/PlaylistBackground.tsx
index e9cc221..6ab3bda 100644
--- a/src/components/playlist/PlaylistBackground.tsx
+++ b/src/components/playlist/PlaylistBackground.tsx
@@ -1,21 +1,33 @@
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
-import { StyleSheet, View } from 'react-native';
+import { StyleSheet, useWindowDimensions, View } from 'react-native';
type PlaylistBackgroundProps = {
+ accentColor?: string;
imageUrl?: string;
};
-export function PlaylistBackground({ imageUrl }: PlaylistBackgroundProps) {
+export function PlaylistBackground({ accentColor, imageUrl }: PlaylistBackgroundProps) {
+ const { height, width } = useWindowDimensions();
+ const repeatedImageStyle = { height: height / 2, width };
+
return (
{imageUrl ? (
-
+ <>
+
+
+ >
) : (
)}
-
+
+ {accentColor ? (
+
+ ) : null}
);
}
diff --git a/src/components/playlist/PlaylistBottomSheet.tsx b/src/components/playlist/PlaylistBottomSheet.tsx
index 25a1e5b..cfec4b1 100644
--- a/src/components/playlist/PlaylistBottomSheet.tsx
+++ b/src/components/playlist/PlaylistBottomSheet.tsx
@@ -1,8 +1,16 @@
import { PropsWithChildren, ReactNode } from 'react';
-import { ScrollView, useWindowDimensions, View } from 'react-native';
+import { LinearGradient } from 'expo-linear-gradient';
+import { Platform, ScrollView, useWindowDimensions, View } from 'react-native';
const COLLAPSED_TOP = 205;
-const SHEET_BACKGROUND = 'rgba(5, 9, 22, 0.78)';
+const SHEET_BACKGROUND = 'rgba(10, 16, 31, 0.26)';
+const glassSurfaceStyle = Platform.select({
+ web: {
+ WebkitBackdropFilter: 'blur(18px) saturate(135%)',
+ backdropFilter: 'blur(18px) saturate(135%)',
+ },
+ default: {},
+});
type PlaylistBottomSheetProps = PropsWithChildren<{
stickyHeader?: ReactNode;
@@ -10,6 +18,7 @@ type PlaylistBottomSheetProps = PropsWithChildren<{
export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSheetProps) {
const { height } = useWindowDimensions();
+ const sheetStyle = { ...glassSurfaceStyle, backgroundColor: SHEET_BACKGROUND };
return (
{stickyHeader ? (
- [
-
-
- {stickyHeader}
- ,
-
- {children}
- ,
- ]
+
+
+
+ {stickyHeader}
+ {children}
+
) : (
+
{children}
diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx
index 0adb3f9..c2d4236 100644
--- a/src/components/playlist/PlaylistCurationScreen.tsx
+++ b/src/components/playlist/PlaylistCurationScreen.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
-import { View } from 'react-native';
+import { ScrollView, useWindowDimensions, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { usePlaylistCurationQuery } from '@/api/playlistQueries';
@@ -27,6 +27,7 @@ type PlaylistCurationScreenProps = {
export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenProps) {
const insets = useSafeAreaInsets();
+ const { height } = useWindowDimensions();
const { currentTrack, setTrack } = usePlayerStore();
const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent);
const {
@@ -75,6 +76,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro
const hasMiniPlayer = Boolean(currentTrack);
const listBottomPadding = getCurationListBottomPadding(insets.bottom, hasMiniPlayer);
+ const usesPlainMoodPage = playlistId === 'calm-walk' || Boolean(playlist?.accentColor);
const playTrack = (track: Track) => {
if (!playlist) {
@@ -131,40 +133,59 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro
};
const closeMenu = () => setSelectedTrackId(undefined);
+ const playlistContent = isLoading ? (
+
+ ) : isError ? (
+ refetch()} />
+ ) : !playlist ? (
+
+ ) : (
+ setSelectedTrackId(track.id)}
+ onSelectTrack={playTrack}
+ savedTrackIds={savedTrackIds}
+ tracks={playlist.tracks}
+ />
+ );
return (
-
-
-
+
+ {usesPlainMoodPage ? (
+
+ {playlist ? (
- ) : undefined
- }
- >
- {isLoading ? (
-
- ) : isError ? (
- refetch()} />
- ) : !playlist ? (
-
- ) : (
- setSelectedTrackId(track.id)}
- onSelectTrack={playTrack}
- savedTrackIds={savedTrackIds}
- tracks={playlist.tracks}
- />
- )}
-
+ ) : null}
+ {playlistContent}
+
+ ) : (
+
+ ) : undefined
+ }
+ >
+ {playlistContent}
+
+ )}
-
-
- {playlist.regionName}
-
- {playlist.placeName ? (
-
- {playlist.placeName}
+
+
+
+
+ {playlist.regionName}
- ) : null}
-
- {playlist.reason}
-
-
-
- {playlist.trackCount}곡
-
-
- {playlist.durationText}
+ {playlist.placeName ? (
+
+ {playlist.placeName}
+
+ ) : null}
+
+ {playlist.reason}
+
+
+
+ {playlist.trackCount}곡
+
+
+ {playlist.durationText}
+
+
+
+
+
-
-
-
+
+
+
);
}
diff --git a/src/components/playlist/PlaylistMusicFilter.tsx b/src/components/playlist/PlaylistMusicFilter.tsx
new file mode 100644
index 0000000..04ab68b
--- /dev/null
+++ b/src/components/playlist/PlaylistMusicFilter.tsx
@@ -0,0 +1,50 @@
+import { useState } from 'react';
+import { Pressable, ScrollView, View } from 'react-native';
+
+import { AppText } from '@/components/AppText';
+
+const musicFilters = ['전체', '드라이브', '산책', '시원한 바람', '신나는'];
+
+export function PlaylistMusicFilter() {
+ const [selectedFilter, setSelectedFilter] = useState(musicFilters[0]);
+
+ return (
+
+ {musicFilters.map((filter) => {
+ const isSelected = selectedFilter === filter;
+
+ return (
+ setSelectedFilter(filter)}
+ style={{
+ backgroundColor: isSelected ? 'rgba(255, 255, 255, 0.18)' : 'rgba(255, 255, 255, 0.09)',
+ borderColor: isSelected ? 'rgba(255, 255, 255, 0.42)' : 'rgba(255, 255, 255, 0.22)',
+ }}
+ >
+
+
+ {filter}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/travel/EndTravelConfirmModal.tsx b/src/components/travel/EndTravelConfirmModal.tsx
new file mode 100644
index 0000000..54d2954
--- /dev/null
+++ b/src/components/travel/EndTravelConfirmModal.tsx
@@ -0,0 +1,52 @@
+import { Feather } from '@expo/vector-icons';
+import { Modal, Pressable, View } from 'react-native';
+
+import { AppText } from '@/components/AppText';
+
+type EndTravelConfirmModalProps = {
+ momentCount: number;
+ onCancel: () => void;
+ onConfirm: () => void;
+ visible: boolean;
+};
+
+export function EndTravelConfirmModal({
+ momentCount,
+ onCancel,
+ onConfirm,
+ visible,
+}: EndTravelConfirmModalProps) {
+ return (
+
+
+
+
+
+
+
+ 여행을 종료할까요?
+
+ 지금까지 저장한 Moment {momentCount}개가 Recap 생성을 위한 여행 기록으로 묶여요.
+
+
+
+
+ 취소
+
+
+ 종료
+
+
+
+
+
+ );
+}
diff --git a/src/components/travel/MomentCard.tsx b/src/components/travel/MomentCard.tsx
new file mode 100644
index 0000000..a0f5617
--- /dev/null
+++ b/src/components/travel/MomentCard.tsx
@@ -0,0 +1,69 @@
+import { Feather } from '@expo/vector-icons';
+import { Image } from 'expo-image';
+import { Pressable, View } from 'react-native';
+
+import { AppText } from '@/components/AppText';
+import type { MomentLog } from '@/types/domain';
+
+import { formatKoreanDateTime } from './travelFormat';
+import { moodLabelByValue } from './travelData';
+
+type MomentCardProps = {
+ item: MomentLog;
+ onPress?: () => void;
+};
+
+export function MomentCard({ item, onPress }: MomentCardProps) {
+ const moodLabel = item.moodTags[0] ? moodLabelByValue[item.moodTags[0]] : '무드 기록';
+
+ return (
+
+
+ {item.photoUri ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+ Moment
+
+
+ {moodLabel}
+
+
+
+
+
+
+ {item.placeName ?? '위치 없음'}
+
+
+
+
+
+
+ {item.track ? `${item.track.title} - ${item.track.artist}` : '음악 없음'}
+
+
+
+
+
+ {formatKoreanDateTime(item.createdAt)}
+
+
+
+ );
+}
diff --git a/src/components/travel/RecapCard.tsx b/src/components/travel/RecapCard.tsx
new file mode 100644
index 0000000..d73c8a6
--- /dev/null
+++ b/src/components/travel/RecapCard.tsx
@@ -0,0 +1,63 @@
+import { Feather } from '@expo/vector-icons';
+import { Pressable, View } from 'react-native';
+
+import { AppText } from '@/components/AppText';
+
+import { modeIconByValue, modeLabelByValue, type TravelRecap } from './travelData';
+
+type RecapCardProps = {
+ item: TravelRecap;
+ onPress: () => void;
+};
+
+export function RecapCard({ item, onPress }: RecapCardProps) {
+ return (
+
+
+
+ {modeIconByValue[item.mode]}
+
+
+
+
+
+ {modeLabelByValue[item.mode]}
+
+ {item.date}
+
+
+
+ {item.durationText} · {item.playTimeText.replace('총 음악 재생 ', '')}
+
+
+
+
+
+ {item.playCount}회
+
+
+
+
+ {item.trackCount}곡
+
+
+
+
+ Moment {item.momentCount}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/travel/TravelModeBottomSheet.tsx b/src/components/travel/TravelModeBottomSheet.tsx
new file mode 100644
index 0000000..9721dd9
--- /dev/null
+++ b/src/components/travel/TravelModeBottomSheet.tsx
@@ -0,0 +1,102 @@
+import { Feather } from '@expo/vector-icons';
+import { Modal, Pressable, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { AppText } from '@/components/AppText';
+import type { TravelMode } from '@/types/domain';
+
+import { travelModeOptions } from './travelData';
+
+type TravelModeBottomSheetProps = {
+ onClose: () => void;
+ onSelectMode: (mode: TravelMode) => void;
+ onStart: () => void;
+ selectedMode?: TravelMode;
+ visible: boolean;
+};
+
+export function TravelModeBottomSheet({
+ onClose,
+ onSelectMode,
+ onStart,
+ selectedMode,
+ visible,
+}: TravelModeBottomSheetProps) {
+ const insets = useSafeAreaInsets();
+
+ return (
+
+
+
+
+
+
+
+
+ 여행 모드 선택
+
+ 지금의 여행 맥락을 고르면 음악 추천과 Moment가 같은 세션으로 묶여요.
+
+
+
+
+
+
+
+
+ {travelModeOptions.map((mode) => {
+ const selected = selectedMode === mode.value;
+
+ return (
+ onSelectMode(mode.value)}
+ >
+ {mode.icon}
+
+ {mode.label}
+
+
+ );
+ })}
+
+
+
+
+ 여행 시작
+
+
+
+
+
+ );
+}
diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx
new file mode 100644
index 0000000..512b73c
--- /dev/null
+++ b/src/components/travel/TravelReportModal.tsx
@@ -0,0 +1,578 @@
+import { Feather } from '@expo/vector-icons';
+import { LinearGradient } from 'expo-linear-gradient';
+import { useEffect, useState } from 'react';
+import { Image, Modal, Pressable, StyleSheet, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { AppText } from '@/components/AppText';
+
+import { modeIconByValue, modeLabelByValue, sampleMoments, type TravelRecap } from './travelData';
+
+type TravelReportModalProps = {
+ item?: TravelRecap;
+ onClose: () => void;
+ visible: boolean;
+};
+
+type StoryPage = {
+ accent: string;
+ hideBottomBar?: boolean;
+ hideGrayCircle?: boolean;
+ hideInnerCircles?: boolean;
+ hideShapes?: boolean;
+ key: string;
+ node: React.ReactNode;
+ palette: [string, string, string];
+};
+
+const STORY_DURATION_MS = 4200;
+const STORY_PAGE_COUNT = 6;
+
+function CoverGeometry({ accent }: { accent: string }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function DotPattern({ color = 'rgba(255,255,255,0.16)' }: { color?: string }) {
+ return (
+
+ {Array.from({ length: 42 }).map((_, index) => {
+ const row = Math.floor(index / 7);
+ const column = index % 7;
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function StoryBackdrop({
+ accent,
+ hideBottomBar = false,
+ hideGrayCircle = false,
+ hideInnerCircles = false,
+ hideShapes = false,
+ minimal = false,
+ pattern,
+}: {
+ accent: string;
+ hideBottomBar?: boolean;
+ hideGrayCircle?: boolean;
+ hideInnerCircles?: boolean;
+ hideShapes?: boolean;
+ minimal?: boolean;
+ pattern?: 'dots';
+}) {
+ return (
+
+ {pattern === 'dots' ? : null}
+ {minimal ? (
+
+ ) : hideShapes ? null : (
+ <>
+
+ {hideInnerCircles ? null : (
+
+ )}
+
+ {hideGrayCircle ? null : (
+
+ )}
+ {hideInnerCircles ? null : (
+
+ )}
+ {hideBottomBar ? null : (
+
+ )}
+ >
+ )}
+
+ );
+}
+
+function ProgressBars({ currentIndex, total }: { currentIndex: number; total: number }) {
+ return (
+
+ {Array.from({ length: total }).map((_, index) => (
+
+
+
+ ))}
+
+ );
+}
+
+function StoryShell({
+ accent,
+ children,
+ hideBottomBar,
+ hideGrayCircle,
+ hideInnerCircles,
+ hideShapes,
+ minimalBackdrop,
+ palette,
+ pattern,
+}: {
+ accent: string;
+ children: React.ReactNode;
+ hideBottomBar?: boolean;
+ hideGrayCircle?: boolean;
+ hideInnerCircles?: boolean;
+ hideShapes?: boolean;
+ minimalBackdrop?: boolean;
+ palette: [string, string, string];
+ pattern?: 'dots';
+}) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+function SmallCaps({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function PlayerBar({ title }: { title: string }) {
+ return (
+
+
+
+ NOW PLAYING
+
+ {title}
+
+
+
+
+
+ );
+}
+
+export function TravelReportModal({ item, onClose, visible }: TravelReportModalProps) {
+ const insets = useSafeAreaInsets();
+ const [pageIndex, setPageIndex] = useState(0);
+
+ useEffect(() => {
+ if (visible) {
+ setPageIndex(0);
+ }
+ }, [visible, item?.id]);
+
+ useEffect(() => {
+ if (!visible) {
+ return;
+ }
+
+ const timeoutId = setTimeout(() => {
+ setPageIndex((index) => {
+ if (index >= STORY_PAGE_COUNT - 1) {
+ return index;
+ }
+
+ return index + 1;
+ });
+ }, STORY_DURATION_MS);
+
+ return () => clearTimeout(timeoutId);
+ }, [pageIndex, visible]);
+
+ if (!item) {
+ return null;
+ }
+
+ const modeLabel = modeLabelByValue[item.mode];
+ const modeIcon = modeIconByValue[item.mode];
+ const mostPlayed = item.topTracks[0];
+ const [startedAtText = item.periodText, rawEndedAtText = item.periodText] = item.periodText
+ .split(' - ')
+ .map((value) => value.trim());
+ const startedDateText = startedAtText.split(' ')[0] ?? '';
+ const endedAtText = rawEndedAtText.includes('.')
+ ? rawEndedAtText
+ : `${startedDateText} ${rawEndedAtText}`.trim();
+ const recapThumbnails = [sampleMoments[0], sampleMoments[1], sampleMoments[0]].filter(Boolean);
+ const pages: StoryPage[] = [
+ {
+ accent: '#F2C94C',
+ hideBottomBar: true,
+ hideInnerCircles: true,
+ key: 'cover',
+ palette: ['#070B1F', '#070B1F', '#070B1F'],
+ node: (
+
+
+
+ Soundlog
+
+
+ Travel{'\n'}Recap
+
+ {modeIcon}
+
+ {modeLabel} 기록
+
+
+
+ {endedAtText}
+
+
+ {startedAtText}
+
+
+
+
+
+ Travel location
+
+
+ {item.locations.join('\n')}
+
+
+
+ ),
+ },
+ {
+ accent: '#FF352B',
+ hideBottomBar: true,
+ hideInnerCircles: true,
+ key: 'summary',
+ palette: ['#F2C94C', '#F2C94C', '#F2C94C'],
+ node: (
+
+
+
+ Your listening time
+
+
+ {item.playTimeText.replace('총 음악 재생 ', '')}
+
+
+ 음악으로 채워진{'\n'}{item.durationText}
+
+
+
+
+
+ Total plays
+
+
+ {item.playCount}
+
+ 회 재생
+
+
+
+ Unique tracks
+
+
+ {item.trackCount}
+
+ 곡 감상
+
+
+
+ ),
+ },
+ {
+ accent: '#FF352B',
+ hideInnerCircles: true,
+ key: 'most',
+ palette: ['#07131A', '#07131A', '#111C22'],
+ node: (
+
+
+ Most played
+
+ 이 여행에서{'\n'}가장 많이 들은 노래
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {mostPlayed.title}
+
+
+ {mostPlayed.artist} · {mostPlayed.playCount}회
+
+
+
+ ),
+ },
+ {
+ accent: '#F2C94C',
+ hideInnerCircles: true,
+ hideBottomBar: true,
+ key: 'ranking',
+ palette: ['#FF352B', '#FF352B', '#FF352B'],
+ node: (
+
+
+ Top songs
+
+ 많이 들은 순위
+
+
+
+ {item.topTracks.map((track, index) => (
+
+ {index + 1}
+
+
+
+ {track.title}
+
+
+ {track.artist}
+
+
+
+ ))}
+
+
+
+ ),
+ },
+ {
+ accent: '#FF352B',
+ hideBottomBar: true,
+ hideGrayCircle: true,
+ hideInnerCircles: true,
+ key: 'recaps',
+ palette: ['#F2C94C', '#F2C94C', '#F2C94C'],
+ node: (
+
+
+ Saved recaps
+
+ 이 여행에서{'\n'}남긴 기록
+
+
+
+
+ {item.momentCount}
+
+ 개의 Recap
+
+
+
+ {recapThumbnails.map((moment, index) => (
+
+
+
+ ))}
+
+
+
+ ),
+ },
+ {
+ accent: '#FF352B',
+ hideBottomBar: true,
+ hideInnerCircles: true,
+ key: 'share',
+ palette: ['#07131A', '#07131A', '#111C22'],
+ node: (
+
+
+ Travel summary
+
+ 숫자로 남은{'\n'}이번 여행
+
+
+
+
+
+ Travel time
+
+
+ {item.durationText.replace('의 여행', '')}
+
+
+
+
+
+ Plays
+
+
+ {item.playCount}
+
+
+
+
+ Tracks
+
+
+ {item.trackCount}
+
+
+
+
+
+ Moments
+
+
+ {item.momentCount}
+
+
+
+
+
+ Mode
+
+
+ {modeIcon} {modeLabel}
+
+
+
+ ),
+ },
+ ];
+
+ const goPrevious = () => setPageIndex((index) => Math.max(0, index - 1));
+ const goNext = () => setPageIndex((index) => Math.min(pages.length - 1, index + 1));
+ const currentPage = pages[pageIndex];
+
+ return (
+
+
+
+
+
+
+
+ Soundlog · Travel Recap
+
+
+
+
+
+
+
+ {currentPage.node}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx
new file mode 100644
index 0000000..e762381
--- /dev/null
+++ b/src/components/travel/TravelScreen.tsx
@@ -0,0 +1,191 @@
+import { router } from 'expo-router';
+import { useEffect, useMemo, useState } from 'react';
+import { Pressable, ScrollView, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+
+import { MiniPlayer } from '@/components/MiniPlayer';
+import { Screen } from '@/components/Screen';
+import { AppText } from '@/components/AppText';
+import { getHomeContentBottomPadding } from '@/constants/layout';
+import { useMomentLogStore } from '@/store/momentLogStore';
+import { usePlayerStore } from '@/store/playerStore';
+import { useTravelSessionStore } from '@/store/travelSessionStore';
+import type { TravelMode } from '@/types/domain';
+
+import { EndTravelConfirmModal } from './EndTravelConfirmModal';
+import { MomentCard } from './MomentCard';
+import { RecapCard } from './RecapCard';
+import { TravelModeBottomSheet } from './TravelModeBottomSheet';
+import { TravelReportModal } from './TravelReportModal';
+import { TravelStatusCard } from './TravelStatusCard';
+import { sampleMoments, sampleRecaps, type TravelRecap } from './travelData';
+
+const sampleMomentMusicLogIds: Record = {
+ 'sample-gwangalli': 'log-1',
+ 'sample-night': 'log-3',
+ 'sample-seongsu': 'log-2',
+};
+
+function getMomentMusicLogId(momentId: string) {
+ return sampleMomentMusicLogIds[momentId] ?? momentId;
+}
+
+export function TravelScreen() {
+ const insets = useSafeAreaInsets();
+ const [isModeSheetVisible, setIsModeSheetVisible] = useState(false);
+ const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false);
+ const [selectedReport, setSelectedReport] = useState();
+ const [, setClockTick] = useState(0);
+ const {
+ currentPlace,
+ selectedMode,
+ session,
+ endSession,
+ resetSession,
+ setMode,
+ startSession,
+ } = useTravelSessionStore();
+ const { currentTrack } = usePlayerStore();
+ const momentLogs = useMomentLogStore((state) => state.logs);
+ const moments = useMemo(
+ () => [...momentLogs, ...sampleMoments].slice(0, 3),
+ [momentLogs],
+ );
+ const momentCount = Math.max(momentLogs.length, session.status === 'active' ? 8 : 0);
+
+ useEffect(() => {
+ if (session.status !== 'active') {
+ return;
+ }
+
+ const intervalId = setInterval(() => {
+ setClockTick((tick) => tick + 1);
+ }, 1000);
+
+ return () => clearInterval(intervalId);
+ }, [session.status]);
+
+ const openModeSheet = () => {
+ if (session.status === 'ended') {
+ resetSession();
+ }
+
+ setIsModeSheetVisible(true);
+ };
+ const handleSelectMode = (mode: TravelMode) => {
+ setMode(mode);
+ };
+ const handleStartTravel = () => {
+ if (!selectedMode) {
+ setMode('cafe');
+ }
+
+ startSession();
+ setIsModeSheetVisible(false);
+ };
+ const handleConfirmEnd = () => {
+ endSession();
+ setIsEndConfirmVisible(false);
+ };
+
+ return (
+
+
+
+
+ 음악으로 기록하는 당신의 여정
+
+
+ Travel
+
+
+
+ setIsEndConfirmVisible(true)}
+ onOpenRecap={() => router.push('/recap-share/seoul-night')}
+ onSaveMoment={() => router.push('/camera')}
+ onStartTravel={openModeSheet}
+ selectedMode={selectedMode}
+ startedAt={session.startedAt}
+ status={session.status}
+ />
+
+
+
+
+ 최근 Moment
+ 여행 중 직접 저장한 순간
+
+ router.push('/library')}>
+ 더보기
+
+
+
+
+ {moments.map((moment) => (
+ router.push(`/recap-share/${getMomentMusicLogId(moment.id)}`)}
+ />
+ ))}
+
+
+
+
+
+
+ Travel Log
+
+ 여행별 음악과 Moment 요약
+
+
+
+
+
+ {sampleRecaps.slice(0, session.status === 'idle' ? 3 : 2).map((recap) => (
+ setSelectedReport(recap)}
+ />
+ ))}
+
+
+
+
+ {currentTrack ? : null}
+
+ setIsModeSheetVisible(false)}
+ onSelectMode={handleSelectMode}
+ onStart={handleStartTravel}
+ selectedMode={selectedMode}
+ visible={isModeSheetVisible}
+ />
+ setIsEndConfirmVisible(false)}
+ onConfirm={handleConfirmEnd}
+ visible={isEndConfirmVisible}
+ />
+ setSelectedReport(undefined)}
+ visible={Boolean(selectedReport)}
+ />
+
+ );
+}
diff --git a/src/components/travel/TravelStatusCard.tsx b/src/components/travel/TravelStatusCard.tsx
new file mode 100644
index 0000000..f237a80
--- /dev/null
+++ b/src/components/travel/TravelStatusCard.tsx
@@ -0,0 +1,184 @@
+import { Feather } from '@expo/vector-icons';
+import { Pressable, View } from 'react-native';
+
+import { AppText } from '@/components/AppText';
+import type { PlaceContext, Track, TravelMode } from '@/types/domain';
+
+import { formatDurationText, formatElapsedTime, formatKoreanDateTime, formatShortEndedAt } from './travelFormat';
+import { modeIconByValue, modeLabelByValue } from './travelData';
+
+type TravelStatus = 'active' | 'ended' | 'idle';
+
+type TravelStatusCardProps = {
+ currentPlace?: PlaceContext;
+ currentTrack?: Track;
+ endedAt?: string;
+ momentCount: number;
+ onEndTravel: () => void;
+ onOpenRecap: () => void;
+ onSaveMoment: () => void;
+ onStartTravel: () => void;
+ selectedMode?: TravelMode;
+ startedAt?: string;
+ status: TravelStatus;
+};
+
+function Metric({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+function CompactMetric({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
+export function TravelStatusCard({
+ currentPlace,
+ currentTrack,
+ endedAt,
+ momentCount,
+ onEndTravel,
+ onOpenRecap,
+ onSaveMoment,
+ onStartTravel,
+ selectedMode,
+ startedAt,
+ status,
+}: TravelStatusCardProps) {
+ const modeLabel = selectedMode ? modeLabelByValue[selectedMode] : '카페 투어';
+ const modeIcon = selectedMode ? modeIconByValue[selectedMode] : '☕';
+ const placeLabel = currentPlace?.title ?? '성수 카페거리';
+ const currentTrackLabel = currentTrack
+ ? `${currentTrack.artist} - ${currentTrack.title}`
+ : 'NewJeans - Ditto';
+
+ if (status === 'active') {
+ return (
+
+
+
+
+ 여행 진행 중
+
+
+
+ {modeIcon} {modeLabel}
+
+
+
+
+
+ {formatElapsedTime(startedAt)}
+
+
+ {formatKoreanDateTime(startedAt)} 시작
+
+
+
+
+
+
+
+
+
+
+ 현재 재생 중
+
+ {currentTrackLabel}
+
+
+
+
+
+
+ 여행 종료
+
+
+ 순간 저장
+
+
+
+ );
+ }
+
+ if (status === 'ended') {
+ return (
+
+ 여행이 종료됐어요
+
+ 저장한 순간은 Recap에서 다시 확인할 수 있어요.
+
+
+ {formatShortEndedAt(endedAt)}
+
+
+
+
+
+
+
+
+
+
+
+
+ Recap 보기
+
+
+ 새 여행 시작
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ 여행을 시작해보세요
+
+
+ 현재 위치에 맞는 음악을 추천받고, 여행 중 남긴 순간을 Recap으로 기록할 수 있어요.
+
+
+ 새 여행 시작
+
+
+ );
+}
diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts
new file mode 100644
index 0000000..743d462
--- /dev/null
+++ b/src/components/travel/travelData.ts
@@ -0,0 +1,177 @@
+import type { MomentLog, MoodTag, TravelMode } from '@/types/domain';
+
+export type TravelModeOption = {
+ icon: string;
+ label: string;
+ value: TravelMode;
+};
+
+export type TravelRecap = {
+ date: string;
+ durationText: string;
+ id: string;
+ locations: string[];
+ mode: TravelMode;
+ momentCount: number;
+ periodText: string;
+ playCount: number;
+ playTimeText: string;
+ representativeTrack: string;
+ topTracks: Array<{
+ artist: string;
+ playCount: number;
+ title: string;
+ }>;
+ trackCount: number;
+ uniqueTracks: string[];
+};
+
+export const travelModeOptions: TravelModeOption[] = [
+ { icon: '🚶', label: '산책', value: 'walk' },
+ { icon: '🚗', label: '드라이브', value: 'drive' },
+ { icon: '☕', label: '카페 투어', value: 'cafe' },
+ { icon: '🌊', label: '바다 보기', value: 'ocean' },
+ { icon: '🎪', label: '축제', value: 'festival' },
+ { icon: '🌙', label: '야경 감상', value: 'night' },
+];
+
+export const modeLabelByValue = travelModeOptions.reduce(
+ (acc, mode) => ({
+ ...acc,
+ [mode.value]: mode.label,
+ }),
+ {} as Record,
+);
+
+export const modeIconByValue = travelModeOptions.reduce(
+ (acc, mode) => ({
+ ...acc,
+ [mode.value]: mode.icon,
+ }),
+ {} as Record,
+);
+
+export const moodLabelByValue: Record = {
+ active: '활기찬',
+ calm: '잔잔한',
+ emotional: '감성적인',
+ fresh: '청량한',
+ local: '로컬',
+};
+
+export const sampleMoments: MomentLog[] = [
+ {
+ createdAt: '2026-06-06T17:21:00.000+09:00',
+ id: 'sample-gwangalli',
+ moodTags: ['fresh'],
+ photoUri: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg',
+ placeName: '광안리 해변',
+ source: 'camera',
+ syncStatus: 'synced',
+ track: {
+ artist: 'NewJeans',
+ fallbackColor: '#7DD3FC',
+ id: 'ditto',
+ title: 'Ditto',
+ },
+ travelMode: 'ocean',
+ },
+ {
+ createdAt: '2026-06-06T15:42:00.000+09:00',
+ id: 'sample-seongsu',
+ moodTags: ['calm', 'local'],
+ photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg',
+ placeName: '성수 카페거리',
+ source: 'camera',
+ syncStatus: 'synced',
+ track: {
+ artist: 'IU',
+ fallbackColor: '#FBBF24',
+ id: 'love-wins-all',
+ title: 'Love wins all',
+ },
+ travelMode: 'cafe',
+ },
+ {
+ createdAt: '2026-06-06T13:18:00.000+09:00',
+ id: 'sample-night',
+ moodTags: ['emotional'],
+ photoUri: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg',
+ placeName: '남산 산책로',
+ source: 'camera',
+ syncStatus: 'synced',
+ track: {
+ artist: '10CM',
+ fallbackColor: '#C084FC',
+ id: 'spring-snow',
+ title: '봄눈',
+ },
+ travelMode: 'walk',
+ },
+];
+
+export const sampleRecaps: TravelRecap[] = [
+ {
+ date: '2026.06.06',
+ durationText: '2시간 14분의 여행',
+ id: 'seoul-night',
+ locations: ['성수 카페거리', '서울숲 입구', '뚝섬 전망대'],
+ mode: 'cafe',
+ momentCount: 12,
+ periodText: '2026.06.06 13:02 - 15:16',
+ playCount: 48,
+ playTimeText: '총 음악 재생 1시간 52분',
+ representativeTrack: 'IU - Love wins all',
+ topTracks: [
+ { artist: 'IU', playCount: 9, title: 'Love wins all' },
+ { artist: 'NewJeans', playCount: 7, title: 'Ditto' },
+ { artist: 'NewJeans', playCount: 6, title: 'ETA' },
+ { artist: 'IU', playCount: 5, title: 'Blueming' },
+ { artist: 'NewJeans', playCount: 4, title: 'Attention' },
+ ],
+ trackCount: 31,
+ uniqueTracks: ['Love wins all', 'Ditto', 'ETA', 'Blueming', 'Attention', '밤편지'],
+ },
+ {
+ date: '2026.05.25',
+ durationText: '1시간 48분의 여행',
+ id: 'log-3',
+ locations: ['광안리 해변', '민락수변공원', '광안대교 전망'],
+ mode: 'ocean',
+ momentCount: 8,
+ periodText: '2026.05.25 16:41 - 18:29',
+ playCount: 36,
+ playTimeText: '총 음악 재생 1시간 20분',
+ representativeTrack: 'NewJeans - Ditto',
+ topTracks: [
+ { artist: 'NewJeans', playCount: 8, title: 'Ditto' },
+ { artist: 'NewJeans', playCount: 6, title: 'Attention' },
+ { artist: 'NewJeans', playCount: 5, title: 'ETA' },
+ { artist: 'AKMU', playCount: 4, title: 'DINOSAUR' },
+ { artist: '10CM', playCount: 3, title: '봄눈' },
+ ],
+ trackCount: 24,
+ uniqueTracks: ['Ditto', 'Attention', 'ETA', 'DINOSAUR', '봄눈', 'Seoul City'],
+ },
+ {
+ date: '2026.05.11',
+ durationText: '3시간 02분의 여행',
+ id: 'log-2',
+ locations: ['남산 산책로', '해방촌', '서울 야경 전망대'],
+ mode: 'night',
+ momentCount: 15,
+ periodText: '2026.05.11 19:08 - 22:10',
+ playCount: 64,
+ playTimeText: '총 음악 재생 2시간 36분',
+ representativeTrack: 'JENNIE - Seoul City',
+ topTracks: [
+ { artist: 'JENNIE', playCount: 11, title: 'Seoul City' },
+ { artist: '10CM', playCount: 8, title: '봄눈' },
+ { artist: 'IU', playCount: 7, title: '밤편지' },
+ { artist: 'Crush', playCount: 6, title: 'Beautiful' },
+ { artist: 'NewJeans', playCount: 5, title: 'Cool With You' },
+ ],
+ trackCount: 42,
+ uniqueTracks: ['Seoul City', '봄눈', '밤편지', 'Beautiful', 'Cool With You', '그라데이션'],
+ },
+];
diff --git a/src/components/travel/travelFormat.ts b/src/components/travel/travelFormat.ts
new file mode 100644
index 0000000..c133472
--- /dev/null
+++ b/src/components/travel/travelFormat.ts
@@ -0,0 +1,83 @@
+export function formatKoreanDateTime(value?: string) {
+ if (!value) {
+ return '아직 없음';
+ }
+
+ const date = new Date(value);
+
+ if (Number.isNaN(date.getTime())) {
+ return '확인 불가';
+ }
+
+ return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(
+ date.getDate(),
+ ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(
+ date.getMinutes(),
+ ).padStart(2, '0')}`;
+}
+
+export function formatShortEndedAt(value?: string) {
+ if (!value) {
+ return '종료 시간 확인 중';
+ }
+
+ const date = new Date(value);
+
+ if (Number.isNaN(date.getTime())) {
+ return '종료 시간 확인 중';
+ }
+
+ const hour = date.getHours();
+ const minute = String(date.getMinutes()).padStart(2, '0');
+ const period = hour >= 12 ? 'PM' : 'AM';
+ const displayHour = hour % 12 || 12;
+
+ return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(
+ date.getDate(),
+ ).padStart(2, '0')} | ${displayHour}:${minute} ${period} 종료`;
+}
+
+export function formatElapsedTime(startedAt?: string, endedAt?: string) {
+ if (!startedAt) {
+ return '00:00:00';
+ }
+
+ const start = new Date(startedAt).getTime();
+ const end = endedAt ? new Date(endedAt).getTime() : Date.now();
+
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
+ return '00:00:00';
+ }
+
+ const seconds = Math.floor((end - start) / 1000);
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const remainingSeconds = seconds % 60;
+
+ return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(
+ remainingSeconds,
+ ).padStart(2, '0')}`;
+}
+
+export function formatDurationText(startedAt?: string, endedAt?: string) {
+ if (!startedAt || !endedAt) {
+ return '2시간 00분';
+ }
+
+ const start = new Date(startedAt).getTime();
+ const end = new Date(endedAt).getTime();
+
+ if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
+ return '2시간 00분';
+ }
+
+ const minutes = Math.max(1, Math.round((end - start) / 60000));
+ const hours = Math.floor(minutes / 60);
+ const remainingMinutes = minutes % 60;
+
+ if (hours <= 0) {
+ return `${remainingMinutes}분`;
+ }
+
+ return `${hours}시간 ${String(remainingMinutes).padStart(2, '0')}분`;
+}
diff --git a/src/mock-server/playlistHandlers.ts b/src/mock-server/playlistHandlers.ts
index 232d40f..e75e122 100644
--- a/src/mock-server/playlistHandlers.ts
+++ b/src/mock-server/playlistHandlers.ts
@@ -5,6 +5,6 @@ export const playlistMockHandlers = {
getPlaylist: (id?: string) =>
mockServerDelay(
'playlist.detail',
- id ? playlistCurationById[id] : playlistDetail,
+ id ? playlistCurationById[id] ?? playlistDetail : playlistDetail,
),
};
diff --git a/src/mocks/homeMocks.ts b/src/mocks/homeMocks.ts
index 59427ed..9d84693 100644
--- a/src/mocks/homeMocks.ts
+++ b/src/mocks/homeMocks.ts
@@ -31,6 +31,7 @@ export const moodRecommendations: MoodRecommendation[] = [
color: '#2B176C',
genres: ['인디', '발라드'],
moods: ['잔잔한', '감성적인'],
+ playlistId: 'calm-walk',
track: { artist: 'JENNIE', fallbackColor: '#192554', id: 'seoul-city', title: 'Seoul City' },
travelStyles: ['산책', '야경 감상'],
},
@@ -40,6 +41,7 @@ export const moodRecommendations: MoodRecommendation[] = [
color: '#B1913A',
genres: ['팝', 'K-POP'],
moods: ['신나는', '청량한', '활기찬'],
+ playlistId: 'drive',
track: { artist: '김건모', fallbackColor: '#48A5B4', id: 'moon-seoul', title: '서울의 달' },
travelStyles: ['드라이브', '바다 보기'],
},
@@ -49,6 +51,7 @@ export const moodRecommendations: MoodRecommendation[] = [
color: '#1F2937',
genres: ['R&B', 'OST'],
moods: ['감성적인', '잔잔한'],
+ playlistId: 'city-night',
track: { artist: '폴킴', fallbackColor: '#D70D31', id: 'hangang', title: '한강에서' },
travelStyles: ['야경 감상', '카페 투어'],
},
@@ -58,6 +61,7 @@ export const moodRecommendations: MoodRecommendation[] = [
color: '#3F2C6B',
genres: ['인디', 'R&B'],
moods: ['잔잔한', '청량한'],
+ playlistId: 'cafe-indie',
track: { artist: '10cm', fallbackColor: '#814D2B', id: 'cafe-night', title: '카페의 밤' },
travelStyles: ['카페 투어', '산책'],
},
@@ -67,6 +71,7 @@ export const moodRecommendations: MoodRecommendation[] = [
color: '#9A3E62',
genres: ['K-POP', '힙합'],
moods: ['신나는', '활기찬'],
+ playlistId: 'festival-kpop',
track: { artist: 'NewJeans', fallbackColor: '#29376B', id: 'festival-air', title: 'Festival Air' },
travelStyles: ['축제'],
},
@@ -100,4 +105,31 @@ export const recentMusicLogs: MusicLogItem[] = [
recapShareId: 'log-3',
trackTitle: '서울의 밤',
},
+ {
+ artistName: 'wave to earth',
+ createdAt: '2026-05-25T00:30:00.000Z',
+ id: 'log-4',
+ imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/33/3010733_image2_1.jpg',
+ placeName: '제주 애월',
+ recapShareId: 'log-1',
+ trackTitle: 'seasons',
+ },
+ {
+ artistName: 'AKMU',
+ createdAt: '2026-05-25T00:40:00.000Z',
+ id: 'log-5',
+ imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/08/2674208_image2_1.jpg',
+ placeName: '경주',
+ recapShareId: 'log-2',
+ trackTitle: 'Dinosaur',
+ },
+ {
+ artistName: '잔나비',
+ createdAt: '2026-05-25T00:50:00.000Z',
+ id: 'log-6',
+ imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg',
+ placeName: '남산',
+ recapShareId: 'log-3',
+ trackTitle: '뜨거운 여름밤은 가고',
+ },
];
diff --git a/src/mocks/playlistMocks.ts b/src/mocks/playlistMocks.ts
index f60038a..929238f 100644
--- a/src/mocks/playlistMocks.ts
+++ b/src/mocks/playlistMocks.ts
@@ -128,6 +128,119 @@ const geojeTracks: Track[] = [
},
];
+const calmWalkTracks: Track[] = [
+ {
+ artist: 'JENNIE',
+ fallbackColor: '#2B176C',
+ id: 'calm-walk-seoul-city',
+ platformUrls: {
+ spotify: 'https://open.spotify.com/search/JENNIE%20Seoul%20City',
+ youtubeMusic: 'https://music.youtube.com/search?q=JENNIE%20Seoul%20City',
+ },
+ title: 'Seoul City',
+ },
+ {
+ artist: '아이유',
+ fallbackColor: '#6E4FD3',
+ id: 'calm-walk-night-letter',
+ platformUrls: {
+ spotify:
+ 'https://open.spotify.com/search/%EC%95%84%EC%9D%B4%EC%9C%A0%20%EB%B0%A4%ED%8E%B8%EC%A7%80',
+ youtubeMusic:
+ 'https://music.youtube.com/search?q=%EC%95%84%EC%9D%B4%EC%9C%A0%20%EB%B0%A4%ED%8E%B8%EC%A7%80',
+ },
+ title: '밤편지',
+ },
+ {
+ artist: 'wave to earth',
+ fallbackColor: '#2D6A72',
+ id: 'calm-walk-seasons',
+ platformUrls: {
+ spotify: 'https://open.spotify.com/search/wave%20to%20earth%20seasons',
+ youtubeMusic: 'https://music.youtube.com/search?q=wave%20to%20earth%20seasons',
+ },
+ title: 'seasons',
+ },
+ {
+ artist: '카더가든',
+ fallbackColor: '#334D3F',
+ id: 'calm-walk-tree',
+ platformUrls: {
+ spotify:
+ 'https://open.spotify.com/search/%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4',
+ youtubeMusic:
+ 'https://music.youtube.com/search?q=%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4',
+ },
+ title: '나무',
+ },
+ {
+ artist: '혁오',
+ fallbackColor: '#45536B',
+ id: 'calm-walk-wi-ing',
+ platformUrls: {
+ spotify: 'https://open.spotify.com/search/hyukoh%20wi%20ing%20wi%20ing',
+ youtubeMusic: 'https://music.youtube.com/search?q=hyukoh%20wi%20ing%20wi%20ing',
+ },
+ title: '위잉위잉',
+ },
+ {
+ artist: '오존',
+ fallbackColor: '#6C7F99',
+ id: 'calm-walk-down',
+ platformUrls: {
+ spotify: 'https://open.spotify.com/search/O3ohn%20Down',
+ youtubeMusic: 'https://music.youtube.com/search?q=O3ohn%20Down',
+ },
+ title: 'Down',
+ },
+];
+
+function createMoodPlaylistTracks(playlistId: string, sourceTracks: Track[], colors: string[]) {
+ return sourceTracks.map((track, index) => ({
+ ...track,
+ fallbackColor: colors[index % colors.length] ?? track.fallbackColor,
+ id: `${playlistId}-${track.id}`,
+ isLiked: undefined,
+ isSaved: undefined,
+ }));
+}
+
+const driveTracks = createMoodPlaylistTracks('drive', [
+ geojeTracks[1],
+ tracks[1],
+ geojeTracks[0],
+ geojeTracks[2],
+ tracks[5],
+ geojeTracks[7],
+], ['#B1913A', '#D29B42', '#1D7F8C', '#DA6C51']);
+
+const cityNightTracks = createMoodPlaylistTracks('city-night', [
+ tracks[2],
+ tracks[0],
+ tracks[3],
+ geojeTracks[3],
+ geojeTracks[7],
+ tracks[5],
+], ['#1F2937', '#45536B', '#526391', '#D70D31']);
+
+const cafeIndieTracks = createMoodPlaylistTracks('cafe-indie', [
+ tracks[5],
+ geojeTracks[6],
+ geojeTracks[3],
+ geojeTracks[5],
+ tracks[3],
+ geojeTracks[4],
+], ['#3F2C6B', '#814D2B', '#334D3F', '#6E4FD3']);
+
+const festivalKpopTracks = createMoodPlaylistTracks('festival-kpop', [
+ tracks[0],
+ geojeTracks[1],
+ geojeTracks[0],
+ tracks[4],
+ tracks[1],
+ geojeTracks[2],
+], ['#9A3E62', '#D70D31', '#E66A73', '#29376B']);
+
export const playlistDetail: PlaylistCuration = {
backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg',
coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg',
@@ -141,6 +254,61 @@ export const playlistDetail: PlaylistCuration = {
};
export const playlistCurationById: Record = {
+ 'calm-walk': {
+ accentColor: '#2B176C',
+ coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/85/2613985_image2_1.jpg',
+ durationText: '28:00분',
+ id: 'calm-walk',
+ placeName: '느린 걸음의 산책길',
+ reason: '잔잔한 산책 무드에 맞춰 천천히 이어지는 음악을 추천했어요',
+ regionName: '잔잔한 산책',
+ trackCount: calmWalkTracks.length,
+ tracks: calmWalkTracks,
+ },
+ drive: {
+ accentColor: '#B1913A',
+ coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg',
+ durationText: '34:00분',
+ id: 'drive',
+ placeName: '해안도로 드라이브',
+ reason: '드라이브 팝 채널의 경쾌한 색감에 맞춰 이동감 있는 음악을 추천했어요',
+ regionName: '드라이브 팝',
+ trackCount: driveTracks.length,
+ tracks: driveTracks,
+ },
+ 'city-night': {
+ accentColor: '#1F2937',
+ coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg',
+ durationText: '31:00분',
+ id: 'city-night',
+ placeName: '도시 야경 산책',
+ reason: '도시의 야경 채널에 맞춰 밤공기와 어울리는 감성적인 음악을 추천했어요',
+ regionName: '도시의 야경',
+ trackCount: cityNightTracks.length,
+ tracks: cityNightTracks,
+ },
+ 'cafe-indie': {
+ accentColor: '#3F2C6B',
+ coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg',
+ durationText: '29:00분',
+ id: 'cafe-indie',
+ placeName: '창가 자리와 오후 산책',
+ reason: '카페 인디 채널의 차분한 색감에 맞춰 오래 머물기 좋은 음악을 추천했어요',
+ regionName: '카페 인디',
+ trackCount: cafeIndieTracks.length,
+ tracks: cafeIndieTracks,
+ },
+ 'festival-kpop': {
+ accentColor: '#9A3E62',
+ coverImageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/33/3010733_image2_1.jpg',
+ durationText: '33:00분',
+ id: 'festival-kpop',
+ placeName: '페스티벌 광장',
+ reason: '페스티벌 K-POP 채널의 에너지에 맞춰 밝고 리듬감 있는 음악을 추천했어요',
+ regionName: '페스티벌 K-POP',
+ trackCount: festivalKpopTracks.length,
+ tracks: festivalKpopTracks,
+ },
'geoje-ocean': {
backgroundImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg',
coverImageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg',
diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts
index 54bd2b3..a830080 100644
--- a/src/store/recommendationEventStore.ts
+++ b/src/store/recommendationEventStore.ts
@@ -12,6 +12,7 @@ export type RecommendationEventType =
| 'track_unlike'
| 'track_save'
| 'track_unsave'
+ | 'playlist_open'
| 'mood_filter_change'
| 'recommendation_mode_change'
| 'top_filter_change';
diff --git a/src/types/domain.ts b/src/types/domain.ts
index 380fca6..722e846 100644
--- a/src/types/domain.ts
+++ b/src/types/domain.ts
@@ -54,6 +54,7 @@ export type MoodRecommendation = {
color: string;
genres?: string[];
moods?: string[];
+ playlistId?: string;
track: Track;
travelStyles?: string[];
};
@@ -63,6 +64,7 @@ export type PlaylistCuration = {
regionName: string;
placeName?: string;
reason: string;
+ accentColor?: string;
coverImageUrl?: string;
backgroundImageUrl?: string;
trackCount: number;