From c8a8681a618a6fe3276f80d627019bd79f2a302f Mon Sep 17 00:00:00 2001 From: sb Date: Sat, 11 Jul 2026 23:55:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B0=B0=EB=96=A1=EC=A7=80=EC=88=98=20?= =?UTF-8?q?=ED=94=BC=EB=93=9C=EB=B0=B1=20UI=20(=EC=9A=B0=EB=A6=AC/?= =?UTF-8?q?=EB=8F=99=EB=84=A4=20=EA=B6=81=ED=95=A9=EB=8F=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeedbackDialog: 잘 놀았어요/안 맞았어요 + BAD 사유 선택·추가입력 - 채팅 '산책 완료' 버튼 + 2시간 FEEDBACK_REQUEST 알림 → 피드백 팝업 - AffinityBadges: 동네 찰떡지수(전체), 우리 찰떡지수(매칭 쌍·유료 게이팅) 스팟 메이트/AI 추천 카드 및 우리 강아지 프로필에 노출 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/affinity.ts | 32 ++++++ src/api/matches.ts | 2 +- src/components/AffinityBadges.vue | 53 +++++++++ src/components/FeedbackDialog.vue | 167 +++++++++++++++++++++++++++++ src/components/MatchChatDialog.vue | 12 ++- src/layouts/MainLayout.vue | 36 ++++++- src/pages/HomePage.vue | 13 +++ src/pages/ProfilePage.vue | 22 ++++ src/stores/matching.ts | 10 ++ 9 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 src/api/affinity.ts create mode 100644 src/components/AffinityBadges.vue create mode 100644 src/components/FeedbackDialog.vue diff --git a/src/api/affinity.ts b/src/api/affinity.ts new file mode 100644 index 0000000..cb1ef6f --- /dev/null +++ b/src/api/affinity.ts @@ -0,0 +1,32 @@ +import { http } from './http' + +export type FeedbackVerdict = 'GOOD' | 'BAD' + +/** 동네 찰떡지수 (전체 노출). */ +export interface NeighborhoodScore { + percent: number + label: string +} + +/** + * 우리 찰떡지수 (1회 이상 매칭된 쌍만). + * - locked=true : 무료회원 → "유료회원 전용" + * - matched=false: 매칭 이력 없음 → 미노출 + */ +export interface PairScore { + matched: boolean + locked: boolean + percent: number + label: string +} + +export const affinityApi = { + /** 산책 후 피드백 제출 → 우리/동네 찰떡지수 반영. */ + feedback: (matchId: number, raterDogId: number, verdict: FeedbackVerdict, reasons?: string) => + http.post(`/api/affinity/matches/${matchId}/feedback`, { raterDogId, verdict, reasons }), + /** 동네 찰떡지수. */ + neighborhood: (dogId: number) => http.get(`/api/affinity/neighborhood/${dogId}`), + /** 우리 찰떡지수 — viewerDogId(내 강아지) 기준. */ + pair: (viewerDogId: number, otherDogId: number) => + http.get(`/api/affinity/pair?viewerDogId=${viewerDogId}&otherDogId=${otherDogId}`), +} \ No newline at end of file diff --git a/src/api/matches.ts b/src/api/matches.ts index 85b1b9d..f98c797 100644 --- a/src/api/matches.ts +++ b/src/api/matches.ts @@ -22,7 +22,7 @@ export interface ReceivedMatch { /** STOMP /topic/users/{userId} 로 오는 매칭 알림. */ export interface MatchNotification { - type: 'NEW_REQUEST' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED' + type: 'NEW_REQUEST' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED' | 'FEEDBACK_REQUEST' matchId: number title: string message: string diff --git a/src/components/AffinityBadges.vue b/src/components/AffinityBadges.vue new file mode 100644 index 0000000..fa0312e --- /dev/null +++ b/src/components/AffinityBadges.vue @@ -0,0 +1,53 @@ + + + \ No newline at end of file diff --git a/src/components/FeedbackDialog.vue b/src/components/FeedbackDialog.vue new file mode 100644 index 0000000..25b7631 --- /dev/null +++ b/src/components/FeedbackDialog.vue @@ -0,0 +1,167 @@ + + + + + \ No newline at end of file diff --git a/src/components/MatchChatDialog.vue b/src/components/MatchChatDialog.vue index 7fa90a7..dac34ec 100644 --- a/src/components/MatchChatDialog.vue +++ b/src/components/MatchChatDialog.vue @@ -7,6 +7,9 @@ {{ connected ? '실시간' : '연결 중…' }} + + 산책을 마치고 오늘 어땠는지 남겨주세요 + @@ -55,7 +58,10 @@ import { createMatchChat } from '@/lib/chatSocket' import { useAuthStore } from '@/stores/auth' const props = defineProps<{ modelValue: boolean; matchId: number }>() -const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>() +const emit = defineEmits<{ + (e: 'update:modelValue', v: boolean): void + (e: 'checkout', matchId: number): void +}>() const auth = useAuthStore() @@ -111,6 +117,10 @@ function close() { emit('update:modelValue', false) } +function onCheckout() { + emit('checkout', props.matchId) +} + function isMine(m: ChatMessage) { return m.senderId === auth.member?.id } diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index 4d9d85d..6b0c4ae 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -78,7 +78,20 @@ - + + + + @@ -88,6 +101,7 @@ import { useRouter } from 'vue-router' import { useQuasar } from 'quasar' import AdBanner from '@/components/AdBanner.vue' import MatchChatDialog from '@/components/MatchChatDialog.vue' +import FeedbackDialog from '@/components/FeedbackDialog.vue' import { useSubscriptionStore } from '@/stores/subscription' import { useAuthStore } from '@/stores/auth' import { useDogsStore } from '@/stores/dogs' @@ -105,6 +119,15 @@ const $q = useQuasar() const matchChatOpen = ref(false) const matchChatId = ref(null) +const feedbackOpen = ref(false) +const feedbackMatchId = ref(null) + +/** 채팅 '산책 완료' 버튼 또는 2시간 알림 → 피드백 팝업 열기. */ +function openFeedback(id: number) { + feedbackMatchId.value = id + feedbackOpen.value = true +} + onMounted(async () => { await dogs.loadMyDogs() if (auth.member && dogs.currentDogId != null) { @@ -125,6 +148,17 @@ watch( }, ) +// 2시간 후 피드백 요청 알림 → 찰떡지수 피드백 팝업 +watch( + () => matching.feedbackMatchId, + (id) => { + if (id != null) { + openFeedback(id) + matching.feedbackMatchId = null + } + }, +) + async function onAccept(id: number) { try { await matching.accept(id) diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index 3bb1f54..dcb36ff 100644 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -76,6 +76,12 @@ 궁합 {{ m.score }} {{ m.breed || '견종 미상' }} · {{ m.reason }} + @@ -113,6 +119,12 @@ {{ mate.name }} {{ mate.breed || '견종 미상' }} + {{ current.breed || '견종 미상' }} · {{ sizeLabel(current.size) }} +
+ + 동네 찰떡지수 {{ hoodScore.percent }}% + ({{ hoodScore.label }}) +
@@ -140,6 +145,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { useQuasar } from 'quasar' import { useAuthStore } from '@/stores/auth' import { useDogsStore } from '@/stores/dogs' +import { affinityApi, type NeighborhoodScore } from '@/api/affinity' import { ApiError } from '@/api/http' const $q = useQuasar() @@ -147,6 +153,22 @@ const auth = useAuthStore() const dogs = useDogsStore() const current = computed(() => dogs.currentDog) + +// 내 강아지 동네 찰떡지수 (전체 노출 지표) +const hoodScore = ref(null) +watch( + () => current.value?.id, + async (id) => { + hoodScore.value = null + if (id == null) return + try { + hoodScore.value = await affinityApi.neighborhood(id) + } catch { + hoodScore.value = null + } + }, + { immediate: true }, +) const selected = ref>(new Set()) const saving = ref(false) const createOpen = ref(false) diff --git a/src/stores/matching.ts b/src/stores/matching.ts index 8e060bf..6d39202 100644 --- a/src/stores/matching.ts +++ b/src/stores/matching.ts @@ -15,6 +15,8 @@ export const useMatchingStore = defineStore('matching', () => { const received = ref([]) const sentPendingTargetIds = ref>(new Set()) const openChatMatchId = ref(null) + /** 산책 후 피드백(찰떡지수) 팝업을 띄울 매칭 id — 알림 수신 시 세팅. */ + const feedbackMatchId = ref(null) let socket: ReturnType | null = null let myDogId: number | null = null @@ -57,6 +59,9 @@ export const useMatchingStore = defineStore('matching', () => { if (n.type === 'ACCEPTED') { openChatMatchId.value = n.matchId // 신청자: 수락됨 → 채팅 열기 } + if (n.type === 'FEEDBACK_REQUEST') { + feedbackMatchId.value = n.matchId // 2시간 후: 찰떡지수 피드백 팝업 + } if (myDogId != null) void refresh(myDogId) // 목록/진행중 동기화 } @@ -86,6 +91,7 @@ export const useMatchingStore = defineStore('matching', () => { received, sentPendingTargetIds, openChatMatchId, + feedbackMatchId, connect, disconnect, refresh, @@ -103,6 +109,8 @@ function colorFor(type: MatchNotification['type']): string { case 'REJECTED': case 'EXPIRED': return 'grey-8' + case 'FEEDBACK_REQUEST': + return 'teal' default: return 'primary' } @@ -116,6 +124,8 @@ function iconFor(type: MatchNotification['type']): string { return 'celebration' case 'EXPIRED': return 'schedule' + case 'FEEDBACK_REQUEST': + return 'rate_review' default: return 'heart_broken' }