From e106032da5b83090c1d8ce364f0a86f3205da482 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 7 Jul 2026 17:06:51 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=97=AC=ED=96=89=20=EC=82=AC?= =?UTF-8?q?=EC=9A=B4=EB=93=9C=EB=A7=B5=EA=B3=BC=20=EA=B8=B0=ED=9A=8D=20?= =?UTF-8?q?=EA=B2=80=EC=88=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 6 + .../SOUNDLOG_RN_DESIGN_SYSTEM.md | 247 +++ docs/frontend/RN_FRONTEND_PLANNING_POINTS.md | 4 +- docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md | 44 +- docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md | 32 +- docs/product/SOUNDLOG_APP_PLANNING.md | 8 +- .../SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md | 588 +++++ ...SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html | 1953 +++++++++++++++++ package-lock.json | 206 ++ package.json | 1 + src/components/travel/TravelScreen.tsx | 9 + src/components/travel/TravelStatusCard.tsx | 2 +- .../live-sound-map/LiveSoundMapSection.tsx | 177 ++ .../live-sound-map/OpenLayersSoundMap.tsx | 58 + .../live-sound-map/OpenLayersSoundMap.web.tsx | 181 ++ src/components/travel/live-sound-map/index.ts | 2 + .../travel/live-sound-map/soundMapData.ts | 89 + src/components/travel/live-sound-map/types.ts | 24 + src/constants/colors.ts | 1 + src/design-system/index.ts | 37 + src/design-system/recipes.ts | 56 + src/design-system/tokens.ts | 74 + src/store/recommendationEventStore.ts | 6 +- tailwind.config.js | 1 + 24 files changed, 3731 insertions(+), 75 deletions(-) create mode 100644 docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md create mode 100644 docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md create mode 100644 docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html create mode 100644 src/components/travel/live-sound-map/LiveSoundMapSection.tsx create mode 100644 src/components/travel/live-sound-map/OpenLayersSoundMap.tsx create mode 100644 src/components/travel/live-sound-map/OpenLayersSoundMap.web.tsx create mode 100644 src/components/travel/live-sound-map/index.ts create mode 100644 src/components/travel/live-sound-map/soundMapData.ts create mode 100644 src/components/travel/live-sound-map/types.ts create mode 100644 src/design-system/index.ts create mode 100644 src/design-system/recipes.ts create mode 100644 src/design-system/tokens.ts diff --git a/docs/README.md b/docs/README.md index dd9a61f..90a31b3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,12 @@ Soundlog 문서는 목적별로 관리합니다. 공모전 기획, RN 프론트 ## Product - [서비스 기획서](product/SOUNDLOG_APP_PLANNING.md): 서비스 배경, 핵심 가치, 주요 기능, 데이터 활용 방향 +- [여행 사운드트랙 로그 기획 명세](product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md): 외부 음악 링크, MomentLog, Recap 중심 MVP 피벗과 화면별 와이어프레임/명세 +- [여행 사운드트랙 HTML 와이어프레임](product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html): 브라우저에서 보는 화면 와이어프레임과 기능 설명 + +## Design System + +- [Soundlog RN 디자인 시스템](design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md): 현재 구현된 React Native 앱 스타일의 색상, 타이포, radius, 공통 컴포넌트, 화면 조립 레시피 ## Frontend diff --git a/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md b/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md new file mode 100644 index 0000000..973ad7c --- /dev/null +++ b/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md @@ -0,0 +1,247 @@ +# Soundlog RN Design System + +이 문서는 현재 구현된 React Native/Expo 앱의 실제 스타일을 기준으로 만든 디자인 시스템입니다. 새 화면을 만들 때는 먼저 `src/design-system`과 이 문서의 레시피를 조립하고, 새 색상이나 새 컴포넌트는 기존 패턴으로 표현하기 어려울 때만 추가합니다. + +## Design Direction + +- Soundlog는 어두운 앱 배경 위에 음악 카드, 여행 상태, Recap을 겹겹이 올리는 구조입니다. +- 기본 화면은 `#070B1F`, 카드와 컨트롤은 `#080D18`, `#090E1B`, `#171B2A` 계열을 사용합니다. +- 선택 상태와 핵심 CTA는 라임 `#B7E628`입니다. 라임은 "지금 선택됨", "여행 시작", "Recap 보기"처럼 행동이 분명한 곳에 씁니다. +- 파랑은 일상 모드나 보조 정보, 골드는 특별한 음악/Recap 강조, 보라색은 브랜드 로고 중심으로 제한합니다. +- 앱 기본 표면에는 장식용 그라데이션, 새 폰트, 과한 네온 효과를 추가하지 않습니다. 현재 앱의 Recap 이미지, 플레이어, 미디어 fallback처럼 콘텐츠를 표현하는 곳에서만 제한적으로 씁니다. +- UI는 둥근 칩, 둥근 카드, 높은 대비 텍스트, 44px 이상 터치 영역을 기본으로 합니다. + +## Code Entry + +```ts +import { + AppText, + BrandLogo, + Chip, + IconButton, + Screen, + soundlogDesignTokens, + soundlogRecipes, +} from '@/design-system'; +``` + +`src/design-system/index.ts`는 새 컴포넌트를 다시 만드는 곳이 아니라, 현재 RN 앱에 구현된 컴포넌트와 스타일 recipe를 일관되게 꺼내 쓰는 조립 지점입니다. 토큰 원천은 `src/constants/colors.ts`와 `tailwind.config.js`입니다. + +## Source Of Truth + +이 디자인 시스템은 HTML 와이어프레임이나 신규 기획 문서의 시안을 기준으로 하지 않습니다. 기준은 현재 RN 앱에 이미 구현된 화면입니다. + +| Area | Current Source | +| --- | --- | +| 기본 화면 배경과 safe area | `src/components/Screen.tsx` | +| 텍스트 래퍼 | `src/components/AppText.tsx` | +| 필터/태그 칩 | `src/components/Chip.tsx` | +| 상단 로고와 모드 세그먼트 | `src/components/home/HomeHeader.tsx` | +| 추천 플레이리스트 카드 | `src/components/home/FeaturedPlaylistCard.tsx` | +| 여행 상태 카드 | `src/components/travel/TravelStatusCard.tsx` | +| Recap 리스트 카드 | `src/components/recap/RecapListCard.tsx` | +| 라이브 사운드맵 | `src/components/travel/live-sound-map/LiveSoundMapSection.tsx` | + +## Color Tokens + +| Role | Value | NativeWind | +| --- | --- | --- | +| App background | `#070B1F` | `bg-soundlog-bg` | +| Raised background | `#0B102A` | `bg-soundlog-bg2` | +| Card | `#080D18` | `bg-soundlog-card` | +| Elevated card | `#090E1B` | `bg-soundlog-elevated` | +| Chip | `#171B2A` | `bg-soundlog-chip` | +| Selected / CTA | `#B7E628` | `bg-soundlog-selected`, `bg-soundlog-lime` | +| Focus border | `#B7E628` | `border-soundlog-focus` | +| Chip border | `#364283` | `border-soundlog-border` | +| Info blue | `#6EA8FF` | `text-soundlog-blue` | +| Accent purple | `#7A2CFF` | `text-soundlog-purple` | +| Accent gold | `#B1913A` | `text-soundlog-gold` | +| Warning action | `#FF8A3D` | `bg-soundlog-warning` | +| Inverse text | `#090515` | `text-soundlog-inverse` | + +## Typography + +| Use | Current Pattern | +| --- | --- | +| Screen title | `text-[28px] font-semibold text-white` | +| Hero card title | `text-[30px] font-semibold leading-9 text-white` | +| Section title | `text-[22px] font-semibold text-white` | +| Card title | `text-[18px] font-bold leading-6 text-white` | +| Body | `text-sm leading-6 text-white/60` | +| Caption | `text-xs text-white/45` | +| Chip label | `text-[13px] font-medium` | + +항상 `AppText`를 먼저 사용합니다. 카드, 리스트, 작은 탭처럼 폭이 제한된 영역은 `numberOfLines`를 지정해서 텍스트가 카드 밖으로 밀려나지 않게 합니다. + +## Radius And Spacing + +| Use | Current Pattern | +| --- | --- | +| Screen horizontal padding | `px-5` | +| Section gap | `mt-4`, `mt-5`, `gap-4` 중심 | +| Icon button | `h-11 w-11 rounded-full` | +| Default chip | `min-h-[38px] rounded-full px-5` | +| Small chip | `min-h-[28px] rounded-full px-3` | +| Compact card | `rounded-[12px]` | +| Metric card | `rounded-[14px]`, `rounded-[18px]` | +| Mode control | `rounded-[20px]` + inner `rounded-full` | +| Active travel card | `rounded-[22px]` | +| Ended/idle travel cards | `rounded-[28px]`, `rounded-[30px]` | + +새 화면의 카드 radius는 먼저 18, 20, 22 중에서 고릅니다. 화면을 대표하는 큰 상태 카드는 28 또는 30까지 허용합니다. + +## Primitive Components + +### Screen + +`Screen`은 앱 배경과 safe area를 고정합니다. + +```tsx + + + +``` + +### AppText + +모든 텍스트의 기본 래퍼입니다. + +```tsx +오늘의 추천 +``` + +### BrandLogo + +현재 앱 로고 이미지를 원형으로 표시합니다. + +```tsx + +``` + +### Chip + +필터, 태그, 작은 메타 정보에 씁니다. 선택 상태는 라임 배경과 inverse text로 처리합니다. + +```tsx + + +``` + +### IconButton + +상단 액션, 닫기, 공유 같은 아이콘 액션에 씁니다. 접근성 label은 필수입니다. + +```tsx + +``` + +### EmptyState + +비어 있음, 권한 전, 데이터 없음 상태에 씁니다. 새 화면에서 빈 상태 문구와 CTA를 임의 스타일로 만들지 말고 이 컴포넌트를 먼저 씁니다. + +## Class Recipes + +반복되는 NativeWind class 조합은 `soundlogRecipes`에서 꺼내 씁니다. recipe는 현재 앱 스타일의 이름표일 뿐이고, 화면마다 필요한 state/접근성 props는 컴포넌트에서 직접 명시합니다. + +```tsx +import { AppText, soundlogRecipes } from '@/design-system'; + + + 주변 사운드 + +``` + +자주 쓰는 recipe: + +| Recipe | Role | +| --- | --- | +| `screen.content` | `px-5 pt-4` 기본 화면 안쪽 여백 | +| `card.glass` | 여행/상태성 카드의 흰색 10% 표면 | +| `card.media` | Recap처럼 이미지/미디어가 중심인 카드 | +| `button.primary` | 56px 라임 CTA | +| `button.icon` | 44px 원형 아이콘 버튼 | +| `control.segmentedShell` | 홈 모드 세그먼트 바깥 컨테이너 | +| `travel.activeCard` | 여행 진행 중 카드 | +| `recap.listCard` | Recap 리스트 카드 | + +## Screen Recipes + +### Standard App Screen + +```tsx + + + Library + {children} + +``` + +### Segmented Mode Control + +홈의 `HomeHeader` 패턴을 재사용합니다. + +- 바깥 컨테이너: `rounded-[20px] bg-soundlog-elevated/80 p-2` +- 안쪽 트랙: `rounded-full bg-black/25 p-1` +- 선택 탭: 일상 모드는 blue, 여행 모드는 lime +- 선택 탭 text: `text-soundlog-inverse` + +### Filter Bar + +`HomeTopFilterBar`처럼 pill 컨테이너 안에 `Chip`을 가로 스크롤로 배치합니다. + +- 컨테이너: `rounded-full border border-soundlog-border/70 bg-soundlog-chip/70 py-2 pl-2` +- 칩 간격: `gap-2` +- 끝 여백: `pr-5` + +### Music Card + +`FeaturedPlaylistCard`와 `MoodRecommendationCard`가 기준입니다. + +- 추천 플레이리스트: `h-[260px] w-[180px] rounded-[20px] border border-white/10 bg-soundlog-card p-4` +- 무드 카드: `h-[136px] w-[136px] rounded-[12px] p-5` +- 제목은 1-2줄 제한, 설명은 1-2줄 제한을 기본으로 둡니다. + +### Travel Status + +`TravelStatusCard`를 기준으로 여행 진행 상태를 표현합니다. + +- active: 라임 border, 라임 status dot, compact metric, 음악 row, 44px CTA +- ended: 28 radius card, metric grid, 56px primary CTA +- idle: 30 radius card, 라임 원형 아이콘, 큰 시작 CTA + +### Recap And Share + +Recap은 앱에서 가장 감성적인 영역이지만, 기본 표면 규칙은 유지합니다. + +- 리스트 카드는 이미지 또는 fallback 콘텐츠가 중심이고 텍스트는 흰색/60 이하 보조 텍스트로 정리합니다. +- 공유 액션은 `ShareActionButton`, `ShareActionList`를 먼저 조립합니다. +- Recap 미리보기는 `RecapPreviewCard`를 기준으로 템플릿만 바꿉니다. +- 스포티파이 그린, 이미지 fallback, Recap 장식색처럼 콘텐츠나 외부 브랜드가 명확한 색은 해당 컴포넌트 안에서만 제한적으로 유지합니다. + +## Do + +- `bg-soundlog-*`, `text-white/*`, `border-white/*` 토큰을 우선 사용합니다. +- 선택/확정/시작 액션에는 라임을 씁니다. +- 아이콘 버튼은 `IconButton` 또는 같은 44px 원형 패턴을 유지합니다. +- 긴 제목, 위치명, 곡명은 `numberOfLines`로 줄 수를 제한합니다. +- 리스트 row와 카드에는 `min-w-0`을 사용해 텍스트 overflow를 막습니다. + +## Don't + +- 일반 배경이나 카드에 새 장식 그라데이션을 만들지 않습니다. +- 새 폰트나 과한 letter spacing을 추가하지 않습니다. +- 라임을 모든 장식 요소에 뿌리지 않습니다. 라임은 상태와 행동의 언어입니다. +- 카드 안에 또 다른 큰 장식 카드를 중첩하지 않습니다. +- 44px 미만의 터치 버튼을 만들지 않습니다. +- `#FFFFFF` 카드, 베이지 배경, 보라-파랑 랜딩 페이지 톤을 앱 화면에 새로 들여오지 않습니다. + +## Design QA Checklist + +- 새 화면의 최상위가 `Screen`인지 확인합니다. +- 새 색상이 필요하면 먼저 `src/constants/colors.ts`, `tailwind.config.js`, `soundlogDesignTokens`에 들어갈 역할 이름을 정합니다. +- 주요 CTA가 하나만 강하게 보이는지 확인합니다. +- iPhone SE 폭에서도 버튼 텍스트와 카드 제목이 넘치지 않는지 확인합니다. +- 탭, 칩, 아이콘 버튼의 접근성 role/label/selected 상태를 확인합니다. +- 로딩, 비어 있음, 실패, 권한 없음 상태가 `EmptyState` 또는 기존 state component로 표현되는지 확인합니다. +- 디자인 리뷰 때는 실제 RN 앱 스크린샷 기준으로 비교합니다. HTML 와이어프레임은 참고 자료이고, 최종 앱 스타일의 기준은 이 RN 디자인 시스템입니다. diff --git a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md index ba8c88e..69072bf 100644 --- a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md +++ b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md @@ -200,7 +200,7 @@ MVP에서 미니/풀 링크 패널은 복잡한 음악 앱 수준의 재생 제 - 외부 음악 앱 검색 링크, 다음/이전 곡 이동, 좋아요/저장을 우선 제공한다. - 다음/이전 이동과 외부 링크 열기 이벤트는 추천 피드백으로 수집한다. - 백그라운드 음원 제어, 인앱 음원 제공, 외부 플랫폼 상태 제어는 MVP 범위에서 제외한다. -- MVP에서는 실제 음원 감상이나 외부 음악 앱 이동을 제공하지 않고, 선택한 곡 정보를 Soundlog 기록 UI에 유지한다. +- MVP에서는 실제 음원 스트리밍이나 외부 플랫폼 재생 제어를 제공하지 않고, 선택한 곡 정보와 외부 음악 링크를 Soundlog 기록 UI에 유지한다. ### 5.7 순간 저장 카메라 @@ -406,7 +406,7 @@ Zustand 또는 Jotai로 관리할 데이터는 다음과 같다. ```txt GET /v1/places/nearby?lat=&lng=&radius= -GET /v1/recommendations/playlists?lat=&lng=&mode=&moods= +GET /v1/recommendations/playlists?x=&y=&state=&mood= POST /v1/recommendation-events POST /v1/tracks/:trackId/like POST /v1/tracks/:trackId/save diff --git a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md index de37588..4dead09 100644 --- a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md +++ b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM 2.md @@ -1,45 +1,7 @@ # Soundlog Design System -## Palette +이 문서는 초기 팔레트 실험 메모입니다. 현재 구현된 RN 앱 기준 디자인 시스템은 아래 문서를 source of truth로 사용합니다. -The Soundlog palette is built from the reference colors: +- [Soundlog RN 디자인 시스템](../design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md) -| 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. +현재 앱은 마젠타/전기 보라 중심 팔레트가 아니라, 어두운 네이비 배경과 라임 CTA, 흰색 투명도 레이어를 중심으로 구현되어 있습니다. 새 작업은 `src/design-system`의 토큰과 recipe를 기준으로 진행하세요. diff --git a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md index 877a607..8480869 100644 --- a/docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md +++ b/docs/frontend/SOUNDLOG_DESIGN_SYSTEM.md @@ -1,33 +1,7 @@ # Soundlog Design System -## Palette +이 문서는 초기 프론트 시안용 메모입니다. 현재 구현된 RN 앱 기준 디자인 시스템은 아래 문서를 source of truth로 사용합니다. -Soundlog keeps the original dark navy and muted violet UI, with one new highlight color: +- [Soundlog RN 디자인 시스템](../design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md) -| Token | Hex | Role | -| --- | --- | --- | -| `background.primary` | `#070B1F` | App background | -| `surface.card` | `#080D18` | Standard cards | -| `surface.chip` | `#0E1E3A` | Default chips and compact controls | -| `surface.chipSelected` | `#243A75` | Selected chips | -| `accent.purple` | `#7A2CFF` | Existing Soundlog purple accent | -| `accent.lime` | `#B7E628` | High-priority action and active focus only | - -## Color Rules - -- Use dark navy surfaces as the default visual language. -- Use fluorescent lime sparingly: camera action, active tab, selected/focused border, and Travel mode. -- Do not use the full reference palette across chips or cards. -- Keep chips compact and calm: default navy, selected muted blue, lime only as the selection border. -- Avoid adding new bright hex values inside components unless the color comes from content data. - -## Tailwind Tokens - -- `bg-soundlog-bg`: app background -- `bg-soundlog-card`: standard cards -- `bg-soundlog-elevated`: emphasized dark panels -- `bg-soundlog-chip`: unselected chips -- `bg-soundlog-selected`: selected chips -- `bg-soundlog-lime`: high-priority action only -- `border-soundlog-border`: standard chip border -- `border-soundlog-focus`: active/focused lime border +새 화면이나 컴포넌트를 만들 때는 `src/design-system`에서 `soundlogDesignTokens`, `soundlogRecipes`, 기존 공통 컴포넌트를 먼저 조립하세요. 이 파일의 오래된 팔레트 설명은 현재 RN 앱 기준과 다를 수 있습니다. diff --git a/docs/product/SOUNDLOG_APP_PLANNING.md b/docs/product/SOUNDLOG_APP_PLANNING.md index 045c3a1..60388e4 100644 --- a/docs/product/SOUNDLOG_APP_PLANNING.md +++ b/docs/product/SOUNDLOG_APP_PLANNING.md @@ -10,6 +10,12 @@ **음악으로 기록하는 당신의 여정, Soundlog** +### MVP 제품 방향 보강 + +MVP의 Soundlog는 앱 안에서 음원을 직접 스트리밍하거나 외부 음악 앱의 재생 상태를 제어하는 서비스가 아니다. 사용자는 Soundlog에서 현재 장소와 무드에 맞는 곡을 추천받고, 실제 감상은 Spotify, YouTube Music, YouTube 등 외부 음악 링크로 이어간다. Soundlog 내부에서는 선택한 곡을 사진, 장소, 시간, 무드와 함께 저장하고, 여행이 끝난 뒤 하나의 사운드트랙 Recap으로 재구성하는 데 집중한다. + +자세한 피벗 기준, 화면 와이어프레임, 기능 명세는 [여행 사운드트랙 로그 기획 명세](SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md)를 따른다. + ### 핵심 가치 - **맥락 기반 추천:** 단순 위치가 아니라 장소 유형, 시간대, 관광 모드, 무드, 사용자 취향을 함께 반영한다. @@ -108,7 +114,7 @@ Music Log는 여행 중 선택하거나 저장한 음악을 장소와 함께 보 주요 기능은 다음과 같다. - 추천 사유 제공: “광안리 해변 산책과 어울리는 청량한 플레이리스트”처럼 추천 맥락을 짧게 설명한다. -- 음악 컨텍스트 UI: 앱 안에서 직접 재생하거나 외부 음악 앱으로 이동하지 않고, 선택한 곡 정보를 Soundlog의 여행 기록 맥락으로 유지한다. +- 음악 컨텍스트 UI: 앱 안에서 직접 재생하지 않고, 선택한 곡 정보를 Soundlog의 여행 기록 맥락으로 유지하며 필요할 때 외부 음악 링크로 이동한다. - 무드 조정: 더 신나게, 더 잔잔하게, 더 감성적으로, 더 로컬하게 옵션을 제공한다. - 좋아요/저장: 마음에 드는 곡과 플레이리스트를 저장한다. diff --git a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md new file mode 100644 index 0000000..18f47b3 --- /dev/null +++ b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_LOG_SPEC.md @@ -0,0 +1,588 @@ +# Soundlog 여행 사운드트랙 로그 기획 명세 + +## 1. 문서 목적 + +이 문서는 Soundlog의 MVP 방향을 **직접 스트리밍/플레이어 조작 앱**이 아니라 **여행 사운드트랙 로그 앱**으로 정리하기 위한 제품 기획서이다. + +브라우저에서 화면 중심으로 확인할 때는 [여행 사운드트랙 HTML 와이어프레임](SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html)을 사용한다. + +핵심 방향은 다음과 같다. + +> 지금 이 장소의 음악을 찾아주고, 여행이 끝나면 너만의 사운드트랙 앨범으로 남겨주는 서비스 + +Soundlog는 사용자가 여행 중에 음악을 직접 재생하는 앱이 아니다. 사용자가 지금 장소와 분위기에 맞는 곡을 고를 수 있도록 추천하고, 선택한 곡을 사진, 장소, 시간, 무드와 함께 저장한 뒤, 여행이 끝났을 때 Recap으로 재구성하는 앱이다. + +--- + +## 2. 요구사항 매핑 + +이 문서는 다음 목표를 충족하도록 작성한다. + +| 요청/요구사항 | 문서 반영 위치 | 완료 기준 | +| --- | --- | --- | +| 기획을 상세하게 다시 작성 | 3~13장 | 제품 정의, 타겟, 핵심 루프, IA, 화면, API, 이벤트, MVP 범위, 데모 시나리오가 한 흐름으로 연결된다. | +| 와이어프레임을 잡고 옆에 기능/화면 명세를 작성 | 8장 | 각 화면이 `와이어프레임`과 `기능 및 화면 명세` 2열 구조로 정리된다. | +| 기록·Recap 중심 피벗 | 3~6장, 8장, 11장 | 추천이 기록과 Recap으로 이어지고, Recap이 조연이 아니라 핵심 결과물이 된다. | +| 재생 외부화 | 4.4장, P-05, 10장 | 앱은 곡 메타데이터와 링크를 제공하고, 실제 감상은 Spotify/YouTube Music/YouTube로 넘긴다. | +| 브랜딩 문구 반영 | 1장, P-01 | "지금 이 장소의 음악을 찾아주고, 여행이 끝나면 너만의 사운드트랙 앨범으로 남겨주는 서비스"가 제품 정의와 첫 화면에 반영된다. | +| 강점과 약점까지 검토 | 3.2장, 3.3장, 14장 | 저작권/정책 리스크 감소, 곡 단위 출력 유지, Recap 주연화와 함께 데모 심심함/피드백 부족 리스크를 보완한다. | +| 리뷰를 거듭하며 루프 | 14장 | 제품 방향, UX/화면, 구현/검증 관점의 다회 자체 리뷰와 반영 결과를 남긴다. | +| 낯선 여행자와 음악 공유/동행 매칭 | 6장, 7장, P-12~P-14 | 여행 모드 사용자가 익명으로 음악 취향을 공개하고, 취향이 맞는 사람끼리 상호 동의 기반 동행 매칭을 시도할 수 있다. | + +--- + +## 3. 왜 이렇게 바꾸는가 + +### 3.1 직접 스트리밍 중심 기획의 문제 + +초기 아이디어에는 Spotify 계정 연동, 재생 시작/정지, 플레이리스트 제어 같은 기능이 포함될 수 있었다. 하지만 이 방향은 MVP의 성공 가능성을 낮추는 리스크가 있다. + +| 이슈 | 설명 | 제품 영향 | +| --- | --- | --- | +| 외부 플랫폼 의존 | Spotify 조작 기능은 사용자 계정, 구독 상태, 활성 기기, 플랫폼 정책에 영향을 받는다. | 테스트 가능한 사용자가 줄고 핵심 플로우가 불안정해진다. | +| 프리미엄 제약 | 실제 재생 제어 기능은 Spotify Premium 사용자에게만 유효한 경우가 많다. | Soundlog 가치가 일부 사용자에게만 전달된다. | +| 저작권/정책 리스크 | 앱 안에서 음원을 직접 제공하거나 재생 제어를 핵심으로 삼으면 심사와 정책 검토 범위가 커진다. | 배포 전 검수 난도가 올라간다. | +| ROI 문제 | "Spotify 사용자가 Spotify를 조작하는 앱"으로 보이면 시장이 좁아진다. | 여행 기록 앱으로서의 차별점이 약해진다. | + +### 3.2 기록과 Recap 중심 기획의 장점 + +Soundlog가 곡의 실제 재생을 외부 앱으로 넘기고, 앱 내부에서는 추천, 선택, 기록, Recap에 집중하면 다음 장점이 생긴다. + +| 장점 | 설명 | +| --- | --- | +| 더 넓은 사용자 대상 | Spotify Premium이 없어도 추천, 저장, 기록, Recap을 사용할 수 있다. | +| 정책 리스크 감소 | 음원 파일을 제공하지 않고 곡 메타데이터와 외부 링크만 다룬다. | +| 제품 정체성 강화 | 음악 앱이 아니라 "여행을 음악으로 기록하는 앱"이 된다. | +| 데모 안정성 향상 | 외부 재생 상태와 무관하게 홈, 로그, Recap 플로우를 보여줄 수 있다. | +| 데이터 학습 가능 | 링크 열기, 좋아요, 저장, 기록, 공유 이벤트로 추천 품질을 개선할 수 있다. | + +### 3.3 약점과 보완 전략 + +기록·Recap 중심 피벗은 안전하지만, 데모와 추천 고도화 측면에서 약점이 있다. MVP에서는 이 약점을 숨기지 않고 제품 구조 안에서 보완한다. + +| 약점 | 사용자에게 보이는 위험 | MVP 보완 | +| --- | --- | --- | +| 앱 안에서 음악이 직접 나오지 않는다. | 데모가 음악 앱처럼 즉각적으로 느껴지지 않을 수 있다. | 온보딩과 홈에서 "외부 앱에서 듣고 Soundlog에는 기록한다"는 콘셉트를 먼저 설명하고, Recap 미리보기 이미지를 첫 화면의 감정적 보상으로 보여준다. | +| 재생/스킵 피드백을 직접 잡기 어렵다. | 추천 품질을 개선할 신호가 부족해질 수 있다. | `external_music_opened`, `track_liked`, `track_saved`, `moment_log_saved`, `next_recommendation_clicked`를 대체 피드백으로 사용한다. | +| 외부 앱 링크가 실패할 수 있다. | 사용자가 음악 감상으로 이어지지 못하고 막힐 수 있다. | Spotify/YouTube Music 딥링크 실패 시 YouTube 또는 웹 검색 링크로 fallback한다. | +| Recap이 약하면 제품 가치가 흐려진다. | "추천만 하고 끝나는 앱"으로 느껴질 수 있다. | MVP 성공 기준을 "로그 1개 이상으로 공유 가능한 이미지형 Recap 생성"으로 둔다. | + +--- + +## 4. 제품 정의 + +### 4.1 한 줄 정의 + +**Soundlog는 현재 장소에 어울리는 음악을 추천하고, 사용자가 고른 곡을 여행 순간에 붙여 Recap 앨범으로 남겨주는 위치 기반 여행 사운드트랙 로그 앱이다.** + +### 4.2 핵심 가치 + +| 가치 | 설명 | +| --- | --- | +| 장소에 맞는 발견 | 사용자는 직접 검색하지 않아도 지금 장소와 무드에 맞는 곡 목록을 받는다. | +| 부담 없는 감상 연결 | 실제 감상은 Spotify, YouTube Music, YouTube 등 외부 앱 링크로 이어진다. | +| 순간의 맥락 저장 | 사진, 장소, 시간, 선택 곡, 무드가 하나의 MomentLog로 저장된다. | +| 여행 후 콘텐츠화 | 여행이 끝나면 선택한 곡과 기록이 사운드트랙 앨범, 필름, 공유 이미지로 재구성된다. | +| 함께 만드는 회고 | 같이 여행 간 사람끼리 사진과 곡을 모아 하나의 공동 Recap을 만든다. | +| 취향 기반 연결 | 낯선 사람의 프로필보다 공개한 음악, 무드, 여행 상태를 먼저 보고 취향이 맞는 여행자를 발견한다. | + +### 4.3 제품 포지셔닝 + +Soundlog는 다음 셋의 중간에 위치한다. + +| 축 | 기존 서비스 | Soundlog의 위치 | +| --- | --- | --- | +| 음악 추천 | 스트리밍 앱의 청취 이력 기반 추천 | 장소, 여행 모드, 무드 기반 추천 | +| 여행 기록 | 사진/지도 중심 기록 | 사진, 장소, 음악, 시간의 결합 기록 | +| 공유 콘텐츠 | 수동 편집형 스토리/릴스 | 여행 로그 기반 자동 Recap | + +### 4.4 MVP에서 하지 않는 것 + +- 앱 내부에서 음원 파일을 스트리밍하지 않는다. +- 외부 음악 앱의 재생, 일시정지, 스킵 상태를 제어하지 않는다. +- Spotify 로그인을 필수로 만들지 않는다. +- 사용자가 Spotify Premium이어야만 핵심 가치를 경험하는 구조로 만들지 않는다. +- 전체 곡 음원, 가사 전문, 저작권 보호 콘텐츠를 저장하지 않는다. + +--- + +## 5. 타겟 사용자와 사용 맥락 + +| 타겟 | 상황 | 페인포인트 | Soundlog 해결 | +| --- | --- | --- | --- | +| 여행 기록형 사용자 | 여행 중 사진과 분위기를 잘 남기고 싶다. | 사진만 보면 그때의 음악과 감정이 사라진다. | 사진, 장소, 음악, 무드를 함께 저장한다. | +| 선곡이 귀찮은 사용자 | 카페, 바다, 야경, 드라이브 중 어울리는 음악을 듣고 싶다. | 음악 앱에서 직접 검색하는 시간이 여행 흐름을 끊는다. | 장소/무드 기반 곡 목록과 외부 링크를 제공한다. | +| 공유 콘텐츠형 사용자 | 여행이 끝난 뒤 감성적인 결과물을 만들고 싶다. | 직접 편집하기 번거롭고 일관된 콘셉트를 만들기 어렵다. | 자동 Recap 템플릿으로 사운드트랙 앨범을 만든다. | +| 동행 여행자 | 친구와 여행 중 각자의 취향을 남기고 싶다. | 한 명의 플레이리스트만 남고 개인의 기억은 흩어진다. | 각자가 마음에 든 곡과 사진을 올리고 공동 Recap으로 합친다. | +| 혼자 여행자/소규모 여행자 | 낯선 곳에서 취향이 맞는 사람과 가볍게 연결되고 싶다. | 일반 동행 매칭은 프로필 중심이라 어색하고 안전 우려가 크다. | 음악 취향, 무드, 여행 상태를 먼저 공유하고 상호 동의 기반으로 동행을 제안한다. | + +--- + +## 6. 핵심 사용자 루프 + +```txt +1. 현재 장소 확인 + -> 위치 또는 수동 장소 선택 + +2. 여행 상태와 무드 선택 + -> 바다 / 드라이브 / 산책 / 카페 / 야경 + -> 잔잔한 / 신나는 / 시원한 / 설레는 / 감성적인 + +3. 장소 기반 사운드트랙 추천 + -> 곡 목록, 추천 이유, 외부 음악 링크 + +4. 곡 선택과 감상 연결 + -> Spotify / YouTube Music / YouTube 검색 링크 열기 + -> 좋아요, 저장, 다음 추천 + +5. 순간 기록 + -> 사진, 장소, 시간, 선택 곡, 무드 저장 + +6. 여행 사운드트랙 로그 누적 + -> 장소별 곡과 사진 타임라인 확인 + +7. 동행자 기록 합치기 + -> 같이 간 사람이 남긴 사진, 곡, 메모를 공동 Recap 후보로 모으기 + +8. Live Sound Map 공유 + -> 여행 모드 ON 사용자의 현재 위치와 선택/청취 중인 곡을 지도에 표시 + +9. 주변 취향 발견 + -> 익명 공개된 주변 여행자의 곡, 무드, 여행 상태를 확인 + +10. 동행 매칭 요청 + -> 취향 일치율이 높은 사람에게 상호 동의 기반 동행 요청 + +11. Recap 생성과 공유 + -> 앨범 커버 / LP / 필름형 이미지 +``` + +### 루프의 핵심 판단 기준 + +- 사용자가 외부 음악 앱을 쓰지 않아도 추천과 기록은 가능해야 한다. +- 사용자가 사진을 찍지 않아도 음악/장소 로그는 저장 가능해야 한다. +- 사용자가 위치 권한을 거부해도 수동 장소 선택 또는 샘플 추천으로 홈까지 진입 가능해야 한다. +- 추천이 마음에 들지 않을 때 "스킵" 대신 "다음 추천", "무드 변경", "좋아요/저장하지 않음" 이벤트로 학습한다. +- 공동 Recap은 초대된 동행자 중심으로 시작하고, 공개 지도/매칭 기능은 여행 모드 ON과 명시적 공개 동의 상태에서만 동작해야 한다. + +### Recap 디자인 원칙 + +Recap은 Soundlog의 최종 보상이므로 기능적으로만 맞는 화면이 아니라 저장하고 공유하고 싶은 결과물이어야 한다. + +| 템플릿 | 목적 | 구성 | +| --- | --- | --- | +| 앨범 커버형 | 대표 여행 이미지를 가장 강하게 보여준다. | 대표 사진, 여행명, 대표 장소, 대표 곡, 무드 팔레트 | +| LP 슬리브형 | 여행을 하나의 레코드처럼 보이게 한다. | LP 그래픽, 트랙 리스트, 대표 곡, 동행자 이름 | +| 필름 타임라인형 | 여행의 흐름을 시간순으로 보여준다. | MomentLog 컷, 시간, 장소, 곡, 짧은 메모 | +| 지도 엽서형 | 이동 경로와 장소별 음악을 한 장에 담는다. | 지도 경로, 장소 핀, 대표 곡, 지역명 | + +### 커뮤니티 확장 원칙 + +커뮤니티는 공개 피드보다 여행 맥락이 선명한 관계형 기능부터 시작한다. + +| 기능 | 설명 | 개인정보 원칙 | +| --- | --- | --- | +| 공동 Recap | 같은 여행방에 참여한 동행자가 사진, 곡, 메모를 올리고 최종 Recap 후보를 함께 고른다. | 초대 코드/링크 기반 참여, 방장 공개 범위 확정 | +| Live Sound Map | 여행 모드 ON 사용자의 현재 위치와 Soundlog에서 선택했거나 외부 링크로 연 곡을 지도 위에 표시한다. | 여행 모드 중에만 위치 표시, 동행자/익명 공개/비공개 선택 | +| 현재 음악 표시 | 실제 외부 플랫폼 재생 상태를 제어하지 않고 Soundlog의 현재 선택 곡 상태를 보여준다. | 사용자가 숨김 처리 가능 | +| Nearby Sound Match | 주변 익명 여행자의 공개 곡, 무드, 여행 상태를 보고 음악 취향이 맞는 사람을 발견한다. | 실명/정확 좌표/연락처 비공개, 공개 핀 TTL 적용 | +| Travel Mate Match | 음악 취향과 여행 목적이 맞는 사람끼리 동행 요청을 주고받는다. | 상호 수락 전 자유 채팅 제한, 신고/차단/반복 요청 제한 | + +--- + +## 7. 정보 구조 + +| 영역 | 화면 ID | 화면명 | 목적 | +| --- | --- | --- | --- | +| 시작 | P-01 | 온보딩 | 서비스 개념과 외부 재생 방식을 명확히 전달 | +| 시작 | P-02 | 취향/권한 설정 | 위치, 여행 상태, 음악 무드 추천 준비 | +| 여행 | P-03 | 홈 / 지금 장소 | 현재 장소 기반 추천 허브 | +| 여행 | P-04 | 추천 사운드트랙 상세 | 곡 목록, 추천 이유, 피드백 제공 | +| 여행 | P-05 | 외부 음악 링크 패널 | 선택 곡을 외부 앱에서 열기 | +| 기록 | P-06 | 순간 기록 | 사진, 장소, 곡, 무드 저장 | +| 기록 | P-07 | 여행 사운드트랙 로그 | 여행별 기록 타임라인 확인 | +| 회고 | P-08 | Recap 생성 / 리스트 | 여행별 Recap 생성 상태 확인 | +| 회고 | P-09 | Recap 상세 / 공유 | 사운드트랙 앨범 결과물 저장/공유 | +| 관리 | P-10 | 보관함 / 마이 | 저장 곡, 권한, 취향 관리 | +| 커뮤니티 | P-11 | 공동 Recap 만들기 | 동행자 기록을 모아 하나의 Recap을 함께 편집 | +| 커뮤니티 | P-12 | Live Sound Map | 여행 모드 사용자의 현재 위치와 음악을 지도에 표시 | +| 커뮤니티 | P-13 | Nearby Sound Match | 주변 익명 여행자와 음악 취향을 먼저 공유 | +| 커뮤니티 | P-14 | Travel Mate Match | 취향이 맞는 사람에게 동행 요청을 보내고 상호 수락 | + +--- + +## 8. 화면 와이어프레임과 명세 + +### P-01. 온보딩 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ Soundlog │
│ 지금 장소의 음악을 │
│ 여행 앨범으로 │
│ │
│ [예시 Recap 이미지]│
│ │
│ 시작하기 │
│ 둘러보기 │
└────────────────────┘
| **사용자 목표:** 서비스가 음악 재생 앱이 아니라 여행 사운드트랙 로그 앱이라는 점을 이해한다.

