From baf62985f3bc6e4582a98cf7d4e2ee201b85f331 Mon Sep 17 00:00:00 2001 From: manNomi Date: Wed, 2 Sep 2026 17:41:29 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B6=94=EC=B2=9C=20=EB=B3=84=EC=A0=90?= =?UTF-8?q?=EA=B3=BC=20=EC=84=A0=ED=83=9D=20=EC=9D=98=EA=B2=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/music.tsx | 50 +-- docs/README.md | 1 + docs/codex/AI_USAGE.md | 88 +++++ .../RECOMMENDATION_FEEDBACK_SERVER_SPEC.md | 139 +++++++ ...09-02-recommendation-star-feedback-plan.md | 83 +++++ src/components/dev/DevTestManager.tsx | 46 +++ .../home/RecommendationFeedbackSheet.tsx | 339 +++++++++++------- .../moment-capture/MomentCaptureScreen.tsx | 120 +++++-- .../moment-capture/MomentReviewPanel.tsx | 50 ++- src/utils/recommendationFeedback.test.ts | 53 +++ src/utils/recommendationFeedback.ts | 42 +++ 11 files changed, 815 insertions(+), 196 deletions(-) create mode 100644 docs/codex/AI_USAGE.md create mode 100644 docs/frontend/RECOMMENDATION_FEEDBACK_SERVER_SPEC.md create mode 100644 docs/implementation/2026-09-02-recommendation-star-feedback-plan.md create mode 100644 src/utils/recommendationFeedback.test.ts create mode 100644 src/utils/recommendationFeedback.ts diff --git a/app/(tabs)/music.tsx b/app/(tabs)/music.tsx index 2e6fa82..fd9cbc0 100644 --- a/app/(tabs)/music.tsx +++ b/app/(tabs)/music.tsx @@ -29,7 +29,7 @@ import { HomeHeader } from "@/components/home/HomeHeader"; import { LocationContextCard } from "@/components/home/LocationContextCard"; import { RecommendationFeedbackSheet, - type RecommendationFeedbackRating, + type RecommendationFeedbackSubmission, } from "@/components/home/RecommendationFeedbackSheet"; import { MoodRecommendationSection, @@ -61,6 +61,7 @@ import { toLibraryPlaylistSummary } from "@/utils/libraryPlaylistSummary"; import { requestForegroundLocationWithStatus } from "@/utils/location"; import { getMoodTagsFromFilter } from "@/utils/moodTags"; import { createRecommendationEventContext } from "@/utils/recommendationEventContext"; +import { createRecommendationFeedbackValue } from "@/utils/recommendationFeedback"; import { getPlaceDisplayTitle } from "@/utils/placeLabel"; const moodFilterToMlMood: Record = { @@ -844,13 +845,13 @@ function HomeContent() { setFeedbackPlaylist(undefined); }, []); const handleSubmitRecommendationFeedback = useCallback( - (rating: RecommendationFeedbackRating, moodFilter?: string) => { + ({ opinion, rating }: RecommendationFeedbackSubmission) => { if (!feedbackPlaylist) { return; } const context = createRecommendationEventContext({ - moodFilter: moodFilter ?? selectedMoodFilter, + moodFilter: selectedMoodFilter, source: feedbackPlaylist.context?.source, }); @@ -859,39 +860,23 @@ function HomeContent() { context, playlistId: feedbackPlaylist.id, type: "recommendation_feedback", - value: moodFilter ? `${rating}:${moodFilter}` : rating, + value: createRecommendationFeedbackValue({ + opinion, + rating, + subject: "music", + }), }), ); - if (moodFilter) { - setSelectedMoodFilter(moodFilter); - syncRecommendationEvent( - addRecommendationEvent({ - context, - playlistId: feedbackPlaylist.id, - type: "mood_adjusted", - value: moodFilter, - }), - ); - setActionMessage( - `${moodFilter} 무드로 바꿨어요. 다음 추천에 바로 반영할게요.`, - ); - } else if (rating === "great") { - setActionMessage("좋아요. 비슷한 장소와 무드 추천에 반영할게요."); - } else if (rating === "okay") { - setActionMessage("피드백을 저장했어요. 다음 추천을 더 잘 맞춰볼게요."); - } else { - setActionMessage("다음 추천에서는 다른 느낌을 더 살펴볼게요."); - } + setActionMessage( + opinion?.trim() + ? `${rating}점과 의견을 남겼어요.` + : `${rating}점을 남겼어요.`, + ); setFeedbackPlaylist(undefined); }, - [ - addRecommendationEvent, - feedbackPlaylist, - selectedMoodFilter, - setSelectedMoodFilter, - ], + [addRecommendationEvent, feedbackPlaylist, selectedMoodFilter], ); const handleSelectMusicPlaylistTrack = useCallback( (track: Track) => { @@ -1167,10 +1152,11 @@ function HomeContent() { visible={isMusicPlaylistSheetVisible} /> {currentTrack ? : null} diff --git a/docs/README.md b/docs/README.md index a1e4473..23d9c08 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ Soundlog 문서는 목적별로 관리합니다. 공모전 기획, RN 프론트 ## Codex +- [Soundlog AI 개발 운영 방식](codex/AI_USAGE.md): 프론트엔드 드리븐 개발, 서버 요구사항 전달, 검증, Pull Request와 병합 승인 원칙 - [비개발자용 Codex 개발 가이드](codex/NON_DEVELOPER_CODEX_GUIDE.md): Codex에게 개발을 맡기는 방식 - [Codex 요청 프롬프트 모음](codex/CODEX_PROMPTS.md): 바로 복사해서 쓸 수 있는 요청문 - [UI 피드백 루프 운영 문서](codex/UI_FEEDBACK_LOOP.md): 자연어 UI 수정 요청을 계획-리뷰-구현-리뷰 루프로 처리하는 방식 diff --git a/docs/codex/AI_USAGE.md b/docs/codex/AI_USAGE.md new file mode 100644 index 0000000..f8c0120 --- /dev/null +++ b/docs/codex/AI_USAGE.md @@ -0,0 +1,88 @@ +# Soundlog AI 개발 운영 방식 + +이 문서는 Soundlog에서 AI와 함께 기능을 설계하고 구현할 때 따르는 기본 흐름을 설명합니다. 제품 요구사항은 프론트엔드 화면과 사용자 흐름으로 먼저 구체화하고, 검증된 프론트엔드 계약을 서버 팀에 전달합니다. + +## 1. 기본 원칙 + +Soundlog는 프론트엔드 드리븐 디벨롭 방식으로 개발합니다. 프론트엔드 드리븐 디벨롭은 사용자가 실제로 보고 누르는 화면을 먼저 설계하고, 그 화면에 필요한 데이터 계약을 나중에 서버 요구사항으로 확정하는 방식입니다. + +AI는 다음 원칙을 지킵니다. + +1. 사용자의 자연어 요구를 사용자 목표와 화면 상태와 수용 조건으로 바꿉니다. +2. 기존 디자인 시스템과 제품 용어를 확인한 뒤 UI를 설계합니다. +3. 프론트엔드에서 가능한 상태와 예외 흐름을 먼저 구현합니다. +4. 서버가 필요한 기능은 앱 저장소의 서버 전달 문서로 작성합니다. +5. 서버 저장소와 운영 인프라는 팀원이 담당하며 AI가 임의로 수정하거나 배포하지 않습니다. +6. 앱 변경은 iOS 시뮬레이터에서 실제 버튼을 눌러 검증합니다. Expo 웹이나 브라우저 화면은 앱 검수 근거로 사용하지 않습니다. +7. 모든 변경은 기능 브랜치와 Pull Request를 거칩니다. AI는 사용자가 해당 Pull Request 번호를 명시해 병합을 요청한 경우에만 병합합니다. + +## 2. 기능 개발 순서 + +### 2.1 요구사항을 화면 계약으로 바꾸기 + +AI는 구현 전에 아래 내용을 짧은 계획 문서로 작성합니다. + +- 사용자가 이 기능을 사용하는 이유 +- 기능에 진입하는 화면과 CTA +- 기본 상태와 로딩 상태와 빈 상태와 실패 상태 +- 입력값과 검증 규칙 +- 접근성과 개인정보 보호 조건 +- 완료 여부를 판단할 수 있는 수용 조건 + +서버 응답이 아직 없어도 화면의 모든 상태를 먼저 정의합니다. 다만 실제 저장 성공을 가짜 데이터로 확정해서 보여주면 안 됩니다. + +### 2.2 UI와 프론트엔드 동작 구현하기 + +기존 컴포넌트를 재사용하고 Soundlog 디자인 시스템을 따릅니다. 사용자에게 보이는 기본 글자는 흰색 계열을 사용합니다. 주요 버튼은 44pt 이상의 터치 영역을 확보합니다. 네트워크 요청은 중복 제출과 실패와 재시도를 고려합니다. + +서버 기능이 준비되지 않은 경우 프론트엔드가 기대하는 요청과 응답을 타입으로 고정할 수 있습니다. 이때 현재 서버가 지원하지 않는 동작을 사용자에게 성공했다고 안내하지 않습니다. + +### 2.3 서버 요구사항 전달하기 + +서버 작업이 필요한 기능은 `docs/frontend/` 아래에 별도 스펙을 만듭니다. 문서에는 다음 내용을 포함합니다. + +- 기능 목적과 호출 시점 +- API 경로와 인증 조건 +- 요청과 응답 예시 +- 필드별 타입과 필수 여부 +- 멱등성과 중복 요청 처리 +- 개인정보와 로그 보존 조건 +- 오류 코드와 프론트엔드 처리 방식 +- 서버 팀이 확인할 수 있는 수용 시나리오 + +사진 파일 경로나 사용자가 입력한 민감정보를 분석 이벤트에 불필요하게 보내지 않습니다. 서버 팀은 이 문서를 기준으로 API를 구현하고, 계약 변경이 필요하면 프론트엔드 담당자와 함께 문서를 먼저 수정합니다. + +### 2.4 검증과 Pull Request + +AI는 타입 검사와 단위 테스트와 저장소 검증 스크립트를 실행합니다. 그 다음 iOS 시뮬레이터에서 진입부터 제출까지 확인하고 스크린샷을 남깁니다. + +Pull Request에는 아래 내용을 기록합니다. + +- 사용자가 체감하는 변경 사항 +- 변경된 화면과 상태 +- 서버 팀에 전달할 계약 문서 +- 실행한 검증 명령과 시뮬레이터 결과 +- 아직 서버 반영이 필요한 부분 + +CI가 통과해도 운영 서버와 TestFlight에서 동작한다는 뜻은 아닙니다. 병합과 앱 배포와 서버 배포와 운영 검수 결과는 각각 구분해 보고합니다. + +## 3. 역할 경계 + +| 역할 | 담당 내용 | +| ------------------ | ----------------------------------------------------------------------------------- | +| 프론트엔드와 AI | 사용자 흐름, UI 상태, 앱 코드, 타입, 앱 테스트, 서버 요구사항 문서, 앱 Pull Request | +| 서버 팀원 | API 구현, 데이터베이스 변경, 운영 환경변수, 서버 배포, 운영 로그 확인 | +| 사용자 또는 리뷰어 | 제품 결정, Pull Request 승인, 해당 Pull Request 병합 지시, 앱 배포 승인 | + +앱 빌드나 EAS 배포 요청은 서버 배포 요청으로 해석하지 않습니다. 서버 구현이 필요하면 앱 Pull Request에서 서버 전달 문서를 공유하고 팀원의 반영을 기다립니다. + +## 4. 이번 기능 적용 예시 + +추천 피드백 기능은 아래 순서로 진행합니다. + +1. 음악 추천 상세를 닫거나 추천사진 편집 화면에서 피드백에 진입합니다. +2. 사용자는 1점부터 5점까지 별점을 선택합니다. +3. 사용자는 별점만 보내거나 짧은 의견을 추가해 보낼 수 있습니다. +4. 프론트엔드는 추천 종류와 별점과 선택 의견만 이벤트로 만듭니다. +5. 추천사진의 원본 주소와 이미지 파일은 피드백 이벤트에 포함하지 않습니다. +6. 서버 팀은 [추천 피드백 서버 요구사항](../frontend/RECOMMENDATION_FEEDBACK_SERVER_SPEC.md)을 기준으로 이벤트를 저장하고 분석할 수 있게 구현합니다. diff --git a/docs/frontend/RECOMMENDATION_FEEDBACK_SERVER_SPEC.md b/docs/frontend/RECOMMENDATION_FEEDBACK_SERVER_SPEC.md new file mode 100644 index 0000000..4a7e527 --- /dev/null +++ b/docs/frontend/RECOMMENDATION_FEEDBACK_SERVER_SPEC.md @@ -0,0 +1,139 @@ +# 추천 피드백 서버 요구사항 + +## 1. 목적 + +앱 사용자가 음악 추천과 관광지 추천사진에 1점부터 5점까지 별점을 남긴다. 사용자는 선택적으로 300자 이하 의견을 함께 보낼 수 있다. 서버는 이 값을 추천 품질 분석과 이후 추천 모델 개선에 사용할 수 있는 형태로 저장해야 한다. + +이 문서는 프론트엔드 구현이 기대하는 서버 계약이다. 서버 저장소와 데이터베이스와 운영 배포는 서버 팀원이 담당한다. + +## 2. 호출 시점 + +- 음악 추천 상세를 닫은 뒤 사용자가 피드백 제출 버튼을 누른 시점 +- 추천사진을 리캡 편집 화면에 적용한 뒤 사용자가 피드백 제출 버튼을 누른 시점 + +시트를 닫거나 건너뛴 경우 요청하지 않는다. + +## 3. 요청 계약 + +기존 엔드포인트를 사용한다. + +```http +POST /v1/recommendation-events +Authorization: Bearer +Idempotency-Key: +Content-Type: application/json +``` + +요청 예시는 다음과 같다. + +```json +{ + "events": [ + { + "id": "event-1725267600000-ab12cd", + "sessionId": "session-1725267000000-ef34gh", + "createdAt": "2026-09-02T10:00:00.000Z", + "type": "recommendation_feedback", + "playlistId": "playlist-seoul-night", + "context": { + "source": "ml-recommendation", + "moodFilter": "감성적인", + "placeId": "tour-126508", + "placeName": "서울숲" + }, + "value": "{\"version\":1,\"subject\":\"music\",\"rating\":5,\"opinion\":\"산책할 때 잘 어울렸어요\"}" + } + ] +} +``` + +`value`를 파싱한 논리 타입은 다음과 같다. + +```ts +type RecommendationFeedbackValue = { + version: 1; + subject: "music" | "photo"; + rating: 1 | 2 | 3 | 4 | 5; + opinion?: string; +}; +``` + +## 4. 필드 규칙 + +| 필드 | 필수 여부 | 규칙 | +| ----------------- | ---------------------- | ---------------------------------------------------------- | +| `type` | 필수 | 항상 `recommendation_feedback` | +| `value.version` | 필수 | 현재 값은 `1` | +| `value.subject` | 필수 | `music` 또는 `photo` | +| `value.rating` | 필수 | 1부터 5까지의 정수 | +| `value.opinion` | 선택 | 앞뒤 공백을 제거한 1자 이상 300자 이하 문자열 | +| `playlistId` | 음악에서 필수 | 평가한 추천 플레이리스트 식별자 | +| `context.placeId` | 사진에서 가능하면 필수 | 추천사진을 제공한 관광지 식별자 | +| `context.source` | 필수 | 음악 추천 출처 또는 `recommended-photo:<관광 데이터 출처>` | + +사진 피드백에는 이미지 URL과 기기 파일 URI와 이미지 바이너리와 GPS 좌표를 넣지 않는다. 장소 식별자와 사람이 읽을 수 있는 장소명만 전달한다. + +## 5. 서버 처리 요구사항 + +1. 인증된 사용자와 이벤트 `id`와 평가 대상을 연결해 저장한다. +2. 같은 이벤트 `id` 또는 같은 `Idempotency-Key`가 다시 오면 중복 레코드를 만들지 않는다. +3. `value` JSON을 검증하고 구조화된 별점과 선택 의견으로 저장한다. +4. 알 수 없는 `version`은 조용히 잘못 해석하지 말고 지원하지 않는 계약으로 처리한다. +5. 별점 범위를 벗어나거나 의견이 300자를 넘으면 해당 이벤트를 거부한다. +6. 한 이벤트가 잘못돼도 배치 전체를 어떻게 처리하는지 응답에 명시한다. +7. 의견은 운영 로그에 원문으로 남기지 않는다. 접근 권한과 보존 기간을 정한다. + +## 6. 응답 계약 + +최소 응답은 기존 앱과 호환되어야 한다. + +```json +{ + "accepted": true +} +``` + +배치의 일부만 거부할 수 있다면 아래 확장 응답을 권장한다. + +```json +{ + "accepted": false, + "acceptedEventIds": [], + "rejectedEvents": [ + { + "eventId": "event-1725267600000-ab12cd", + "code": "INVALID_FEEDBACK_VALUE", + "message": "rating은 1부터 5까지의 정수여야 합니다." + } + ] +} +``` + +## 7. 오류 처리 + +| 상태 | 의미 | 앱 처리 | +| ----- | ---------------------------- | ---------------------------------------------------------- | +| `400` | 요청 또는 피드백 값이 잘못됨 | 사용자 흐름은 유지하고 진단 로그로 확인 | +| `401` | 인증이 없거나 만료됨 | 현재 앱 정책에 따라 인증 복구 후 새 이벤트부터 전송 | +| `409` | 멱등성 키 충돌 | 동일 이벤트면 성공으로 취급할 수 있는 식별 정보 제공 | +| `429` | 요청 제한 | 추천과 리캡 흐름은 유지하고 이후 재시도 정책을 별도로 결정 | +| `5xx` | 서버 오류 | 추천과 리캡 흐름을 막지 않음 | + +현재 앱은 이 분석 이벤트 전송 실패를 사용자 핵심 기능 실패로 처리하지 않는다. 서버가 안정적인 재전송을 요구한다면 앱의 전송 큐와 완료 상태 계약을 별도 기능으로 합의해야 한다. + +## 8. 서버 수용 시나리오 + +1. 음악 추천에 5점만 보낸 이벤트를 저장한다. +2. 음악 추천에 3점과 의견을 보낸 이벤트를 저장한다. +3. 추천사진에 4점만 보낸 이벤트를 장소와 연결해 저장한다. +4. 공백만 있는 의견은 없는 값으로 정규화하거나 명시적인 검증 오류로 처리한다. +5. 0점과 6점과 소수점 별점을 거부한다. +6. 301자 의견을 거부한다. +7. 같은 이벤트를 두 번 보내도 하나만 저장한다. +8. 사진 이벤트의 요청과 저장 데이터에 사진 URI가 없는지 확인한다. + +## 9. 연관 확인 사항 + +2026년 9월 2일 iOS 시뮬레이터 검수에서 서버가 반환한 관광지 추천사진 한 건이 리캡 편집 화면에서 로드되지 않았다. 피드백 계약과는 별개지만 추천사진을 평가하려면 원본 사진이 먼저 보여야 한다. 서버 팀은 관광지 응답의 `imageUrl`이 HTTPS를 사용하는지와 인증 없이 외부에서 읽을 수 있는지와 만료되지 않은 주소인지 확인해야 한다. + +앱은 추천 피드백 이벤트에 이 주소를 다시 보내지 않는다. 서버 확인에는 원래 관광지 조회 응답과 서버 로그를 사용한다. diff --git a/docs/implementation/2026-09-02-recommendation-star-feedback-plan.md b/docs/implementation/2026-09-02-recommendation-star-feedback-plan.md new file mode 100644 index 0000000..79c6b51 --- /dev/null +++ b/docs/implementation/2026-09-02-recommendation-star-feedback-plan.md @@ -0,0 +1,83 @@ +# 추천 별점과 선택 의견 구현 계획 + +## 목표 + +사용자가 음악 추천이나 추천사진을 경험한 뒤 부담 없이 별점만 남기거나 별점과 짧은 의견을 함께 남길 수 있게 한다. 같은 피드백 UI를 두 추천 흐름에서 사용하고, 서버 팀이 구현할 데이터 계약을 앱 저장소에 함께 남긴다. + +## 사용자 흐름 + +### 음악 추천 + +1. 사용자가 추천 플레이리스트를 확인한다. +2. 플레이리스트 시트를 닫으면 추천 피드백 시트가 열린다. +3. 1점부터 5점 중 하나를 선택한다. +4. `별점만 보내기`를 누르거나 의견 입력을 펼친 뒤 `별점과 의견 보내기`를 누른다. +5. 제출이 끝나면 다음 추천에 반영한다는 안내를 보여준다. + +### 추천사진 + +1. 카메라 화면에서 `추천사진`을 선택한다. +2. 리캡 편집 화면에서 추천사진 평가 카드를 확인한다. +3. 사용자가 `별점 남기기`를 누르면 공통 피드백 시트가 열린다. +4. 별점만 보내거나 선택 의견을 함께 보낸다. +5. 평가를 마쳐도 리캡 편집 값과 저장 흐름은 유지된다. + +## 화면 계약 + +- 피드백 시트 제목은 음악과 사진의 대상을 구분해 표시한다. +- 별점을 선택하기 전에는 제출 버튼을 비활성화한다. +- 선택한 별점은 색상과 `n점 선택됨` 문구로 함께 알린다. +- 의견 입력은 기본으로 접어 두고 사용자가 원할 때만 펼친다. +- 의견은 앞뒤 공백을 제거하고 최대 300자로 제한한다. +- 닫기와 건너뛰기는 어떤 이벤트도 제출하지 않는다. +- 제출 버튼을 한 번 누르면 시트를 닫아 중복 제출을 막는다. +- 모든 텍스트는 흰색 계열 또는 브랜드 강조색을 사용한다. + +## 데이터 계약 + +기존 `POST /v1/recommendation-events` 요청을 유지한다. 이벤트의 `type`은 `recommendation_feedback`을 사용하고 `value`에는 버전이 있는 JSON 문자열을 넣는다. + +```ts +type RecommendationFeedbackValue = { + version: 1; + subject: "music" | "photo"; + rating: 1 | 2 | 3 | 4 | 5; + opinion?: string; +}; +``` + +음악 이벤트에는 `playlistId`를 포함한다. 추천사진 이벤트에는 장소 식별자와 장소명과 관광 데이터 출처를 `context`에 포함한다. 사진 URI와 이미지 파일과 GPS 좌표는 피드백 이벤트에 포함하지 않는다. + +## 실패 처리 + +- 앱은 이벤트를 로컬 추천 이벤트 저장소에 먼저 남긴다. +- 인증되지 않았거나 서버 전송이 실패해도 리캡 편집과 음악 탐색을 막지 않는다. +- 서버 수신 성공 여부를 보장할 수 없으므로 사용자 문구는 `서버에 저장 완료` 대신 `의견을 남겼어요`로 표현한다. +- 서버의 구조화 저장과 분석 지원은 별도 서버 요구사항 문서로 전달한다. + +## 수용 조건 + +- 음악 추천에서 1점부터 5점까지 선택할 수 있다. +- 추천사진에서 같은 별점 UI를 열 수 있다. +- 두 흐름 모두 별점만 제출할 수 있다. +- 두 흐름 모두 별점과 300자 이하 의견을 제출할 수 있다. +- 공백만 입력한 의견은 의견 없음으로 저장한다. +- 사진 피드백 이벤트에 사진 URI가 들어가지 않는다. +- 타입 검사와 단위 테스트가 통과한다. +- iOS 시뮬레이터에서 음악과 추천사진 진입과 제출과 닫기를 확인한다. + +## 계획 자체 검토 결과 + +처음에는 추천사진을 고른 직후 피드백 시트를 자동으로 띄우는 방식을 고려했다. 이 방식은 사용자가 사진과 장소와 음악을 확인하는 핵심 편집 흐름을 가린다. 따라서 추천사진을 사용 중이라는 카드와 명시적인 `별점 남기기` CTA를 편집 화면에 제공한다. + +기존 음악 피드백에는 낮은 평가 뒤 무드를 다시 선택하는 단계가 있었다. 이번 요구는 별점만 또는 별점과 의견을 남기는 단순한 흐름이므로 피드백 시트 안의 무드 변경 단계를 제거한다. 음악 화면의 기존 무드 필터는 그대로 유지한다. + +## 시뮬레이터 검증 결과 + +2026년 9월 2일 iOS 18.0 iPhone 16 Pro 시뮬레이터의 개발 빌드에서 아래 항목을 확인했다. + +- 음악 피드백 시트의 별점 미선택 상태와 5점 선택과 별점만 제출이 동작했다. +- 사진 피드백 시트의 4점 선택과 의견 입력과 제출이 동작했다. +- 실제 카메라 화면에서 추천사진을 선택한 뒤 리캡 편집 화면의 `별점 남기기` CTA로 진입했다. +- 실제 추천사진 피드백을 제출한 뒤 편집 화면의 카드가 제출 완료 상태로 바뀌었다. +- 서버가 반환한 관광지 추천사진 한 건은 편집 화면에서 이미지 로드에 실패했다. 피드백 UI와 제출 흐름은 동작했지만 서버 팀이 해당 `imageUrl`의 HTTPS 지원과 외부 접근 가능 여부를 별도로 확인해야 한다. diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index 660bcc6..505d1bf 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -17,6 +17,7 @@ import { SOUNDLOG_TERMS_VERSION } from '@/constants/legal'; import { getApiBaseUrl } from '@/api/client'; import { queryClient } from '@/providers/queryClient'; import { AppText } from '@/components/AppText'; +import { RecommendationFeedbackSheet } from '@/components/home/RecommendationFeedbackSheet'; import { getPublicImageUrl } from '@/utils/publicImageAssets'; import { playlistCurationById } from '@/mocks/playlistMocks'; import { useAuthStore } from '@/store/authStore'; @@ -28,6 +29,10 @@ import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; import { AuthProvider, AuthSession } from '@/types/auth'; import { GeoPoint, PlaceContext, Track, TravelMode } from '@/types/domain'; +import { + type RecommendationFeedbackSubject, + type RecommendationFeedbackSubmission, +} from '@/utils/recommendationFeedback'; const BUTTON_SIZE = 58; const samplePlaylist = playlistCurationById['busan-ocean']; @@ -173,6 +178,9 @@ function DevTestManagerContent() { const [isOpen, setIsOpen] = useState(false); const [isServerAuthPending, setIsServerAuthPending] = useState(false); const [serverAuthMessage, setServerAuthMessage] = useState(); + const [feedbackPreviewSubject, setFeedbackPreviewSubject] = + useState(); + const [feedbackPreviewMessage, setFeedbackPreviewMessage] = useState(); const maxX = Math.max(width - BUTTON_SIZE - 12, 12); const maxY = Math.max(height - BUTTON_SIZE - Math.max(insets.bottom, 12) - 12, 80); const defaultPosition = useMemo( @@ -346,6 +354,17 @@ function DevTestManagerContent() { likedTracks.forEach((record) => removeLikedTrack(record.track.id)); savedTracks.forEach((record) => removeSavedTrack(record.track.id)); }; + const openFeedbackPreview = (subject: RecommendationFeedbackSubject) => { + setFeedbackPreviewSubject(subject); + setFeedbackPreviewMessage(undefined); + setIsOpen(false); + }; + const submitFeedbackPreview = ({ opinion, rating }: RecommendationFeedbackSubmission) => { + setFeedbackPreviewMessage( + opinion?.trim() ? `${rating}점과 의견 입력 확인` : `${rating}점만 입력 확인`, + ); + setFeedbackPreviewSubject(undefined); + }; return ( <> + + openFeedbackPreview('music')} + /> + openFeedbackPreview('photo')} + /> + {feedbackPreviewMessage ? : null} + + navigate('/')} /> navigate('/auth/login')} /> @@ -532,6 +566,18 @@ function DevTestManagerContent() { + setFeedbackPreviewSubject(undefined)} + onSubmit={submitFeedbackPreview} + recommendationReason={ + feedbackPreviewSubject === 'music' + ? '산책 중인 지금, 시원한 무드에 맞춰 추천했어요.' + : undefined + } + subject={feedbackPreviewSubject ?? 'music'} + visible={Boolean(feedbackPreviewSubject)} + /> ); } diff --git a/src/components/home/RecommendationFeedbackSheet.tsx b/src/components/home/RecommendationFeedbackSheet.tsx index 0c317c9..4f79012 100644 --- a/src/components/home/RecommendationFeedbackSheet.tsx +++ b/src/components/home/RecommendationFeedbackSheet.tsx @@ -1,86 +1,106 @@ -import { Feather } from '@expo/vector-icons'; -import { useEffect, useState } from 'react'; -import { Animated, Modal, Pressable, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Feather } from "@expo/vector-icons"; +import { useEffect, useRef, useState } from "react"; +import { + Animated, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { AppText } from '@/components/AppText'; -import { useDismissibleBottomSheetGesture } from '@/hooks/useBottomSheetGesture'; +import { AppText } from "@/components/AppText"; +import { useDismissibleBottomSheetGesture } from "@/hooks/useBottomSheetGesture"; +import { + RECOMMENDATION_FEEDBACK_MAX_LENGTH, + type RecommendationFeedbackRating, + type RecommendationFeedbackSubject, + type RecommendationFeedbackSubmission, +} from "@/utils/recommendationFeedback"; -export type RecommendationFeedbackRating = 'great' | 'not_for_me' | 'okay'; +export type { + RecommendationFeedbackRating, + RecommendationFeedbackSubmission, +} from "@/utils/recommendationFeedback"; type RecommendationFeedbackSheetProps = { + contextLabel?: string; onClose: () => void; - onSubmit: (rating: RecommendationFeedbackRating, moodFilter?: string) => void; - playlistReason?: string; - regionName?: string; + onSubmit: (submission: RecommendationFeedbackSubmission) => void; + recommendationReason?: string; + subject: RecommendationFeedbackSubject; visible: boolean; }; -const moodOptions = [ - { icon: '🌿', label: '잔잔한' }, - { icon: '⚡', label: '신나는' }, - { icon: '🌙', label: '감성적인' }, - { icon: '🌊', label: '시원한' }, -] as const; - -const ratingOptions: Array<{ - description: string; - icon: keyof typeof Feather.glyphMap; - label: string; - value: RecommendationFeedbackRating; -}> = [ - { - description: '지금 장소와 분위기에 잘 맞아요', - icon: 'thumbs-up', - label: '딱 좋아요', - value: 'great', - }, - { - description: '들어볼 만하지만 조금 더 맞출 수 있어요', - icon: 'minus-circle', - label: '괜찮아요', - value: 'okay', +const ratings: RecommendationFeedbackRating[] = [1, 2, 3, 4, 5]; + +const subjectCopy: Record< + RecommendationFeedbackSubject, + { description: string; title: string } +> = { + music: { + description: "다음 음악 추천을 더 잘 맞추는 데 반영할게요.", + title: "이번 음악 추천은 어땠나요?", }, - { - description: '다른 분위기의 추천을 받고 싶어요', - icon: 'refresh-cw', - label: '다른 느낌이 좋아요', - value: 'not_for_me', + photo: { + description: "다음 추천사진을 더 잘 고르는 데 반영할게요.", + title: "이번 추천사진은 어땠나요?", }, -]; +}; export function RecommendationFeedbackSheet({ + contextLabel, onClose, onSubmit, - playlistReason, - regionName, + recommendationReason, + subject, visible, }: RecommendationFeedbackSheetProps) { const insets = useSafeAreaInsets(); - const [isChoosingMood, setIsChoosingMood] = useState(false); + const [isOpinionVisible, setIsOpinionVisible] = useState(false); + const [opinion, setOpinion] = useState(""); + const [rating, setRating] = useState(); + const isSubmittingRef = useRef(false); const { panHandlers, translateY } = useDismissibleBottomSheetGesture({ onDismiss: onClose, visible, }); + const copy = subjectCopy[subject]; useEffect(() => { - if (visible) { - setIsChoosingMood(false); + if (!visible) { + return; } + + setIsOpinionVisible(false); + setOpinion(""); + setRating(undefined); + isSubmittingRef.current = false; }, [visible]); - const handleSelectRating = (rating: RecommendationFeedbackRating) => { - if (rating === 'not_for_me') { - setIsChoosingMood(true); + const handleSubmit = () => { + if (!rating || isSubmittingRef.current) { return; } - onSubmit(rating); + isSubmittingRef.current = true; + onSubmit({ opinion, rating }); }; return ( - - + + - - - - 추천 피드백 - - - {isChoosingMood ? '어떤 느낌으로 바꿔볼까요?' : '이번 추천 어땠나요?'} - - {!isChoosingMood && regionName ? ( - - {regionName}의 사운드트랙을 더 잘 맞추는 데 반영할게요. + + + + + 추천 피드백 + + + {copy.title} - ) : null} + + {contextLabel ? `${contextLabel}에 어울리는 ` : ""} + {copy.description} + + + + + - - - - - {playlistReason && !isChoosingMood ? ( - - - {playlistReason} + {recommendationReason ? ( + + + {recommendationReason} + + + ) : null} + + + + {ratings.map((value) => { + const selected = Boolean(rating && value <= rating); + + return ( + setRating(value)} + > + + + ); + })} + + + {rating ? `${rating}점 선택됨` : "별점을 선택해주세요"} - ) : null} - - {isChoosingMood ? ( - <> - - {moodOptions.map((option) => ( - onSubmit('not_for_me', option.label)} - > - {option.icon} - - 더 {option.label} - - - ))} + + {isOpinionVisible ? ( + + + + 의견 + + + {opinion.length}/{RECOMMENDATION_FEEDBACK_MAX_LENGTH} + + + + ) : ( onSubmit('not_for_me')} + className="mt-5 min-h-11 flex-row items-center justify-center rounded-full border border-white/12 bg-white/[0.04] px-4" + onPress={() => setIsOpinionVisible(true)} > - - 무드 변경 없이 보내기 + + + 의견도 남길래요 - - ) : ( - - {ratingOptions.map((option) => ( - handleSelectRating(option.value)} - > - - - - - {option.label} - - {option.description} - - - - - ))} - - )} + )} - - 건너뛰기 - + + + {isOpinionVisible ? "별점과 의견 보내기" : "별점만 보내기"} + + + + + + 건너뛰기 + + + - + ); } diff --git a/src/components/moment-capture/MomentCaptureScreen.tsx b/src/components/moment-capture/MomentCaptureScreen.tsx index 4caf523..3bac96c 100644 --- a/src/components/moment-capture/MomentCaptureScreen.tsx +++ b/src/components/moment-capture/MomentCaptureScreen.tsx @@ -11,6 +11,10 @@ import { recapApi } from "@/api/recapApi"; import { ApiError } from "@/api/client"; import { recapQueryKeys } from "@/api/recapQueries"; import { AppText } from "@/components/AppText"; +import { + RecommendationFeedbackSheet, + type RecommendationFeedbackSubmission, +} from "@/components/home/RecommendationFeedbackSheet"; import { PageHeader } from "@/components/PageHeader"; import { CameraCaptureView } from "@/components/moment-capture/CameraCaptureView"; import { CameraPermissionState } from "@/components/moment-capture/CameraPermissionState"; @@ -28,6 +32,7 @@ import { useTravelSessionStore } from "@/store/travelSessionStore"; import { GeoPoint, MomentLog, + type PlaceContext, RecapTemplateId, RecapVisibility, } from "@/types/domain"; @@ -35,6 +40,7 @@ import { getForegroundLocationWithTimeout } from "@/utils/location"; import { getMoodTagsFromFilter } from "@/utils/moodTags"; import { pickMomentPhotoFromLibrary } from "@/utils/momentPhotoPicker"; import { createRecommendationEventContext } from "@/utils/recommendationEventContext"; +import { createRecommendationFeedbackValue } from "@/utils/recommendationFeedback"; import { getDistanceMeters } from "@/utils/recapTravelSummary"; type LocationStatus = "denied" | "granted" | "idle" | "loading" | "unavailable"; @@ -81,6 +87,11 @@ export function MomentCaptureScreen() { const [isPickingPhoto, setIsPickingPhoto] = useState(false); const [isSaving, setIsSaving] = useState(false); const [locationStatus, setLocationStatus] = useState("idle"); + const [isPhotoFeedbackVisible, setIsPhotoFeedbackVisible] = useState(false); + const [isPhotoFeedbackSubmitted, setIsPhotoFeedbackSubmitted] = + useState(false); + const [recommendedPhotoPlace, setRecommendedPhotoPlace] = + useState(); const addRecommendationEvent = useRecommendationEventStore( (state) => state.addEvent, @@ -120,7 +131,10 @@ export function MomentCaptureScreen() { // same tick as the first one claims the lock. const isSavingRef = useRef(false); - const prepareReview = (photoUri?: string) => { + const prepareReview = ( + photoUri?: string, + recommendationPlace?: PlaceContext, + ) => { const nextCapturedAt = new Date().toISOString(); setCapturedAt(nextCapturedAt); @@ -131,6 +145,9 @@ export function MomentCaptureScreen() { setReviewMoodTags(moodTags); setRecapVisibility("private"); setShouldSaveMusic(Boolean(currentTrack)); + setRecommendedPhotoPlace(recommendationPlace); + setIsPhotoFeedbackSubmitted(false); + setIsPhotoFeedbackVisible(false); setIsReviewing(true); setErrorMessage(undefined); }; @@ -241,7 +258,36 @@ export function MomentCaptureScreen() { return; } - prepareReview(recommendedPhotoUri); + prepareReview(recommendedPhotoUri, capturePlace); + }; + + const handleSubmitPhotoFeedback = ({ + opinion, + rating, + }: RecommendationFeedbackSubmission) => { + if (!recommendedPhotoPlace) { + return; + } + + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext({ + placeCategory: recommendedPhotoPlace.category, + placeId: recommendedPhotoPlace.id, + placeName: recommendedPhotoPlace.title, + source: `recommended-photo:${recommendedPhotoPlace.source}`, + }), + type: "recommendation_feedback", + value: createRecommendationFeedbackValue({ + opinion, + rating, + subject: "photo", + }), + }), + ); + + setIsPhotoFeedbackSubmitted(true); + setIsPhotoFeedbackVisible(false); }; const handleSave = async () => { @@ -357,34 +403,48 @@ export function MomentCaptureScreen() { if (isReviewing) { return ( - { - setCapturedAt(undefined); - setSaveIdempotencyKey(undefined); - setCapturedPhotoUri(undefined); - setIsReviewing(false); - setErrorMessage(undefined); - }} - onSave={handleSave} - onToggleMusic={() => setShouldSaveMusic((value) => !value)} - photoUri={capturedPhotoUri} - placeName={reviewPlaceName} - selectedTemplate={reviewTemplate} - track={currentTrack} - travelMode={session.status === "active" ? selectedMode : undefined} - visibility={recapVisibility} - /> + <> + setIsPhotoFeedbackVisible(true)} + onRetake={() => { + setCapturedAt(undefined); + setSaveIdempotencyKey(undefined); + setCapturedPhotoUri(undefined); + setRecommendedPhotoPlace(undefined); + setIsPhotoFeedbackVisible(false); + setIsReviewing(false); + setErrorMessage(undefined); + }} + onSave={handleSave} + onToggleMusic={() => setShouldSaveMusic((value) => !value)} + photoUri={capturedPhotoUri} + placeName={reviewPlaceName} + selectedTemplate={reviewTemplate} + track={currentTrack} + travelMode={session.status === "active" ? selectedMode : undefined} + visibility={recapVisibility} + /> + setIsPhotoFeedbackVisible(false)} + onSubmit={handleSubmitPhotoFeedback} + subject="photo" + visible={isPhotoFeedbackVisible} + /> + ); } diff --git a/src/components/moment-capture/MomentReviewPanel.tsx b/src/components/moment-capture/MomentReviewPanel.tsx index 0c06981..f3c70b8 100644 --- a/src/components/moment-capture/MomentReviewPanel.tsx +++ b/src/components/moment-capture/MomentReviewPanel.tsx @@ -53,6 +53,8 @@ type MomentReviewPanelProps = { errorMessage?: string; includeMusic: boolean; isSaving: boolean; + isRecommendedPhoto: boolean; + isRecommendedPhotoFeedbackSubmitted: boolean; location?: GeoPoint; moodTags: MoodTag[]; onChangeMoodTags: (moodTags: MoodTag[]) => void; @@ -60,6 +62,7 @@ type MomentReviewPanelProps = { onChangeTemplate: (template: RecapTemplateId) => void; onChangeVisibility: (visibility: RecapVisibility) => void; onRetake: () => void; + onRateRecommendedPhoto: () => void; onSave: () => void; onToggleMusic: () => void; photoUri?: string; @@ -85,6 +88,8 @@ export const MomentReviewPanel = forwardRef< errorMessage, includeMusic, isSaving, + isRecommendedPhoto, + isRecommendedPhotoFeedbackSubmitted, location, moodTags, onChangeMoodTags, @@ -92,6 +97,7 @@ export const MomentReviewPanel = forwardRef< onChangeTemplate, onChangeVisibility, onRetake, + onRateRecommendedPhoto, onSave, onToggleMusic, photoUri, @@ -105,7 +111,8 @@ export const MomentReviewPanel = forwardRef< ) { const photoCanvasRef = useRef(null); const [isCanvasStickerDragging, setIsCanvasStickerDragging] = useState(false); - const moodLabel = moodTags.map((tag) => moodLabels[tag]).join(", ") || "선택 안 함"; + const moodLabel = + moodTags.map((tag) => moodLabels[tag]).join(", ") || "선택 안 함"; const canToggleMusic = Boolean(track); const travelModeLabel = travelMode ? (travelModeLabels[travelMode] ?? "미설정") @@ -195,6 +202,39 @@ export const MomentReviewPanel = forwardRef< )} + {isRecommendedPhoto ? ( + + + + + + + 추천사진 사용 중 + + + {isRecommendedPhotoFeedbackSubmitted + ? "추천사진 별점을 남겼어요." + : "이 장소와 사진이 잘 어울리는지 알려주세요."} + + + {isRecommendedPhotoFeedbackSubmitted ? ( + + ) : ( + + + 별점 남기기 + + + )} + + ) : null} + @@ -220,7 +260,9 @@ export const MomentReviewPanel = forwardRef< - 공개 범위 + + 공개 범위 + {( [ @@ -300,7 +342,9 @@ export const MomentReviewPanel = forwardRef< {moodLabel} + + {moodLabel} + } title="무드" /> diff --git a/src/utils/recommendationFeedback.test.ts b/src/utils/recommendationFeedback.test.ts new file mode 100644 index 0000000..02eecb8 --- /dev/null +++ b/src/utils/recommendationFeedback.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { + createRecommendationFeedbackValue, + normalizeRecommendationFeedbackOpinion, + RECOMMENDATION_FEEDBACK_MAX_LENGTH, +} from "@/utils/recommendationFeedback"; + +describe("recommendationFeedback", () => { + it("creates a rating-only value", () => { + expect( + JSON.parse( + createRecommendationFeedbackValue({ + rating: 5, + subject: "music", + }), + ), + ).toEqual({ + rating: 5, + subject: "music", + version: 1, + }); + }); + + it("trims an optional opinion", () => { + expect( + JSON.parse( + createRecommendationFeedbackValue({ + opinion: " 산책할 때 잘 어울렸어요. ", + rating: 4, + subject: "photo", + }), + ), + ).toEqual({ + opinion: "산책할 때 잘 어울렸어요.", + rating: 4, + subject: "photo", + version: 1, + }); + }); + + it("removes an opinion that only contains spaces", () => { + expect(normalizeRecommendationFeedbackOpinion(" \n ")).toBeUndefined(); + }); + + it("limits an opinion to the frontend contract", () => { + expect( + normalizeRecommendationFeedbackOpinion( + "가".repeat(RECOMMENDATION_FEEDBACK_MAX_LENGTH + 1), + ), + ).toHaveLength(RECOMMENDATION_FEEDBACK_MAX_LENGTH); + }); +}); diff --git a/src/utils/recommendationFeedback.ts b/src/utils/recommendationFeedback.ts new file mode 100644 index 0000000..a5dbcd9 --- /dev/null +++ b/src/utils/recommendationFeedback.ts @@ -0,0 +1,42 @@ +export const RECOMMENDATION_FEEDBACK_MAX_LENGTH = 300; + +export type RecommendationFeedbackRating = 1 | 2 | 3 | 4 | 5; +export type RecommendationFeedbackSubject = "music" | "photo"; + +export type RecommendationFeedbackSubmission = { + opinion?: string; + rating: RecommendationFeedbackRating; +}; + +export type RecommendationFeedbackValue = RecommendationFeedbackSubmission & { + subject: RecommendationFeedbackSubject; + version: 1; +}; + +export function normalizeRecommendationFeedbackOpinion(opinion?: string) { + const normalizedOpinion = opinion?.trim(); + + if (!normalizedOpinion) { + return undefined; + } + + return normalizedOpinion.slice(0, RECOMMENDATION_FEEDBACK_MAX_LENGTH); +} + +export function createRecommendationFeedbackValue({ + opinion, + rating, + subject, +}: RecommendationFeedbackSubmission & { + subject: RecommendationFeedbackSubject; +}) { + const normalizedOpinion = normalizeRecommendationFeedbackOpinion(opinion); + const value: RecommendationFeedbackValue = { + rating, + subject, + version: 1, + ...(normalizedOpinion ? { opinion: normalizedOpinion } : {}), + }; + + return JSON.stringify(value); +}