diff --git a/README.md b/README.md index 8947e02..7a14035 100644 --- a/README.md +++ b/README.md @@ -41,23 +41,23 @@ EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS=playlist.detail npm run web 실제 서버와 연동할 때는 Expo 환경변수로 API base URL을 지정합니다. 이 값이 없으면 mock으로 돌아가지 않고 API URL 누락 오류를 표시합니다. ```bash -EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog npm run web +EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=https://api.soundlog.shop npm run ios ``` -Vercel web 배포는 브라우저 API 호출을 같은 origin에서 처리하도록 -`https://soundlog.shop/api/soundlog` rewrite proxy를 사용합니다. 브라우저와 네이티브 앱은 별도 `api` 서브도메인을 쓰지 않습니다. Vercel 내부 rewrite 대상은 -`SOUNDLOG_API_ORIGIN` 환경변수로 관리하며, 이 값은 최신 SoundLogServer origin이어야 합니다. `vercel.mjs`의 build command가 -`EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server`와 -`EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. +Soundlog는 웹 서비스를 배포하지 않습니다. 운영 배포 대상은 Expo/EAS로 빌드한 iOS·Android 네이티브 앱이며, 앱은 Vercel 프록시 없이 GCP의 `https://api.soundlog.shop`을 직접 호출합니다. 저장소의 web·Vercel 관련 설정은 로컬 호환성 확인과 과거 실험용 자료일 뿐 운영 배포 경로에 포함하지 않습니다. -네이티브 실배포 빌드는 `https://soundlog.shop/api/soundlog` HTTPS 프록시를 사용합니다. 로그인은 Soundlog 자체 이메일/비밀번호 계정으로 처리합니다. +네이티브 실배포 빌드는 `api.soundlog.shop` 서브도메인을 직접 호출합니다(GCP VM 위 Caddy가 HTTPS를 종료). 로그인은 Soundlog 자체 이메일/비밀번호 계정으로 처리합니다. ## 테스트 설치 빌드 -`development`, `preview` EAS profile은 HTTPS Vercel API proxy를 바라보도록 설정되어 있습니다. +`development`, `preview` EAS profile은 `api.soundlog.shop`을 직접 바라보도록 설정되어 있습니다. -- Web: `https://soundlog.shop` -- API: `https://soundlog.shop/api/soundlog` +본인 iPhone을 USB로 연결해 최신 코드를 직접 테스트할 때는 [iPhone 실기기 테스트 가이드](docs/deployment/IOS_PHYSICAL_DEVICE_TESTING.md)를 먼저 확인합니다. 실기기에서 `127.0.0.1`은 Mac이 아니라 iPhone 자신이므로, 로컬 서버를 사용할 때는 Mac의 LAN IP를 앱 빌드 환경변수로 지정해야 합니다. + +- API: `https://api.soundlog.shop` +- 개인정보 처리방침: `https://api.soundlog.shop/legal/privacy` +- 서비스 이용약관: `https://api.soundlog.shop/legal/terms` +- 고객지원: `https://api.soundlog.shop/support` - API source: `server` - auth: Soundlog 자체 이메일/비밀번호 로그인 - iOS/Android: HTTPS API만 사용 @@ -67,7 +67,7 @@ Mock API로 되돌리는 런타임 경로는 제거했습니다. 화면 상태 API origin이 최신 서버인지 확인하려면 아래처럼 실행합니다. ```bash -SOUNDLOG_API_ORIGIN=http://:4000 npm run check:api-origin +SOUNDLOG_API_ORIGIN=https://api.soundlog.shop npm run check:api-origin ``` 이 검사는 로그인 필수 API도 함께 확인하므로 `SOUNDLOG_CHECK_EMAIL`, @@ -80,7 +80,7 @@ Android 지인 테스트용 내부 배포 빌드는 아래 명령으로 생성 npx eas build --profile preview --platform android ``` -iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/TestFlight에 올릴 production profile도 현재는 `https://soundlog.shop/api/soundlog`을 사용합니다. +iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/TestFlight에 올릴 production profile도 현재는 `https://api.soundlog.shop`을 사용합니다. ## 문서 @@ -93,7 +93,8 @@ iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/Test - [UI 피드백 루프 운영 문서](docs/codex/UI_FEEDBACK_LOOP.md) - [개발용 테스트 매니저](docs/codex/TEST_MANAGER.md) - [PR 전용 개발 흐름](docs/codex/PR_ONLY_WORKFLOW.md) -- [soundlog.shop 배포 설정](docs/deployment/SOUNDLOG_SHOP_DOMAIN.md) +- [GCP 앱 배포 도메인 설정](docs/deployment/SOUNDLOG_SHOP_DOMAIN.md) +- [iPhone 실기기 테스트 가이드](docs/deployment/IOS_PHYSICAL_DEVICE_TESTING.md) - [Mock Server 안내](src/mock-server/README.md) ## Codex로 개발할 때 diff --git a/app.config.js b/app.config.js index ff0e23c..583fe16 100644 --- a/app.config.js +++ b/app.config.js @@ -9,6 +9,7 @@ const baseConfig = { ios: { infoPlist: { CFBundleAllowMixedLocalizations: true, + ITSAppUsesNonExemptEncryption: false, NSCameraUsageDescription: 'Soundlog가 여행 순간을 사진으로 기록하기 위해 카메라 권한이 필요합니다.', NSFaceIDUsageDescription: @@ -20,8 +21,77 @@ const baseConfig = { NSPhotoLibraryUsageDescription: 'Soundlog가 리캡 이미지를 저장하기 위해 사진 접근 권한이 필요합니다.', }, - supportsTablet: true, + supportsTablet: false, bundleIdentifier: 'com.mannomi.soundlog', + // Declares data actually collected and linked to the account (name, email, + // account ID, precise location, photos, user-entered content, and in-app + // interactions). None of it is used for cross-app/company ad tracking, so + // NSPrivacyTracking stays false. The + // NSPrivacyAccessedAPITypes entries mirror the reasons already present in the + // generated ios/Soundlog/PrivacyInfo.xcprivacy (UserDefaults, FileTimestamp, + // SystemBootTime) so they survive `expo prebuild --clean`. + privacyManifests: { + NSPrivacyTracking: false, + NSPrivacyTrackingDomains: [], + NSPrivacyCollectedDataTypes: [ + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeEmailAddress', + NSPrivacyCollectedDataTypeLinked: true, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality'], + }, + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeName', + NSPrivacyCollectedDataTypeLinked: true, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality'], + }, + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeUserID', + NSPrivacyCollectedDataTypeLinked: true, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality'], + }, + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypePreciseLocation', + NSPrivacyCollectedDataTypeLinked: true, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality'], + }, + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypePhotosorVideos', + NSPrivacyCollectedDataTypeLinked: true, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality'], + }, + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeOtherUserContent', + NSPrivacyCollectedDataTypeLinked: true, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAppFunctionality'], + }, + { + NSPrivacyCollectedDataType: 'NSPrivacyCollectedDataTypeProductInteraction', + NSPrivacyCollectedDataTypeLinked: true, + NSPrivacyCollectedDataTypeTracking: false, + NSPrivacyCollectedDataTypePurposes: ['NSPrivacyCollectedDataTypePurposeAnalytics'], + }, + ], + NSPrivacyAccessedAPITypes: [ + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults', + NSPrivacyAccessedAPITypeReasons: ['CA92.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryFileTimestamp', + NSPrivacyAccessedAPITypeReasons: ['C617.1'], + }, + { + NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategorySystemBootTime', + NSPrivacyAccessedAPITypeReasons: ['35F9.1'], + }, + ], + }, }, android: { package: 'com.mannomi.soundlog', @@ -58,6 +128,8 @@ const baseConfig = { { cameraPermission: 'Soundlog가 여행 순간을 사진으로 기록하기 위해 카메라 권한이 필요합니다.', + microphonePermission: false, + recordAudioAndroid: false, }, ], [ @@ -65,6 +137,13 @@ const baseConfig = { { locationWhenInUsePermission: 'Soundlog가 현재 장소에 맞는 음악을 추천하고 여행 순간의 위치를 기록하기 위해 위치 권한이 필요합니다.', + locationAlwaysAndWhenInUsePermission: false, + locationAlwaysPermission: false, + motionUsagePermission: false, + isIosBackgroundLocationEnabled: false, + isAndroidBackgroundLocationEnabled: false, + isAndroidForegroundServiceEnabled: false, + isAndroidMotionActivityEnabled: false, }, ], 'expo-sharing', @@ -79,7 +158,20 @@ const baseConfig = { ], 'expo-secure-store', 'expo-image', - ['expo-build-properties', {}], + // Pin Android SDK versions explicitly instead of relying on the Expo SDK + // template default, so Play Store's targetSdk requirement doesn't silently + // drift on an SDK upgrade. Values match the current expo-modules-core + // default for Expo SDK 56 (confirmed in + // node_modules/expo-modules-core/expo-module-gradle-plugin/.../ProjectConfiguration.kt). + [ + 'expo-build-properties', + { + android: { + compileSdkVersion: 36, + targetSdkVersion: 36, + }, + }, + ], ], experiments: { typedRoutes: true, diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index 6faed61..5288f94 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -150,7 +150,7 @@ export default function MyScreen() { onPress={() => router.push({ pathname: '/recap', - params: { view: 'all' }, + params: { view: 'mine' }, } as never) } /> diff --git a/app/auth/callback.tsx b/app/auth/callback.tsx index 8a1b52c..9b401b7 100644 --- a/app/auth/callback.tsx +++ b/app/auth/callback.tsx @@ -1,3 +1,4 @@ +import { Feather } from '@expo/vector-icons'; import { router } from 'expo-router'; import { Pressable, View } from 'react-native'; @@ -5,8 +6,6 @@ import { AppText } from '@/components/AppText'; import { IconButton } from '@/components/IconButton'; import { PageHeader } from '@/components/PageHeader'; import { Screen } from '@/components/Screen'; -import { SectionTitle } from '@/components/SectionTitle'; -import { SettingsRow } from '@/components/SettingsRow'; export default function AuthCallbackScreen() { return ( @@ -20,22 +19,22 @@ export default function AuthCallbackScreen() { onPress={() => router.replace('/auth/login' as never)} /> } - title="로그인 연결" + title="로그인" /> - - - + + + + + + 로그인을 완료하지 못했어요 + + + 로그인 정보가 만료되었거나 올바르지 않습니다.{`\n`}이메일과 + 비밀번호를 다시 확인해주세요. + - - 로그인 화면에서 이메일과 비밀번호를 입력해 다시 진행해주세요. - - - - router.replace("/onboarding" as never)} - /> - } - title="Soundlog" - /> + router.replace("/onboarding" as never)} + /> + } + title={mode === "login" ? "로그인" : "회원가입"} + /> - - 계정으로 계속하기 + + + {mode === "login" + ? "다시 만나서 반가워요" + : "Soundlog를 시작해볼까요?"} - - 이메일 계정으로 취향, 좋아요, 리캡, 여행 로그를 서버에 동기화할 수 - 있어요. Soundlog 이용은 로그인 후 시작할 수 있습니다. + + {mode === "login" + ? "이메일과 비밀번호를 입력해주세요." + : "기록을 안전하게 보관할 계정을 만들어주세요."} - - - - - - - - {(["login", "register"] as const).map((item) => { - const isActive = mode === item; - - return ( - handleModePress(item)} - > - - {item === "login" ? "로그인" : "가입"} - - - ); - })} - - + {mode === "register" ? ( + + + 이름 + + + + ) : null} + + + + 이메일 + - ) : null} - - + - { - void handleSubmit(); - }} - placeholder="비밀번호" - placeholderTextColor="rgba(255,255,255,0.35)" - returnKeyType="done" - secureTextEntry - textContentType={mode === "login" ? "password" : "newPassword"} - value={password} - /> + + + 비밀번호 + + + { + void handleSubmit(); + }} + placeholder="8자 이상 입력해주세요" + placeholderTextColor="rgba(255,255,255,0.38)" + returnKeyType="done" + secureTextEntry={!isPasswordVisible} + textContentType={mode === "login" ? "password" : "newPassword"} + value={password} + /> + setIsPasswordVisible((visible) => !visible)} + > + + + + {mode === "register" ? ( + + 비밀번호는 8자 이상이어야 합니다. + + ) : null} + {mode === "register" ? ( { setHasAcceptedRequiredTerms((accepted) => !accepted); @@ -266,7 +269,7 @@ export default function LoginScreen() { { void handleSubmit(); @@ -281,13 +284,28 @@ export default function LoginScreen() { - - - {mode === "register" - ? "필수 약관에 동의한 뒤 계정을 만들 수 있어요." - : "로그인하면 기존 계정의 설정과 기록을 불러옵니다."} + + + {mode === "login" + ? "계정이 없으신가요?" + : "이미 계정이 있으신가요?"} - + + handleModePress(mode === "login" ? "register" : "login") + } + > + + {mode === "login" ? "회원가입" : "로그인"} + + + + + + router.push("/legal/terms" as never)} diff --git a/app/legal/privacy.tsx b/app/legal/privacy.tsx index ee3747d..81fc55f 100644 --- a/app/legal/privacy.tsx +++ b/app/legal/privacy.tsx @@ -4,7 +4,7 @@ import { SOUNDLOG_SUPPORT_EMAIL } from "@/constants/legal"; const privacySections = [ { title: "수집하는 정보", - body: "Soundlog는 자체 계정 가입과 로그인을 위해 사용자가 입력한 이름, 이메일, 비밀번호 인증 정보를 처리할 수 있습니다. 사용자가 입력한 음악 취향, 여행 스타일, 동행 유형, 좋아요와 저장한 음악, 리캡과 여행 로그 생성에 필요한 사진, 위치, 시간, 장소, 음악 정보를 저장할 수 있습니다.", + body: "Soundlog는 이메일 계정 가입과 로그인을 위해 사용자가 입력한 이름, 이메일, 비밀번호 인증 정보를 처리할 수 있습니다. 사용자가 입력한 음악 취향, 여행 스타일, 동행 유형, 좋아요와 저장한 음악, 리캡과 여행 로그 생성에 필요한 사진, 위치, 시간, 장소, 음악 정보를 저장할 수 있습니다.", }, { title: "위치와 사진 권한", @@ -16,7 +16,7 @@ const privacySections = [ }, { title: "제3자 서비스", - body: "로그인에는 Soundlog 자체 계정이 사용되고, 장소 정보에는 공공 관광 데이터 API가 사용될 수 있습니다. Soundlog는 음악 추천과 기록 UI를 앱 안에서 제공하며, 음원 재생 서비스 계정 정보는 수집하지 않습니다.", + body: "로그인에는 이메일 계정이 사용되고, 장소 정보에는 공공 관광 데이터 API가 사용될 수 있습니다. Soundlog는 음악 추천과 기록 UI를 앱 안에서 제공하며, 음원 재생 서비스 계정 정보는 수집하지 않습니다.", }, { title: "보관과 삭제", diff --git a/artifacts/screenshots/2026-07-14-all-pages/01-map.png b/artifacts/screenshots/2026-07-14-all-pages/01-map.png new file mode 100644 index 0000000..c6c0143 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/01-map.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/02-music-recommendation.png b/artifacts/screenshots/2026-07-14-all-pages/02-music-recommendation.png new file mode 100644 index 0000000..678675b Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/02-music-recommendation.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/03-camera.png b/artifacts/screenshots/2026-07-14-all-pages/03-camera.png new file mode 100644 index 0000000..891ce0b Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/03-camera.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/04-recap-create-review.png b/artifacts/screenshots/2026-07-14-all-pages/04-recap-create-review.png new file mode 100644 index 0000000..a3f09bc Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/04-recap-create-review.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/05-log-others.png b/artifacts/screenshots/2026-07-14-all-pages/05-log-others.png new file mode 100644 index 0000000..7c9a9f7 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/05-log-others.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/06-log-all.png b/artifacts/screenshots/2026-07-14-all-pages/06-log-all.png new file mode 100644 index 0000000..7304e14 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/06-log-all.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/07-my.png b/artifacts/screenshots/2026-07-14-all-pages/07-my.png new file mode 100644 index 0000000..c60fad0 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/07-my.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/08-library-liked.png b/artifacts/screenshots/2026-07-14-all-pages/08-library-liked.png new file mode 100644 index 0000000..9c3be7c Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/08-library-liked.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/09-library-saved.png b/artifacts/screenshots/2026-07-14-all-pages/09-library-saved.png new file mode 100644 index 0000000..d7bea9d Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/09-library-saved.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/10-library-playlists.png b/artifacts/screenshots/2026-07-14-all-pages/10-library-playlists.png new file mode 100644 index 0000000..1433b96 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/10-library-playlists.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/11-playlist-detail.png b/artifacts/screenshots/2026-07-14-all-pages/11-playlist-detail.png new file mode 100644 index 0000000..a46a9a8 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/11-playlist-detail.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/12-recap-detail.png b/artifacts/screenshots/2026-07-14-all-pages/12-recap-detail.png new file mode 100644 index 0000000..a1ba460 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/12-recap-detail.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/13-travel-room.png b/artifacts/screenshots/2026-07-14-all-pages/13-travel-room.png new file mode 100644 index 0000000..ae460de Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/13-travel-room.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/14-privacy.png b/artifacts/screenshots/2026-07-14-all-pages/14-privacy.png new file mode 100644 index 0000000..ded94b3 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/14-privacy.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/15-terms.png b/artifacts/screenshots/2026-07-14-all-pages/15-terms.png new file mode 100644 index 0000000..c8ff006 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/15-terms.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/16-onboarding.png b/artifacts/screenshots/2026-07-14-all-pages/16-onboarding.png new file mode 100644 index 0000000..acaaa91 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/16-onboarding.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/17-login.png b/artifacts/screenshots/2026-07-14-all-pages/17-login.png new file mode 100644 index 0000000..c1936cd Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/17-login.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/18-register.png b/artifacts/screenshots/2026-07-14-all-pages/18-register.png new file mode 100644 index 0000000..ba90954 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/18-register.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/19-auth-callback.png b/artifacts/screenshots/2026-07-14-all-pages/19-auth-callback.png new file mode 100644 index 0000000..a6c2a16 Binary files /dev/null and b/artifacts/screenshots/2026-07-14-all-pages/19-auth-callback.png differ diff --git a/artifacts/screenshots/2026-07-14-all-pages/index.html b/artifacts/screenshots/2026-07-14-all-pages/index.html new file mode 100644 index 0000000..c427e36 --- /dev/null +++ b/artifacts/screenshots/2026-07-14-all-pages/index.html @@ -0,0 +1,102 @@ + + + + + + Soundlog iOS Screenshots - 2026-07-14 + + + +
+

Soundlog iOS 전체 페이지

+

iPhone 16 Pro · iOS 18.0 · 1206 × 2622 · 2026-07-14

+
+
+
지도
01. 지도
+
음악추천
02. 음악추천
+
카메라
03. 카메라
+
리캡 만들기
04. 리캡 만들기
+
다른 사람 로그
05. 다른 사람 로그
+
모든 사람 로그
06. 모든 사람 로그
+
마이
07. 마이
+
좋아요 보관함
08. 보관함 - 좋아요
+
저장곡 보관함
09. 보관함 - 저장곡
+
플레이리스트 보관함
10. 보관함 - 플레이리스트
+
플레이리스트 상세
11. 플레이리스트 상세
+
리캡 상세
12. 리캡 상세
+
공동 여행방
13. 공동 여행방
+
개인정보 처리방침
14. 개인정보 처리방침
+
서비스 이용약관
15. 서비스 이용약관
+
온보딩
16. 온보딩
+
로그인
17. 로그인
+
회원가입
18. 회원가입
+
로그인 콜백 안내
19. 로그인 콜백 안내
+
+ + diff --git a/artifacts/screenshots/2026-07-20-design-feedback/01-map-travel-mode.png b/artifacts/screenshots/2026-07-20-design-feedback/01-map-travel-mode.png new file mode 100644 index 0000000..2942c31 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/01-map-travel-mode.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/05-log-tabs.png b/artifacts/screenshots/2026-07-20-design-feedback/05-log-tabs.png new file mode 100644 index 0000000..ce871a5 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/05-log-tabs.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/09-library-saved-swipe-delete.png b/artifacts/screenshots/2026-07-20-design-feedback/09-library-saved-swipe-delete.png new file mode 100644 index 0000000..21bf379 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/09-library-saved-swipe-delete.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/11-playlist-detail-transparent-sheet.png b/artifacts/screenshots/2026-07-20-design-feedback/11-playlist-detail-transparent-sheet.png new file mode 100644 index 0000000..abcb633 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/11-playlist-detail-transparent-sheet.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/15-terms-white-content.png b/artifacts/screenshots/2026-07-20-design-feedback/15-terms-white-content.png new file mode 100644 index 0000000..9bad6a1 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/15-terms-white-content.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/16-1-onboarding-location.png b/artifacts/screenshots/2026-07-20-design-feedback/16-1-onboarding-location.png new file mode 100644 index 0000000..b98ba33 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/16-1-onboarding-location.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/16-2-onboarding-recap.png b/artifacts/screenshots/2026-07-20-design-feedback/16-2-onboarding-recap.png new file mode 100644 index 0000000..e6425b1 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/16-2-onboarding-recap.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/16-3-onboarding-travel-log.png b/artifacts/screenshots/2026-07-20-design-feedback/16-3-onboarding-travel-log.png new file mode 100644 index 0000000..749fa67 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/16-3-onboarding-travel-log.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/17-login.png b/artifacts/screenshots/2026-07-20-design-feedback/17-login.png new file mode 100644 index 0000000..75e0d16 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/17-login.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/18-register.png b/artifacts/screenshots/2026-07-20-design-feedback/18-register.png new file mode 100644 index 0000000..8d99438 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/18-register.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/19-auth-callback.png b/artifacts/screenshots/2026-07-20-design-feedback/19-auth-callback.png new file mode 100644 index 0000000..ba8b457 Binary files /dev/null and b/artifacts/screenshots/2026-07-20-design-feedback/19-auth-callback.png differ diff --git a/artifacts/screenshots/2026-07-20-design-feedback/index.html b/artifacts/screenshots/2026-07-20-design-feedback/index.html new file mode 100644 index 0000000..4ee3273 --- /dev/null +++ b/artifacts/screenshots/2026-07-20-design-feedback/index.html @@ -0,0 +1,227 @@ + + + + + + Soundlog 디자인 피드백 검수 + + + +
+

Soundlog 디자인 피드백 검수

+

iPhone 16 Pro · iOS 18.0 · 이미지의 번호 배지와 페이지명으로 검수 대상을 구분합니다.