**진입:** 첫 실행, 로그아웃 상태.

**나가기:** 취향/권한 설정 또는 샘플 홈.

**핵심 문구:** "음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 남겨요."

**상태:** 첫 실행 여부, 로그인 여부, 온보딩 완료 여부.

**예외:** 네트워크가 없어도 샘플 이미지와 둘러보기는 가능해야 한다. | + +### P-02. 취향/권한 설정 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 어떤 여행 중인가요?│
│ [바다] [드라이브] │
│ [산책] [카페] [야경]│
│ │
│ 어떤 분위기인가요? │
│ [잔잔한] [신나는] │
│ [시원한] [설레는] │
│ [감성적인] │
│ │
│ 위치 추천 켜기 │
│ 나중에 하기 │
└────────────────────┘
| **사용자 목표:** 추천에 필요한 최소 입력을 빠르게 설정한다.

**필수 입력:** 없음. 스킵 가능해야 한다.

**기본 상태:** 여행 상태는 `산책`, 무드는 `잔잔한`을 기본값으로 둘 수 있다.

**권한 정책:** 위치 권한은 홈에서 "현재 위치로 추천받기"를 누를 때 요청해도 된다. 초기 강제 요청은 피한다.

**로컬 저장:** 마지막 선택 상태/무드는 로컬에 저장한다.

**이벤트:** `onboarding_completed`, `travel_state_selected`, `mood_selected`, `location_permission_requested`. | + +### P-03. 홈 / 지금 장소 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 현재 위치 │
│ 성수동 카페거리 │
│ [장소 바꾸기] │
│ │
│ 상태 [카페 v] │
│ 무드 [잔잔한 v] │
│ │
│ 오늘의 사운드트랙 │
│ ┌────────────────┐ │
│ │ 커버 / 추천 이유│ │
│ │ 12곡 · 카페 무드│ │
│ └────────────────┘ │
│ │
│ [곡 보기] [기록] │
└────────────────────┘
| **사용자 목표:** 지금 장소에 어울리는 사운드트랙을 바로 확인한다.

**핵심 CTA:** 곡 보기, 순간 기록.

**API:** `GET /v1/recommendations/playlists?x=&y=&state=&mood=` 또는 수동 장소 기반 요청.

**상태:** 위치 조회 중, 권한 거부, 수동 장소, 추천 로딩, 추천 실패, 추천 없음.

**오프라인:** 마지막 추천을 표시하고 "최근 추천" 배지를 붙인다.

**피드백:** 상태/무드 변경은 즉시 추천 재요청 또는 적용 버튼 방식 중 하나로 통일한다. MVP는 적용 버튼보다 즉시 재요청이 더 간단하다. | + +### P-04. 추천 사운드트랙 상세 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 성수동 카페 사운드 │
│ 조용한 오후에 맞는 │
│ 잔잔한 인디/재즈 │
│ │
│ [더 잔잔하게] │
│ [더 신나게] │
│ │
│ 01 곡명 - 아티스트 │
│ ♡ 저장 열기 │
│ 02 곡명 - 아티스트 │
│ ♡ 저장 열기 │
│ 03 곡명 - 아티스트 │
│ ♡ 저장 열기 │
└────────────────────┘
| **사용자 목표:** 추천 이유를 이해하고 마음에 드는 곡을 고른다.

