Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
76bf3d9
fix: 서버 API 고정 및 음악 스트리밍 제거
manNomi Jul 3, 2026
5107f74
fix: 외부 음악 링크 성공 후 이벤트 기록
manNomi Jul 3, 2026
7dc0365
fix: 맞춤 플레이리스트 실패 숨김 제거
manNomi Jul 3, 2026
1a9e7aa
fix: 로그인 사용자 인증 API 실패 숨김 제거
manNomi Jul 3, 2026
900b577
fix: 프로필 저장 실패를 사용자에게 표시
manNomi Jul 3, 2026
0ce4d90
fix: 미니 플레이어 가짜 진행 표시 제거
manNomi Jul 3, 2026
4e89539
fix: 프로덕션 배포 설정 검증 통과
manNomi Jul 3, 2026
e9f86d2
fix: 맞춤 플레이리스트 장르 선호 전달
manNomi Jul 3, 2026
14101d2
fix: 자체 계정 기준 사용자 문구 정리
manNomi Jul 3, 2026
1e40892
fix: 원격 트랙 플랫폼 URL 정규화
manNomi Jul 3, 2026
4035430
docs: 음악 스트리밍 제거 범위 정리
manNomi Jul 3, 2026
24f7654
ci: 서버 웹 번들 검증 추가
manNomi Jul 3, 2026
20ffecd
fix: 프론트 mock API 런타임 경로 제거
manNomi Jul 3, 2026
5da1e47
test: 서버 웹 번들 mock 검증 강화
manNomi Jul 3, 2026
300a288
ci: 앱 릴리즈 설정 검사 추가
manNomi Jul 3, 2026
94037b4
refactor: 음악 플랫폼 응답 타입 제거
manNomi Jul 3, 2026
ee60e71
fix: 가짜 재생 표면 추가 제거
manNomi Jul 3, 2026
0f5fd0e
test: 배포 웹 검증 스크립트 추가
manNomi Jul 3, 2026
b9eae9a
fix: 장소 seed 응답 계약 반영
manNomi Jul 3, 2026
29672a8
Merge remote-tracking branch 'origin/main' into codex/app-server-audi…
manNomi Jul 4, 2026
f6fd0c2
fix: soundlog.shop API 프록시 도메인 반영
manNomi Jul 4, 2026
3fa20c2
fix: 홈 위치 추천 진입점 연결
manNomi Jul 4, 2026
7c50bc3
fix: API rewrite origin 검증 강화
manNomi Jul 4, 2026
1707db3
fix: Vercel API origin 검증 방식 수정
manNomi Jul 4, 2026
4b62e8c
fix: Vercel rewrite 설정 형식 보정
manNomi Jul 4, 2026
facbb49
fix: 음악 링크 UX와 mock 검증 보강
manNomi Jul 4, 2026
8aeb6ab
test: 배포 웹 장소 검증 기준 정리
manNomi Jul 4, 2026
43499a5
ci: 현재 API origin 검증 허용
manNomi Jul 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,12 @@ jobs:

- name: Run typecheck
run: npm run typecheck

- name: Check server web export
run: npm run check:server-web-export

- name: Check Vercel config
run: npm run check:vercel-config

- name: Check store release config
run: npm run check:store-release
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,45 +25,52 @@ npm run check
npm run web:clear
```

Mock API 상태를 확인할 때는 아래처럼 지연이나 실패를 강제로 줄 수 있습니다.
레거시 mock API 상태를 확인하던 지연/실패 환경변수는 `src/mock-server` 참고용으로만 남아 있습니다. 현재 앱 API facade는 서버 API만 호출합니다.

```bash
EXPO_PUBLIC_MOCK_API_DELAY_MS=1500 npm run web
EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS=playlist.detail npm run web
```

실제 서버와 연동할 때는 Expo 환경변수로 API base URL을 지정합니다. 이 값이 없으면 앱은 기존 mock API를 사용합니다.
실제 서버와 연동할 때는 Expo 환경변수로 API base URL을 지정합니다. 이 값이 없으면 mock으로 돌아가지 않고 API URL 누락 오류를 표시합니다.

```bash
EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=https://api.soundlog.shop npm run web
EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog npm run web
```

Vercel web 배포는 브라우저 API 호출을 같은 origin에서 처리하도록
`/api/soundlog` rewrite proxy를 사용합니다. rewrite 대상은 `https://api.soundlog.shop`입니다. `vercel.json`의 build command가
`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`를 주입합니다.

네이티브 실배포 빌드는 HTTPS API를 사용해야 합니다. 로그인은 Soundlog 자체 이메일/비밀번호 계정으로 처리합니다.
네이티브 실배포 빌드는 `https://soundlog.shop/api/soundlog` HTTPS 프록시를 사용합니다. 로그인은 Soundlog 자체 이메일/비밀번호 계정으로 처리합니다.

## 테스트 설치 빌드

`development`, `preview` EAS profile은 Soundlog API 도메인을 바라보도록 설정되어 있습니다.
`development`, `preview` EAS profile은 HTTPS Vercel API proxy를 바라보도록 설정되어 있습니다.