+
+ +
+
+ + 01지도 / 여행모드 + 지도와 여행모드 화면 + +
상단 패널 패딩, 필터 버튼과 하단 내비게이션 투명도
+
+ +
+ + 05로그 + 다른사람 보기와 내것만 보기 로그 탭 + +
다른사람 보기 / 내것만 보기 2개 탭과 공개 로그 목록
+
+ +
+ + 09보관함 - 저장곡 + 저장곡 스와이프 삭제 화면 + +
행을 왼쪽으로 밀었을 때 나타나는 빨간 삭제 동작
+
+ +
+ + 11플레이리스트 상세 + 투명한 바텀시트가 적용된 플레이리스트 상세 + +
단일 배경 이미지와 뒤 배경이 이어져 보이는 투명 바텀시트
+
+ +
+ + 15서비스 이용약관 + 흰색 본문이 적용된 서비스 이용약관 + +
제목과 본문을 어두운 배경에서 읽기 쉬운 흰색 계열로 표시
+
+ +
+ + 16-1온보딩 - 장소 추천 + 장소 기반 음악 추천 온보딩 + +
장소와 취향을 활용한 음악 추천 소개
+
+ +
+ + 16-2온보딩 - 리캡 기록 + 리캡 기록 온보딩 + +
사진, 장소, 음악을 리캡으로 기록하는 흐름 소개
+
+ +
+ + 16-3온보딩 - 여행 로그 + 여행 로그 온보딩 + +
여행 중 리캡을 이동 경로와 함께 모으는 흐름 소개
+
+ +
+ + 17로그인 + 이메일과 비밀번호 로그인 화면 + +
일반적인 이메일 / 비밀번호 로그인 흐름
+
+ +
+ + 18회원가입 + 회원가입 화면 + +
이름, 이메일, 비밀번호와 필수 약관 동의
+
+ +
+ + 19로그인 콜백 안내 + 로그인 콜백 오류 안내 화면 + +
만료되거나 잘못된 로그인 정보에 대한 복구 안내
+
+
+ + diff --git a/artifacts/screenshots/soundlog-ios-all-pages-2026-07-14.zip b/artifacts/screenshots/soundlog-ios-all-pages-2026-07-14.zip new file mode 100644 index 0000000..ebed0b5 Binary files /dev/null and b/artifacts/screenshots/soundlog-ios-all-pages-2026-07-14.zip differ diff --git a/assets/onboarding/location-recommendation.png b/assets/onboarding/location-recommendation.png new file mode 100644 index 0000000..b1f2a02 Binary files /dev/null and b/assets/onboarding/location-recommendation.png differ diff --git a/assets/onboarding/recap-memory.png b/assets/onboarding/recap-memory.png new file mode 100644 index 0000000..7bb02e0 Binary files /dev/null and b/assets/onboarding/recap-memory.png differ diff --git a/assets/onboarding/travel-log.png b/assets/onboarding/travel-log.png new file mode 100644 index 0000000..86c0790 Binary files /dev/null and b/assets/onboarding/travel-log.png differ diff --git a/docs/README.md b/docs/README.md index b4ec0f7..a1e4473 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,7 +30,8 @@ Soundlog 문서는 목적별로 관리합니다. 공모전 기획, RN 프론트 ## Deployment -- [soundlog.shop 배포 설정](deployment/SOUNDLOG_SHOP_DOMAIN.md): Gabia DNS, Vercel, EC2 API 도메인, EAS env 설정 기준 +- [GCP 앱 배포 도메인 설정](deployment/SOUNDLOG_SHOP_DOMAIN.md): 웹 미배포 원칙, GCP Cloud DNS, API·법적 문서 도메인과 EAS env 설정 기준 +- [iPhone 실기기 테스트 가이드](deployment/IOS_PHYSICAL_DEVICE_TESTING.md): USB development build, 로컬 API 연결, EAS 내부 배포, TestFlight, 실기기 검수 체크리스트 ## Legacy Mock API diff --git a/docs/deployment/IOS_PHYSICAL_DEVICE_TESTING.md b/docs/deployment/IOS_PHYSICAL_DEVICE_TESTING.md new file mode 100644 index 0000000..58e1c50 --- /dev/null +++ b/docs/deployment/IOS_PHYSICAL_DEVICE_TESTING.md @@ -0,0 +1,262 @@ +# iPhone 실기기 테스트 가이드 + +Soundlog를 실제 iPhone에 설치해 카메라, 위치, 지도, 사진 보관함, API 연동을 검수하는 방법을 정리합니다. + +## 1. 권장 테스트 방식 + +개발 중인 최신 코드를 본인 iPhone에서 확인할 때는 **USB 연결 로컬 development build**를 우선 사용합니다. + +| 방식 | 용도 | Apple Developer Program | 개발자 모드 | +| -------------------------- | ------------------------------ | ------------------------ | ----------- | +| USB 로컬 development build | 본인 기기에서 최신 코드 디버깅 | 무료 Apple 계정으로 가능 | 필요 | +| EAS 내부 배포 | 등록한 기기에 QR 링크로 설치 | 유료 멤버십 필요 | 필요 | +| TestFlight | 팀원·지인에게 배포 | 유료 멤버십 필요 | 불필요 | + +Soundlog는 `expo-camera`, `expo-location`, `expo-media-library`, `react-native-maps` 등 네이티브 모듈을 사용하므로 실제 기능 검수는 Expo Go보다 development build가 적합합니다. + +공식 참고 자료: + +- [Expo iOS 실기기 development build](https://docs.expo.dev/get-started/set-up-your-environment/?device=physical&mode=development-build&platform=ios) +- [Expo 로컬 네이티브 빌드](https://docs.expo.dev/guides/local-app-development/) +- [Apple 개발자 모드 활성화](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device) +- [Apple Xcode 지원 버전](https://developer.apple.com/support/xcode/) + +## 2. 현재 프로젝트 준비 상태 + +프로젝트에는 실기기 development build에 필요한 설정이 이미 있습니다. + +- iOS bundle identifier: `com.mannomi.soundlog` +- URL scheme: `soundlog` +- EAS project ID 설정 완료 +- `expo-dev-client` 설치 완료 +- `development`, `preview`, `production` EAS profile 구성 완료 +- HTTP 로컬 API 사용 시 `app.config.js`가 해당 호스트의 ATS 예외를 개발 빌드에 추가 + +### 2026-07-23 점검 상태 + +- 감지된 기기: iPhone 15 Pro, iOS 26.5 +- 기기 상태: `unavailable` +- 설치된 Xcode: 26.3, iOS 26.2 SDK +- 권장 Xcode: iOS 26.5를 지원하는 Xcode 26.6 이상 +- `soundlog.shop`: 점검 당시 DNS `SERVFAIL` + +위 상태는 시간이 지나면 달라질 수 있으므로 아래 명령으로 다시 확인합니다. + +```bash +xcodebuild -version +xcrun devicectl list devices +xcrun xctrace list devices +``` + +## 3. iPhone과 Mac 준비 + +1. Xcode를 iPhone OS와 호환되는 최신 버전으로 업데이트합니다. +2. iPhone을 USB로 Mac에 연결하고 잠금을 해제합니다. +3. iPhone에서 `이 컴퓨터를 신뢰`를 승인합니다. +4. Xcode의 `Window > Devices and Simulators`에서 기기가 연결된 상태인지 확인합니다. +5. iPhone의 `설정 > 개인정보 보호 및 보안 > 개발자 모드`를 켭니다. +6. iPhone을 재시작한 뒤 개발자 모드 활성화를 다시 승인합니다. + +정상 연결되면 다음 명령의 상태가 `available`이어야 합니다. + +```bash +xcrun devicectl list devices +``` + +## 4. 로컬 API와 연결 + +### `127.0.0.1`을 사용하면 안 되는 이유 + +iPhone에서 `127.0.0.1`은 Mac이 아니라 **iPhone 자신**입니다. `.env.local`의 아래 값은 시뮬레이터용이며 실기기에서는 서버에 도달하지 못합니다. + +```env +EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=http://127.0.0.1:4000 +``` + +Mac과 iPhone을 같은 Wi-Fi에 연결하고 Mac의 LAN IP를 확인합니다. + +```bash +ipconfig getifaddr en0 +``` + +예를 들어 결과가 `172.30.1.99`라면 실기기용 API 주소는 다음과 같습니다. + +```text +http://172.30.1.99:4000 +``` + +### 서버 실행 및 확인 + +DB와 서버 환경변수를 준비한 뒤 API를 실행합니다. + +```bash +cd /Users/manwook-han/Desktop/hmw/code/apps/soundlog/SoundLogServer +npm run dev +``` + +Mac에서 LAN 주소로 헬스 체크가 되는지 확인합니다. + +```bash +curl http://:4000/v1/health +``` + +iPhone Safari에서도 같은 헬스 체크 URL을 열 수 있어야 합니다. 열리지 않으면 다음을 확인합니다. + +- Mac과 iPhone이 같은 네트워크인지 +- macOS 방화벽이 Node.js 또는 포트 4000을 차단하는지 +- 서버가 포트 4000에서 실행 중인지 +- 공유기에서 기기 간 통신을 차단하는 게스트 Wi-Fi인지 + +## 5. iPhone에 앱 설치 + +앱 디렉터리에서 Mac의 LAN IP를 주입해 개발 빌드를 설치합니다. + +```bash +cd /Users/manwook-han/Desktop/hmw/code/apps/soundlog/soundlog + +EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server \ +EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=http://:4000 \ +npx expo run:ios --device +``` + +명령에서 기기 선택 화면이 나오면 연결한 iPhone을 선택합니다. 처음 실행할 때 Xcode 코드 서명용 Apple 계정 또는 Team 선택이 필요할 수 있습니다. + +앱이 한 번 설치된 뒤 JavaScript 코드만 수정할 때는 네이티브 재빌드 없이 Metro를 다시 연결할 수 있습니다. + +```bash +EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server \ +EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=http://:4000 \ +npx expo start --dev-client --lan +``` + +LAN 연결이 불안정하면 Metro에만 `--tunnel`을 사용할 수 있습니다. 이 경우에도 로컬 API는 별도로 iPhone에서 접근 가능한 주소여야 합니다. + +## 6. 배포 API로 테스트 + +배포 API가 정상일 때는 다음 주소를 사용합니다. + +```text +https://api.soundlog.shop +``` + +앱 실행 전에 먼저 상태를 확인합니다. + +```bash +curl https://api.soundlog.shop/v1/health +``` + +DNS 오류나 타임아웃이 발생하면 배포 API 테스트를 중단하고 로컬 LAN API를 사용하거나 도메인과 GCP API 상태를 먼저 복구합니다. 도메인 장애 중에 GCP IP를 앱에 임시 하드코딩하지 않습니다. + +## 7. EAS 내부 배포 + +USB 없이 등록된 iPhone에 설치 링크를 전달하려면 EAS 내부 배포를 사용합니다. 활성 Apple Developer Program 멤버십이 필요합니다. + +```bash +cd /Users/manwook-han/Desktop/hmw/code/apps/soundlog/soundlog +npx eas-cli login +npx eas-cli device:create +npm run build:dev:ios +``` + +빌드가 완료되면 표시되는 링크나 QR 코드로 iPhone에 설치합니다. 현재 `development` profile은 `https://api.soundlog.shop`을 직접 사용하므로 도메인 상태가 정상이어야 전체 기능을 테스트할 수 있습니다. + +설치 후 로컬 코드를 연결하려면 다음을 실행합니다. + +```bash +npm run dev:client +``` + +## 8. TestFlight 배포 + +다른 사람에게 설치를 요청하거나 개발자 모드 없이 테스트하려면 TestFlight를 사용합니다. + +```bash +npx eas-cli build --platform ios --profile production +npx eas-cli submit --platform ios --profile production +``` + +TestFlight 빌드는 App Store Connect 처리와 내부 또는 외부 테스터 초대가 필요합니다. 외부 테스터는 Apple의 베타 앱 심사를 거칠 수 있습니다. + +v1.0은 iPhone 전용으로 제출한다. iPad 스크린샷 자산과 iPad 실기기 QA가 완료되지 않았으므로 `ios.supportsTablet`은 `false`로 유지한다. + +### App Store 스크린샷 seed + +development build에서 Test Manager를 열고 맨 위의 `App Store 스크린샷 seed`를 누르면 mock 로그인, 온보딩 완료, 부산 광안리·시원한 무드, 선택 곡과 보관함, 리캡 3개, 이동 경로가 있는 종료 여행 세션을 준비한 뒤 홈으로 이동한다. 이 도구는 `__DEV__`에서만 존재하며 서버 동기화 큐를 비운다. + +캡처할 때는 `EXPO_PUBLIC_SOUNDLOG_SCREENSHOT_MODE=true`로 development build를 실행해 Test Manager 오버레이를 숨긴다. 이 값은 production 환경에 넣지 않으며, `npm run check:store-release`가 설정된 production build를 실패시킨다. + +스크린샷 모드에서는 AppProviders가 인증·온보딩 hydration 이후 seed를 한 번 자동 적용하고 홈으로 이동하므로 Test Manager 버튼을 누를 필요가 없다. + +### 출시 권한 및 개인정보 선언 + +production 설정은 사진 기록에 필요한 카메라와 사진 보관함, 현재 장소와 여행 기록에 필요한 `When In Use` 위치 권한만 요청한다. 녹음, 항상 위치, 모션 활동, iOS/Android 백그라운드 위치 권한은 사용하지 않는다. + +`ios.privacyManifests`에는 계정 이름·이메일·사용자 ID, 정확한 위치, 사진 또는 비디오, 사용자 콘텐츠를 앱 기능 목적으로 선언하고, 추천·저장·공유 등 앱 내 상호작용은 분석 목적으로 선언한다. 모든 선언은 계정과 연결되지만 추적에는 사용하지 않는다. App Store Connect의 App Privacy 응답도 이 설정 및 실제 서버 처리와 동일하게 입력한다. + +제출 전 다음 검사를 실행한다. + +```bash +npx expo config --type introspect --json +npm run check:store-release +``` + +## 9. 실기기 검수 체크리스트 + +### 설치와 인증 + +- 앱이 개발자 모드 오류 없이 실행되는가 +- 회원가입과 로그인이 실제 서버와 연결되는가 +- 앱 재실행 후 인증 상태가 유지되는가 + +### 위치와 지도 + +- 위치 권한 요청 문구가 정상인가 +- 허용, 한 번 허용, 거부 상태를 모두 처리하는가 +- 현재 위치와 주변 관광지가 정상 표시되는가 +- 지도 핀과 클러스터링이 확대·축소에 맞게 동작하는가 + +### 카메라와 Recap + +- 카메라 권한과 촬영이 정상인가 +- 사진 보관함 선택과 저장이 정상인가 +- 촬영한 사진, 장소, 시간, 음악이 Recap에 반영되는가 +- 두 번째 온보딩 이미지 외곽 테두리가 보이지 않는가 + +### 음악과 플레이리스트 + +- 플레이리스트 선택 시 대표곡이 현재 곡으로 지정되는가 +- 상세에서 곡을 선택하면 활성 행과 미니 플레이어가 함께 변경되는가 +- 저장곡 행을 누르면 삭제 버튼 애니메이션이 정상 표시되는가 + +### 네트워크 + +- Wi-Fi에서 API 요청이 정상인가 +- 네트워크 단절 시 오류와 재시도 UI가 표시되는가 +- 앱 화면에 `127.0.0.1`, 좌표, 원시 서버 오류가 노출되지 않는가 + +## 10. 자주 발생하는 문제 + +### 기기가 `unavailable`로 표시됨 + +- USB를 다시 연결합니다. +- iPhone 잠금을 해제하고 Mac 신뢰를 승인합니다. +- Xcode와 iPhone OS 버전 호환성을 확인합니다. +- Xcode `Devices and Simulators`에서 기존 페어링을 해제하고 다시 연결합니다. + +### 개발자 모드 항목이 없음 + +Xcode에서 기기 페어링을 먼저 시작한 뒤 iPhone의 `설정 > 개인정보 보호 및 보안`을 다시 확인합니다. + +### 앱은 열리지만 API가 모두 실패함 + +- 실기기에서 `127.0.0.1`을 사용하고 있지 않은지 확인합니다. +- iPhone Safari에서 API 헬스 체크 URL을 엽니다. +- Metro를 시작할 때와 네이티브 앱을 빌드할 때 같은 API 환경변수를 사용합니다. +- `api.soundlog.shop` 사용 시 GCP Cloud DNS, 인증서와 GCP API 상태를 확인합니다. + +### Metro에 연결되지 않음 + +- Mac과 iPhone을 같은 Wi-Fi에 연결합니다. +- VPN과 게스트 Wi-Fi를 끕니다. +- `npx expo start --dev-client --lan`을 다시 실행합니다. +- LAN 검색이 차단되면 `--tunnel`을 사용합니다. diff --git a/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md b/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md index e23d09f..05f94e6 100644 --- a/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md +++ b/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md @@ -1,77 +1,66 @@ -# soundlog.shop deployment +# Soundlog app deployment domain -Soundlog 운영/테스트 도메인은 아래 구조로 사용합니다. +Soundlog는 웹 서비스를 운영하거나 배포하지 않습니다. 출시 대상은 Expo/EAS로 빌드한 iOS·Android 네이티브 앱이며, 앱은 GCP API를 직접 호출합니다. Vercel은 운영 배포 경로에 포함하지 않습니다. -| Host | Target | Purpose | -| --- | --- | --- | -| `soundlog.shop` | Vercel | Expo web frontend | -| `www.soundlog.shop` | Vercel | Frontend alias | -| `soundlog.shop/api/soundlog` | Vercel rewrite | Express API proxy | - -## Gabia DNS - -Gabia DNS 관리툴에서 아래 레코드를 설정합니다. +## Production architecture -| Type | Host | Value | +| Host | Target | Purpose | | --- | --- | --- | -| `A` | `@` | `76.76.21.21` | -| `CNAME` | `www` | `cname.vercel-dns.com.` | +| `api.soundlog.shop` | GCP Compute Engine | 앱 API, 업로드, 개인정보 처리방침, 이용약관, 고객지원 | -별도 `api` 서브도메인은 사용하지 않습니다. API 컨테이너는 EC2 `4000` 포트에서 동작하고, Vercel이 `https://soundlog.shop/api/soundlog/:path*` 요청을 EC2 API로 rewrite합니다. +앱의 모든 네트워크 요청은 `https://api.soundlog.shop`으로 전송됩니다. GCP VM의 Caddy가 HTTPS를 종료하고 Express API로 reverse proxy합니다. -## Vercel +## GCP Cloud DNS -Vercel project domain에 아래 도메인을 추가합니다. +GCP project `nomi-app-deploy-2026`의 `soundlog-shop` public zone을 사용합니다. 도메인 등록기관에서 `soundlog.shop`의 네임서버를 아래 값으로 위임합니다. -- `soundlog.shop` -- `www.soundlog.shop` +- `ns-cloud-d1.googledomains.com` +- `ns-cloud-d2.googledomains.com` +- `ns-cloud-d3.googledomains.com` +- `ns-cloud-d4.googledomains.com` -Web build는 `vercel.mjs`에서 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. `/api/soundlog/:path*` 요청은 Vercel이 서버 사이드에서 `SOUNDLOG_API_ORIGIN`으로 rewrite합니다. +Cloud DNS에는 아래 운영 레코드가 등록되어 있습니다. -Vercel 환경변수에 아래 값을 설정합니다. +| Type | Host | TTL | Value | +| --- | --- | --- | --- | +| `A` | `api` | `300` | `34.64.116.40` | -```dotenv -SOUNDLOG_API_ORIGIN=http://:4000 -``` - -브라우저와 앱이 호출하는 공개 API URL은 계속 `https://soundlog.shop/api/soundlog`입니다. `SOUNDLOG_API_ORIGIN`은 Vercel 서버 사이드 rewrite에서만 쓰이는 내부 origin입니다. +기존 AWS Route 53 또는 Vercel 네임서버는 사용하지 않습니다. 등록기관의 네임서버 위임이 Cloud DNS로 바뀐 뒤 Caddy가 `api.soundlog.shop`의 인증서를 자동 발급합니다. ## EAS app env -`development`, `preview`, `production` profile은 `soundlog.shop` HTTPS Vercel proxy를 사용합니다. +`development`, `preview`, `production` profile은 모두 GCP API를 직접 호출합니다. ```dotenv EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server -EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=https://soundlog.shop/api/soundlog +EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=https://api.soundlog.shop +EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN=https://api.soundlog.shop ``` -Production profile은 추가로 약관/개인정보 URL과 지원 메일을 설정합니다. +Production profile은 App Store와 앱 설정에 사용할 공개 법적 문서 URL을 같은 GCP 서버로 지정합니다. ```dotenv -EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL=https://soundlog.shop/legal/privacy -EXPO_PUBLIC_SOUNDLOG_TERMS_URL=https://soundlog.shop/legal/terms +EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL=https://api.soundlog.shop/legal/privacy +EXPO_PUBLIC_SOUNDLOG_TERMS_URL=https://api.soundlog.shop/legal/terms EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL=support@soundlog.shop ``` -## Verification - -DNS와 HTTPS 설정이 끝나면 아래 명령으로 확인합니다. +## Public pages -```bash -dig +short soundlog.shop A -dig +short www.soundlog.shop CNAME -curl -I https://soundlog.shop -curl https://soundlog.shop/api/soundlog/v1/health -npm run check:deployed-web -- https://soundlog.shop -``` +- 개인정보 처리방침: `https://api.soundlog.shop/legal/privacy` +- 서비스 이용약관: `https://api.soundlog.shop/legal/terms` +- 고객지원: `https://api.soundlog.shop/support` -`check:deployed-web`는 로그인 필수 API도 검증합니다. 반복 실행 시 DB에 smoke -계정이 계속 생기는 것을 피하려면 `SOUNDLOG_CHECK_EMAIL`, -`SOUNDLOG_CHECK_PASSWORD`를 지정해 고정 검증 계정으로 실행합니다. 값을 지정하지 -않으면 스크립트가 `@soundlog.test` 임시 계정을 생성합니다. +세 페이지는 로그인 없이 접근할 수 있어야 하며 SoundLogServer가 직접 HTML로 제공합니다. -EC2 origin을 직접 검증하려면 아래 명령을 실행합니다. 이 검사는 `/openapi.yaml`, fallback 장소 source, Spotify 메타데이터 제거 여부까지 확인하므로 예전 백엔드로 잘못 붙은 경우 실패합니다. +## Verification ```bash -SOUNDLOG_API_ORIGIN=http://:4000 npm run check:api-origin +dig +short soundlog.shop NS +dig +short api.soundlog.shop A +curl https://api.soundlog.shop/v1/health +curl -I https://api.soundlog.shop/legal/privacy +curl -I https://api.soundlog.shop/legal/terms +curl -I https://api.soundlog.shop/support +SOUNDLOG_API_ORIGIN=https://api.soundlog.shop npm run check:api-origin ``` diff --git a/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md b/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md index dcb4faf..077e638 100644 --- a/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md +++ b/docs/frontend/AUTH_LOGIN_FLOW_PLAN.md @@ -1,6 +1,6 @@ # Soundlog 로그인/소셜 로그인 구현 계획 -> **Superseded:** 이 문서의 소셜 로그인 설계는 과거 검토안이다. 현재 MVP 인증 기준은 Soundlog 자체 이메일/비밀번호이며, 실제 구현과 제품 명세는 `docs/implementation/2026-07-02-first-party-login-plan.md` 및 `docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md`를 따른다. +> **Superseded:** 이 문서의 소셜 로그인 설계는 과거 검토안이다. 현재 MVP 인증 기준은 이메일/비밀번호이며, 실제 구현과 제품 명세는 `docs/implementation/2026-07-02-first-party-login-plan.md` 및 `docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md`를 따른다. ## 1. 목표 diff --git a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md index 4084224..bee4deb 100644 --- a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md +++ b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md @@ -45,17 +45,17 @@ Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔 ### 3.1 권장 기술 스택 -| 영역 | 권장 선택 | 이유 | -| --- | --- | --- | -| 앱 프레임워크 | React Native + Expo 또는 bare RN | 위치, 카메라, 푸시, 미디어 기능이 많으므로 초기에는 Expo가 빠르다. 단, 음악 백그라운드 제어가 깊어지면 bare 전환 검토 | -| 언어 | TypeScript | 위치/음악/로그 데이터 타입 안정성 확보 | -| 네비게이션 | React Navigation | 탭, 스택, 모달 흐름 구성에 적합 | -| 서버 상태 | TanStack Query | 관광 API, 추천 API, Recap API 캐싱 및 실패 처리 | -| 클라이언트 상태 | Zustand 또는 Jotai | 현재 여행 세션, 선택 태그, 선택 곡 상태 관리 | -| 로컬 저장소 | MMKV 또는 AsyncStorage | 온보딩 상태, 최근 여행 세션, 임시 로그 저장 | -| 카메라 | expo-camera 또는 react-native-vision-camera | MVP는 expo-camera, 고성능 촬영/프레임 처리는 vision-camera 검토 | -| 위치 | expo-location 또는 react-native-geolocation-service | 백그라운드 위치 필요 여부에 따라 선택 | -| 이미지 처리 | expo-image, expo-image-manipulator | Recap 썸네일, 이미지 압축, 캐싱 | +| 영역 | 권장 선택 | 이유 | +| --------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| 앱 프레임워크 | React Native + Expo 또는 bare RN | 위치, 카메라, 푸시, 미디어 기능이 많으므로 초기에는 Expo가 빠르다. 단, 음악 백그라운드 제어가 깊어지면 bare 전환 검토 | +| 언어 | TypeScript | 위치/음악/로그 데이터 타입 안정성 확보 | +| 네비게이션 | React Navigation | 탭, 스택, 모달 흐름 구성에 적합 | +| 서버 상태 | TanStack Query | 관광 API, 추천 API, Recap API 캐싱 및 실패 처리 | +| 클라이언트 상태 | Zustand 또는 Jotai | 현재 여행 세션, 선택 태그, 선택 곡 상태 관리 | +| 로컬 저장소 | MMKV 또는 AsyncStorage | 온보딩 상태, 최근 여행 세션, 임시 로그 저장 | +| 카메라 | expo-camera 또는 react-native-vision-camera | MVP는 expo-camera, 고성능 촬영/프레임 처리는 vision-camera 검토 | +| 위치 | expo-location 또는 react-native-geolocation-service | 백그라운드 위치 필요 여부에 따라 선택 | +| 이미지 처리 | expo-image, expo-image-manipulator | Recap 썸네일, 이미지 압축, 캐싱 | ### 3.2 Expo 사용 시 판단 포인트 @@ -77,12 +77,12 @@ MVP 단계에서는 Expo가 적합하다. 온보딩, 위치, 카메라, 이미 2026년 7월 재정렬 기준 MVP 주요 목적지 탭은 4개로 단순화한다. 앱의 첫 화면은 지도이며, 카메라는 콘텐츠 탭이 아니라 하단 중앙의 리캡 작성 액션이다. 하단 바는 `지도 · 음악추천 · 중앙 카메라 액션 · 로그 · 마이`의 5개 위치로 보인다. -| 탭 | 목적 | 주요 화면 | -| --- | --- | --- | -| 지도 | 여행모드 진입과 주변 공개 리캡 탐색 | 지도, 필터 칩, 여행모드 CTA, 공개/내 리캡 핀 | -| 음악추천 | 일상모드 위치 기반 사운드트랙 추천 | 오늘의 사운드트랙, 무드 조정, 외부 음악 링크 | -| 로그 | 공개 로그 탐색과 내 로그 관리 | 다른사람 보기, 모든 사람 보기, 격자형 로그, 공개/비공개 토글 | -| 마이 | 계정/권한 관리 | 취향 수정, 위치/카메라 권한, 설정 | +| 탭 | 목적 | 주요 화면 | +| -------- | ----------------------------------- | --------------------------------------------------------- | +| 지도 | 여행모드 진입과 주변 공개 리캡 탐색 | 지도, 필터 칩, 여행모드 CTA, 공개/내 리캡 핀 | +| 음악추천 | 일상모드 위치 기반 사운드트랙 추천 | 오늘의 사운드트랙, 무드 조정, 외부 음악 링크 | +| 로그 | 공개 로그 탐색과 내 로그 관리 | 다른사람 보기, 내것만 보기, 격자형 로그, 공개/비공개 토글 | +| 마이 | 계정/권한 관리 | 취향 수정, 위치/카메라 권한, 설정 | 카메라는 독립 콘텐츠 탭으로 두지 않는다. 진입점은 하단 중앙 카메라 버튼과 지도 탭의 `기록 남기기`처럼 기록 맥락이 분명한 CTA다. 로그 탭은 격자로 로그를 탐색하고 공개 범위를 관리하는 화면이며, 새 리캡 작성은 하단 중앙 카메라 버튼에서 시작한다. @@ -92,26 +92,26 @@ MVP 단계에서는 Expo가 적합하다. 온보딩, 위치, 카메라, 이미 - 여행 세션 시작 전: 지도는 `여행모드 시작` CTA를 보여준다. - 여행 세션 중: 지도는 카메라 버튼형 `기록 남기기` CTA를 보여주고, 생성된 리캡을 현재 로그에 묶는다. -- 로그 탭: `다른사람 보기`와 `모든 사람 보기` 두 탭으로 구성하고, 모든 사람 보기에서 내 로그의 공개/비공개를 설정한다. +- 로그 탭: `다른사람 보기`와 `내것만 보기` 두 탭으로 구성하고, 내것만 보기에서 내 로그의 공개/비공개를 설정한다. - 카메라 화면 하단에는 좌측 `갤러리`, 중앙 촬영 버튼, 우측 `추천사진` CTA를 제공한다. -- 공개/비공개 선택은 로그 탭의 `모든 사람 보기` 또는 상세 화면에서 처리한다. +- 공개/비공개 선택은 로그 탭의 `내것만 보기` 또는 상세 화면에서 처리한다. ### 4.2 화면 목록 -| 화면 ID | 화면명 | 목적 | -| --- | --- | --- | -| P-01 | 온보딩 | 서비스 가치 전달 | -| P-02 | 취향 설문 | 음악/여행 취향 수집 | -| P-03 | 권한/취향 | 위치, 카메라, 취향 설정 | -| P-04 | 지도 / 여행모드 | 여행모드 시작, 주변 공개 리캡, 내 리캡 지도 확인 | -| P-05 | 음악 추천 / 일상모드 | 위치 기반 사운드트랙, 추천 이유, 곡 목록, 무드 조정 | -| P-06 | 미니/풀 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | -| P-07 | 리캡 작성 도구 | 사진 촬영, 갤러리, 추천사진 또는 사진 없이 리캡 생성 | -| P-08 | 리캡 저장 확인 | 장소, 위치, 음악, 무드, 촬영 시간, 템플릿 확인 후 저장 | -| P-09 | 로그 탐색 | 남의 공개 로그와 내 로그를 격자로 확인하고 상세로 진입 | -| P-10 | 로그 상세 | 시간순 리캡, 여행 경로, 요약, 공유용 결과 확인 및 공개 범위 설정 | -| P-11 | 저장 음악 | 좋아요 음악, 저장 PL. MVP에서는 음악추천/마이에서 진입 | -| P-12 | 마이페이지 | 연동, 권한, 취향 관리 | +| 화면 ID | 화면명 | 목적 | +| ------- | -------------------- | ---------------------------------------------------------------- | +| P-01 | 온보딩 | 서비스 가치 전달 | +| P-02 | 취향 설문 | 음악/여행 취향 수집 | +| P-03 | 권한/취향 | 위치, 카메라, 취향 설정 | +| P-04 | 지도 / 여행모드 | 여행모드 시작, 주변 공개 리캡, 내 리캡 지도 확인 | +| P-05 | 음악 추천 / 일상모드 | 위치 기반 사운드트랙, 추천 이유, 곡 목록, 무드 조정 | +| P-06 | 미니/풀 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | +| P-07 | 리캡 작성 도구 | 사진 촬영, 갤러리, 추천사진 또는 사진 없이 리캡 생성 | +| P-08 | 리캡 저장 확인 | 장소, 위치, 음악, 무드, 촬영 시간, 템플릿 확인 후 저장 | +| P-09 | 로그 탐색 | 남의 공개 로그와 내 로그를 격자로 확인하고 상세로 진입 | +| P-10 | 로그 상세 | 시간순 리캡, 여행 경로, 요약, 공유용 결과 확인 및 공개 범위 설정 | +| P-11 | 저장 음악 | 좋아요 음악, 저장 PL. MVP에서는 음악추천/마이에서 진입 | +| P-12 | 마이페이지 | 연동, 권한, 취향 관리 | --- @@ -283,7 +283,7 @@ MVP에서 미니/풀 링크 패널은 복잡한 음악 앱 수준의 재생 제 ```ts type TravelSession = { id: string; - status: 'idle' | 'active' | 'ended'; + status: "idle" | "active" | "ended"; startedAt: string; endedAt?: string; currentLocation?: GeoPoint; @@ -306,7 +306,7 @@ type PlaceContext = { imageUrl?: string; overview?: string; distanceMeters?: number; - source: 'reverse-geocode' | 'seed' | 'tour-api' | 'user'; + source: "reverse-geocode" | "seed" | "tour-api" | "user"; }; ``` @@ -318,7 +318,7 @@ type PlaylistRecommendation = { title: string; reason: string; coverImageUrl?: string; - source: 'trend' | 'personalized' | 'place_context' | 'fallback'; + source: "trend" | "personalized" | "place_context" | "fallback"; tracks: Track[]; placeContext: PlaceContext; mode?: TravelMode; @@ -341,7 +341,7 @@ type MomentLog = { mode?: TravelMode; moods: MoodTag[]; memo?: string; - syncStatus: 'local' | 'syncing' | 'synced' | 'failed'; + syncStatus: "local" | "syncing" | "synced" | "failed"; }; ``` @@ -353,8 +353,8 @@ type TravelLog = { id: string; sessionId: string; title: string; - type: 'album_cover' | 'lp' | 'film' | 'video'; - status: 'generating' | 'ready' | 'failed'; + type: "album_cover" | "lp" | "film" | "video"; + status: "generating" | "ready" | "failed"; representativePlace?: PlaceContext; representativeTrack?: Track; imageUrl?: string; @@ -449,33 +449,33 @@ GET /v1/recaps/:id 상태별 UI가 필요하다. -| 상태 | UI 대응 | -| --- | --- | -| 권한 미요청 | 위치 기반 추천의 이점을 설명하고 요청 | -| 권한 허용 | 현재 위치 기반 추천 활성화 | -| 권한 거부 | 직접 지역 선택 또는 샘플 추천 제공 | -| 위치 조회 실패 | 최근 위치 또는 수동 지역 선택 제공 | -| 백그라운드 위치 불가 | 앱 사용 중 추천만 제공 | +| 상태 | UI 대응 | +| -------------------- | ------------------------------------- | +| 권한 미요청 | 위치 기반 추천의 이점을 설명하고 요청 | +| 권한 허용 | 현재 위치 기반 추천 활성화 | +| 권한 거부 | 직접 지역 선택 또는 샘플 추천 제공 | +| 위치 조회 실패 | 최근 위치 또는 수동 지역 선택 제공 | +| 백그라운드 위치 불가 | 앱 사용 중 추천만 제공 | ### 9.2 카메라 권한 카메라 권한이 없더라도 리캡 작성 기능을 완전히 막기보다 사진 없는 Recap 저장을 허용할 수 있다. -| 상태 | UI 대응 | -| --- | --- | -| 권한 허용 | 촬영 후 Recap 생성 | +| 상태 | UI 대응 | +| --------- | ---------------------------------------------- | +| 권한 허용 | 촬영 후 Recap 생성 | | 권한 거부 | 설정 이동 안내, 사진 없는 Recap 저장 옵션 제공 | -| 촬영 실패 | 재촬영, 임시 Recap 저장 | +| 촬영 실패 | 재촬영, 임시 Recap 저장 | ### 9.3 외부 음악 링크 실패 외부 음악 앱 링크는 기기와 서비스 상태에 따라 열리지 않을 수 있으므로 fallback이 필요하다. -| 상태 | UI 대응 | -| --- | --- | -| 링크 없음 | 곡 제목/아티스트 기반 검색 링크 생성 | -| 외부 앱 열기 실패 | 안내 메시지와 다시 시도 제공 | -| 해당 곡 검색 불가 | 대체 곡 또는 추천 목록 유지 | +| 상태 | UI 대응 | +| ----------------- | ------------------------------------ | +| 링크 없음 | 곡 제목/아티스트 기반 검색 링크 생성 | +| 외부 앱 열기 실패 | 안내 메시지와 다시 시도 제공 | +| 해당 곡 검색 불가 | 대체 곡 또는 추천 목록 유지 | --- @@ -642,15 +642,15 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 ## 16. 주요 프론트 리스크 -| 리스크 | 설명 | 대응 | -| --- | --- | --- | -| 권한 이탈 | 위치/카메라 요청이 초기 이탈을 만들 수 있음 | 기능 맥락에서 순차 요청 | +| 리스크 | 설명 | 대응 | +| --------------------- | ------------------------------------------------------------ | ------------------------------------------ | +| 권한 이탈 | 위치/카메라 요청이 초기 이탈을 만들 수 있음 | 기능 맥락에서 순차 요청 | | 음악 감상 범위 불명확 | 외부 음악 앱 SDK나 직접 음원 제공 범위를 잘못 약속할 수 있음 | MVP는 외부 음악 앱 검색 링크 중심으로 설계 | -| 위치 정확도 문제 | 실내, 지하, 이동 중 위치가 부정확할 수 있음 | 주변 장소 후보 선택 UI 제공 | -| 네트워크 불안정 | 여행지에서 API 요청 실패 가능 | 최근 추천/임시 로그 로컬 캐싱 | -| 이미지 품질 편차 | 관광공사 이미지가 없거나 비율이 다를 수 있음 | fallback 이미지 및 crop 정책 정의 | -| Recap 생성 비용 | 영상형 Recap은 프론트/서버 모두 비용이 큼 | MVP는 이미지형 Recap 우선 | -| 상태 복잡도 증가 | 위치, 선택 곡, 여행 세션, 로그가 얽힘 | 서버 상태와 클라이언트 상태 분리 | +| 위치 정확도 문제 | 실내, 지하, 이동 중 위치가 부정확할 수 있음 | 주변 장소 후보 선택 UI 제공 | +| 네트워크 불안정 | 여행지에서 API 요청 실패 가능 | 최근 추천/임시 로그 로컬 캐싱 | +| 이미지 품질 편차 | 관광공사 이미지가 없거나 비율이 다를 수 있음 | fallback 이미지 및 crop 정책 정의 | +| Recap 생성 비용 | 영상형 Recap은 프론트/서버 모두 비용이 큼 | MVP는 이미지형 Recap 우선 | +| 상태 복잡도 증가 | 위치, 선택 곡, 여행 세션, 로그가 얽힘 | 서버 상태와 클라이언트 상태 분리 | --- diff --git a/docs/implementation/2026-07-20-design-feedback-plan.md b/docs/implementation/2026-07-20-design-feedback-plan.md new file mode 100644 index 0000000..ccf553c --- /dev/null +++ b/docs/implementation/2026-07-20-design-feedback-plan.md @@ -0,0 +1,89 @@ +# 2026-07-20 디자인 피드백 구현·검수 계획 + +## 목표 + +1·5·9·11·15·16·17·18·19번 화면의 디자인 피드백을 반영하고, 각 변경이 기존 서버 계약과 앱 상태 흐름을 깨지 않는지 iOS 시뮬레이터에서 검증한다. + +## 화면별 수용 기준 + +### 1번 지도 + +- 상단 정보판, 필터, 현재 위치, 여행 CTA가 지도 위에서 읽힐 정도로 배경 불투명도가 높다. +- 하단 내비게이션은 지도 맥락이 보이지만 아이콘과 라벨을 방해하지 않는다. +- `지도 / 여행모드` 정보판의 좌우·상하 여백이 기존보다 넓고 작은 화면에서 내용이 겹치지 않는다. + +### 5번 로그 + +- 탭은 `다른사람 보기`, `내것만 보기` 두 개뿐이다. +- 다른사람 보기는 타인의 공개 로그만, 내것만 보기는 현재 계정의 로컬·서버 로그만 보여준다. +- 내 로그 공개 범위 변경은 내것만 보기에서만 가능하고, 성공 후 관련 목록 캐시를 갱신한다. + +### 9번 보관함 + +- 삭제 버튼은 기본 상태에서 보이지 않는다. +- 저장곡 행을 탭하면 왼쪽 이동 애니메이션과 함께 빨간 삭제 버튼이 나타난다. +- 다른 행을 탭하면 먼저 열려 있던 삭제 버튼은 닫히고 새 행만 열린다. +- 플레이리스트 행을 탭하면 대표곡과 저장곡 큐를 현재 플레이어 상태로 지정한 뒤 상세로 이동한다. +- 삭제 요청 실패 시 로컬 상태를 되돌리고 오류 메시지를 보여주는 기존 동작이 유지된다. + +### 11번 플레이리스트 상세 + +- 배경 이미지는 한 번만 렌더링되고 전체 화면을 채운다. +- 바텀시트 배경은 투명해 동일한 배경이 자연스럽게 이어진다. +- 바텀시트를 올려도 곡 제목과 액션 아이콘의 대비가 유지된다. +- 곡을 탭하면 현재 곡, 활성 행, 하단 미니 플레이어가 같은 곡으로 갱신된다. + +### 15번 약관 + +- 소개와 각 약관 본문이 흰색으로 렌더링된다. +- 긴 본문 스크롤과 고객지원 링크가 기존처럼 동작한다. + +### 16번 온보딩 + +- Figma `309:730`, `309:744`, `309:757`의 일러스트가 각각 한 장씩 표시된다. +- 손가락 스와이프와 다음 버튼으로 3장을 이동할 수 있고 인디케이터가 현재 장을 반영한다. +- 비로그인 사용자는 마지막 장에서 로그인으로, 로그인한 미완료 사용자는 추천 취향 설정으로 이동한다. +- 마이페이지의 설정 수정 진입은 소개 슬라이드를 건너뛴다. + +### 17·18·19번 인증 + +- 로그인과 회원가입은 이름(가입만), 이메일, 비밀번호, 약관(가입만)의 익숙한 폼 구조를 사용한다. +- `Soundlog 자체 계정`과 계정 연결 방식 설명 문구가 사용자 화면에 없다. +- 로그인·가입 성공 시 온보딩 완료 여부에 맞는 다음 화면으로 이동하고, 실패 시 같은 화면에서 재시도할 수 있다. +- 콜백 오류 화면은 일반적인 로그인 실패 안내와 로그인 복귀 CTA만 제공한다. + +## 검증 순서 + +1. TypeScript 타입 검사 +2. Expo Doctor와 기존 정적 검사 +3. iOS 시뮬레이터에서 화면별 시각 검사 +4. 로그 탭 데이터 분리 확인 +5. 보관함 스와이프 삭제와 실패 롤백 확인 +6. 온보딩 스와이프·버튼·로그인 이후 분기 확인 +7. 로그인·회원가입 입력 검증과 서버 요청 확인 +8. 요구사항별 완료 증거와 잔여 이슈 기록 + +## 검수 결과 + +| 페이지 | 결과 | 확인 내용 | +| --------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 지도 | 통과 | 상단 정보판 패딩과 배경 불투명도, 필터·CTA·하단 내비게이션 대비를 iPhone 16 Pro 시뮬레이터에서 확인했다. | +| 5 로그 | 통과 | `다른사람 보기`에는 타인 공개 로그 20개, `내것만 보기`에는 현재 계정 로그 0개가 표시되어 데이터 범위가 분리됨을 확인했다. | +| 9 보관함 | 통과 | 저장곡 행을 탭하면 220ms 이동 애니메이션으로 빨간 삭제 버튼이 열리고, 다른 행을 열 때 기존 행이 자동으로 닫힌다. 플레이리스트를 탭하면 대표곡이 현재 곡으로 지정된 상태에서 상세로 이동한다. | +| 11 플레이리스트 | 통과 | 배경 이미지는 한 번만 표시되고 바텀시트 아래에서 같은 이미지가 끊김 없이 보인다. 상세 곡 탭 시 현재 곡, 활성 행, 미니 플레이어가 함께 갱신된다. | +| 15 이용약관 | 통과 | 소개와 긴 본문이 모두 흰색으로 렌더링되고 스크롤 가능한 상태를 확인했다. | +| 16 온보딩 | 통과 | Figma 3개 이미지를 순서대로 표시하고 다음 버튼, 현재 슬라이드 인디케이터, 비활성 슬라이드 접근성 제외를 확인했다. | +| 17 로그인 | 통과 | 이메일·비밀번호 입력, 빈 이메일 검증 메시지, 회원가입 전환을 확인했다. | +| 18 회원가입 | 통과 | 이름·이메일·비밀번호, 필수 약관 체크박스와 로그인 복귀를 확인했다. | +| 19 콜백 오류 | 통과 | 일반적인 실패 안내와 로그인 복귀 CTA만 표시됨을 확인했다. | + +### 자동 검사 + +- `npm run check`: TypeScript, 리캡 클러스터링 검사, Expo Doctor 18/18 통과 +- `npm run check:server-contract`: 프론트 52개와 서버 63개 API 작업의 계약 검사 통과 +- `git diff --check`: 공백 오류 없음 +- 캡처 갤러리: 데스크톱 4열과 모바일 390px 1열에서 번호·페이지명 라벨 잘림 없음 + +### 검수 참고 + +페이지 9는 물리 스와이프 대신 행 탭으로 삭제 액션을 여는 최신 기획을 적용했다. iPhone 16 Pro 시뮬레이터에서 행 탭, 다른 행 전환, 삭제 버튼 접근성 노출을 각각 확인했다. diff --git a/docs/product/RECAP_LOG_DOMAIN_MODEL.md b/docs/product/RECAP_LOG_DOMAIN_MODEL.md index bf9ec1b..380e6c0 100644 --- a/docs/product/RECAP_LOG_DOMAIN_MODEL.md +++ b/docs/product/RECAP_LOG_DOMAIN_MODEL.md @@ -223,7 +223,7 @@ sessionId가 있고 + 해당 세션의 리캡이 1개 이상인 경우 - 여행 날짜 또는 시작 시각 - 공개/비공개 상태 -`다른사람 보기`는 다른 사용자가 공개한 여행 로그만 보여준다. `모든 사람 보기`는 내가 볼 권한이 있는 여행 로그를 합쳐 보여준다. 어떤 탭에서도 여행모드 밖의 독립 리캡을 로그 카드로 위장해서 보여주면 안 된다. +`다른사람 보기`는 다른 사용자가 공개한 여행 로그만 보여준다. `내것만 보기`는 현재 계정의 공개·비공개 여행 로그만 보여준다. 어떤 탭에서도 여행모드 밖의 독립 리캡을 로그 카드로 위장해서 보여주면 안 된다. ### 5.2 로그 상세 @@ -529,18 +529,18 @@ type TravelLog = { 다른 작업 세션은 아래 파일에서 현재 구현을 확인한다. -| 책임 | 프론트엔드 기준 파일 | -| ------------------------------------ | ----------------------------------------------------------- | -| 카메라 촬영과 Recap 로컬 저장 | `src/components/moment-capture/MomentCaptureScreen.tsx` | -| 촬영 후 템플릿/장소/공개 범위 설정 | `src/components/moment-capture/MomentReviewPanel.tsx` | -| Recap 오프라인 큐와 동기화 | `src/store/momentLogStore.ts`, `src/utils/momentLogSync.ts` | +| 책임 | 프론트엔드 기준 파일 | +| ------------------------------------ | --------------------------------------------------------------- | +| 카메라 촬영과 Recap 로컬 저장 | `src/components/moment-capture/MomentCaptureScreen.tsx` | +| 촬영 후 템플릿/장소/공개 범위 설정 | `src/components/moment-capture/MomentReviewPanel.tsx` | +| Recap 오프라인 큐와 동기화 | `src/store/momentLogStore.ts`, `src/utils/momentLogSync.ts` | | 여행 종료 Log 생성 영속 큐 | `src/store/travelLogSyncStore.ts`, `src/utils/travelLogSync.ts` | -| 여행 세션과 경로 로컬 영속화 | `src/store/travelSessionStore.ts` | -| foreground GPS 경로 수집 | `src/hooks/useTravelRouteTracking.ts` | -| `sessionId` 기반 Log 그룹 생성 | `src/utils/recapMappers.ts` | -| 여행 Log만 보여주는 격자 목록 | `src/components/recap/RecapListScreen.tsx` | -| Log 상세과 독립 Recap 상세 분기 | `src/components/recap-share/RecapShareScreen.tsx` | -| 현재 Log Recap 핀과 세션 경로 렌더링 | `src/components/recap-share/RecapRouteMap.tsx` | +| 여행 세션과 경로 로컬 영속화 | `src/store/travelSessionStore.ts` | +| foreground GPS 경로 수집 | `src/hooks/useTravelRouteTracking.ts` | +| `sessionId` 기반 Log 그룹 생성 | `src/utils/recapMappers.ts` | +| 여행 Log만 보여주는 격자 목록 | `src/components/recap/RecapListScreen.tsx` | +| Log 상세과 독립 Recap 상세 분기 | `src/components/recap-share/RecapShareScreen.tsx` | +| 현재 Log Recap 핀과 세션 경로 렌더링 | `src/components/recap-share/RecapRouteMap.tsx` | 서버 기준점: diff --git a/docs/product/SOUNDLOG_APP_PLANNING.md b/docs/product/SOUNDLOG_APP_PLANNING.md index ff0e92a..f4efb75 100644 --- a/docs/product/SOUNDLOG_APP_PLANNING.md +++ b/docs/product/SOUNDLOG_APP_PLANNING.md @@ -28,14 +28,14 @@ MVP의 Soundlog는 앱 안에서 음원을 직접 스트리밍하거나 외부 아래 구조가 현재 구현과 기획 검수의 우선 기준이다. 이 문서 하단의 과거 `홈`, `보관함` 표현과 충돌할 경우 이 구조와 v0.3 Detailed를 우선한다. -| 탭 | 화면명 | 핵심 역할 | -| --- | --- | --- | -| 1 | 지도 | 앱 첫 화면. 여행모드 시작, 현재 위치, 주변 공개 리캡, 내 리캡 핀을 본다. | -| 2 | 음악추천 | 일상모드. 위치 기반 오늘의 사운드트랙과 외부 음악 링크를 제공한다. 여행모드 전환은 제공하지 않는다. | -| 3 | 로그 | 다른사람 공개 로그와 모든 로그를 격자로 탐색하고, 내 로그의 공개/비공개를 관리한다. | -| 4 | 마이 | 계정, 권한, 취향, 공개 설정을 관리한다. | +| 탭 | 화면명 | 핵심 역할 | +| --- | -------- | --------------------------------------------------------------------------------------------------- | +| 1 | 지도 | 앱 첫 화면. 여행모드 시작, 현재 위치, 주변 공개 리캡, 내 리캡 핀을 본다. | +| 2 | 음악추천 | 일상모드. 위치 기반 오늘의 사운드트랙과 외부 음악 링크를 제공한다. 여행모드 전환은 제공하지 않는다. | +| 3 | 로그 | 다른사람 공개 로그와 모든 로그를 격자로 탐색하고, 내 로그의 공개/비공개를 관리한다. | +| 4 | 마이 | 계정, 권한, 취향, 공개 설정을 관리한다. | -카메라는 독립 콘텐츠 탭이 아니라 하단 중앙의 Recap 작성 액션이다. 하단 중앙 카메라 버튼과 지도 탭의 `기록 남기기`처럼 기록 맥락이 있는 CTA에서 호출된다. 로그 탭은 여행모드에서 생성된 로그만 `다른사람 보기`와 `모든 사람 보기` 두 탭으로 탐색하고 공개 범위를 관리한다. 카메라 화면 하단에는 좌측 `갤러리`, 중앙 촬영 버튼, 우측 `추천사진` CTA를 제공한다. 여행모드 중 저장한 Recap은 현재 Log에 묶이고, 여행모드 밖에서 저장한 Recap은 Log가 아닌 독립 Recap으로 관리한다. +카메라는 독립 콘텐츠 탭이 아니라 하단 중앙의 Recap 작성 액션이다. 하단 중앙 카메라 버튼과 지도 탭의 `기록 남기기`처럼 기록 맥락이 있는 CTA에서 호출된다. 로그 탭은 여행모드에서 생성된 로그만 `다른사람 보기`와 `내것만 보기` 두 탭으로 탐색하고 공개 범위를 관리한다. 카메라 화면 하단에는 좌측 `갤러리`, 중앙 촬영 버튼, 우측 `추천사진` CTA를 제공한다. 여행모드 중 저장한 Recap은 현재 Log에 묶이고, 여행모드 밖에서 저장한 Recap은 Log가 아닌 독립 Recap으로 관리한다. ### 핵심 가치 @@ -62,11 +62,11 @@ Soundlog는 이 문제를 다음 세 가지 관점에서 해결한다. ## 3. 타겟 사용자 및 페인포인트 -| 핵심 타겟 | 특징 | 페인포인트 | Soundlog 해결 방식 | -| --- | --- | --- | --- | -| 기록에 진심인 여행자 | 사진, 음악, 기록을 통해 여행을 하나의 작품처럼 남기고 싶어 한다. | 사진만으로는 당시의 분위기와 감정을 충분히 복기하기 어렵다. | 사진, 장소, 음악, 시간을 함께 저장하고 Recap으로 재구성한다. | -| 선곡이 귀찮은 여행자 | 장소에 어울리는 음악을 듣고 싶지만 직접 찾는 과정을 번거롭게 느낀다. | 음악 검색이 여행 흐름을 끊고 피로감을 만든다. | 위치와 관광 맥락 기반으로 플레이리스트를 자동 추천한다. | -| 현장 몰입을 중시하는 여행자 | 여행 중에는 휴대폰보다 풍경과 분위기에 집중하고 싶어 한다. | 정보 중심 앱은 계속 화면 조작을 요구한다. | 외부 음악 링크, 무드 조정, 원클릭 저장 중심의 Eyes-free UX를 제공한다. | +| 핵심 타겟 | 특징 | 페인포인트 | Soundlog 해결 방식 | +| --------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- | +| 기록에 진심인 여행자 | 사진, 음악, 기록을 통해 여행을 하나의 작품처럼 남기고 싶어 한다. | 사진만으로는 당시의 분위기와 감정을 충분히 복기하기 어렵다. | 사진, 장소, 음악, 시간을 함께 저장하고 Recap으로 재구성한다. | +| 선곡이 귀찮은 여행자 | 장소에 어울리는 음악을 듣고 싶지만 직접 찾는 과정을 번거롭게 느낀다. | 음악 검색이 여행 흐름을 끊고 피로감을 만든다. | 위치와 관광 맥락 기반으로 플레이리스트를 자동 추천한다. | +| 현장 몰입을 중시하는 여행자 | 여행 중에는 휴대폰보다 풍경과 분위기에 집중하고 싶어 한다. | 정보 중심 앱은 계속 화면 조작을 요구한다. | 외부 음악 링크, 무드 조정, 원클릭 저장 중심의 Eyes-free UX를 제공한다. | --- @@ -108,11 +108,11 @@ Log 상세 지도는 해당 Log에 속한 Recap 위치만 번호 핀으로 표 MVP에서 직접 수집하는 항목은 다음과 같다. 장르, 아티스트, 동행 유형은 서버 모델 확장을 위해 유지할 수 있지만 현재 온보딩 필수 입력은 아니다. -| 구분 | 수집 항목 | 예시 | -| --- | --- | --- | -| 음악 취향 | 선호 분위기 | 잔잔한, 신나는, 청량한, 감성적인, 활기찬 | -| 여행 성향 | 여행 스타일 | 바다, 드라이브, 산책, 카페, 야경 | -| 사용 환경 | 위치 접근 허용 | 현재 위치 기반 추천 사용 여부 | +| 구분 | 수집 항목 | 예시 | +| --------- | -------------- | ---------------------------------------- | +| 음악 취향 | 선호 분위기 | 잔잔한, 신나는, 청량한, 감성적인, 활기찬 | +| 여행 성향 | 여행 스타일 | 바다, 드라이브, 산책, 카페, 야경 | +| 사용 환경 | 위치 접근 허용 | 현재 위치 기반 추천 사용 여부 | ### 5.2 지도 / 여행모드 @@ -177,17 +177,17 @@ Recap 표현 템플릿은 촬영 후 저장 확인 화면에서 다음 중 하 ## 6. 정보 구조 -| 영역 | 화면 | 목적 | -| --- | --- | --- | -| 시작 | 온보딩 | 서비스 가치 전달 및 취향 데이터 수집 | -| 시작 | 권한/취향 | 위치 추천 여부와 음악·여행 취향 설정 | -| 여행 | 지도 / 여행모드 | 현재 위치, 여행모드 시작, 공개/내 리캡 지도 확인 | -| 추천 | 음악추천 / 일상모드 | 위치 기반 플레이리스트와 무드 조정 | -| 여행 | 음악 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | -| 기록 | Recap 작성 도구 | 사진, 장소, 음악, 시간 기반 단일 Recap 생성 | -| 회고 | 여행 Log 탐색 | 남의 공개 Log, 내 Log 격자 보기, 내 Log 공개 범위 관리 | -| 회고 | Log 상세 | 소속 Recap, GPS 로드맵, 저장된 표현 결과 확인 및 공유 | -| 관리 | 마이페이지 | 계정, 연동, 좋아요, 취향 정보 관리 | +| 영역 | 화면 | 목적 | +| ---- | ------------------- | ------------------------------------------------------ | +| 시작 | 온보딩 | 서비스 가치 전달 및 취향 데이터 수집 | +| 시작 | 권한/취향 | 위치 추천 여부와 음악·여행 취향 설정 | +| 여행 | 지도 / 여행모드 | 현재 위치, 여행모드 시작, 공개/내 리캡 지도 확인 | +| 추천 | 음악추천 / 일상모드 | 위치 기반 플레이리스트와 무드 조정 | +| 여행 | 음악 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | +| 기록 | Recap 작성 도구 | 사진, 장소, 음악, 시간 기반 단일 Recap 생성 | +| 회고 | 여행 Log 탐색 | 남의 공개 Log, 내 Log 격자 보기, 내 Log 공개 범위 관리 | +| 회고 | Log 상세 | 소속 Recap, GPS 로드맵, 저장된 표현 결과 확인 및 공유 | +| 관리 | 마이페이지 | 계정, 연동, 좋아요, 취향 정보 관리 | --- @@ -212,37 +212,37 @@ Recap 표현 템플릿은 촬영 후 저장 확인 화면에서 다음 중 하 Soundlog에서 한국관광공사 데이터는 부가 정보가 아니라 서비스 추천의 출발점이다. 관광 데이터가 장소의 의미를 정의하고, 음악 데이터가 그 장소의 감정을 완성하며, 사용자 기록 데이터가 여행 후 Recap 콘텐츠로 확장된다. -| 활용 데이터 | 주요 필드 | 활용 목적 | -| --- | --- | --- | -| 국문 관광정보 서비스 | 관광지명, 주소, 좌표, 카테고리, 콘텐츠 타입, 장소 설명 | 현재 위치를 관광 맥락으로 변환 | -| 위치 기반 관광정보 | 주변 POI, 거리, 좌표 | 사용자 주변 관광지 탐색 및 추천 기준 생성 | -| 키워드 검색 조회 | 장소명, 태그, 관광 유형 | 사용자가 선택한 모드/무드와 관련된 장소 보강 | -| 행사·축제 정보 | 행사명, 기간, 장소, 개요 | 축제 현장 감지 및 에너지 있는 음악 추천 | -| 관광사진 정보 | 이미지 URL, 지역, 키워드 | 플레이리스트 커버 및 Recap 배경 이미지 생성 | -| 관광지별 연관 관광지 정보 | 연관 장소, 대체 장소 | 다음 행선지 예측 및 이동 흐름 기반 추천 | -| 관광지 집중률 방문자 추이 예측 정보 | 방문자 추이, 혼잡도 | 혼잡도 기반 추천 및 대체 장소 제안 확장 | +| 활용 데이터 | 주요 필드 | 활용 목적 | +| ----------------------------------- | ------------------------------------------------------ | -------------------------------------------- | +| 국문 관광정보 서비스 | 관광지명, 주소, 좌표, 카테고리, 콘텐츠 타입, 장소 설명 | 현재 위치를 관광 맥락으로 변환 | +| 위치 기반 관광정보 | 주변 POI, 거리, 좌표 | 사용자 주변 관광지 탐색 및 추천 기준 생성 | +| 키워드 검색 조회 | 장소명, 태그, 관광 유형 | 사용자가 선택한 모드/무드와 관련된 장소 보강 | +| 행사·축제 정보 | 행사명, 기간, 장소, 개요 | 축제 현장 감지 및 에너지 있는 음악 추천 | +| 관광사진 정보 | 이미지 URL, 지역, 키워드 | 플레이리스트 커버 및 Recap 배경 이미지 생성 | +| 관광지별 연관 관광지 정보 | 연관 장소, 대체 장소 | 다음 행선지 예측 및 이동 흐름 기반 추천 | +| 관광지 집중률 방문자 추이 예측 정보 | 방문자 추이, 혼잡도 | 혼잡도 기반 추천 및 대체 장소 제안 확장 | ### 8.2 사용자 입력 데이터 -| 구분 | 데이터 | 활용 | -| --- | --- | --- | -| 음악 취향 | 장르, 분위기, 아티스트, 비선호 음악 | 초기 추천 필터링 | -| 여행 모드 | 산책, 드라이브, 카페 투어, 바다 보기, 축제, 야경 감상 | 장소 맥락과 음악 분위기 연결 | -| 동행 유형 | 혼자, 친구, 연인, 가족 | 추천 무드 및 Recap 톤 조정 | -| 실시간 상황 | 이동 중, 걷는 중, 쉬는 중, 사진 찍는 중 | 현재 행위에 맞는 음악 재정렬 | -| 무드 조정 | 더 신나게, 더 잔잔하게, 더 감성적으로, 더 로컬하게 | 추천 결과 즉시 보정 및 피드백 학습 | -| 기록 데이터 | 사진, 메모, 대표 음악, Recap 유형 | 여행 Recap 생성 | +| 구분 | 데이터 | 활용 | +| ----------- | ----------------------------------------------------- | ---------------------------------- | +| 음악 취향 | 장르, 분위기, 아티스트, 비선호 음악 | 초기 추천 필터링 | +| 여행 모드 | 산책, 드라이브, 카페 투어, 바다 보기, 축제, 야경 감상 | 장소 맥락과 음악 분위기 연결 | +| 동행 유형 | 혼자, 친구, 연인, 가족 | 추천 무드 및 Recap 톤 조정 | +| 실시간 상황 | 이동 중, 걷는 중, 쉬는 중, 사진 찍는 중 | 현재 행위에 맞는 음악 재정렬 | +| 무드 조정 | 더 신나게, 더 잔잔하게, 더 감성적으로, 더 로컬하게 | 추천 결과 즉시 보정 및 피드백 학습 | +| 기록 데이터 | 사진, 메모, 대표 음악, Recap 유형 | 여행 Recap 생성 | ### 8.3 서비스 행동 로그 -| 이벤트 | 수집 데이터 | 활용 | -| --- | --- | --- | -| 외부 링크 열기 | track_id, 플랫폼, 위치, 관광 모드 | 개인화 추천 학습 | -| 다음/이전 이동 | track_id, 이동 방향, 위치, 관광 모드 | 부적합 음악 감지 | -| 좋아요/저장 | track_id, 추천 컨텍스트, 시각 | 선호 음악 및 지역 트렌드 집계 | -| 무드 조정 | 조정 방향, 직전 추천 결과 | 추천 랭킹 보정 | -| 위치 변동 | GPS, 주변 POI ID, 타임스탬프 | 여행 경로 및 Recap 생성 | -| 리캡 저장 | 사진, 위치, 음악, 시간, 무드 | 독립 리캡 또는 현재 여행 로그 구성원 생성 | +| 이벤트 | 수집 데이터 | 활용 | +| -------------- | ------------------------------------ | ----------------------------------------- | +| 외부 링크 열기 | track_id, 플랫폼, 위치, 관광 모드 | 개인화 추천 학습 | +| 다음/이전 이동 | track_id, 이동 방향, 위치, 관광 모드 | 부적합 음악 감지 | +| 좋아요/저장 | track_id, 추천 컨텍스트, 시각 | 선호 음악 및 지역 트렌드 집계 | +| 무드 조정 | 조정 방향, 직전 추천 결과 | 추천 랭킹 보정 | +| 위치 변동 | GPS, 주변 POI ID, 타임스탬프 | 여행 경로 및 Recap 생성 | +| 리캡 저장 | 사진, 위치, 음악, 시간, 무드 | 독립 리캡 또는 현재 여행 로그 구성원 생성 | --- @@ -266,12 +266,12 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 예를 들어 사용자가 “부산 광안리에서 산책 중이고 잔잔하고 청량한 음악을 듣고 싶다”고 입력하면, LLM은 다음과 같이 변환한다. -| 항목 | 구조화 결과 | -| --- | --- | -| 장소 | 부산 광안리 | -| 활동 | 산책 | -| 동행 | 미입력 | -| 무드 | 잔잔함, 청량함 | +| 항목 | 구조화 결과 | +| --------- | ------------------------- | +| 장소 | 부산 광안리 | +| 활동 | 산책 | +| 동행 | 미입력 | +| 무드 | 잔잔함, 청량함 | | 제외 조건 | 과하게 시끄러운 음악 제외 | ### 9.3 콜드 스타트 대응 @@ -287,11 +287,11 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 ### 9.4 개인화 전환 전략 -| 단계 | 사용자 로그 누적량 | 지역/트렌드 기반 추천 | 개인화 추천 | 전략 | -| --- | --- | --- | --- | --- | -| 초기 | 0건 | 100% | 0% | 관광 맥락과 사전 구축 코퍼스 중심 추천 | -| 탐색기 | 10~50건 | 70% | 30% | 사용자 반응을 일부 반영하되 안정성 우선 | -| 정착기 | 100건 이상 | 20% | 80% | 개인화 중심, 단 트렌드 비중 유지로 탐색성 확보 | +| 단계 | 사용자 로그 누적량 | 지역/트렌드 기반 추천 | 개인화 추천 | 전략 | +| ------ | ------------------ | --------------------- | ----------- | ---------------------------------------------- | +| 초기 | 0건 | 100% | 0% | 관광 맥락과 사전 구축 코퍼스 중심 추천 | +| 탐색기 | 10~50건 | 70% | 30% | 사용자 반응을 일부 반영하되 안정성 우선 | +| 정착기 | 100건 이상 | 20% | 80% | 개인화 중심, 단 트렌드 비중 유지로 탐색성 확보 | --- @@ -350,7 +350,7 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 ### P-07 로그 탐색 -여행모드에서 생성된 로그만 `다른사람 보기`와 `모든 사람 보기` 격자로 확인한다. +여행모드에서 생성된 로그만 `다른사람 보기`와 `내것만 보기` 격자로 확인한다. ### P-08 로그 상세 @@ -400,12 +400,12 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 ## 14. 담당 업무 -| 이름 | 담당 업무 | -| --- | --- | -| 한만욱 | 앱 전체 서비스 플로우 설계, 화면 구조 기획, 핵심 기능 간 사용자 경험 흐름 정리 | -| 이수경 | 앱 UI/UX 디자인 제작, 주요 화면 시안 구성, 서비스 콘셉트에 맞는 비주얼 방향성 설계 | +| 이름 | 담당 업무 | +| ------ | ------------------------------------------------------------------------------------------------ | +| 한만욱 | 앱 전체 서비스 플로우 설계, 화면 구조 기획, 핵심 기능 간 사용자 경험 흐름 정리 | +| 이수경 | 앱 UI/UX 디자인 제작, 주요 화면 시안 구성, 서비스 콘셉트에 맞는 비주얼 방향성 설계 | | 김재걸 | 관광 OpenAPI 활용 방안 조사, 데이터 연계 구조 정리, 사용자 상황 기반 플레이리스트 추천 로직 설계 | -| 홍채연 | 관광 트렌드 및 사용자 니즈 조사, 서비스 기획서 작성, 공모전 제출 문서 구성 | +| 홍채연 | 관광 트렌드 및 사용자 니즈 조사, 서비스 기획서 작성, 공모전 제출 문서 구성 | --- diff --git a/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md b/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md index a2d6d74..5a68ecd 100644 --- a/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md +++ b/docs/product/SOUNDLOG_PRODUCT_SPEC_V0_3_DETAILED.md @@ -16,12 +16,12 @@ MVP의 중심은 스트리밍이 아니라 `추천 -> 선택 -> 리캡 생성 -> 초기 아이디어였던 스포티파이 조작형 앱은 매력은 있지만 MVP 리스크가 크다. -| 문제 | 설명 | Soundlog의 선택 | -| --- | --- | --- | -| Spotify Premium 의존 | 재생 제어는 프리미엄, 활성 기기, 계정 상태에 크게 의존한다. | 재생 제어를 핵심 가치에서 제외한다. | -| 심사/정책 리스크 | 인앱 스트리밍, 재생 제어, 음원 저작권 처리가 커진다. | 곡 메타데이터와 외부 링크만 다룬다. | -| 사용 대상 축소 | Spotify를 쓰지 않는 사용자는 가치가 줄어든다. | 누구나 추천, 저장, 리캡을 쓸 수 있게 한다. | -| 제품 정체성 약화 | 음악 플레이어 보조앱처럼 보일 수 있다. | 여행을 음악으로 기록하는 서비스로 정의한다. | +| 문제 | 설명 | Soundlog의 선택 | +| -------------------- | ----------------------------------------------------------- | ------------------------------------------- | +| Spotify Premium 의존 | 재생 제어는 프리미엄, 활성 기기, 계정 상태에 크게 의존한다. | 재생 제어를 핵심 가치에서 제외한다. | +| 심사/정책 리스크 | 인앱 스트리밍, 재생 제어, 음원 저작권 처리가 커진다. | 곡 메타데이터와 외부 링크만 다룬다. | +| 사용 대상 축소 | Spotify를 쓰지 않는 사용자는 가치가 줄어든다. | 누구나 추천, 저장, 리캡을 쓸 수 있게 한다. | +| 제품 정체성 약화 | 음악 플레이어 보조앱처럼 보일 수 있다. | 여행을 음악으로 기록하는 서비스로 정의한다. | 따라서 Soundlog의 MVP 성공 기준은 “앱 안에서 음악을 틀 수 있는가”가 아니라 “내 여행을 음악과 장소가 붙은 콘텐츠로 남기고 다시 보고 싶어지는가”이다. @@ -41,21 +41,21 @@ Recap은 가장 작은 제품 기록 단위다. 카메라 촬영, 갤러리 선 필드: -| 필드 | 필수 | 설명 | -| --- | --- | --- | -| id | 필수 | Recap 식별자 | -| userId | 필수 | 작성자 | -| sessionId | 선택 | 여행모드에서 생성했을 때의 여행 세션 | -| photoUrl | 선택 | 촬영 또는 업로드 이미지 | -| lat/lng | 선택 | 내부 저장 GPS 위치. 좌표 문자열은 UI에 노출하지 않음 | -| placeName | 선택 | 사용자가 입력한 장소명 | -| recordedAt | 필수 | 저장 시각 | -| track | 선택 | 선택한 곡 | -| mood | 선택 | 잔잔한, 신나는 등 | -| travelState | 선택 | 바다, 드라이브 등 | -| caption | 선택 | 레거시 기록 호환용. 신규 작성 화면에서는 입력받지 않음 | -| templateId | 필수 | 앨범, LP, 필름, 지도 | -| visibility | 필수 | private 또는 public | +| 필드 | 필수 | 설명 | +| ----------- | ---- | ------------------------------------------------------ | +| id | 필수 | Recap 식별자 | +| userId | 필수 | 작성자 | +| sessionId | 선택 | 여행모드에서 생성했을 때의 여행 세션 | +| photoUrl | 선택 | 촬영 또는 업로드 이미지 | +| lat/lng | 선택 | 내부 저장 GPS 위치. 좌표 문자열은 UI에 노출하지 않음 | +| placeName | 선택 | 사용자가 입력한 장소명 | +| recordedAt | 필수 | 저장 시각 | +| track | 선택 | 선택한 곡 | +| mood | 선택 | 잔잔한, 신나는 등 | +| travelState | 선택 | 바다, 드라이브 등 | +| caption | 선택 | 레거시 기록 호환용. 신규 작성 화면에서는 입력받지 않음 | +| templateId | 필수 | 앨범, LP, 필름, 지도 | +| visibility | 필수 | private 또는 public | 현재 코드의 `MomentLog`, `RecapShareMoment`, `/v1/recap-captures`는 이 제품 Recap을 가리키는 레거시 기술명이다. 사용자 화면에는 Moment라는 별도 개념을 노출하지 않는다. @@ -96,12 +96,12 @@ Public Recap Marker는 공개 Recap이 지도에 남긴 핀이다. MVP 탭은 4개다. -| 탭 | 화면명 | 역할 | 기본 질문 | -| --- | --- | --- | --- | -| 1 | 지도 / 여행모드 | 여행모드 진입, 주변 공개 리캡 탐색, 내 리캡 지도 확인 | 지금 이 장소에 어떤 사운드 로그가 남아 있나? | -| 2 | 음악 추천 / 일상모드 | 현재 장소 기반 사운드트랙 추천 | 지금 여기서 뭘 들으면 좋을까? | -| 3 | 로그 | 남의 공개 로그 탐색, 내 로그 공개 범위 관리 | 다른 사람과 내가 남긴 사운드로그는 어떻게 보이나? | -| 4 | 마이페이지 | 계정, 권한, 취향, 공개 설정 관리 | 내 데이터와 취향을 어떻게 관리할까? | +| 탭 | 화면명 | 역할 | 기본 질문 | +| --- | -------------------- | ----------------------------------------------------- | ------------------------------------------------- | +| 1 | 지도 / 여행모드 | 여행모드 진입, 주변 공개 리캡 탐색, 내 리캡 지도 확인 | 지금 이 장소에 어떤 사운드 로그가 남아 있나? | +| 2 | 음악 추천 / 일상모드 | 현재 장소 기반 사운드트랙 추천 | 지금 여기서 뭘 들으면 좋을까? | +| 3 | 로그 | 남의 공개 로그 탐색, 내 로그 공개 범위 관리 | 다른 사람과 내가 남긴 사운드로그는 어떻게 보이나? | +| 4 | 마이페이지 | 계정, 권한, 취향, 공개 설정 관리 | 내 데이터와 취향을 어떻게 관리할까? | 카메라는 독립 콘텐츠 탭이 아니라 하단 중앙의 Recap 작성 액션이다. 진입점은 하단 중앙 카메라 버튼과 여행모드의 `기록 남기기`처럼 기록 맥락이 있는 CTA다. @@ -129,11 +129,11 @@ MVP 탭은 4개다. ### 5.3 로그 탐색 루프 -1. 로그 탭에서 `다른사람 보기` 또는 `모든 사람 보기`를 선택한다. +1. 로그 탭에서 `다른사람 보기` 또는 `내것만 보기`를 선택한다. 2. 격자형 카드에서 특정 Log를 선택해 상세로 진입한다. -3. `모든 사람 보기`에서는 내 로그의 공개/비공개 상태를 확인한다. +3. `내것만 보기`에서는 내 로그의 공개/비공개 상태를 확인한다. 4. 내 로그를 `전체공개`로 바꾸면 다른사람 보기와 지도 공개 핀에 노출된다. -5. 내 로그를 `비공개`로 바꾸면 모든 사람 보기에서만 볼 수 있다. +5. 내 로그를 `비공개`로 바꾸면 내것만 보기에서만 볼 수 있다. ## 6. 화면별 상세 명세 @@ -158,7 +158,7 @@ MVP 탭은 4개다. - 로그인 전에는 지도, 추천, 리캡, 마이페이지 진입을 막는다. - 로그인 실패 시 원인을 안내하고 같은 화면에서 재시도할 수 있게 한다. -- 현재 MVP는 Soundlog 자체 이메일/비밀번호 계정만 제공한다. Apple/Kakao 로그인은 공급자 키, 서버 검증, 스토어 정책을 함께 확정한 뒤 별도 단계에서 추가한다. +- 현재 MVP는 이메일/비밀번호 로그인을 제공한다. Apple/Kakao 로그인은 공급자 키, 서버 검증, 스토어 정책을 함께 확정한 뒤 별도 단계에서 추가한다. - 위치 권한은 온보딩 첫 화면에서 바로 요청하지 않는다. 완료 조건: @@ -184,11 +184,11 @@ MVP 탭은 4개다. 지도 필터: -| 필터 | 보이는 것 | CTA | -| --- | --- | --- | -| 장소 보기 | 현재 위치, 주변 장소, 여행모드 진입 카드 | 여행모드 시작 또는 기록 남기기 | -| 전체 리캡 | 300m 이내 공개 Recap 핀 | CTA 숨김 | -| 내 리캡 | 내 전체 private/public Recap의 방문 좌표 클러스터 | CTA 숨김 | +| 필터 | 보이는 것 | CTA | +| --------- | ------------------------------------------------- | ------------------------------ | +| 장소 보기 | 현재 위치, 주변 장소, 여행모드 진입 카드 | 여행모드 시작 또는 기록 남기기 | +| 전체 리캡 | 300m 이내 공개 Recap 핀 | CTA 숨김 | +| 내 리캡 | 내 전체 private/public Recap의 방문 좌표 클러스터 | CTA 숨김 | CTA 정책: @@ -234,14 +234,14 @@ CTA 정책: 입력값: -| 입력 | 예시 | 필수 여부 | -| --- | --- | --- | -| 위치 | lat/lng | 권장 | -| 장소 | 성수동 카페거리 | 위치 없을 때 대체 | -| 여행 상태 | 바다, 드라이브, 산책, 카페, 야경 | 필수 기본값 | -| 무드 | 잔잔한, 신나는, 시원한, 설레는, 감성적인 | 필수 기본값 | -| 취향 | 선호 장르, 저장 곡 | 선택 | -| 시간대 | 오후, 밤 | 자동 | +| 입력 | 예시 | 필수 여부 | +| --------- | ---------------------------------------- | ----------------- | +| 위치 | lat/lng | 권장 | +| 장소 | 성수동 카페거리 | 위치 없을 때 대체 | +| 여행 상태 | 바다, 드라이브, 산책, 카페, 야경 | 필수 기본값 | +| 무드 | 잔잔한, 신나는, 시원한, 설레는, 감성적인 | 필수 기본값 | +| 취향 | 선호 장르, 저장 곡 | 선택 | +| 시간대 | 오후, 밤 | 자동 | 추천 결과: @@ -282,16 +282,16 @@ CTA 정책: 작성 필드: -| 필드 | 기본값 | -| --- | --- | -| 사진 | 촬영 이미지 또는 없음 | -| 위치 | 현재 GPS 위치를 내부 저장 | -| 장소 | 빈 값에서 시작하며 사용자가 직접 입력 | -| 시간 | 촬영 시각으로 고정, 사용자가 수정할 수 없음 | -| 음악 | 현재 선택 곡 또는 없음 | -| 무드 | 마지막 선택 무드 | +| 필드 | 기본값 | +| ----------- | ----------------------------------------------- | +| 사진 | 촬영 이미지 또는 없음 | +| 위치 | 현재 GPS 위치를 내부 저장 | +| 장소 | 빈 값에서 시작하며 사용자가 직접 입력 | +| 시간 | 촬영 시각으로 고정, 사용자가 수정할 수 없음 | +| 음악 | 현재 선택 곡 또는 없음 | +| 무드 | 마지막 선택 무드 | | 표현 템플릿 | 필름 기본값, 사용자가 앨범/LP/필름/지도 중 선택 | -| 공개 범위 | 나만보기 기본값 | +| 공개 범위 | 나만보기 기본값 | 저장 정책: @@ -312,10 +312,10 @@ CTA 정책: 섹션: -| 섹션 | 설명 | -| --- | --- | +| 섹션 | 설명 | +| ------------- | --------------------------------------- | | 다른사람 보기 | visibility가 public인 다른 사용자의 Log | -| 모든 사람 보기 | 내 계정에 저장된 private/public Log와 공개 Log | +| 내것만 보기 | 내 계정에 저장된 private/public Log | 로그 판별 기준은 `sessionId` 존재 여부다. 같은 여행 세션에 Recap이 하나만 있어도 Log이며, `sessionId`가 없는 독립 Recap은 Log가 아니다. @@ -339,7 +339,7 @@ CTA 정책: 내 로그 공개 설정: - `전체공개`: 다른사람 보기와 지도 공개 영역에 표시 -- `비공개`: 모든 사람 보기의 내 로그로만 표시 +- `비공개`: 내것만 보기에서만 표시 - 서버에 저장되지 않은 로컬 로그는 상세 진입만 가능하고 공개 범위 변경은 비활성화 빈 상태: @@ -357,12 +357,12 @@ CTA 정책: 템플릿: -| 템플릿 | 용도 | 주요 요소 | -| --- | --- | --- | -| 앨범 커버 | 대표 순간 강조 | 사진, 제목, 대표 곡, 장소 | -| 필름 | 시간 흐름 강조 | 여러 Recap 컷, 촬영 시간, 장소 | -| LP | 음악 정체성 강조 | LP 그래픽, 트랙리스트, 아티스트 | -| 지도 엽서 | 장소성 강조 | 지도, 핀, 경로, 대표 곡 | +| 템플릿 | 용도 | 주요 요소 | +| --------- | ---------------- | ------------------------------- | +| 앨범 커버 | 대표 순간 강조 | 사진, 제목, 대표 곡, 장소 | +| 필름 | 시간 흐름 강조 | 여러 Recap 컷, 촬영 시간, 장소 | +| LP | 음악 정체성 강조 | LP 그래픽, 트랙리스트, 아티스트 | +| 지도 엽서 | 장소성 강조 | 지도, 핀, 경로, 대표 곡 | 저장 전 설정 가능 요소: @@ -402,9 +402,9 @@ CTA 정책: 공개 범위: -| 상태 | 설명 | -| --- | --- | -| 나만보기 | 내 계정에서만 확인 가능 | +| 상태 | 설명 | +| -------- | ----------------------------------- | +| 나만보기 | 내 계정에서만 확인 가능 | | 전체공개 | 주변 사용자 지도에 공개 핀으로 노출 | 공개 전 확인 문구: @@ -608,16 +608,16 @@ MVP 이후 흐름: ## 9. 데이터 모델 요약 -| 모델 | 핵심 필드 | -| --- | --- | -| User | id, provider, displayName, profileImage, tasteProfile | -| TravelSession | id, userId, title, startedAt, endedAt, status, routePoints | -| Recap 원본(legacy MomentLog) | id, userId, sessionId, photoUrl, lat, lng, placeName, recordedAt, trackId, mood, state, caption, templateId, visibility | -| Track | id, title, artist, albumImageUrl, spotifyUrl, youtubeUrl | -| Log 집계(legacy server Recap) | id, userId, sessionId, title, visibility, recapCount, routePoints, createdAt | -| LogRecap 관계(legacy RecapMoment) | logId, recapId, order | -| PublicMarker | recapId, lat, lng, placeName, distanceMeters | -| Report | id, reporterId, targetType, targetId, reason | +| 모델 | 핵심 필드 | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| User | id, provider, displayName, profileImage, tasteProfile | +| TravelSession | id, userId, title, startedAt, endedAt, status, routePoints | +| Recap 원본(legacy MomentLog) | id, userId, sessionId, photoUrl, lat, lng, placeName, recordedAt, trackId, mood, state, caption, templateId, visibility | +| Track | id, title, artist, albumImageUrl, spotifyUrl, youtubeUrl | +| Log 집계(legacy server Recap) | id, userId, sessionId, title, visibility, recapCount, routePoints, createdAt | +| LogRecap 관계(legacy RecapMoment) | logId, recapId, order | +| PublicMarker | recapId, lat, lng, placeName, distanceMeters | +| Report | id, reporterId, targetType, targetId, reason | ## 10. 권한과 개인정보 @@ -648,32 +648,32 @@ MVP 이후 흐름: ## 11. 상태와 예외 -| 상황 | 처리 | -| --- | --- | -| 위치 권한 거부 | 수동 장소 선택, 권한 설정 이동 제공 | -| 지도 로딩 실패 | 기본 지도 영역 유지, 핀만 실패 상태 | -| 추천 API 실패 | 이전 추천 또는 샘플 추천 fallback | -| 외부 링크 실패 | YouTube 검색 링크 fallback | -| 사진 업로드 실패 | 로컬 임시 저장 후 재시도 | -| Recap 저장 실패 | 편집 초안 유지 | -| 공개 Recap 위치 없음 | 공개 전환 차단 | -| 로그인 만료 | 재로그인 유도, 작성 중 데이터 임시 보존 | +| 상황 | 처리 | +| -------------------- | --------------------------------------- | +| 위치 권한 거부 | 수동 장소 선택, 권한 설정 이동 제공 | +| 지도 로딩 실패 | 기본 지도 영역 유지, 핀만 실패 상태 | +| 추천 API 실패 | 이전 추천 또는 샘플 추천 fallback | +| 외부 링크 실패 | YouTube 검색 링크 fallback | +| 사진 업로드 실패 | 로컬 임시 저장 후 재시도 | +| Recap 저장 실패 | 편집 초안 유지 | +| 공개 Recap 위치 없음 | 공개 전환 차단 | +| 로그인 만료 | 재로그인 유도, 작성 중 데이터 임시 보존 | ## 12. 이벤트 설계 -| 이벤트 | 발생 시점 | 주요 속성 | -| --- | --- | --- | -| onboarding_completed | 온보딩 완료 | provider | -| travel_mode_started | 여행모드 시작 | lat, lng, state, mood | -| travel_mode_ended | 여행모드 종료 | duration, recapCount | -| recommendation_requested | 추천 요청 | lat, lng, state, mood | -| track_selected | 곡 선택 | trackId, source | -| external_music_opened | 외부 링크 열기 | trackId, platform | -| recap_saved | 단일 Recap 저장 | hasPhoto, hasTrack, hasLocation, templateId | -| travel_log_created | 여행 Log 확정 | sessionId, recapCount, routePointCount | -| recap_visibility_changed | 공개 범위 변경 | from, to | -| recap_marker_opened | 지도 핀 열기 | recapId, distance | -| recap_shared | 공유 실행 | method | +| 이벤트 | 발생 시점 | 주요 속성 | +| ------------------------ | --------------- | ------------------------------------------- | +| onboarding_completed | 온보딩 완료 | provider | +| travel_mode_started | 여행모드 시작 | lat, lng, state, mood | +| travel_mode_ended | 여행모드 종료 | duration, recapCount | +| recommendation_requested | 추천 요청 | lat, lng, state, mood | +| track_selected | 곡 선택 | trackId, source | +| external_music_opened | 외부 링크 열기 | trackId, platform | +| recap_saved | 단일 Recap 저장 | hasPhoto, hasTrack, hasLocation, templateId | +| travel_log_created | 여행 Log 확정 | sessionId, recapCount, routePointCount | +| recap_visibility_changed | 공개 범위 변경 | from, to | +| recap_marker_opened | 지도 핀 열기 | recapId, distance | +| recap_shared | 공유 실행 | method | ## 13. 우선순위 @@ -751,13 +751,13 @@ MVP 이후 흐름: 7. 추천 탭에서 `산책/잔잔한` 추천 확인 8. 곡 선택 후 외부 링크 열기 9. 지도 탭에서 기록 남기기 -10. 로그 탭에서 모든 사람 보기 확인 +10. 로그 탭에서 내것만 보기 확인 11. 필요하면 비공개 상태 유지 ### 시나리오 B: 내 로그 공개로 전환하기 1. 로그 탭 진입 -2. `모든 사람 보기` 선택 +2. `내것만 보기` 선택 3. 기존 Log 카드의 공개 상태 토글 선택 4. 전체공개 선택 5. 지도 탭 전체 리캡 필터에서 핀 확인 diff --git a/eas.json b/eas.json index 52657f3..96dcae2 100644 --- a/eas.json +++ b/eas.json @@ -8,25 +8,28 @@ "developmentClient": true, "distribution": "internal", "env": { - "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://soundlog.shop/api/soundlog", - "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server" + "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://api.soundlog.shop", + "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server", + "EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN": "https://api.soundlog.shop" } }, "preview": { "distribution": "internal", "env": { - "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://soundlog.shop/api/soundlog", - "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server" + "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://api.soundlog.shop", + "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server", + "EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN": "https://api.soundlog.shop" } }, "production": { "autoIncrement": true, "env": { - "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://soundlog.shop/api/soundlog", + "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://api.soundlog.shop", "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server", - "EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL": "https://soundlog.shop/legal/privacy", + "EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL": "https://api.soundlog.shop/legal/privacy", "EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL": "support@soundlog.shop", - "EXPO_PUBLIC_SOUNDLOG_TERMS_URL": "https://soundlog.shop/legal/terms" + "EXPO_PUBLIC_SOUNDLOG_TERMS_URL": "https://api.soundlog.shop/legal/terms", + "EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN": "https://api.soundlog.shop" } } }, diff --git a/package-lock.json b/package-lock.json index ad3f9cc..8bb4a7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,22 +11,22 @@ "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "2.2.0", "@tanstack/react-query": "^5.100.14", - "expo": "~56.0.15", - "expo-build-properties": "~56.0.22", + "expo": "~56.0.18", + "expo-build-properties": "~56.0.24", "expo-camera": "~56.0.8", - "expo-constants": "~56.0.15", - "expo-dev-client": "~56.0.22", + "expo-constants": "~56.0.21", + "expo-dev-client": "~56.0.24", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-image": "~56.0.11", - "expo-image-picker": "~56.0.20", + "expo-image-picker": "~56.0.22", "expo-linear-gradient": "~56.0.4", - "expo-linking": "~56.0.15", - "expo-location": "~56.0.20", - "expo-media-library": "~56.0.9", - "expo-router": "~56.2.14", + "expo-linking": "~56.0.16", + "expo-location": "~56.0.22", + "expo-media-library": "~56.0.10", + "expo-router": "~56.2.17", "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.21", + "expo-sharing": "~56.0.23", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", "react": "19.2.3", @@ -35,7 +35,7 @@ "react-native-maps": "1.27.2", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2", + "react-native-screens": "~4.26.0", "react-native-view-shot": "5.1.0", "react-native-web": "^0.21.2", "react-native-worklets": "0.8.3", @@ -48,7 +48,8 @@ "babel-preset-expo": "^56.0.12", "prettier-plugin-tailwindcss": "^0.5.14", "tailwindcss": "^3.4.19", - "typescript": "~6.0.3" + "typescript": "~6.0.3", + "vitest": "^4.1.10" } }, "node_modules/@adobe/css-tools": { @@ -1220,6 +1221,40 @@ "node": ">=0.8.0" } }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@expo-google-fonts/material-symbols": { "version": "0.4.38", "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.38.tgz", @@ -1236,15 +1271,15 @@ } }, "node_modules/@expo/config": { - "version": "56.0.11", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.11.tgz", - "integrity": "sha512-Rt3U9Rqr6midz/sDeubFN5fefR0KzHpcEEgAUnV98XsLSSTnfzIxoC1blICb7pUUscKc8TtlVyn7/Gita2wnOQ==", + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.13.tgz", + "integrity": "sha512-Fi1J1mjD3O2qDQsUPTHhsFXoHduZ5gSkWvCeyKxyp3R9fXgXrmhbpo6T+lZJG8x2t6xL1P/SIc6bLdsTJkKaOg==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~56.0.11", + "@expo/config-plugins": "~56.0.14", "@expo/config-types": "^56.0.7", "@expo/json-file": "^10.2.0", - "@expo/require-utils": "^56.1.4", + "@expo/require-utils": "^56.1.6", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", @@ -1254,15 +1289,15 @@ } }, "node_modules/@expo/config-plugins": { - "version": "56.0.12", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.12.tgz", - "integrity": "sha512-UKjPEOkvxBzTvjghmjUECURDoJLPJIFIevB0JQCe8l9Fg4yfy2fabU5LU3Kfrmmu1/8et/93bucCnABEmoxYEw==", + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.14.tgz", + "integrity": "sha512-pdjBwH67BF1dnim4N/OxgBshhil27eu3C9uuhwzzK2Bnzl6bIcdt9WjEVuMXluFnEpWe0ws5sxE8NvBR5Ms7bQ==", "license": "MIT", "dependencies": { "@expo/config-types": "^56.0.7", "@expo/json-file": "~10.2.0", "@expo/plist": "^0.7.0", - "@expo/require-utils": "^56.1.4", + "@expo/require-utils": "^56.1.6", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", @@ -1332,9 +1367,9 @@ } }, "node_modules/@expo/env": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.1.tgz", - "integrity": "sha512-3c9Mg9x0HmGPEsVrGAGyEDJsNUOZ55cZvZ47/HLmXh7MHV9Zv7My73wThklKrObaBBoMfE4YqpKjYKDRzojpjQ==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.2.tgz", + "integrity": "sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -1352,9 +1387,9 @@ "license": "MIT" }, "node_modules/@expo/fingerprint": { - "version": "0.19.7", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.7.tgz", - "integrity": "sha512-Q04NyJE0E7qKGXepBjI8e0p983RrQGBWJcSICKyyLczsr5JhNuSmqw604aL7koaXG2ctrUL36qd332XiMS/s6w==", + "version": "0.19.9", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.9.tgz", + "integrity": "sha512-gphqoJt5dXPfq6iWFwSLKFcUi6gDnBy9GM4XuKkKbjQEqGyBH6v2iS16Oht6xfCBWkRSy28CNFsm8lKmoJVP2g==", "license": "MIT", "dependencies": { "@expo/env": "^2.3.1", @@ -1374,12 +1409,12 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.2.tgz", - "integrity": "sha512-qQUGaacqXduoFTCUQAMceIWYzlKU0xX4/BKTyh8TdVj0uHv9/W3MfHOy5yXoOqBVrSccXk++Gm4D/NfS5HnCNA==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.4.tgz", + "integrity": "sha512-QGdC9eeNyF7vozMV77wQQwX4z3ZJK8D707WSTcBTE/XI19T41g5NtGVM/BMqb1oBV+zSZlNwpOA6bXbbvtC2UA==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^56.1.4", + "@expo/require-utils": "^56.1.6", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", @@ -1389,12 +1424,12 @@ } }, "node_modules/@expo/inline-modules": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.13.tgz", - "integrity": "sha512-26RllWesRmYsAAo70cRcR9DaqXPKJct9MIGxZteS+Tkg25ljOkFeG7fYEsSazZOLpAslKBiilqtZykVYrxSzCw==", + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.15.tgz", + "integrity": "sha512-EtGaKjzsNF0Jp3Alcn6aKX/QOPEdf1P6i5qTm91mJBmJ4lcWbQkyPrCVNEZHjp989w7c4exd8myA6c0whHcghg==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~56.0.11" + "@expo/config-plugins": "~56.0.14" } }, "node_modules/@expo/json-file": { @@ -1408,12 +1443,12 @@ } }, "node_modules/@expo/local-build-cache-provider": { - "version": "56.0.9", - "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.9.tgz", - "integrity": "sha512-VMJC5ul7dXPfD2OO16ObPi6ym4H8vlnCBUuCM/KeKh0OvLRub35X8OB2uYrFQ+vbWkPemAEOWvk0oOptpPxbzA==", + "version": "56.0.11", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.11.tgz", + "integrity": "sha512-2SfYrb5vBs0Ra3bZfpvtJXJbS1oTSwo9DmG4bpZUW/sKUT0+kXHkPI9uGiUa687SIOEQuFBinylD3C9Z2lma3Q==", "license": "MIT", "dependencies": { - "@expo/config": "~56.0.11", + "@expo/config": "~56.0.13", "chalk": "^4.1.2" } }, @@ -1457,19 +1492,19 @@ } }, "node_modules/@expo/metro-config": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.16.tgz", - "integrity": "sha512-mxPZ23exC6kkEpPYQOteamaiWYER3uDk2IqKys7EJwtIKc3F1207Xtsdko/DNNG04DLwpN/WM2UlNl+Ak8uPRg==", + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.18.tgz", + "integrity": "sha512-X7ephejQE7YjL4401ObdfpluL0enEmLK4ZjMLJrhtkFh0ksZI7jQMvUHxLXZzCHD0SlMXa/RdB9abnWsOj1E5A==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~56.0.11", + "@expo/config": "~56.0.13", "@expo/env": "~2.3.1", "@expo/json-file": "~10.2.0", "@expo/metro": "~56.0.0", - "@expo/require-utils": "^56.1.4", + "@expo/require-utils": "^56.1.6", "@expo/spawn-async": "^1.8.0", "@jridgewell/gen-mapping": "^0.3.13", "@jridgewell/remapping": "^2.3.5", @@ -1551,9 +1586,9 @@ } }, "node_modules/@expo/metro-runtime": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.16.tgz", - "integrity": "sha512-UsKrBLOCa+kAVHW1S0HdGkM0hkiQXeh3fZIpjcriOmoNg/CVRgdKQhUxUkNoXAoPfT85uW1x2fzNbiYhPQO1SA==", + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-56.0.18.tgz", + "integrity": "sha512-j/y6F0uigTn5H/sfN1v/Db+ecrbb14sLsejBEQ7tNyKajrqM86D+yLTUqTP0KdufYqcXc/DfGVa+UmtvcdlIaA==", "license": "MIT", "dependencies": { "@expo/log-box": "^56.0.14", @@ -1576,9 +1611,9 @@ } }, "node_modules/@expo/osascript": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz", - "integrity": "sha512-wKIXL8UtbuX4KwavPasIW3CUcgTbYfjzLcgUhjyKUAYDEqMaf6gmU1bqz3ffBPTokmX+G8/vFG1ZuI9etQWukA==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz", + "integrity": "sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==", "license": "MIT", "dependencies": { "@expo/spawn-async": "^1.8.0" @@ -1588,12 +1623,12 @@ } }, "node_modules/@expo/package-manager": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.0.tgz", - "integrity": "sha512-s3W3eZafJDEyVL7W/jxj2Nz3eONKxSCU604S5xj8ijrVaRz83x0DnZznLf/UXQEI1w+FyibH68nHeQyk767b1A==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.1.tgz", + "integrity": "sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==", "license": "MIT", "dependencies": { - "@expo/json-file": "^11.0.0", + "@expo/json-file": "^11.0.1", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", @@ -1602,9 +1637,9 @@ } }, "node_modules/@expo/package-manager/node_modules/@expo/json-file": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.0.tgz", - "integrity": "sha512-pHJCETqFL5x5BzNV6cEPwjwuECgGmnl0bNmfHIJ6LM1tlh2eVXi5HEdit3zby/JO/B8Otk5cgcqtJXgvvUat3A==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.1.tgz", + "integrity": "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1623,27 +1658,27 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "56.0.19", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.19.tgz", - "integrity": "sha512-aP/7kGDPMmwY9C9cYMK4MfUiZk23KhN4AfnKz0eeRyg3Izw0OGdtzNDLIcchnqpDAwHsI0+LUHZhucGl1jQJ5A==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.21.tgz", + "integrity": "sha512-HjhcxGsi6OoDSQxxw06tRq27gRXfXLYlsiXj3cH0R4GKiNK4yQAINKUFup4R3DtYxYNde87eHaNX28IMBCm9qA==", "license": "MIT", "dependencies": { - "@expo/config": "~56.0.11", - "@expo/config-plugins": "~56.0.12", + "@expo/config": "~56.0.13", + "@expo/config-plugins": "~56.0.14", "@expo/config-types": "^56.0.7", - "@expo/image-utils": "^0.10.2", + "@expo/image-utils": "^0.10.4", "@expo/json-file": "^10.2.0", "@react-native/normalize-colors": "0.85.3", "debug": "^4.3.1", - "expo-modules-autolinking": "~56.0.19", + "expo-modules-autolinking": "~56.0.21", "resolve-from": "^5.0.0", "semver": "^7.6.0" } }, "node_modules/@expo/require-utils": { - "version": "56.1.4", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.4.tgz", - "integrity": "sha512-IX7XXg9obnrH3ni0TClBbVKgqk0nT0bjTEPTBtbD+WgIeZ0hpcgwxMJH3j5uBsUqEAq1jqUAkJWrPcg7ZRSV7g==", + "version": "56.1.6", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.6.tgz", + "integrity": "sha512-e68r6X+c7yPxJP0gac/CaJc/RCHeLB2Bf4MyCuqfNM4YXfl1ONmQbQlRL0RbwBfEHn6A6UZKj+5UX9j0Im646g==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1651,7 +1686,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -1690,9 +1725,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "56.0.21", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.21.tgz", - "integrity": "sha512-DosrgEiDqxc/tPH39/Nf5BmVxdeJ616xk/ihu7CJ7Xuzkr79E6/fp4IA15KzBiTdJeH3id1adhIwj8RX1jnh6A==", + "version": "56.0.24", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-56.0.24.tgz", + "integrity": "sha512-5i3ON+MlRwDneYIb2X1kyrXnj6Ew/0QMrU1TpnCccFW0P4jexV4NrqvVz/hkv3xkgQxpjXyxGaCCWn92NeMxKQ==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -1836,6 +1871,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1871,6 +1928,16 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -1952,23 +2019,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", - "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.15", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.12", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.7", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.3", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1988,15 +2056,15 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", - "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2009,9 +2077,9 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", - "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2024,12 +2092,12 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2042,12 +2110,12 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", - "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2065,12 +2133,12 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -2088,13 +2156,14 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", - "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2107,12 +2176,12 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2125,9 +2194,9 @@ } }, "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2155,16 +2224,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", - "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.5", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-effect-event": "0.0.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", @@ -2182,15 +2251,15 @@ } }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", - "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2203,12 +2272,12 @@ } }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -2226,9 +2295,9 @@ } }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2241,12 +2310,12 @@ } }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2259,9 +2328,9 @@ } }, "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2274,9 +2343,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2289,14 +2358,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", - "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2314,9 +2383,9 @@ } }, "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2329,12 +2398,12 @@ } }, "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -2352,9 +2421,9 @@ } }, "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2385,13 +2454,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz", - "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2409,12 +2478,12 @@ } }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -2432,9 +2501,9 @@ } }, "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2543,12 +2612,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -2561,9 +2630,9 @@ } }, "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2945,12 +3014,280 @@ } } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.100.14", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz", @@ -3070,6 +3407,17 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3077,12 +3425,37 @@ "license": "MIT", "peer": true }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -3176,26 +3549,139 @@ "dev": true, "license": "MIT", "dependencies": { - "@vercel/routing-utils": "6.4.0", - "pretty-cache-header": "^1.0.0", - "zod": "^3.22.0" + "@vercel/routing-utils": "6.4.0", + "pretty-cache-header": "^1.0.0", + "zod": "^3.22.0" + }, + "bin": { + "config": "dist/cli.js" + } + }, + "node_modules/@vercel/routing-utils": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@vercel/routing-utils/-/routing-utils-6.4.0.tgz", + "integrity": "sha512-SnL/449ftNIDt1cB/oDqFmdZWywckNA6Q/S3WuzzsxBdE9DxmNn498qMKRtgx0W6EW3Mm/wVmg7aNJrfElMBTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "path-to-regexp": "6.1.0", + "path-to-regexp-updated": "npm:path-to-regexp@6.3.0" + }, + "optionalDependencies": { + "ajv": "^6.12.3" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, - "bin": { - "config": "dist/cli.js" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@vercel/routing-utils": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/@vercel/routing-utils/-/routing-utils-6.4.0.tgz", - "integrity": "sha512-SnL/449ftNIDt1cB/oDqFmdZWywckNA6Q/S3WuzzsxBdE9DxmNn498qMKRtgx0W6EW3Mm/wVmg7aNJrfElMBTA==", + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", "dependencies": { - "path-to-regexp": "6.1.0", - "path-to-regexp-updated": "npm:path-to-regexp@6.3.0" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, - "optionalDependencies": { - "ajv": "^6.12.3" + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/@xmldom/xmldom": { @@ -3254,9 +3740,9 @@ } }, "node_modules/agent-cli-detector": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.2.tgz", - "integrity": "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.4.tgz", + "integrity": "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q==", "license": "MIT", "bin": { "agent-cli-detector": "dist/cli.js" @@ -3404,6 +3890,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/babel-plugin-module-resolver": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/babel-plugin-module-resolver/-/babel-plugin-module-resolver-5.0.3.tgz", @@ -3426,9 +3922,9 @@ "license": "MIT" }, "node_modules/babel-plugin-module-resolver/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -3612,9 +4108,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "56.0.17", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.17.tgz", - "integrity": "sha512-yanAlbzaMMNqnju/uMnZsf3ltgssG71UubkfD+2iMaoXGNIj+TODEs9hF12jrTX2lzq/zH0HWSuRLZLNalf8fw==", + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.18.tgz", + "integrity": "sha512-QHEa3AasSFjnA08ciQkKKdVKWPRc2ye6UvxwCweYr3rKATBwTUVOuA0mzRD2PFVdB16R9yqgHROpiqL+K4A0Ww==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -3663,7 +4159,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^56.0.22", + "expo-widgets": "^56.0.24", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -3780,15 +4276,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -3901,6 +4397,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4434,6 +4940,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4474,6 +4987,16 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4492,32 +5015,42 @@ "node": ">=6" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expo": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.15.tgz", - "integrity": "sha512-Tnas9Sq1fDY865rhSQ4266Kd4GULEUyBEBEchbNLQsiY6U+AAt4MgCKJUsRvUkNHdaZwnGzexy6hdnOjsgvNcA==", + "version": "56.0.18", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.18.tgz", + "integrity": "sha512-xEYiFlkeOzEKEEJlE6qFYtSo+HOwcIDcwZodRqLQRlhPOHU8bYzutyednlcKbytrS6NMEFpwsBwt5LiN1ZLEjA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^56.1.19", - "@expo/config": "~56.0.11", - "@expo/config-plugins": "~56.0.12", + "@expo/cli": "^56.1.22", + "@expo/config": "~56.0.13", + "@expo/config-plugins": "~56.0.14", "@expo/devtools": "~56.0.2", "@expo/dom-webview": "~56.0.6", - "@expo/fingerprint": "^0.19.7", - "@expo/local-build-cache-provider": "^56.0.9", + "@expo/fingerprint": "^0.19.9", + "@expo/local-build-cache-provider": "^56.0.11", "@expo/log-box": "^56.0.14", "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.16", + "@expo/metro-config": "~56.0.18", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~56.0.17", - "expo-asset": "~56.0.19", - "expo-constants": "~56.0.20", + "babel-preset-expo": "~56.0.18", + "expo-asset": "~56.0.21", + "expo-constants": "~56.0.22", "expo-file-system": "~56.0.8", "expo-font": "~56.0.7", "expo-keep-awake": "~56.0.3", - "expo-modules-autolinking": "~56.0.19", - "expo-modules-core": "~56.0.20", + "expo-modules-autolinking": "~56.0.21", + "expo-modules-core": "~56.0.22", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -4555,13 +5088,13 @@ } }, "node_modules/expo-asset": { - "version": "56.0.19", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.19.tgz", - "integrity": "sha512-huGY0bVfYUivNOir+iUEjjW9IbNHzLFNo8d6FGh22u1OsXcFR7Za3vpu5V1gMp0dc31wiqmsXkTazjm+Rro3vA==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.21.tgz", + "integrity": "sha512-0p5JByDPy6MxB1S2TBoz38r6I8IYx6z5uD/e00nc0sc9f8xRpcZPCJALbOsGW8PjOwXNfTyEE9KsC6xTLjC2ZA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.10.2", - "expo-constants": "~56.0.20" + "@expo/image-utils": "^0.10.4", + "expo-constants": "~56.0.22" }, "peerDependencies": { "expo": "*", @@ -4570,9 +5103,9 @@ } }, "node_modules/expo-build-properties": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.22.tgz", - "integrity": "sha512-HbWHkpHUJxorDO/7FKtniQVsbylkIW+h1/wIJCNNGF5WAm+VBrF2M9Jnm+Uho5BFQBKJ/QMynyfxr6Ans9UrpQ==", + "version": "56.0.24", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.24.tgz", + "integrity": "sha512-qqaxfKAc8BenYWVj7I6ZLXATAf/7Gu5HasMJhj6epoQCT2pwUYJKCht1mrllkdZg+32Da5UdL1n2bjvKoaCdGw==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^56.0.2", @@ -4604,9 +5137,9 @@ } }, "node_modules/expo-constants": { - "version": "56.0.20", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.20.tgz", - "integrity": "sha512-4HgoVvUiMvcqujr/CUj7L1tumLWj8WX0PLM77OriDPoaJrMEwUHd841m4o4IoUyMPVT8XTiTiPFwJtGali8+9Q==", + "version": "56.0.22", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.22.tgz", + "integrity": "sha512-XNRssTBSGDR3gsaYhhp513WdpPsLi89wgfYT0wLsCtMY8vXAgXR4jNlaYGXFCnvNVbx9gEDSBHOSntqRK1i8Lw==", "license": "MIT", "dependencies": { "@expo/env": "~2.3.1" @@ -4631,13 +5164,13 @@ } }, "node_modules/expo-dev-client": { - "version": "56.0.22", - "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.22.tgz", - "integrity": "sha512-LKhgIlMu8DkmhTtfurpX9YMPsnv4QI1hCcDZqcFn+jlB+s6yE1THB9UWZ+BVtef8kYMzsw+BH3ClXvNkRd0vXw==", + "version": "56.0.24", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.24.tgz", + "integrity": "sha512-Nc5EKGiq7fVP60ONHiXVh4jTNHQbajbYwGA69Ahm02hKBp27CBnGkOxHp04hkgbcN6JLD8F1Y+W8SjXb0ioUJg==", "license": "MIT", "dependencies": { - "expo-dev-launcher": "~56.0.23", - "expo-dev-menu": "~56.0.19", + "expo-dev-launcher": "~56.0.25", + "expo-dev-menu": "~56.0.21", "expo-dev-menu-interface": "~56.0.0", "expo-manifests": "~56.0.4", "expo-updates-interface": "~56.0.1" @@ -4647,13 +5180,13 @@ } }, "node_modules/expo-dev-launcher": { - "version": "56.0.23", - "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.23.tgz", - "integrity": "sha512-OKgLAn6m15Lu+Qyg8WA0HPurFR9pxhsrHUvvafv8pTZHspB0BbOvBwhOJdxgiMfR2LHq4032cnMf8164Vzqeng==", + "version": "56.0.25", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.25.tgz", + "integrity": "sha512-oTFCHGpy4I1+7dd58ltNOrwKTLkYATtaRHURhZgcvJRddsFF+OMcBJbJTB6CJu5DlFyHtwVd9nNlZuo49xClWw==", "license": "MIT", "dependencies": { "@expo/schema-utils": "^56.0.2", - "expo-dev-menu": "~56.0.19", + "expo-dev-menu": "~56.0.21", "expo-manifests": "~56.0.4" }, "peerDependencies": { @@ -4662,9 +5195,9 @@ } }, "node_modules/expo-dev-menu": { - "version": "56.0.19", - "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.19.tgz", - "integrity": "sha512-sm6dfnkq3lgbn39Kd4yjFFm7UQAZqm6Hn3OpjagGtWBoWByQae3nKh1MF5K4Peh172TqUPJTOhjRasnZSkUkOQ==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.21.tgz", + "integrity": "sha512-+WSpwdT8iPAwygpuJX3qiF8q/9S9/5doRYzwBI/+eBdUY6YhLt69ajlndf6gid+IdbGDS3kgTfqB++XQ8rE6mg==", "license": "MIT", "dependencies": { "expo-dev-menu-interface": "~56.0.0" @@ -4748,9 +5281,9 @@ } }, "node_modules/expo-image-picker": { - "version": "56.0.20", - "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.20.tgz", - "integrity": "sha512-75xP7WA7Z8b4+5wYCu1cISg90Cu9CowneGMFuAgmmpNxCXLnBbrJXO6XqYIM69KAjEv7PApjgQF3h6RdBqus4w==", + "version": "56.0.22", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.22.tgz", + "integrity": "sha512-4QrLk07a+WqCSZkmk2tbwFa57HgPYZisM2/n+OQ5kS3ty923x9dfxFXSdh17zF1lRCHhpsXj8H24+0IjcgVqIQ==", "license": "MIT", "dependencies": { "expo-image-loader": "~56.0.3" @@ -4777,12 +5310,12 @@ } }, "node_modules/expo-linking": { - "version": "56.0.15", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.15.tgz", - "integrity": "sha512-V3tkCxHIWO624ayw2L5Nfi0/bPgYy/0MgmoQR7g3FAKF4nyAayK8IhgAInz4MZ5Ao5Bb+5uZWDdUaS4yyEb+Gw==", + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.16.tgz", + "integrity": "sha512-rI/ZE22t0sgYqnE0WnDUbld/vTzOiTxEX/uKuNFGTIUZ9tmes/nvcqJufEMqRV7u6REUEIBCFxbROOBibvwG4g==", "license": "MIT", "dependencies": { - "expo-constants": "~56.0.20", + "expo-constants": "~56.0.22", "invariant": "^2.2.4" }, "peerDependencies": { @@ -4791,12 +5324,12 @@ } }, "node_modules/expo-location": { - "version": "56.0.20", - "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.20.tgz", - "integrity": "sha512-yQhkHgjW47dz7cbRg1UcsGWuWNQ1Yh+nEqmZkAdFdDVw6PXuxV63c8VNyXBzvuOgNuBd2TSstSuOb0t3tFJJZg==", + "version": "56.0.22", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.22.tgz", + "integrity": "sha512-gX4WlukHV3s7N7qyhHbkZ88DsbJ2sOS04CHDaxAHGN/1b0hcJv0SqpJ4z+DyFZ4AT223Vd8RhYejiPkU1N+3kA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.10.2" + "@expo/image-utils": "^0.10.4" }, "peerDependencies": { "expo": "*" @@ -4815,9 +5348,9 @@ } }, "node_modules/expo-media-library": { - "version": "56.0.9", - "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-56.0.9.tgz", - "integrity": "sha512-SD92CiE/UPXpDmvSrOWtMX12TeGhN4w9ZdePNNxa43bC9uTnEAS3YTLAkNaFHHbCSUlWSNVLW4yPZhNgugAO/g==", + "version": "56.0.10", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-56.0.10.tgz", + "integrity": "sha512-sTYv209n83iSt9afMH/yM4n+T80jaZcd7oxdZR6rnqvvRmPu/9sWVqsCLKCIXyisbhXSEjV+YipapErO3XSLVw==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -4825,12 +5358,12 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "56.0.19", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.19.tgz", - "integrity": "sha512-ztzTzS21fbq4oJQItSgT+WWJMZXGsC0GdX/cDN/GBhCTPiZz8foHkVa14DiLa5CWd7pEZ9EeSMdHDcAEc2dZ5Q==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.21.tgz", + "integrity": "sha512-DG0ykIaC4cTXmEL49x7b8Pet/ZCcoNgQ00ooeroH/p8TKLMRs/KULiv9kiHWZRL3n0E0llsaHSvfKj9fAsaI/A==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^56.1.4", + "@expo/require-utils": "^56.1.6", "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", "commander": "^7.2.0" @@ -4840,9 +5373,9 @@ } }, "node_modules/expo-modules-core": { - "version": "56.0.20", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.20.tgz", - "integrity": "sha512-Xagwt/gC6sV1jiSnlFrYxLooZr90adLrpV64YlkXZjgZ6Kot89FF62e457RyejLtyDm2pOdy6FZphF1ReyK2Jw==", + "version": "56.0.22", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.22.tgz", + "integrity": "sha512-VmkVNpbjC+gTny1pvxyxu90LafGpMdhUB5TRSpe+So4ICrZCwcWBADjWc0JVBHKdFCMOaWBMskuIHI/RcNhEtQ==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.2.2", @@ -4870,15 +5403,15 @@ } }, "node_modules/expo-router": { - "version": "56.2.14", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.14.tgz", - "integrity": "sha512-zhJ0YgMxzosxPVTqPJTzTwOCvY06+uFJSW93vFYpF+AyMSZ/DCT+iQew0bwGky6dmWrrIjLBbZ/bv/CdaEdAEg==", + "version": "56.2.17", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-56.2.17.tgz", + "integrity": "sha512-fc12lz/A/zPZvEr6iuHXoWOi0Vr989wlIMyo5klynYc9ObrdYW2GNVI1X6AtXPoe1qOVZqWTTMwFT4agDxnEqA==", "license": "MIT", "dependencies": { "@expo/log-box": "^56.0.14", - "@expo/metro-runtime": "^56.0.16", + "@expo/metro-runtime": "^56.0.18", "@expo/schema-utils": "^56.0.2", - "@expo/ui": "^56.0.21", + "@expo/ui": "^56.0.24", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -4898,7 +5431,7 @@ "react-fast-compare": "^3.2.2", "react-is": "^19.1.0", "react-native-drawer-layout": "^4.2.2", - "react-native-screens": "^4.25.2", + "react-native-screens": "^4.26.0", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", @@ -4907,18 +5440,18 @@ }, "peerDependencies": { "@expo/log-box": "^56.0.14", - "@expo/metro-runtime": "^56.0.16", + "@expo/metro-runtime": "^56.0.18", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^56.0.20", - "expo-linking": "^56.0.15", + "expo-constants": "^56.0.22", + "expo-linking": "^56.0.16", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", - "react-native-screens": "^4.25.2", + "react-native-screens": "^4.26.0", "react-native-web": "*", "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" }, @@ -4998,12 +5531,12 @@ } }, "node_modules/expo-sharing": { - "version": "56.0.21", - "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.21.tgz", - "integrity": "sha512-X+NmHbjxF0jIfQRm14DlI9aJTwl2U6sVIEmVST4twFLekv3KHDUn2znzLZsEHOI/MskRevszfXP68rj1a8R4Xg==", + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-56.0.23.tgz", + "integrity": "sha512-O/f6+ttCuwwTDnzM+ZqeVeNOJJ3lY1qvhoMfYeYAgEFi36qms5j+uuUISm520wmrG+1AwT7Is5tdSRk4zf+0AA==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "^56.0.12", + "@expo/config-plugins": "^56.0.14", "@expo/config-types": "^56.0.7", "@expo/plist": "^0.7.0" }, @@ -5050,29 +5583,29 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "56.1.19", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.19.tgz", - "integrity": "sha512-k+oipwbu83WFnACtybvGZvDbbItarY4lfjkKgP9F5lml2ilvL/VvzsPhbhT7nRyFB3zPJq9SbNNKa45VDLoihA==", + "version": "56.1.22", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.22.tgz", + "integrity": "sha512-G9IOhKU62TexMJGeZkDmyAYLsvbYT40RvIG7AR4y2ha8OEmhbbHNLNQ0mI0xK4QVf4MXiC0CPHLhMMh/ak8XQA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~56.0.11", - "@expo/config-plugins": "~56.0.12", + "@expo/config": "~56.0.13", + "@expo/config-plugins": "~56.0.14", "@expo/devcert": "^1.2.1", "@expo/env": "~2.3.1", - "@expo/image-utils": "^0.10.2", - "@expo/inline-modules": "^0.0.13", + "@expo/image-utils": "^0.10.4", + "@expo/inline-modules": "^0.0.15", "@expo/json-file": "^10.2.0", "@expo/log-box": "^56.0.14", "@expo/metro": "~56.0.0", - "@expo/metro-config": "~56.0.16", + "@expo/metro-config": "~56.0.18", "@expo/metro-file-map": "^56.0.3", "@expo/osascript": "^2.6.0", "@expo/package-manager": "^1.12.1", "@expo/plist": "^0.7.0", - "@expo/prebuild-config": "^56.0.19", - "@expo/require-utils": "^56.1.4", - "@expo/router-server": "^56.0.16", + "@expo/prebuild-config": "^56.0.21", + "@expo/require-utils": "^56.1.6", + "@expo/router-server": "^56.0.17", "@expo/schema-utils": "^56.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", @@ -5132,17 +5665,17 @@ } }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "56.0.16", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.16.tgz", - "integrity": "sha512-df5BI5GFnSKqAhuALTPIbuHisd4CUhuYam7pwF9Dt1D6BinPctsteWWDm/sEGXdLVhsub4NPI7tvUHo/HWL7aA==", + "version": "56.0.17", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.17.tgz", + "integrity": "sha512-ogGX5cxqAXIzMmstjwZ03QAjCU8cFOU5yooDSbqPDVxscbnzsxV8oquHSMUsqiEy3vlyZp5S5HVPnCsrjoCDqw==", "license": "MIT", "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { - "@expo/metro-runtime": "^56.0.16", + "@expo/metro-runtime": "^56.0.18", "expo": "*", - "expo-constants": "^56.0.20", + "expo-constants": "^56.0.22", "expo-font": "^56.0.7", "expo-router": "*", "expo-server": "^56.0.5", @@ -5269,9 +5802,9 @@ } }, "node_modules/expo/node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6021,9 +6554,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -6133,9 +6666,9 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -6148,23 +6681,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -6182,9 +6715,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -6202,9 +6735,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -6222,9 +6755,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -6242,9 +6775,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -6262,9 +6795,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -6282,9 +6815,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -6302,9 +6835,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -6322,9 +6855,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -6342,9 +6875,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -6362,9 +6895,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -6539,6 +7072,16 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -6983,9 +7526,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -7130,6 +7673,20 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -7413,6 +7970,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7495,9 +8059,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -7514,7 +8078,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8364,9 +8928,9 @@ } }, "node_modules/react-native-screens": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.25.2.tgz", - "integrity": "sha512-1Nj1fusFd+rIMKU/qC9yGKVG+3ofh11d3OdBQKL1iVvQfKvcB8vhvTGQf2TkfxW3bamxN+hCZIXmNuU0mRkyDg==", + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.26.2.tgz", + "integrity": "sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==", "license": "MIT", "dependencies": { "react-freeze": "^1.0.0", @@ -8374,7 +8938,7 @@ }, "peerDependencies": { "react": "*", - "react-native": ">=0.82.0" + "react-native": "*" } }, "node_modules/react-native-view-shot": { @@ -8706,6 +9270,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8933,9 +9531,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -8944,6 +9542,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9031,6 +9636,13 @@ "node": ">=6" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -9064,6 +9676,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -9335,10 +9954,27 @@ "node": ">=8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -9380,6 +10016,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9696,6 +10342,200 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -9769,6 +10609,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 88ea2d0..9b51861 100644 --- a/package.json +++ b/package.json @@ -6,22 +6,22 @@ "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "2.2.0", "@tanstack/react-query": "^5.100.14", - "expo": "~56.0.15", - "expo-build-properties": "~56.0.22", + "expo": "~56.0.18", + "expo-build-properties": "~56.0.24", "expo-camera": "~56.0.8", - "expo-constants": "~56.0.15", - "expo-dev-client": "~56.0.22", + "expo-constants": "~56.0.21", + "expo-dev-client": "~56.0.24", "expo-file-system": "~56.0.7", "expo-font": "~56.0.5", "expo-image": "~56.0.11", - "expo-image-picker": "~56.0.20", + "expo-image-picker": "~56.0.22", "expo-linear-gradient": "~56.0.4", - "expo-linking": "~56.0.15", - "expo-location": "~56.0.20", - "expo-media-library": "~56.0.9", - "expo-router": "~56.2.14", + "expo-linking": "~56.0.16", + "expo-location": "~56.0.22", + "expo-media-library": "~56.0.10", + "expo-router": "~56.2.17", "expo-secure-store": "~56.0.4", - "expo-sharing": "~56.0.21", + "expo-sharing": "~56.0.23", "expo-status-bar": "~56.0.4", "nativewind": "^4.2.4", "react": "19.2.3", @@ -30,7 +30,7 @@ "react-native-maps": "1.27.2", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", - "react-native-screens": "4.25.2", + "react-native-screens": "~4.26.0", "react-native-view-shot": "5.1.0", "react-native-web": "^0.21.2", "react-native-worklets": "0.8.3", @@ -43,7 +43,8 @@ "babel-preset-expo": "^56.0.12", "prettier-plugin-tailwindcss": "^0.5.14", "tailwindcss": "^3.4.19", - "typescript": "~6.0.3" + "typescript": "~6.0.3", + "vitest": "^4.1.10" }, "scripts": { "start": "expo start", @@ -58,6 +59,7 @@ "build:preview": "eas build --profile preview --platform all", "build:production": "eas build --profile production --platform all", "typecheck": "tsc --noEmit", + "test": "vitest run", "doctor": "npx expo-doctor", "check:api-origin": "node scripts/check-api-origin.js", "check:deployed-web": "node scripts/check-deployed-web.js", diff --git a/scripts/check-api-origin.js b/scripts/check-api-origin.js index dab7c13..445a1fa 100644 --- a/scripts/check-api-origin.js +++ b/scripts/check-api-origin.js @@ -5,7 +5,7 @@ const apiOrigin = (process.argv[2] || process.env.SOUNDLOG_API_ORIGIN || '').rep let authHeaderPromise; if (!apiOrigin) { - console.error('Usage: npm run check:api-origin -- http://:4000'); + console.error('Usage: npm run check:api-origin -- https://api.soundlog.shop'); process.exit(1); } diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index fd65fdb..1e3ecf6 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -200,7 +200,7 @@ function verifyBundle(bundleText) { assertExcludes( bundleText, /http:\/\/\d+\.\d+\.\d+\.\d+:4000/, - 'Server web export must not inline a direct EC2 HTTP API URL.', + 'Server web export must not inline a direct raw-IP HTTP API URL.', ); [ ['authMockHandlers', 'Server web export must not include auth mock handlers.'], diff --git a/scripts/check-store-release.js b/scripts/check-store-release.js index f6f9adb..835023c 100644 --- a/scripts/check-store-release.js +++ b/scripts/check-store-release.js @@ -84,6 +84,11 @@ function assertProductionEnv(productionEnv) { const privacyUrl = productionEnv.EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL; const termsUrl = productionEnv.EXPO_PUBLIC_SOUNDLOG_TERMS_URL; const supportEmail = productionEnv.EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL; + const uploadOrigin = productionEnv.EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN; + + if (productionEnv.EXPO_PUBLIC_SOUNDLOG_SCREENSHOT_MODE === 'true') { + addError('EAS production env must not enable EXPO_PUBLIC_SOUNDLOG_SCREENSHOT_MODE.'); + } if (!apiBaseUrl?.startsWith('https://')) { addError('EAS production env must set EXPO_PUBLIC_SOUNDLOG_API_BASE_URL to an HTTPS URL.'); @@ -100,6 +105,22 @@ function assertProductionEnv(productionEnv) { if (!supportEmail || supportEmail.endsWith('@example.com')) { addError('EAS production env must set EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL to a real mailbox.'); } + + if (apiBaseUrl !== 'https://api.soundlog.shop') { + addError('EAS production API must call the GCP api.soundlog.shop origin directly.'); + } + + if (uploadOrigin !== 'https://api.soundlog.shop') { + addError('EAS production uploads must use the GCP api.soundlog.shop origin directly.'); + } + + if (privacyUrl !== 'https://api.soundlog.shop/legal/privacy') { + addError('EAS production privacy URL must be hosted by the GCP API server.'); + } + + if (termsUrl !== 'https://api.soundlog.shop/legal/terms') { + addError('EAS production terms URL must be hosted by the GCP API server.'); + } } function hasPlugin(config, pluginName) { @@ -108,6 +129,14 @@ function hasPlugin(config, pluginName) { ); } +function getPluginConfig(config, pluginName) { + const plugin = (config.plugins ?? []).find( + (candidate) => Array.isArray(candidate) && candidate[0] === pluginName, + ); + + return plugin?.[1]; +} + function assertAppIcon(config) { const iconPath = path.resolve(projectRoot, config.icon ?? ''); @@ -140,6 +169,95 @@ function assertAndroidPermissions(config) { addError(`Android release config should not request unused sensitive permission: ${permission}`); } }); + + [ + 'android.permission.ACCESS_BACKGROUND_LOCATION', + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_LOCATION', + 'android.permission.ACTIVITY_RECOGNITION', + 'com.google.android.gms.permission.ACTIVITY_RECOGNITION', + ].forEach((permission) => { + if (permissions.has(permission)) { + addError(`Android release config must not request unused background or motion permission: ${permission}`); + } + }); +} + +function assertRuntimePermissionPlugins(config) { + const camera = getPluginConfig(config, 'expo-camera'); + const location = getPluginConfig(config, 'expo-location'); + + if (!camera) { + addError('Store release config must include the expo-camera plugin.'); + } else { + if (camera.microphonePermission !== false) { + addError('expo-camera must remove the unused iOS microphone permission.'); + } + + if (camera.recordAudioAndroid !== false) { + addError('expo-camera must not add the unused Android RECORD_AUDIO permission.'); + } + } + + if (!location) { + addError('Store release config must include the expo-location plugin.'); + return; + } + + if (!location.locationWhenInUsePermission) { + addError('expo-location must retain an iOS When In Use location permission message.'); + } + + [ + 'locationAlwaysAndWhenInUsePermission', + 'locationAlwaysPermission', + 'motionUsagePermission', + 'isIosBackgroundLocationEnabled', + 'isAndroidBackgroundLocationEnabled', + 'isAndroidForegroundServiceEnabled', + 'isAndroidMotionActivityEnabled', + ].forEach((key) => { + if (location[key] !== false) { + addError(`expo-location must explicitly disable unused permission setting: ${key}`); + } + }); +} + +function assertPrivacyManifest(config) { + const manifest = config.ios?.privacyManifests; + const collectedDataTypes = manifest?.NSPrivacyCollectedDataTypes ?? []; + const requiredDeclarations = [ + ['NSPrivacyCollectedDataTypeName', 'NSPrivacyCollectedDataTypePurposeAppFunctionality'], + ['NSPrivacyCollectedDataTypeUserID', 'NSPrivacyCollectedDataTypePurposeAppFunctionality'], + ['NSPrivacyCollectedDataTypeProductInteraction', 'NSPrivacyCollectedDataTypePurposeAnalytics'], + ]; + + if (manifest?.NSPrivacyTracking !== false) { + addError('iOS privacy manifest must declare NSPrivacyTracking as false.'); + } + + requiredDeclarations.forEach(([dataType, purpose]) => { + const declaration = collectedDataTypes.find( + (entry) => entry.NSPrivacyCollectedDataType === dataType, + ); + + if (!declaration) { + addError(`iOS privacy manifest is missing ${dataType}.`); + return; + } + + if (declaration.NSPrivacyCollectedDataTypeLinked !== true) { + addError(`${dataType} must be declared as linked to the user.`); + } + + if (declaration.NSPrivacyCollectedDataTypeTracking !== false) { + addError(`${dataType} must be declared as not used for tracking.`); + } + + if (!declaration.NSPrivacyCollectedDataTypePurposes?.includes(purpose)) { + addError(`${dataType} must declare ${purpose}.`); + } + }); } function assertTransportSecurity(config) { @@ -163,6 +281,12 @@ function assertTransportSecurity(config) { } } +function assertIosDeviceSupport(config) { + if (config.ios?.supportsTablet !== false) { + addError('iOS v1.0 release config must remain iPhone-only until iPad assets and QA are complete.'); + } +} + function assertNativeIosPlist() { const plistPath = path.join(projectRoot, 'ios/Soundlog/Info.plist'); const entitlementsPath = path.join(projectRoot, 'ios/Soundlog/Soundlog.entitlements'); @@ -232,7 +356,10 @@ function main() { if (config) { assertAppIcon(config); assertAndroidPermissions(config); + assertRuntimePermissionPlugins(config); + assertPrivacyManifest(config); assertTransportSecurity(config); + assertIosDeviceSupport(config); } assertNativeIosPlist(); diff --git a/scripts/require-vercel-api-origin.js b/scripts/require-vercel-api-origin.js index 13b1024..11c219e 100644 --- a/scripts/require-vercel-api-origin.js +++ b/scripts/require-vercel-api-origin.js @@ -3,12 +3,11 @@ const apiOrigin = process.env.SOUNDLOG_API_ORIGIN?.replace(/\/+$/, ''); const staleOrigins = new Set([ 'http://52.79.185.121:4000', - 'https://api.soundlog.shop', ]); if (!apiOrigin) { console.error( - 'SOUNDLOG_API_ORIGIN is required for Vercel builds. Set it to the current SoundLogServer origin, for example http://:4000.', + 'SOUNDLOG_API_ORIGIN is required for Vercel builds. Set it to the current SoundLogServer origin, for example https://api.soundlog.shop.', ); process.exit(1); } diff --git a/src/api/client.ts b/src/api/client.ts index f05cab5..b3c167e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,6 +1,6 @@ import { useAuthStore } from '@/store/authStore'; import { AuthSession } from '@/types/auth'; -import { clearAccountSession } from '@/utils/accountSession'; +import { clearAuthSession } from '@/utils/accountSession'; type QueryValue = boolean | number | string | Array | null | undefined; @@ -106,9 +106,42 @@ function shouldSendJsonContentType(body: ApiRequestOptions['body']) { return Boolean(body && !isFormDataBody(body) && typeof body !== 'string'); } +export type PageMeta = { + limit: number; + nextCursor?: string | null; +}; + +// Server list endpoints respond via pagedResponse() as +// `{ data: T[], page: { limit, nextCursor } }`. Historically unwrapData +// discarded `page` entirely, so cursor-based pagination was unreachable +// from the client (see soundlog task P2-2). To avoid touching every caller +// of unwrapData/requestApi (whose return type is just `T`), the page +// metadata is attached as a non-enumerable property on the returned data +// itself rather than changing the return shape — existing callers keep +// working unmodified, and callers that care can read it via getPageMeta(). +const PAGE_META = Symbol('soundlog-page-meta'); + +export function getPageMeta(data: T): PageMeta | undefined { + if (!data || typeof data !== 'object') { + return undefined; + } + + return (data as { [PAGE_META]?: PageMeta })[PAGE_META]; +} + function unwrapData(payload: unknown): T { if (payload && typeof payload === 'object' && 'data' in payload) { - return (payload as { data: T }).data; + const { data, page } = payload as { data: T; page?: PageMeta }; + + if (page && data && typeof data === 'object') { + Object.defineProperty(data, PAGE_META, { + configurable: true, + enumerable: false, + value: page, + }); + } + + return data; } return payload as T; @@ -140,7 +173,10 @@ async function refreshSession(baseUrl: string) { }); if (!response.ok) { - clearAccountSession(); + // Refresh failed (expired/revoked refresh token): drop auth state only. + // Unsynced drafts, pending actions, and the active travel session must + // survive so the user can re-login and resume — see clearAuthSession(). + clearAuthSession(); return undefined; } diff --git a/src/api/libraryApi.ts b/src/api/libraryApi.ts index db88ace..ecfc6b1 100644 --- a/src/api/libraryApi.ts +++ b/src/api/libraryApi.ts @@ -1,5 +1,7 @@ import { createIdempotencyKey, + getPageMeta, + type PageMeta, requestApi, shouldAttemptAuthenticatedApi, } from '@/api/client'; @@ -25,20 +27,34 @@ export type RemoteLibraryTrackRecord = { track: Track; }; +export type LibraryTracksPage = { + page?: PageMeta; + records: RemoteLibraryTrackRecord[]; +}; + export const libraryApi = { - getTracks: async (kind: 'all' | 'liked' | 'saved' = 'all') => { + // `cursor` lets callers page past the server's default limit (50) instead + // of silently truncating at page one — see P2-2. Existing single-page + // callers can simply omit it. + getTracks: async ( + kind: 'all' | 'liked' | 'saved' = 'all', + cursor?: string, + ): Promise => { if (!shouldAttemptAuthenticatedApi()) { - return Promise.resolve([]); + return Promise.resolve({ records: [] }); } - const records = await requestApi('/v1/library/tracks', { - query: { kind, limit: 50 }, + const rawRecords = await requestApi('/v1/library/tracks', { + query: { cursor, kind, limit: 50 }, }); - return records.map((record) => ({ - ...record, - track: sanitizeTrack(record.track), - })); + return { + page: getPageMeta(rawRecords), + records: rawRecords.map((record) => ({ + ...record, + track: sanitizeTrack(record.track), + })), + }; }, updateTrackState: ( trackId: string, diff --git a/src/components/auth/AppEntryGate.tsx b/src/components/auth/AppEntryGate.tsx index 034e8f7..4b8c4c5 100644 --- a/src/components/auth/AppEntryGate.tsx +++ b/src/components/auth/AppEntryGate.tsx @@ -1,5 +1,6 @@ import { Redirect, usePathname } from 'expo-router'; +import { isScreenshotModeEnabled } from '@/components/dev/devTestManagerVisibility'; import { useAuthStore } from '@/store/authStore'; import { useUserProfileStore } from '@/store/userProfileStore'; @@ -8,6 +9,10 @@ export function AppEntryGate() { const { isHydrated: authHydrated, status } = useAuthStore(); const { isHydrated: profileHydrated, profile } = useUserProfileStore(); + if (__DEV__ && isScreenshotModeEnabled()) { + return null; + } + if (!authHydrated || !profileHydrated || status === 'checking') { return null; } @@ -22,11 +27,15 @@ export function AppEntryGate() { } if (isAuthRoute && status === 'authenticated') { - return ; + return ( + + ); } if (hasAppSession && !profile.completedOnboarding && !isOnboardingRoute) { - return ; + return ; } return null; diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index 92c0708..4ed6456 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -14,6 +14,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { authApi } from '@/api/authApi'; import { getApiBaseUrl } from '@/api/client'; +import { isScreenshotModeEnabled } from '@/components/dev/devTestManagerVisibility'; +import { applyScreenshotSeed } from '@/components/dev/screenshotSeedBootstrap'; import { queryClient } from '@/providers/queryClient'; import { AppText } from '@/components/AppText'; import { playlistCurationById } from '@/mocks/playlistMocks'; @@ -159,7 +161,7 @@ function getSampleTrack(index = 0): Track { } export function DevTestManager() { - if (!__DEV__) { + if (!__DEV__ || isScreenshotModeEnabled()) { return null; } @@ -306,6 +308,11 @@ function DevTestManagerContent() { setSelectedMoodFilter('시원한'); router.replace('/' as never); }; + const applyAppStoreScreenshotSeed = () => { + applyScreenshotSeed(); + setIsOpen(false); + router.replace('/' as never); + }; const resetProfile = () => { resetOnboarding(); setSelectedMoodFilter('전체'); @@ -446,6 +453,17 @@ function DevTestManagerContent() { contentContainerStyle={{ gap: 12, paddingBottom: 18, paddingHorizontal: 20 }} showsVerticalScrollIndicator={false} > + + + + applyMockLogin(provider)} /> ))} diff --git a/src/components/dev/ScreenshotSeedBootstrap.tsx b/src/components/dev/ScreenshotSeedBootstrap.tsx new file mode 100644 index 0000000..692a7c6 --- /dev/null +++ b/src/components/dev/ScreenshotSeedBootstrap.tsx @@ -0,0 +1,21 @@ +import { router } from 'expo-router'; +import { useEffect } from 'react'; + +import { bootstrapScreenshotSeedOnce } from '@/components/dev/screenshotSeedBootstrap'; +import { useAuthStore } from '@/store/authStore'; +import { useUserProfileStore } from '@/store/userProfileStore'; + +export function ScreenshotSeedBootstrap() { + const authHydrated = useAuthStore((state) => state.isHydrated); + const profileHydrated = useUserProfileStore((state) => state.isHydrated); + + useEffect(() => { + if (!authHydrated || !profileHydrated || !bootstrapScreenshotSeedOnce()) { + return; + } + + router.replace('/' as never); + }, [authHydrated, profileHydrated]); + + return null; +} diff --git a/src/components/dev/devTestManagerVisibility.test.ts b/src/components/dev/devTestManagerVisibility.test.ts new file mode 100644 index 0000000..b4c91f9 --- /dev/null +++ b/src/components/dev/devTestManagerVisibility.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldRenderDevTestManager } from '@/components/dev/devTestManagerVisibility'; + +describe('shouldRenderDevTestManager', () => { + it('renders only for native development builds by default', () => { + expect( + shouldRenderDevTestManager({ isDev: true, platform: 'ios', screenshotMode: undefined }), + ).toBe(true); + }); + + it('hides the manager when screenshot mode is enabled', () => { + expect( + shouldRenderDevTestManager({ isDev: true, platform: 'ios', screenshotMode: 'true' }), + ).toBe(false); + }); + + it('never renders for production or web', () => { + expect( + shouldRenderDevTestManager({ isDev: false, platform: 'ios', screenshotMode: undefined }), + ).toBe(false); + expect( + shouldRenderDevTestManager({ isDev: true, platform: 'web', screenshotMode: undefined }), + ).toBe(false); + }); +}); diff --git a/src/components/dev/devTestManagerVisibility.ts b/src/components/dev/devTestManagerVisibility.ts new file mode 100644 index 0000000..49e669f --- /dev/null +++ b/src/components/dev/devTestManagerVisibility.ts @@ -0,0 +1,17 @@ +export function isScreenshotModeEnabled( + screenshotMode = process.env.EXPO_PUBLIC_SOUNDLOG_SCREENSHOT_MODE, +) { + return screenshotMode === 'true'; +} + +export function shouldRenderDevTestManager({ + isDev, + platform, + screenshotMode, +}: { + isDev: boolean; + platform: string; + screenshotMode?: string; +}) { + return isDev && platform !== 'web' && !isScreenshotModeEnabled(screenshotMode); +} diff --git a/src/components/dev/screenshotSeed.test.ts b/src/components/dev/screenshotSeed.test.ts new file mode 100644 index 0000000..25ecde8 --- /dev/null +++ b/src/components/dev/screenshotSeed.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + createScreenshotSeed, + SCREENSHOT_SEED_LOCATION, + SCREENSHOT_SEED_SESSION_ID, + SCREENSHOT_SEED_USER_ID, +} from '@/components/dev/screenshotSeed'; + +describe('createScreenshotSeed', () => { + it('prepares one internally consistent, ended Gwangalli trip for App Store screenshots', () => { + const seed = createScreenshotSeed(new Date('2026-08-02T01:15:00.000Z')); + + expect(seed.authSession.user.id).toBe(SCREENSHOT_SEED_USER_ID); + expect(seed.profile.preferredMoods).toContain('시원한'); + expect(seed.location).toEqual(SCREENSHOT_SEED_LOCATION); + expect(seed.place.title).toBe('광안리 해수욕장'); + expect(seed.selectedMoodFilter).toBe('시원한'); + expect(seed.selectedMode).toBe('ocean'); + expect(seed.playlist.tracks.map((track) => track.id)).toContain(seed.currentTrack.id); + expect(seed.library.seededPlaylistIds).toEqual([seed.playlist.id]); + expect(seed.library.likedTracks.every((record) => record.playlistId === seed.playlist.id)).toBe( + true, + ); + expect(seed.library.savedTracks.every((record) => record.playlistId === seed.playlist.id)).toBe( + true, + ); + expect(seed.library.likedTracks).toHaveLength(2); + expect(seed.library.savedTracks).toHaveLength(2); + expect(seed.momentLogs).toHaveLength(3); + expect(seed.momentLogs.every((log) => log.sessionId === SCREENSHOT_SEED_SESSION_ID)).toBe(true); + expect(seed.momentLogs.every((log) => log.syncStatus === 'synced')).toBe(true); + expect(seed.session).toMatchObject({ + id: SCREENSHOT_SEED_SESSION_ID, + ownerUserId: SCREENSHOT_SEED_USER_ID, + status: 'ended', + }); + expect(seed.session.routePoints).toHaveLength(4); + expect(new Date(seed.session.endedAt).getTime()).toBeGreaterThan( + new Date(seed.session.startedAt).getTime(), + ); + }); +}); diff --git a/src/components/dev/screenshotSeed.ts b/src/components/dev/screenshotSeed.ts new file mode 100644 index 0000000..4912910 --- /dev/null +++ b/src/components/dev/screenshotSeed.ts @@ -0,0 +1,182 @@ +import { playlistCurationById } from '@/mocks/playlistMocks'; +import type { AuthSession } from '@/types/auth'; +import type { + GeoPoint, + MomentLog, + PlaceContext, + PlaylistCuration, + RoutePoint, + Track, +} from '@/types/domain'; + +import type { LibraryTrackRecord } from '@/store/libraryStore'; +import type { UserProfileInput } from '@/store/userProfileStore'; + +export const SCREENSHOT_SEED_USER_ID = 'dev-screenshot-user'; +export const SCREENSHOT_SEED_SESSION_ID = 'dev-screenshot-trip-busan'; + +const screenshotPlaylist = playlistCurationById['busan-ocean']; + +export const SCREENSHOT_SEED_LOCATION: GeoPoint = { lat: 35.1532, lng: 129.1186 }; + +export const SCREENSHOT_SEED_PLACE: PlaceContext = { + category: '해변', + contentType: '관광지', + id: 'dev-screenshot-gwangalli', + imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + location: SCREENSHOT_SEED_LOCATION, + overview: '광안리 바다와 산책의 분위기에 맞춘 App Store 스크린샷용 개발 데이터입니다.', + source: 'seed', + title: '광안리 해수욕장', +}; + +function getTrack(index: number): Track { + return screenshotPlaylist.tracks[index % screenshotPlaylist.tracks.length]; +} + +function getTimestamp(now: Date, minutesBefore: number) { + return new Date(now.getTime() - minutesBefore * 60 * 1000).toISOString(); +} + +function createLibraryRecord(track: Track, createdAt: string): LibraryTrackRecord { + return { + createdAt, + playlist: screenshotPlaylist, + playlistId: screenshotPlaylist.id, + track: { ...track, isLiked: undefined, isSaved: undefined }, + }; +} + +export type ScreenshotSeed = { + authSession: AuthSession; + currentTrack: Track; + library: { + likedTracks: LibraryTrackRecord[]; + savedTracks: LibraryTrackRecord[]; + seededPlaylistIds: string[]; + }; + location: GeoPoint; + momentLogs: MomentLog[]; + place: PlaceContext; + playlist: PlaylistCuration; + profile: UserProfileInput; + selectedMoodFilter: '시원한'; + selectedMode: 'ocean'; + session: { + endedAt: string; + id: string; + ownerUserId: string; + routePoints: RoutePoint[]; + startedAt: string; + status: 'ended'; + }; +}; + +export function createScreenshotSeed(now = new Date()): ScreenshotSeed { + const startedAt = getTimestamp(now, 92); + const endedAt = getTimestamp(now, 4); + const routePoints: RoutePoint[] = [ + { lat: 35.1527, lng: 129.1176, recordedAt: startedAt }, + { lat: 35.1529, lng: 129.1180, recordedAt: getTimestamp(now, 67) }, + { lat: 35.1531, lng: 129.1184, recordedAt: getTimestamp(now, 38) }, + { lat: 35.1532, lng: 129.1186, recordedAt: endedAt }, + ]; + const createdAt = getTimestamp(now, 12); + + return { + authSession: { + accessToken: 'dev-screenshot-access-token', + expiresIn: 3600, + isNewUser: false, + refreshToken: 'dev-screenshot-refresh-token', + user: { + displayName: '광안리 산책자', + email: 'screenshot@soundlog.test', + id: SCREENSHOT_SEED_USER_ID, + provider: 'email', + }, + }, + currentTrack: getTrack(0), + library: { + likedTracks: [createLibraryRecord(getTrack(0), createdAt), createLibraryRecord(getTrack(2), createdAt)], + savedTracks: [createLibraryRecord(getTrack(1), createdAt), createLibraryRecord(getTrack(3), createdAt)], + seededPlaylistIds: [screenshotPlaylist.id], + }, + location: SCREENSHOT_SEED_LOCATION, + momentLogs: [ + { + createdAt: getTimestamp(now, 74), + id: 'dev-screenshot-gwangalli-sunset', + location: routePoints[0], + moodTags: ['fresh', 'local'], + note: '바닷바람이 가장 시원했던 시작점', + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + placeCategory: '해변', + placeId: SCREENSHOT_SEED_PLACE.id, + placeName: '광안리 해수욕장', + recapVisibility: 'private', + sessionId: SCREENSHOT_SEED_SESSION_ID, + source: 'camera', + syncStatus: 'synced', + templateId: 'film', + track: getTrack(0), + travelMode: 'ocean', + }, + { + createdAt: getTimestamp(now, 43), + id: 'dev-screenshot-gwangalli-walk', + location: routePoints[1], + moodTags: ['fresh', 'calm'], + note: '파도 소리를 들으며 걷는 시간', + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + placeCategory: '산책로', + placeId: SCREENSHOT_SEED_PLACE.id, + placeName: '광안리 해변 산책로', + recapVisibility: 'private', + sessionId: SCREENSHOT_SEED_SESSION_ID, + source: 'camera', + syncStatus: 'synced', + templateId: 'album', + track: getTrack(1), + travelMode: 'ocean', + }, + { + createdAt, + id: 'dev-screenshot-gwangalli-night', + location: routePoints[3], + moodTags: ['fresh', 'emotional'], + note: '광안대교 불빛과 함께 남긴 오늘의 마지막 곡', + photoUri: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', + placeCategory: '야경', + placeId: SCREENSHOT_SEED_PLACE.id, + placeName: '광안리 해수욕장', + recapVisibility: 'private', + sessionId: SCREENSHOT_SEED_SESSION_ID, + source: 'camera', + syncStatus: 'synced', + templateId: 'map', + track: getTrack(2), + travelMode: 'ocean', + }, + ], + place: SCREENSHOT_SEED_PLACE, + playlist: screenshotPlaylist, + profile: { + companionType: '친구', + locationRecommendationEnabled: true, + preferredGenres: ['K-POP', '인디', '팝'], + preferredMoods: ['시원한', '잔잔한'], + travelStyles: ['산책', '바다 보기'], + }, + selectedMoodFilter: '시원한', + selectedMode: 'ocean', + session: { + endedAt, + id: SCREENSHOT_SEED_SESSION_ID, + ownerUserId: SCREENSHOT_SEED_USER_ID, + routePoints, + startedAt, + status: 'ended', + }, + }; +} diff --git a/src/components/dev/screenshotSeedBootstrap.test.ts b/src/components/dev/screenshotSeedBootstrap.test.ts new file mode 100644 index 0000000..1179b43 --- /dev/null +++ b/src/components/dev/screenshotSeedBootstrap.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + applyScreenshotSeed, + bootstrapScreenshotSeedOnce, + resetScreenshotSeedBootstrapForTests, +} from '@/components/dev/screenshotSeedBootstrap'; +import { SCREENSHOT_SEED_SESSION_ID, SCREENSHOT_SEED_USER_ID } from '@/components/dev/screenshotSeed'; +import { useAuthStore } from '@/store/authStore'; +import { useHomeFilterStore } from '@/store/homeFilterStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { usePlayerStore } from '@/store/playerStore'; +import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import { useUserProfileStore } from '@/store/userProfileStore'; + +beforeEach(() => { + resetScreenshotSeedBootstrapForTests(); + useAuthStore.setState({ + accessToken: undefined, + isHydrated: true, + refreshToken: undefined, + status: 'unauthenticated', + user: undefined, + }); + useUserProfileStore.setState({ + isHydrated: true, + profile: { + completedOnboarding: false, + locationRecommendationEnabled: true, + preferredGenres: [], + preferredMoods: [], + travelStyles: [], + }, + }); + useMomentLogStore.setState({ + logs: [], + pendingActions: [], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); + useTravelSessionStore.setState({ + quarantinedSessions: [], + session: { id: 'local-session', routePoints: [], status: 'idle' }, + }); + useLibraryStore.setState({ likedTracks: [], savedTracks: [], seededPlaylistIds: [] }); + useTravelLogSyncStore.setState({ pendingFinalizations: [] }); +}); + +describe('screenshot seed bootstrap', () => { + it('applies the same complete local state used by DevTestManager', () => { + const seed = applyScreenshotSeed(); + + expect(useAuthStore.getState().user?.id).toBe(SCREENSHOT_SEED_USER_ID); + expect(useAuthStore.getState().status).toBe('authenticated'); + expect(useUserProfileStore.getState().profile.completedOnboarding).toBe(true); + expect(useHomeFilterStore.getState().selectedMoodFilter).toBe('시원한'); + expect(useTravelSessionStore.getState().session).toMatchObject({ + id: SCREENSHOT_SEED_SESSION_ID, + status: 'ended', + }); + expect(useMomentLogStore.getState().logs).toHaveLength(seed.momentLogs.length); + expect(useLibraryStore.getState().savedTracks).toHaveLength(seed.library.savedTracks.length); + expect(usePlayerStore.getState().playlistId).toBe(seed.playlist.id); + expect(useTravelLogSyncStore.getState().pendingFinalizations).toEqual([]); + }); + + it('applies the automatic bootstrap once per app runtime', () => { + expect(bootstrapScreenshotSeedOnce()).toBe(true); + expect(bootstrapScreenshotSeedOnce()).toBe(false); + }); +}); diff --git a/src/components/dev/screenshotSeedBootstrap.ts b/src/components/dev/screenshotSeedBootstrap.ts new file mode 100644 index 0000000..7dce4b3 --- /dev/null +++ b/src/components/dev/screenshotSeedBootstrap.ts @@ -0,0 +1,67 @@ +import { queryClient } from '@/providers/queryClient'; +import { useAuthStore } from '@/store/authStore'; +import { useHomeFilterStore } from '@/store/homeFilterStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { usePlayerStore } from '@/store/playerStore'; +import { useRecommendationEventStore } from '@/store/recommendationEventStore'; +import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import { useUserProfileStore } from '@/store/userProfileStore'; + +import { createScreenshotSeed, type ScreenshotSeed } from '@/components/dev/screenshotSeed'; + +let hasBootstrappedScreenshotSeed = false; + +export function applyScreenshotSeed(): ScreenshotSeed { + const seed = createScreenshotSeed(); + + // This is intentionally local-only. Clearing queues prevents a mock + // account or screenshot fixture from ever being sent to the API. + useAuthStore.getState().finishLogin(seed.authSession); + useUserProfileStore.getState().completeOnboarding(seed.profile); + useHomeFilterStore.getState().setSelectedMoodFilter(seed.selectedMoodFilter); + useTravelSessionStore.setState({ + currentLocation: seed.location, + currentPlace: seed.place, + locationStatus: 'granted', + locationUpdatedAt: seed.session.endedAt, + quarantinedSessions: [], + recommendationMode: 'travel', + selectedMode: seed.selectedMode, + session: seed.session, + }); + useMomentLogStore.setState({ + logs: [], + pendingActions: [], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); + seed.momentLogs.forEach((log) => useMomentLogStore.getState().addLog(log)); + useLibraryStore.setState(seed.library); + usePlayerStore.getState().setTrack( + seed.currentTrack, + seed.playlist.id, + seed.playlist.tracks, + seed.playlist, + ); + useTravelLogSyncStore.setState({ pendingFinalizations: [] }); + useRecommendationEventStore.getState().clearEvents(); + queryClient.clear(); + + return seed; +} + +export function bootstrapScreenshotSeedOnce() { + if (hasBootstrappedScreenshotSeed) { + return false; + } + + applyScreenshotSeed(); + hasBootstrappedScreenshotSeed = true; + return true; +} + +export function resetScreenshotSeedBootstrapForTests() { + hasBootstrappedScreenshotSeed = false; +} diff --git a/src/components/legal/LegalDocumentScreen.tsx b/src/components/legal/LegalDocumentScreen.tsx index b90201e..3a917db 100644 --- a/src/components/legal/LegalDocumentScreen.tsx +++ b/src/components/legal/LegalDocumentScreen.tsx @@ -44,7 +44,11 @@ export function LegalDocumentScreen({ > router.back()} /> + router.back()} + /> } title={title} /> @@ -53,7 +57,7 @@ export function LegalDocumentScreen({ {updatedAt} - + {subtitle}
@@ -62,7 +66,7 @@ export function LegalDocumentScreen({ {sections.map((section) => ( - + {section.body} diff --git a/src/components/library/LibraryScreen.tsx b/src/components/library/LibraryScreen.tsx index d3c620a..d2d2a5d 100644 --- a/src/components/library/LibraryScreen.tsx +++ b/src/components/library/LibraryScreen.tsx @@ -1,27 +1,31 @@ -import { useQuery } from '@tanstack/react-query'; -import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; -import { router } from 'expo-router'; -import { useEffect, useMemo, useState } from 'react'; -import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native'; - -import { libraryApi } from '@/api/libraryApi'; -import { syncRecommendationEvent } from '@/api/recommendationEventApi'; -import { AppText } from '@/components/AppText'; -import { IconButton } from '@/components/IconButton'; -import { LibraryEmptyState } from '@/components/library/LibraryEmptyState'; -import { LibraryTrackRow } from '@/components/library/LibraryTrackRow'; -import { PageHeader } from '@/components/PageHeader'; -import { Screen } from '@/components/Screen'; -import { SectionTitle } from '@/components/SectionTitle'; -import { SettingsRow } from '@/components/SettingsRow'; -import { LibraryTrackRecord, useLibraryStore } from '@/store/libraryStore'; -import { usePlayerStore } from '@/store/playerStore'; -import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import type { LibraryPlaylistSummary } from '@/types/domain'; -import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; - -type LibraryTab = 'liked' | 'playlists' | 'saved'; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Feather } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { router } from "expo-router"; +import { useEffect, useMemo, useState } from "react"; +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native"; + +import { + libraryApi, + type LibraryTracksPage, + type RemoteLibraryTrackRecord, +} from "@/api/libraryApi"; +import { syncRecommendationEvent } from "@/api/recommendationEventApi"; +import { AppText } from "@/components/AppText"; +import { IconButton } from "@/components/IconButton"; +import { LibraryEmptyState } from "@/components/library/LibraryEmptyState"; +import { LibraryTrackRow } from "@/components/library/LibraryTrackRow"; +import { PageHeader } from "@/components/PageHeader"; +import { Screen } from "@/components/Screen"; +import { SectionTitle } from "@/components/SectionTitle"; +import { SettingsRow } from "@/components/SettingsRow"; +import { LibraryTrackRecord, useLibraryStore } from "@/store/libraryStore"; +import { usePlayerStore } from "@/store/playerStore"; +import { useRecommendationEventStore } from "@/store/recommendationEventStore"; +import type { LibraryPlaylistSummary } from "@/types/domain"; +import { createRecommendationEventContext } from "@/utils/recommendationEventContext"; + +type LibraryTab = "liked" | "playlists" | "saved"; type LibraryPlaylistRecord = { createdAt: string; playlist?: LibraryPlaylistSummary; @@ -30,53 +34,81 @@ type LibraryPlaylistRecord = { }; const tabs: Array<{ id: LibraryTab; label: string }> = [ - { id: 'liked', label: '좋아요' }, - { id: 'saved', label: '저장곡' }, - { id: 'playlists', label: '플레이리스트' }, + { id: "liked", label: "좋아요" }, + { id: "saved", label: "저장곡" }, + { id: "playlists", label: "플레이리스트" }, ]; -function getPlaylistTitle(playlist?: LibraryPlaylistSummary, playlistId?: string) { +function getPlaylistTitle( + playlist?: LibraryPlaylistSummary, + playlistId?: string, +) { const placeLabel = playlist?.placeName ?? playlist?.regionName; if (placeLabel) { return `${placeLabel} 사운드트랙`; } - return playlistId ? '저장한 사운드트랙' : '저장한 플레이리스트'; + return playlistId ? "저장한 사운드트랙" : "저장한 플레이리스트"; } function getPlaylistDescription(playlist?: LibraryPlaylistSummary) { return ( playlist?.reason || playlist?.description || - [playlist?.durationText, playlist?.trackCount ? `${playlist.trackCount}곡` : undefined] + [ + playlist?.durationText, + playlist?.trackCount ? `${playlist.trackCount}곡` : undefined, + ] .filter(Boolean) - .join(' · ') || - '다시 듣고 싶은 곡을 모아둔 추천 목록' + .join(" · ") || + "다시 듣고 싶은 곡을 모아둔 추천 목록" ); } export function LibraryScreen() { - const [selectedTab, setSelectedTab] = useState('liked'); + const [selectedTab, setSelectedTab] = useState("liked"); const [actionMessage, setActionMessage] = useState(); - const { - likedTracks, - savedTracks, - setLikeState, - setSaveState, - } = useLibraryStore(); + const [openTrackId, setOpenTrackId] = useState(); + const { likedTracks, savedTracks, setLikeState, setSaveState } = + useLibraryStore(); const setTrack = usePlayerStore((state) => state.setTrack); - const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); + const addRecommendationEvent = useRecommendationEventStore( + (state) => state.addEvent, + ); const { - data: remoteLibraryRecords = [], + data: remoteLibraryPages, + fetchNextPage: fetchMoreRemoteLibrary, + hasNextPage: hasMoreRemoteLibrary, isError: isRemoteLibraryError, + isFetchingNextPage: isLoadingMoreRemoteLibrary, isLoading: isRemoteLibraryLoading, refetch: refetchRemoteLibrary, - } = useQuery({ - queryFn: () => libraryApi.getTracks('all'), - queryKey: ['library', 'tracks', 'all'], + // `queryFn` must come first so TS can infer TQueryFnData from it before + // typing `lastPage` in getNextPageParam below (object-literal generic + // inference is order-sensitive here). + } = useInfiniteQuery({ + queryFn: ({ pageParam }) => libraryApi.getTracks("all", pageParam), + queryKey: ["library", "tracks", "all"], + getNextPageParam: (lastPage: LibraryTracksPage) => + lastPage.page?.nextCursor ?? undefined, + initialPageParam: undefined as string | undefined, staleTime: 5 * 60 * 1000, }); + // Pages come back in server (cursor) order; a track can only ever appear + // in one page, but dedupe by id defensively so a refetch/retry overlap + // never double-counts a row. Map preserves first-seen (i.e. server) order. + const remoteLibraryRecords = useMemo(() => { + const byId = new Map(); + + remoteLibraryPages?.pages.forEach((page) => { + page.records.forEach((record) => { + byId.set(record.id, record); + }); + }); + + return Array.from(byId.values()); + }, [remoteLibraryPages]); const playlistRecords = useMemo(() => { const groupMap = new Map(); @@ -110,24 +142,24 @@ export function LibraryScreen() { second.createdAt.localeCompare(first.createdAt), ); }, [savedTracks]); - const records = selectedTab === 'liked' ? likedTracks : savedTracks; + const records = selectedTab === "liked" ? likedTracks : savedTracks; const hasLocalRecords = likedTracks.length > 0 || savedTracks.length > 0; const selectedCount = - selectedTab === 'playlists' ? playlistRecords.length : records.length; + selectedTab === "playlists" ? playlistRecords.length : records.length; const selectedSectionTitle = - selectedTab === 'liked' - ? '좋아요한 음악' - : selectedTab === 'saved' - ? '저장한 음악' - : '저장한 플레이리스트'; + selectedTab === "liked" + ? "좋아요한 음악" + : selectedTab === "saved" + ? "저장한 음악" + : "저장한 플레이리스트"; useEffect(() => { remoteLibraryRecords.forEach((record) => { - if (record.track.isLiked || record.kind === 'liked') { + if (record.track.isLiked || record.kind === "liked") { setLikeState(record.track, true, record.playlistId, record.playlist); } - if (record.track.isSaved || record.kind === 'saved') { + if (record.track.isSaved || record.kind === "saved") { setSaveState(record.track, true, record.playlistId, record.playlist); } }); @@ -138,16 +170,26 @@ export function LibraryScreen() { }; const selectTab = (tab: LibraryTab) => { setSelectedTab(tab); + setOpenTrackId(undefined); clearActionMessage(); }; - const playRecord = (record: LibraryTrackRecord) => { - setActionMessage(undefined); - setTrack(record.track, record.playlistId, undefined, record.playlist); - setActionMessage('이 곡을 SoundLog 음악으로 선택했어요. 하단 패널에서 저장하거나 리캡에 담을 수 있어요.'); + const openPlaylist = (playlist: LibraryPlaylistRecord) => { + const representativeRecord = playlist.records[0]; + + if (representativeRecord) { + setTrack( + representativeRecord.track, + playlist.playlistId, + playlist.records.map((record) => record.track), + playlist.playlist, + ); + } + + router.push(`/playlist/${playlist.playlistId}` as never); }; const removeRecord = (record: LibraryTrackRecord) => { const context = createRecommendationEventContext(); - const isRemovingLikedTrack = selectedTab === 'liked'; + const isRemovingLikedTrack = selectedTab === "liked"; setActionMessage(undefined); @@ -155,46 +197,46 @@ export function LibraryScreen() { setLikeState(record.track, false, record.playlistId); void libraryApi .updateTrackState(record.track.id, { - action: 'unlike', + action: "unlike", context, playlistId: record.playlistId, }) .catch(() => { setLikeState(record.track, true, record.playlistId, record.playlist); - setActionMessage('서버 저장에 실패해서 좋아요 상태를 되돌렸어요.'); + setActionMessage("서버 저장에 실패해서 좋아요 상태를 되돌렸어요."); }); syncRecommendationEvent( addRecommendationEvent({ context, playlistId: record.playlistId, trackId: record.track.id, - type: 'track_unlike', + type: "track_unlike", }), ); - setActionMessage('좋아요 목록에서 삭제했어요.'); + setActionMessage("좋아요 목록에서 삭제했어요."); return; } setSaveState(record.track, false, record.playlistId); void libraryApi .updateTrackState(record.track.id, { - action: 'unsave', + action: "unsave", context, playlistId: record.playlistId, }) .catch(() => { setSaveState(record.track, true, record.playlistId, record.playlist); - setActionMessage('서버 저장에 실패해서 저장 상태를 되돌렸어요.'); + setActionMessage("서버 저장에 실패해서 저장 상태를 되돌렸어요."); }); syncRecommendationEvent( addRecommendationEvent({ context, playlistId: record.playlistId, trackId: record.track.id, - type: 'track_unsave', + type: "track_unsave", }), ); - setActionMessage('저장곡 목록에서 삭제했어요.'); + setActionMessage("저장곡 목록에서 삭제했어요."); }; return ( @@ -212,7 +254,7 @@ export function LibraryScreen() { router.replace('/my' as never)} + onPress={() => router.replace("/my" as never)} /> } title="보관함" @@ -229,11 +271,15 @@ export function LibraryScreen() { accessibilityState={{ selected: isSelected }} className="h-10 flex-1 items-center justify-center rounded-full" onPress={() => selectTab(tab.id)} - style={{ backgroundColor: isSelected ? '#ffffff' : 'transparent' }} + style={{ + backgroundColor: isSelected ? "#ffffff" : "transparent", + }} > {tab.label} @@ -273,8 +319,8 @@ export function LibraryScreen() { ) : null} - {isRemoteLibraryLoading && !hasLocalRecords ? null : selectedTab === 'playlists' ? ( + {isRemoteLibraryLoading && !hasLocalRecords ? null : selectedTab === + "playlists" ? ( playlistRecords.length === 0 ? ( router.push(`/playlist/${playlist.playlistId}` as never)} + onPress={() => openPlaylist(playlist)} style={({ pressed }) => ({ opacity: pressed ? 0.62 : 1 })} > {imageUrl ? ( - + ) : null} - + {title} - + {description} - + 저장곡 {playlist.records.length} {playlist.playlist?.durationText ? ` · ${playlist.playlist.durationText}` - : ''} + : ""} - + ); })} @@ -341,25 +412,61 @@ export function LibraryScreen() { ) : records.length === 0 ? ( ) : ( {records.map((record) => ( playRecord(record)} - onRemove={() => removeRecord(record)} + isDeleteOpen={openTrackId === record.track.id} + onRemove={() => { + setOpenTrackId(undefined); + removeRecord(record); + }} + onToggleDelete={() => + setOpenTrackId((currentTrackId) => + currentTrackId === record.track.id + ? undefined + : record.track.id, + ) + } record={record} /> ))} )} + + {hasMoreRemoteLibrary ? ( + void fetchMoreRemoteLibrary()} + > + {isLoadingMoreRemoteLibrary ? ( + + ) : ( + + 더 불러오기 + + )} + + ) : null} ); diff --git a/src/components/library/LibraryTrackRow.tsx b/src/components/library/LibraryTrackRow.tsx index a393374..53c7755 100644 --- a/src/components/library/LibraryTrackRow.tsx +++ b/src/components/library/LibraryTrackRow.tsx @@ -1,69 +1,166 @@ -import { Feather } from '@expo/vector-icons'; -import { Image } from 'expo-image'; -import { Pressable, View } from 'react-native'; +import { Feather } from "@expo/vector-icons"; +import { Image } from "expo-image"; +import { useEffect, useRef } from "react"; +import { Animated, Easing, Pressable, StyleSheet, View } from "react-native"; -import { AppText } from '@/components/AppText'; -import { LibraryTrackRecord } from '@/store/libraryStore'; -import { formatRecapRecordedAt } from '@/utils/dateFormat'; +import { AppText } from "@/components/AppText"; +import { LibraryTrackRecord } from "@/store/libraryStore"; +import { formatRecapRecordedAt } from "@/utils/dateFormat"; type LibraryTrackRowProps = { - onPress: () => void; + isDeleteOpen: boolean; onRemove: () => void; + onToggleDelete: () => void; record: LibraryTrackRecord; }; +const DELETE_ACTION_WIDTH = 84; +const DELETE_REVEAL_DURATION_MS = 220; + export function LibraryTrackRow({ - onPress, + isDeleteOpen, onRemove, + onToggleDelete, record, }: LibraryTrackRowProps) { const { track } = record; + const revealProgress = useRef(new Animated.Value(0)).current; + const actionTranslateX = revealProgress.interpolate({ + inputRange: [0, 1], + outputRange: [DELETE_ACTION_WIDTH, 0], + }); + const rowTranslateX = revealProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0, -20], + }); + + useEffect(() => { + const animation = Animated.timing(revealProgress, { + duration: DELETE_REVEAL_DURATION_MS, + easing: Easing.out(Easing.cubic), + toValue: isDeleteOpen ? 1 : 0, + useNativeDriver: true, + }); + + animation.start(); + + return () => animation.stop(); + }, [isDeleteOpen, revealProgress]); + + const handleRemove = () => { + onRemove(); + }; return ( - - + - - {track.albumImageUrl ? ( - - ) : null} - - - - - {track.title} + + + 삭제 - - {track.artist} - - - {formatRecapRecordedAt(record.createdAt)} - - - - + + - - - + { + if (event.nativeEvent.actionName === "delete") { + handleRemove(); + return; + } + + onToggleDelete(); + }} + onPress={onToggleDelete} + > + + {track.albumImageUrl ? ( + + ) : null} + + + + + {track.title} + + + {track.artist} + + + {formatRecapRecordedAt(record.createdAt)} + + + + + ); } + +const styles = StyleSheet.create({ + deleteAction: { + flexShrink: 0, + width: DELETE_ACTION_WIDTH, + zIndex: 1, + }, + rowSurface: { + backgroundColor: "#070B1F", + width: "100%", + }, + rowContainer: { + width: "100%", + }, +}); diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 12b4e42..39c29f9 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -112,7 +112,21 @@ export function MomentCaptureScreen() { [selectedMoodFilter], ); + // Synchronous guard against double-submitting a save: `isSaving` is React + // state, so a second tap that lands before the first commit still reads + // the stale `false` and slips past the state-based check. A ref updates + // immediately, before any await, so the second call is rejected in the + // same tick as the first one claims the lock. + const isSavingRef = useRef(false); + // The moment/idempotency id for the in-flight (or retry-pending) save + // attempt. Generated once per review session and reused across retries so + // a failed save followed by the user tapping "저장" again reuses the same + // idempotencyKey instead of minting a new one — see queueCreate / + // syncCreateAction, which key the server request on this id. + const saveIdRef = useRef(undefined); + const prepareReview = (photoUri?: string) => { + saveIdRef.current = undefined; setCapturedAt(new Date().toISOString()); setCapturedPhotoUri(photoUri); setReviewPlaceName(""); @@ -234,15 +248,17 @@ export function MomentCaptureScreen() { }; const handleSave = async () => { - if (!isReviewing || isSaving) { + if (!isReviewing || isSavingRef.current) { return; } + isSavingRef.current = true; setIsSaving(true); setErrorMessage(undefined); try { - const id = `moment-${Date.now()}`; + const id = saveIdRef.current ?? `moment-${Date.now()}`; + saveIdRef.current = id; const locationSnapshot: GeoPoint | undefined = currentLocation; const activeSessionId = session.status === "active" ? session.id : undefined; @@ -318,6 +334,7 @@ export function MomentCaptureScreen() { } catch { setErrorMessage("이 리캡을 저장하지 못했어요. 다시 시도해주세요."); } finally { + isSavingRef.current = false; setIsSaving(false); } }; diff --git a/src/components/onboarding/OnboardingScreen.tsx b/src/components/onboarding/OnboardingScreen.tsx index f80371f..f94a308 100644 --- a/src/components/onboarding/OnboardingScreen.tsx +++ b/src/components/onboarding/OnboardingScreen.tsx @@ -1,6 +1,15 @@ import { router, useLocalSearchParams } from 'expo-router'; -import { useMemo, useState } from 'react'; -import { Pressable, ScrollView, Switch, View } from 'react-native'; +import { Image } from 'expo-image'; +import { useMemo, useRef, useState } from 'react'; +import { + NativeScrollEvent, + NativeSyntheticEvent, + Pressable, + ScrollView, + Switch, + useWindowDimensions, + View, +} from 'react-native'; import { meApi } from '@/api/meApi'; import { AppText } from '@/components/AppText'; @@ -23,6 +32,30 @@ type TravelOption = { profileValue: string; }; +const introSlides = [ + { + cropImageBorder: false, + description: '지금 있는 장소와 취향을 바탕으로 오늘의 음악을 골라요.', + id: 'location', + image: require('../../../assets/onboarding/location-recommendation.png'), + title: '장소 기반 음악 추천', + }, + { + cropImageBorder: true, + description: '사진과 장소, 음악을 한 장의 리캡으로 기록해요.', + id: 'recap', + image: require('../../../assets/onboarding/recap-memory.png'), + title: '순간을 리캡으로 기록', + }, + { + cropImageBorder: false, + description: '여행 중 남긴 리캡을 이동 경로와 함께 하나의 로그로 모아요.', + id: 'travel-log', + image: require('../../../assets/onboarding/travel-log.png'), + title: '여행 로그로 회고', + }, +] as const; + const travelOptions: TravelOption[] = [ { label: '바다', profileValue: '바다 보기' }, { label: '드라이브', profileValue: '드라이브' }, @@ -34,6 +67,7 @@ const travelOptions: TravelOption[] = [ const moodOptions = ['잔잔한', '신나는', '시원한', '설레는', '감성적인']; const defaultTravelLabel = '산책'; const defaultMood = '잔잔한'; +const embeddedImageBorderCrop = 4; function getTravelLabelFromProfile(profile: UserProfileInput) { const selectedTravel = travelOptions.find((option) => @@ -44,7 +78,10 @@ function getTravelLabelFromProfile(profile: UserProfileInput) { } function getMoodFromProfile(profile: UserProfileInput) { - return moodOptions.find((mood) => profile.preferredMoods.includes(mood)) ?? defaultMood; + return ( + moodOptions.find((mood) => profile.preferredMoods.includes(mood)) ?? + defaultMood + ); } function buildProfileInput({ @@ -59,7 +96,8 @@ function buildProfileInput({ selectedTravelLabel: string; }): UserProfileInput { const selectedTravel = - travelOptions.find((option) => option.label === selectedTravelLabel) ?? travelOptions[2]; + travelOptions.find((option) => option.label === selectedTravelLabel) ?? + travelOptions[2]; return { companionType: baseProfile.companionType, @@ -71,22 +109,30 @@ function buildProfileInput({ } export function OnboardingScreen() { + const { height, width } = useWindowDimensions(); const params = useLocalSearchParams<{ mode?: string | string[] }>(); const mode = Array.isArray(params.mode) ? params.mode[0] : params.mode; const isEditMode = mode === 'edit'; + const shouldStartWithSetup = isEditMode || mode === 'setup'; const { completeOnboarding, profile, updateProfile } = useUserProfileStore(); const { status } = useAuthStore(); const { setSelectedMoodFilter } = useHomeFilterStore(); - const [currentStep, setCurrentStep] = useState(isEditMode ? 'setup' : 'intro'); + const [currentStep, setCurrentStep] = useState( + shouldStartWithSetup ? 'setup' : 'intro', + ); + const [introIndex, setIntroIndex] = useState(0); const [selectedTravelLabel, setSelectedTravelLabel] = useState(() => getTravelLabelFromProfile(profile), ); - const [selectedMood, setSelectedMood] = useState(() => getMoodFromProfile(profile)); - const [locationRecommendationEnabled, setLocationRecommendationEnabled] = useState( - profile.locationRecommendationEnabled, + const [selectedMood, setSelectedMood] = useState(() => + getMoodFromProfile(profile), ); + const [locationRecommendationEnabled, setLocationRecommendationEnabled] = + useState(profile.locationRecommendationEnabled); const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(); + const introPagerRef = useRef(null); + const introImageSize = Math.min(width - 48, Math.max(240, height * 0.4), 334); const draft = useMemo( () => @@ -111,7 +157,9 @@ export function OnboardingScreen() { await meApi.updateProfile(input); return true; } catch { - setSaveErrorMessage('프로필을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.'); + setSaveErrorMessage( + '프로필을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.', + ); return false; } finally { setIsSaving(false); @@ -156,83 +204,130 @@ export function OnboardingScreen() { void enterHome(input); }; + const handleIntroScrollEnd = ( + event: NativeSyntheticEvent, + ) => { + const nextIndex = Math.round(event.nativeEvent.contentOffset.x / width); + setIntroIndex(Math.max(0, Math.min(nextIndex, introSlides.length - 1))); + }; + + const handleIntroPrimaryAction = () => { + const isLastSlide = introIndex === introSlides.length - 1; + + if (!isLastSlide) { + const nextIndex = introIndex + 1; + introPagerRef.current?.scrollTo({ animated: true, x: nextIndex * width }); + return; + } + + if (status !== 'authenticated') { + router.push('/auth/login' as never); + return; + } + + setCurrentStep('setup'); + }; + const renderIntro = () => ( - <> - + + + + Soundlog + + {status === 'authenticated' ? null : ( router.push('/auth/login' as never)} - style={{ opacity: isSaving ? 0.45 : 1 }} > - 로그인 + + 로그인 + - ) - } - title="Soundlog" - /> - - - - 지금 장소의 음악을{'\n'}여행 앨범으로 - - - 음악은 외부 앱에서 듣고, Soundlog에는 여행의 사운드트랙을 남겨요. - - - - - - - - - + )} - - - - {saveErrorMessage ? ( - - {saveErrorMessage} - - ) : null} - { - if (status !== 'authenticated') { - router.push('/auth/login' as never); - return; - } - - setCurrentStep('setup'); - }} - style={{ opacity: isSaving ? 0.55 : 1 }} + - - {status === 'authenticated' - ? '추천 취향 설정하기' - : '계정 만들기 또는 로그인'} - - + {introSlides.map((slide, index) => ( + + + + + + {slide.title} + + + {slide.description} + + + ))} + + + + + {introSlides.map((slide, index) => ( + + ))} + + + + {introIndex < introSlides.length - 1 + ? '다음' + : status === 'authenticated' + ? '추천 취향 설정하기' + : '로그인하고 시작하기'} + + + - + ); const renderSetup = () => ( @@ -240,7 +335,9 @@ export function OnboardingScreen() { isEditMode @@ -387,7 +484,7 @@ export function OnboardingScreen() { onPress={() => handlePrimarySetup(false)} style={{ opacity: isSaving ? 0.55 : 1 }} > - + 나중에 하기 @@ -396,18 +493,22 @@ export function OnboardingScreen() { ); + if (currentStep === 'intro') { + return renderIntro(); + } + return ( - {currentStep === 'intro' ? renderIntro() : renderSetup()} + {renderSetup()} ); diff --git a/src/components/playlist/PlaylistBackground.tsx b/src/components/playlist/PlaylistBackground.tsx index 6ab3bda..62c03d2 100644 --- a/src/components/playlist/PlaylistBackground.tsx +++ b/src/components/playlist/PlaylistBackground.tsx @@ -1,36 +1,28 @@ -import { Image } from 'expo-image'; -import { LinearGradient } from 'expo-linear-gradient'; -import { StyleSheet, useWindowDimensions, View } from 'react-native'; +import { Image } from "expo-image"; +import { LinearGradient } from "expo-linear-gradient"; +import { StyleSheet, View } from "react-native"; type PlaylistBackgroundProps = { accentColor?: string; imageUrl?: string; }; -export function PlaylistBackground({ accentColor, imageUrl }: PlaylistBackgroundProps) { - const { height, width } = useWindowDimensions(); - const repeatedImageStyle = { height: height / 2, width }; - +export function PlaylistBackground({ + accentColor, + imageUrl, +}: PlaylistBackgroundProps) { return ( {imageUrl ? ( - <> - - - + ) : ( ) : null} diff --git a/src/components/playlist/PlaylistBottomSheet.tsx b/src/components/playlist/PlaylistBottomSheet.tsx index cfec4b1..08b3a38 100644 --- a/src/components/playlist/PlaylistBottomSheet.tsx +++ b/src/components/playlist/PlaylistBottomSheet.tsx @@ -1,13 +1,12 @@ -import { PropsWithChildren, ReactNode } from 'react'; -import { LinearGradient } from 'expo-linear-gradient'; -import { Platform, ScrollView, useWindowDimensions, View } from 'react-native'; +import { PropsWithChildren, ReactNode } from "react"; +import { Platform, ScrollView, useWindowDimensions, View } from "react-native"; const COLLAPSED_TOP = 205; -const SHEET_BACKGROUND = 'rgba(10, 16, 31, 0.26)'; +const SHEET_BACKGROUND = "rgba(7, 11, 31, 0.1)"; const glassSurfaceStyle = Platform.select({ web: { - WebkitBackdropFilter: 'blur(18px) saturate(135%)', - backdropFilter: 'blur(18px) saturate(135%)', + WebkitBackdropFilter: "blur(18px) saturate(135%)", + backdropFilter: "blur(18px) saturate(135%)", }, default: {}, }); @@ -16,14 +15,23 @@ type PlaylistBottomSheetProps = PropsWithChildren<{ stickyHeader?: ReactNode; }>; -export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSheetProps) { +export function PlaylistBottomSheet({ + children, + stickyHeader, +}: PlaylistBottomSheetProps) { const { height } = useWindowDimensions(); - const sheetStyle = { ...glassSurfaceStyle, backgroundColor: SHEET_BACKGROUND }; + const sheetStyle = { + ...glassSurfaceStyle, + backgroundColor: SHEET_BACKGROUND, + }; return ( @@ -32,12 +40,6 @@ export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSh className="overflow-hidden rounded-t-[20px] border-x border-t border-white/20 pt-3" style={{ ...sheetStyle, minHeight: height }} > - {stickyHeader} {children} @@ -47,12 +49,6 @@ export function PlaylistBottomSheet({ children, stickyHeader }: PlaylistBottomSh className="min-h-full overflow-hidden rounded-t-[20px] border border-white/20 pt-3" style={{ ...sheetStyle, height }} > - {children} diff --git a/src/components/recap-share/RecapPreviewCard.tsx b/src/components/recap-share/RecapPreviewCard.tsx index 69a29ce..0967027 100644 --- a/src/components/recap-share/RecapPreviewCard.tsx +++ b/src/components/recap-share/RecapPreviewCard.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AppText } from '@/components/AppText'; import { RecordDisc } from '@/components/recap-share/RecordDisc'; +import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; import { GeoPoint, RecapShare, @@ -62,6 +63,7 @@ function RecapBackground({ const [failedImageUrl, setFailedImageUrl] = useState(); const visibleImageUrl = imageUrl && failedImageUrl !== imageUrl ? imageUrl : undefined; + const photoSource = useAuthenticatedImageSource(visibleImageUrl); return ( <> @@ -69,7 +71,7 @@ function RecapBackground({ setFailedImageUrl(visibleImageUrl)} - source={{ uri: visibleImageUrl }} + source={photoSource} style={StyleSheet.absoluteFill} transition={300} /> @@ -321,6 +323,51 @@ function createRouteSegments(positions: Array<{ left: number; top: number }>) { }); } +// Extracted so `useAuthenticatedImageSource` can be called safely: it must run in a +// component's own render body, not directly inside the `.map()` in RecapFilmTemplate below +// (a hook call site would otherwise vary per render as the moment list's length changes). +function RecapFilmMomentThumbnail({ moment, index }: { index: number; moment: RecapShareMoment }) { + const photoSource = useAuthenticatedImageSource(moment.imageUrl); + + return ( + + {moment.imageUrl ? ( + + ) : ( + + )} + + + + {String(index + 1).padStart(2, '0')} + + + {moment.trackTitle} + + + {moment.placeName} + + + + ); +} + function RecapFilmTemplate({ recap }: { recap: RecapShare }) { const moments = ( recap.moments?.length ? recap.moments : [createFallbackMoment(recap)] @@ -366,44 +413,7 @@ function RecapFilmTemplate({ recap }: { recap: RecapShare }) { {moments.map((moment, index) => ( - - {moment.imageUrl ? ( - - ) : ( - - )} - - - - {String(index + 1).padStart(2, '0')} - - - {moment.trackTitle} - - - {moment.placeName} - - - + ))} diff --git a/src/components/recap-share/RecapRouteMap.tsx b/src/components/recap-share/RecapRouteMap.tsx index 10b2d3e..0713bb6 100644 --- a/src/components/recap-share/RecapRouteMap.tsx +++ b/src/components/recap-share/RecapRouteMap.tsx @@ -13,6 +13,7 @@ import MapView, { Marker, Polyline, type Region } from "react-native-maps"; import { AppText } from "@/components/AppText"; import { googleDarkMapStyle } from "@/components/travel/live-sound-map/SoundMapView"; +import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; import type { GeoPoint, RecapShare, @@ -70,13 +71,15 @@ function MomentPreview({ moment: RecapShareMoment; onOpen: () => void; }) { + const photoSource = useAuthenticatedImageSource(moment.imageUrl); + return ( {moment.imageUrl ? ( ) : ( @@ -116,6 +119,8 @@ function MomentDetailModal({ moment?: RecapShareMoment; onClose: () => void; }) { + const photoSource = useAuthenticatedImageSource(moment?.imageUrl); + return ( ) : ( diff --git a/src/components/recap-share/RecapSoundLogList.tsx b/src/components/recap-share/RecapSoundLogList.tsx index 4c04fc1..d8ef8f4 100644 --- a/src/components/recap-share/RecapSoundLogList.tsx +++ b/src/components/recap-share/RecapSoundLogList.tsx @@ -14,6 +14,7 @@ import { import { AppText } from "@/components/AppText"; import { SectionTitle } from "@/components/SectionTitle"; +import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; import type { RecapShare, RecapShareMoment } from "@/types/domain"; import { getRecapSoundLogs } from "@/utils/recapTravelSummary"; @@ -48,6 +49,7 @@ function SoundLogBackground({ imageUrl }: { imageUrl?: string }) { const [failedImageUrl, setFailedImageUrl] = useState(); const visibleImageUrl = imageUrl && failedImageUrl !== imageUrl ? imageUrl : undefined; + const photoSource = useAuthenticatedImageSource(visibleImageUrl); return ( <> @@ -55,7 +57,7 @@ function SoundLogBackground({ imageUrl }: { imageUrl?: string }) { setFailedImageUrl(visibleImageUrl)} - source={{ uri: visibleImageUrl }} + source={photoSource} style={StyleSheet.absoluteFill} transition={260} /> diff --git a/src/components/recap-share/RecordDisc.tsx b/src/components/recap-share/RecordDisc.tsx index 161c494..b13e4fc 100644 --- a/src/components/recap-share/RecordDisc.tsx +++ b/src/components/recap-share/RecordDisc.tsx @@ -1,17 +1,21 @@ import { Image } from 'expo-image'; import { View } from 'react-native'; +import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; + type RecordDiscProps = { imageUrl?: string; }; export function RecordDisc({ imageUrl }: RecordDiscProps) { + const photoSource = useAuthenticatedImageSource(imageUrl); + return ( {imageUrl ? ( diff --git a/src/components/recap/RecapListCard.tsx b/src/components/recap/RecapListCard.tsx index abc3eb1..e59eed0 100644 --- a/src/components/recap/RecapListCard.tsx +++ b/src/components/recap/RecapListCard.tsx @@ -4,6 +4,7 @@ import { LinearGradient } from "expo-linear-gradient"; import { Pressable, StyleSheet, View } from "react-native"; import { AppText } from "@/components/AppText"; +import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; import { RecapItem } from "@/types/domain"; import { formatRecapRecordedAt } from "@/utils/dateFormat"; @@ -14,6 +15,7 @@ type RecapListCardProps = { }; export function RecapListCard({ imageUrl, item, onPress }: RecapListCardProps) { + const photoSource = useAuthenticatedImageSource(imageUrl); const momentCountLabel = item.momentCount && item.momentCount > 1 ? `기록 ${item.momentCount}개` @@ -29,7 +31,7 @@ export function RecapListCard({ imageUrl, item, onPress }: RecapListCardProps) { {imageUrl ? ( diff --git a/src/components/recap/RecapListScreen.tsx b/src/components/recap/RecapListScreen.tsx index f5467b2..93bd6dc 100644 --- a/src/components/recap/RecapListScreen.tsx +++ b/src/components/recap/RecapListScreen.tsx @@ -25,6 +25,7 @@ import { PageHeader } from "@/components/PageHeader"; import { RecapEmptyState } from "@/components/recap/RecapEmptyState"; import { Screen } from "@/components/Screen"; import { getTabBarHeight } from "@/constants/layout"; +import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; import { useMomentLogStore } from "@/store/momentLogStore"; import type { MomentLog, RecapItem, RecapVisibility } from "@/types/domain"; import { @@ -33,7 +34,7 @@ import { } from "@/utils/recapMappers"; import { flushPendingMomentActions } from "@/utils/momentLogSync"; -type LogFeedTabId = "others" | "all"; +type LogFeedTabId = "others" | "mine"; type LogGridEntry = { imageUrl?: string; @@ -53,8 +54,8 @@ const logFeedTabs: Array<{ value: "others", }, { - label: "모든 사람 보기", - value: "all", + label: "내것만 보기", + value: "mine", }, ]; @@ -66,21 +67,6 @@ function sortEntriesByCreatedAt(entries: LogGridEntry[]) { ); } -function dedupeEntries(entries: LogGridEntry[]) { - const seenKeys = new Set(); - - return entries.filter((entry) => { - const key = `${entry.source}:${entry.shareId}`; - - if (seenKeys.has(key)) { - return false; - } - - seenKeys.add(key); - return true; - }); -} - function getEntryImageUrl(entry: LogGridEntry) { return entry.imageUrl ?? entry.item.representativeTrack.albumImageUrl; } @@ -150,6 +136,7 @@ function LogGridCard({ const [failedImageUrl, setFailedImageUrl] = useState(); const visibleImageUrl = imageUrl && failedImageUrl !== imageUrl ? imageUrl : undefined; + const photoSource = useAuthenticatedImageSource(visibleImageUrl); const visibility = entry.item.visibility ?? "private"; const nextVisibility: RecapVisibility = visibility === "public" ? "private" : "public"; @@ -175,7 +162,7 @@ function LogGridCard({ setFailedImageUrl(visibleImageUrl)} - source={{ uri: visibleImageUrl }} + source={photoSource} style={StyleSheet.absoluteFill} transition={180} /> @@ -379,12 +366,13 @@ export function RecapListScreen() { const queryClient = useQueryClient(); const momentLogs = useMomentLogStore((state) => state.logs); const [selectedTab, setSelectedTab] = useState( - initialView === "all" ? "all" : "others", + initialView === "mine" || initialView === "all" ? "mine" : "others", ); const [updatingRecapId, setUpdatingRecapId] = useState(); const [actionMessage, setActionMessage] = useState(); const pagerRef = useRef(null); - const initialTabIndex = initialView === "all" ? 1 : 0; + const initialTabIndex = + initialView === "mine" || initialView === "all" ? 1 : 0; const scrollX = useRef(new Animated.Value(initialTabIndex * width)).current; const mineRecapsQuery = useRecapListQuery("mine"); const otherRecapsQuery = useRecapListQuery("others"); @@ -455,12 +443,7 @@ export function RecapListScreen() { () => sortEntriesByCreatedAt([...localEntries, ...serverMineEntries]), [localEntries, serverMineEntries], ); - const allEntries = useMemo( - () => - sortEntriesByCreatedAt(dedupeEntries([...myEntries, ...otherEntries])), - [myEntries, otherEntries], - ); - const hasAnyLog = otherEntries.length > 0 || allEntries.length > 0; + const hasAnyLog = otherEntries.length > 0 || myEntries.length > 0; const handleOpenEntry = useCallback((entry: LogGridEntry) => { router.push(`/recap-share/${entry.shareId}`); }, []); @@ -574,7 +557,7 @@ export function RecapListScreen() { setActionMessage( nextVisibility === "public" - ? "전체공개로 바꿨어요. 다른사람 보기와 지도 공개 영역에 표시돼요." + ? "전체공개로 바꿨어요. 다른 사람의 공개 피드와 지도에 표시돼요." : "비공개로 바꿨어요. 내 로그에서만 확인할 수 있어요.", ); void queryClient.invalidateQueries({ queryKey: recapQueryKeys.lists }); @@ -601,7 +584,7 @@ export function RecapListScreen() { {logFeedTabs.map((tab) => { const selected = selectedTab === tab.value; const count = - tab.value === "others" ? otherEntries.length : allEntries.length; + tab.value === "others" ? otherEntries.length : myEntries.length; return ( diff --git a/src/components/travel/MomentCard.tsx b/src/components/travel/MomentCard.tsx index d151a09..b610a2f 100644 --- a/src/components/travel/MomentCard.tsx +++ b/src/components/travel/MomentCard.tsx @@ -3,6 +3,7 @@ import { Image } from 'expo-image'; import { ActivityIndicator, Pressable, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; import type { MomentLog } from '@/types/domain'; import { formatKoreanDateTime } from './travelFormat'; @@ -15,6 +16,7 @@ type MomentCardProps = { }; export function MomentCard({ item, onPress, onRetry }: MomentCardProps) { + const photoSource = useAuthenticatedImageSource(item.photoUri); const moodLabel = item.moodTags[0] ? moodLabelByValue[item.moodTags[0]] : '무드 기록'; const isRetrying = item.syncStatus === 'pending'; const showRetry = item.syncStatus === 'failed' && onRetry; @@ -37,7 +39,7 @@ export function MomentCard({ item, onPress, onRetry }: MomentCardProps) { onPress={onPress} > {item.photoUri ? ( - + ) : ( diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx index 4316b41..d824133 100644 --- a/src/components/travel/TravelReportModal.tsx +++ b/src/components/travel/TravelReportModal.tsx @@ -5,6 +5,7 @@ import { Image, Modal, Pressable, StyleSheet, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText } from "@/components/AppText"; +import { useAuthenticatedImageSource } from "@/hooks/useAuthenticatedImageSource"; import { modeIconByValue, @@ -19,6 +20,35 @@ type TravelReportModalProps = { visible: boolean; }; +// Extracted so `useAuthenticatedImageSource` can be called safely: it must run in a +// component's own render body, not directly inside the `recapThumbnails.map()` below (a +// hook call site would otherwise vary per render as the thumbnail list's length changes). +function RecapThumbnailFrame({ photoUri }: { photoUri?: string }) { + const photoSource = useAuthenticatedImageSource(photoUri); + + if (!photoUri) { + return ( + + + + 음악 기록 + + + ); + } + + return ( + + ); +} + type StoryPage = { accent: string; hideBottomBar?: boolean; @@ -481,27 +511,7 @@ export function TravelReportModal({ className="flex-1 overflow-hidden rounded-[22px]" style={{ height: 154 }} > - {moment.photoUri ? ( - - ) : ( - - - - 음악 기록 - - - )} + ))} diff --git a/src/components/travel/TravelScreen.tsx b/src/components/travel/TravelScreen.tsx index a8acf16..4ffabe7 100644 --- a/src/components/travel/TravelScreen.tsx +++ b/src/components/travel/TravelScreen.tsx @@ -19,6 +19,7 @@ import { RecapListCard } from '@/components/recap/RecapListCard'; import { Screen } from '@/components/Screen'; import { AppText } from '@/components/AppText'; import { getHomeContentBottomPadding } from '@/constants/layout'; +import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; import { createRoutePoint, useTravelRouteTracking, @@ -144,6 +145,23 @@ function getMomentMoodLabel(log: MomentLog) { ); } +// Extracted so `useAuthenticatedImageSource` can be called safely: it must run in a +// component's own render body, not directly inside the `logs.slice(0, 3).map()` below (a +// hook call site would otherwise vary per render as the log list's length changes). +function EditPhotoPreview({ photoUri }: { photoUri?: string }) { + const photoSource = useAuthenticatedImageSource(photoUri); + + if (!photoUri) { + return null; + } + + return ( + + + + ); +} + function TravelLogSummaryCard({ currentTrack, logs, @@ -336,15 +354,7 @@ function TravelLogSummaryCard({ 사진 - {editPhotoUriDraft ? ( - - - - ) : null} + {editPhotoUriDraft ? '사진 연결됨' : '사진 없음'} diff --git a/src/components/travel/recap-map/RecapMapSection.tsx b/src/components/travel/recap-map/RecapMapSection.tsx index f56d6d5..6abf0bd 100644 --- a/src/components/travel/recap-map/RecapMapSection.tsx +++ b/src/components/travel/recap-map/RecapMapSection.tsx @@ -476,7 +476,7 @@ export function RecapMapSection({ className={`h-8 w-full items-center justify-center rounded-full border px-2 ${ selected ? 'border-soundlog-lime bg-soundlog-lime' - : 'border-white/10 bg-white/10' + : 'border-white/22 bg-black/68' }`} > mapViewRef.current?.focusCurrentLocation()} style={{ opacity: currentLocation ? 1 : 0.48 }} > - + - + 지도 / 여행모드 {mapTitle} - + + {mapMessage} @@ -590,7 +590,7 @@ export function RecapMapSection({ ) : visibleMarkers.length === 0 ? ( - + {getEmptyCopy(filter)} diff --git a/src/components/travel/recap-map/SelectedRecapPinPanel.tsx b/src/components/travel/recap-map/SelectedRecapPinPanel.tsx index fae3cc5..48a32f0 100644 --- a/src/components/travel/recap-map/SelectedRecapPinPanel.tsx +++ b/src/components/travel/recap-map/SelectedRecapPinPanel.tsx @@ -3,6 +3,7 @@ import { Image } from 'expo-image'; import { Pressable, ScrollView, View } from 'react-native'; import { AppText } from '@/components/AppText'; +import { useAuthenticatedImageSource } from '@/hooks/useAuthenticatedImageSource'; import type { RecapMapMarker } from '@/types/domain'; import { formatRecapRecordedAt } from '@/utils/dateFormat'; @@ -15,6 +16,30 @@ type SelectedRecapPinPanelProps = { pin: SoundMapPin; }; +// Extracted so `useAuthenticatedImageSource` can be called safely: it must run in a +// component's own render body, not directly inside the `markers.map()` below (a hook call +// site would otherwise vary per render as the marker list's length changes). +function MarkerThumbnail({ imageUrl }: { imageUrl?: string }) { + const photoSource = useAuthenticatedImageSource(imageUrl); + + return ( + + {imageUrl ? ( + + ) : ( + + + + )} + + ); +} + export function SelectedRecapPinPanel({ markers, onClose, @@ -65,24 +90,7 @@ export function SelectedRecapPinPanel({ key={marker.id} onPress={() => onOpenRecap(marker.recapId)} > - - {marker.imageUrl ? ( - - ) : ( - - - - )} - + diff --git a/src/constants/colors.ts b/src/constants/colors.ts index 679c67b..194ef50 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -21,7 +21,7 @@ export const colors = { chip: '#171B2A', chipSelected: '#B7E628', player: '#45343D', - tab: 'rgba(10, 16, 30, 0.58)', + tab: 'rgba(10, 16, 30, 0.78)', glass: 'rgba(255,255,255,0.1)', }, border: { diff --git a/src/hooks/useAuthenticatedImageSource.test.ts b/src/hooks/useAuthenticatedImageSource.test.ts new file mode 100644 index 0000000..3a53abb --- /dev/null +++ b/src/hooks/useAuthenticatedImageSource.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useAuthStore } from '@/store/authStore'; +import { toAuthenticatedImageSource } from '@/utils/authenticatedImageSource'; + +// This project's vitest setup intentionally does not render React components (see +// vitest.config.ts) — there is no @testing-library/react-hooks / react-test-renderer +// available to mount `useAuthenticatedImageSource` and observe an actual re-render. +// +// `useAuthenticatedImageSource` is a thin composition of two things: +// 1. `useAuthStore((state) => state.accessToken)` — a reactive subscription that causes +// React to re-render any component using it whenever the token changes (this is +// zustand's own, independently-tested subscription mechanism). +// 2. `toAuthenticatedImageSource(uri, accessToken)` — the pure value computation, fully +// covered in authenticatedImageSource.test.ts. +// +// What these tests verify is the part that was actually broken before this fix: that +// reading the token via the store (rather than a one-off `useAuthStore.getState()` capture) +// (a) is backed by a real subscription that fires on every login/refresh/logout transition, +// and (b) recomputing the image source from that live value — exactly what the hook does on +// every re-render — always reflects the *current* token, never a stale one. +describe('useAuthenticatedImageSource token reactivity', () => { + const uploadsUri = '/v1/uploads/abc123'; + + beforeEach(() => { + useAuthStore.setState({ + accessToken: undefined, + errorMessage: undefined, + lastLoginProvider: undefined, + refreshToken: undefined, + status: 'unauthenticated', + updatedAt: undefined, + user: undefined, + }); + }); + + it('has no Authorization header before any login', () => { + const source = toAuthenticatedImageSource(uploadsUri, useAuthStore.getState().accessToken); + + expect(source).toEqual({ uri: uploadsUri }); + }); + + it('picks up a fresh token immediately after login, via a live store subscription', () => { + const tokenReadsFromSubscription: Array = []; + const unsubscribe = useAuthStore.subscribe((state) => { + tokenReadsFromSubscription.push(state.accessToken); + }); + + try { + useAuthStore.getState().finishLogin({ + accessToken: 'session-token-1', + expiresIn: 3600, + isNewUser: false, + refreshToken: 'refresh-1', + user: { displayName: 'Test User', id: 'user-a', provider: 'email' }, + }); + + // The subscription (what React's useSyncExternalStore-based hook relies on) must + // have actually fired with the new token — proving this is a live subscription, not + // a value captured once and never revisited. + expect(tokenReadsFromSubscription).toContain('session-token-1'); + + const source = toAuthenticatedImageSource( + uploadsUri, + useAuthStore.getState().accessToken, + ); + + expect(source).toEqual({ + headers: { Authorization: 'Bearer session-token-1' }, + uri: uploadsUri, + }); + } finally { + unsubscribe(); + } + }); + + it('reflects a refreshed token (different from the original) without any stale value lingering', () => { + useAuthStore.getState().finishLogin({ + accessToken: 'session-token-1', + expiresIn: 3600, + isNewUser: false, + refreshToken: 'refresh-1', + user: { displayName: 'Test User', id: 'user-a', provider: 'email' }, + }); + + const beforeRefresh = toAuthenticatedImageSource( + uploadsUri, + useAuthStore.getState().accessToken, + ); + expect(beforeRefresh?.headers?.Authorization).toBe('Bearer session-token-1'); + + // Simulates what a background token refresh does: update just the access token. + useAuthStore.setState({ accessToken: 'refreshed-token-2' }); + + const afterRefresh = toAuthenticatedImageSource( + uploadsUri, + useAuthStore.getState().accessToken, + ); + expect(afterRefresh?.headers?.Authorization).toBe('Bearer refreshed-token-2'); + expect(afterRefresh?.headers?.Authorization).not.toBe( + beforeRefresh?.headers?.Authorization, + ); + }); + + it('removes the Authorization header immediately after logout', () => { + useAuthStore.getState().finishLogin({ + accessToken: 'session-token-1', + expiresIn: 3600, + isNewUser: false, + refreshToken: 'refresh-1', + user: { displayName: 'Test User', id: 'user-a', provider: 'email' }, + }); + expect( + toAuthenticatedImageSource(uploadsUri, useAuthStore.getState().accessToken), + ).toHaveProperty('headers'); + + useAuthStore.getState().logoutLocal(); + + const source = toAuthenticatedImageSource(uploadsUri, useAuthStore.getState().accessToken); + expect(source).toEqual({ uri: uploadsUri }); + expect(source).not.toHaveProperty('headers'); + }); + + it('never keeps using a previous account\'s token after a different account logs in', () => { + useAuthStore.getState().finishLogin({ + accessToken: 'account-a-token', + expiresIn: 3600, + isNewUser: false, + refreshToken: 'refresh-a', + user: { displayName: 'Account A', id: 'user-a', provider: 'email' }, + }); + useAuthStore.getState().logoutLocal(); + + useAuthStore.getState().finishLogin({ + accessToken: 'account-b-token', + expiresIn: 3600, + isNewUser: false, + refreshToken: 'refresh-b', + user: { displayName: 'Account B', id: 'user-b', provider: 'email' }, + }); + + const source = toAuthenticatedImageSource(uploadsUri, useAuthStore.getState().accessToken); + expect(source?.headers?.Authorization).toBe('Bearer account-b-token'); + expect(source?.headers?.Authorization).not.toContain('account-a-token'); + }); +}); diff --git a/src/hooks/useAuthenticatedImageSource.ts b/src/hooks/useAuthenticatedImageSource.ts new file mode 100644 index 0000000..16f855e --- /dev/null +++ b/src/hooks/useAuthenticatedImageSource.ts @@ -0,0 +1,26 @@ +import { useAuthStore } from '@/store/authStore'; +import type { AuthenticatedImageSource } from '@/utils/authenticatedImageSource'; +import { toAuthenticatedImageSource } from '@/utils/authenticatedImageSource'; + +/** + * Reactive wrapper around `toAuthenticatedImageSource`. Subscribes to the current access + * token (not a one-off `useAuthStore.getState()` read) so any component using this hook + * re-renders — and re-attaches a fresh `Authorization` header — whenever the token changes: + * on login, on logout, and after a background token refresh. Without this subscription, an + * image mounted with a token that later expires (access tokens are short-lived) would keep + * requesting with the stale header and stay broken until something else happened to + * re-render the component. + * + * Only call this from a component's own render body, not inside a `.map()` or a `FlatList` + * `renderItem` callback — those aren't valid hook call sites. For that case, subscribe to + * `useAuthStore((state) => state.accessToken)` once in the owning component so it re-renders + * on token change, and call the plain `toAuthenticatedImageSource(uri, accessToken)` + * function (exported from `@/utils/authenticatedImageSource`) inside the loop/callback. + */ +export function useAuthenticatedImageSource( + uri: string | undefined, +): AuthenticatedImageSource | undefined { + const accessToken = useAuthStore((state) => state.accessToken); + + return toAuthenticatedImageSource(uri, accessToken); +} diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index 652d172..5c274ea 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -5,8 +5,21 @@ import { Platform } from 'react-native'; import { queryClient } from '@/providers/queryClient'; import { MomentLogSyncWorker } from '@/providers/MomentLogSyncWorker'; import { useAuthStore } from '@/store/authStore'; +import { + isScreenshotModeEnabled, + shouldRenderDevTestManager, +} from '@/components/dev/devTestManagerVisibility'; -const DevTestManager = __DEV__ && Platform.OS !== 'web' +const shouldBootstrapScreenshotSeed = __DEV__ && isScreenshotModeEnabled(); + +const ScreenshotSeedBootstrap = shouldBootstrapScreenshotSeed + ? require('@/components/dev/ScreenshotSeedBootstrap').ScreenshotSeedBootstrap + : undefined; + +const DevTestManager = shouldRenderDevTestManager({ + isDev: __DEV__, + platform: Platform.OS, +}) ? require('@/components/dev/DevTestManager').DevTestManager : undefined; @@ -38,6 +51,7 @@ export function AppProviders({ children }: PropsWithChildren) { return ( + {ScreenshotSeedBootstrap ? : null} {children} {DevTestManager ? : null} diff --git a/src/providers/MomentLogSyncWorker.tsx b/src/providers/MomentLogSyncWorker.tsx index 4f17c59..01b18f2 100644 --- a/src/providers/MomentLogSyncWorker.tsx +++ b/src/providers/MomentLogSyncWorker.tsx @@ -12,6 +12,16 @@ import { flushPendingTravelLogFinalizations } from '@/utils/travelLogSync'; const RETRY_INTERVAL_MS = 30_000; +// Note: account-ownership reconciliation (quarantining/restoring drafts on +// login/logout) is NOT handled here. A React `useEffect` runs after commit, +// which left a one-frame window where a freshly-rendered screen could read +// the previous account's `logs` before reconciliation caught up. Instead, +// `src/store/momentLogStore.ts` subscribes to `useAuthStore` directly at +// module scope, so reconciliation runs synchronously inside the same +// `set()` call that logs a user in/out — before React has a chance to +// render anything against the stale state. See `reconcileOwnership` and +// the `useAuthStore.subscribe(...)` call there. + export function MomentLogSyncWorker() { const queryClient = useQueryClient(); const authStatus = useAuthStore((state) => state.status); diff --git a/src/store/momentLogStore.ts b/src/store/momentLogStore.ts index 830309a..cef7f58 100644 --- a/src/store/momentLogStore.ts +++ b/src/store/momentLogStore.ts @@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; +import { useAuthStore } from '@/store/authStore'; import { GeoPoint, MomentLog, @@ -42,6 +43,12 @@ export type MomentLogPendingAction = | { id: string; momentLogId: string; + // Account that owned the session when this action was queued. Used to + // gate sync so a preserved offline draft never uploads under a + // different account after re-login. `undefined` means the owner is + // unknown (legacy data persisted before this field existed) and the + // action must stay quarantined rather than be treated as a match. + ownerUserId?: string; payload: MomentLogCreateQueuePayload; queuedAt: string; type: 'create'; @@ -49,6 +56,7 @@ export type MomentLogPendingAction = | { id: string; momentLogId: string; + ownerUserId?: string; payload: MomentLogEditQueuePayload; queuedAt: string; type: 'edit'; @@ -56,13 +64,28 @@ export type MomentLogPendingAction = | { id: string; momentLogId: string; + ownerUserId?: string; queuedAt: string; type: 'delete'; }; +// Internal-only shape: every MomentLog kept in `logs` / `quarantinedLogs` +// also carries the account id that owned it when it entered local state. +// Kept as an intersection (rather than editing the shared MomentLog type in +// types/domain.ts) so every other consumer of MomentLog is unaffected — a +// plain MomentLog is still assignable wherever this type is required. +type OwnedMomentLog = MomentLog & { ownerUserId?: string }; + type MomentLogState = { logs: MomentLog[]; pendingActions: MomentLogPendingAction[]; + // Drafts / pending actions preserved (never auto-deleted) but hidden from + // every screen because they belong to an account other than whoever is + // currently logged in — or to an unknown owner (data persisted before + // ownership tracking existed). Restored into `logs`/`pendingActions` by + // `reconcileOwnership` only once the matching account re-authenticates. + quarantinedLogs: MomentLog[]; + quarantinedPendingActions: MomentLogPendingAction[]; addLog: (log: MomentLog) => void; getRecentLogs: (limit?: number) => MomentLog[]; mergeServerLogs: (logs: MomentLog[]) => void; @@ -72,6 +95,11 @@ type MomentLogState = { ) => void; queueEdit: (momentLogId: string, payload: MomentLogEditQueuePayload) => void; queueDelete: (log: MomentLog) => void; + // Re-partitions logs/pendingActions vs. their quarantined counterparts + // based on which account currently owns the session. Call this whenever + // the authenticated account changes (login, logout, refresh failure) — + // see MomentLogSyncWorker.tsx. + reconcileOwnership: (currentUserId?: string) => void; removePendingAction: (id: string) => void; removeLog: (id: string) => void; resolveLocalLog: (localMomentLogId: string, serverLog: MomentLog) => void; @@ -128,13 +156,23 @@ export const useMomentLogStore = create()( (set, get) => ({ logs: [], pendingActions: [], + quarantinedLogs: [], + quarantinedPendingActions: [], addLog: (log) => - set((state) => ({ - logs: [log, ...state.logs.filter((item) => item.id !== log.id)], - })), + set((state) => { + const owned: OwnedMomentLog = { + ...log, + ownerUserId: useAuthStore.getState().user?.id, + }; + + return { + logs: [owned, ...state.logs.filter((item) => item.id !== log.id)], + }; + }), getRecentLogs: (limit = 10) => get().logs.slice(0, limit), mergeServerLogs: (serverLogs) => set((state) => { + const ownerUserId = useAuthStore.getState().user?.id; const pendingDeleteIds = new Set( state.pendingActions .filter((action) => action.type === 'delete') @@ -146,12 +184,12 @@ export const useMomentLogStore = create()( .map((action) => action.momentLogId), ); const localLogsById = new Map(state.logs.map((log) => [log.id, log])); - const visibleServerLogs = serverLogs + const visibleServerLogs: OwnedMomentLog[] = serverLogs .filter((log) => !pendingDeleteIds.has(log.id)) .map((log) => pendingEditIds.has(log.id) ? (localLogsById.get(log.id) ?? log) - : log, + : { ...log, ownerUserId }, ); const serverLogIds = new Set(visibleServerLogs.map((log) => log.id)); const localOnlyLogs = state.logs.filter( @@ -182,6 +220,7 @@ export const useMomentLogStore = create()( { id: createActionId, momentLogId, + ownerUserId: useAuthStore.getState().user?.id, payload, queuedAt: new Date().toISOString(), type: 'create', @@ -209,6 +248,7 @@ export const useMomentLogStore = create()( { id: editActionId, momentLogId, + ownerUserId: useAuthStore.getState().user?.id, payload, queuedAt: new Date().toISOString(), type: 'edit', @@ -229,12 +269,64 @@ export const useMomentLogStore = create()( { id: deleteActionId, momentLogId: log.id, + ownerUserId: useAuthStore.getState().user?.id, queuedAt: new Date().toISOString(), type: 'delete', }, ], }; }), + reconcileOwnership: (currentUserId) => + set((state) => { + const allLogs = [ + ...state.logs, + ...state.quarantinedLogs, + ] as OwnedMomentLog[]; + const allActions = [ + ...state.pendingActions, + ...state.quarantinedPendingActions, + ]; + const visibleLogs: OwnedMomentLog[] = []; + const hiddenLogs: OwnedMomentLog[] = []; + const seenLogIds = new Set(); + + for (const log of allLogs) { + if (seenLogIds.has(log.id)) { + continue; + } + seenLogIds.add(log.id); + + if (currentUserId && log.ownerUserId === currentUserId) { + visibleLogs.push(log); + } else { + hiddenLogs.push(log); + } + } + + const visibleActions: MomentLogPendingAction[] = []; + const hiddenActions: MomentLogPendingAction[] = []; + const seenActionIds = new Set(); + + for (const action of allActions) { + if (seenActionIds.has(action.id)) { + continue; + } + seenActionIds.add(action.id); + + if (currentUserId && action.ownerUserId === currentUserId) { + visibleActions.push(action); + } else { + hiddenActions.push(action); + } + } + + return { + logs: sortByNewest(visibleLogs), + pendingActions: visibleActions, + quarantinedLogs: hiddenLogs, + quarantinedPendingActions: hiddenActions, + }; + }), removePendingAction: (id) => set((state) => ({ pendingActions: state.pendingActions.filter( @@ -247,6 +339,12 @@ export const useMomentLogStore = create()( pendingActions: state.pendingActions.filter( (action) => action.momentLogId !== id, ), + quarantinedLogs: state.quarantinedLogs.filter( + (item) => item.id !== id, + ), + quarantinedPendingActions: state.quarantinedPendingActions.filter( + (action) => action.momentLogId !== id, + ), })), resolveLocalLog: (localMomentLogId, serverLog) => set((state) => { @@ -258,6 +356,10 @@ export const useMomentLogStore = create()( return state; } + const ownedServerLog: OwnedMomentLog = { + ...serverLog, + ownerUserId: useAuthStore.getState().user?.id, + }; const remappedActions = state.pendingActions .filter( (action) => @@ -274,7 +376,7 @@ export const useMomentLogStore = create()( return { logs: sortByNewest([ - serverLog, + ownedServerLog, ...state.logs.filter( (item) => item.id !== localMomentLogId && item.id !== serverLog.id, @@ -291,8 +393,73 @@ export const useMomentLogStore = create()( })), }), { + // v1 introduced `ownerUserId` on MomentLogPendingAction. + // v2 introduces `quarantinedLogs` / `quarantinedPendingActions`. Older + // persisted state simply lacks both fields — that's the "unknown + // owner" state sync/display gating treats as quarantined, so no data + // transform is required beyond defaulting the new arrays to empty. + migrate: (persistedState) => { + const state = (persistedState ?? {}) as Partial; + + return { + ...state, + logs: state.logs ?? [], + pendingActions: state.pendingActions ?? [], + quarantinedLogs: state.quarantinedLogs ?? [], + quarantinedPendingActions: state.quarantinedPendingActions ?? [], + } as MomentLogState; + }, name: 'soundlog-moment-logs', + // Fires once this store's own persisted logs/pendingActions have + // loaded. Needed in addition to the useAuthStore.subscribe below: + // on cold start, auth can finish hydrating (and fire its + // subscription) before THIS store's async storage read resolves, in + // which case reconciliation at that point runs against an empty + // in-memory state. This closes that gap by re-running reconciliation + // once real persisted data is in place. + onRehydrateStorage: () => () => { + reconcileMomentLogOwnershipWithCurrentAuth(); + }, storage: createJSONStorage(() => AsyncStorage), + version: 2, }, ), ); + +// --- Cross-account display/sync isolation ----------------------------- +// +// `reconcileOwnership` must run synchronously the instant the authenticated +// account changes (login, logout, refresh failure) — BEFORE React renders +// any screen that reads `logs`/`pendingActions`. A `useEffect` in a +// provider component is too late: effects run after commit, so a screen +// can paint one frame of the previous account's data first. Subscribing to +// useAuthStore directly, here at module scope, means our listener runs +// synchronously inside the very `set()` call that logs the new account in, +// before React's own re-render of any subscribed screen is committed. +let lastReconciledOwnerUserId: string | undefined; +let hasReconciledSinceHydration = false; + +function reconcileMomentLogOwnershipWithCurrentAuth() { + const authState = useAuthStore.getState(); + + // Guard against cold start: before auth has hydrated, `user` is + // transiently undefined even for an already-logged-in device. Treating + // that as "logged out" would needlessly quarantine everything. + if (!authState.isHydrated) { + return; + } + + const ownerUserId = authState.user?.id; + + if (hasReconciledSinceHydration && ownerUserId === lastReconciledOwnerUserId) { + return; + } + + hasReconciledSinceHydration = true; + lastReconciledOwnerUserId = ownerUserId; + useMomentLogStore.getState().reconcileOwnership(ownerUserId); +} + +useAuthStore.subscribe(() => { + reconcileMomentLogOwnershipWithCurrentAuth(); +}); diff --git a/src/store/travelLogSyncStore.ts b/src/store/travelLogSyncStore.ts index c0360c8..bdbf217 100644 --- a/src/store/travelLogSyncStore.ts +++ b/src/store/travelLogSyncStore.ts @@ -2,12 +2,17 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; +import { useAuthStore } from '@/store/authStore'; import type { GeoPoint, RecapTemplateId, RoutePoint } from '@/types/domain'; export type PendingTravelLogFinalization = { endedAt: string; id: string; location?: GeoPoint; + // Account that owned the session when finalization was queued. `undefined` + // means unknown owner (legacy data) and must stay quarantined by sync + // gating rather than be treated as belonging to whoever is logged in now. + ownerUserId?: string; queuedAt: string; routePoints: RoutePoint[]; sessionId: string; @@ -41,6 +46,7 @@ export const useTravelLogSyncStore = create()( { ...input, id, + ownerUserId: useAuthStore.getState().user?.id, queuedAt: new Date().toISOString(), }, ], @@ -54,8 +60,12 @@ export const useTravelLogSyncStore = create()( })), }), { + // v1 introduces `ownerUserId`; pre-v1 entries simply lack it, which is + // the intended "unknown owner" quarantined state (see momentLogStore). + migrate: (persistedState) => persistedState as TravelLogSyncState, name: 'soundlog-travel-log-finalizations', storage: createJSONStorage(() => AsyncStorage), + version: 1, }, ), ); diff --git a/src/store/travelSessionStore.ts b/src/store/travelSessionStore.ts index e6687f8..2b8eaa5 100644 --- a/src/store/travelSessionStore.ts +++ b/src/store/travelSessionStore.ts @@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; +import { useAuthStore } from '@/store/authStore'; import { GeoPoint, MusicRecommendationMode, @@ -15,6 +16,12 @@ export type HomeLocationStatus = 'denied' | 'granted' | 'idle' | 'loading' | 'un type TravelSession = { endedAt?: string; id: string; + // Account that owned the device when this session was started. Used to + // gate display/sync the same way momentLogStore gates moment logs — see + // `reconcileOwnership` below. `undefined` means unknown owner (data + // persisted before this field existed) and is treated like a mismatch: + // quarantined, never auto-deleted, never shown to whoever is logged in. + ownerUserId?: string; recapId?: string; routePoints: RoutePoint[]; startedAt?: string; @@ -29,9 +36,20 @@ type TravelSessionState = { recommendationMode: MusicRecommendationMode; selectedMode?: TravelMode; session: TravelSession; + // Sessions preserved (never auto-deleted — they may hold unsynced + // routePoints) but hidden from every screen that reads `session` because + // they belong to a different (or unknown) account than whoever is + // currently logged in. Restored by `reconcileOwnership` once the + // matching account re-authenticates. + quarantinedSessions: TravelSession[]; appendRoutePoint: (point: RoutePoint) => void; clearLocation: () => void; endSession: () => void; + // Re-partitions `session` vs. `quarantinedSessions` based on which + // account currently owns the device session. Call this whenever the + // authenticated account changes (login, logout, refresh failure) — see + // the useAuthStore.subscribe registration at the bottom of this file. + reconcileOwnership: (currentUserId?: string) => void; resetSession: () => void; setLocation: (location: GeoPoint) => void; setPlace: (place?: PlaceContext) => void; @@ -54,6 +72,7 @@ export const useTravelSessionStore = create()( persist( (set, get) => ({ session: idleSession, + quarantinedSessions: [], locationStatus: 'idle', recommendationMode: 'everyday', appendRoutePoint: (point) => @@ -103,6 +122,36 @@ export const useTravelSessionStore = create()( }, }); }, + reconcileOwnership: (currentUserId) => + set((state) => { + const pool: TravelSession[] = [ + ...(state.session.status !== 'idle' ? [state.session] : []), + ...state.quarantinedSessions, + ]; + + if (pool.length === 0) { + return state; + } + + const seenIds = new Set(); + const deduped = pool.filter((session) => { + if (seenIds.has(session.id)) { + return false; + } + seenIds.add(session.id); + return true; + }); + + const owned = deduped.find( + (session) => currentUserId && session.ownerUserId === currentUserId, + ); + const hidden = deduped.filter((session) => session !== owned); + + return { + session: owned ?? idleSession, + quarantinedSessions: hidden, + }; + }), resetSession: () => set({ session: idleSession, @@ -128,6 +177,7 @@ export const useTravelSessionStore = create()( set({ session: { id: session?.id ?? `session-${Date.now()}`, + ownerUserId: useAuthStore.getState().user?.id, routePoints: session?.routePoints ?? [], startedAt: session?.startedAt ?? new Date().toISOString(), status: 'active', @@ -142,6 +192,7 @@ export const useTravelSessionStore = create()( return { ...currentState, ...persisted, + quarantinedSessions: persisted?.quarantinedSessions ?? [], session: { ...idleSession, ...persistedSession, @@ -150,15 +201,68 @@ export const useTravelSessionStore = create()( }; }, name: 'soundlog-travel-session', + // Fires once this store's own persisted session/quarantinedSessions + // have loaded. Needed alongside the useAuthStore.subscribe below for + // the same cold-start reason as momentLogStore: auth can finish + // hydrating (and fire its subscription) before this store's own + // async storage read resolves, so reconciliation must also re-run + // once real persisted data is in place. + onRehydrateStorage: () => () => { + reconcileTravelSessionOwnershipWithCurrentAuth(); + }, partialize: (state) => ({ currentLocation: state.currentLocation, currentPlace: state.currentPlace, locationUpdatedAt: state.locationUpdatedAt, + quarantinedSessions: state.quarantinedSessions, recommendationMode: state.recommendationMode, selectedMode: state.selectedMode, session: state.session, }), storage: createJSONStorage(() => AsyncStorage), + // v1 introduces `ownerUserId` on TravelSession and the + // `quarantinedSessions` array. Pre-v1 persisted sessions simply lack + // `ownerUserId` — reconcileOwnership treats that exactly like an + // owner mismatch (quarantined, never auto-restored, never + // auto-deleted) rather than auto-adopting it for whoever logs in. + version: 1, }, ), ); + +// --- Cross-account display isolation (mirrors momentLogStore.ts) ------ +// +// Registered independently here (rather than piggy-backing on +// momentLogStore's subscription) to keep the two stores decoupled, but it +// rides the exact same mechanism: useAuthStore notifies subscribers +// synchronously inside the very `set()` call that logs an account in/out, +// so this reconciliation always finishes before React renders any screen +// that reads `session` — no one-frame exposure of a stale account's +// travel session. +let lastReconciledTravelSessionOwnerUserId: string | undefined; +let hasReconciledTravelSessionSinceHydration = false; + +function reconcileTravelSessionOwnershipWithCurrentAuth() { + const authState = useAuthStore.getState(); + + if (!authState.isHydrated) { + return; + } + + const ownerUserId = authState.user?.id; + + if ( + hasReconciledTravelSessionSinceHydration && + ownerUserId === lastReconciledTravelSessionOwnerUserId + ) { + return; + } + + hasReconciledTravelSessionSinceHydration = true; + lastReconciledTravelSessionOwnerUserId = ownerUserId; + useTravelSessionStore.getState().reconcileOwnership(ownerUserId); +} + +useAuthStore.subscribe(() => { + reconcileTravelSessionOwnershipWithCurrentAuth(); +}); diff --git a/src/test/__tests__/accountSession.test.ts b/src/test/__tests__/accountSession.test.ts new file mode 100644 index 0000000..48c51f0 --- /dev/null +++ b/src/test/__tests__/accountSession.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requestApi } from '@/api/client'; +import { queryClient } from '@/providers/queryClient'; +import { useAuthStore } from '@/store/authStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { useRecommendationCacheStore } from '@/store/recommendationCacheStore'; +import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; +import { useUserProfileStore } from '@/store/userProfileStore'; +import { clearAccountSession, clearAuthSession } from '@/utils/accountSession'; + +const AUTHENTICATED_USER = { + displayName: 'Test User', + id: 'user-a', + provider: 'email' as const, +}; + +function seedAuthenticatedState() { + useAuthStore.setState({ + accessToken: 'access-old', + errorMessage: undefined, + lastLoginProvider: 'email', + refreshToken: 'refresh-old', + status: 'authenticated', + updatedAt: new Date().toISOString(), + user: AUTHENTICATED_USER, + }); +} + +function seedUnsyncedDraftState() { + useMomentLogStore.setState({ + logs: [ + { + createdAt: new Date().toISOString(), + id: 'moment-1', + moodTags: [], + // Mirrors real addLog/queueCreate behavior: the log and its pending + // action are stamped with the same ownerUserId at creation time. + ownerUserId: 'user-a', + source: 'camera', + syncStatus: 'pending', + } as never, + ], + pendingActions: [ + { + id: 'create:moment-1', + momentLogId: 'moment-1', + ownerUserId: 'user-a', + payload: { createdAt: new Date().toISOString(), moodTags: [] }, + queuedAt: new Date().toISOString(), + type: 'create', + }, + ], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); + + useTravelLogSyncStore.setState({ + pendingFinalizations: [ + { + endedAt: new Date().toISOString(), + id: 'travel-log:session-1', + ownerUserId: 'user-a', + queuedAt: new Date().toISOString(), + routePoints: [], + sessionId: 'session-1', + templateId: 'film', + title: 'Test trip', + }, + ], + }); + + useTravelSessionStore.setState({ + quarantinedSessions: [], + session: { + id: 'session-1', + // Mirrors real startSession behavior: stamped with the account that + // was authenticated when the session began. + ownerUserId: 'user-a', + routePoints: [{ lat: 1, lng: 1, recordedAt: new Date().toISOString() }], + startedAt: new Date().toISOString(), + status: 'active', + }, + }); +} + +function seedServerDerivedCaches() { + useLibraryStore.setState({ + likedTracks: [ + { + createdAt: new Date().toISOString(), + track: { artist: 'Artist', id: 'track-1', title: 'Song' }, + }, + ], + savedTracks: [], + seededPlaylistIds: ['playlist-1'], + }); + useUserProfileStore.setState({ + profile: { + completedOnboarding: true, + locationRecommendationEnabled: true, + preferredGenres: ['lofi'], + preferredMoods: [], + travelStyles: [], + }, + }); + useRecommendationCacheStore.setState({ + featuredPlaylists: { + 'key-1': { cachedAt: new Date().toISOString(), data: [] }, + }, + moodRecommendations: {}, + }); + queryClient.setQueryData(['probe-query'], { fromPreviousAccount: true }); +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + seedAuthenticatedState(); + seedUnsyncedDraftState(); + seedServerDerivedCaches(); +}); + +describe('clearAuthSession (P0-3: refresh-failure path)', () => { + it('resets only auth state and preserves unsynced drafts / active session', () => { + clearAuthSession(); + + expect(useAuthStore.getState().status).toBe('unauthenticated'); + expect(useAuthStore.getState().accessToken).toBeUndefined(); + expect(useAuthStore.getState().refreshToken).toBeUndefined(); + expect(useAuthStore.getState().user).toBeUndefined(); + + // Preserved, but no longer "current account" -- logoutLocal() flips the + // authenticated user to undefined, which synchronously reconciles the + // draft out of the visible arrays (see momentLogStore.ts's + // useAuthStore.subscribe). Nothing is deleted: it lands in quarantine + // and comes straight back once the SAME account re-authenticates. + expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); + expect(useMomentLogStore.getState().logs).toHaveLength(0); + expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( + 1, + ); + expect(useMomentLogStore.getState().quarantinedLogs).toHaveLength(1); + // Preserved: pending travel Log finalization. + expect(useTravelLogSyncStore.getState().pendingFinalizations).toHaveLength( + 1, + ); + // Preserved travel session: also immediately quarantined (invisible) + // rather than deleted, same as the moment log draft above. + expect(useTravelSessionStore.getState().session.status).toBe('idle'); + expect(useTravelSessionStore.getState().quarantinedSessions).toHaveLength( + 1, + ); + expect( + useTravelSessionStore.getState().quarantinedSessions[0].routePoints, + ).toHaveLength(1); + }); + + it('wipes server-derived caches (library, profile, recommendation cache, query cache)', () => { + clearAuthSession(); + + expect(useLibraryStore.getState().likedTracks).toHaveLength(0); + expect(useLibraryStore.getState().seededPlaylistIds).toHaveLength(0); + expect(useUserProfileStore.getState().profile.completedOnboarding).toBe( + false, + ); + expect( + useRecommendationCacheStore.getState().featuredPlaylists, + ).toEqual({}); + expect(queryClient.getQueryData(['probe-query'])).toBeUndefined(); + }); +}); + +describe('clearAccountSession (explicit logout / account deletion)', () => { + it('wipes auth state and all local drafts', () => { + clearAccountSession(); + + expect(useAuthStore.getState().status).toBe('unauthenticated'); + expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); + expect(useMomentLogStore.getState().logs).toHaveLength(0); + // Explicit account deletion/logout wipes quarantine too -- unlike the + // refresh-failure path, this is user-initiated and intentionally final. + expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( + 0, + ); + expect(useMomentLogStore.getState().quarantinedLogs).toHaveLength(0); + expect(useTravelLogSyncStore.getState().pendingFinalizations).toHaveLength( + 0, + ); + expect(useTravelSessionStore.getState().session.status).toBe('idle'); + expect(useTravelSessionStore.getState().session.routePoints).toHaveLength( + 0, + ); + expect(useTravelSessionStore.getState().quarantinedSessions).toHaveLength( + 0, + ); + }); +}); + +describe('client.ts token refresh failure', () => { + it('preserves pendingActions and drops only auth state on a failed refresh', async () => { + process.env.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL = 'https://api.test.local'; + + const fetchMock = vi.fn(async (input: unknown) => { + const url = String(input); + + if (url.endsWith('/v1/auth/refresh')) { + return new Response(JSON.stringify({ error: { message: 'expired' } }), { + status: 401, + }); + } + + return new Response(JSON.stringify({ error: { message: 'unauthorized' } }), { + status: 401, + }); + }); + + vi.stubGlobal('fetch', fetchMock); + + await expect(requestApi('/v1/some-resource')).rejects.toMatchObject({ + status: 401, + }); + + // Auth state cleared (forces re-login)... + expect(useAuthStore.getState().status).toBe('unauthenticated'); + // ...and the draft is immediately out of the visible arrays (no + // account is "current" to own it right now)... + expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); + expect(useMomentLogStore.getState().logs).toHaveLength(0); + // ...but it survives, quarantined, ready to come back for its owner. + expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( + 1, + ); + expect(useMomentLogStore.getState().quarantinedLogs).toHaveLength(1); + }); +}); diff --git a/src/test/__tests__/apiPagination.test.ts b/src/test/__tests__/apiPagination.test.ts new file mode 100644 index 0000000..a71feb2 --- /dev/null +++ b/src/test/__tests__/apiPagination.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getPageMeta, requestApi } from '@/api/client'; +import { libraryApi } from '@/api/libraryApi'; +import { useAuthStore } from '@/store/authStore'; + +// P2-2: the server's pagedResponse() shape is `{ data, page: { limit, +// nextCursor } }`. unwrapData used to discard `page` outright, so cursor +// pagination was unreachable from the client and any list beyond the +// server's default limit was permanently inaccessible. These tests pin the +// fix: page metadata now rides along on the returned data (readable via +// getPageMeta) without changing what existing callers receive. + +function seedAuthenticated() { + useAuthStore.setState({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + status: 'authenticated', + user: { displayName: 'Test', id: 'user-a', provider: 'email' }, + } as never); +} + +beforeEach(() => { + process.env.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL = 'https://api.test.local'; + useAuthStore.setState({ + accessToken: undefined, + refreshToken: undefined, + status: 'unauthenticated', + user: undefined, + } as never); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('getPageMeta / unwrapData (P2-2)', () => { + it('exposes page metadata for a paged list response without changing the returned data', async () => { + seedAuthenticated(); + + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + data: [{ id: 'a' }, { id: 'b' }], + page: { limit: 2, nextCursor: 'cursor-2' }, + }), + { status: 200 }, + ), + ); + + vi.stubGlobal('fetch', fetchMock); + + const result = await requestApi>('/v1/some-list'); + + expect(result).toEqual([{ id: 'a' }, { id: 'b' }]); + expect(getPageMeta(result)).toEqual({ limit: 2, nextCursor: 'cursor-2' }); + }); + + it('returns undefined page metadata for a non-paged response (existing callers unaffected)', async () => { + seedAuthenticated(); + + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ data: { id: 'solo' } }), { status: 200 }), + ); + + vi.stubGlobal('fetch', fetchMock); + + const result = await requestApi<{ id: string }>('/v1/some-resource'); + + expect(result).toEqual({ id: 'solo' }); + expect(getPageMeta(result)).toBeUndefined(); + }); +}); + +describe('libraryApi.getTracks pagination (P2-2)', () => { + it('forwards the cursor to the server and surfaces nextCursor for the following page', async () => { + seedAuthenticated(); + + const fetchMock = vi.fn(async (input: unknown) => { + const url = new URL(String(input)); + const cursor = url.searchParams.get('cursor'); + + if (!cursor) { + return new Response( + JSON.stringify({ + data: [{ createdAt: 't', id: 'track-1', kind: 'liked', track: { id: 'track-1' } }], + page: { limit: 1, nextCursor: 'page-2' }, + }), + { status: 200 }, + ); + } + + expect(cursor).toBe('page-2'); + + return new Response( + JSON.stringify({ + data: [{ createdAt: 't', id: 'track-2', kind: 'liked', track: { id: 'track-2' } }], + page: { limit: 1, nextCursor: null }, + }), + { status: 200 }, + ); + }); + + vi.stubGlobal('fetch', fetchMock); + + const firstPage = await libraryApi.getTracks('liked'); + expect(firstPage.records.map((record) => record.id)).toEqual(['track-1']); + expect(firstPage.page?.nextCursor).toBe('page-2'); + + const secondPage = await libraryApi.getTracks('liked', firstPage.page?.nextCursor ?? undefined); + expect(secondPage.records.map((record) => record.id)).toEqual(['track-2']); + expect(secondPage.page?.nextCursor).toBeNull(); + }); + + it('returns an empty page without calling the server when not authenticated', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await libraryApi.getTracks('liked'); + + expect(result).toEqual({ records: [] }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/test/__tests__/momentLogQuarantine.test.ts b/src/test/__tests__/momentLogQuarantine.test.ts new file mode 100644 index 0000000..688e2d0 --- /dev/null +++ b/src/test/__tests__/momentLogQuarantine.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/store/authStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { flushPendingMomentActions } from '@/utils/momentLogSync'; + +// Same mocks as momentLogSync.test.ts — createMomentLog / createRecap should +// only ever be reached once a draft has been un-quarantined for its owner. +vi.mock('@/api/momentLogApi', () => ({ + momentLogApi: { + createMomentLog: vi.fn(async () => ({ + createdAt: new Date().toISOString(), + id: 'server-moment-1', + moodTags: [], + source: 'camera', + syncStatus: 'synced', + })), + }, +})); + +vi.mock('@/api/recapApi', () => ({ + recapApi: { + createRecap: vi.fn(async () => ({ id: 'recap-1' })), + }, +})); + +const OWNER_A = 'user-a'; +const OWNER_B = 'user-b'; + +function seedOwnedDraft() { + useMomentLogStore.setState({ + logs: [ + { + createdAt: new Date().toISOString(), + id: 'moment-1', + moodTags: [], + // `ownerUserId` isn't part of the public MomentLog type (see + // momentLogStore's internal OwnedMomentLog intersection), but the + // store stamps it onto every persisted entry — mirror that shape + // here rather than going through `addLog`, to keep this test + // focused on `reconcileOwnership` alone. + ownerUserId: OWNER_A, + source: 'camera', + syncStatus: 'pending', + } as never, + ], + pendingActions: [ + { + id: 'create:moment-1', + momentLogId: 'moment-1', + ownerUserId: OWNER_A, + payload: { createdAt: new Date().toISOString(), moodTags: [] }, + queuedAt: new Date().toISOString(), + type: 'create', + }, + ], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); +} + +function seedAuthenticatedAs(userId: string) { + useAuthStore.setState({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + status: 'authenticated', + user: { displayName: 'Test', id: userId, provider: 'email' }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + useMomentLogStore.setState({ + logs: [], + pendingActions: [], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); +}); + +describe('reconcileOwnership (display-level account isolation)', () => { + it('quarantines a draft out of `logs`/`pendingActions` when a different account logs in, without deleting it', () => { + seedOwnedDraft(); + + useMomentLogStore.getState().reconcileOwnership(OWNER_B); + + const state = useMomentLogStore.getState(); + + // Hidden from every screen that reads `logs`. + expect(state.logs).toHaveLength(0); + expect(state.pendingActions).toHaveLength(0); + // Not deleted — preserved in the quarantine arrays. + expect(state.quarantinedLogs).toHaveLength(1); + expect(state.quarantinedLogs[0].id).toBe('moment-1'); + expect(state.quarantinedPendingActions).toHaveLength(1); + }); + + it('quarantines unknown-owner (legacy/guest) data the same way', () => { + useMomentLogStore.setState({ + logs: [ + { + createdAt: new Date().toISOString(), + id: 'legacy-moment', + moodTags: [], + source: 'camera', + syncStatus: 'local', + }, + ], + pendingActions: [], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); + + useMomentLogStore.getState().reconcileOwnership(OWNER_A); + + const state = useMomentLogStore.getState(); + + expect(state.logs).toHaveLength(0); + expect(state.quarantinedLogs).toHaveLength(1); + expect(state.quarantinedLogs[0].id).toBe('legacy-moment'); + }); + + it('restores a quarantined draft to `logs`/`pendingActions` when its owner logs back in, and resumes sync', async () => { + seedOwnedDraft(); + useMomentLogStore.getState().reconcileOwnership(OWNER_B); // quarantine under a different account + expect(useMomentLogStore.getState().logs).toHaveLength(0); + + // The original owner logs back in. + useMomentLogStore.getState().reconcileOwnership(OWNER_A); + + const restored = useMomentLogStore.getState(); + + expect(restored.logs).toHaveLength(1); + expect(restored.pendingActions).toHaveLength(1); + expect(restored.quarantinedLogs).toHaveLength(0); + expect(restored.quarantinedPendingActions).toHaveLength(0); + + // Sync resumes now that the action is visible and owner-matched again. + seedAuthenticatedAs(OWNER_A); + + const { momentLogApi } = await import('@/api/momentLogApi'); + const result = await flushPendingMomentActions(); + + expect(momentLogApi.createMomentLog).toHaveBeenCalledTimes(1); + expect(result.successCount).toBe(1); + }); +}); + +describe('reconciliation timing (no stale-account render frame)', () => { + it('quarantines the previous account draft synchronously inside the same account-switch call, before any await/microtask', () => { + // Owner A is logged in with a visible draft (mirrors normal app usage: + // the log and its pending action already sit in the visible arrays). + seedOwnedDraft(); + useAuthStore.setState({ + accessToken: 'access-a', + isHydrated: true, + refreshToken: 'refresh-a', + status: 'authenticated', + user: { displayName: 'A', id: OWNER_A, provider: 'email' }, + }); + expect(useMomentLogStore.getState().logs).toHaveLength(1); + + // Account B logs in — e.g. the exact `set()` finishLogin performs. + // This is the single synchronous statement a screen's render would + // race against; there is no `await`, `setTimeout`, or `Promise.resolve()` + // between it and the assertions below. + useAuthStore.setState({ + accessToken: 'access-b', + refreshToken: 'refresh-b', + status: 'authenticated', + user: { displayName: 'B', id: OWNER_B, provider: 'email' }, + }); + + // If reconciliation depended on a React effect (which only runs after + // commit), this would still show account A's draft here. Because + // momentLogStore subscribes to useAuthStore directly, reconciliation + // already ran synchronously inside the `setState` call above — so by + // the time ANY screen's render reads `logs`, it's already account B's + // (empty) view. + const state = useMomentLogStore.getState(); + + expect(state.logs).toHaveLength(0); + expect(state.pendingActions).toHaveLength(0); + expect(state.quarantinedLogs).toHaveLength(1); + expect(state.quarantinedLogs[0].id).toBe('moment-1'); + expect(state.quarantinedPendingActions).toHaveLength(1); + }); +}); diff --git a/src/test/__tests__/momentLogSync.test.ts b/src/test/__tests__/momentLogSync.test.ts new file mode 100644 index 0000000..c706dd5 --- /dev/null +++ b/src/test/__tests__/momentLogSync.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/store/authStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { flushPendingMomentActions } from '@/utils/momentLogSync'; + +vi.mock('@/api/momentLogApi', () => ({ + momentLogApi: { + createMomentLog: vi.fn(async () => ({ + createdAt: new Date().toISOString(), + id: 'server-moment-1', + moodTags: [], + source: 'camera', + syncStatus: 'synced', + })), + }, +})); + +vi.mock('@/api/recapApi', () => ({ + recapApi: { + createRecap: vi.fn(async () => ({ id: 'recap-1' })), + }, +})); + +const OWNER_A = 'user-a'; +const OWNER_B = 'user-b'; + +function seedPendingCreateAction(ownerUserId: string | undefined) { + useMomentLogStore.setState({ + logs: [ + { + createdAt: new Date().toISOString(), + id: 'moment-1', + moodTags: [], + // Real callers (addLog + queueCreate) stamp the log and its + // pending action with the same ownerUserId in the same beat — mirror + // that here so the now-synchronous auth-driven reconciliation (see + // momentLogStore.ts's useAuthStore.subscribe) treats them consistently. + ownerUserId, + source: 'camera', + syncStatus: 'pending', + } as never, + ], + pendingActions: [ + { + id: 'create:moment-1', + momentLogId: 'moment-1', + ownerUserId, + payload: { createdAt: new Date().toISOString(), moodTags: [] }, + queuedAt: new Date().toISOString(), + type: 'create', + }, + ], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); +} + +function seedAuthenticatedAs(userId: string) { + useAuthStore.setState({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + status: 'authenticated', + user: { displayName: 'Test', id: userId, provider: 'email' }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + useMomentLogStore.setState({ + logs: [], + pendingActions: [], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); + useAuthStore.setState({ + accessToken: undefined, + refreshToken: undefined, + status: 'unauthenticated', + user: undefined, + }); +}); + +describe('flushPendingMomentActions account gating (P0-3)', () => { + it('resumes sync when the re-logged-in account matches the draft owner', async () => { + seedPendingCreateAction(OWNER_A); + seedAuthenticatedAs(OWNER_A); + + const { momentLogApi } = await import('@/api/momentLogApi'); + const result = await flushPendingMomentActions(); + + expect(result.successCount).toBe(1); + expect(momentLogApi.createMomentLog).toHaveBeenCalledTimes(1); + }); + + it('quarantines the draft when a different account is logged in (no upload)', async () => { + seedPendingCreateAction(OWNER_A); + seedAuthenticatedAs(OWNER_B); + + const { momentLogApi } = await import('@/api/momentLogApi'); + const result = await flushPendingMomentActions(); + + expect(momentLogApi.createMomentLog).not.toHaveBeenCalled(); + expect(result.successCount).toBe(0); + expect(result.failureCount).toBe(0); + // The account-change subscription already moved it out of the visible + // array before flush ever ran... + expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); + // ...but it's queued, untouched, in quarantine for whenever its owner + // logs back in. + expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( + 1, + ); + }); + + it('quarantines legacy actions with an unknown owner (undefined ownerUserId)', async () => { + seedPendingCreateAction(undefined); + seedAuthenticatedAs(OWNER_A); + + const { momentLogApi } = await import('@/api/momentLogApi'); + const result = await flushPendingMomentActions(); + + expect(momentLogApi.createMomentLog).not.toHaveBeenCalled(); + expect(result.successCount).toBe(0); + expect(useMomentLogStore.getState().pendingActions).toHaveLength(0); + expect(useMomentLogStore.getState().quarantinedPendingActions).toHaveLength( + 1, + ); + }); +}); diff --git a/src/test/__tests__/travelLogSync.test.ts b/src/test/__tests__/travelLogSync.test.ts new file mode 100644 index 0000000..4e1d24b --- /dev/null +++ b/src/test/__tests__/travelLogSync.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/store/authStore'; +import { useMomentLogStore } from '@/store/momentLogStore'; +import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; +import { flushPendingTravelLogFinalizations } from '@/utils/travelLogSync'; + +vi.mock('@/api/travelSessionApi', () => ({ + travelSessionApi: { + endTravelSession: vi.fn(async () => undefined), + }, +})); + +vi.mock('@/api/recapApi', () => ({ + recapApi: { + createRecap: vi.fn(async () => ({ id: 'recap-1' })), + }, +})); + +const OWNER_A = 'user-a'; +const CURRENT_SESSION = 'session-current'; +const OTHER_SESSION = 'session-other'; + +function seedFinalization() { + useTravelLogSyncStore.setState({ + pendingFinalizations: [ + { + endedAt: new Date().toISOString(), + id: `travel-log:${CURRENT_SESSION}`, + ownerUserId: OWNER_A, + queuedAt: new Date().toISOString(), + routePoints: [], + sessionId: CURRENT_SESSION, + templateId: 'film', + title: 'Trip', + }, + ], + }); +} + +function seedSyncedSessionLog() { + useMomentLogStore.setState((state) => ({ + logs: [ + ...state.logs, + { + createdAt: new Date().toISOString(), + id: 'log-current-session', + moodTags: [], + sessionId: CURRENT_SESSION, + source: 'camera', + syncStatus: 'synced', + }, + ], + })); +} + +function seedAuthenticatedAs(userId: string) { + useAuthStore.setState({ + accessToken: 'access-token', + refreshToken: 'refresh-token', + status: 'authenticated', + user: { displayName: 'Test', id: userId, provider: 'email' }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + useMomentLogStore.setState({ logs: [], pendingActions: [] }); + useTravelLogSyncStore.setState({ pendingFinalizations: [] }); + seedAuthenticatedAs(OWNER_A); + seedFinalization(); + seedSyncedSessionLog(); +}); + +describe('flushPendingTravelLogFinalizations session-scoped gate (P1-3)', () => { + it('confirms the Log when the only pending action belongs to a different session', async () => { + useMomentLogStore.setState((state) => ({ + logs: [ + ...state.logs, + { + createdAt: new Date().toISOString(), + id: 'log-other-session', + moodTags: [], + sessionId: OTHER_SESSION, + source: 'camera', + syncStatus: 'pending', + }, + ], + pendingActions: [ + { + id: 'create:log-other-session', + momentLogId: 'log-other-session', + ownerUserId: OWNER_A, + payload: { createdAt: new Date().toISOString(), moodTags: [] }, + queuedAt: new Date().toISOString(), + type: 'create', + }, + ], + })); + + const { recapApi } = await import('@/api/recapApi'); + const result = await flushPendingTravelLogFinalizations(); + + expect(result.successCount).toBe(1); + expect(result.deferredCount).toBe(0); + expect(recapApi.createRecap).toHaveBeenCalledTimes(1); + expect(useTravelLogSyncStore.getState().pendingFinalizations).toHaveLength( + 0, + ); + }); + + it('defers the Log while a pending action for its own session is unresolved', async () => { + // The log itself already reports `synced` (e.g. its create succeeded), + // but a follow-up edit for the same session is still queued. This is + // exactly the case the old global `pendingActions.length > 0` check and + // the syncStatus-based session check could each miss on their own. + useMomentLogStore.setState((state) => ({ + logs: state.logs, + pendingActions: [ + { + id: 'edit:log-current-session', + momentLogId: 'log-current-session', + ownerUserId: OWNER_A, + payload: { moodTags: [], note: null, placeName: null }, + queuedAt: new Date().toISOString(), + type: 'edit', + }, + ], + })); + + const { recapApi } = await import('@/api/recapApi'); + const result = await flushPendingTravelLogFinalizations(); + + expect(result.deferredCount).toBe(1); + expect(result.successCount).toBe(0); + expect(recapApi.createRecap).not.toHaveBeenCalled(); + expect(useTravelLogSyncStore.getState().pendingFinalizations).toHaveLength( + 1, + ); + }); +}); diff --git a/src/test/__tests__/travelSessionQuarantine.test.ts b/src/test/__tests__/travelSessionQuarantine.test.ts new file mode 100644 index 0000000..ee29a6a --- /dev/null +++ b/src/test/__tests__/travelSessionQuarantine.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useAuthStore } from '@/store/authStore'; +import { useTravelSessionStore } from '@/store/travelSessionStore'; + +const OWNER_A = 'user-a'; +const OWNER_B = 'user-b'; + +function seedActiveSessionOwnedBy(ownerUserId: string | undefined) { + useTravelSessionStore.setState({ + quarantinedSessions: [], + session: { + id: 'travel-session-1', + ownerUserId, + routePoints: [ + { lat: 37.5, lng: 127.0, recordedAt: new Date().toISOString() }, + { lat: 37.51, lng: 127.01, recordedAt: new Date().toISOString() }, + ], + startedAt: new Date().toISOString(), + status: 'active', + }, + }); +} + +beforeEach(() => { + useTravelSessionStore.setState({ + currentLocation: undefined, + currentPlace: undefined, + quarantinedSessions: [], + session: { id: 'local-session', routePoints: [], status: 'idle' }, + }); +}); + +describe('travelSessionStore account isolation (mirrors momentLogStore)', () => { + it('quarantines the active session and its routePoints out of `session` when a different account logs in, without deleting it', () => { + seedActiveSessionOwnedBy(OWNER_A); + + useTravelSessionStore.getState().reconcileOwnership(OWNER_B); + + const state = useTravelSessionStore.getState(); + + // Hidden from every screen that reads `session` (e.g. app/(tabs)/index.tsx, + // TravelScreen.tsx) -- account B never sees account A's active trip. + expect(state.session.status).toBe('idle'); + expect(state.session.routePoints).toHaveLength(0); + // Not deleted -- preserved, GPS routePoints intact, in quarantine. + expect(state.quarantinedSessions).toHaveLength(1); + expect(state.quarantinedSessions[0].id).toBe('travel-session-1'); + expect(state.quarantinedSessions[0].routePoints).toHaveLength(2); + }); + + it('quarantines an unknown-owner (pre-migration) session the same way', () => { + seedActiveSessionOwnedBy(undefined); + + useTravelSessionStore.getState().reconcileOwnership(OWNER_A); + + const state = useTravelSessionStore.getState(); + + expect(state.session.status).toBe('idle'); + expect(state.quarantinedSessions).toHaveLength(1); + expect(state.quarantinedSessions[0].id).toBe('travel-session-1'); + }); + + it('restores the session to `session` when its owner logs back in', () => { + seedActiveSessionOwnedBy(OWNER_A); + useTravelSessionStore.getState().reconcileOwnership(OWNER_B); // quarantine under a different account + expect(useTravelSessionStore.getState().session.status).toBe('idle'); + + // The original owner logs back in. + useTravelSessionStore.getState().reconcileOwnership(OWNER_A); + + const restored = useTravelSessionStore.getState(); + + expect(restored.session.id).toBe('travel-session-1'); + expect(restored.session.status).toBe('active'); + expect(restored.session.routePoints).toHaveLength(2); + expect(restored.quarantinedSessions).toHaveLength(0); + }); + + it('reconciles synchronously inside the same account-switch call (no stale-session render frame)', () => { + seedActiveSessionOwnedBy(OWNER_A); + useAuthStore.setState({ + accessToken: 'access-a', + isHydrated: true, + refreshToken: 'refresh-a', + status: 'authenticated', + user: { displayName: 'A', id: OWNER_A, provider: 'email' }, + }); + expect(useTravelSessionStore.getState().session.status).toBe('active'); + + // Account B logs in -- the exact `set()` finishLogin performs. No + // await/microtask between this and the assertions below. + useAuthStore.setState({ + accessToken: 'access-b', + refreshToken: 'refresh-b', + status: 'authenticated', + user: { displayName: 'B', id: OWNER_B, provider: 'email' }, + }); + + const state = useTravelSessionStore.getState(); + + expect(state.session.status).toBe('idle'); + expect(state.quarantinedSessions).toHaveLength(1); + expect(state.quarantinedSessions[0].id).toBe('travel-session-1'); + }); +}); diff --git a/src/test/mocks/asyncStorageMock.ts b/src/test/mocks/asyncStorageMock.ts new file mode 100644 index 0000000..32ee632 --- /dev/null +++ b/src/test/mocks/asyncStorageMock.ts @@ -0,0 +1,19 @@ +// Minimal in-memory stand-in for @react-native-async-storage/async-storage, +// used only under vitest (see vitest.config.ts alias). Zustand's `persist` +// middleware just needs an object satisfying getItem/setItem/removeItem. +const memoryStore = new Map(); + +const AsyncStorageMock = { + clear: async () => { + memoryStore.clear(); + }, + getItem: async (key: string) => memoryStore.get(key) ?? null, + removeItem: async (key: string) => { + memoryStore.delete(key); + }, + setItem: async (key: string, value: string) => { + memoryStore.set(key, value); + }, +}; + +export default AsyncStorageMock; diff --git a/src/test/mocks/reactNativeMock.ts b/src/test/mocks/reactNativeMock.ts new file mode 100644 index 0000000..7f27ebd --- /dev/null +++ b/src/test/mocks/reactNativeMock.ts @@ -0,0 +1,13 @@ +// Minimal stand-in for the `react-native` package under vitest (see +// vitest.config.ts alias). Only `Platform.OS` is needed by +// src/store/authStorage.ts to choose the AsyncStorage branch over +// expo-secure-store, and AppState is referenced by +// src/providers/MomentLogSyncWorker.tsx (not exercised by these unit +// tests, but stubbed for safety if it's ever imported transitively). +export const Platform = { + OS: 'web' as const, +}; + +export const AppState = { + addEventListener: () => ({ remove: () => {} }), +}; diff --git a/src/utils/accountSession.ts b/src/utils/accountSession.ts index 2e33d0b..538273f 100644 --- a/src/utils/accountSession.ts +++ b/src/utils/accountSession.ts @@ -11,21 +11,63 @@ import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; -export function clearAccountSession() { +/** + * Resets authentication state PLUS every store that is just a local cache of + * server data the current account can refetch (library likes/saves, profile, + * recommendation caches, travel rooms, recommendation events, and the React + * Query cache). None of that is user-authored or unsynced, so clearing it + * can't lose anything — and leaving it around is exactly what let a + * previous account's data render on screen after someone else logs in. + * + * Used on the silent token-refresh-failure path so an expired session + * prompts re-login WITHOUT discarding unsynced local data: pending moment + * log actions, in-progress recap drafts, the active travel session and its + * route points, and pending Log finalizations. Those are preserved (see + * momentLogStore's `quarantinedLogs` / `quarantinedPendingActions` and + * travelSessionStore's `quarantinedSessions`) and are gated back into + * visibility + sync (see momentLogSync.ts / travelLogSync.ts, and each + * store's own synchronous `useAuthStore.subscribe(...)` -> `reconcileOwnership` + * call) only once the SAME account re-authenticates; a different account + * never sees them, not even for a single render frame. + * + * Use `clearAccountSession` instead for explicit logout / account deletion, + * where wiping all local data — including those preserved drafts — is the + * intended, user-initiated behavior. + */ +export function clearAuthSession() { queryClient.clear(); - useMomentLogStore.setState({ logs: [], pendingActions: [] }); useLibraryStore.setState({ likedTracks: [], savedTracks: [], seededPlaylistIds: [], }); useTravelRoomStore.setState({ roomsById: {}, roomsBySessionId: {} }); + useRecommendationCacheStore.setState({ + featuredFallback: undefined, + featuredPlaylists: {}, + moodFallback: undefined, + moodRecommendations: {}, + }); + useRecommendationEventStore.getState().clearEvents(); + useUserProfileStore.getState().resetOnboarding(); + useAuthStore.getState().logoutLocal(); +} + +export function clearAccountSession() { + clearAuthSession(); + useMomentLogStore.setState({ + logs: [], + pendingActions: [], + quarantinedLogs: [], + quarantinedPendingActions: [], + }); useTravelLogSyncStore.setState({ pendingFinalizations: [] }); useTravelSessionStore.setState({ currentLocation: undefined, currentPlace: undefined, locationStatus: 'idle', locationUpdatedAt: undefined, + quarantinedSessions: [], recommendationMode: 'everyday', selectedMode: undefined, session: { @@ -34,17 +76,8 @@ export function clearAccountSession() { status: 'idle', }, }); - useRecommendationCacheStore.setState({ - featuredFallback: undefined, - featuredPlaylists: {}, - moodFallback: undefined, - moodRecommendations: {}, - }); - useRecommendationEventStore.getState().clearEvents(); useHomeFilterStore.setState({ selectedMoodFilter: '전체', }); usePlayerStore.getState().clearTrack(); - useUserProfileStore.getState().resetOnboarding(); - useAuthStore.getState().logoutLocal(); } diff --git a/src/utils/authenticatedImageSource.test.ts b/src/utils/authenticatedImageSource.test.ts new file mode 100644 index 0000000..b6c8527 --- /dev/null +++ b/src/utils/authenticatedImageSource.test.ts @@ -0,0 +1,276 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { toAuthenticatedImageSource } from '@/utils/authenticatedImageSource'; + +const TOKEN = 'test-access-token'; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('toAuthenticatedImageSource', () => { + it('returns undefined for undefined input', () => { + expect(toAuthenticatedImageSource(undefined, TOKEN)).toBeUndefined(); + }); + + it('returns undefined for an empty string', () => { + expect(toAuthenticatedImageSource('', TOKEN)).toBeUndefined(); + }); + + describe('relative uploads paths (implicitly same-origin, no allowlist needed)', () => { + it('attaches the Authorization header for a relative /uploads/ path', () => { + const result = toAuthenticatedImageSource('/uploads/a.jpg', TOKEN); + + expect(result).toEqual({ + headers: { Authorization: `Bearer ${TOKEN}` }, + uri: '/uploads/a.jpg', + }); + }); + + it('attaches the Authorization header for a relative /v1/uploads/ path', () => { + const result = toAuthenticatedImageSource('/v1/uploads/a.jpg', TOKEN); + + expect(result).toEqual({ + headers: { Authorization: `Bearer ${TOKEN}` }, + uri: '/v1/uploads/a.jpg', + }); + }); + + it('attaches the header for a relative path with a query string', () => { + const uri = '/v1/uploads/abc123?x=1'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result?.headers).toEqual({ Authorization: `Bearer ${TOKEN}` }); + expect(result?.uri).toBe(uri); + }); + }); + + describe('absolute uploads URLs — only on an allowlisted origin', () => { + it('attaches the header when the origin matches EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://api.soundlog.shop'); + + const uri = 'https://api.soundlog.shop/v1/uploads/abc123'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ + headers: { Authorization: `Bearer ${TOKEN}` }, + uri, + }); + }); + + it('attaches the header when the origin matches EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN, even though it differs from the API base URL', () => { + // Mirrors production: JSON API calls go through a Vercel proxy on soundlog.shop, + // while uploaded photos are served directly from api.soundlog.shop. + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://soundlog.shop/api/soundlog'); + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN', 'https://api.soundlog.shop'); + + const uri = 'https://api.soundlog.shop/v1/uploads/abc123'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ + headers: { Authorization: `Bearer ${TOKEN}` }, + uri, + }); + }); + + it('preserves the query string on an allowlisted absolute origin', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://api.soundlog.shop'); + + const uri = 'https://api.soundlog.shop/v1/uploads/abc123?cachebust=1&x=2'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ + headers: { Authorization: `Bearer ${TOKEN}` }, + uri, + }); + }); + + it('does NOT attach the header to the upload host when EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN is unset and it differs from the API base origin (safe fallback)', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://soundlog.shop/api/soundlog'); + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN', ''); + + const uri = 'https://api.soundlog.shop/v1/uploads/abc123'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + expect(result).not.toHaveProperty('headers'); + }); + + it('attaches the header for local/native/web-style base URLs when they are the configured API base', () => { + const hosts = [ + 'https://api.soundlog.shop', + 'https://soundlog.shop', + 'http://127.0.0.1:4000', + 'http://localhost:4000', + ]; + + for (const host of hosts) { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', host); + + const uri = `${host}/v1/uploads/abc123`; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ + headers: { Authorization: `Bearer ${TOKEN}` }, + uri, + }); + } + }); + }); + + describe('token leak vulnerability: absolute URL on an untrusted origin', () => { + it('does NOT attach the header to an arbitrary external host, even with the exact uploads pathname shape', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://soundlog.shop/api/soundlog'); + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN', 'https://api.soundlog.shop'); + + const uri = 'https://evil.example.com/uploads/anything'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + expect(result).not.toHaveProperty('headers'); + }); + + it('does NOT attach the header to an untrusted host even when no allowlist env vars are configured at all', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', ''); + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN', ''); + + const uri = 'https://host/uploads/a.jpg'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + expect(result).not.toHaveProperty('headers'); + }); + + it('does not leak the token even to a host that spells out the internal placeholder base used for relative-URL parsing', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://soundlog.shop/api/soundlog'); + + const uri = 'http://soundlog-local.invalid/uploads/a.jpg'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + expect(result).not.toHaveProperty('headers'); + }); + + it('does not leak the token to a protocol-relative decoy host', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://soundlog.shop/api/soundlog'); + + const uri = '//evil.example.com/uploads/a.jpg'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + expect(result).not.toHaveProperty('headers'); + }); + }); + + describe('no double conversion', () => { + it('does not alter or double-prefix a URL that is already in /v1/uploads/ form', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://api.soundlog.shop'); + + const uri = 'https://api.soundlog.shop/v1/uploads/abc123'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + // The returned uri must be byte-for-byte identical to the input — no re-prefixing, + // no "/v1/v1/uploads/..." mangling, no re-encoding. + expect(result?.uri).toBe(uri); + expect(result?.uri).not.toContain('/v1/v1/uploads/'); + expect(result?.headers).toEqual({ Authorization: `Bearer ${TOKEN}` }); + }); + + it('is idempotent: passing the already-converted uri through again yields the same result', () => { + const uri = '/v1/uploads/abc123'; + const first = toAuthenticatedImageSource(uri, TOKEN); + const second = toAuthenticatedImageSource(first?.uri, TOKEN); + + expect(second).toEqual(first); + }); + }); + + describe('unrelated paths / external URLs that must never receive the token', () => { + it('does not attach a header when "/uploads/" appears mid-path, even on an allowlisted origin', () => { + vi.stubEnv('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL', 'https://api.soundlog.shop'); + + const uri = 'https://cdn.example.com/gallery/uploads/photo.jpg'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + expect(result).not.toHaveProperty('headers'); + }); + + it('does not attach a header for an unrelated external URL containing "uploads" as a word fragment', () => { + const uri = 'https://example.com/my-uploads-page'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + expect(result).not.toHaveProperty('headers'); + }); + + it('leaves external album art URLs completely unchanged', () => { + const uri = 'https://i.scdn.co/image/ab67616d0000b273abc123'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + }); + + it('leaves external tourism API photo URLs completely unchanged', () => { + const uri = 'https://tong.visitkorea.or.kr/cms2/website/82/1870082.jpg'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + }); + + it('leaves local file:// capture previews unchanged', () => { + const uri = 'file:///var/mobile/Containers/Data/tmp/capture.jpg'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result).toEqual({ uri }); + }); + }); + + describe('URL-encoded paths', () => { + it('still recognizes a relative uploads path whose fileId segment is percent-encoded', () => { + const uri = '/v1/uploads/%EA%B0%80%EB%82%98%EB%8B%A4'; + const result = toAuthenticatedImageSource(uri, TOKEN); + + expect(result?.headers).toEqual({ Authorization: `Bearer ${TOKEN}` }); + expect(result?.uri).toBe(uri); + }); + }); + + describe('malformed input', () => { + it('does not throw for a malformed/garbage uri and treats it as external (no header)', () => { + const uri = 'not a url \\ with spaces and no scheme'; + + expect(() => toAuthenticatedImageSource(uri, TOKEN)).not.toThrow(); + + const result = toAuthenticatedImageSource(uri, TOKEN); + expect(result).not.toHaveProperty('headers'); + }); + }); + + describe('token presence', () => { + it('does not attach a header when accessToken is undefined, even for a matching relative uploads URL', () => { + const uri = '/v1/uploads/abc123'; + const result = toAuthenticatedImageSource(uri, undefined); + + expect(result).toEqual({ uri }); + expect(result).not.toHaveProperty('headers'); + }); + + it('does not attach a header when accessToken is an empty string', () => { + const uri = '/v1/uploads/abc123'; + const result = toAuthenticatedImageSource(uri, ''); + + expect(result).toEqual({ uri }); + }); + + it('reflects whatever token value is passed in at call time (no caching/staleness)', () => { + const uri = '/v1/uploads/abc123'; + + const withOldToken = toAuthenticatedImageSource(uri, 'old-token'); + const withNewToken = toAuthenticatedImageSource(uri, 'new-token'); + + expect(withOldToken?.headers?.Authorization).toBe('Bearer old-token'); + expect(withNewToken?.headers?.Authorization).toBe('Bearer new-token'); + }); + }); +}); diff --git a/src/utils/authenticatedImageSource.ts b/src/utils/authenticatedImageSource.ts new file mode 100644 index 0000000..da8bb33 --- /dev/null +++ b/src/utils/authenticatedImageSource.ts @@ -0,0 +1,136 @@ +import { getApiBaseUrl } from '@/api/client'; + +export type AuthenticatedImageSource = { + headers?: Record; + uri: string; +}; + +// Uploaded files are always served at a path that starts with `/uploads/` or +// `/v1/uploads/` (see SoundLogServer's upload.middleware.ts / +// upload-file.service.ts — the legacy `/uploads/` form is still resolved server-side for +// old rows). Anchoring at the start of the pathname (`^`) is required: a loose +// `pathname.includes('/uploads/')` check would also match an unrelated external URL such +// as `https://cdn.example.com/gallery/uploads/photo.jpg` (the substring appears in the +// middle of the path). Matching the pathname shape is necessary but NOT sufficient on its +// own — see the origin check below. +const UPLOADED_FILE_PATHNAME_PATTERN = /^\/(v1\/)?uploads\/[^/]+\/?$/; + +// A fixed, inert placeholder used only so `new URL(uri, base)` always has *some* base to +// resolve a relative `uri` against and therefore always yields a `.pathname`/`.origin` we +// can test — it is never fetched or otherwise used. +const INERT_URL_RESOLUTION_BASE = 'http://soundlog-local.invalid'; + +// Whether `uri` carries its own explicit scheme/host (`https://host/...`, `//host/...`) as +// opposed to being relative (`/uploads/x`, `uploads/x`). Deliberately checked on the RAW +// string rather than by comparing a parsed `.origin` against the inert placeholder above: +// comparing against the placeholder would let a malicious `uri` that literally spells out +// our placeholder host (e.g. `http://soundlog-local.invalid/uploads/x`) get misclassified +// as "relative" and therefore trusted. +const EXPLICIT_SCHEME_OR_HOST_PATTERN = /^([a-zA-Z][a-zA-Z\d+\-.]*:)?\/\//; + +function safeOrigin(url: string): string | undefined { + try { + return new URL(url).origin; + } catch { + return undefined; + } +} + +// The set of absolute origins allowed to receive the Authorization header. A relative `uri` +// (no explicit scheme/host) is trusted implicitly — it can only resolve against whatever +// origin the app itself was loaded from (e.g. the web build's same-origin `/api/soundlog` +// rewrite), so there is nothing to allowlist. An absolute `uri` must match one of these: +// +// - the origin the app's own JSON API calls go to (`getApiBaseUrl()`), when that base URL +// is itself absolute (on native/production it usually is; on web it is often a relative +// path, in which case this contributes nothing and relative-uri trust covers it), and +// - `EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN`, an explicit allowlist entry for the host that +// actually serves uploaded files. This is necessary because the uploads host can +// legitimately differ from the JSON API's host/proxy (e.g. API calls go through +// `https://soundlog.shop/api/soundlog`, a Vercel proxy, while uploaded photos are served +// directly from `https://api.soundlog.shop` — see SoundLogServer's +// `UPLOAD_PUBLIC_BASE_URL`). When this env var is not set, only the API base origin is +// trusted (a safe, conservative fallback — never "trust everything"). +function getAllowedUploadOrigins(): ReadonlySet { + const origins = new Set(); + + const apiBaseUrl = getApiBaseUrl(); + const apiOrigin = apiBaseUrl ? safeOrigin(apiBaseUrl) : undefined; + + if (apiOrigin) { + origins.add(apiOrigin); + } + + const uploadOrigin = process.env.EXPO_PUBLIC_SOUNDLOG_UPLOAD_ORIGIN; + const resolvedUploadOrigin = uploadOrigin ? safeOrigin(uploadOrigin) : undefined; + + if (resolvedUploadOrigin) { + origins.add(resolvedUploadOrigin); + } + + return origins; +} + +function isUploadedFileUri(uri: string, allowedOrigins: ReadonlySet): boolean { + let parsed: URL; + + try { + parsed = new URL(uri, INERT_URL_RESOLUTION_BASE); + } catch { + return false; + } + + if (!UPLOADED_FILE_PATHNAME_PATTERN.test(parsed.pathname)) { + return false; + } + + if (!EXPLICIT_SCHEME_OR_HOST_PATTERN.test(uri)) { + // No explicit scheme/host in the original string: relative to our own app, trusted. + return true; + } + + // Absolute uri: only ever trust it if its real origin is one we explicitly allow. This is + // the actual defense against a hostile host that happens to reuse the same pathname shape + // (e.g. `https://evil.example.com/uploads/anything`) — pathname matching alone cannot + // distinguish that from a legitimate uploads URL. + return allowedOrigins.has(parsed.origin); +} + +/** + * Uploaded photos (moment photos, recap background/disc art) are served from our own API + * behind `GET /v1/uploads/:fileId`, which requires an `Authorization` header — plain + * `` never sends one, so the request would 401. + * + * This resolves a photo URL into an `expo-image`/RN `Image` source, attaching `accessToken` + * as a header only when `uri` both (a) has the uploads pathname shape and (b) is either + * relative (implicitly same-origin as the app) or absolute with an origin on the allowlist + * (see `getAllowedUploadOrigins`). Any other URL — external album art, tourism API photos, + * local `file://` capture previews, or a hostile URL merely shaped like an uploads path on + * an untrusted host — is returned unchanged, with no header attached. This origin check is + * what actually prevents the access token from being sent to a third-party server; pathname + * matching by itself only rejects unrelated paths on hosts we already trust. + * + * `accessToken` must be passed in explicitly by the caller (read at render time, typically + * via the `useAuthenticatedImageSource` hook in `@/hooks/useAuthenticatedImageSource`, which + * subscribes to the auth store so images pick up a refreshed token or logout immediately). + * This function itself does no reactive reads, so it is also safe to call directly inside + * `.map()`/`renderItem` loops where hooks cannot be used, as long as the caller has already + * subscribed to the token higher up the tree. + */ +export function toAuthenticatedImageSource( + uri: string | undefined, + accessToken: string | undefined, +): AuthenticatedImageSource | undefined { + if (!uri) { + return undefined; + } + + if (!accessToken || !isUploadedFileUri(uri, getAllowedUploadOrigins())) { + return { uri }; + } + + return { + headers: { Authorization: `Bearer ${accessToken}` }, + uri, + }; +} diff --git a/src/utils/momentLogSync.ts b/src/utils/momentLogSync.ts index 0984680..d2df5d3 100644 --- a/src/utils/momentLogSync.ts +++ b/src/utils/momentLogSync.ts @@ -1,11 +1,23 @@ import { ApiError, shouldAttemptAuthenticatedApi } from "@/api/client"; import { momentLogApi } from "@/api/momentLogApi"; import { recapApi } from "@/api/recapApi"; +import { useAuthStore } from "@/store/authStore"; import { useMomentLogStore, type MomentLogPendingAction, } from "@/store/momentLogStore"; +// A pending action only syncs when it was queued by the account that is +// currently logged in. `undefined` ownerUserId covers both legacy data +// (persisted before this field existed) and drafts queued while signed out +// — both stay quarantined rather than uploading under whichever account +// happens to log in next. +function belongsToCurrentAccount(action: MomentLogPendingAction) { + const currentUserId = useAuthStore.getState().user?.id; + + return Boolean(action.ownerUserId) && action.ownerUserId === currentUserId; +} + export type MomentLogSyncResult = { failureCount: number; successCount: number; @@ -154,7 +166,9 @@ async function performFlush(): Promise { return { failureCount: 0, successCount: 0 }; } - const actions = [...useMomentLogStore.getState().pendingActions]; + const actions = [...useMomentLogStore.getState().pendingActions].filter( + belongsToCurrentAccount, + ); let failureCount = 0; let successCount = 0; diff --git a/src/utils/travelLogSync.ts b/src/utils/travelLogSync.ts index 8cf7cd2..1deb1a3 100644 --- a/src/utils/travelLogSync.ts +++ b/src/utils/travelLogSync.ts @@ -1,10 +1,26 @@ import { ApiError, shouldAttemptAuthenticatedApi } from '@/api/client'; import { recapApi } from '@/api/recapApi'; import { travelSessionApi } from '@/api/travelSessionApi'; +import { useAuthStore } from '@/store/authStore'; import { useMomentLogStore } from '@/store/momentLogStore'; -import { useTravelLogSyncStore } from '@/store/travelLogSyncStore'; +import { + useTravelLogSyncStore, + type PendingTravelLogFinalization, +} from '@/store/travelLogSyncStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; +// Mirrors momentLogSync's account gate: only finalize a Log finalization +// queued by the account that is currently logged in. Unknown owner (legacy +// data, or queued while signed out) stays quarantined. +function belongsToCurrentAccount(finalization: PendingTravelLogFinalization) { + const currentUserId = useAuthStore.getState().user?.id; + + return ( + Boolean(finalization.ownerUserId) && + finalization.ownerUserId === currentUserId + ); +} + export type TravelLogSyncResult = { createdRecapIds: Record; deferredCount: number; @@ -28,12 +44,24 @@ async function performFlush(): Promise { const pendingFinalizations = [ ...useTravelLogSyncStore.getState().pendingFinalizations, - ]; + ].filter(belongsToCurrentAccount); for (const finalization of pendingFinalizations) { const momentState = useMomentLogStore.getState(); - if (momentState.pendingActions.length > 0) { + // Only pending actions that belong to THIS session may block its Log + // confirmation. A pending action has no sessionId of its own, so we + // resolve it via the moment log it targets. Actions whose log is no + // longer present (e.g. an already-queued delete removed it from + // `logs`) can't affect this session's finalized content, since the + // sessionLogs computation below also no longer includes it. + const sessionPendingActionCount = momentState.pendingActions.filter( + (action) => + momentState.logs.find((log) => log.id === action.momentLogId) + ?.sessionId === finalization.sessionId, + ).length; + + if (sessionPendingActionCount > 0) { result.deferredCount += 1; continue; } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..857b685 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,29 @@ +import path from 'node:path'; + +import { defineConfig } from 'vitest/config'; + +// Minimal vitest setup for pure logic / zustand store unit tests only. +// RN component rendering is intentionally out of scope (see AGENTS.md / +// task notes) — this project has no test runner otherwise. +export default defineConfig({ + resolve: { + alias: [ + { + find: '@react-native-async-storage/async-storage', + replacement: path.resolve( + __dirname, + 'src/test/mocks/asyncStorageMock.ts', + ), + }, + { + find: 'react-native', + replacement: path.resolve(__dirname, 'src/test/mocks/reactNativeMock.ts'), + }, + { find: '@', replacement: path.resolve(__dirname, 'src') }, + ], + }, + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});