**핵심 CTA:** 열기, 좋아요, 저장, 무드 조정.

**앱 내부 역할:** 곡을 재생하는 것이 아니라 선택한 곡을 현재 여행 맥락에 연결한다.

**API:** 추천 상세 조회, 좋아요/저장, 추천 피드백 이벤트 전송.

**데이터:** track id, title, artist, album image, external links, reason tags.

**실패:** 링크가 없으면 곡명/아티스트 기반 검색 URL을 생성한다.

**이벤트:** `track_selected`, `track_liked`, `track_saved`, `mood_adjusted`. | + +### P-05. 외부 음악 링크 패널 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 선택한 곡 │
│ 곡명 │
│ 아티스트 │
│ 성수동 카페거리 │
│ │
│ 어디에서 들을까요? │
│ [Spotify에서 열기] │
│ [YouTube Music] │
│ [YouTube 검색] │
│ │
│ [이 곡으로 기록] │
└────────────────────┘
| **사용자 목표:** 선택한 곡을 익숙한 외부 서비스에서 듣거나, 이 곡을 여행 기록에 붙인다.

**중요 원칙:** 외부 앱이 없어도 Soundlog 기록은 가능해야 한다.

**딥링크 우선순위:** 앱 딥링크가 가능하면 앱으로 열고, 실패하면 웹 검색으로 fallback한다.

**API:** 링크 열기 이벤트만 서버에 보낸다. 재생 상태는 추적하지 않는다.

**상태:** 외부 앱 열기 성공/실패, 웹 fallback, 링크 없음.

**이벤트:** `external_music_opened`, `external_music_open_failed`, `track_attached_to_moment`. | + +### P-06. 순간 기록 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ [카메라 프리뷰] │
│ │
│ 촬영 버튼 │
└────────────────────┘

┌────────────────────┐
│ 이 순간 저장하기 │
│ 사진 썸네일 │
│ 장소 성수동 카페거리│
│ 곡명 - 아티스트 │
│ 무드 잔잔한 │
│ 메모 추가 │
│ [저장] │
└────────────────────┘
| **사용자 목표:** 여행 중 인상적인 순간을 사진, 장소, 곡, 무드로 저장한다.

**권한:** 카메라 권한은 이 화면 진입 시 요청한다.

**저장 정책:** 사진이 없어도 저장 가능, 위치가 없어도 수동 장소로 저장 가능, 곡이 없어도 "음악 없음"으로 저장 가능.

**API:** `POST /v1/moment-logs`.

**로컬:** 네트워크 실패 시 로컬 큐에 저장하고 재시도한다.

**실패:** 업로드 실패를 숨기지 않고 `동기화 대기` 상태로 표시한다.

**이벤트:** `moment_capture_started`, `moment_log_saved`, `moment_log_sync_failed`. | + +### P-07. 여행 사운드트랙 로그 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 이번 여행 로그 │
│ 7개 순간 · 12곡 │
│ │
│ 14:20 성수동 │
│ 사진 / 곡 / 무드 │
│ │
│ 16:05 서울숲 │
│ 사진 / 곡 / 무드 │
│ │
│ [Recap 만들기] │
└────────────────────┘
| **사용자 목표:** 여행 중 저장한 순간과 곡이 누적되는 것을 확인한다.

**핵심 CTA:** Recap 만들기, 로그 수정, 로그 삭제.

**데이터:** MomentLog 목록, 여행 세션 상태, 동기화 상태.

**빈 상태:** "아직 저장한 순간이 없어요. 지금 장소의 곡을 하나 골라볼까요?"로 홈 연결.

**오프라인:** 로컬 로그를 우선 표시하고 동기화 상태를 배지로 표시한다.

**이벤트:** `travel_log_viewed`, `moment_log_edited`, `recap_create_clicked`. | + +### P-08. Recap 생성 / 리스트 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ Recap │
│ │
│ 이번 여행 │
│ 생성 중... │
│ │
│ 6월 강릉 여행 │
│ 대표 사진 / 대표 곡│
│ │
│ 5월 서울 산책 │
│ 대표 사진 / 대표 곡│
└────────────────────┘
| **사용자 목표:** 여행별 사운드트랙 앨범 생성 상태를 확인한다.

**API:** `POST /v1/recaps`, `GET /v1/recaps`.

**상태:** 생성 전, 생성 중, 완료, 실패, 재시도 가능.

**최소 생성 조건:** 로그 1개 이상이면 이미지형 Recap 생성 가능. 사진이 없으면 장소 이미지 또는 기본 템플릿 사용.

**실패:** 실패한 Recap은 숨기지 말고 재시도 버튼을 제공한다.

**이벤트:** `recap_requested`, `recap_generation_failed`, `recap_opened`. | + +### P-09. Recap 상세 / 공유 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 강릉 바다 사운드 │
│ ┌────────────────┐ │
│ │ 앨범 커버형 │ │
│ │ 사진 + 대표 곡 │ │
│ └────────────────┘ │
│ [앨범] [LP] [필름] │
│ │
│ 대표 장소 주문진 │
│ 대표 곡 곡명 │
│ │
│ [이미지 저장] │
│ [공유하기] │
└────────────────────┘
| **사용자 목표:** 여행 결과물을 감상하고 저장/공유한다.

**MVP 우선순위:** 이미지형 Recap부터 구현한다. 영상형 Recap은 후순위다.

**비율:** 앨범 커버 1:1, 스토리 공유 9:16, 필름형 세로 스크롤.

**권한:** 사진 저장 권한은 저장 시점에 요청한다. 공유는 OS share sheet를 우선 사용한다.

**실패:** 캡처 실패, 저장 실패, 공유 실패를 각각 분리 안내한다.

**이벤트:** `recap_exported`, `recap_shared`, `recap_template_changed`. | + +### P-10. 보관함 / 마이 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 보관함 │
│ [좋아요] [저장곡] │
│ 곡명 - 아티스트 │
│ 곡명 - 아티스트 │
│ │
│ 마이 │
│ 취향 수정 │
│ 위치 권한 │
│ 카메라 권한 │
│ 데이터 관리 │
└────────────────────┘
| **사용자 목표:** 마음에 든 음악과 앱 권한/취향을 관리한다.

**보관함:** 좋아요한 곡, 저장한 곡, 저장한 추천 목록을 분리한다.

**마이:** 계정, 권한, 취향, 데이터 삭제, 로그아웃을 제공한다.

**오프라인:** 최근 저장 목록은 로컬 캐시로 표시한다.

**권한:** 위치/카메라/사진 권한 상태를 확인하고 OS 설정으로 이동할 수 있어야 한다.

**이벤트:** `library_viewed`, `preference_updated`, `permission_settings_opened`. | + +### P-11. 공동 Recap 만들기 + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 강릉 여행방 │
│ 4명이 함께 만드는 │
│ 사운드트랙 앨범 │
│ │
│ 18개 순간 · 24곡 │
│ │
│ 주문진 바다 컷 │
│ 수경 추가 · 곡명 A │
│ [채택] [댓글] │
│ │
│ [공동 Recap 생성] │
└────────────────────┘
| **사용자 목표:** 같이 여행 간 사람이 남긴 사진과 곡을 하나의 Recap으로 합친다.

**초대:** 초대 코드 또는 링크로 여행방에 참여한다.

**권한:** 방장만 최종 Recap 생성과 공개 범위를 확정한다.

**기여:** 사진, 장소, 곡, 메모를 후보로 올리고 채택/댓글로 최종 구성에 반영한다.

**이벤트:** `trip_room_joined`, `shared_moment_added`, `collab_recap_created`. | + +### P-12. Live Sound Map + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ Live Sound Map │
│ 여행 모드 ON │
│ 주변 익명 공개 │
│ │
│ [지도] │
│ 나 · 곡명 A │
│ 친구 · 곡명 B │
│ 82% · 근처 여행자 │
│ │
│ 현재 음악 │
│ 곡명 A - 아티스트 │
│ 주변 취향 매칭 보기│
│ [동행자만] [익명] │
└────────────────────┘
| **사용자 목표:** 여행 중 내가 어디서 어떤 음악을 듣고 있는지 동행자 또는 주변 익명 여행자와 공유한다.

**현재 음악 기준:** 외부 앱 재생 상태가 아니라 Soundlog에서 선택했거나 외부 링크로 연 곡을 현재 음악으로 표시한다.

**위치 정책:** 여행 모드 ON일 때만 표시하고, 공개 범위는 동행자/주변 익명/비공개로 제어한다. 주변 익명 공개는 정확 좌표가 아니라 대략 위치만 사용한다.

**상태:** 위치 권한 거부, 여행 모드 OFF, 현재 곡 없음, 네트워크 실패, 동행자 없음, 주변 공개 사용자 없음 상태를 분리한다.

**이벤트:** `travel_mode_enabled`, `live_track_shared`, `sound_map_viewed`, `nearby_sound_opened`. | + +### P-13. Nearby Sound Match + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 주변 사운드 취향 │
│ 대략 위치만 공개 │
│ │
│ [산책] [카페] [야경]│
│ │
│ 82% 비 오는 골목 │
│ 잔잔한 · 인디 │
│ 곡명 C - 아티스트 │
│ │
│ 76% 야경 수집가 │
│ 설레는 · 시티팝 │
│ │
│ [동행 후보 보기] │
└────────────────────┘
| **사용자 목표:** 낯선 사람의 실명/프로필보다 음악 취향과 여행 맥락을 먼저 보고 연결 가능성을 판단한다.

**진입:** P-12 Live Sound Map의 주변 취향 매칭 CTA, 홈의 여행 모드 배너.

**매칭 기준:** 공개 곡, 좋아요/저장 태그, 현재 무드, 여행 상태, 대략 거리, 최근 공개 시간.

**개인정보:** 실명, 정확 좌표, 연락처, 전체 청취 이력은 공개하지 않는다. 공개 핀은 기본 2시간 TTL 후 자동 숨김 처리한다.

**상태:** 주변 공개 사용자 없음, 내 공개 상태 OFF, 차단한 사용자 숨김, 네트워크 실패, 매칭 계산 실패.

**이벤트:** `nearby_sound_opened`, `music_match_viewed`, `music_match_profile_opened`. | + +### P-14. Travel Mate Match + +| 와이어프레임 | 기능 및 화면 명세 | +| --- | --- | +|
┌────────────────────┐
│ 동행 매칭 요청 │
│ 취향 82% 일치 │
│ 정확한 위치 비공개 │
│ │
│ 비 오는 골목 │
│ 잔잔한 인디 │
│ 카페 산책 선호 │
│ │
│ 공개 사운드 │
│ 곡명 C - 아티스트 │
│ │
│ 안전장치 │
│ 위치 선택 공개 │
│ 첫 메시지 제한 │
│ 차단/신고 가능 │
│ │
│ [취향으로 인사] │
│ [차단/신고] │
└────────────────────┘
| **사용자 목표:** 음악 취향과 여행 목적이 맞는 사람에게 부담 낮은 동행 요청을 보낸다.

**상호 동의:** 요청 발송과 상대 수락이 모두 있어야 대화가 열리고, 만남 제안은 안전 안내를 거친다.

**첫 메시지:** 자유 입력 전에 "이 곡 좋아해서 눌렀어요", "근처 카페 산책 코스 같이 볼까요?" 같은 템플릿을 우선 사용한다.

**안전장치:** 정확 위치 숨김, 연락처 비공개, 신고/차단, 반복 요청 제한, 매칭 취소, 만남 전 주의 안내를 제공한다.

**상태:** 요청 대기, 수락, 거절, 만료, 차단, 신고 완료, 상대 여행 모드 OFF.

**이벤트:** `travel_mate_requested`, `travel_mate_accepted`, `travel_mate_declined`, `community_user_blocked`, `community_user_reported`. | + +--- + +## 9. 핵심 데이터와 API 계약 + +### 9.1 추천 요청 + +```txt +GET /v1/recommendations/playlists?x=126.9963&y=37.5104&state=산책&mood=잔잔한 +``` + +| 필드 | 설명 | +| --- | --- | +| `x` | 경도 | +| `y` | 위도 | +| `state` | 여행 상태: 바다, 드라이브, 산책, 카페, 야경 | +| `mood` | 무드: 잔잔한, 신나는, 시원한, 설레는, 감성적인 | + +### 9.2 추천 응답 + +```ts +type PlaylistRecommendation = { + id: string; + title: string; + reason: string; + place: { + name: string; + address?: string; + x?: number; + y?: number; + imageUrl?: string; + }; + state: '바다' | '드라이브' | '산책' | '카페' | '야경'; + mood: '잔잔한' | '신나는' | '시원한' | '설레는' | '감성적인'; + tracks: TrackRecommendation[]; +}; + +type TrackRecommendation = { + id: string; + title: string; + artist: string; + albumImageUrl?: string; + reasonTags: string[]; + externalLinks: { + spotify?: string; + youtubeMusic?: string; + youtube?: string; + search?: string; + }; +}; +``` + +### 9.3 MomentLog 저장 + +```ts +type MomentLogCreateRequest = { + sessionId?: string; + placeId?: string; + placeName?: string; + x?: number; + y?: number; + state?: string; + mood?: string; + trackId?: string; + trackTitle?: string; + trackArtist?: string; + photoUploadId?: string; + memo?: string; + capturedAt: string; +}; +``` + +### 9.4 Recap 생성 + +```ts +type RecapCreateRequest = { + sessionId: string; + template: 'album_cover' | 'lp' | 'film'; +}; +``` + +### 9.5 공동 여행 / 지도 + +```txt +POST /v1/travel-rooms +POST /v1/travel-rooms/:roomId/join +POST /v1/travel-rooms/:roomId/moments +POST /v1/travel-rooms/:roomId/recaps +GET /v1/sound-map?x=&y=&radius=&visibility= +POST /v1/sound-map/current-track +GET /v1/sound-map/nearby?x=&y=&radius=&state=&mood= +GET /v1/music-matches?x=&y=&radius=&state=&mood= +POST /v1/travel-mate-requests +PATCH /v1/travel-mate-requests/:requestId +POST /v1/community/blocks +POST /v1/community/reports +``` + +| API | 목적 | +| --- | --- | +| `POST /v1/travel-rooms` | 같이 여행 간 사람끼리 쓸 여행방을 만든다. | +| `POST /v1/travel-rooms/:roomId/moments` | 동행자가 공동 Recap 후보 순간을 추가한다. | +| `POST /v1/travel-rooms/:roomId/recaps` | 방장이 공동 Recap을 생성한다. | +| `GET /v1/sound-map` | 여행 모드 ON 사용자의 현재 위치와 선택 곡 핀을 조회한다. | +| `POST /v1/sound-map/current-track` | 내 현재 위치와 Soundlog 현재 선택 곡을 지도에 표시한다. | +| `GET /v1/sound-map/nearby` | 주변 익명 공개 음악 핀을 조회한다. 정확 좌표 대신 대략 위치만 응답한다. | +| `GET /v1/music-matches` | 주변 여행자와의 음악 취향 일치율과 공개 프로필 카드를 조회한다. | +| `POST /v1/travel-mate-requests` | 취향이 맞는 사용자에게 동행 매칭 요청을 보낸다. | +| `PATCH /v1/travel-mate-requests/:requestId` | 매칭 요청을 수락, 거절, 만료, 취소 상태로 변경한다. | +| `POST /v1/community/blocks` | 사용자 차단을 기록하고 이후 지도/매칭에서 숨긴다. | +| `POST /v1/community/reports` | 부적절한 공개 핀, 프로필, 매칭 요청을 신고한다. | + +--- + +## 10. 추천 학습 이벤트 + +직접 재생 제어가 없으므로 Soundlog의 추천 피드백은 다음 이벤트로 쌓는다. + +| 이벤트 | 의미 | 추천 반영 | +| --- | --- | --- | +| `track_selected` | 사용자가 곡 상세나 링크 패널을 열었다. | 약한 긍정 신호 | +| `external_music_opened` | 외부 음악 앱/웹으로 이동했다. | 강한 긍정 신호 | +| `track_liked` | 사용자가 좋아요를 눌렀다. | 강한 긍정 신호 | +| `track_saved` | 사용자가 곡을 저장했다. | 강한 긍정 신호 | +| `moment_log_saved` | 곡이 실제 여행 기록에 붙었다. | 매우 강한 긍정 신호 | +| `mood_adjusted` | 사용자가 추천 방향을 바꿨다. | 현재 추천의 부분 부적합 신호 | +| `next_recommendation_clicked` | 사용자가 다음 추천을 원했다. | 약한 부정 신호 | +| `recap_shared` | 결과물을 공유했다. | 여행/Recap 만족 신호 | +| `shared_moment_added` | 동행자가 공동 여행방에 순간을 추가했다. | 공동 Recap 후보 신호 | +| `live_track_shared` | 사용자가 여행 모드 지도에 현재 곡을 표시했다. | 장소-음악 커뮤니티 신호 | +| `nearby_sound_opened` | 사용자가 주변 익명 음악 지도를 열었다. | 공개 지도 관심 신호 | +| `music_match_viewed` | 사용자가 취향 매칭 후보를 봤다. | 취향 기반 커뮤니티 신호 | +| `travel_mate_requested` | 사용자가 동행 매칭 요청을 보냈다. | 매칭 전환 신호 | +| `travel_mate_accepted` | 상대가 동행 매칭 요청을 수락했다. | 강한 커뮤니티 성공 신호 | +| `community_user_blocked` | 사용자가 상대를 차단했다. | 안전/품질 관리 신호 | + +--- + +## 11. MVP 범위 + +### 11.1 반드시 포함 + +- 온보딩에서 Soundlog가 외부 재생 링크 기반 서비스임을 명확히 설명한다. +- 홈에서 위치 또는 수동 장소 기반 추천을 받을 수 있다. +- 여행 상태 5개와 무드 5개를 추천 파라미터로 사용한다. +- 추천 상세에서 곡 목록, 추천 이유, 좋아요, 저장, 외부 링크 열기를 제공한다. +- Spotify, YouTube Music, YouTube 검색 링크 중 가능한 링크를 제공한다. +- 순간 기록에서 사진, 장소, 선택 곡, 시간, 무드를 저장한다. +- 사진/위치/곡 중 일부가 없어도 로그를 저장할 수 있다. +- 여행 사운드트랙 로그에서 저장한 순간을 시간순으로 확인한다. +- 이미지형 Recap을 생성하고 저장/공유할 수 있다. +- 동행자 초대 기반 공동 Recap을 만들 수 있다. +- 여행 모드 ON 상태에서 현재 위치와 선택 곡을 지도 위에 표시할 수 있다. +- 베타 플래그에서 주변 익명 음악 공개와 취향 기반 매칭 후보를 확인할 수 있다. +- 매칭 기능에는 정확 위치 숨김, 상호 동의, 신고/차단, 반복 요청 제한을 포함한다. +- 오프라인 또는 API 실패 시 최근 추천과 로컬 로그를 유지한다. + +### 11.2 후순위 + +- Spotify OAuth 로그인 +- Spotify 재생 제어 +- 동행자 투표 기반 곡 선정 +- 영상형 Recap 자동 생성 +- 전국 단위 공개 피드 +- 자동 오프라인 만남 추천 +- 웨어러블 기반 추천 + +### 11.3 MVP 성공 기준 + +| 기준 | 성공 조건 | +| --- | --- | +| 첫 경험 | 신규 사용자가 1분 안에 현재 장소 추천을 볼 수 있다. | +| 추천 이해 | 사용자가 왜 이 곡이 추천됐는지 짧게 이해할 수 있다. | +| 외부 감상 연결 | 외부 음악 링크가 실패해도 검색 fallback을 제공한다. | +| 기록 완성 | 사용자가 사진, 장소, 곡, 무드가 묶인 MomentLog를 저장할 수 있다. | +| Recap 완성 | 로그 1개 이상으로 공유 가능한 이미지형 Recap을 만들 수 있다. | +| 공동 회고 | 동행자 2명 이상이 같은 여행방에 기록을 추가하고 공동 Recap을 생성할 수 있다. | +| 지도 공유 | 여행 모드 ON 사용자의 현재 위치와 선택 곡이 지도에 표시되고 공개 범위를 바꿀 수 있다. | +| 취향 매칭 베타 | 주변 익명 공개 사용자를 음악 취향 기준으로 발견하고, 상호 동의 기반 요청을 보낼 수 있다. | + +--- + +## 12. 구현 원칙 + +### 12.1 프론트엔드 + +- API 접근은 `src/api` facade 뒤에 둔다. +- 추천, 로그, Recap은 TanStack Query로 서버 상태를 관리한다. +- 현재 여행 세션, 선택 상태/무드, 선택 곡은 클라이언트 상태로 관리한다. +- 로컬 로그 재시도 큐를 둬서 순간 기록 손실을 막는다. +- 외부 음악 앱 링크는 앱 딥링크 실패 시 웹 검색으로 fallback한다. + +### 12.2 백엔드 + +- 프론트가 ML 서버를 직접 호출하지 않도록 백엔드 API가 추천 요청을 중계한다. +- ML 추천 응답을 프론트 화면 모델로 정규화한다. +- 트랙 메타데이터, 외부 링크, 추천 이벤트, MomentLog, Recap을 DB에 저장한다. +- 음원 파일이나 전체 가사 같은 저작권 보호 콘텐츠는 저장하지 않는다. +- 추천 이벤트는 사용자 식별이 없어도 세션 단위로 수집 가능해야 한다. + +### 12.3 정책/권한 + +- 위치는 MVP에서 foreground 기준으로만 사용한다. +- 카메라는 순간 기록 진입 시점에 요청한다. +- 사진 저장 권한은 Recap 이미지 저장 시점에 요청한다. +- 로그아웃 상태에서도 로컬 기록은 가능하게 하고, 로그인 후 동기화할 수 있게 설계한다. + +--- + +## 13. 데모 시나리오 + +### 13.1 3분 데모 + +1. 앱을 실행하고 온보딩에서 "여행 사운드트랙 로그" 개념을 확인한다. +2. 홈에서 장소를 현재 위치 또는 수동 장소로 설정한다. +3. 상태를 `산책`, 무드를 `잔잔한`으로 선택한다. +4. 추천 사운드트랙 상세에서 곡 목록과 추천 이유를 확인한다. +5. 선택 곡을 Spotify 또는 YouTube Music 링크로 열어본다. +6. 앱으로 돌아와 "이 곡으로 기록"을 누른다. +7. 사진 또는 사진 없는 순간 기록을 저장한다. +8. 여행 사운드트랙 로그에서 기록을 확인한다. +9. 여행 모드에서 주변 익명 음악 핀과 취향 매칭 후보를 확인한다. +10. 매칭 요청 화면에서 정확 위치 비공개와 신고/차단 안내를 확인한다. +11. Recap을 생성하고 앨범 커버형 이미지를 공유한다. + +### 13.2 실패 상태 데모 + +1. 위치 권한을 거부하고 수동 장소 선택으로 추천을 받는다. +2. 외부 음악 앱 링크가 없을 때 YouTube 검색 fallback을 연다. +3. 네트워크 실패 상태에서 순간 기록을 저장하고 로컬 동기화 대기 상태를 확인한다. +4. Recap 생성 실패 후 재시도 버튼을 확인한다. + +--- + +## 14. 리뷰 루프와 반영 결과 + +### 14.1 리뷰 기준 + +- 스트리밍 앱처럼 오해될 여지가 없는가? +- Spotify Premium 사용자만 가치가 생기는 구조가 아닌가? +- 추천, 기록, Recap 루프가 한 번에 이어지는가? +- 위치/카메라/외부 링크 실패 상태가 설계돼 있는가? +- 실제 구현 시 프론트/백엔드 API 계약으로 옮길 수 있는가? + +### 14.2 1차 리뷰 - 제품 방향 + +| 중요도 | 발견 사항 | 반영 내용 | +| --- | --- | --- | +| P0 | "음악 앱"으로 보이면 직접 재생 기능 부재가 결함처럼 느껴질 수 있다. | 온보딩과 제품 정의에 "외부 앱에서 듣고 Soundlog에는 기록한다"는 문구를 명시했다. | +| P0 | Spotify Premium이 없으면 핵심 플로우가 막히면 안 된다. | Spotify 로그인/재생 제어를 후순위로 내리고, YouTube Music/YouTube 검색 fallback을 MVP에 포함했다. | +| P1 | 피벗 이유가 기능 목록만으로 보이면 설득력이 약하다. | 직접 스트리밍 중심 기획의 문제와 기록·Recap 중심 기획의 장점을 분리해 설명했다. | + +### 14.3 2차 리뷰 - UX와 화면 명세 + +| 중요도 | 발견 사항 | 반영 내용 | +| --- | --- | --- | +| P0 | 와이어프레임만 있으면 실제 구현자가 상태/권한/API를 놓칠 수 있다. | 모든 화면을 `와이어프레임`과 `기능 및 화면 명세` 2열 구조로 작성하고, 주요 상태와 이벤트를 함께 적었다. | +| P1 | 사진, 위치, 곡 중 하나가 없으면 순간 기록이 깨질 수 있다. | 일부 데이터가 없어도 MomentLog 저장 가능하도록 저장 정책을 명시했다. | +| P1 | 외부 링크 실패 시 사용자가 막힐 수 있다. | 앱 딥링크 실패 시 웹 검색 fallback과 실패 이벤트를 추가했다. | +| P2 | Recap이 화면 목록 뒤쪽에만 있으면 조연처럼 보일 수 있다. | 핵심 루프와 MVP 성공 기준에 Recap 생성을 포함하고, P-08/P-09를 별도 핵심 화면으로 분리했다. | + +### 14.4 3차 리뷰 - 추천 학습과 구현 가능성 + +| 중요도 | 발견 사항 | 반영 내용 | +| --- | --- | --- | +| P1 | 직접 스킵/재생 이벤트가 없어 추천 학습 데이터가 부족할 수 있다. | 링크 열기, 좋아요, 저장, 기록, 공유, 다음 추천 이벤트를 추천 학습 신호로 정의했다. | +| P2 | Recap이 영상형부터 시작하면 구현 비용이 크다. | MVP는 이미지형 Recap 우선, 영상형은 후순위로 분리했다. | +| P2 | 네트워크가 불안정한 여행지에서 로그 손실이 생길 수 있다. | 로컬 로그 큐와 동기화 대기 상태를 구현 원칙에 포함했다. | +| P1 | 낯선 사람 매칭은 위치/안전 리스크가 커서 바로 공개하면 신뢰를 잃을 수 있다. | 정확 위치를 숨기고, 익명 공개 TTL, 상호 동의, 신고/차단, 반복 요청 제한을 기본 정책으로 추가했다. | + +### 14.5 남은 후속 검토 + +아래 항목은 MVP 기획을 막지는 않지만, 구현 단계에서 다시 검토해야 한다. + +| 항목 | 이유 | 판단 | +| --- | --- | --- | +| Spotify OAuth | 플레이리스트 export나 계정 기반 저장에는 유용하지만 MVP 핵심은 아니다. | Phase 2 | +| 전국 단위 공개 피드 | 지역 전체 공개 피드는 운영/신고/노출 품질 관리 리스크가 커진다. | Phase 2 | +| 동행자 투표 기반 곡 선정 | 공동 Recap과 Live Sound Map 이후에 붙이면 여행방의 상호작용을 더 키울 수 있다. | Phase 2 | +| 자유 채팅 기반 동행 | 음악 취향 검증 없이 바로 채팅을 열면 스팸과 안전 문제가 커진다. | 매칭 베타 검증 후 | +| 영상형 Recap | 데모 효과는 크지만 구현 비용과 안정성 리스크가 크다. | 이미지형 이후 | +| 백그라운드 위치 | 여행 경로 자동 기록에 유용하지만 권한/배터리/심사 부담이 있다. | foreground 검증 후 | + +--- + +## 15. 최종 기획 판단 + +Soundlog의 MVP는 **음악을 직접 틀어주는 앱**보다 **여행에 어울리는 곡을 발견하고, 그 곡을 여행 기억에 붙여주는 앱**으로 가는 편이 더 안전하고 넓다. + +이 방향은 Spotify Premium 제약, 스트리밍 정책 리스크, 외부 플랫폼 의존도를 낮추면서도 Soundlog만의 차별점인 장소 기반 추천과 Recap을 더 선명하게 만든다. 따라서 초기 출시는 외부 음악 링크, MomentLog, 이미지형 Recap, 동행자 공동 Recap, 여행 모드 기반 Live Sound Map을 중심으로 설계하고, 베타 플래그로 주변 익명 음악 공유와 취향 기반 동행 매칭을 검증한 뒤 전국 단위 공개 피드, 플레이리스트 export, 선택적 Spotify 연동을 확장하는 것이 적절하다. diff --git a/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html new file mode 100644 index 0000000..f71eff1 --- /dev/null +++ b/docs/product/SOUNDLOG_TRAVEL_SOUNDTRACK_WIREFRAMES.html @@ -0,0 +1,1953 @@ + + + + + + Soundlog 여행 사운드트랙 로그 와이어프레임 + + + +
+
+