- Web: `https://soundlog.shop`
- API: `https://api.soundlog.shop`
- API: `https://soundlog.shop/api/soundlog`
- API source: `server`
- auth: Soundlog 자체 이메일/비밀번호 로그인
- iOS/Android: HTTPS API만 사용

Mock API로 되돌려 화면 상태를 확인할 때는 `EXPO_PUBLIC_SOUNDLOG_API_SOURCE=mock`으로 실행하거나 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL`을 비워둡니다.
Mock API로 되돌리는 런타임 경로는 제거했습니다. 화면 상태 테스트가 필요하면 서버 fixture 또는 `src/mock-server` 레거시 자료를 별도 개발 도구에서만 사용합니다.

API origin이 최신 서버인지 확인하려면 아래처럼 실행합니다.

```bash
SOUNDLOG_API_ORIGIN=http://<EC2_HOST>:4000 npm run check:api-origin
```

Android 지인 테스트용 내부 배포 빌드는 아래 명령으로 생성합니다.

```bash
npx eas build --profile preview --platform android
```

iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/TestFlight에 올릴 production profile도 `https://api.soundlog.shop`을 사용합니다.
iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/TestFlight에 올릴 production profile도 현재는 `https://soundlog.shop/api/soundlog`을 사용합니다.

## 문서

