Compare commits
19 Commits
08743892d4
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ce66565b04 | |||
| 08a88f9198 | |||
| 8386974ec9 | |||
| d4839678d1 | |||
| f2d29a838a | |||
| 8457f83f17 | |||
| 7dc13cbb24 | |||
| 7dec505d08 | |||
| 7f18d18a11 | |||
| a7b633a3e5 | |||
| c8a8681a61 | |||
| eb51201669 | |||
| f29d5e106c | |||
| f9d6cf1fd4 | |||
| 0dcec68cfe | |||
| 38ac028c15 | |||
| c3231c59ac | |||
| 3bebe77cab | |||
| eac3098680 |
@@ -1,39 +1,71 @@
|
|||||||
# 산책갈개 · dognation_PT (Frontend)
|
# 산책갈개 · dognation_PT (Frontend)
|
||||||
|
|
||||||
우리 개 성향에 맞는 동네 산책 메이트를 안전하게 찾는 iOS/AOS 앱의 프론트엔드.
|
우리 강아지 성향에 맞는 동네 산책 메이트를 안전하게 찾는 iOS/AOS 앱의 프론트엔드.
|
||||||
|
|
||||||
## 스택
|
## 스택
|
||||||
- **Vue 3 + TypeScript**
|
- **Vue 3 + TypeScript**
|
||||||
- **Quasar** (UI, 하늘색 파스텔 테마)
|
- **Quasar** (UI, 하늘색 파스텔 테마)
|
||||||
- **Vite** (번들러)
|
- **Vite** (번들러)
|
||||||
- **Pinia** (상태관리 · 구독 티어)
|
- **Pinia** (상태관리 · 구독 티어 · 매칭)
|
||||||
- **Capacitor** (iOS/AOS 네이티브 패키징)
|
- **Capacitor** (iOS/AOS 네이티브 패키징, SPM)
|
||||||
|
- **카카오 지도** · **STOMP**(실시간 알림/채팅)
|
||||||
|
|
||||||
## 시작하기
|
## 시작하기
|
||||||
```bash
|
```bash
|
||||||
npm install
|
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 dev # 웹 개발 서버 (http://localhost:9000)
|
||||||
|
npm run typecheck # vue-tsc 타입체크
|
||||||
```
|
```
|
||||||
|
|
||||||
## 빌드
|
## 빌드 / 네이티브
|
||||||
```bash
|
```bash
|
||||||
npm run build # 타입체크 + 웹 빌드
|
npm run build # 타입체크 + 웹 빌드
|
||||||
npm run build:app # Capacitor용 빌드 (상대경로)
|
npm run build:app # Capacitor용 빌드 (상대경로)
|
||||||
```
|
npx cap sync ios # 네이티브 동기화
|
||||||
|
|
||||||
## 네이티브(추가 예정)
|
|
||||||
```bash
|
|
||||||
npx cap add ios
|
|
||||||
npx cap add android
|
|
||||||
npx cap sync
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 구조
|
## 구조
|
||||||
- `src/pages/HomePage.vue` — 스팟 지도 / 체크인 / 한줄평 / 메이트
|
- `src/pages/HomePage.vue` — 지도 / 체크인 / 자주 오는 견종 / AI 추천 찰떡 메이트 / 한줄평 / 스팟 메이트
|
||||||
- `src/pages/ProfilePage.vue` — 강아지 프로필 / 댕비티아이 성향 태그 / 구독 티어
|
- `src/pages/ProfilePage.vue` — 강아지 프로필(중성화 필수) / 댕비티아이 성향 태그 / 동네 찰떡지수
|
||||||
- `src/stores/subscription.ts` — 구독 티어 상태 (광고 노출·매칭 제한 파생)
|
- `src/layouts/MainLayout.vue` — 헤더(알림 벨) / 탭바 / 매칭 채팅·피드백 다이얼로그
|
||||||
- `src/components/AdBanner.vue` — FREE 티어 배너 광고 (v-if 제어)
|
- `src/components/`
|
||||||
- `src/quasar-variables.scss` — 하늘색 파스텔 브랜드 컬러
|
- `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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 오늘 진행 내역 (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)
|
> 백엔드: [dognation_BT](https://gitlab.sblog.kr/sb/dognation_BT)
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import type { CapacitorConfig } from '@capacitor/cli'
|
import type { CapacitorConfig } from '@capacitor/cli'
|
||||||
|
|
||||||
const config: CapacitorConfig = {
|
const config: CapacitorConfig = {
|
||||||
appId: 'kr.sblog.dognation',
|
appId: 'com.dog.dognation',
|
||||||
appName: '산책갈개',
|
appName: '산책갈개',
|
||||||
webDir: 'dist',
|
webDir: 'dist',
|
||||||
ios: {
|
ios: {
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,10 @@
|
|||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="theme-color" content="#6DB3E8" />
|
||||||
<title>산책갈개</title>
|
<title>산책갈개</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Generated
+13
@@ -12,6 +12,7 @@
|
|||||||
"@capacitor/app": "^8.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.3.4",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
"@capgo/capacitor-social-login": "^8.3.36",
|
||||||
"@quasar/extras": "^1.16.12",
|
"@quasar/extras": "^1.16.12",
|
||||||
"@stomp/stompjs": "^7.3.0",
|
"@stomp/stompjs": "^7.3.0",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
@@ -187,6 +188,18 @@
|
|||||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@capgo/capacitor-social-login": {
|
||||||
|
"version": "8.3.36",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capgo/capacitor-social-login/-/capacitor-social-login-8.3.36.tgz",
|
||||||
|
"integrity": "sha512-Y4HYjEJablzbHHwPBrbC2UvHVPsR0bAyCAqBzVBoUEOf0UFHAHyTP3gD9JUTKP/iERU8I+6IA2RWtLqDePCNuA==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@capacitor/app": "^8.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.3.4",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
"@capgo/capacitor-social-login": "^8.3.36",
|
||||||
"@quasar/extras": "^1.16.12",
|
"@quasar/extras": "^1.16.12",
|
||||||
"@stomp/stompjs": "^7.3.0",
|
"@stomp/stompjs": "^7.3.0",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ export interface Dog {
|
|||||||
gender: string | null
|
gender: string | null
|
||||||
neutered: boolean
|
neutered: boolean
|
||||||
imageUrl: string | null
|
imageUrl: string | null
|
||||||
|
prefGender: string
|
||||||
|
prefAge: string
|
||||||
|
prefNeutered: string
|
||||||
traits: TraitTag[]
|
traits: TraitTag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +30,9 @@ export interface DogSaveRequest {
|
|||||||
gender?: string | null
|
gender?: string | null
|
||||||
neutered?: boolean
|
neutered?: boolean
|
||||||
imageUrl?: string | null
|
imageUrl?: string | null
|
||||||
|
prefGender?: string
|
||||||
|
prefAge?: string
|
||||||
|
prefNeutered?: string
|
||||||
traitIds?: number[]
|
traitIds?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ export interface ReceivedMatch {
|
|||||||
|
|
||||||
/** STOMP /topic/users/{userId} 로 오는 매칭 알림. */
|
/** STOMP /topic/users/{userId} 로 오는 매칭 알림. */
|
||||||
export interface MatchNotification {
|
export interface MatchNotification {
|
||||||
type: 'NEW_REQUEST' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED'
|
type: 'NEW_REQUEST' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED' | 'FEEDBACK_REQUEST'
|
||||||
matchId: number
|
matchId: number
|
||||||
title: string
|
title: string
|
||||||
message: string
|
message: string
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export const spotsApi = {
|
|||||||
http.get<Mate[]>(`/api/spots/${spotId}/mates${userId ? `?userId=${userId}` : ''}`),
|
http.get<Mate[]>(`/api/spots/${spotId}/mates${userId ? `?userId=${userId}` : ''}`),
|
||||||
aiMates: (spotId: number, dogId: number) =>
|
aiMates: (spotId: number, dogId: number) =>
|
||||||
http.get<AiMate[]>(`/api/spots/${spotId}/ai-mates?dogId=${dogId}`),
|
http.get<AiMate[]>(`/api/spots/${spotId}/ai-mates?dogId=${dogId}`),
|
||||||
|
frequentBreeds: (spotId: number) =>
|
||||||
|
http.get<{ breeds: string[] }>(`/api/spots/${spotId}/frequent-breeds`),
|
||||||
reviews: (spotId: number) => http.get<Review[]>(`/api/spots/${spotId}/reviews`),
|
reviews: (spotId: number) => http.get<Review[]>(`/api/spots/${spotId}/reviews`),
|
||||||
checkIn: (spotId: number, userId: number, dogId: number) =>
|
checkIn: (spotId: number, userId: number, dogId: number) =>
|
||||||
http.post<CheckInResult>(`/api/spots/${spotId}/checkins`, { userId, dogId }),
|
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>
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<template>
|
|
||||||
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" maximized>
|
|
||||||
<q-card class="column no-wrap">
|
|
||||||
<q-toolbar class="bg-primary text-white">
|
|
||||||
<q-icon name="chat" class="q-mr-sm" />
|
|
||||||
<q-toolbar-title>{{ spotName }} 채팅</q-toolbar-title>
|
|
||||||
<q-chip dense :color="connected ? 'positive' : 'grey-5'" text-color="white">
|
|
||||||
{{ connected ? '실시간' : '연결 중…' }}
|
|
||||||
</q-chip>
|
|
||||||
<q-btn flat round dense icon="close" @click="close" />
|
|
||||||
</q-toolbar>
|
|
||||||
|
|
||||||
<!-- 메시지 목록 -->
|
|
||||||
<q-scroll-area ref="scrollArea" class="col q-pa-md" style="background: #f4faff">
|
|
||||||
<div v-if="!messages.length" class="text-center text-grey-5 q-mt-lg">
|
|
||||||
첫 메시지를 남겨보세요. 이 스팟에 체크인한 이웃들과 대화해요.
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="m in messages"
|
|
||||||
:key="m.id"
|
|
||||||
class="q-mb-sm row"
|
|
||||||
:class="isMine(m) ? 'justify-end' : 'justify-start'"
|
|
||||||
>
|
|
||||||
<div :class="isMine(m) ? 'msg mine' : 'msg'">
|
|
||||||
<div v-if="!isMine(m)" class="text-caption text-weight-medium text-accent">
|
|
||||||
{{ m.senderNickname }}
|
|
||||||
</div>
|
|
||||||
<div>{{ m.content }}</div>
|
|
||||||
<div class="text-caption text-grey-6 text-right">{{ time(m.createdAt) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-scroll-area>
|
|
||||||
|
|
||||||
<!-- 입력 -->
|
|
||||||
<div class="row q-pa-sm q-gutter-sm bg-white items-center">
|
|
||||||
<q-input
|
|
||||||
v-model="draft"
|
|
||||||
class="col"
|
|
||||||
dense
|
|
||||||
outlined
|
|
||||||
placeholder="메시지 입력…"
|
|
||||||
maxlength="1000"
|
|
||||||
:disable="!connected"
|
|
||||||
@keyup.enter="send"
|
|
||||||
/>
|
|
||||||
<q-btn round color="primary" icon="send" :disable="!connected || !draft.trim()" @click="send" />
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</q-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { QScrollArea, useQuasar } from 'quasar'
|
|
||||||
import { chatApi, type ChatMessage } from '@/api/chat'
|
|
||||||
import { createSpotChat } from '@/lib/chatSocket'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean; spotId: number; spotName: string }>()
|
|
||||||
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
|
|
||||||
|
|
||||||
const $q = useQuasar()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
|
||||||
const draft = ref('')
|
|
||||||
const connected = ref(false)
|
|
||||||
const scrollArea = ref<QScrollArea | null>(null)
|
|
||||||
let chat: ReturnType<typeof createSpotChat> | null = null
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
async (open) => {
|
|
||||||
if (open) await start()
|
|
||||||
else stop()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onBeforeUnmount(stop)
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
messages.value = []
|
|
||||||
try {
|
|
||||||
messages.value = await chatApi.history(props.spotId)
|
|
||||||
scrollToBottom()
|
|
||||||
} catch {
|
|
||||||
// 히스토리 실패해도 실시간 연결은 시도
|
|
||||||
}
|
|
||||||
chat = createSpotChat(
|
|
||||||
props.spotId,
|
|
||||||
(m) => {
|
|
||||||
messages.value.push(m)
|
|
||||||
scrollToBottom()
|
|
||||||
},
|
|
||||||
(c) => (connected.value = c),
|
|
||||||
)
|
|
||||||
chat.activate()
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
chat?.deactivate()
|
|
||||||
chat = null
|
|
||||||
connected.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function send() {
|
|
||||||
const text = draft.value.trim()
|
|
||||||
if (!text || !chat) return
|
|
||||||
chat.send(text)
|
|
||||||
draft.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMine(m: ChatMessage) {
|
|
||||||
return m.senderId === auth.member?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
function time(iso: string) {
|
|
||||||
const d = new Date(iso)
|
|
||||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom() {
|
|
||||||
nextTick(() => {
|
|
||||||
const el = scrollArea.value
|
|
||||||
if (el) el.setScrollPercentage('vertical', 1, 150)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.msg {
|
|
||||||
max-width: 76%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #e3eef7;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.msg.mine {
|
|
||||||
background: #d7ecfb;
|
|
||||||
border-color: #cfe8fb;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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">
|
<q-chip dense :color="connected ? 'positive' : 'grey-5'" text-color="white">
|
||||||
{{ connected ? '실시간' : '연결 중…' }}
|
{{ connected ? '실시간' : '연결 중…' }}
|
||||||
</q-chip>
|
</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-btn flat round dense icon="close" @click="close" />
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
|
|
||||||
@@ -55,7 +58,10 @@ import { createMatchChat } from '@/lib/chatSocket'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean; matchId: number }>()
|
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()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
@@ -111,6 +117,10 @@ function close() {
|
|||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onCheckout() {
|
||||||
|
emit('checkout', props.matchId)
|
||||||
|
}
|
||||||
|
|
||||||
function isMine(m: ChatMessage) {
|
function isMine(m: ChatMessage) {
|
||||||
return m.senderId === auth.member?.id
|
return m.senderId === auth.member?.id
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-5
@@ -1,18 +1,26 @@
|
|||||||
// 전역 스타일 — 하늘색 파스텔 배경
|
// 전역 스타일 — 하늘색 파스텔 배경
|
||||||
|
html,
|
||||||
body {
|
body {
|
||||||
background: #F4FAFF;
|
background: #F4FAFF;
|
||||||
|
// input 포커스/키보드 시 가로 스크롤(오버플로) 방지
|
||||||
|
overflow-x: hidden;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-brand {
|
.text-brand {
|
||||||
color: #3E92CC;
|
color: #3E92CC;
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS 노치/홈바 safe-area 대응
|
// 토스트(Notify)가 상단 노치/상태바에 겹치지 않도록 safe-area 만큼 아래로.
|
||||||
// 다이나믹 아일랜드는 화면 가운데에만 있어 헤더 양옆(제목·아이콘)은 full inset 이 불필요.
|
.q-notifications__list--top {
|
||||||
// 상태바/홈바 최소 여백만 남기고 축소해 상/하단 빈 공간을 줄인다.
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 노치/상태바/홈바 safe-area 대응 (edge-to-edge 웹뷰).
|
||||||
|
// 헤더는 상태바 높이만큼, 풋터는 홈 인디케이터 높이만큼 콘텐츠를 내부로 밀어 겹침 방지.
|
||||||
.safe-top {
|
.safe-top {
|
||||||
padding-top: max(24px, calc(env(safe-area-inset-top) - 8px));
|
padding-top: env(safe-area-inset-top);
|
||||||
}
|
}
|
||||||
.safe-bottom {
|
.safe-bottom {
|
||||||
padding-bottom: max(6px, calc(env(safe-area-inset-bottom) - 14px));
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,12 +73,25 @@
|
|||||||
class="text-grey-6"
|
class="text-grey-6"
|
||||||
>
|
>
|
||||||
<q-route-tab name="home" :to="{ name: 'home' }" icon="map" label="산책 스팟" />
|
<q-route-tab name="home" :to="{ name: 'home' }" icon="map" label="산책 스팟" />
|
||||||
<q-route-tab name="profile" :to="{ name: 'profile' }" icon="pets" label="우리 개" />
|
<q-route-tab name="profile" :to="{ name: 'profile' }" icon="pets" label="우리 강아지" />
|
||||||
</q-tabs>
|
</q-tabs>
|
||||||
</q-footer>
|
</q-footer>
|
||||||
|
|
||||||
<!-- 매칭 수락 시 1:1 채팅 -->
|
<!-- 매칭 수락 시 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>
|
</q-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -88,6 +101,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import AdBanner from '@/components/AdBanner.vue'
|
import AdBanner from '@/components/AdBanner.vue'
|
||||||
import MatchChatDialog from '@/components/MatchChatDialog.vue'
|
import MatchChatDialog from '@/components/MatchChatDialog.vue'
|
||||||
|
import FeedbackDialog from '@/components/FeedbackDialog.vue'
|
||||||
import { useSubscriptionStore } from '@/stores/subscription'
|
import { useSubscriptionStore } from '@/stores/subscription'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useDogsStore } from '@/stores/dogs'
|
import { useDogsStore } from '@/stores/dogs'
|
||||||
@@ -105,6 +119,15 @@ const $q = useQuasar()
|
|||||||
const matchChatOpen = ref(false)
|
const matchChatOpen = ref(false)
|
||||||
const matchChatId = ref<number | null>(null)
|
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 () => {
|
onMounted(async () => {
|
||||||
await dogs.loadMyDogs()
|
await dogs.loadMyDogs()
|
||||||
if (auth.member && dogs.currentDogId != null) {
|
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) {
|
async function onAccept(id: number) {
|
||||||
try {
|
try {
|
||||||
await matching.accept(id)
|
await matching.accept(id)
|
||||||
|
|||||||
@@ -24,42 +24,6 @@ function stompClient(onConnect: (c: Client) => void, onStatus?: (connected: bool
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 스팟 채팅용 STOMP 클라이언트.
|
|
||||||
* connect 후 스팟 토픽을 구독하고, send 로 메시지를 전송한다.
|
|
||||||
*/
|
|
||||||
export function createSpotChat(
|
|
||||||
spotId: number,
|
|
||||||
onMessage: (m: ChatMessage) => void,
|
|
||||||
onStatus?: (connected: boolean) => void,
|
|
||||||
) {
|
|
||||||
const client = new Client({
|
|
||||||
brokerURL: wsUrl(),
|
|
||||||
connectHeaders: { Authorization: `Bearer ${tokenStore.get() ?? ''}` },
|
|
||||||
reconnectDelay: 3000,
|
|
||||||
onConnect: () => {
|
|
||||||
onStatus?.(true)
|
|
||||||
client.subscribe(`/topic/spots/${spotId}`, (msg: IMessage) => {
|
|
||||||
onMessage(JSON.parse(msg.body) as ChatMessage)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onDisconnect: () => onStatus?.(false),
|
|
||||||
onWebSocketClose: () => onStatus?.(false),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
activate: () => client.activate(),
|
|
||||||
deactivate: () => client.deactivate(),
|
|
||||||
send: (content: string) => {
|
|
||||||
client.publish({
|
|
||||||
destination: `/app/spots/${spotId}/chat`,
|
|
||||||
body: JSON.stringify({ content }),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
isConnected: () => client.connected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 매칭 수락으로 열린 1:1 채팅용 STOMP 클라이언트. */
|
/** 매칭 수락으로 열린 1:1 채팅용 STOMP 클라이언트. */
|
||||||
export function createMatchChat(
|
export function createMatchChat(
|
||||||
matchId: number,
|
matchId: number,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Capacitor } from '@capacitor/core'
|
||||||
|
import { SocialLogin } from '@capgo/capacitor-social-login'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네이티브(Capacitor) 구글 로그인.
|
||||||
|
* 웹뷰에서는 GIS(팝업/FedCM)가 동작하지 않으므로, 앱에서는 이 네이티브 흐름을 사용한다.
|
||||||
|
* 받은 ID 토큰은 웹 GIS 와 동일하게 백엔드 /api/auth/google 로 검증한다.
|
||||||
|
* - Android: serverClientId(webClientId) 로 토큰을 받으므로 aud = 웹 클라 ID
|
||||||
|
* - iOS: iOS 클라 ID 로 로그인 → aud = iOS 클라 ID (백엔드가 web/iOS 둘 다 허용)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// iOS 구글 클라이언트 ID (공개값). iOS 네이티브 로그인용.
|
||||||
|
const IOS_CLIENT_ID = '913571348421-5a9k5kosv2mfosskcc3tllv5h1h9nfrn.apps.googleusercontent.com'
|
||||||
|
|
||||||
|
let initializedFor = ''
|
||||||
|
|
||||||
|
/** 네이티브 플랫폼(iOS/Android) 여부 — 웹은 기존 GIS 사용. */
|
||||||
|
export function isNativeGoogleAvailable(): boolean {
|
||||||
|
return Capacitor.isNativePlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 네이티브 구글 로그인 → ID 토큰 반환.
|
||||||
|
* @param webClientId 백엔드가 aud 검증에 쓰는 웹 클라이언트 ID (serverClientId)
|
||||||
|
*/
|
||||||
|
export async function signInWithGoogle(webClientId: string): Promise<string> {
|
||||||
|
if (initializedFor !== webClientId) {
|
||||||
|
const google: {
|
||||||
|
webClientId: string
|
||||||
|
iOSClientId?: string
|
||||||
|
iOSServerClientId?: string
|
||||||
|
} = { webClientId }
|
||||||
|
if (Capacitor.getPlatform() === 'ios') {
|
||||||
|
google.iOSClientId = IOS_CLIENT_ID
|
||||||
|
google.iOSServerClientId = webClientId
|
||||||
|
}
|
||||||
|
await SocialLogin.initialize({ google })
|
||||||
|
initializedFor = webClientId
|
||||||
|
}
|
||||||
|
// scopes 를 넘기면 플러그인이 MainActivity 수정을 요구한다.
|
||||||
|
// 기본 로그인(Credential Manager)은 email·profile 이 ID 토큰에 포함되므로 scopes 불필요.
|
||||||
|
const res = await SocialLogin.login({
|
||||||
|
provider: 'google',
|
||||||
|
options: {},
|
||||||
|
})
|
||||||
|
const result = res.result as { idToken?: string | null }
|
||||||
|
if (!result?.idToken) {
|
||||||
|
throw new Error('구글 ID 토큰을 받지 못했습니다.')
|
||||||
|
}
|
||||||
|
return result.idToken
|
||||||
|
}
|
||||||
+41
-8
@@ -23,14 +23,29 @@
|
|||||||
@click="onCheckIn"
|
@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 궁합 추천 (체크인 후, 이 스팟의 강아지 중 사이좋게 지낼 만한 목록) -->
|
<!-- AI 궁합 추천 (체크인 후, 이 스팟의 강아지 중 사이좋게 지낼 만한 목록) -->
|
||||||
<div v-if="aiLoading || aiTried" class="q-mt-lg">
|
<div v-if="aiLoading || aiTried" class="q-mt-lg">
|
||||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">
|
<div class="q-mb-sm">
|
||||||
<q-icon name="auto_awesome" color="primary" class="q-mr-xs" />AI 궁합 추천
|
<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="text-caption text-grey-6 q-mb-sm">
|
||||||
|
추천 조건은 <span class="text-primary">우리 강아지 > AI 추천 조건</span>에서 설정해요
|
||||||
</div>
|
</div>
|
||||||
<div v-if="aiLoading" class="row items-center q-gutter-sm text-grey-6">
|
<div v-if="aiLoading" class="row items-center q-gutter-sm text-grey-6">
|
||||||
<q-spinner-dots size="24px" color="primary" />
|
<q-spinner-dots size="24px" color="primary" />
|
||||||
<span class="text-caption">사이좋게 지낼 강아지를 찾는 중…</span>
|
<span class="text-caption">친구 찾는 중…</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<q-card v-for="m in aiMates" :key="m.dogId" flat bordered class="q-mb-sm">
|
<q-card v-for="m in aiMates" :key="m.dogId" flat bordered class="q-mb-sm">
|
||||||
@@ -44,6 +59,12 @@
|
|||||||
<q-badge color="primary" class="q-ml-xs">궁합 {{ m.score }}</q-badge>
|
<q-badge color="primary" class="q-ml-xs">궁합 {{ m.score }}</q-badge>
|
||||||
</q-item-label>
|
</q-item-label>
|
||||||
<q-item-label caption>{{ m.breed || '견종 미상' }} · {{ m.reason }}</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-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-card>
|
</q-card>
|
||||||
@@ -81,6 +102,12 @@
|
|||||||
<q-item-section>
|
<q-item-section>
|
||||||
<q-item-label class="text-weight-medium">{{ mate.name }}</q-item-label>
|
<q-item-label class="text-weight-medium">{{ mate.name }}</q-item-label>
|
||||||
<q-item-label caption>{{ mate.breed || '견종 미상' }}</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>
|
||||||
<q-item-section side>
|
<q-item-section side>
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -118,6 +145,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useDogsStore } from '@/stores/dogs'
|
import { useDogsStore } from '@/stores/dogs'
|
||||||
import { useMatchingStore } from '@/stores/matching'
|
import { useMatchingStore } from '@/stores/matching'
|
||||||
import KakaoMap from '@/components/KakaoMap.vue'
|
import KakaoMap from '@/components/KakaoMap.vue'
|
||||||
|
import AffinityBadges from '@/components/AffinityBadges.vue'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -131,6 +159,7 @@ const spots = ref<Spot[]>([])
|
|||||||
const currentSpot = ref<Spot | null>(null)
|
const currentSpot = ref<Spot | null>(null)
|
||||||
const reviews = ref<Review[]>([])
|
const reviews = ref<Review[]>([])
|
||||||
const mates = ref<Mate[]>([])
|
const mates = ref<Mate[]>([])
|
||||||
|
const frequentBreeds = ref<string[]>([])
|
||||||
const checkedIn = ref(false)
|
const checkedIn = ref(false)
|
||||||
const checkingIn = ref(false)
|
const checkingIn = ref(false)
|
||||||
const matchingDogId = ref<number | null>(null)
|
const matchingDogId = ref<number | null>(null)
|
||||||
@@ -169,18 +198,21 @@ async function onSelectSpot(spotId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSpot(spotId: number) {
|
async function refreshSpot(spotId: number) {
|
||||||
const [r, m] = await Promise.all([
|
const [r, m, fb] = await Promise.all([
|
||||||
spotsApi.reviews(spotId),
|
spotsApi.reviews(spotId),
|
||||||
spotsApi.mates(spotId, currentUserId()),
|
spotsApi.mates(spotId, currentUserId()),
|
||||||
|
// 통계 엔드포인트는 부가 정보 — 실패(구버전 백엔드 등)해도 나머지 화면은 정상 표시
|
||||||
|
spotsApi.frequentBreeds(spotId).catch(() => ({ breeds: [] })),
|
||||||
])
|
])
|
||||||
reviews.value = r
|
reviews.value = r
|
||||||
mates.value = m
|
mates.value = m
|
||||||
|
frequentBreeds.value = fb.breeds
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCheckIn() {
|
async function onCheckIn() {
|
||||||
if (!currentSpot.value) return
|
if (!currentSpot.value) return
|
||||||
if (dogs.currentDogId == null) {
|
if (dogs.currentDogId == null) {
|
||||||
$q.notify({ color: 'warning', message: '먼저 우리 개를 등록해 주세요.', icon: 'pets', position: 'top' })
|
$q.notify({ color: 'warning', message: '먼저 우리 강아지를 등록해 주세요.', icon: 'pets', position: 'top' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
checkingIn.value = true
|
checkingIn.value = true
|
||||||
@@ -188,7 +220,7 @@ async function onCheckIn() {
|
|||||||
await spotsApi.checkIn(currentSpot.value.id, currentUserId(), dogs.currentDogId)
|
await spotsApi.checkIn(currentSpot.value.id, currentUserId(), dogs.currentDogId)
|
||||||
checkedIn.value = true
|
checkedIn.value = true
|
||||||
await refreshSpot(currentSpot.value.id)
|
await refreshSpot(currentSpot.value.id)
|
||||||
$q.notify({ color: 'primary', message: '체크인 완료! 스팟 채팅이 열렸어요.', icon: 'place', position: 'top' })
|
$q.notify({ color: 'primary', message: '체크인 완료!', icon: 'place', position: 'top' })
|
||||||
loadAiMates() // 체크인 후 AI 궁합 추천 (비동기)
|
loadAiMates() // 체크인 후 AI 궁합 추천 (비동기)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyError(e)
|
notifyError(e)
|
||||||
@@ -197,7 +229,8 @@ async function onCheckIn() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 체크인 후 AI 궁합 추천 로드 — 이 스팟의 강아지 중 내 개와 사이좋게 지낼 만한 목록. */
|
/** 체크인 후 AI 궁합 추천 로드 — 이 스팟의 강아지 중 내 개와 사이좋게 지낼 만한 목록.
|
||||||
|
* 추천 조건(성별/나이/중성화)은 내 강아지 프로필에 저장된 값을 백엔드가 사용한다. */
|
||||||
async function loadAiMates() {
|
async function loadAiMates() {
|
||||||
if (!currentSpot.value || dogs.currentDogId == null) return
|
if (!currentSpot.value || dogs.currentDogId == null) return
|
||||||
aiLoading.value = true
|
aiLoading.value = true
|
||||||
@@ -213,7 +246,7 @@ async function loadAiMates() {
|
|||||||
|
|
||||||
async function onMatch(mate: Mate) {
|
async function onMatch(mate: Mate) {
|
||||||
if (dogs.currentDogId == null) {
|
if (dogs.currentDogId == null) {
|
||||||
$q.notify({ color: 'warning', message: '먼저 우리 개를 등록해 주세요.', icon: 'pets', position: 'top' })
|
$q.notify({ color: 'warning', message: '먼저 우리 강아지를 등록해 주세요.', icon: 'pets', position: 'top' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
matchingDogId.value = mate.dogId
|
matchingDogId.value = mate.dogId
|
||||||
|
|||||||
+41
-5
@@ -7,13 +7,25 @@
|
|||||||
<q-avatar size="88px" color="white" text-color="primary" icon="pets" />
|
<q-avatar size="88px" color="white" text-color="primary" icon="pets" />
|
||||||
<div class="text-h4 text-weight-bold text-white q-mt-md">산책갈개</div>
|
<div class="text-h4 text-weight-bold text-white q-mt-md">산책갈개</div>
|
||||||
<div class="text-white q-mt-xs" style="opacity: 0.9">
|
<div class="text-white q-mt-xs" style="opacity: 0.9">
|
||||||
우리 개 성향에 맞는 동네 산책 메이트
|
우리 강아지 성향에 맞는 동네 산책 메이트
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<q-card flat class="login-card q-pa-lg">
|
<q-card flat class="login-card q-pa-lg">
|
||||||
<!-- 구글 로그인 버튼(GIS 렌더링 영역) -->
|
<!-- 구글 로그인: 앱(네이티브)은 버튼, 웹은 GIS 렌더링 -->
|
||||||
<div ref="googleBtn" class="flex flex-center q-mb-md"></div>
|
<q-btn
|
||||||
|
v-if="isNative"
|
||||||
|
class="full-width q-py-sm q-mb-md"
|
||||||
|
color="white"
|
||||||
|
text-color="grey-9"
|
||||||
|
unelevated
|
||||||
|
icon="mdi-google"
|
||||||
|
label="Google로 계속하기"
|
||||||
|
:loading="googleLoading"
|
||||||
|
:disable="!googleClientId"
|
||||||
|
@click="onGoogleNative"
|
||||||
|
/>
|
||||||
|
<div v-else ref="googleBtn" class="flex flex-center q-mb-md"></div>
|
||||||
<div v-if="googleError" class="text-caption text-negative text-center q-mb-md">
|
<div v-if="googleError" class="text-caption text-negative text-center q-mb-md">
|
||||||
{{ googleError }}
|
{{ googleError }}
|
||||||
</div>
|
</div>
|
||||||
@@ -63,6 +75,7 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { authApi } from '@/api/auth'
|
import { authApi } from '@/api/auth'
|
||||||
import { ApiError } from '@/api/http'
|
import { ApiError } from '@/api/http'
|
||||||
import { isAppleSignInAvailable, signInWithApple } from '@/lib/appleAuth'
|
import { isAppleSignInAvailable, signInWithApple } from '@/lib/appleAuth'
|
||||||
|
import { isNativeGoogleAvailable, signInWithGoogle } from '@/lib/googleAuth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -71,6 +84,9 @@ const auth = useAuthStore()
|
|||||||
|
|
||||||
const googleBtn = ref<HTMLElement | null>(null)
|
const googleBtn = ref<HTMLElement | null>(null)
|
||||||
const googleError = ref('')
|
const googleError = ref('')
|
||||||
|
const googleClientId = ref('')
|
||||||
|
const googleLoading = ref(false)
|
||||||
|
const isNative = isNativeGoogleAvailable()
|
||||||
const appleLoading = ref(false)
|
const appleLoading = ref(false)
|
||||||
const appleAvailable = isAppleSignInAvailable()
|
const appleAvailable = isAppleSignInAvailable()
|
||||||
const devLoading = ref(false)
|
const devLoading = ref(false)
|
||||||
@@ -85,6 +101,9 @@ async function initGoogle() {
|
|||||||
googleError.value = '구글 로그인이 아직 설정되지 않았습니다.'
|
googleError.value = '구글 로그인이 아직 설정되지 않았습니다.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
googleClientId.value = clientId
|
||||||
|
// 앱(네이티브)은 버튼 클릭 시 네이티브 로그인 → GIS 초기화 불필요
|
||||||
|
if (isNative) return
|
||||||
await loadGsi()
|
await loadGsi()
|
||||||
const google = (window as unknown as { google: any }).google
|
const google = (window as unknown as { google: any }).google
|
||||||
google.accounts.id.initialize({
|
google.accounts.id.initialize({
|
||||||
@@ -124,6 +143,21 @@ function loadGsi(): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onGoogleNative() {
|
||||||
|
if (!googleClientId.value) return
|
||||||
|
googleLoading.value = true
|
||||||
|
try {
|
||||||
|
const idToken = await signInWithGoogle(googleClientId.value)
|
||||||
|
await auth.loginGoogle(idToken)
|
||||||
|
redirectAfterLogin()
|
||||||
|
} catch (e) {
|
||||||
|
if (isAppleCancel(e)) return
|
||||||
|
notifyError(e)
|
||||||
|
} finally {
|
||||||
|
googleLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onApple() {
|
async function onApple() {
|
||||||
appleLoading.value = true
|
appleLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -164,8 +198,10 @@ function redirectAfterLogin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notifyError(e: unknown) {
|
function notifyError(e: unknown) {
|
||||||
const message = e instanceof ApiError ? e.message : '로그인에 실패했습니다.'
|
let message = '로그인에 실패했습니다.'
|
||||||
$q.notify({ color: 'negative', message, icon: 'error', position: 'top' })
|
if (e instanceof ApiError) message = e.message
|
||||||
|
else if (e instanceof Error && e.message) message = e.message // 네이티브 플러그인 오류 노출(진단용)
|
||||||
|
$q.notify({ color: 'negative', message, icon: 'error', position: 'top', timeout: 6000 })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
+292
-20
@@ -22,13 +22,13 @@
|
|||||||
<q-card v-if="!dogs.myDogs.length" flat bordered class="q-pa-lg text-center text-grey-6">
|
<q-card v-if="!dogs.myDogs.length" flat bordered class="q-pa-lg text-center text-grey-6">
|
||||||
<q-icon name="pets" size="40px" color="secondary" />
|
<q-icon name="pets" size="40px" color="secondary" />
|
||||||
<div class="q-mt-sm q-mb-md">아직 등록된 강아지가 없어요.</div>
|
<div class="q-mt-sm q-mb-md">아직 등록된 강아지가 없어요.</div>
|
||||||
<q-btn color="primary" unelevated icon="add" label="우리 개 등록" @click="openCreate" />
|
<q-btn color="primary" unelevated icon="add" label="우리 강아지 등록" @click="openCreate" />
|
||||||
</q-card>
|
</q-card>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 강아지 선택 (여러 마리) -->
|
<!-- 강아지 선택 (여러 마리) -->
|
||||||
<div class="row items-center q-mb-sm">
|
<div class="row items-center q-mb-sm">
|
||||||
<div class="text-subtitle1 text-weight-bold">우리 개</div>
|
<div class="text-subtitle1 text-weight-bold">우리 강아지</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn dense flat color="primary" icon="add" label="추가" @click="openCreate" />
|
<q-btn dense flat color="primary" icon="add" label="추가" @click="openCreate" />
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +53,14 @@
|
|||||||
<div class="q-ml-md">
|
<div class="q-ml-md">
|
||||||
<div class="text-h6">{{ current.name }}</div>
|
<div class="text-h6">{{ current.name }}</div>
|
||||||
<div class="text-caption text-grey-7">
|
<div class="text-caption text-grey-7">
|
||||||
{{ current.breed || '견종 미상' }}<span v-if="current.size"> · {{ sizeLabel(current.size) }}</span>
|
{{ current.breed || '견종 미상' }}<span v-if="current.size"> · {{ sizeLabel(current.size) }}</span
|
||||||
|
><span v-if="current.gender"> · {{ genderLabel(current.gender) }}</span
|
||||||
|
><span v-if="dogAge(current.birthDate)"> · {{ dogAge(current.birthDate) }}</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>
|
||||||
</div>
|
</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
@@ -75,11 +82,47 @@
|
|||||||
{{ tag.label }}
|
{{ tag.label }}
|
||||||
</q-chip>
|
</q-chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 추천 조건 (원하는 메이트 조건 → AI 분석 반영) -->
|
||||||
|
<div class="text-subtitle1 text-weight-bold q-mb-xs row items-center">
|
||||||
|
<q-icon name="auto_awesome" color="primary" size="20px" class="q-mr-xs" />AI 추천 조건
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-6 q-mb-sm">
|
||||||
|
AI 추천 찰떡 메이트를 찾을 때 이 조건을 반영해요
|
||||||
|
</div>
|
||||||
|
<div class="q-mb-sm">
|
||||||
|
<div class="text-caption text-grey-7 q-mb-xs">선호 성별</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="prefGender"
|
||||||
|
spread dense unelevated no-caps size="sm"
|
||||||
|
toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
|
:options="prefGenderOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="q-mb-sm">
|
||||||
|
<div class="text-caption text-grey-7 q-mb-xs">선호 연령</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="prefAge"
|
||||||
|
spread dense unelevated no-caps size="sm"
|
||||||
|
toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
|
:options="prefAgeOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="q-mb-md">
|
||||||
|
<div class="text-caption text-grey-7 q-mb-xs">선호 중성화</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="prefNeutered"
|
||||||
|
spread dense unelevated no-caps size="sm"
|
||||||
|
toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
|
:options="prefNeuteredOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<q-btn
|
<q-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
unelevated
|
unelevated
|
||||||
icon="save"
|
icon="save"
|
||||||
label="성향 저장"
|
label="저장"
|
||||||
:loading="saving"
|
:loading="saving"
|
||||||
:disable="!dirty"
|
:disable="!dirty"
|
||||||
@click="saveTraits"
|
@click="saveTraits"
|
||||||
@@ -89,19 +132,120 @@
|
|||||||
<!-- 강아지 등록 다이얼로그 -->
|
<!-- 강아지 등록 다이얼로그 -->
|
||||||
<q-dialog v-model="createOpen">
|
<q-dialog v-model="createOpen">
|
||||||
<q-card style="min-width: 300px">
|
<q-card style="min-width: 300px">
|
||||||
<q-card-section class="text-subtitle1 text-weight-bold">우리 개 등록</q-card-section>
|
<q-card-section class="text-subtitle1 text-weight-bold">우리 강아지 등록</q-card-section>
|
||||||
<q-card-section class="q-gutter-sm">
|
<q-card-section class="q-gutter-sm">
|
||||||
<q-input v-model="form.name" label="이름 *" dense outlined />
|
<q-input v-model="form.name" label="이름 *" dense outlined />
|
||||||
<q-input v-model="form.breed" label="견종" dense outlined />
|
<q-input v-model="form.breed" label="견종" dense outlined />
|
||||||
<q-select
|
|
||||||
v-model="form.size"
|
<div>
|
||||||
:options="sizeOptions"
|
<div class="text-caption text-grey-7 q-mb-xs">크기</div>
|
||||||
label="크기"
|
<q-btn-toggle
|
||||||
dense
|
v-model="form.size"
|
||||||
outlined
|
spread
|
||||||
emit-value
|
unelevated
|
||||||
map-options
|
no-caps
|
||||||
/>
|
toggle-color="primary"
|
||||||
|
color="grey-3"
|
||||||
|
text-color="grey-8"
|
||||||
|
:options="sizeOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-xs">성별</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="form.gender"
|
||||||
|
spread
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
toggle-color="primary"
|
||||||
|
color="grey-3"
|
||||||
|
text-color="grey-8"
|
||||||
|
:options="[
|
||||||
|
{ label: '남아', value: 'MALE' },
|
||||||
|
{ label: '여아', value: 'FEMALE' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-xs">
|
||||||
|
연령<span v-if="ageText" class="text-primary q-ml-xs">· {{ ageText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<q-select
|
||||||
|
v-model="form.birthYear"
|
||||||
|
:options="yearOptions"
|
||||||
|
label="출생 연도"
|
||||||
|
class="col"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
/>
|
||||||
|
<q-select
|
||||||
|
v-model="form.birthMonth"
|
||||||
|
:options="monthOptions"
|
||||||
|
label="월"
|
||||||
|
class="col-5"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- AI 추천 조건: 원하는 메이트 조건 (AI 분석에 반영) -->
|
||||||
|
<q-separator class="q-my-xs" />
|
||||||
|
<div class="text-subtitle2 row items-center">
|
||||||
|
<q-icon name="auto_awesome" color="primary" size="18px" class="q-mr-xs" />AI 추천 조건
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-6 q-mb-xs">이런 친구를 찾아달라고 AI에게 알려줘요</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-xs">선호 성별</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="form.prefGender"
|
||||||
|
spread dense unelevated no-caps size="sm"
|
||||||
|
toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
|
:options="prefGenderOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-xs">선호 연령</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="form.prefAge"
|
||||||
|
spread dense unelevated no-caps size="sm"
|
||||||
|
toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
|
:options="prefAgeOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-xs">선호 중성화</div>
|
||||||
|
<q-btn-toggle
|
||||||
|
v-model="form.prefNeutered"
|
||||||
|
spread dense unelevated no-caps size="sm"
|
||||||
|
toggle-color="primary" color="grey-3" text-color="grey-8"
|
||||||
|
:options="prefNeuteredOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn flat label="취소" v-close-popup />
|
<q-btn flat label="취소" v-close-popup />
|
||||||
@@ -110,7 +254,7 @@
|
|||||||
unelevated
|
unelevated
|
||||||
label="등록"
|
label="등록"
|
||||||
:loading="creating"
|
:loading="creating"
|
||||||
:disable="!form.name"
|
:disable="!form.name || form.neutered === null"
|
||||||
@click="onCreate"
|
@click="onCreate"
|
||||||
/>
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
@@ -124,6 +268,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useDogsStore } from '@/stores/dogs'
|
import { useDogsStore } from '@/stores/dogs'
|
||||||
|
import { affinityApi, type NeighborhoodScore } from '@/api/affinity'
|
||||||
import { ApiError } from '@/api/http'
|
import { ApiError } from '@/api/http'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
@@ -131,6 +276,22 @@ const auth = useAuthStore()
|
|||||||
const dogs = useDogsStore()
|
const dogs = useDogsStore()
|
||||||
|
|
||||||
const current = computed(() => dogs.currentDog)
|
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 selected = ref<Set<number>>(new Set())
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const createOpen = ref(false)
|
const createOpen = ref(false)
|
||||||
@@ -141,10 +302,66 @@ const sizeOptions = [
|
|||||||
{ label: '중형견', value: 'MEDIUM' },
|
{ label: '중형견', value: 'MEDIUM' },
|
||||||
{ label: '대형견', value: 'LARGE' },
|
{ label: '대형견', value: 'LARGE' },
|
||||||
]
|
]
|
||||||
const form = reactive<{ name: string; breed: string; size: string | null }>({
|
|
||||||
|
// AI 추천 조건 선택지 (ANY = 상관없음)
|
||||||
|
const prefGenderOptions = [
|
||||||
|
{ label: '상관없음', value: 'ANY' },
|
||||||
|
{ label: '남아', value: 'MALE' },
|
||||||
|
{ label: '여아', value: 'FEMALE' },
|
||||||
|
]
|
||||||
|
const prefAgeOptions = [
|
||||||
|
{ label: '상관없음', value: 'ANY' },
|
||||||
|
{ label: '퍼피', value: 'PUPPY' },
|
||||||
|
{ label: '성견', value: 'ADULT' },
|
||||||
|
{ label: '노령', value: 'SENIOR' },
|
||||||
|
]
|
||||||
|
const prefNeuteredOptions = [
|
||||||
|
{ label: '상관없음', value: 'ANY' },
|
||||||
|
{ label: '완료', value: 'YES' },
|
||||||
|
{ label: '안 함', value: 'NO' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const form = reactive<{
|
||||||
|
name: string
|
||||||
|
breed: string
|
||||||
|
size: string | null
|
||||||
|
gender: string | null
|
||||||
|
birthYear: number | null
|
||||||
|
birthMonth: number | null
|
||||||
|
neutered: boolean | null
|
||||||
|
prefGender: string
|
||||||
|
prefAge: string
|
||||||
|
prefNeutered: string
|
||||||
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
breed: '',
|
breed: '',
|
||||||
size: null,
|
size: null,
|
||||||
|
gender: null,
|
||||||
|
birthYear: null,
|
||||||
|
birthMonth: null,
|
||||||
|
neutered: null,
|
||||||
|
prefGender: 'ANY',
|
||||||
|
prefAge: 'ANY',
|
||||||
|
prefNeutered: 'ANY',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 프로필 화면에서 편집하는 현재 강아지의 AI 추천 조건
|
||||||
|
const prefGender = ref('ANY')
|
||||||
|
const prefAge = ref('ANY')
|
||||||
|
const prefNeutered = ref('ANY')
|
||||||
|
|
||||||
|
// 출생 연도(올해 ~ 20년 전) / 월 선택지
|
||||||
|
const thisYear = new Date().getFullYear()
|
||||||
|
const yearOptions = Array.from({ length: 21 }, (_, i) => {
|
||||||
|
const y = thisYear - i
|
||||||
|
return { label: `${y}년`, value: y }
|
||||||
|
})
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, i) => ({ label: `${i + 1}월`, value: i + 1 }))
|
||||||
|
|
||||||
|
// 선택한 출생 연·월 → 연령 텍스트 (예: 2살 3개월)
|
||||||
|
const ageText = computed(() => {
|
||||||
|
if (form.birthYear == null || form.birthMonth == null) return ''
|
||||||
|
return ageFromBirth(form.birthYear, form.birthMonth)
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -155,20 +372,26 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 대표 강아지가 바뀌면 선택된 성향 동기화
|
// 대표 강아지가 바뀌면 선택된 성향 + AI 추천 조건 동기화
|
||||||
watch(
|
watch(
|
||||||
current,
|
current,
|
||||||
(d) => {
|
(d) => {
|
||||||
selected.value = new Set(d?.traits.map((t) => t.id) ?? [])
|
selected.value = new Set(d?.traits.map((t) => t.id) ?? [])
|
||||||
|
prefGender.value = d?.prefGender ?? 'ANY'
|
||||||
|
prefAge.value = d?.prefAge ?? 'ANY'
|
||||||
|
prefNeutered.value = d?.prefNeutered ?? 'ANY'
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// 변경 감지(저장 버튼 활성화)
|
// 변경 감지(저장 버튼 활성화) — 성향 태그 또는 AI 추천 조건 변경 시
|
||||||
const dirty = computed(() => {
|
const dirty = computed(() => {
|
||||||
const orig = new Set(current.value?.traits.map((t) => t.id) ?? [])
|
const orig = new Set(current.value?.traits.map((t) => t.id) ?? [])
|
||||||
if (orig.size !== selected.value.size) return true
|
if (orig.size !== selected.value.size) return true
|
||||||
for (const id of selected.value) if (!orig.has(id)) return true
|
for (const id of selected.value) if (!orig.has(id)) return true
|
||||||
|
if (prefGender.value !== (current.value?.prefGender ?? 'ANY')) return true
|
||||||
|
if (prefAge.value !== (current.value?.prefAge ?? 'ANY')) return true
|
||||||
|
if (prefNeutered.value !== (current.value?.prefNeutered ?? 'ANY')) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,6 +405,18 @@ function sizeLabel(size: string) {
|
|||||||
return sizeOptions.find((o) => o.value === size)?.label ?? size
|
return sizeOptions.find((o) => o.value === size)?.label ?? size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function genderLabel(gender: string) {
|
||||||
|
return gender === 'MALE' ? '남아' : gender === 'FEMALE' ? '여아' : gender
|
||||||
|
}
|
||||||
|
|
||||||
|
/** birthDate(YYYY-MM-DD) → 연령 텍스트. 없으면 빈 문자열. */
|
||||||
|
function dogAge(birthDate: string | null): string {
|
||||||
|
if (!birthDate) return ''
|
||||||
|
const [y, m] = birthDate.split('-').map(Number)
|
||||||
|
if (!y || !m) return ''
|
||||||
|
return ageFromBirth(y, m)
|
||||||
|
}
|
||||||
|
|
||||||
async function saveTraits() {
|
async function saveTraits() {
|
||||||
if (!current.value) return
|
if (!current.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -194,9 +429,12 @@ async function saveTraits() {
|
|||||||
gender: current.value.gender,
|
gender: current.value.gender,
|
||||||
neutered: current.value.neutered,
|
neutered: current.value.neutered,
|
||||||
imageUrl: current.value.imageUrl,
|
imageUrl: current.value.imageUrl,
|
||||||
|
prefGender: prefGender.value,
|
||||||
|
prefAge: prefAge.value,
|
||||||
|
prefNeutered: prefNeutered.value,
|
||||||
traitIds: [...selected.value],
|
traitIds: [...selected.value],
|
||||||
})
|
})
|
||||||
$q.notify({ color: 'primary', message: '성향을 저장했어요.', icon: 'check', position: 'top' })
|
$q.notify({ color: 'primary', message: '저장했어요.', icon: 'check', position: 'top' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyError(e)
|
notifyError(e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -208,13 +446,36 @@ function openCreate() {
|
|||||||
form.name = ''
|
form.name = ''
|
||||||
form.breed = ''
|
form.breed = ''
|
||||||
form.size = null
|
form.size = null
|
||||||
|
form.gender = null
|
||||||
|
form.birthYear = null
|
||||||
|
form.birthMonth = null
|
||||||
|
form.neutered = null
|
||||||
|
form.prefGender = 'ANY'
|
||||||
|
form.prefAge = 'ANY'
|
||||||
|
form.prefNeutered = 'ANY'
|
||||||
createOpen.value = true
|
createOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onCreate() {
|
async function onCreate() {
|
||||||
|
if (form.neutered === null) return
|
||||||
creating.value = true
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
await dogs.createDog({ name: form.name, breed: form.breed || null, size: form.size })
|
// 출생 연·월 → birthDate (해당 월 1일). 둘 다 선택해야 저장.
|
||||||
|
const birthDate =
|
||||||
|
form.birthYear != null && form.birthMonth != null
|
||||||
|
? `${form.birthYear}-${String(form.birthMonth).padStart(2, '0')}-01`
|
||||||
|
: null
|
||||||
|
await dogs.createDog({
|
||||||
|
name: form.name,
|
||||||
|
breed: form.breed || null,
|
||||||
|
size: form.size,
|
||||||
|
gender: form.gender,
|
||||||
|
birthDate,
|
||||||
|
neutered: form.neutered,
|
||||||
|
prefGender: form.prefGender,
|
||||||
|
prefAge: form.prefAge,
|
||||||
|
prefNeutered: form.prefNeutered,
|
||||||
|
})
|
||||||
createOpen.value = false
|
createOpen.value = false
|
||||||
$q.notify({ color: 'primary', message: `${form.name} 등록 완료!`, icon: 'pets', position: 'top' })
|
$q.notify({ color: 'primary', message: `${form.name} 등록 완료!`, icon: 'pets', position: 'top' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -224,6 +485,17 @@ async function onCreate() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 출생 연·월로 연령 텍스트 계산 (예: 2살 3개월 / 5개월). */
|
||||||
|
function ageFromBirth(year: number, month: number): string {
|
||||||
|
const now = new Date()
|
||||||
|
let months = (now.getFullYear() - year) * 12 + (now.getMonth() + 1 - month)
|
||||||
|
if (months < 0) months = 0
|
||||||
|
const y = Math.floor(months / 12)
|
||||||
|
const mo = months % 12
|
||||||
|
if (y <= 0) return `${mo}개월`
|
||||||
|
return mo > 0 ? `${y}살 ${mo}개월` : `${y}살`
|
||||||
|
}
|
||||||
|
|
||||||
async function onDelete() {
|
async function onDelete() {
|
||||||
if (!current.value) return
|
if (!current.value) return
|
||||||
$q.dialog({
|
$q.dialog({
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const useMatchingStore = defineStore('matching', () => {
|
|||||||
const received = ref<ReceivedMatch[]>([])
|
const received = ref<ReceivedMatch[]>([])
|
||||||
const sentPendingTargetIds = ref<Set<number>>(new Set())
|
const sentPendingTargetIds = ref<Set<number>>(new Set())
|
||||||
const openChatMatchId = ref<number | null>(null)
|
const openChatMatchId = ref<number | null>(null)
|
||||||
|
/** 산책 후 피드백(찰떡지수) 팝업을 띄울 매칭 id — 알림 수신 시 세팅. */
|
||||||
|
const feedbackMatchId = ref<number | null>(null)
|
||||||
|
|
||||||
let socket: ReturnType<typeof createUserNotifications> | null = null
|
let socket: ReturnType<typeof createUserNotifications> | null = null
|
||||||
let myDogId: number | null = null
|
let myDogId: number | null = null
|
||||||
@@ -57,6 +59,9 @@ export const useMatchingStore = defineStore('matching', () => {
|
|||||||
if (n.type === 'ACCEPTED') {
|
if (n.type === 'ACCEPTED') {
|
||||||
openChatMatchId.value = n.matchId // 신청자: 수락됨 → 채팅 열기
|
openChatMatchId.value = n.matchId // 신청자: 수락됨 → 채팅 열기
|
||||||
}
|
}
|
||||||
|
if (n.type === 'FEEDBACK_REQUEST') {
|
||||||
|
feedbackMatchId.value = n.matchId // 2시간 후: 찰떡지수 피드백 팝업
|
||||||
|
}
|
||||||
if (myDogId != null) void refresh(myDogId) // 목록/진행중 동기화
|
if (myDogId != null) void refresh(myDogId) // 목록/진행중 동기화
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +91,7 @@ export const useMatchingStore = defineStore('matching', () => {
|
|||||||
received,
|
received,
|
||||||
sentPendingTargetIds,
|
sentPendingTargetIds,
|
||||||
openChatMatchId,
|
openChatMatchId,
|
||||||
|
feedbackMatchId,
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
refresh,
|
refresh,
|
||||||
@@ -103,6 +109,8 @@ function colorFor(type: MatchNotification['type']): string {
|
|||||||
case 'REJECTED':
|
case 'REJECTED':
|
||||||
case 'EXPIRED':
|
case 'EXPIRED':
|
||||||
return 'grey-8'
|
return 'grey-8'
|
||||||
|
case 'FEEDBACK_REQUEST':
|
||||||
|
return 'teal'
|
||||||
default:
|
default:
|
||||||
return 'primary'
|
return 'primary'
|
||||||
}
|
}
|
||||||
@@ -116,6 +124,8 @@ function iconFor(type: MatchNotification['type']): string {
|
|||||||
return 'celebration'
|
return 'celebration'
|
||||||
case 'EXPIRED':
|
case 'EXPIRED':
|
||||||
return 'schedule'
|
return 'schedule'
|
||||||
|
case 'FEEDBACK_REQUEST':
|
||||||
|
return 'rate_review'
|
||||||
default:
|
default:
|
||||||
return 'heart_broken'
|
return 'heart_broken'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user