+ + Soundlog MVP Wireframes +

+

여행 사운드트랙 로그 화면 명세

+

+ Soundlog는 앱 안에서 음악을 직접 재생하지 않고, 지금 장소의 곡을 추천한 뒤 + 사용자가 선택한 음악을 사진, 장소, 시간, 무드와 함께 기록하고 Recap으로 남깁니다. +

+ +
+
+ 브랜딩 + 지금 이 장소의 음악을 찾아주고, 여행이 끝나면 사운드트랙 앨범으로 남깁니다. +
+
+ 재생 방식 + Spotify, YouTube Music, YouTube 딥링크와 검색 fallback으로 감상을 외부화합니다. +
+
+ 핵심 기록 + MomentLog는 사진, 장소, 시간, 선택 곡, 무드를 하나의 여행 순간으로 저장합니다. +
+
+ 최종 보상 + Recap은 조연이 아니라 공유 가능한 사운드트랙 앨범 결과물입니다. +
+
+ 커뮤니티 + 동행자는 공동 Recap을 만들고, 낯선 여행자와는 익명 음악 취향으로 연결됩니다. +
+
+ 매칭 + 현재 위치를 정확히 노출하지 않고, 음악 취향이 맞는 사람끼리 여행 동행을 요청합니다. +
+
+
+
+ +
+ + +
+
+

제품 방향

+

+ MVP는 직접 스트리밍과 외부 플레이어 제어를 제외합니다. 대신 곡 목록, 추천 이유, + 외부 음악 링크, MomentLog, 이미지형 Recap을 통해 "추천에서 기록, 공유로 이어지는" 흐름을 검증합니다. +

+
+
+

하지 않는 것

+

음원 스트리밍, 재생/일시정지/스킵 제어, Spotify 로그인 필수화.

+
+
+

반드시 하는 것

+

곡 단위 추천, 외부 링크, 좋아요/저장, 순간 기록, 예쁜 Recap 생성, 공동 편집, 익명 음악 공유.

+
+
+

리스크 보완

+

앱 안에서 음악이 안 나오는 심심함은 앨범형 Recap과 지도 위 음악 공유, 취향 매칭으로 보완.

+
+
+
+ +
+

Recap을 예쁘게 만드는 방식

+

+ Recap은 단순 기록 목록이 아니라 여행이 끝났을 때 받는 보상입니다. 사진, 장소, 대표 곡, 무드, + 동행자 기여를 조합해 앨범 커버처럼 저장하고 SNS에 공유하고 싶게 만드는 것이 핵심입니다. +

+
+
+
+

앨범 커버형

+

대표 사진 위에 장소명, 여행명, 대표 곡을 앨범 타이포처럼 배치합니다.

+
+
+
+

LP 슬리브형

+

대표 곡을 중심으로 여행을 한 장의 레코드처럼 보여줍니다.

+
+
+
+

필름 타임라인형

+

순간 기록을 시간순 컷으로 이어 여행의 흐름을 보여줍니다.

+
+
+
+

지도 엽서형

+

이동 경로와 장소별 대표 음악을 지도 위에 엽서처럼 남깁니다.

+
+
+
+ Recap 디자인 원칙: 사진 원본을 크게 쓰고, 장소명과 대표 곡을 제목처럼 세우며, 무드는 색상 팔레트로 표현합니다. 공유 이미지는 1:1 앨범 커버와 9:16 스토리 비율을 우선 제공합니다. +
+
+ +
+

커뮤니티 모드

+

+ 커뮤니티성은 공개 SNS 피드보다 "같이 여행 간 사람끼리 만드는 기록"에서 시작합니다. + 같은 여행방에 들어온 동행자는 각자 사진과 곡을 붙이고, 여행 모드를 켠 사용자는 지도 위에서 + 현재 위치와 지금 듣는 음악을 표시할 수 있습니다. +

+
+
+

공동 Recap

+

초대 코드로 여행방에 들어오고, 동행자가 남긴 순간 기록을 하나의 Recap 후보로 모읍니다.

+
+
+

Live Sound Map

+

여행 모드를 켠 사람의 현재 위치와 선택/청취 중인 곡을 지도 위 핀으로 보여줍니다.

+
+
+

Nearby Sound Match

+

주변 익명 여행자의 공개 곡, 무드, 여행 상태를 보고 취향이 맞는 사람을 발견합니다.

+
+
+

Travel Mate Match

+

서로 수락한 경우에만 대화와 동행 제안을 열고, 정확한 위치는 매칭 후에도 선택 공개합니다.

+
+
+

공개 범위

+

동행자만 보기, 주변 여행자에게 익명 공개, 비공개 중 선택하며 위치 공유는 여행 모드 중에만 켭니다.

+
+
+
+ "현재 듣는 음악"은 외부 플랫폼 재생 상태를 직접 제어하지 않고, Soundlog에서 선택했거나 외부 링크로 연 곡을 현재 곡 상태로 표시합니다. 낯선 사람에게는 닉네임, 취향 태그, 대략 위치만 공개합니다. +
+
+ +
+

핵심 루프

+

현재 장소 확인에서 공동 Recap, 주변 취향 발견, 동행 매칭까지 한 번의 여행 경험을 11단계로 봅니다.

+
+
01현재 장소 확인
+
02상태와 무드 선택
+
03사운드트랙 추천
+
04외부 음악 링크 열기
+
05곡을 여행 순간에 붙이기
+
06동행자 기록 모으기
+
07지도에 현재 음악 표시
+
08주변 취향 맞는 사람 발견
+
09동행 매칭 요청
+
10공동 Recap 편집
+
11Recap 생성과 공유
+
+
+ +
+
+
+

P-01. 온보딩

+

Soundlog가 음악 재생 앱이 아니라 여행 사운드트랙 로그 앱임을 첫 화면에서 설명합니다.

+
+ Start +
+ +
+
+
9:41LTE 100%
+
+ + Soundlog +
+

지금 장소의 음악을 여행 앨범으로

+

음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 남겨요.

+
Recap preview
album cover / film / LP
+ +
+
시작하기
+
둘러보기
+
+
+
+ +
+

기능 설명

+
+
+ 사용자 목표 +

서비스의 핵심 개념과 외부 재생 방식을 빠르게 이해합니다.

+
+
+ 진입/이탈 +

첫 실행 또는 로그아웃 상태에서 진입하고, 취향 설정 또는 샘플 홈으로 이동합니다.

+
+
+ 상태 +

첫 실행 여부, 로그인 여부, 온보딩 완료 여부를 확인합니다.

+
+
+ 예외 +

네트워크가 없어도 샘플 Recap 이미지를 보여주고 둘러보기로 진입할 수 있어야 합니다.

+
+
+
핵심 카피: "외부 앱에서 듣고 Soundlog에는 기록한다"를 첫 화면에서 분명히 말합니다.
+
+
+ +
+
+
+

P-02. 취향/권한 설정

+

추천에 필요한 최소 입력을 받고, 위치 권한은 맥락이 생긴 뒤 요청합니다.

+
+ Setup +
+ +
+
+
9:41LTE 100%
+

어떤 여행 중인가요?

+
+ 바다 + 드라이브 + 산책 + 카페 + 야경 +
+ +
+ 잔잔한 + 신나는 + 시원한 + 설레는 + 감성적인 +
+ +
+ 현재 위치로 추천받기 + 거부해도 수동 장소 선택 가능 +
+
+
위치 추천 켜기
+
나중에 하기
+
+
+
+ +
+

기능 설명

+
+
+ 입력값 +

여행 상태 5개와 무드 5개를 추천 API 파라미터로 사용합니다.

+
+
+ 기본값 +

스킵 가능하며 기본 상태는 산책, 기본 무드는 잔잔한으로 둘 수 있습니다.

+
+
+ 권한 정책 +

초기 강제 요청보다 "현재 위치로 추천받기" 액션 이후 요청합니다.

+
+
+ 이벤트 +

travel_state_selected, mood_selected, location_permission_requested

+
+
+
+
+ +
+
+
+

P-03. 홈 / 지금 장소

+

현재 장소, 상태, 무드, 추천 사운드트랙, 기록 CTA가 한 화면에서 이어집니다.

+
+ Home +
+ +
+
+
9:41LTE 100%
+
+ + Soundlog +
+ +
+
+ 성수동 카페거리 + 수동 장소 변경 가능 +
+ 변경 +
+
+ 카페 + 잔잔한 +
+ +
+
cover image / place mood
+

성수동 카페 사운드

+ 12곡 · 조용한 오후에 맞는 인디/재즈 +
+
+
곡 보기
+
기록
+
+
+ Recap기록보관함마이 +
+
+
+ +
+

기능 설명

+
+
+ 핵심 CTA +

곡 보기로 추천 상세에 들어가고, 기록으로 순간 저장 플로우에 진입합니다.

+
+
+ 상태 +

위치 조회 중, 권한 거부, 수동 장소, 추천 로딩, 추천 실패, 추천 없음.

+
+
+ 오프라인 +

마지막 추천을 유지하고 "최근 추천" 상태를 표시합니다.

+
+
+ 피드백 +

상태/무드 변경은 즉시 추천 재요청으로 시작합니다.

+
+
+
GET /v1/recommendations/playlists?x=&y=&state=&mood=
+
+
+ +
+
+
+

P-04. 추천 사운드트랙 상세

+

추천을 신뢰하게 만들고, 곡을 선택/저장/외부 링크로 이어주는 화면입니다.

+
+ Recommendation +
+ +
+
+
9:41LTE 100%
+

성수동 카페 사운드

+

조용한 오후, 잔잔한 인디와 재즈가 어울려요.

+
+ 더 잔잔하게 + 더 신나게 + 더 감성적으로 +
+ +
+
01. 곡명 A아티스트 · 카페 · 잔잔함
+ 열기 +
+
+
02. 곡명 B아티스트 · 인디 · 오후
+ 저장 +
+
+
03. 곡명 C아티스트 · 재즈 · 산책
+ 좋아요 +
+
+
+ +
+

기능 설명

+
+
+ 사용자 목표 +

추천 이유를 이해하고 마음에 드는 곡을 고릅니다.

+
+
+ 앱 내부 역할 +

곡을 재생하지 않고 현재 여행 맥락에 연결할 곡을 고릅니다.

+
+
+ 실패 대응 +

링크가 없으면 곡명/아티스트 기반 검색 URL을 생성합니다.

+
+
+ 이벤트 +

track_selected, track_liked, track_saved, mood_adjusted

+
+
+
+
+ +
+
+
+

P-05. 외부 음악 링크 패널

+

선택한 곡을 외부 음악 앱에서 열거나, 이 곡을 여행 기록에 붙입니다.

+
+ External Links +
+ +
+
+
9:41LTE 100%
+ +
+
album image
+

곡명 A

+ 아티스트 · 성수동 카페거리 +
+ +
+
Spotify에서 열기
+
YouTube Music
+
YouTube 검색
+
+
이 곡으로 기록
+
+
+ +
+

기능 설명

+
+
+ 중요 원칙 +

외부 앱이 없어도 Soundlog 기록은 가능해야 합니다.

+
+
+ 딥링크 우선순위 +

앱 딥링크를 먼저 시도하고 실패하면 웹 검색으로 fallback합니다.

+
+
+ API +

재생 상태를 추적하지 않고 링크 열기 이벤트만 서버에 보냅니다.

+
+
+ 이벤트 +

external_music_opened, external_music_open_failed, track_attached_to_moment

+
+
+
재생 제어가 아니라 "감상 연결 + 기록 연결" 패널입니다.
+
+
+ +
+
+
+

P-06. 순간 기록

+

사진, 장소, 곡, 무드를 하나의 MomentLog로 저장합니다.

+
+ MomentLog +
+ +
+
+
9:41LTE 100%
+
camera preview
+
촬영
+ +
+ 이 순간 저장하기 + 성수동 카페거리 · 곡명 A · 잔잔한 +
메모 추가
+
+
저장
+
+
+ +
+

기능 설명

+
+
+ 권한 +

카메라 권한은 이 화면 진입 시점에 요청합니다.

+
+
+ 저장 정책 +

사진, 위치, 곡 중 일부가 없어도 저장 가능해야 합니다.

+
+
+ 로컬 +

네트워크 실패 시 로컬 큐에 저장하고 재시도합니다.

+
+
+ 이벤트 +

moment_capture_started, moment_log_saved, moment_log_sync_failed

+
+
+
POST /v1/moment-logs
+
+
+ +
+
+
+

P-07. 여행 사운드트랙 로그

+

여행 중 저장한 순간과 곡을 시간순으로 확인하고 Recap 생성으로 넘어갑니다.

+
+ Travel Log +
+ +
+
+
9:41LTE 100%
+

이번 여행 로그

+
+
7순간
+
12
+
3장소
+
+
+
14:20 성수동사진 · 곡명 A · 잔잔한
+ 수정 +
+
+
16:05 서울숲사진 없음 · 곡명 B · 산책
+ 동기화 +
+
Recap 만들기
+
+
+ +
+

기능 설명

+
+
+ 핵심 CTA +

Recap 만들기, 로그 수정, 로그 삭제를 제공합니다.

+
+
+ 데이터 +

MomentLog 목록, 여행 세션 상태, 동기화 상태를 보여줍니다.

+
+
+ 빈 상태 +

저장한 순간이 없으면 홈으로 돌아가 곡을 고르게 연결합니다.

+
+
+ 오프라인 +

로컬 로그를 우선 표시하고 동기화 대기 배지를 보여줍니다.

+
+
+
+
+ +
+
+
+

P-08. Recap 생성 / 리스트

+