Expand Down
5 changes: 0 additions & 5 deletions app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const baseConfig = {
ios: {
infoPlist: {
CFBundleAllowMixedLocalizations: true,
LSApplicationQueriesSchemes: ['spotify'],
NSCameraUsageDescription:
'Soundlog가 여행 순간을 사진으로 기록하기 위해 카메라 권한이 필요합니다.',
NSFaceIDUsageDescription:
Expand Down Expand Up @@ -80,7 +79,6 @@ const baseConfig = {
],
'expo-secure-store',
'expo-image',
'expo-web-browser',
],
experiments: {
typedRoutes: true,
Expand All @@ -91,9 +89,6 @@ const baseConfig = {
supportEmail: process.env.EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL ?? 'support@soundlog.shop',
termsUrl: process.env.EXPO_PUBLIC_SOUNDLOG_TERMS_URL,
},
spotify: {
clientId: process.env.EXPO_PUBLIC_SPOTIFY_CLIENT_ID,
},
eas: {
projectId: '4b07627b-36bf-463d-a15e-b4839022ecbb',
},
Expand Down
92 changes: 75 additions & 17 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { meApi } from '@/api/meApi';
import { playlistApi, PlaylistMlMood, PlaylistMlState } from '@/api/playlistApi';
import { playlistQueryKeys } from '@/api/playlistQueries';
import { syncRecommendationEvent } from '@/api/recommendationEventApi';
import { AppText } from '@/components/AppText';
import { useNearbyPlacesQuery } from '@/api/tourQueries';
import { MiniPlayer } from '@/components/MiniPlayer';
import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection';
Expand All @@ -21,6 +22,7 @@ import {
HomeTopFilterBar,
isHomeTopFilter,
} from '@/components/home/HomeHeader';
import { LocationContextCard } from '@/components/home/LocationContextCard';
import {
MoodRecommendationSection,
isMoodRecommendationFilter,
Expand All @@ -37,12 +39,12 @@ import {
import { usePlayerStore } from '@/store/playerStore';
import { useRecommendationEventStore } from '@/store/recommendationEventStore';
import { queryClient } from '@/providers/queryClient';
import { playSelectedSpotifyOrFallback } from '@/spotify/spotifyPlayback';
import { useTravelSessionStore } from '@/store/travelSessionStore';
import { useUserProfileStore } from '@/store/userProfileStore';
import { FeaturedPlaylist, MoodRecommendation, MusicLogItem, TravelMode } from '@/types/domain';
import { requestForegroundLocationWithStatus } from '@/utils/location';
import { getMoodTagsFromFilter } from '@/utils/moodTags';
import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks';
import { createRecommendationEventContext } from '@/utils/recommendationEventContext';

const moodFilterToMlMood: Record<string, PlaylistMlMood> = {
Expand Down Expand Up @@ -78,6 +80,7 @@ function resolvePlaylistState(mode?: TravelMode): PlaylistMlState {

function HomeContent() {
const insets = useSafeAreaInsets();
const [actionMessage, setActionMessage] = useState<string>();
const [creatingPlaylistId, setCreatingPlaylistId] = useState<string>();
const {
selectedMoodFilter,
Expand Down Expand Up @@ -131,6 +134,15 @@ function HomeContent() {
...momentLogs.slice(0, 6).map(momentLogToMusicLogItem),
...(recentMusicLogsQuery.data ?? []),
].slice(0, 10);
const placeInfoMessage =
profile.locationRecommendationEnabled &&
currentLocation &&
!nearbyPlacesQuery.isFetching &&
(nearbyPlacesQuery.data?.length ?? 0) === 0
? '주변 관광지 결과가 없어도 기본 추천은 계속 사용할 수 있어요.'
: nearbyPlacesQuery.isError
? '주변 관광지를 불러오지 못했지만 기본 추천은 계속 사용할 수 있어요.'
: undefined;

useEffect(() => {
if (!isMoodRecommendationFilter(selectedMoodFilter)) {
Expand All @@ -156,7 +168,9 @@ function HomeContent() {
}
}, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]);

const handleSelectRecommendation = (item: MoodRecommendation) => {
const handleSelectRecommendation = async (item: MoodRecommendation) => {
setActionMessage(undefined);

if (item.playlistId) {
router.push(`/playlist/${item.playlistId}`);
syncRecommendationEvent(
Expand All @@ -170,23 +184,31 @@ function HomeContent() {
return;
}

const externalLink = getTrackExternalLink(item.track);

setTrack(item.track);
void playSelectedSpotifyOrFallback(item.track);
syncRecommendationEvent(
addRecommendationEvent({
context: createRecommendationEventContext(),
trackId: item.track.id,
type: 'track_play',
value: item.id,
}),
);

try {
await openMusicPlatformUrl(externalLink);
syncRecommendationEvent(
addRecommendationEvent({
context: createRecommendationEventContext(),
trackId: item.track.id,
type: 'track_external_open',
value: externalLink.platformId,
}),
);
} catch {
setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.');
}
};
const handleSelectFeaturedPlaylist = useCallback(
async (playlist: FeaturedPlaylist) => {
if (creatingPlaylistId) {
return;
}

setActionMessage(undefined);
setCreatingPlaylistId(playlist.id);

try {
Expand All @@ -196,6 +218,7 @@ function HomeContent() {
mood: resolvePlaylistMood(selectedMoodFilter, profile.preferredMoods),
moodTags: getMoodTagsFromFilter(selectedMoodFilter),
placeId: currentPlace?.id,
preferredGenres: profile.preferredGenres,
preferredMoods: profile.preferredMoods,
state: resolvePlaylistState(selectedMode),
travelMode: selectedMode,
Expand All @@ -222,6 +245,8 @@ function HomeContent() {
}),
);
router.push(`/playlist/${nextPlaylistId}`);
} catch {
setActionMessage('맞춤 플레이리스트를 만들지 못했어요. 잠시 후 다시 시도해주세요.');
} finally {
setCreatingPlaylistId(undefined);
}
Expand All @@ -231,6 +256,7 @@ function HomeContent() {
creatingPlaylistId,
currentLocation,
currentPlace,
profile.preferredGenres,
profile.preferredMoods,
selectedMode,
selectedMoodFilter,
Expand Down Expand Up @@ -290,7 +316,7 @@ function HomeContent() {
},
[addRecommendationEvent, recommendationMode, setRecommendationMode],
);
const handleEnableLocationRecommendation = () => {
const handleEnableLocationRecommendation = useCallback(async () => {
const nextProfile = {
companionType: profile.companionType,
locationRecommendationEnabled: true,
Expand All @@ -299,9 +325,17 @@ function HomeContent() {
travelStyles: profile.travelStyles,
};

updateProfile(nextProfile);
void meApi.updateProfile(nextProfile).catch(() => undefined);
};
setActionMessage(undefined);

try {
await meApi.updateProfile(nextProfile);
updateProfile(nextProfile);
return true;
} catch {
setActionMessage('위치 추천 설정을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.');
return false;
}
}, [profile, updateProfile]);
const handleRefreshLocation = useCallback(async () => {
if (locationStatus === 'loading') {
return;
Expand All @@ -322,9 +356,13 @@ function HomeContent() {
setLocationStatus('unavailable');
}
}, [locationStatus, setLocation, setLocationStatus]);
const handleSetCurrentLocation = useCallback(() => {
const handleSetCurrentLocation = useCallback(async () => {
if (!profile.locationRecommendationEnabled) {
handleEnableLocationRecommendation();
const didEnable = await handleEnableLocationRecommendation();

if (!didEnable) {
return;
}
}

void handleRefreshLocation();
Expand Down Expand Up @@ -356,6 +394,20 @@ function HomeContent() {
recommendationMode={recommendationMode}
/>

<LocationContextCard
enabled={profile.locationRecommendationEnabled}
isLoading={locationStatus === 'loading'}
isPlaceLoading={nearbyPlacesQuery.isFetching}
location={currentLocation}
onEnable={handleSetCurrentLocation}
onRefresh={handleRefreshLocation}
place={currentPlace}
placeCount={nearbyPlacesQuery.data?.length ?? 0}
placeInfoMessage={placeInfoMessage}
status={locationStatus}
updatedAt={locationUpdatedAt}
/>

{recommendationMode === 'travel' ? (
<View className="-mt-2">
<TravelSessionCard
Expand Down Expand Up @@ -397,6 +449,12 @@ function HomeContent() {
/>
</View>

{actionMessage ? (
<View className="rounded-[14px] border border-amber-300/20 bg-amber-300/10 px-4 py-3">
<AppText className="text-xs leading-5 text-amber-100">{actionMessage}</AppText>
</View>
) : null}

<View className="mt-2">
<MusicLogSection
data={musicLogs}
Expand Down
Loading
Loading