Compare commits
8 Commits
3bebe77cab
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a7b633a3e5 | |||
| c8a8681a61 | |||
| eb51201669 | |||
| f29d5e106c | |||
| f9d6cf1fd4 | |||
| 0dcec68cfe | |||
| 38ac028c15 | |||
| c3231c59ac |
@@ -1,39 +1,71 @@
|
||||
# 산책갈개 · dognation_PT (Frontend)
|
||||
|
||||
우리 개 성향에 맞는 동네 산책 메이트를 안전하게 찾는 iOS/AOS 앱의 프론트엔드.
|
||||
우리 강아지 성향에 맞는 동네 산책 메이트를 안전하게 찾는 iOS/AOS 앱의 프론트엔드.
|
||||
|
||||
## 스택
|
||||
- **Vue 3 + TypeScript**
|
||||
- **Quasar** (UI, 하늘색 파스텔 테마)
|
||||
- **Vite** (번들러)
|
||||
- **Pinia** (상태관리 · 구독 티어)
|
||||
- **Capacitor** (iOS/AOS 네이티브 패키징)
|
||||
- **Pinia** (상태관리 · 구독 티어 · 매칭)
|
||||
- **Capacitor** (iOS/AOS 네이티브 패키징, SPM)
|
||||
- **카카오 지도** · **STOMP**(실시간 알림/채팅)
|
||||
|
||||
## 시작하기
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # VITE_API_BASE_URL 설정
|
||||
cp .env.example .env # VITE_API_BASE_URL, VITE_KAKAO_MAP_KEY 설정
|
||||
npm run dev # 웹 개발 서버 (http://localhost:9000)
|
||||
npm run typecheck # vue-tsc 타입체크
|
||||
```
|
||||
|
||||
## 빌드
|
||||
## 빌드 / 네이티브
|
||||
```bash
|
||||
npm run build # 타입체크 + 웹 빌드
|
||||
npm run build:app # Capacitor용 빌드 (상대경로)
|
||||
```
|
||||
|
||||
## 네이티브(추가 예정)
|
||||
```bash
|
||||
npx cap add ios
|
||||
npx cap add android
|
||||
npx cap sync
|
||||
npx cap sync ios # 네이티브 동기화
|
||||
```
|
||||
|
||||
## 구조
|
||||
- `src/pages/HomePage.vue` — 스팟 지도 / 체크인 / 한줄평 / 메이트
|
||||
- `src/pages/ProfilePage.vue` — 강아지 프로필 / 댕비티아이 성향 태그 / 구독 티어
|
||||
- `src/stores/subscription.ts` — 구독 티어 상태 (광고 노출·매칭 제한 파생)
|
||||
- `src/components/AdBanner.vue` — FREE 티어 배너 광고 (v-if 제어)
|
||||
- `src/quasar-variables.scss` — 하늘색 파스텔 브랜드 컬러
|
||||
- `src/pages/HomePage.vue` — 지도 / 체크인 / 자주 오는 견종 / AI 추천 찰떡 메이트 / 한줄평 / 스팟 메이트
|
||||
- `src/pages/ProfilePage.vue` — 강아지 프로필(중성화 필수) / 댕비티아이 성향 태그 / 동네 찰떡지수
|
||||
- `src/layouts/MainLayout.vue` — 헤더(알림 벨) / 탭바 / 매칭 채팅·피드백 다이얼로그
|
||||
- `src/components/`
|
||||
- `KakaoMap.vue` — 카카오 지도(마커·최근접 스팟 선택·확대/축소)
|
||||
- `MatchChatDialog.vue` — 매칭 1:1 채팅 + '산책 완료' 체크아웃
|
||||
- `FeedbackDialog.vue` — 산책 후 피드백(잘 놀았어요/안 맞았어요 + 사유)
|
||||
- `AffinityBadges.vue` — 동네/우리 찰떡지수 배지(유료 게이팅)
|
||||
- `AdBanner.vue` — FREE 티어 배너 광고
|
||||
- `src/stores/` — `subscription`(티어) · `matching`(알림/진행중/피드백) · `dogs` · `auth`
|
||||
- `src/api/` — `spots` · `matches` · `affinity` · `dogs` · `chat`
|
||||
|
||||
> 백엔드: [dognation_BT](https://gitlab.sblog.kr/sb/dognation_BT)
|
||||
---
|
||||
|
||||
## 오늘 진행 내역 (2026-07-11 ~ 07-12)
|
||||
|
||||
### 지도 · 스팟
|
||||
- 지도 **네이버 → 카카오 전환**(네이버 코드 유지), 확대/축소, 지도 클릭 시 **최근접 스팟 선택**
|
||||
- 체크인 후 **AI 추천 찰떡 메이트** 표시 (`AI 궁합 추천` → `AI 추천 찰떡 메이트`, 안내 문구 추가)
|
||||
- 도착 버튼 아래 **'이 시간대 자주 보이는 견종'** 한 줄 표시(실패해도 화면 안 깨짐)
|
||||
- 로딩 문구 → **'친구 찾는 중'**
|
||||
|
||||
### 매칭 · 채팅
|
||||
- 매칭 플로우 UI — 진행중 표시 · 실시간 알림 · 수락/거절 · **1:1 채팅**
|
||||
- 체크인 옆 스팟 채팅 버튼 제거(매칭 1:1 채팅만 유지)
|
||||
|
||||
### 찰떡지수 피드백 (신규)
|
||||
- `FeedbackDialog` — 잘 놀았어요/안 맞았어요, BAD 시 사유 선택 + 추가입력
|
||||
- 채팅 **'산책 완료' 버튼** & 2시간 **`FEEDBACK_REQUEST` 알림** → 피드백 팝업
|
||||
- `AffinityBadges` — **동네 찰떡지수**(전체 노출) · **우리 찰떡지수**(매칭 쌍, 무료회원은 "유료회원 전용")
|
||||
스팟 메이트·AI 추천 카드 및 우리 강아지 프로필에 노출
|
||||
|
||||
### 강아지 프로필
|
||||
- 등록 시 **중성화 여부 필수 선택**, AI 추천에 중성화 조건 옵션 연동
|
||||
- UI 문구 **'우리 개' → '우리 강아지'**
|
||||
|
||||
### UI · 네이티브 정리
|
||||
- 로그인 화면 QLayout 래핑(빈 화면 수정)
|
||||
- iOS **상/하단 이중 safe-area 여백 제거**(contentInset never), 광고 배너를 footer 안으로
|
||||
- input 포커스 시 가로 스크롤/확대 방지
|
||||
- **관리자 콘솔을 별도 프로젝트(dognation_AT)로 분리** — PT에서 관리자 코드 제거
|
||||
|
||||
> 백엔드: [dognation_BT](https://gitlab.sblog.kr/sb/dognation_BT)
|
||||
+4
-1
@@ -2,7 +2,10 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#6DB3E8" />
|
||||
<title>산책갈개</title>
|
||||
</head>
|
||||
|
||||
@@ -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<void>(`/api/affinity/matches/${matchId}/feedback`, { raterDogId, verdict, reasons }),
|
||||
/** 동네 찰떡지수. */
|
||||
neighborhood: (dogId: number) => http.get<NeighborhoodScore>(`/api/affinity/neighborhood/${dogId}`),
|
||||
/** 우리 찰떡지수 — viewerDogId(내 강아지) 기준. */
|
||||
pair: (viewerDogId: number, otherDogId: number) =>
|
||||
http.get<PairScore>(`/api/affinity/pair?viewerDogId=${viewerDogId}&otherDogId=${otherDogId}`),
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+7
-2
@@ -40,8 +40,13 @@ export const spotsApi = {
|
||||
list: () => http.get<Spot[]>('/api/spots'),
|
||||
mates: (spotId: number, userId?: number) =>
|
||||
http.get<Mate[]>(`/api/spots/${spotId}/mates${userId ? `?userId=${userId}` : ''}`),
|
||||
aiMates: (spotId: number, dogId: number) =>
|
||||
http.get<AiMate[]>(`/api/spots/${spotId}/ai-mates?dogId=${dogId}`),
|
||||
aiMates: (spotId: number, dogId: number, neutered?: boolean) =>
|
||||
http.get<AiMate[]>(
|
||||
`/api/spots/${spotId}/ai-mates?dogId=${dogId}` +
|
||||
(neutered != null ? `&neutered=${neutered}` : ''),
|
||||
),
|
||||
frequentBreeds: (spotId: number) =>
|
||||
http.get<{ breeds: string[] }>(`/api/spots/${spotId}/frequent-breeds`),
|
||||
reviews: (spotId: number) => http.get<Review[]>(`/api/spots/${spotId}/reviews`),
|
||||
checkIn: (spotId: number, userId: number, dogId: number) =>
|
||||
http.post<CheckInResult>(`/api/spots/${spotId}/checkins`, { userId, dogId }),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="column q-gutter-xs">
|
||||
<!-- 동네 찰떡지수 (전체 노출) -->
|
||||
<div v-if="hood" class="text-caption row items-center no-wrap">
|
||||
<q-icon name="groups" size="14px" color="teal" class="q-mr-xs" />
|
||||
<span class="text-weight-medium text-teal-8">동네 찰떡지수 {{ hood.percent }}%</span>
|
||||
<span class="text-grey-6 q-ml-xs">({{ hood.label }})</span>
|
||||
</div>
|
||||
|
||||
<!-- 우리 찰떡지수 (매칭 이력 있는 쌍만) -->
|
||||
<div v-if="pair?.matched" class="text-caption row items-center no-wrap">
|
||||
<q-icon name="favorite" size="14px" color="pink-5" class="q-mr-xs" />
|
||||
<template v-if="pair.locked">
|
||||
<span class="text-weight-medium text-pink-6">우리 찰떡지수</span>
|
||||
<q-badge color="amber-7" class="q-ml-xs" text-color="white">유료회원 전용</q-badge>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-weight-medium text-pink-6">우리 찰떡지수 {{ pair.percent }}%</span>
|
||||
<span class="text-grey-6 q-ml-xs">({{ pair.label }})</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { affinityApi, type NeighborhoodScore, type PairScore } from '@/api/affinity'
|
||||
|
||||
const props = defineProps<{ myDogId: number; otherDogId: number }>()
|
||||
|
||||
const hood = ref<NeighborhoodScore | null>(null)
|
||||
const pair = ref<PairScore | null>(null)
|
||||
|
||||
watch(
|
||||
() => [props.myDogId, props.otherDogId] as const,
|
||||
() => void load(),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function load() {
|
||||
// 부가 정보 — 실패해도 카드 나머지는 정상 표시
|
||||
try {
|
||||
hood.value = await affinityApi.neighborhood(props.otherDogId)
|
||||
} catch {
|
||||
hood.value = null
|
||||
}
|
||||
try {
|
||||
pair.value = await affinityApi.pair(props.myDogId, props.otherDogId)
|
||||
} catch {
|
||||
pair.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<q-dialog :model-value="modelValue" @update:model-value="onDialog" persistent>
|
||||
<q-card class="feedback-card">
|
||||
<q-card-section class="text-center q-pb-none">
|
||||
<div class="text-h6 text-weight-bold">오늘 산책 어떠셨나요?</div>
|
||||
<div v-if="otherDogName" class="text-caption text-grey-7 q-mt-xs">
|
||||
{{ otherDogName }}와의 산책을 평가해 주세요
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- 1단계: 잘 놀았어요 / 안 맞았어요 -->
|
||||
<q-card-section v-if="!showReasons" class="q-gutter-sm">
|
||||
<q-btn
|
||||
class="full-width verdict-btn"
|
||||
unelevated
|
||||
color="positive"
|
||||
no-caps
|
||||
:loading="submitting"
|
||||
@click="submitGood"
|
||||
>
|
||||
<q-icon name="sentiment_very_satisfied" size="26px" class="q-mr-sm" />
|
||||
잘 놀았어요
|
||||
</q-btn>
|
||||
<q-btn
|
||||
class="full-width verdict-btn"
|
||||
outline
|
||||
color="grey-8"
|
||||
no-caps
|
||||
:disable="submitting"
|
||||
@click="showReasons = true"
|
||||
>
|
||||
<q-icon name="sentiment_dissatisfied" size="26px" class="q-mr-sm" />
|
||||
안 맞았어요
|
||||
</q-btn>
|
||||
</q-card-section>
|
||||
|
||||
<!-- 2단계: 안 맞았어요 사유 -->
|
||||
<q-card-section v-else>
|
||||
<div class="text-subtitle2 q-mb-sm">어떤 점이 아쉬웠나요? (선택)</div>
|
||||
<q-option-group
|
||||
v-model="selectedReasons"
|
||||
:options="reasonOptions"
|
||||
type="checkbox"
|
||||
color="primary"
|
||||
/>
|
||||
<q-input
|
||||
v-model="extraReason"
|
||||
class="q-mt-sm"
|
||||
dense
|
||||
outlined
|
||||
type="textarea"
|
||||
rows="2"
|
||||
maxlength="200"
|
||||
placeholder="추가로 남기고 싶은 점이 있다면 적어주세요"
|
||||
/>
|
||||
<div class="row q-gutter-sm q-mt-md">
|
||||
<q-btn flat color="grey-7" label="뒤로" no-caps :disable="submitting" @click="showReasons = false" />
|
||||
<q-btn
|
||||
class="col"
|
||||
unelevated
|
||||
color="primary"
|
||||
label="제출"
|
||||
no-caps
|
||||
:loading="submitting"
|
||||
@click="submitBad"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { affinityApi } from '@/api/affinity'
|
||||
import { ApiError } from '@/api/http'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
matchId: number
|
||||
raterDogId: number
|
||||
otherDogName?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
const $q = useQuasar()
|
||||
|
||||
const showReasons = ref(false)
|
||||
const submitting = ref(false)
|
||||
const selectedReasons = ref<string[]>([])
|
||||
const extraReason = ref('')
|
||||
|
||||
const reasonOptions = [
|
||||
{ label: '강아지 성향이 안 맞아요', value: '강아지 성향이 안 맞아요' },
|
||||
{ label: '간식/장난감 때문에 으르릉거려요', value: '간식/장난감 때문에 으르릉거려요' },
|
||||
{ label: '일방적으로 입질/공격성을 보였어요', value: '일방적으로 입질/공격성을 보였어요' },
|
||||
{ label: '보호자 매너가 아쉬워요', value: '보호자 매너가 아쉬워요' },
|
||||
]
|
||||
|
||||
// 열릴 때마다 초기화
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
showReasons.value = false
|
||||
selectedReasons.value = []
|
||||
extraReason.value = ''
|
||||
submitting.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function onDialog(v: boolean) {
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
async function submitGood() {
|
||||
await submit('GOOD')
|
||||
}
|
||||
|
||||
async function submitBad() {
|
||||
const reasons = [...selectedReasons.value]
|
||||
const extra = extraReason.value.trim()
|
||||
if (extra) reasons.push(extra)
|
||||
await submit('BAD', reasons.join(', '))
|
||||
}
|
||||
|
||||
async function submit(verdict: 'GOOD' | 'BAD', reasons?: string) {
|
||||
submitting.value = true
|
||||
try {
|
||||
await affinityApi.feedback(props.matchId, props.raterDogId, verdict, reasons)
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
icon: 'favorite',
|
||||
message: '소중한 피드백 고마워요! 찰떡지수에 반영했어요.',
|
||||
position: 'top',
|
||||
timeout: 2500,
|
||||
})
|
||||
emit('submitted')
|
||||
emit('update:modelValue', false)
|
||||
} catch (e) {
|
||||
// 이미 제출한 경우(409) 등은 조용히 닫기
|
||||
const message = e instanceof ApiError ? e.message : '피드백 전송에 실패했어요.'
|
||||
$q.notify({ color: 'grey-8', icon: 'info', message, position: 'top', timeout: 2500 })
|
||||
emit('update:modelValue', false)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feedback-card {
|
||||
width: 340px;
|
||||
max-width: 90vw;
|
||||
border-radius: 16px;
|
||||
}
|
||||
.verdict-btn {
|
||||
height: 56px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,9 @@
|
||||
<q-chip dense :color="connected ? 'positive' : 'grey-5'" text-color="white">
|
||||
{{ connected ? '실시간' : '연결 중…' }}
|
||||
</q-chip>
|
||||
<q-btn flat dense no-caps icon="event_available" label="산책 완료" class="q-mr-xs" @click="onCheckout">
|
||||
<q-tooltip>산책을 마치고 오늘 어땠는지 남겨주세요</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat round dense icon="close" @click="close" />
|
||||
</q-toolbar>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// 전역 스타일 — 하늘색 파스텔 배경
|
||||
html,
|
||||
body {
|
||||
background: #F4FAFF;
|
||||
// input 포커스/키보드 시 가로 스크롤(오버플로) 방지
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.text-brand {
|
||||
|
||||
@@ -78,7 +78,20 @@
|
||||
</q-footer>
|
||||
|
||||
<!-- 매칭 수락 시 1:1 채팅 -->
|
||||
<MatchChatDialog v-if="matchChatId != null" v-model="matchChatOpen" :match-id="matchChatId" />
|
||||
<MatchChatDialog
|
||||
v-if="matchChatId != null"
|
||||
v-model="matchChatOpen"
|
||||
:match-id="matchChatId"
|
||||
@checkout="openFeedback"
|
||||
/>
|
||||
|
||||
<!-- 산책 후 찰떡지수 피드백 -->
|
||||
<FeedbackDialog
|
||||
v-if="feedbackMatchId != null && dogs.currentDogId != null"
|
||||
v-model="feedbackOpen"
|
||||
:match-id="feedbackMatchId"
|
||||
:rater-dog-id="dogs.currentDogId"
|
||||
/>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
|
||||
const feedbackOpen = ref(false)
|
||||
const feedbackMatchId = ref<number | null>(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)
|
||||
|
||||
+56
-5
@@ -23,14 +23,46 @@
|
||||
@click="onCheckIn"
|
||||
/>
|
||||
|
||||
<!-- 이 시간대 자주 오는 견종 (통계) -->
|
||||
<div
|
||||
v-if="frequentBreeds.length"
|
||||
class="text-caption text-grey-7 q-mt-sm row items-center no-wrap"
|
||||
>
|
||||
<q-icon name="pets" size="16px" color="primary" class="q-mr-xs" />
|
||||
이 시간대 자주 보이는 견종 · {{ frequentBreeds.join(', ') }}
|
||||
</div>
|
||||
|
||||
<!-- AI 궁합 추천 (체크인 후, 이 스팟의 강아지 중 사이좋게 지낼 만한 목록) -->
|
||||
<div v-if="aiLoading || aiTried" class="q-mt-lg">
|
||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">
|
||||
<q-icon name="auto_awesome" color="primary" class="q-mr-xs" />AI 궁합 추천
|
||||
<div class="q-mb-sm">
|
||||
<span class="text-subtitle1 text-weight-bold">
|
||||
<q-icon name="auto_awesome" color="primary" class="q-mr-xs" />AI 추천 찰떡 메이트
|
||||
</span>
|
||||
<span class="text-caption text-grey-5 q-ml-xs">· AI 분석으로 실제와 다를 수 있어요</span>
|
||||
</div>
|
||||
<!-- 찾기 옵션: 중성화 조건 -->
|
||||
<div class="row items-center q-mb-sm">
|
||||
<span class="text-caption text-grey-7 q-mr-sm">중성화</span>
|
||||
<q-btn-toggle
|
||||
v-model="aiNeutered"
|
||||
dense
|
||||
unelevated
|
||||
no-caps
|
||||
size="sm"
|
||||
toggle-color="primary"
|
||||
color="grey-3"
|
||||
text-color="grey-8"
|
||||
:options="[
|
||||
{ label: '상관없음', value: 'any' },
|
||||
{ label: '완료', value: 'yes' },
|
||||
{ label: '안 함', value: 'no' },
|
||||
]"
|
||||
@update:model-value="loadAiMates"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="aiLoading" class="row items-center q-gutter-sm text-grey-6">
|
||||
<q-spinner-dots size="24px" color="primary" />
|
||||
<span class="text-caption">사이좋게 지낼 강아지를 찾는 중…</span>
|
||||
<span class="text-caption">친구 찾는 중…</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<q-card v-for="m in aiMates" :key="m.dogId" flat bordered class="q-mb-sm">
|
||||
@@ -44,6 +76,12 @@
|
||||
<q-badge color="primary" class="q-ml-xs">궁합 {{ m.score }}</q-badge>
|
||||
</q-item-label>
|
||||
<q-item-label caption>{{ m.breed || '견종 미상' }} · {{ m.reason }}</q-item-label>
|
||||
<AffinityBadges
|
||||
v-if="dogs.currentDogId != null"
|
||||
class="q-mt-xs"
|
||||
:my-dog-id="dogs.currentDogId"
|
||||
:other-dog-id="m.dogId"
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card>
|
||||
@@ -81,6 +119,12 @@
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">{{ mate.name }}</q-item-label>
|
||||
<q-item-label caption>{{ mate.breed || '견종 미상' }}</q-item-label>
|
||||
<AffinityBadges
|
||||
v-if="dogs.currentDogId != null"
|
||||
class="q-mt-xs"
|
||||
:my-dog-id="dogs.currentDogId"
|
||||
:other-dog-id="mate.dogId"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
@@ -118,6 +162,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useDogsStore } from '@/stores/dogs'
|
||||
import { useMatchingStore } from '@/stores/matching'
|
||||
import KakaoMap from '@/components/KakaoMap.vue'
|
||||
import AffinityBadges from '@/components/AffinityBadges.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
const auth = useAuthStore()
|
||||
@@ -131,6 +176,7 @@ const spots = ref<Spot[]>([])
|
||||
const currentSpot = ref<Spot | null>(null)
|
||||
const reviews = ref<Review[]>([])
|
||||
const mates = ref<Mate[]>([])
|
||||
const frequentBreeds = ref<string[]>([])
|
||||
const checkedIn = ref(false)
|
||||
const checkingIn = ref(false)
|
||||
const matchingDogId = ref<number | null>(null)
|
||||
@@ -140,6 +186,7 @@ const loadError = ref(false)
|
||||
const aiMates = ref<AiMate[]>([])
|
||||
const aiLoading = ref(false)
|
||||
const aiTried = ref(false)
|
||||
const aiNeutered = ref<'any' | 'yes' | 'no'>('any') // AI 찾기 옵션: 중성화 조건
|
||||
|
||||
onMounted(load)
|
||||
|
||||
@@ -169,12 +216,15 @@ async function onSelectSpot(spotId: number) {
|
||||
}
|
||||
|
||||
async function refreshSpot(spotId: number) {
|
||||
const [r, m] = await Promise.all([
|
||||
const [r, m, fb] = await Promise.all([
|
||||
spotsApi.reviews(spotId),
|
||||
spotsApi.mates(spotId, currentUserId()),
|
||||
// 통계 엔드포인트는 부가 정보 — 실패(구버전 백엔드 등)해도 나머지 화면은 정상 표시
|
||||
spotsApi.frequentBreeds(spotId).catch(() => ({ breeds: [] })),
|
||||
])
|
||||
reviews.value = r
|
||||
mates.value = m
|
||||
frequentBreeds.value = fb.breeds
|
||||
}
|
||||
|
||||
async function onCheckIn() {
|
||||
@@ -202,8 +252,9 @@ async function loadAiMates() {
|
||||
if (!currentSpot.value || dogs.currentDogId == null) return
|
||||
aiLoading.value = true
|
||||
aiTried.value = true
|
||||
const neutered = aiNeutered.value === 'yes' ? true : aiNeutered.value === 'no' ? false : undefined
|
||||
try {
|
||||
aiMates.value = await spotsApi.aiMates(currentSpot.value.id, dogs.currentDogId)
|
||||
aiMates.value = await spotsApi.aiMates(currentSpot.value.id, dogs.currentDogId, neutered)
|
||||
} catch {
|
||||
aiMates.value = []
|
||||
} finally {
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
<div class="text-caption text-grey-7">
|
||||
{{ current.breed || '견종 미상' }}<span v-if="current.size"> · {{ sizeLabel(current.size) }}</span>
|
||||
</div>
|
||||
<div v-if="hoodScore" class="text-caption row items-center no-wrap q-mt-xs">
|
||||
<q-icon name="groups" size="14px" color="teal" class="q-mr-xs" />
|
||||
<span class="text-weight-medium text-teal-8">동네 찰떡지수 {{ hoodScore.percent }}%</span>
|
||||
<span class="text-grey-6 q-ml-xs">({{ hoodScore.label }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<q-space />
|
||||
<q-btn dense flat round color="grey-6" icon="delete_outline" @click="onDelete" />
|
||||
@@ -102,6 +107,22 @@
|
||||
emit-value
|
||||
map-options
|
||||
/>
|
||||
<div>
|
||||
<div class="text-caption text-grey-7 q-mb-xs">중성화 여부 <span class="text-negative">*</span></div>
|
||||
<q-btn-toggle
|
||||
v-model="form.neutered"
|
||||
spread
|
||||
unelevated
|
||||
no-caps
|
||||
toggle-color="primary"
|
||||
color="grey-3"
|
||||
text-color="grey-8"
|
||||
:options="[
|
||||
{ label: '중성화 완료', value: true },
|
||||
{ label: '안 함', value: false },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="취소" v-close-popup />
|
||||
@@ -110,7 +131,7 @@
|
||||
unelevated
|
||||
label="등록"
|
||||
:loading="creating"
|
||||
:disable="!form.name"
|
||||
:disable="!form.name || form.neutered === null"
|
||||
@click="onCreate"
|
||||
/>
|
||||
</q-card-actions>
|
||||
@@ -124,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()
|
||||
@@ -131,6 +153,22 @@ const auth = useAuthStore()
|
||||
const dogs = useDogsStore()
|
||||
|
||||
const current = computed(() => dogs.currentDog)
|
||||
|
||||
// 내 강아지 동네 찰떡지수 (전체 노출 지표)
|
||||
const hoodScore = ref<NeighborhoodScore | null>(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<Set<number>>(new Set())
|
||||
const saving = ref(false)
|
||||
const createOpen = ref(false)
|
||||
@@ -141,10 +179,16 @@ const sizeOptions = [
|
||||
{ label: '중형견', value: 'MEDIUM' },
|
||||
{ label: '대형견', value: 'LARGE' },
|
||||
]
|
||||
const form = reactive<{ name: string; breed: string; size: string | null }>({
|
||||
const form = reactive<{
|
||||
name: string
|
||||
breed: string
|
||||
size: string | null
|
||||
neutered: boolean | null
|
||||
}>({
|
||||
name: '',
|
||||
breed: '',
|
||||
size: null,
|
||||
neutered: null,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -208,13 +252,20 @@ function openCreate() {
|
||||
form.name = ''
|
||||
form.breed = ''
|
||||
form.size = null
|
||||
form.neutered = null
|
||||
createOpen.value = true
|
||||
}
|
||||
|
||||
async function onCreate() {
|
||||
if (form.neutered === null) return
|
||||
creating.value = true
|
||||
try {
|
||||
await dogs.createDog({ name: form.name, breed: form.breed || null, size: form.size })
|
||||
await dogs.createDog({
|
||||
name: form.name,
|
||||
breed: form.breed || null,
|
||||
size: form.size,
|
||||
neutered: form.neutered,
|
||||
})
|
||||
createOpen.value = false
|
||||
$q.notify({ color: 'primary', message: `${form.name} 등록 완료!`, icon: 'pets', position: 'top' })
|
||||
} catch (e) {
|
||||
|
||||
@@ -15,6 +15,8 @@ export const useMatchingStore = defineStore('matching', () => {
|
||||
const received = ref<ReceivedMatch[]>([])
|
||||
const sentPendingTargetIds = ref<Set<number>>(new Set())
|
||||
const openChatMatchId = ref<number | null>(null)
|
||||
/** 산책 후 피드백(찰떡지수) 팝업을 띄울 매칭 id — 알림 수신 시 세팅. */
|
||||
const feedbackMatchId = ref<number | null>(null)
|
||||
|
||||
let socket: ReturnType<typeof createUserNotifications> | 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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user