여행별 사운드트랙 앨범 생성 상태를 확인합니다.

+
+ Recap List +
+ +
+
+
9:41LTE 100%
+

Recap

+
+ 이번 여행 + 생성 중... 사진 7장 · 곡 12개 +
+
+ 6월 강릉 여행 + 대표 사진 · 대표 곡 준비 완료 +
+
+ 5월 서울 산책 + 생성 실패 · 다시 시도 가능 +
+
새 Recap 생성
+
+
+ +
+

기능 설명

+
+
+ 상태 +

생성 전, 생성 중, 완료, 실패, 재시도 가능 상태를 분리합니다.

+
+
+ 최소 조건 +

로그 1개 이상이면 이미지형 Recap을 생성할 수 있습니다.

+
+
+ 실패 +

실패한 Recap을 숨기지 않고 재시도 버튼을 제공합니다.

+
+
+ 이벤트 +

recap_requested, recap_generation_failed, recap_opened

+
+
+
POST /v1/recaps · GET /v1/recaps
+
+
+ +
+
+
+

P-09. Recap 상세 / 공유

+

사운드트랙 앨범 결과물을 감상하고 저장 또는 공유합니다.

+
+ Share +
+ +
+
+
9:41LTE 100%
+

강릉 바다 사운드

+
+
+ 앨범 + LP + 필름 + 지도 +
+ +
+
주문진 · 곡명 A사진 7장 · 곡 12개 · 잔잔한
+
+
+
이미지 저장
+
공유하기
+
+
+
+ +
+

기능 설명

+
+
+ MVP 우선순위 +

앨범 커버형, LP 슬리브형, 필름형, 지도 엽서형 이미지 Recap을 우선합니다.

+
+
+ 비율 +

앨범 커버 1:1, 스토리 공유 9:16, 필름형 세로 스크롤.

+
+
+ 권한 +

사진 저장 권한은 저장 시점에 요청하고 공유는 OS share sheet를 우선합니다.

+
+
+ 실패 +

캡처 실패, 저장 실패, 공유 실패를 각각 분리해 안내합니다.

+
+
+
+
+ +
+
+
+

P-10. 보관함 / 마이

+

좋아요/저장한 곡과 권한, 취향, 데이터 설정을 관리합니다.

+
+ Library +
+ +
+
+
9:41LTE 100%
+

보관함

+
+ 좋아요 + 저장곡 + 플레이리스트 +
+
+
곡명 A성수동 카페거리에서 저장
+ 열기 +
+
+
곡명 B서울숲 산책에서 저장
+ 삭제 +
+ +
취향 수정장르 · 무드 · 여행 스타일
+
권한 설정위치 · 카메라 · 사진
+
+
+ +
+

기능 설명

+
+
+ 보관함 +

좋아요한 곡, 저장한 곡, 저장한 추천 목록을 분리합니다.

+
+
+ 마이 +

계정, 권한, 취향, 데이터 삭제, 로그아웃을 제공합니다.

+
+
+ 오프라인 +

최근 저장 목록은 로컬 캐시로 표시합니다.

+
+
+ 이벤트 +

library_viewed, preference_updated, permission_settings_opened

+
+
+
+
+ +
+
+
+

P-11. 공동 Recap 만들기

+

같이 여행 간 사람끼리 각자의 사진과 곡을 모아 하나의 Recap을 함께 만듭니다.

+
+ Community +
+ +
+
+
9:41LTE 100%
+

강릉 여행방

+

4명이 함께 만드는 사운드트랙 앨범

+
+
+
18순간
+
24
+
4참여자
+
+ +
+
주문진 바다 컷수경 추가 · 곡명 A
+ 채택 +
+
+
카페 거리 컷재걸 추가 · 곡명 B
+ 댓글 +
+
공동 Recap 생성
+
+
+ +
+

기능 설명

+
+
+ 사용자 목표 +

동행자들이 각자 남긴 순간을 모아 하나의 여행 앨범으로 완성합니다.

+
+
+ 초대/권한 +

초대 코드 또는 링크로 참여하고, 방장만 최종 Recap 생성/공개 범위를 확정합니다.

+
+
+ 기여 방식 +

사진, 장소, 곡, 메모를 후보로 올리고 채택/댓글로 최종 구성에 반영합니다.

+
+
+ 이벤트 +

trip_room_joined, shared_moment_added, collab_recap_created

+
+
+
+
+ +
+
+
+

P-12. Live Sound Map

+

여행 모드를 켠 사람의 현재 위치와 지금 선택한 음악을 지도 위에 표시하고, 주변 익명 공개로 확장합니다.

+
+ Map +
+ +
+
+
9:41LTE 100%
+

Live Sound Map

+

여행 모드 ON · 주변 익명 공개

+
+
나 · 곡명 A
성수동
+
수경 · 곡명 B
서울숲
+
82% · 곡명 C
근처 여행자
+
+ +
+
곡명 A - 아티스트Spotify 링크로 열었던 곡
+ 표시중 +
+
+
주변 취향 매칭반경 1km · 익명 6명
+ 보기 +
+
+
동행자만
+
주변 익명
+
+
+
+ +
+

기능 설명

+
+
+ 사용자 목표 +

여행 중 내가 어디서 어떤 음악을 듣고 있는지 동행자와 느슨하게 공유합니다.

+
+
+ 현재 음악 기준 +

외부 앱 재생 상태가 아니라 Soundlog에서 선택했거나 외부 링크로 연 곡을 현재 음악으로 표시합니다.

+
+
+ 개인정보 +

여행 모드 ON일 때만 표시하며, 낯선 사람에게는 대략 위치, 닉네임, 취향 태그만 공개합니다.

+
+
+ 이벤트 +

travel_mode_enabled, live_track_shared, sound_map_viewed, nearby_sound_opened

+
+
+
+
+ +
+
+
+

P-13. Nearby Sound Match

+

잘 모르는 주변 여행자와 음악 취향을 먼저 공유하고, 취향이 맞는 사람을 발견합니다.

+
+ Discover +
+ +
+
+
9:41LTE 100%
+

주변 사운드 취향

+

대략 위치만 공개 · 2시간 후 자동 숨김

+
+ 산책 + 카페 + 야경 +
+ +
+
+
+ 비 오는 골목 닉네임 + 잔잔한 · 인디 · 혼자 여행 +
+ 82% +
+
+
지금 공개한 곡곡명 C - 아티스트 · 성수 근처
+
+
+
+
+
+ 야경 수집가 + 설레는 · 시티팝 · 사진 산책 +
+ 76% +
+
+
동행 매칭 후보 보기
+
+
+ +
+

기능 설명

+
+
+ 사용자 목표 +

낯선 사람의 프로필보다 음악 취향과 여행 맥락을 먼저 보고 연결 가능성을 판단합니다.

+
+
+ 매칭 기준 +

공개 곡, 좋아요/저장 태그, 현재 무드, 여행 상태, 대략 거리로 취향 일치율을 계산합니다.

+
+
+ 개인정보 +

실명, 정확한 좌표, 연락처는 노출하지 않고 공개 핀은 짧은 TTL 후 자동 숨김 처리합니다.

+
+
+ 이벤트 +

nearby_sound_opened, music_match_viewed, music_match_profile_opened

+
+
+
+
+ +
+
+
+

P-14. Travel Mate Match

+

음악 취향이 맞는 사람에게 여행 동행을 제안하되, 서로 수락한 뒤에만 대화를 엽니다.

+
+ Match +
+ +
+
+
9:41LTE 100%
+

동행 매칭 요청

+

취향 82% 일치 · 성수 근처 · 정확한 위치 비공개

+
+
+
+ 비 오는 골목 닉네임 + 잔잔한 인디, 카페 산책, 사진 기록 선호 +
+ 82% +
+ +
+
곡명 C - 아티스트"조용한 골목 걸을 때 좋아요"
+
+
+
+ 정확한 위치는 서로 수락 후에도 선택 공개 + 매칭 전 자유 채팅 없음, 첫 메시지는 템플릿 사용 + 언제든 차단/신고 가능 +
+
+
취향으로 인사
+
차단/신고
+
+
+
+ +
+

기능 설명

+
+
+ 사용자 목표 +

음악 취향과 여행 목적이 맞는 사람에게 부담 낮은 동행 요청을 보냅니다.

+
+
+ 상호 동의 +

요청 발송과 수락이 모두 있어야 대화가 열리고, 만남 제안은 앱 내 안전 안내를 거칩니다.

+
+
+ 안전장치 +

정확 위치 숨김, 템플릿 첫 메시지, 신고/차단, 반복 요청 제한, 만남 전 주의 안내를 둡니다.

+
+
+ 이벤트 +

travel_mate_requested, travel_mate_accepted, community_user_blocked

+
+
+
+
+ +
+

검수 체크

+

HTML 와이어프레임이 원 기획 목표를 화면 기준으로 충족하는지 확인하는 표입니다.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
기준화면 반영확인 포인트
재생 외부화P-04, P-05앱 내부 재생 버튼이 아니라 외부 링크와 기록 CTA가 중심입니다.
기록 중심P-06, P-07곡 선택이 MomentLog 저장으로 이어집니다.
Recap 주연화P-08, P-09Recap은 앨범 커버, LP, 필름, 지도 엽서형 결과물로 공유 욕구를 만듭니다.
공동 제작P-11같이 여행 간 사람끼리 사진과 곡을 모아 하나의 Recap을 함께 만듭니다.
지도 커뮤니티P-12여행 모드 사용자는 현재 위치와 Soundlog에서 선택한 음악을 지도 위에 표시합니다.
낯선 사람 음악 공유P-12, P-13주변 여행자는 실명보다 공개 곡, 무드, 여행 상태를 먼저 보고 취향 연결을 시작합니다.
여행 동행 매칭P-14취향 일치율이 높은 사람에게만 상호 동의 기반의 동행 요청을 보낼 수 있습니다.
안전장치P-13, P-14정확 위치/연락처는 숨기고, TTL, 첫 메시지 제한, 차단/신고를 기본 플로우에 포함합니다.
약점 보완P-01, P-05, P-09, P-12, P-13앱 안에서 음악이 나오지 않는 점은 명확한 카피, 예쁜 Recap, 지도 위 음악 공유와 취향 매칭으로 보완합니다.
+
+
+
+ + diff --git a/package-lock.json b/package-lock.json index 4ad32dc..e2a17c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "expo-sharing": "~56.0.18", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", + "ol": "^10.9.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", @@ -2064,6 +2065,12 @@ "node": ">= 8" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3316,6 +3323,12 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/rbush": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-4.0.0.tgz", + "integrity": "sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", @@ -3394,6 +3407,16 @@ "node": ">=10.0.0" } }, + "node_modules/@zarrita/storage": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.2.0.tgz", + "integrity": "sha512-855ZXqtnds7spnT8vNvD+MXa3QExP1m2GqShe8yt7uZXHnQLgJHgkpVwFjE1B0KDDRO0ki09hmk6OboTaIfPsQ==", + "license": "MIT", + "dependencies": { + "reference-spec-reader": "^0.2.0", + "unzipit": "2.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4564,6 +4587,12 @@ "license": "MIT", "peer": true }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5313,6 +5342,12 @@ "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", "license": "MIT" }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5450,6 +5485,25 @@ "node": ">=6.9.0" } }, + "node_modules/geotiff": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", + "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.9.3", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0" + }, + "engines": { + "node": ">=10.19" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -6008,6 +6062,12 @@ "lan-network": "dist/lan-network-cli.js" } }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7010,6 +7070,15 @@ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", "license": "MIT" }, + "node_modules/numcodecs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.3.2.tgz", + "integrity": "sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw==", + "license": "MIT", + "dependencies": { + "fflate": "^0.8.0" + } + }, "node_modules/ob1": { "version": "0.84.4", "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", @@ -7040,6 +7109,24 @@ "node": ">= 6" } }, + "node_modules/ol": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/ol/-/ol-10.9.0.tgz", + "integrity": "sha512-svbbgVQUmEHaKpLQ8kRySojs59Brvgl2zYIrqG9eQNXGfsbi55rQasZIDpwpQzDL6OlzrUb0H4hQaiX9wDoGmA==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/rbush": "4.0.0", + "earcut": "^3.0.0", + "geotiff": "^3.0.5 || ^3.1.0-beta.0", + "pbf": "4.0.1", + "rbush": "^4.0.0", + "zarrita": "^0.7.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/openlayers" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -7237,6 +7324,28 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, "node_modules/parse-png": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", @@ -7323,6 +7432,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7731,6 +7852,12 @@ "node": ">= 6" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7789,6 +7916,24 @@ ], "license": "MIT" }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7798,6 +7943,15 @@ "node": ">= 0.6" } }, + "node_modules/rbush": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", + "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", + "license": "MIT", + "dependencies": { + "quickselect": "^3.0.0" + } + }, "node_modules/react": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -8460,6 +8614,12 @@ "node": ">=8" } }, + "node_modules/reference-spec-reader": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==", + "license": "MIT" + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -8565,6 +8725,15 @@ "node": ">=8" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/resolve-workspace-root": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", @@ -9423,6 +9592,15 @@ "node": ">= 0.8" } }, + "node_modules/unzipit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-2.0.0.tgz", + "integrity": "sha512-DVeVIWUZCAQPNzm5sB0hpsG1GygTTdBnzNtYYEpInkttx5evkyqRgZi6rTczoySqp8hO5jHVKzrH0f23X8FZLg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -9614,6 +9792,12 @@ "defaults": "^1.0.3" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -9708,6 +9892,12 @@ "node": ">=10.0.0" } }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, "node_modules/xml2js": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", @@ -9796,6 +9986,16 @@ "node": ">=12" } }, + "node_modules/zarrita": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.7.3.tgz", + "integrity": "sha512-wChTQ1Ox75INoQCzKAfLWAfB70JJ4KjdW8Sz5x4ZWrFB4Dw+YZdnxHTL0xSdsrB9EmKSeK7fS1Y+I2ibhfGbkw==", + "license": "MIT", + "dependencies": { + "@zarrita/storage": "^0.2.0", + "numcodecs": "^0.3.2" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -9805,6 +10005,12 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "license": "MIT AND BSD-3-Clause" + }, "node_modules/zustand": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", diff --git a/package.json b/package.json index 3fe0569..73535b6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "expo-sharing": "~56.0.18", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", + "ol": "^10.9.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.85.3", diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx index 2ba758a..2cace19 100644 --- a/src/components/travel/TravelScreen.tsx +++ b/src/components/travel/TravelScreen.tsx @@ -18,6 +18,7 @@ import { useTravelSessionStore } from '@/store/travelSessionStore'; import type { TravelMode } from '@/types/domain'; import { EndTravelConfirmModal } from './EndTravelConfirmModal'; +import { LiveSoundMapSection } from './live-sound-map'; import { MomentCard } from './MomentCard'; import { TravelModeBottomSheet } from './TravelModeBottomSheet'; import { TravelStatusCard } from './TravelStatusCard'; @@ -37,6 +38,7 @@ export function TravelScreen() { const [recapMessage, setRecapMessage] = useState(); const [, setClockTick] = useState(0); const { + currentLocation, currentPlace, selectedMode, session, @@ -237,6 +239,13 @@ export function TravelScreen() { ) : null} + + diff --git a/src/components/travel/TravelStatusCard.tsx b/src/components/travel/TravelStatusCard.tsx index 62e98b6..0168db4 100644 --- a/src/components/travel/TravelStatusCard.tsx +++ b/src/components/travel/TravelStatusCard.tsx @@ -109,7 +109,7 @@ export function TravelStatusCard({ 여행 종료 diff --git a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx new file mode 100644 index 0000000..e4fb13c --- /dev/null +++ b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx @@ -0,0 +1,177 @@ +import { Feather } from '@expo/vector-icons'; +import { useMemo, useState } from 'react'; +import { Pressable, View } from 'react-native'; + +import { syncRecommendationEvent } from '@/api/recommendationEventApi'; +import { AppText } from '@/components/AppText'; +import { useRecommendationEventStore } from '@/store/recommendationEventStore'; +import type { GeoPoint, PlaceContext, Track } from '@/types/domain'; +import { formatPlaceLabel } from '@/utils/placeLabel'; + +import { OpenLayersSoundMap } from './OpenLayersSoundMap'; +import { createSoundMapCenter, createSoundMapPins } from './soundMapData'; +import type { SoundMapVisibility } from './types'; + +type LiveSoundMapSectionProps = { + currentLocation?: GeoPoint; + currentPlace?: PlaceContext; + currentTrack?: Track; + sessionStatus: 'active' | 'ended' | 'idle'; +}; + +const visibilityOptions: Array<{ label: string; value: SoundMapVisibility }> = [ + { label: '동행자만', value: 'companions' }, + { label: '주변 익명', value: 'nearby' }, + { label: '비공개', value: 'private' }, +]; + +const visibilityCopy: Record = { + companions: { + description: '같은 여행방에 있는 동행자에게만 현재 음악 핀을 보여줘요.', + title: '동행자 공개', + }, + nearby: { + description: '정확한 좌표 대신 대략 위치와 음악 취향만 주변 여행자에게 보여줘요.', + title: '주변 익명 공개', + }, + private: { + description: '내 지도에는 보이지만 다른 사람에게는 현재 음악 핀을 공개하지 않아요.', + title: '비공개', + }, +}; + +export function LiveSoundMapSection({ + currentLocation, + currentPlace, + currentTrack, + sessionStatus, +}: LiveSoundMapSectionProps) { + const [visibility, setVisibility] = useState('companions'); + const addEvent = useRecommendationEventStore((state) => state.addEvent); + const center = useMemo( + () => createSoundMapCenter(currentLocation, currentPlace), + [currentLocation, currentPlace], + ); + const pins = useMemo( + () => createSoundMapPins({ center, currentTrack, visibility }), + [center, currentTrack, visibility], + ); + const isTravelActive = sessionStatus === 'active'; + const isLive = isTravelActive && visibility !== 'private'; + const locationLabel = currentPlace?.title ?? formatPlaceLabel(center); + const visibilityMessage = visibilityCopy[visibility]; + + const handleVisibilityPress = (nextVisibility: SoundMapVisibility) => { + setVisibility(nextVisibility); + + if (!isTravelActive || !currentTrack || nextVisibility === 'private') { + return; + } + + const event = addEvent({ + context: { + placeId: currentPlace?.id, + placeName: currentPlace?.title, + }, + trackId: currentTrack.id, + type: nextVisibility === 'nearby' ? 'nearby_sound_opened' : 'live_track_shared', + value: nextVisibility, + }); + + syncRecommendationEvent(event); + }; + + return ( + + + + + + + Live Sound Map + + + + 지금 듣는 음악을 지도에 남겨요 + + + OpenLayers 지도를 Soundlog 다크 테마로 조정하고, 여행 모드 중인 내 음악과 + 동행자/주변 익명 핀을 함께 보여줘요. + + + + + {isLive ? 'LIVE' : 'PREVIEW'} + + + + + + + + + + + + + + + + {isTravelActive ? visibilityMessage.title : '여행 모드 OFF'} + + + {isTravelActive + ? visibilityMessage.description + : '여행을 시작하면 현재 위치와 Soundlog에서 선택한 음악을 지도에 표시할 수 있어요.'} + + + {locationLabel} · {currentTrack ? `${currentTrack.title} - ${currentTrack.artist}` : '선택한 음악 없음'} + + + + + + + {visibilityOptions.map((option) => { + const selected = visibility === option.value; + + return ( + handleVisibilityPress(option.value)} + > + + {option.label} + + + ); + })} + + + ); +} diff --git a/src/components/travel/live-sound-map/OpenLayersSoundMap.tsx b/src/components/travel/live-sound-map/OpenLayersSoundMap.tsx new file mode 100644 index 0000000..c81ff40 --- /dev/null +++ b/src/components/travel/live-sound-map/OpenLayersSoundMap.tsx @@ -0,0 +1,58 @@ +import { Feather } from '@expo/vector-icons'; +import { View } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +import type { SoundMapViewport } from './types'; + +const pinPositions: Record = { + 'companion-su': { left: '62%', top: '28%' }, + me: { left: '36%', top: '43%' }, + 'nearby-night': { left: '22%', top: '31%' }, + 'nearby-rainy': { left: '52%', top: '66%' }, +}; + +const kindClassName = { + companion: 'border-soundlog-blue bg-[#132244]', + me: 'border-soundlog-lime bg-[#1E2B16]', + nearby: 'border-soundlog-warning bg-[#2A1A15]', +} as const; + +export function OpenLayersSoundMap({ pins, sessionStatus, visibility }: SoundMapViewport) { + const isLive = sessionStatus === 'active' && visibility !== 'private'; + + return ( + + + + + + + + + + {isLive ? 'Live preview' : 'Map preview'} + + + + {pins.map((pin) => { + const position = pinPositions[pin.id] ?? pinPositions.me; + + return ( + + + {pin.label} · {pin.trackTitle} + + + {pin.subtitle} + + + ); + })} + + ); +} diff --git a/src/components/travel/live-sound-map/OpenLayersSoundMap.web.tsx b/src/components/travel/live-sound-map/OpenLayersSoundMap.web.tsx new file mode 100644 index 0000000..529c7d9 --- /dev/null +++ b/src/components/travel/live-sound-map/OpenLayersSoundMap.web.tsx @@ -0,0 +1,181 @@ +import { useEffect, useMemo, useRef } from 'react'; +import { View } from 'react-native'; +import Feature from 'ol/Feature'; +import OlMap from 'ol/Map'; +import OlView from 'ol/View'; +import Point from 'ol/geom/Point'; +import TileLayer from 'ol/layer/Tile'; +import VectorLayer from 'ol/layer/Vector'; +import { fromLonLat } from 'ol/proj'; +import OSM from 'ol/source/OSM'; +import VectorSource from 'ol/source/Vector'; +import { Circle as CircleStyle, Fill, Stroke, Style, Text } from 'ol/style'; + +import type { SoundMapPin, SoundMapPinKind, SoundMapViewport } from './types'; + +const pinColors: Record = { + companion: { + fill: '#132244', + stroke: '#6EA8FF', + text: '#E4E8FF', + }, + me: { + fill: '#B7E628', + stroke: '#090515', + text: '#090515', + }, + nearby: { + fill: '#2A1A15', + stroke: '#FF8A3D', + text: '#FFD0B0', + }, +}; + +const openLayersThemeCss = ` + .soundlog-openlayers-map, + .soundlog-openlayers-map .ol-viewport { + border-radius: 22px; + overflow: hidden; + background: #10172A; + } + + .soundlog-openlayers-map .ol-layer canvas { + filter: invert(1) hue-rotate(180deg) saturate(0.48) brightness(0.58) contrast(1.18); + } + + .soundlog-openlayers-map .ol-control, + .soundlog-openlayers-map .ol-attribution { + display: none; + } + + .soundlog-map-attribution { + position: absolute; + right: 10px; + bottom: 8px; + padding: 3px 6px; + border-radius: 999px; + background: rgba(8,13,24,0.72); + color: rgba(255,255,255,0.62); + font: 600 10px -apple-system, BlinkMacSystemFont, Apple SD Gothic Neo, sans-serif; + pointer-events: none; + } +`; + +function toMapCenter(location: { lat: number; lng: number }) { + return fromLonLat([location.lng, location.lat]); +} + +function createPinStyle(pin: SoundMapPin) { + const colors = pinColors[pin.kind]; + const label = pin.kind === 'nearby' && pin.matchScore ? `${pin.matchScore}%` : pin.label; + const isMe = pin.kind === 'me'; + + return new Style({ + image: new CircleStyle({ + fill: new Fill({ color: colors.fill }), + radius: pin.kind === 'me' ? 13 : 11, + stroke: new Stroke({ + color: colors.stroke, + width: pin.kind === 'me' ? 4 : 3, + }), + }), + text: new Text({ + backgroundFill: new Fill({ color: isMe ? '#B7E628' : 'rgba(8,13,24,0.9)' }), + backgroundStroke: new Stroke({ color: colors.stroke, width: 1 }), + fill: new Fill({ color: colors.text }), + font: '700 12px -apple-system, BlinkMacSystemFont, Apple SD Gothic Neo, sans-serif', + offsetY: -30, + padding: [6, 9, 6, 9], + text: `${label} · ${pin.trackTitle}`, + }), + }); +} + +function createFeature(pin: SoundMapPin) { + const feature = new Feature({ + geometry: new Point(toMapCenter(pin.location)), + soundlogPinId: pin.id, + }); + + feature.setStyle(createPinStyle(pin)); + + return feature; +} + +export function OpenLayersSoundMap({ center, pins }: SoundMapViewport) { + const mapElementRef = useRef(null); + const mapRef = useRef(null); + const vectorSourceRef = useRef(null); + const initialCenter = useMemo(() => toMapCenter(center), [center]); + + useEffect(() => { + if (!mapElementRef.current || mapRef.current) { + return; + } + + const vectorSource = new VectorSource(); + const vectorLayer = new VectorLayer({ + source: vectorSource, + zIndex: 2, + }); + const tileLayer = new TileLayer({ + source: new OSM({ + crossOrigin: 'anonymous', + }), + zIndex: 1, + }); + + vectorSourceRef.current = vectorSource; + mapRef.current = new OlMap({ + controls: [], + layers: [tileLayer, vectorLayer], + target: mapElementRef.current, + view: new OlView({ + center: initialCenter, + maxZoom: 17, + minZoom: 11, + zoom: 14, + }), + }); + + return () => { + mapRef.current?.setTarget(undefined); + mapRef.current = null; + vectorSourceRef.current = null; + }; + }, [initialCenter]); + + useEffect(() => { + const vectorSource = vectorSourceRef.current; + const map = mapRef.current; + + if (!vectorSource || !map) { + return; + } + + vectorSource.clear(); + vectorSource.addFeatures(pins.map(createFeature)); + map.getView().animate({ + center: toMapCenter(center), + duration: 220, + zoom: pins.length > 2 ? 13 : 14, + }); + }, [center, pins]); + + return ( + + +
+
© OpenStreetMap contributors
+ + ); +} diff --git a/src/components/travel/live-sound-map/index.ts b/src/components/travel/live-sound-map/index.ts new file mode 100644 index 0000000..3150b19 --- /dev/null +++ b/src/components/travel/live-sound-map/index.ts @@ -0,0 +1,2 @@ +export { LiveSoundMapSection } from './LiveSoundMapSection'; +export type { SoundMapPin, SoundMapVisibility } from './types'; diff --git a/src/components/travel/live-sound-map/soundMapData.ts b/src/components/travel/live-sound-map/soundMapData.ts new file mode 100644 index 0000000..60ecb5b --- /dev/null +++ b/src/components/travel/live-sound-map/soundMapData.ts @@ -0,0 +1,89 @@ +import type { GeoPoint, PlaceContext, Track } from '@/types/domain'; + +import type { SoundMapPin, SoundMapVisibility } from './types'; + +const DEFAULT_CENTER: GeoPoint = { + lat: 37.5104, + lng: 126.9963, +}; + +const fallbackTrack: Track = { + artist: 'Soundlog', + id: 'soundlog-fallback-track', + title: '선택한 음악 없음', +}; + +function offsetLocation(center: GeoPoint, latOffset: number, lngOffset: number): GeoPoint { + return { + lat: center.lat + latOffset, + lng: center.lng + lngOffset, + }; +} + +export function createSoundMapCenter(location?: GeoPoint, place?: PlaceContext): GeoPoint { + return location ?? place?.location ?? DEFAULT_CENTER; +} + +export function createSoundMapPins({ + center, + currentTrack, + visibility, +}: { + center: GeoPoint; + currentTrack?: Track; + visibility: SoundMapVisibility; +}): SoundMapPin[] { + const selectedTrack = currentTrack ?? fallbackTrack; + const pins: SoundMapPin[] = [ + { + artistName: selectedTrack.artist, + id: 'me', + kind: 'me', + label: '나', + location: center, + subtitle: 'Soundlog에서 선택한 현재 곡', + trackTitle: selectedTrack.title, + }, + ]; + + if (visibility === 'private') { + return pins; + } + + pins.push({ + artistName: '수경', + id: 'companion-su', + kind: 'companion', + label: '동행자', + location: offsetLocation(center, 0.0042, 0.0038), + subtitle: '같은 여행방 · 방금 공개', + trackTitle: 'Coastal Drive', + }); + + if (visibility === 'nearby') { + pins.push( + { + artistName: '익명 여행자', + id: 'nearby-rainy', + kind: 'nearby', + label: '82%', + location: offsetLocation(center, -0.0038, -0.0046), + matchScore: 82, + subtitle: '대략 위치 · 잔잔한 인디', + trackTitle: '비 오는 골목', + }, + { + artistName: '익명 여행자', + id: 'nearby-night', + kind: 'nearby', + label: '76%', + location: offsetLocation(center, 0.0022, -0.006), + matchScore: 76, + subtitle: '대략 위치 · 야경 산책', + trackTitle: 'City Light Walk', + }, + ); + } + + return pins; +} diff --git a/src/components/travel/live-sound-map/types.ts b/src/components/travel/live-sound-map/types.ts new file mode 100644 index 0000000..bb41120 --- /dev/null +++ b/src/components/travel/live-sound-map/types.ts @@ -0,0 +1,24 @@ +import type { GeoPoint, Track } from '@/types/domain'; + +export type SoundMapVisibility = 'companions' | 'nearby' | 'private'; + +export type SoundMapPinKind = 'companion' | 'me' | 'nearby'; + +export type SoundMapPin = { + artistName?: string; + id: string; + kind: SoundMapPinKind; + label: string; + location: GeoPoint; + matchScore?: number; + subtitle: string; + trackTitle: string; +}; + +export type SoundMapViewport = { + center: GeoPoint; + pins: SoundMapPin[]; + selectedTrack?: Track; + sessionStatus: 'active' | 'ended' | 'idle'; + visibility: SoundMapVisibility; +}; diff --git a/src/constants/colors.ts b/src/constants/colors.ts index 4509ee2..679c67b 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -34,6 +34,7 @@ export const colors = { purple: '#7A2CFF', gold: '#B1913A', lime: '#B7E628', + warning: '#FF8A3D', }, text: { primary: '#FFFFFF', diff --git a/src/design-system/index.ts b/src/design-system/index.ts new file mode 100644 index 0000000..001a830 --- /dev/null +++ b/src/design-system/index.ts @@ -0,0 +1,37 @@ +export { soundlogDesignTokens } from './tokens'; +export type { SoundlogDesignTokens } from './tokens'; +export { soundlogRecipes } from './recipes'; +export type { SoundlogRecipes } from './recipes'; + +export { AppText } from '@/components/AppText'; +export { BrandLogo } from '@/components/BrandLogo'; +export { Chip } from '@/components/Chip'; +export { EmptyState } from '@/components/EmptyState'; +export { IconButton } from '@/components/IconButton'; +export { MiniPlayer } from '@/components/MiniPlayer'; +export { Screen } from '@/components/Screen'; +export { FeaturedPlaylistCard } from '@/components/home/FeaturedPlaylistCard'; +export { + HomeHeader, + HomeNavigationBar, + HomeTopFilterBar, + isHomeTopFilter, +} from '@/components/home/HomeHeader'; +export { LocationContextCard } from '@/components/home/LocationContextCard'; +export { MoodRecommendationCard } from '@/components/home/MoodRecommendationCard'; +export { + MoodRecommendationSection, + isMoodRecommendationFilter, +} from '@/components/home/MoodRecommendationSection'; +export { TravelSessionCard } from '@/components/home/TravelSessionCard'; +export { LiveSoundMapSection } from '@/components/travel/live-sound-map'; +export { TravelStatusCard } from '@/components/travel/TravelStatusCard'; +export { LibraryEmptyState } from '@/components/library/LibraryEmptyState'; +export { LibraryTrackRow } from '@/components/library/LibraryTrackRow'; +export { RecapListCard } from '@/components/recap/RecapListCard'; +export { RecapCaptureFrame } from '@/components/recap-share/RecapCaptureFrame'; +export { RecapMusicSummary } from '@/components/recap-share/RecapMusicSummary'; +export { RecapPreviewCard } from '@/components/recap-share/RecapPreviewCard'; +export { RecapTemplateSelector } from '@/components/recap-share/RecapTemplateSelector'; +export { ShareActionButton } from '@/components/recap-share/ShareActionButton'; +export { ShareActionList } from '@/components/recap-share/ShareActionList'; diff --git a/src/design-system/recipes.ts b/src/design-system/recipes.ts new file mode 100644 index 0000000..257949b --- /dev/null +++ b/src/design-system/recipes.ts @@ -0,0 +1,56 @@ +export const soundlogRecipes = { + screen: { + root: 'flex-1 bg-soundlog-bg', + content: 'px-5 pt-4', + sectionStack: 'mt-5 gap-4', + }, + text: { + screenTitle: 'text-[28px] font-semibold text-white', + heroTitle: 'text-[30px] font-semibold leading-9 text-white', + sectionTitle: 'text-[22px] font-semibold text-white', + cardTitle: 'text-[18px] font-bold leading-6 text-white', + body: 'text-sm leading-6 text-white/60', + caption: 'text-xs text-white/45', + chip: 'text-[13px] font-medium', + }, + card: { + base: 'rounded-[20px] border border-white/10 bg-soundlog-card p-4', + elevated: 'rounded-[20px] bg-soundlog-elevated/80 p-2', + glass: 'rounded-[22px] border border-white/10 bg-white/10 p-4', + hero: 'rounded-[30px] border border-white/10 bg-white/10 p-6', + media: 'overflow-hidden rounded-[26px] border border-white/10 bg-white/10', + }, + chip: { + base: 'min-h-[38px] justify-center rounded-full border px-5', + selected: 'border-soundlog-lime bg-soundlog-selected', + idle: 'border-transparent bg-soundlog-chip', + small: 'min-h-[28px] px-3', + }, + button: { + primary: 'h-14 items-center justify-center rounded-full bg-soundlog-lime', + secondary: 'h-14 items-center justify-center rounded-full border border-white/15', + compactPrimary: 'h-11 items-center justify-center rounded-full bg-soundlog-lime', + compactDanger: 'h-11 items-center justify-center rounded-full bg-soundlog-warning', + icon: 'h-11 w-11 items-center justify-center rounded-full bg-white/10', + }, + control: { + segmentedShell: 'rounded-[20px] bg-soundlog-elevated/80 p-2', + segmentedTrack: 'rounded-full bg-black/25 p-1', + segmentedItem: 'h-10 flex-1 items-center justify-center rounded-full', + filterShell: + 'rounded-full border border-soundlog-border/70 bg-soundlog-chip/70 py-2 pl-2 pr-0', + }, + travel: { + activeCard: 'overflow-hidden rounded-[22px] border border-soundlog-lime/35 bg-white/10 p-4', + endedCard: 'rounded-[28px] border border-white/10 bg-white/10 p-5', + idleCard: 'rounded-[30px] border border-white/10 bg-white/10 p-6', + metric: 'min-w-[126px] flex-1 rounded-[18px] bg-white/10 p-4', + compactMetric: 'min-w-[126px] flex-1 rounded-[14px] bg-white/10 px-3 py-2.5', + }, + recap: { + listCard: 'h-[188px] overflow-hidden rounded-[26px] border border-white/10 bg-white/10', + actionCircle: 'h-10 w-10 items-center justify-center rounded-full bg-white/90', + }, +} as const; + +export type SoundlogRecipes = typeof soundlogRecipes; diff --git a/src/design-system/tokens.ts b/src/design-system/tokens.ts new file mode 100644 index 0000000..d84ef1e --- /dev/null +++ b/src/design-system/tokens.ts @@ -0,0 +1,74 @@ +import { colors } from '@/constants/colors'; + +export const soundlogDesignTokens = { + color: { + brand: { + limeWave: colors.brand.limeWave, + }, + background: { + app: colors.background.primary, + raised: colors.background.secondary, + }, + surface: { + card: colors.surface.card, + elevated: colors.surface.cardElevated, + chip: colors.surface.chip, + selected: colors.surface.chipSelected, + player: colors.surface.player, + tab: colors.surface.tab, + glass: colors.surface.glass, + }, + border: { + subtle: colors.border.subtle, + chip: colors.border.chip, + focus: colors.border.focus, + }, + accent: { + blue: colors.accent.blue, + purple: colors.accent.purple, + gold: colors.accent.gold, + lime: colors.accent.lime, + warning: colors.accent.warning, + }, + text: { + primary: colors.text.primary, + secondary: colors.text.secondary, + muted: colors.text.muted, + inverse: colors.text.inverse, + }, + }, + radius: { + chip: 999, + control: 20, + cardSmall: 12, + cardMedium: 18, + cardLarge: 22, + cardXLarge: 28, + heroCard: 30, + }, + spacing: { + screenX: 20, + screenTop: 16, + sectionGap: 18, + cardPadding: 16, + cardPaddingLarge: 20, + chipX: 20, + chipY: 8, + }, + typography: { + screenTitle: 'text-[28px] font-semibold text-white', + heroTitle: 'text-[30px] font-semibold leading-9 text-white', + sectionTitle: 'text-[22px] font-semibold text-white', + cardTitle: 'text-[18px] font-bold leading-6 text-white', + body: 'text-sm leading-6 text-white/60', + caption: 'text-xs text-white/45', + chip: 'text-[13px] font-medium', + }, + touchTarget: { + iconButton: 44, + primaryButton: 56, + compactButton: 44, + }, +} as const; + +export type SoundlogDesignTokens = typeof soundlogDesignTokens; diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index 7610f34..92d5ca0 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -5,14 +5,18 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { MusicRecommendationMode, TravelMode } from '@/types/domain'; export type RecommendationEventType = + | 'track_external_open' | 'track_like' | 'track_unlike' | 'track_save' | 'track_unsave' | 'playlist_open' | 'mood_filter_change' + | 'live_track_shared' + | 'nearby_sound_opened' | 'recommendation_mode_change' - | 'top_filter_change'; + | 'top_filter_change' + | 'recap_representative_track_select'; export type RecommendationEventContext = { moodFilter?: string; diff --git a/tailwind.config.js b/tailwind.config.js index d924808..e4796c8 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -20,6 +20,7 @@ module.exports = { gold: '#B1913A', lime: '#B7E628', player: '#45343D', + warning: '#FF8A3D', inverse: '#090515', }, }, From 6b21388f228ed83b9eaba7719f69e5715529a028 Mon Sep 17 00:00:00 2001 From: manNomi Date: Tue, 7 Jul 2026 17:58:26 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EC=97=AC=ED=96=89=20=EA=B8=B0=EB=8A=A5=20=ED=94=84?= =?UTF-8?q?=EB=A1=A0=ED=8A=B8=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SOUNDLOG_RN_DESIGN_SYSTEM.md | 190 ++++++++---- src/api/communityApi.ts | 226 +++++++++++++++ src/components/travel/CommunityRecapCard.tsx | 188 ++++++++++++ src/components/travel/TravelScreen.tsx | 17 ++ .../live-sound-map/LiveSoundMapSection.tsx | 270 +++++++++++++++++- src/design-system/index.ts | 11 + src/design-system/primitives.tsx | 270 ++++++++++++++++++ src/types/domain.ts | 81 ++++++ 8 files changed, 1188 insertions(+), 65 deletions(-) create mode 100644 src/api/communityApi.ts create mode 100644 src/components/travel/CommunityRecapCard.tsx create mode 100644 src/design-system/primitives.tsx diff --git a/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md b/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md index 973ad7c..d70924a 100644 --- a/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md +++ b/docs/design-system/SOUNDLOG_RN_DESIGN_SYSTEM.md @@ -20,74 +20,79 @@ import { Chip, IconButton, Screen, + SoundlogButton, + SoundlogMetric, + SoundlogSectionHeader, + SoundlogSurface, soundlogDesignTokens, soundlogRecipes, } from '@/design-system'; ``` -`src/design-system/index.ts`는 새 컴포넌트를 다시 만드는 곳이 아니라, 현재 RN 앱에 구현된 컴포넌트와 스타일 recipe를 일관되게 꺼내 쓰는 조립 지점입니다. 토큰 원천은 `src/constants/colors.ts`와 `tailwind.config.js`입니다. +`src/design-system/index.ts`는 새 컴포넌트를 다시 만드는 곳이 아니라, 현재 RN 앱에 구현된 컴포넌트와 스타일 recipe를 일관되게 꺼내 쓰는 조립 지점입니다. 토큰 원천은 `src/constants/colors.ts`와 `tailwind.config.js`이고, 새 화면은 `SoundlogSurface`, `SoundlogButton`, `SoundlogSectionHeader`, `SoundlogMetric` 같은 조립 단위를 우선 사용합니다. ## Source Of Truth 이 디자인 시스템은 HTML 와이어프레임이나 신규 기획 문서의 시안을 기준으로 하지 않습니다. 기준은 현재 RN 앱에 이미 구현된 화면입니다. -| Area | Current Source | -| --- | --- | -| 기본 화면 배경과 safe area | `src/components/Screen.tsx` | -| 텍스트 래퍼 | `src/components/AppText.tsx` | -| 필터/태그 칩 | `src/components/Chip.tsx` | -| 상단 로고와 모드 세그먼트 | `src/components/home/HomeHeader.tsx` | -| 추천 플레이리스트 카드 | `src/components/home/FeaturedPlaylistCard.tsx` | -| 여행 상태 카드 | `src/components/travel/TravelStatusCard.tsx` | -| Recap 리스트 카드 | `src/components/recap/RecapListCard.tsx` | -| 라이브 사운드맵 | `src/components/travel/live-sound-map/LiveSoundMapSection.tsx` | +| Area | Current Source | +| -------------------------- | -------------------------------------------------------------- | +| 기본 화면 배경과 safe area | `src/components/Screen.tsx` | +| 텍스트 래퍼 | `src/components/AppText.tsx` | +| 필터/태그 칩 | `src/components/Chip.tsx` | +| 상단 로고와 모드 세그먼트 | `src/components/home/HomeHeader.tsx` | +| 추천 플레이리스트 카드 | `src/components/home/FeaturedPlaylistCard.tsx` | +| 여행 상태 카드 | `src/components/travel/TravelStatusCard.tsx` | +| Recap 리스트 카드 | `src/components/recap/RecapListCard.tsx` | +| 라이브 사운드맵 | `src/components/travel/live-sound-map/LiveSoundMapSection.tsx` | +| 조립용 프리미티브 | `src/design-system/primitives.tsx` | ## Color Tokens -| Role | Value | NativeWind | -| --- | --- | --- | -| App background | `#070B1F` | `bg-soundlog-bg` | -| Raised background | `#0B102A` | `bg-soundlog-bg2` | -| Card | `#080D18` | `bg-soundlog-card` | -| Elevated card | `#090E1B` | `bg-soundlog-elevated` | -| Chip | `#171B2A` | `bg-soundlog-chip` | -| Selected / CTA | `#B7E628` | `bg-soundlog-selected`, `bg-soundlog-lime` | -| Focus border | `#B7E628` | `border-soundlog-focus` | -| Chip border | `#364283` | `border-soundlog-border` | -| Info blue | `#6EA8FF` | `text-soundlog-blue` | -| Accent purple | `#7A2CFF` | `text-soundlog-purple` | -| Accent gold | `#B1913A` | `text-soundlog-gold` | -| Warning action | `#FF8A3D` | `bg-soundlog-warning` | -| Inverse text | `#090515` | `text-soundlog-inverse` | +| Role | Value | NativeWind | +| ----------------- | --------- | ------------------------------------------ | +| App background | `#070B1F` | `bg-soundlog-bg` | +| Raised background | `#0B102A` | `bg-soundlog-bg2` | +| Card | `#080D18` | `bg-soundlog-card` | +| Elevated card | `#090E1B` | `bg-soundlog-elevated` | +| Chip | `#171B2A` | `bg-soundlog-chip` | +| Selected / CTA | `#B7E628` | `bg-soundlog-selected`, `bg-soundlog-lime` | +| Focus border | `#B7E628` | `border-soundlog-focus` | +| Chip border | `#364283` | `border-soundlog-border` | +| Info blue | `#6EA8FF` | `text-soundlog-blue` | +| Accent purple | `#7A2CFF` | `text-soundlog-purple` | +| Accent gold | `#B1913A` | `text-soundlog-gold` | +| Warning action | `#FF8A3D` | `bg-soundlog-warning` | +| Inverse text | `#090515` | `text-soundlog-inverse` | ## Typography -| Use | Current Pattern | -| --- | --- | -| Screen title | `text-[28px] font-semibold text-white` | +| Use | Current Pattern | +| --------------- | ------------------------------------------------ | +| Screen title | `text-[28px] font-semibold text-white` | | Hero card title | `text-[30px] font-semibold leading-9 text-white` | -| Section title | `text-[22px] font-semibold text-white` | -| Card title | `text-[18px] font-bold leading-6 text-white` | -| Body | `text-sm leading-6 text-white/60` | -| Caption | `text-xs text-white/45` | -| Chip label | `text-[13px] font-medium` | +| Section title | `text-[22px] font-semibold text-white` | +| Card title | `text-[18px] font-bold leading-6 text-white` | +| Body | `text-sm leading-6 text-white/60` | +| Caption | `text-xs text-white/45` | +| Chip label | `text-[13px] font-medium` | 항상 `AppText`를 먼저 사용합니다. 카드, 리스트, 작은 탭처럼 폭이 제한된 영역은 `numberOfLines`를 지정해서 텍스트가 카드 밖으로 밀려나지 않게 합니다. ## Radius And Spacing -| Use | Current Pattern | -| --- | --- | -| Screen horizontal padding | `px-5` | -| Section gap | `mt-4`, `mt-5`, `gap-4` 중심 | -| Icon button | `h-11 w-11 rounded-full` | -| Default chip | `min-h-[38px] rounded-full px-5` | -| Small chip | `min-h-[28px] rounded-full px-3` | -| Compact card | `rounded-[12px]` | -| Metric card | `rounded-[14px]`, `rounded-[18px]` | -| Mode control | `rounded-[20px]` + inner `rounded-full` | -| Active travel card | `rounded-[22px]` | -| Ended/idle travel cards | `rounded-[28px]`, `rounded-[30px]` | +| Use | Current Pattern | +| ------------------------- | --------------------------------------- | +| Screen horizontal padding | `px-5` | +| Section gap | `mt-4`, `mt-5`, `gap-4` 중심 | +| Icon button | `h-11 w-11 rounded-full` | +| Default chip | `min-h-[38px] rounded-full px-5` | +| Small chip | `min-h-[28px] rounded-full px-3` | +| Compact card | `rounded-[12px]` | +| Metric card | `rounded-[14px]`, `rounded-[18px]` | +| Mode control | `rounded-[20px]` + inner `rounded-full` | +| Active travel card | `rounded-[22px]` | +| Ended/idle travel cards | `rounded-[28px]`, `rounded-[30px]` | 새 화면의 카드 radius는 먼저 18, 20, 22 중에서 고릅니다. 화면을 대표하는 큰 상태 카드는 28 또는 30까지 허용합니다. @@ -140,6 +145,73 @@ import { 비어 있음, 권한 전, 데이터 없음 상태에 씁니다. 새 화면에서 빈 상태 문구와 CTA를 임의 스타일로 만들지 말고 이 컴포넌트를 먼저 씁니다. +## Composable Primitives + +프리미티브는 새 화면을 빠르게 조립하기 위한 얇은 컴포넌트입니다. 기존 화면을 강제로 갈아엎기 위한 레이어가 아니라, 앞으로 추가되는 화면에서 반복 className 복붙을 줄이기 위한 기준입니다. + +### SoundlogSurface + +카드 표면을 만들 때 씁니다. 현재 RN 앱의 `bg-white/10`, `border-white/10`, 20~30 radius 패턴을 variant로 제공합니다. + +```tsx + + + +``` + +| Variant | Role | +| ---------- | --------------------------------- | +| `base` | 기본 카드, `bg-soundlog-card` | +| `glass` | 여행/상태성 카드, `bg-white/10` | +| `hero` | 화면을 대표하는 큰 상태 카드 | +| `media` | Recap/이미지 중심 카드 | +| `elevated` | 세그먼트 컨트롤 같은 raised shell | + +### SoundlogButton + +CTA와 보조 액션에 씁니다. 모든 variant는 최소 44px 이상 터치 영역을 유지합니다. + +```tsx + + + +``` + +| Variant | Use | +| ----------- | -------------------------------------- | +| `primary` | 라임 CTA, 선택/시작/확정 | +| `secondary` | 같은 레벨의 보조 액션 | +| `ghost` | 카드 안의 가벼운 액션 | +| `danger` | 여행 종료, 신고처럼 주의가 필요한 액션 | + +### SoundlogSectionHeader + +섹션 타이틀, 설명, 작은 우측 액션을 한 번에 배치합니다. 긴 제목과 설명은 줄 수를 제한해 작은 화면에서 깨지지 않게 합니다. + +```tsx + +``` + +### SoundlogMetric + +여행 시간, 저장한 순간, 기록된 음악처럼 짧은 지표를 표현합니다. `TravelStatusCard`의 metric 패턴을 그대로 가져왔습니다. + +```tsx + + + + +``` + ## Class Recipes 반복되는 NativeWind class 조합은 `soundlogRecipes`에서 꺼내 씁니다. recipe는 현재 앱 스타일의 이름표일 뿐이고, 화면마다 필요한 state/접근성 props는 컴포넌트에서 직접 명시합니다. @@ -154,16 +226,18 @@ import { AppText, soundlogRecipes } from '@/design-system'; 자주 쓰는 recipe: -| Recipe | Role | -| --- | --- | -| `screen.content` | `px-5 pt-4` 기본 화면 안쪽 여백 | -| `card.glass` | 여행/상태성 카드의 흰색 10% 표면 | -| `card.media` | Recap처럼 이미지/미디어가 중심인 카드 | -| `button.primary` | 56px 라임 CTA | -| `button.icon` | 44px 원형 아이콘 버튼 | -| `control.segmentedShell` | 홈 모드 세그먼트 바깥 컨테이너 | -| `travel.activeCard` | 여행 진행 중 카드 | -| `recap.listCard` | Recap 리스트 카드 | +| Recipe | Role | +| ------------------------ | ------------------------------------- | +| `screen.content` | `px-5 pt-4` 기본 화면 안쪽 여백 | +| `card.glass` | 여행/상태성 카드의 흰색 10% 표면 | +| `card.media` | Recap처럼 이미지/미디어가 중심인 카드 | +| `button.primary` | 56px 라임 CTA | +| `button.icon` | 44px 원형 아이콘 버튼 | +| `control.segmentedShell` | 홈 모드 세그먼트 바깥 컨테이너 | +| `travel.activeCard` | 여행 진행 중 카드 | +| `recap.listCard` | Recap 리스트 카드 | + +프리미티브로 표현 가능한 경우에는 recipe를 직접 붙이기보다 프리미티브를 먼저 씁니다. 예를 들어 버튼은 `soundlogRecipes.button.primary`를 직접 복붙하기보다 `SoundlogButton variant="primary"`를 우선 선택합니다. ## Screen Recipes @@ -172,7 +246,9 @@ import { AppText, soundlogRecipes } from '@/design-system'; ```tsx - Library + + Library + {children} ``` diff --git a/src/api/communityApi.ts b/src/api/communityApi.ts new file mode 100644 index 0000000..1b2f212 --- /dev/null +++ b/src/api/communityApi.ts @@ -0,0 +1,226 @@ +import { + createIdempotencyKey, + requestApi, + shouldAttemptAuthenticatedApi, +} from '@/api/client'; +import type { + CommunityVisibility, + GeoPoint, + MoodTag, + MusicMatch, + RecapItem, + SoundMapPin, + Track, + TravelMateRequest, + TravelMode, + TravelRoom, + TravelRoomMoment, +} from '@/types/domain'; +import { sanitizeTrack } from '@/utils/trackSanitizer'; + +type CreateTravelRoomInput = { + sessionId?: string; + title: string; + visibility?: 'companions' | 'invite_only'; +}; + +type AddTravelRoomMomentInput = { + artistName?: string; + momentLogId?: string; + note?: string; + placeName?: string; + status?: 'accepted' | 'candidate'; + trackId?: string; + trackTitle?: string; +}; + +type UpsertCurrentTrackInput = { + artistName?: string; + location: GeoPoint; + moodTags?: MoodTag[]; + placeName?: string; + sessionId?: string; + trackId?: string; + trackTitle?: string; + travelMode?: TravelMode; + ttlMinutes?: number; + visibility: CommunityVisibility; +}; + +type SoundMapQuery = { + lat?: number; + lng?: number; + mood?: string; + radiusMeters?: number; + state?: string; + visibility?: Exclude; +}; + +function sanitizeSoundMapPin(pin: SoundMapPin): SoundMapPin { + return { + ...pin, + track: pin.track ? sanitizeTrack(pin.track) : undefined, + }; +} + +function sanitizeMusicMatch(match: MusicMatch): MusicMatch { + return { + ...match, + pin: sanitizeSoundMapPin(match.pin), + }; +} + +function sanitizeRoomMoment(moment: TravelRoomMoment): TravelRoomMoment { + return { + ...moment, + track: moment.track ? sanitizeTrack(moment.track) : undefined, + }; +} + +function sanitizeTravelRoom(room: TravelRoom): TravelRoom { + return { + ...room, + moments: room.moments.map(sanitizeRoomMoment), + }; +} + +function trackMomentInput(track?: Track, placeName?: string): AddTravelRoomMomentInput { + return { + artistName: track?.artist, + note: track ? `${track.title}을 공동 Recap 후보로 추가` : undefined, + placeName, + status: 'candidate', + trackId: track?.id, + trackTitle: track?.title, + }; +} + +export const communityApi = { + createTravelRoom: async (input: CreateTravelRoomInput) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + const room = await requestApi('/v1/travel-rooms', { + body: input, + method: 'POST', + }); + + return sanitizeTravelRoom(room); + }, + addCurrentTrackMoment: async (roomId: string, track?: Track, placeName?: string) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + const moment = await requestApi( + `/v1/travel-rooms/${encodeURIComponent(roomId)}/moments`, + { + body: trackMomentInput(track, placeName), + method: 'POST', + }, + ); + + return sanitizeRoomMoment(moment); + }, + createTravelRoomRecap: async ( + roomId: string, + input: { representativeTrackId?: string; templateId?: 'album' | 'film' | 'lp'; title?: string }, + ) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + return requestApi(`/v1/travel-rooms/${encodeURIComponent(roomId)}/recaps`, { + body: input, + idempotencyKey: createIdempotencyKey(`travel-room-recap-${roomId}`), + method: 'POST', + }); + }, + getSoundMap: async (query: SoundMapQuery) => { + if (!shouldAttemptAuthenticatedApi()) { + return []; + } + + const pins = await requestApi('/v1/sound-map', { query }); + + return pins.map(sanitizeSoundMapPin); + }, + getNearbySounds: async (query: SoundMapQuery) => { + if (!shouldAttemptAuthenticatedApi()) { + return []; + } + + const pins = await requestApi>( + '/v1/sound-map/nearby', + { query }, + ); + + return pins.map(sanitizeSoundMapPin); + }, + getMusicMatches: async (query: SoundMapQuery) => { + if (!shouldAttemptAuthenticatedApi()) { + return []; + } + + const matches = await requestApi('/v1/music-matches', { query }); + + return matches.map(sanitizeMusicMatch); + }, + upsertCurrentTrack: async (input: UpsertCurrentTrackInput) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + const pin = await requestApi('/v1/sound-map/current-track', { + body: input, + method: 'POST', + }); + + return sanitizeSoundMapPin(pin); + }, + createTravelMateRequest: async (input: { + messageTemplate?: 'cafe_together' | 'liked_track' | 'walk_together'; + targetPinId?: string; + targetUserId?: string; + }) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + return requestApi('/v1/travel-mate-requests', { + body: { + messageTemplate: input.messageTemplate ?? 'liked_track', + targetPinId: input.targetPinId, + targetUserId: input.targetUserId, + }, + method: 'POST', + }); + }, + blockUser: async (input: { targetPinId?: string; targetUserId?: string }) => { + if (!shouldAttemptAuthenticatedApi()) { + return { accepted: false }; + } + + return requestApi<{ accepted: boolean }>('/v1/community/blocks', { + body: input, + method: 'POST', + }); + }, + reportTarget: async (input: { + details?: string; + reason: 'inappropriate' | 'other' | 'safety' | 'spam'; + requestId?: string; + targetPinId?: string; + targetUserId?: string; + }) => { + if (!shouldAttemptAuthenticatedApi()) { + return { accepted: false }; + } + + return requestApi<{ accepted: boolean }>('/v1/community/reports', { + body: input, + method: 'POST', + }); + }, +}; diff --git a/src/components/travel/CommunityRecapCard.tsx b/src/components/travel/CommunityRecapCard.tsx new file mode 100644 index 0000000..c00c1eb --- /dev/null +++ b/src/components/travel/CommunityRecapCard.tsx @@ -0,0 +1,188 @@ +import { Feather } from '@expo/vector-icons'; +import { useState } from 'react'; +import { View } from 'react-native'; + +import { communityApi } from '@/api/communityApi'; +import { AppText } from '@/components/AppText'; +import { SoundlogButton, SoundlogMetric, SoundlogSurface } from '@/design-system'; +import type { PlaceContext, RecapItem, Track, TravelRoom } from '@/types/domain'; + +type CommunityRecapCardProps = { + currentPlace?: PlaceContext; + currentTrack?: Track; + momentCount: number; + onRecapCreated: (recap: RecapItem) => void; + sessionId: string; + sessionStatus: 'active' | 'ended' | 'idle'; +}; + +export function CommunityRecapCard({ + currentPlace, + currentTrack, + momentCount, + onRecapCreated, + sessionId, + sessionStatus, +}: CommunityRecapCardProps) { + const [room, setRoom] = useState(); + const [isCreatingRoom, setIsCreatingRoom] = useState(false); + const [isAddingMoment, setIsAddingMoment] = useState(false); + const [isCreatingRecap, setIsCreatingRecap] = useState(false); + const [message, setMessage] = useState(); + + const placeName = currentPlace?.title ?? '이번 여행'; + const canUseRoom = sessionStatus !== 'idle'; + + const handleCreateRoom = async () => { + if (!canUseRoom || isCreatingRoom) { + return; + } + + setIsCreatingRoom(true); + setMessage(undefined); + + try { + const nextRoom = await communityApi.createTravelRoom({ + sessionId, + title: `${placeName} 사운드 여행방`, + visibility: 'invite_only', + }); + + if (!nextRoom) { + setMessage('로그인 후 공동 Recap 여행방을 만들 수 있어요.'); + return; + } + + setRoom(nextRoom); + setMessage('초대 코드를 공유하면 동행자가 Moment 후보를 함께 올릴 수 있어요.'); + } catch { + setMessage('공동 여행방을 만들지 못했어요. 잠시 후 다시 시도해주세요.'); + } finally { + setIsCreatingRoom(false); + } + }; + + const handleAddCurrentTrack = async () => { + if (!room || isAddingMoment) { + return; + } + + setIsAddingMoment(true); + setMessage(undefined); + + try { + const moment = await communityApi.addCurrentTrackMoment( + room.id, + currentTrack, + currentPlace?.title, + ); + + if (!moment) { + setMessage('서버에 연결된 로그인 상태에서 후보를 추가할 수 있어요.'); + return; + } + + setRoom({ + ...room, + momentCount: room.momentCount + 1, + moments: [moment, ...room.moments], + }); + setMessage('현재 선택 곡을 공동 Recap 후보에 추가했어요.'); + } catch { + setMessage('후보 추가에 실패했어요. 현재 곡이나 네트워크 상태를 확인해주세요.'); + } finally { + setIsAddingMoment(false); + } + }; + + const handleCreateRecap = async () => { + if (!room || isCreatingRecap) { + return; + } + + setIsCreatingRecap(true); + setMessage(undefined); + + try { + const recap = await communityApi.createTravelRoomRecap(room.id, { + representativeTrackId: currentTrack?.id, + templateId: 'album', + title: `${placeName} 공동 Recap`, + }); + + if (!recap) { + setMessage('공동 Recap 생성은 로그인된 서버 세션에서 사용할 수 있어요.'); + return; + } + + setMessage('공동 Recap을 만들었어요.'); + onRecapCreated(recap); + } catch { + setMessage('공동 Recap 생성에 실패했어요. 후보 Moment가 있는지 확인해주세요.'); + } finally { + setIsCreatingRecap(false); + } + }; + + return ( + + + + + + + + Collaborative Recap + + + 같이 간 사람과 사운드트랙을 모아요 + + + 한 명이 만든 플레이리스트가 아니라, 각자 고른 곡과 순간을 하나의 Recap 후보로 + 합칠 수 있어요. + + + + + + + + + + + + {message ? ( + + {message} + + ) : null} + + + {!room ? ( + void handleCreateRoom()} + /> + ) : ( + + void handleAddCurrentTrack()} + variant="ghost" + /> + void handleCreateRecap()} + /> + + )} + + + ); +} diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx index 2cace19..9269be3 100644 --- a/src/components/travel/TravelScreen.tsx +++ b/src/components/travel/TravelScreen.tsx @@ -17,6 +17,7 @@ import { usePlayerStore } from '@/store/playerStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import type { TravelMode } from '@/types/domain'; +import { CommunityRecapCard } from './CommunityRecapCard'; import { EndTravelConfirmModal } from './EndTravelConfirmModal'; import { LiveSoundMapSection } from './live-sound-map'; import { MomentCard } from './MomentCard'; @@ -196,6 +197,11 @@ export function TravelScreen() { router.push(`/recap-share/${recapId}`); }; + const handleCommunityRecapCreated = async (recap: { id: string }) => { + setSessionRecapId(recap.id); + await queryClient.invalidateQueries({ queryKey: recapQueryKeys.list }); + router.push(`/recap-share/${recap.id}`); + }; return ( @@ -243,6 +249,17 @@ export function TravelScreen() { currentLocation={currentLocation} currentPlace={currentPlace} currentTrack={currentTrack} + selectedMode={selectedMode} + sessionId={session.id} + sessionStatus={session.status} + /> + + void handleCommunityRecapCreated(recap)} + sessionId={session.id} sessionStatus={session.status} /> diff --git a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx index e4fb13c..8f18d66 100644 --- a/src/components/travel/live-sound-map/LiveSoundMapSection.tsx +++ b/src/components/travel/live-sound-map/LiveSoundMapSection.tsx @@ -1,21 +1,31 @@ import { Feather } from '@expo/vector-icons'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Pressable, View } from 'react-native'; +import { communityApi } from '@/api/communityApi'; import { syncRecommendationEvent } from '@/api/recommendationEventApi'; import { AppText } from '@/components/AppText'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import type { GeoPoint, PlaceContext, Track } from '@/types/domain'; +import type { + GeoPoint, + MusicMatch, + PlaceContext, + SoundMapPin as ServerSoundMapPin, + Track, + TravelMode, +} from '@/types/domain'; import { formatPlaceLabel } from '@/utils/placeLabel'; import { OpenLayersSoundMap } from './OpenLayersSoundMap'; import { createSoundMapCenter, createSoundMapPins } from './soundMapData'; -import type { SoundMapVisibility } from './types'; +import type { SoundMapPin, SoundMapVisibility } from './types'; type LiveSoundMapSectionProps = { currentLocation?: GeoPoint; currentPlace?: PlaceContext; currentTrack?: Track; + selectedMode?: TravelMode; + sessionId: string; sessionStatus: 'active' | 'ended' | 'idle'; }; @@ -44,22 +54,136 @@ export function LiveSoundMapSection({ currentLocation, currentPlace, currentTrack, + selectedMode, + sessionId, sessionStatus, }: LiveSoundMapSectionProps) { const [visibility, setVisibility] = useState('companions'); + const [serverPins, setServerPins] = useState([]); + const [matches, setMatches] = useState([]); + const [mapMessage, setMapMessage] = useState(); + const [requestedMatchIds, setRequestedMatchIds] = useState>({}); + const [hiddenMatchIds, setHiddenMatchIds] = useState>({}); + const [pendingMatchId, setPendingMatchId] = useState(); const addEvent = useRecommendationEventStore((state) => state.addEvent); const center = useMemo( () => createSoundMapCenter(currentLocation, currentPlace), [currentLocation, currentPlace], ); - const pins = useMemo( - () => createSoundMapPins({ center, currentTrack, visibility }), - [center, currentTrack, visibility], - ); const isTravelActive = sessionStatus === 'active'; const isLive = isTravelActive && visibility !== 'private'; + const syncLocation = currentLocation ?? currentPlace?.location; const locationLabel = currentPlace?.title ?? formatPlaceLabel(center); const visibilityMessage = visibilityCopy[visibility]; + const pins = useMemo(() => { + const matchScoreByPinId = new Map(matches.map((match) => [match.targetPinId, match.matchScore])); + const serverVisualPins = serverPins.map((pin) => toVisualPin(pin, matchScoreByPinId.get(pin.id))); + const knownPinIds = new Set(serverVisualPins.map((pin) => pin.id)); + const matchVisualPins = matches + .filter((match) => !knownPinIds.has(match.pin.id)) + .map((match) => toVisualPin(match.pin, match.matchScore)); + + if (serverVisualPins.length > 0 || matchVisualPins.length > 0) { + return [...serverVisualPins, ...matchVisualPins]; + } + + return createSoundMapPins({ center, currentTrack, visibility }); + }, [center, currentTrack, matches, serverPins, visibility]); + + useEffect(() => { + if (!isTravelActive) { + setServerPins([]); + setMatches([]); + return; + } + + let ignore = false; + const query = syncLocation + ? { lat: syncLocation.lat, lng: syncLocation.lng, radiusMeters: 3000 } + : { radiusMeters: 3000 }; + + communityApi + .getSoundMap(query) + .then((nextPins) => { + if (!ignore) { + setServerPins(nextPins); + } + }) + .catch(() => { + if (!ignore) { + setMapMessage('사운드맵 서버 핀을 불러오지 못해 로컬 미리보기로 보여드려요.'); + } + }); + + if (visibility === 'nearby') { + communityApi + .getMusicMatches(query) + .then((nextMatches) => { + if (!ignore) { + setMatches(nextMatches); + } + }) + .catch(() => { + if (!ignore) { + setMatches([]); + } + }); + } else { + setMatches([]); + } + + return () => { + ignore = true; + }; + }, [isTravelActive, syncLocation?.lat, syncLocation?.lng, visibility]); + + useEffect(() => { + if (!isLive || !currentTrack || !syncLocation) { + return; + } + + let ignore = false; + + communityApi + .upsertCurrentTrack({ + artistName: currentTrack.artist, + location: syncLocation, + moodTags: [], + placeName: currentPlace?.title, + sessionId, + trackId: currentTrack.id, + trackTitle: currentTrack.title, + travelMode: selectedMode, + visibility, + }) + .then((pin) => { + if (!pin || ignore) { + return; + } + + setServerPins((pins) => [pin, ...pins.filter((item) => item.id !== pin.id)]); + setMapMessage(undefined); + }) + .catch(() => { + if (!ignore) { + setMapMessage('현재 곡 공개 상태를 서버에 저장하지 못했어요.'); + } + }); + + return () => { + ignore = true; + }; + }, [ + currentPlace?.title, + currentTrack?.artist, + currentTrack?.id, + currentTrack?.title, + isLive, + selectedMode, + sessionId, + syncLocation, + visibility, + ]); const handleVisibilityPress = (nextVisibility: SoundMapVisibility) => { setVisibility(nextVisibility); @@ -80,6 +204,55 @@ export function LiveSoundMapSection({ syncRecommendationEvent(event); }; + const handleMateRequest = async (match: MusicMatch) => { + if (pendingMatchId) { + return; + } + + setPendingMatchId(match.id); + setMapMessage(undefined); + + try { + const request = await communityApi.createTravelMateRequest({ + messageTemplate: 'liked_track', + targetPinId: match.targetPinId, + }); + + if (!request) { + setMapMessage('로그인된 서버 세션에서 동행 요청을 보낼 수 있어요.'); + return; + } + + setRequestedMatchIds((state) => ({ ...state, [match.id]: request.id })); + setMapMessage('동행 요청을 보냈어요. 상대가 수락하기 전까지 연락처와 정확한 위치는 숨겨져요.'); + } catch { + setMapMessage('동행 요청을 보내지 못했어요. 잠시 후 다시 시도해주세요.'); + } finally { + setPendingMatchId(undefined); + } + }; + const handleReportMatch = async (match: MusicMatch) => { + try { + await communityApi.reportTarget({ + reason: 'safety', + targetPinId: match.targetPinId, + }); + setMapMessage('신고를 접수했어요. 이 매칭은 계속 볼 수 있지만 정확한 위치와 연락처는 노출되지 않아요.'); + } catch { + setMapMessage('신고를 접수하지 못했어요. 잠시 후 다시 시도해주세요.'); + } + }; + const handleBlockMatch = async (match: MusicMatch) => { + try { + await communityApi.blockUser({ targetPinId: match.targetPinId }); + setHiddenMatchIds((state) => ({ ...state, [match.id]: true })); + setMatches((items) => items.filter((item) => item.id !== match.id)); + setServerPins((items) => items.filter((item) => item.id !== match.targetPinId)); + setMapMessage('차단했어요. 해당 여행자의 공개 음악은 더 이상 추천에 보이지 않아요.'); + } catch { + setMapMessage('차단하지 못했어요. 잠시 후 다시 시도해주세요.'); + } + }; return ( @@ -124,6 +297,12 @@ export function LiveSoundMapSection({ /> + {mapMessage ? ( + + {mapMessage} + + ) : null} + @@ -153,7 +332,7 @@ export function LiveSoundMapSection({ + + {visibility === 'nearby' && matches.length > 0 ? ( + + 주변 음악 취향 매칭 + {matches.filter((match) => !hiddenMatchIds[match.id]).slice(0, 2).map((match) => { + const requested = Boolean(requestedMatchIds[match.id]); + const disabled = requested || pendingMatchId === match.id; + + return ( + + + + + {match.pin.track?.title ?? '공개한 음악'} · {match.matchScore}% + + + {match.pin.alias} · {match.pin.placeName ?? '대략 위치'} · 정확한 좌표 숨김 + + + void handleMateRequest(match)} + > + + {requested ? '요청됨' : pendingMatchId === match.id ? '전송 중' : '동행 요청'} + + + + + void handleReportMatch(match)} + > + 신고 + + void handleBlockMatch(match)} + > + 차단 + + + + ); + })} + + ) : null} ); } + +function toVisualPin(pin: ServerSoundMapPin, matchScore?: number): SoundMapPin { + const kind = pin.isMine ? 'me' : pin.visibility === 'nearby' ? 'nearby' : 'companion'; + + return { + artistName: pin.track?.artist ?? pin.alias, + id: pin.id, + kind, + label: pin.isMine ? '나' : matchScore ? `${matchScore}%` : pin.visibility === 'nearby' ? '취향' : '동행자', + location: pin.location, + matchScore, + subtitle: pin.placeName ?? (pin.visibility === 'nearby' ? '대략 위치 공개' : '동행자 공개'), + trackTitle: pin.track?.title ?? '선택한 음악 없음', + }; +} diff --git a/src/design-system/index.ts b/src/design-system/index.ts index 001a830..1e3f95d 100644 --- a/src/design-system/index.ts +++ b/src/design-system/index.ts @@ -2,6 +2,17 @@ export { soundlogDesignTokens } from './tokens'; export type { SoundlogDesignTokens } from './tokens'; export { soundlogRecipes } from './recipes'; export type { SoundlogRecipes } from './recipes'; +export { + SoundlogButton, + SoundlogMetric, + SoundlogSectionHeader, + SoundlogSurface, +} from './primitives'; +export type { + SoundlogButtonSize, + SoundlogButtonVariant, + SoundlogSurfaceVariant, +} from './primitives'; export { AppText } from '@/components/AppText'; export { BrandLogo } from '@/components/BrandLogo'; diff --git a/src/design-system/primitives.tsx b/src/design-system/primitives.tsx new file mode 100644 index 0000000..73a84ce --- /dev/null +++ b/src/design-system/primitives.tsx @@ -0,0 +1,270 @@ +import { Feather } from '@expo/vector-icons'; +import type { ComponentProps, PropsWithChildren, ReactNode } from 'react'; +import { Pressable, PressableProps, View, ViewProps } from 'react-native'; + +import { AppText } from '@/components/AppText'; + +type FeatherIconName = ComponentProps['name']; + +type ClassValue = false | null | string | undefined; + +function mergeClasses(...classes: ClassValue[]) { + return classes.filter(Boolean).join(' '); +} + +export type SoundlogButtonVariant = + | 'danger' + | 'ghost' + | 'primary' + | 'secondary'; +export type SoundlogButtonSize = 'compact' | 'default'; + +type SoundlogButtonProps = Omit & { + className?: string; + fullWidth?: boolean; + iconName?: FeatherIconName; + label: string; + size?: SoundlogButtonSize; + textClassName?: string; + variant?: SoundlogButtonVariant; +}; + +const buttonSizeClass: Record = { + compact: 'h-11 px-4', + default: 'h-14 px-5', +}; + +const buttonVariantClass: Record = { + danger: 'bg-soundlog-warning', + ghost: 'border border-white/10 bg-white/10', + primary: 'bg-soundlog-lime', + secondary: 'border border-white/15 bg-transparent', +}; + +const buttonTextClass: Record = { + danger: 'text-black', + ghost: 'text-white', + primary: 'text-soundlog-inverse', + secondary: 'text-white', +}; + +function getButtonIconColor(variant: SoundlogButtonVariant) { + return variant === 'primary' || variant === 'danger' ? '#090515' : '#FFFFFF'; +} + +export function SoundlogButton({ + accessibilityLabel, + className, + disabled, + fullWidth = false, + iconName, + label, + size = 'default', + textClassName, + variant = 'primary', + ...props +}: SoundlogButtonProps) { + const isDisabled = Boolean(disabled); + + return ( + + {iconName ? ( + + ) : null} + + {label} + + + ); +} + +export type SoundlogSurfaceVariant = + | 'base' + | 'elevated' + | 'glass' + | 'hero' + | 'media'; + +type SoundlogSurfaceProps = PropsWithChildren< + ViewProps & { + className?: string; + variant?: SoundlogSurfaceVariant; + } +>; + +const surfaceClass: Record = { + base: 'rounded-[20px] border border-white/10 bg-soundlog-card p-4', + elevated: 'rounded-[20px] bg-soundlog-elevated/80 p-2', + glass: 'rounded-[22px] border border-white/10 bg-white/10 p-4', + hero: 'rounded-[30px] border border-white/10 bg-white/10 p-6', + media: 'overflow-hidden rounded-[26px] border border-white/10 bg-white/10', +}; + +export function SoundlogSurface({ + children, + className, + variant = 'glass', + ...props +}: SoundlogSurfaceProps) { + return ( + + {children} + + ); +} + +type SoundlogSectionHeaderProps = { + actionIconName?: FeatherIconName; + actionLabel?: string; + className?: string; + description?: string; + eyebrow?: string; + onActionPress?: () => void; + title: string; +}; + +export function SoundlogSectionHeader({ + actionIconName, + actionLabel, + className, + description, + eyebrow, + onActionPress, + title, +}: SoundlogSectionHeaderProps) { + const hasAction = Boolean(actionLabel && onActionPress); + + return ( + + + {eyebrow ? ( + + {eyebrow} + + ) : null} + + {title} + + {description ? ( + + {description} + + ) : null} + + + {hasAction ? ( + + {actionIconName ? ( + + ) : null} + + {actionLabel} + + + ) : null} + + ); +} + +type SoundlogMetricProps = { + className?: string; + compact?: boolean; + label: string; + tone?: 'default' | 'lime' | 'muted'; + value: ReactNode; +}; + +const metricToneClass: Record< + NonNullable, + string +> = { + default: 'text-white', + lime: 'text-soundlog-lime', + muted: 'text-white/70', +}; + +export function SoundlogMetric({ + className, + compact = false, + label, + tone = 'default', + value, +}: SoundlogMetricProps) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/src/types/domain.ts b/src/types/domain.ts index 0629188..08f4e7b 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -129,3 +129,84 @@ export type RecapShare = { recordedAt: string; shareImageUrl?: string; }; + +export type CommunityVisibility = 'companions' | 'nearby' | 'private'; + +export type TravelRoomMember = { + id: string; + userId: string; + role: 'member' | 'owner'; + displayName?: string; + joinedAt: string; +}; + +export type TravelRoomMoment = { + id: string; + userId: string; + momentLogId?: string; + note?: string; + placeName?: string; + status: 'accepted' | 'candidate'; + track?: Track; + createdAt: string; +}; + +export type TravelRoom = { + id: string; + title: string; + inviteCode: string; + sessionId?: string; + visibility: 'companions' | 'invite_only'; + memberCount: number; + momentCount: number; + members: TravelRoomMember[]; + moments: TravelRoomMoment[]; + createdAt: string; + updatedAt: string; +}; + +export type SoundMapProfile = { + preferredGenres: string[]; + preferredMoods: string[]; + travelStyles: string[]; +}; + +export type SoundMapPin = { + id: string; + userId?: string; + alias: string; + isMine: boolean; + visibility: CommunityVisibility; + location: GeoPoint; + moodTags: MoodTag[]; + placeName?: string; + profile: SoundMapProfile; + sessionId?: string; + track?: Track; + travelMode?: TravelMode; + expiresAt: string; + updatedAt: string; +}; + +export type MusicMatch = { + id: string; + pin: SoundMapPin; + targetPinId: string; + matchScore: number; + safety: { + exactLocationHidden: boolean; + firstMessageTemplates: Array<'cafe_together' | 'liked_track' | 'walk_together'>; + contactHiddenUntilAccepted: boolean; + }; +}; + +export type TravelMateRequest = { + id: string; + requesterId: string; + targetUserId: string; + targetPinId?: string; + messageTemplate: 'cafe_together' | 'liked_track' | 'walk_together'; + status: 'accepted' | 'cancelled' | 'declined' | 'expired' | 'pending'; + createdAt: string; + updatedAt: string; +}; From 2ebfd0612704886fd721dd06a31e0fd1cd0ec4f1 Mon Sep 17 00:00:00 2001 From: manNomi Date: Wed, 8 Jul 2026 11:51:52 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=97=AC=ED=96=89=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20API=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/travelSessionApi.ts | 48 +++++++++++++++++++ .../moment-capture/MomentCaptureScreen.tsx | 2 +- src/components/travel/TravelScreen.tsx | 45 +++++++++++++++-- src/store/travelSessionStore.ts | 8 ++-- src/types/domain.ts | 9 +++- 5 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 src/api/travelSessionApi.ts diff --git a/src/api/travelSessionApi.ts b/src/api/travelSessionApi.ts new file mode 100644 index 0000000..a0a5904 --- /dev/null +++ b/src/api/travelSessionApi.ts @@ -0,0 +1,48 @@ +import { requestApi, shouldAttemptAuthenticatedApi } from '@/api/client'; +import type { GeoPoint, TravelMode } from '@/types/domain'; + +export type TravelSessionDto = { + endedAt?: string; + id: string; + startedAt?: string; + status: 'active' | 'ended'; + travelMode?: TravelMode; +}; + +export const travelSessionApi = { + createTravelSession: async (input: { + location?: GeoPoint; + startedAt?: string; + travelMode?: TravelMode; + }) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + return requestApi('/v1/travel-sessions', { + body: input, + method: 'POST', + }); + }, + + endTravelSession: async ( + sessionId: string, + input: { + endedAt?: string; + location?: GeoPoint; + } = {}, + ) => { + if (!shouldAttemptAuthenticatedApi()) { + return undefined; + } + + return requestApi(`/v1/travel-sessions/${encodeURIComponent(sessionId)}`, { + body: { + endedAt: input.endedAt, + location: input.location, + status: 'ended', + }, + method: 'PATCH', + }); + }, +}; diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 2156b31..3701be2 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -190,7 +190,7 @@ export function MomentCaptureScreen() { startSession()} > 여행 시작하고 촬영하기 diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx index 9269be3..e30e1c5 100644 --- a/src/components/travel/TravelScreen.tsx +++ b/src/components/travel/TravelScreen.tsx @@ -7,6 +7,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { momentLogApi } from '@/api/momentLogApi'; import { recapApi } from '@/api/recapApi'; import { recapQueryKeys } from '@/api/recapQueries'; +import { travelSessionApi } from '@/api/travelSessionApi'; import { MiniPlayer } from '@/components/MiniPlayer'; import { RecapListCard } from '@/components/recap/RecapListCard'; import { Screen } from '@/components/Screen'; @@ -35,6 +36,7 @@ export function TravelScreen() { const queryClient = useQueryClient(); const [isModeSheetVisible, setIsModeSheetVisible] = useState(false); const [isEndConfirmVisible, setIsEndConfirmVisible] = useState(false); + const [isStartingTravel, setIsStartingTravel] = useState(false); const [isCreatingRecap, setIsCreatingRecap] = useState(false); const [recapMessage, setRecapMessage] = useState(); const [, setClockTick] = useState(0); @@ -98,13 +100,36 @@ export function TravelScreen() { const handleSelectMode = (mode: TravelMode) => { setMode(mode); }; - const handleStartTravel = () => { + const handleStartTravel = async () => { + if (isStartingTravel) { + return; + } + + const nextMode = selectedMode ?? 'cafe'; + if (!selectedMode) { - setMode('cafe'); + setMode(nextMode); } - startSession(); - setIsModeSheetVisible(false); + setIsStartingTravel(true); + + try { + const serverSession = await travelSessionApi.createTravelSession({ + location: currentLocation ?? currentPlace?.location, + travelMode: nextMode, + }); + + startSession({ + id: serverSession?.id, + startedAt: serverSession?.startedAt, + }); + } catch { + startSession(); + setRecapMessage('서버 여행 세션 연결에 실패해서 로컬 세션으로 먼저 시작했어요.'); + } finally { + setIsStartingTravel(false); + setIsModeSheetVisible(false); + } }; const retryMomentLog = async (log: MomentLog) => { if (log.syncStatus === 'pending') { @@ -147,11 +172,21 @@ export function TravelScreen() { const endedSessionId = session.id; const logsForSession = sessionLogs; const localRecapId = createSessionRecapId(endedSessionId); + const endedAt = new Date().toISOString(); setRecapMessage(undefined); endSession(); setIsEndConfirmVisible(false); + try { + await travelSessionApi.endTravelSession(endedSessionId, { + endedAt, + location: currentLocation ?? currentPlace?.location, + }); + } catch { + setRecapMessage('서버 여행 세션 종료 동기화에 실패했지만 로컬 Recap은 계속 만들 수 있어요.'); + } + if (logsForSession.length === 0) { setSessionRecapId(undefined); setRecapMessage('저장한 Moment가 없어 Recap 대신 빈 기록 화면으로 이동할 수 있어요.'); @@ -330,7 +365,7 @@ export function TravelScreen() { setIsModeSheetVisible(false)} onSelectMode={handleSelectMode} - onStart={handleStartTravel} + onStart={() => void handleStartTravel()} selectedMode={selectedMode} visible={isModeSheetVisible} /> diff --git a/src/store/travelSessionStore.ts b/src/store/travelSessionStore.ts index fba6608..875ce8a 100644 --- a/src/store/travelSessionStore.ts +++ b/src/store/travelSessionStore.ts @@ -36,7 +36,7 @@ type TravelSessionState = { setMode: (mode: TravelMode) => void; setRecommendationMode: (mode: MusicRecommendationMode) => void; setSessionRecapId: (recapId?: string) => void; - startSession: () => void; + startSession: (session?: Partial>) => void; }; const idleSession: TravelSession = { @@ -93,11 +93,11 @@ export const useTravelSessionStore = create()( recapId, }, })), - startSession: () => + startSession: (session) => set({ session: { - id: `session-${Date.now()}`, - startedAt: new Date().toISOString(), + id: session?.id ?? `session-${Date.now()}`, + startedAt: session?.startedAt ?? new Date().toISOString(), status: 'active', }, }), diff --git a/src/types/domain.ts b/src/types/domain.ts index 08f4e7b..cf17320 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -146,8 +146,15 @@ export type TravelRoomMoment = { momentLogId?: string; note?: string; placeName?: string; - status: 'accepted' | 'candidate'; + status: 'accepted' | 'candidate' | 'rejected'; track?: Track; + commentCount?: number; + comments?: Array<{ + id: string; + userId: string; + body: string; + createdAt: string; + }>; createdAt: string; };