Compare commits
24 Commits
acc21b4ac7
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a7b633a3e5 | |||
| c8a8681a61 | |||
| eb51201669 | |||
| f29d5e106c | |||
| f9d6cf1fd4 | |||
| 0dcec68cfe | |||
| 38ac028c15 | |||
| c3231c59ac | |||
| 3bebe77cab | |||
| eac3098680 | |||
| 08743892d4 | |||
| 36f17be03a | |||
| 35d26e3563 | |||
| 16d7cf4332 | |||
| cc3a0fa836 | |||
| 12dd26c47f | |||
| 0f77f71869 | |||
| 56255c0ae6 | |||
| 0e69218710 | |||
| b5f394a7bc | |||
| 5f849c552e | |||
| c1fb8625e7 | |||
| 768aba282d | |||
| d4fd4a67e2 |
+6
-1
@@ -1,7 +1,12 @@
|
||||
# 백엔드 API 주소 (dognation_BT)
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
|
||||
# 네이버 지도 (네이버 클라우드 플랫폼 > Maps 에서 발급)
|
||||
# 지도: 현재 카카오 지도 사용 (홈 화면). 네이버 키는 참고용으로 남겨둠.
|
||||
# 카카오 지도 (Kakao Developers > 내 애플리케이션 > 앱 키 > JavaScript 키)
|
||||
# 콘솔 > 플랫폼 > Web 에 서비스 도메인 등록 필요 (개발: http://localhost:9000)
|
||||
VITE_KAKAO_MAP_KEY=your_kakao_javascript_key
|
||||
|
||||
# 네이버 지도 (네이버 클라우드 플랫폼 > Maps) — 현재 미사용, 코드는 유지
|
||||
VITE_NAVER_MAP_CLIENT_ID=your_ncp_key_id
|
||||
# 신규 키는 ncpKeyId(기본), 구형 키면 ncpClientId 로 변경
|
||||
# VITE_NAVER_MAP_AUTH_PARAM=ncpKeyId
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: CI
|
||||
|
||||
# dev 브랜치 전용 CI (요청: dev 에만).
|
||||
on:
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발(sb-front 와 동일)
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# build = vue-tsc 타입체크 + vite 빌드 (test/lint 스크립트는 아직 없음)
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build output
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
retention-days: 7
|
||||
@@ -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)
|
||||
+2
-1
@@ -5,7 +5,8 @@ const config: CapacitorConfig = {
|
||||
appName: '산책갈개',
|
||||
webDir: 'dist',
|
||||
ios: {
|
||||
contentInset: 'always',
|
||||
// never + CSS safe-area(env inset)로 노치/홈바 여백 처리 (이중 인셋 방지)
|
||||
contentInset: 'never',
|
||||
},
|
||||
server: {
|
||||
androidScheme: 'https',
|
||||
|
||||
+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>
|
||||
|
||||
Generated
+45
-65
@@ -8,8 +8,10 @@
|
||||
"name": "dognation_pt",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@capacitor-community/apple-sign-in": "^7.1.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@quasar/extras": "^1.16.12",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"pinia": "^2.3.0",
|
||||
@@ -83,6 +85,19 @@
|
||||
"dev": true,
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@capacitor-community/apple-sign-in": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/apple-sign-in/-/apple-sign-in-7.1.0.tgz",
|
||||
"integrity": "sha512-df7cA7vfvvvilTtpJKeVvxO31W+py5t3HK9233GBD5MvgAgtGvCqeQ3pI0noNkVsKPF9B1g/po8Mph6s4bVSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/scriptjs": "0.0.2",
|
||||
"scriptjs": "^2.5.9"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.4.1.tgz",
|
||||
@@ -144,6 +159,18 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/geolocation": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.0.tgz",
|
||||
"integrity": "sha512-N29QcoIPmme0xSxRkm7+3hjoHp6mBAOarxecvtCCZKyOBeKiJsFUq981cezg2XWBa6fhCXJMCCjQPngKK/dIag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor/synapse": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.1.tgz",
|
||||
@@ -154,6 +181,12 @@
|
||||
"@capacitor/core": "^8.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -892,9 +925,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -916,9 +946,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -940,9 +967,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -964,9 +988,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -988,9 +1009,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1012,9 +1030,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1213,9 +1228,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1230,9 +1242,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1247,9 +1256,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1264,9 +1270,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1281,9 +1284,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1298,9 +1298,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1315,9 +1312,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1332,9 +1326,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1349,9 +1340,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1366,9 +1354,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1383,9 +1368,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1400,9 +1382,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1417,9 +1396,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1560,6 +1536,12 @@
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scriptjs": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/scriptjs/-/scriptjs-0.0.2.tgz",
|
||||
"integrity": "sha512-GFrUgzGYfNX3VWZwMyzr0JrBwzLv5Kes4BQBvo6hXdRy14/GBbpCiVwwB7v1o2xIEvFf+9GyznmYhuesQQjSag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/slice-ansi": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/slice-ansi/-/slice-ansi-4.0.0.tgz",
|
||||
@@ -2997,7 +2979,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": "glibc",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3015,7 +2996,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": "glibc",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3033,7 +3013,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": "musl",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3051,7 +3030,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": "musl",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3069,7 +3047,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": "musl",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3087,7 +3064,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": "musl",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3105,7 +3081,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": "glibc",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3123,7 +3098,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": "glibc",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3191,6 +3165,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/scriptjs": {
|
||||
"version": "2.5.9",
|
||||
"resolved": "https://registry.npmjs.org/scriptjs/-/scriptjs-2.5.9.tgz",
|
||||
"integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/apple-sign-in": "^7.1.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/core": "^8.3.4",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@quasar/extras": "^1.16.12",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"pinia": "^2.3.0",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { http } from './http'
|
||||
import type { LoginResponse } from './auth'
|
||||
|
||||
/** 관리자 콘솔 전용 인증 API (사용자 authApi 와 분리). */
|
||||
export const adminAuthApi = {
|
||||
/** 개발자 로그인 — local 백엔드 전용(구글 없이 이메일로 세션 발급). */
|
||||
devLogin: (email: string) => http.post<LoginResponse>('/api/auth/dev-login', { email }),
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { http } from './http'
|
||||
import type { Member } from './auth'
|
||||
|
||||
export type MemberStatus = 'ACTIVE' | 'DORMANT' | 'BANNED'
|
||||
export type MemberTier = 'FREE' | 'AD_FREE' | 'PREMIUM'
|
||||
export type MemberRole = 'USER' | 'ADMIN'
|
||||
|
||||
/** 관리자 회원관리 API — /api/admin/members (ADMIN 전용) */
|
||||
export const adminMembersApi = {
|
||||
list: () => http.get<Member[]>('/api/admin/members'),
|
||||
|
||||
updateStatus: (id: number, status: MemberStatus) =>
|
||||
http.put<Member>(`/api/admin/members/${id}/status`, { status }),
|
||||
|
||||
updateTier: (id: number, tier: MemberTier) =>
|
||||
http.put<Member>(`/api/admin/members/${id}/tier`, { tier }),
|
||||
|
||||
updateRole: (id: number, role: MemberRole) =>
|
||||
http.put<Member>(`/api/admin/members/${id}/role`, { role }),
|
||||
|
||||
remove: (id: number) => http.del<void>(`/api/admin/members/${id}`),
|
||||
}
|
||||
@@ -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}`),
|
||||
}
|
||||
+4
-1
@@ -2,7 +2,8 @@ import { http } from './http'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number
|
||||
spotId: number
|
||||
spotId: number | null
|
||||
matchId: number | null
|
||||
senderId: number
|
||||
senderNickname: string
|
||||
content: string
|
||||
@@ -11,4 +12,6 @@ export interface ChatMessage {
|
||||
|
||||
export const chatApi = {
|
||||
history: (spotId: number) => http.get<ChatMessage[]>(`/api/chat/spots/${spotId}/messages`),
|
||||
matchHistory: (matchId: number) =>
|
||||
http.get<ChatMessage[]>(`/api/chat/matches/${matchId}/messages`),
|
||||
}
|
||||
|
||||
+21
-1
@@ -6,13 +6,33 @@ export interface Match {
|
||||
targetDogId: number
|
||||
status: string
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
respondedAt: string | null
|
||||
}
|
||||
|
||||
/** 받은 매칭 신청 (신청 견 정보 포함) — 수락/거절 UI용. */
|
||||
export interface ReceivedMatch {
|
||||
id: number
|
||||
requesterDogId: number
|
||||
requesterDogName: string
|
||||
requesterDogBreed: string | null
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
/** STOMP /topic/users/{userId} 로 오는 매칭 알림. */
|
||||
export interface MatchNotification {
|
||||
type: 'NEW_REQUEST' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED' | 'FEEDBACK_REQUEST'
|
||||
matchId: number
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const matchesApi = {
|
||||
create: (requesterDogId: number, targetDogId: number) =>
|
||||
http.post<Match>('/api/matches', { requesterDogId, targetDogId }),
|
||||
accept: (id: number) => http.post<Match>(`/api/matches/${id}/accept`),
|
||||
reject: (id: number) => http.post<Match>(`/api/matches/${id}/reject`),
|
||||
received: (dogId: number) => http.get<Match[]>(`/api/matches/received/${dogId}`),
|
||||
received: (dogId: number) => http.get<ReceivedMatch[]>(`/api/matches/received/${dogId}`),
|
||||
sent: (dogId: number) => http.get<Match[]>(`/api/matches/sent/${dogId}`),
|
||||
}
|
||||
|
||||
+17
-1
@@ -14,6 +14,14 @@ export interface Mate {
|
||||
breed: string | null
|
||||
}
|
||||
|
||||
export interface AiMate {
|
||||
dogId: number
|
||||
name: string
|
||||
breed: string | null
|
||||
score: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface Review {
|
||||
id: number
|
||||
tag: string | null
|
||||
@@ -30,7 +38,15 @@ export interface CheckInResult {
|
||||
|
||||
export const spotsApi = {
|
||||
list: () => http.get<Spot[]>('/api/spots'),
|
||||
mates: (spotId: number) => http.get<Mate[]>(`/api/spots/${spotId}/mates`),
|
||||
mates: (spotId: number, userId?: number) =>
|
||||
http.get<Mate[]>(`/api/spots/${spotId}/mates${userId ? `?userId=${userId}` : ''}`),
|
||||
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>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-canvas"></div>
|
||||
|
||||
<!-- SDK 미로드/키 미설정 시 폴백 -->
|
||||
<div v-if="errorMsg" class="map-fallback flex flex-center column text-grey-6">
|
||||
<q-icon name="map" size="40px" color="secondary" />
|
||||
<div class="q-mt-sm text-caption text-center">{{ errorMsg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { loadKakaoMaps } from '@/lib/kakaoMap'
|
||||
import type { Spot } from '@/api/spots'
|
||||
|
||||
const props = defineProps<{
|
||||
spots: Spot[]
|
||||
selectedId: number | null
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'select', id: number): void }>()
|
||||
|
||||
// 기본 중심: 서울시청
|
||||
const DEFAULT_CENTER = { lat: 37.5666, lng: 126.9784 }
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
const errorMsg = ref('')
|
||||
|
||||
let map: kakao.maps.Map | null = null
|
||||
let infoWindow: kakao.maps.InfoWindow | null = null
|
||||
const markers = new Map<number, kakao.maps.Marker>()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const kk = await loadKakaoMaps()
|
||||
if (!mapEl.value) return
|
||||
|
||||
const center = firstCenter()
|
||||
map = new kk.maps.Map(mapEl.value, {
|
||||
center: new kk.maps.LatLng(center.lat, center.lng),
|
||||
level: 5, // 네이버 zoom 14 ≈ 카카오 level 5
|
||||
})
|
||||
// 확대/축소 +/- 컨트롤 (핀치 줌도 함께 동작)
|
||||
map.addControl(new kk.maps.ZoomControl(), kk.maps.ControlPosition.RIGHT)
|
||||
infoWindow = new kk.maps.InfoWindow({ removable: false })
|
||||
// 지도 아무 곳이나 탭하면 가장 가까운 스팟 선택 (마커가 겹쳐도 선택 가능)
|
||||
kk.maps.event.addListener(map, 'click', (e) => {
|
||||
if (!e) return
|
||||
const spot = nearestSpot(e.latLng.getLat(), e.latLng.getLng())
|
||||
if (spot) emit('select', spot.id)
|
||||
})
|
||||
renderMarkers(kk)
|
||||
} catch (e) {
|
||||
errorMsg.value = `${(e as Error).message} 지도 키(VITE_KAKAO_MAP_KEY)를 설정하세요.`
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
markers.forEach((m) => m.setMap(null))
|
||||
markers.clear()
|
||||
})
|
||||
|
||||
// 스팟 목록이 갱신되면 마커 다시 그림
|
||||
watch(
|
||||
() => props.spots,
|
||||
() => {
|
||||
if (map && window.kakao?.maps) renderMarkers(window.kakao)
|
||||
},
|
||||
)
|
||||
|
||||
// 외부에서 선택된 스팟이 바뀌면 지도 중심 이동 + 정보창
|
||||
watch(
|
||||
() => props.selectedId,
|
||||
(id) => {
|
||||
if (!map || id == null) return
|
||||
const spot = props.spots.find((s) => s.id === id)
|
||||
const marker = markers.get(id)
|
||||
if (spot?.latitude != null && spot?.longitude != null) {
|
||||
map.panTo(new window.kakao.maps.LatLng(spot.latitude, spot.longitude))
|
||||
}
|
||||
if (spot && marker) openInfo(spot, marker)
|
||||
},
|
||||
)
|
||||
|
||||
function firstCenter() {
|
||||
const withCoord = props.spots.find((s) => s.latitude != null && s.longitude != null)
|
||||
return withCoord ? { lat: withCoord.latitude!, lng: withCoord.longitude! } : DEFAULT_CENTER
|
||||
}
|
||||
|
||||
/** 클릭 좌표에서 가장 가까운 스팟(위경도 제곱거리 최소). */
|
||||
function nearestSpot(lat: number, lng: number): Spot | null {
|
||||
let best: Spot | null = null
|
||||
let bestDist = Infinity
|
||||
for (const s of props.spots) {
|
||||
if (s.latitude == null || s.longitude == null) continue
|
||||
const dLat = s.latitude - lat
|
||||
const dLng = s.longitude - lng
|
||||
const dist = dLat * dLat + dLng * dLng
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist
|
||||
best = s
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function renderMarkers(kk: typeof kakao) {
|
||||
markers.forEach((m) => m.setMap(null))
|
||||
markers.clear()
|
||||
|
||||
for (const spot of props.spots) {
|
||||
if (spot.latitude == null || spot.longitude == null) continue
|
||||
const marker = new kk.maps.Marker({
|
||||
position: new kk.maps.LatLng(spot.latitude, spot.longitude),
|
||||
map: map!,
|
||||
title: spot.name,
|
||||
})
|
||||
kk.maps.event.addListener(marker, 'click', () => {
|
||||
openInfo(spot, marker)
|
||||
emit('select', spot.id)
|
||||
})
|
||||
markers.set(spot.id, marker)
|
||||
}
|
||||
}
|
||||
|
||||
function openInfo(spot: Spot, marker: kakao.maps.Marker) {
|
||||
if (!infoWindow || !map) return
|
||||
infoWindow.setContent(
|
||||
`<div style="padding:6px 12px;font-size:13px;font-weight:600;color:#3E92CC;white-space:nowrap;">📍 ${spot.name}</div>`,
|
||||
)
|
||||
infoWindow.open(map, marker)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-wrap {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.map-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.map-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(160deg, #eaf5fe 0%, #d7ecfb 100%);
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<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="favorite" class="q-mr-sm" />
|
||||
<q-toolbar-title>매칭 채팅</q-toolbar-title>
|
||||
<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>
|
||||
|
||||
<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 } from 'quasar'
|
||||
import { chatApi, type ChatMessage } from '@/api/chat'
|
||||
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
|
||||
(e: 'checkout', matchId: number): void
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const draft = ref('')
|
||||
const connected = ref(false)
|
||||
const scrollArea = ref<QScrollArea | null>(null)
|
||||
let chat: ReturnType<typeof createMatchChat> | 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.matchHistory(props.matchId)
|
||||
scrollToBottom()
|
||||
} catch {
|
||||
// 히스토리 실패해도 실시간 연결은 시도
|
||||
}
|
||||
chat = createMatchChat(
|
||||
props.matchId,
|
||||
(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 onCheckout() {
|
||||
emit('checkout', props.matchId)
|
||||
}
|
||||
|
||||
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>
|
||||
+9
-3
@@ -1,16 +1,22 @@
|
||||
// 전역 스타일 — 하늘색 파스텔 배경
|
||||
html,
|
||||
body {
|
||||
background: #F4FAFF;
|
||||
// input 포커스/키보드 시 가로 스크롤(오버플로) 방지
|
||||
overflow-x: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.text-brand {
|
||||
color: #3E92CC;
|
||||
}
|
||||
|
||||
// iOS 노치/홈바 safe-area 대응
|
||||
// iOS 노치/홈바 safe-area 대응.
|
||||
// 네이티브 웹뷰가 이미 safe-area 아래로 배치돼(이중 여백 방지) CSS 추가 여백은 0.
|
||||
// (제목이 상태바에 겹치면 이 값을 소폭 늘린다)
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-top: 0;
|
||||
}
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -11,6 +11,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_NAVER_MAP_CLIENT_ID: string
|
||||
readonly VITE_NAVER_MAP_AUTH_PARAM?: string
|
||||
readonly VITE_KAKAO_MAP_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
@@ -19,4 +20,5 @@ interface ImportMeta {
|
||||
|
||||
interface Window {
|
||||
naver: typeof naver
|
||||
kakao: typeof kakao
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<template>
|
||||
<q-layout view="hHh Lpr lFf">
|
||||
<!-- 헤더 -->
|
||||
<q-header elevated class="bg-primary text-white">
|
||||
<q-toolbar>
|
||||
<q-btn flat dense round icon="menu" aria-label="메뉴" @click="drawer = !drawer" />
|
||||
<q-toolbar-title class="text-weight-bold">
|
||||
<q-icon name="pets" class="q-mr-sm" />산책갈개 관리자
|
||||
</q-toolbar-title>
|
||||
|
||||
<q-chip dense color="white" text-color="primary" icon="admin_panel_settings">
|
||||
{{ admin.member?.nickname ?? '관리자' }}
|
||||
</q-chip>
|
||||
<q-btn flat dense round icon="logout" aria-label="로그아웃" @click="onLogout" />
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<!-- 좌측 사이드 메뉴 -->
|
||||
<q-drawer v-model="drawer" show-if-above bordered :width="240" class="bg-grey-1">
|
||||
<q-list padding>
|
||||
<q-item-label header class="text-grey-7">관리 메뉴</q-item-label>
|
||||
|
||||
<q-item
|
||||
v-for="item in menu"
|
||||
:key="item.name"
|
||||
clickable
|
||||
:to="{ name: item.name }"
|
||||
active-class="text-primary bg-blue-1"
|
||||
exact
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="item.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>{{ item.label }}</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-drawer>
|
||||
|
||||
<!-- 바디 -->
|
||||
<q-page-container>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
||||
<!-- 풋터 -->
|
||||
<q-footer class="bg-white text-grey-6">
|
||||
<div class="text-caption text-center q-py-sm">© 2026 산책갈개 · 관리자 콘솔</div>
|
||||
</q-footer>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAdminAuthStore } from '@/stores/adminAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const admin = useAdminAuthStore()
|
||||
|
||||
const drawer = ref(true)
|
||||
|
||||
const menu = [{ name: 'admin-members', label: '회원 관리', icon: 'group' }]
|
||||
|
||||
async function onLogout() {
|
||||
await admin.logout()
|
||||
await router.replace({ name: 'admin-login' })
|
||||
}
|
||||
</script>
|
||||
+121
-5
@@ -8,6 +8,37 @@
|
||||
<q-chip dense color="white" text-color="primary" icon="bolt">
|
||||
{{ tierLabel }}
|
||||
</q-chip>
|
||||
|
||||
<!-- 받은 매칭 알림 -->
|
||||
<q-btn flat round dense icon="notifications" class="q-mr-xs">
|
||||
<q-badge v-if="matching.received.length" color="red" floating>
|
||||
{{ matching.received.length }}
|
||||
</q-badge>
|
||||
<q-menu anchor="bottom right" self="top right">
|
||||
<q-list style="min-width: 260px">
|
||||
<q-item-label header>받은 매칭 신청</q-item-label>
|
||||
<q-item v-if="!matching.received.length">
|
||||
<q-item-section class="text-grey-6">받은 신청이 없어요.</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-for="r in matching.received" :key="r.id">
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="secondary" text-color="accent" icon="pets" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">{{ r.requesterDogName }}</q-item-label>
|
||||
<q-item-label caption>{{ r.requesterDogBreed || '견종 미상' }} · 매칭 신청</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div class="row q-gutter-xs">
|
||||
<q-btn dense unelevated color="primary" label="수락" @click="onAccept(r.id)" />
|
||||
<q-btn dense flat color="grey-7" label="거절" @click="onReject(r.id)" />
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
|
||||
<q-btn flat round dense icon="account_circle">
|
||||
<q-menu>
|
||||
<q-list style="min-width: 160px">
|
||||
@@ -32,10 +63,9 @@
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
||||
<!-- FREE 티어 하단 배너 광고 -->
|
||||
<AdBanner />
|
||||
|
||||
<q-footer class="bg-white text-grey-8 safe-bottom">
|
||||
<!-- FREE 티어 하단 배너 광고 (탭바 위). 유료 티어는 숨김 → 콘텐츠 공간 회수 -->
|
||||
<AdBanner />
|
||||
<q-tabs
|
||||
v-model="tab"
|
||||
active-color="primary"
|
||||
@@ -43,25 +73,110 @@
|
||||
class="text-grey-6"
|
||||
>
|
||||
<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-footer>
|
||||
|
||||
<!-- 매칭 수락 시 1:1 채팅 -->
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
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'
|
||||
import { useMatchingStore } from '@/stores/matching'
|
||||
import { ApiError } from '@/api/http'
|
||||
|
||||
const tab = ref('home')
|
||||
const subscription = useSubscriptionStore()
|
||||
const auth = useAuthStore()
|
||||
const dogs = useDogsStore()
|
||||
const matching = useMatchingStore()
|
||||
const router = useRouter()
|
||||
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) {
|
||||
matching.connect(auth.member.id, dogs.currentDogId)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => matching.disconnect())
|
||||
|
||||
// 수락(내가/상대가) → 1:1 채팅 열기
|
||||
watch(
|
||||
() => matching.openChatMatchId,
|
||||
(id) => {
|
||||
if (id != null) {
|
||||
matchChatId.value = id
|
||||
matchChatOpen.value = true
|
||||
matching.openChatMatchId = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 2시간 후 피드백 요청 알림 → 찰떡지수 피드백 팝업
|
||||
watch(
|
||||
() => matching.feedbackMatchId,
|
||||
(id) => {
|
||||
if (id != null) {
|
||||
openFeedback(id)
|
||||
matching.feedbackMatchId = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function onAccept(id: number) {
|
||||
try {
|
||||
await matching.accept(id)
|
||||
} catch (e) {
|
||||
notifyError(e)
|
||||
}
|
||||
}
|
||||
async function onReject(id: number) {
|
||||
try {
|
||||
await matching.reject(id)
|
||||
} catch (e) {
|
||||
notifyError(e)
|
||||
}
|
||||
}
|
||||
function notifyError(e: unknown) {
|
||||
const message = e instanceof ApiError ? e.message : '처리 중 오류가 발생했습니다.'
|
||||
$q.notify({ color: 'negative', message, icon: 'error', position: 'top' })
|
||||
}
|
||||
|
||||
const tierLabel = computed(() => {
|
||||
switch (subscription.tier) {
|
||||
@@ -75,6 +190,7 @@ const tierLabel = computed(() => {
|
||||
})
|
||||
|
||||
async function onLogout() {
|
||||
matching.disconnect()
|
||||
await auth.logout()
|
||||
dogs.reset()
|
||||
router.replace({ name: 'login' })
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Sign in with Apple — 네이티브(iOS) 전용 래퍼.
|
||||
// @capacitor-community/apple-sign-in 플러그인으로 identityToken(JWT)을 받아온다.
|
||||
// 이 토큰의 aud 는 앱 번들 ID(=capacitor appId)이며, 백엔드가 APPLE_CLIENT_ID 로 검증한다.
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { SignInWithApple } from '@capacitor-community/apple-sign-in'
|
||||
|
||||
// capacitor.config 의 appId 와 일치. iOS 네이티브 플로우의 aud 가 된다.
|
||||
const APPLE_CLIENT_ID = 'kr.sblog.dognation'
|
||||
|
||||
export interface AppleCredential {
|
||||
identityToken: string
|
||||
/** 애플은 최초 인증 시에만 이름을 준다. 이후에는 null. */
|
||||
name: string | null
|
||||
}
|
||||
|
||||
/** 현재 실행 환경이 네이티브(iOS)인지 — 애플 로그인 지원 여부. */
|
||||
export function isAppleSignInAvailable(): boolean {
|
||||
return Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'ios'
|
||||
}
|
||||
|
||||
/**
|
||||
* 네이티브 Sign in with Apple 을 실행하고 identityToken 을 반환한다.
|
||||
* 사용자가 취소하면 플러그인이 reject 하므로 호출부에서 구분 처리한다.
|
||||
*/
|
||||
export async function signInWithApple(): Promise<AppleCredential> {
|
||||
const { response } = await SignInWithApple.authorize({
|
||||
clientId: APPLE_CLIENT_ID,
|
||||
// iOS 네이티브 플로우에서는 사용되지 않지만 타입상 필요.
|
||||
redirectURI: `https://${APPLE_CLIENT_ID}/apple/callback`,
|
||||
scopes: 'name email',
|
||||
})
|
||||
// familyName + givenName (한국식). 최초 인증에만 제공되며, 없으면 null.
|
||||
const name =
|
||||
[response.familyName, response.givenName].filter(Boolean).join(' ').trim() || null
|
||||
return { identityToken: response.identityToken, name }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Client, type IMessage } from '@stomp/stompjs'
|
||||
import { tokenStore } from '@/lib/token'
|
||||
import type { ChatMessage } from '@/api/chat'
|
||||
import type { MatchNotification } from '@/api/matches'
|
||||
|
||||
function wsUrl(): string {
|
||||
const base = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
|
||||
@@ -8,6 +9,21 @@ function wsUrl(): string {
|
||||
return base.replace(/^http/, 'ws') + '/ws'
|
||||
}
|
||||
|
||||
function stompClient(onConnect: (c: Client) => void, onStatus?: (connected: boolean) => void) {
|
||||
const client = new Client({
|
||||
brokerURL: wsUrl(),
|
||||
connectHeaders: { Authorization: `Bearer ${tokenStore.get() ?? ''}` },
|
||||
reconnectDelay: 3000,
|
||||
onConnect: () => {
|
||||
onStatus?.(true)
|
||||
onConnect(client)
|
||||
},
|
||||
onDisconnect: () => onStatus?.(false),
|
||||
onWebSocketClose: () => onStatus?.(false),
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* 스팟 채팅용 STOMP 클라이언트.
|
||||
* connect 후 스팟 토픽을 구독하고, send 로 메시지를 전송한다.
|
||||
@@ -43,3 +59,44 @@ export function createSpotChat(
|
||||
isConnected: () => client.connected,
|
||||
}
|
||||
}
|
||||
|
||||
/** 매칭 수락으로 열린 1:1 채팅용 STOMP 클라이언트. */
|
||||
export function createMatchChat(
|
||||
matchId: number,
|
||||
onMessage: (m: ChatMessage) => void,
|
||||
onStatus?: (connected: boolean) => void,
|
||||
) {
|
||||
const client = stompClient((c) => {
|
||||
c.subscribe(`/topic/matches/${matchId}`, (msg: IMessage) => {
|
||||
onMessage(JSON.parse(msg.body) as ChatMessage)
|
||||
})
|
||||
}, onStatus)
|
||||
|
||||
return {
|
||||
activate: () => client.activate(),
|
||||
deactivate: () => client.deactivate(),
|
||||
send: (content: string) => {
|
||||
client.publish({
|
||||
destination: `/app/matches/${matchId}/chat`,
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
},
|
||||
isConnected: () => client.connected,
|
||||
}
|
||||
}
|
||||
|
||||
/** 로그인 사용자용 매칭 실시간 알림 구독 (/topic/users/{userId}). */
|
||||
export function createUserNotifications(
|
||||
userId: number,
|
||||
onNotification: (n: MatchNotification) => void,
|
||||
) {
|
||||
const client = stompClient((c) => {
|
||||
c.subscribe(`/topic/users/${userId}`, (msg: IMessage) => {
|
||||
onNotification(JSON.parse(msg.body) as MatchNotification)
|
||||
})
|
||||
})
|
||||
return {
|
||||
activate: () => client.activate(),
|
||||
deactivate: () => client.deactivate(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// 카카오 지도 JS SDK 동적 로더 (싱글턴)
|
||||
// JavaScript 키는 Kakao Developers > 내 애플리케이션 > 앱 키 > JavaScript 키.
|
||||
// .env 의 VITE_KAKAO_MAP_KEY 로 주입. 콘솔 > 플랫폼 > Web 에 서비스 도메인 등록 필요
|
||||
// (개발: http://localhost:9000 / 라이브리로드 IP, 배포: 실제 도메인·capacitor 스킴).
|
||||
let loadPromise: Promise<typeof kakao> | null = null
|
||||
|
||||
export function loadKakaoMaps(): Promise<typeof kakao> {
|
||||
if (typeof window !== 'undefined' && window.kakao?.maps) {
|
||||
return Promise.resolve(window.kakao)
|
||||
}
|
||||
if (loadPromise) {
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
const appKey = import.meta.env.VITE_KAKAO_MAP_KEY
|
||||
|
||||
loadPromise = new Promise<typeof kakao>((resolve, reject) => {
|
||||
if (!appKey) {
|
||||
reject(new Error('VITE_KAKAO_MAP_KEY 가 설정되지 않았습니다.'))
|
||||
return
|
||||
}
|
||||
const script = document.createElement('script')
|
||||
// autoload=false → 스크립트 로드 후 kakao.maps.load 로 명시적으로 초기화한다.
|
||||
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${appKey}&autoload=false`
|
||||
script.async = true
|
||||
script.onload = () => {
|
||||
// 키/도메인이 유효하지 않으면 load 콜백이 호출되지 않을 수 있어 타임아웃으로 방어.
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('카카오 지도 초기화 실패 · 키/도메인 등록을 확인하세요.')),
|
||||
8000,
|
||||
)
|
||||
window.kakao.maps.load(() => {
|
||||
clearTimeout(timer)
|
||||
resolve(window.kakao)
|
||||
})
|
||||
}
|
||||
script.onerror = () => reject(new Error('카카오 지도 SDK 를 불러오지 못했습니다.'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
return loadPromise
|
||||
}
|
||||
+149
-44
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<!-- 네이버 지도: 스팟 마커 표시, 마커 클릭 시 해당 스팟 선택 -->
|
||||
<!-- 카카오 지도: 스팟 마커 표시, 마커 클릭 시 해당 스팟 선택 -->
|
||||
<q-card flat bordered class="q-mb-md overflow-hidden">
|
||||
<NaverMap :spots="spots" :selected-id="currentSpot?.id ?? null" @select="onSelectSpot" />
|
||||
<KakaoMap :spots="spots" :selected-id="currentSpot?.id ?? null" @select="onSelectSpot" />
|
||||
</q-card>
|
||||
|
||||
<!-- 로딩 / 에러 -->
|
||||
@@ -11,40 +11,85 @@
|
||||
백엔드에 연결하지 못했습니다. (서버 실행 여부를 확인하세요)
|
||||
</q-banner>
|
||||
|
||||
<!-- 체크인 + 채팅 -->
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
color="primary"
|
||||
class="full-width q-py-sm"
|
||||
unelevated
|
||||
:loading="checkingIn"
|
||||
:disable="!currentSpot"
|
||||
icon="place"
|
||||
:label="checkedIn ? '체크인 완료' : '나 지금 여기 도착!'"
|
||||
@click="onCheckIn"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn
|
||||
color="accent"
|
||||
class="full-height q-px-md"
|
||||
outline
|
||||
:disable="!currentSpot"
|
||||
icon="chat"
|
||||
label="채팅"
|
||||
@click="chatOpen = true"
|
||||
/>
|
||||
</div>
|
||||
<!-- 체크인 -->
|
||||
<q-btn
|
||||
color="primary"
|
||||
class="full-width q-py-sm"
|
||||
unelevated
|
||||
:loading="checkingIn"
|
||||
:disable="!currentSpot"
|
||||
icon="place"
|
||||
:label="checkedIn ? '체크인 완료' : '나 지금 여기 도착!'"
|
||||
@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>
|
||||
|
||||
<!-- 스팟 채팅 -->
|
||||
<ChatDialog
|
||||
v-if="currentSpot"
|
||||
v-model="chatOpen"
|
||||
:spot-id="currentSpot.id"
|
||||
:spot-name="currentSpot.name"
|
||||
/>
|
||||
<!-- AI 궁합 추천 (체크인 후, 이 스팟의 강아지 중 사이좋게 지낼 만한 목록) -->
|
||||
<div v-if="aiLoading || aiTried" class="q-mt-lg">
|
||||
<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>
|
||||
</div>
|
||||
<template v-else>
|
||||
<q-card v-for="m in aiMates" :key="m.dogId" flat bordered class="q-mb-sm">
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="primary" text-color="white" icon="favorite" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">
|
||||
{{ m.name }}
|
||||
<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>
|
||||
<div v-if="!aiMates.length" class="text-caption text-grey-5">
|
||||
지금 이 스팟엔 잘 맞는 강아지가 없어요.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 스팟 한줄평 (네이버 지도식 태그형) -->
|
||||
<div class="text-subtitle1 text-weight-bold q-mt-lg q-mb-sm">
|
||||
@@ -74,9 +119,25 @@
|
||||
<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
|
||||
v-if="matching.isPending(mate.dogId)"
|
||||
dense
|
||||
outline
|
||||
color="grey-7"
|
||||
icon="hourglass_top"
|
||||
label="진행중"
|
||||
disable
|
||||
/>
|
||||
<q-btn
|
||||
v-else
|
||||
dense
|
||||
unelevated
|
||||
color="primary"
|
||||
@@ -94,19 +155,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { spotsApi, type Mate, type Review, type Spot } from '@/api/spots'
|
||||
import { spotsApi, type AiMate, type Mate, type Review, type Spot } from '@/api/spots'
|
||||
import { matchesApi } from '@/api/matches'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDogsStore } from '@/stores/dogs'
|
||||
import NaverMap from '@/components/NaverMap.vue'
|
||||
import ChatDialog from '@/components/ChatDialog.vue'
|
||||
import { useMatchingStore } from '@/stores/matching'
|
||||
import KakaoMap from '@/components/KakaoMap.vue'
|
||||
import AffinityBadges from '@/components/AffinityBadges.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
const auth = useAuthStore()
|
||||
const dogs = useDogsStore()
|
||||
|
||||
const chatOpen = ref(false)
|
||||
const matching = useMatchingStore()
|
||||
|
||||
// 체크인/매칭 주체: 로그인 회원 + 대표 강아지
|
||||
const currentUserId = () => auth.member?.id ?? 0
|
||||
@@ -115,11 +176,18 @@ 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)
|
||||
const loadError = ref(false)
|
||||
|
||||
// AI 궁합 추천 (체크인 후 로드)
|
||||
const aiMates = ref<AiMate[]>([])
|
||||
const aiLoading = ref(false)
|
||||
const aiTried = ref(false)
|
||||
const aiNeutered = ref<'any' | 'yes' | 'no'>('any') // AI 찾기 옵션: 중성화 조건
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
@@ -142,19 +210,27 @@ async function onSelectSpot(spotId: number) {
|
||||
if (!spot) return
|
||||
currentSpot.value = spot
|
||||
checkedIn.value = false
|
||||
aiMates.value = []
|
||||
aiTried.value = false
|
||||
await refreshSpot(spotId)
|
||||
}
|
||||
|
||||
async function refreshSpot(spotId: number) {
|
||||
const [r, m] = await Promise.all([spotsApi.reviews(spotId), spotsApi.mates(spotId)])
|
||||
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() {
|
||||
if (!currentSpot.value) return
|
||||
if (dogs.currentDogId == null) {
|
||||
$q.notify({ color: 'warning', message: '먼저 우리 개를 등록해 주세요.', icon: 'pets', position: 'top' })
|
||||
$q.notify({ color: 'warning', message: '먼저 우리 강아지를 등록해 주세요.', icon: 'pets', position: 'top' })
|
||||
return
|
||||
}
|
||||
checkingIn.value = true
|
||||
@@ -163,6 +239,7 @@ async function onCheckIn() {
|
||||
checkedIn.value = true
|
||||
await refreshSpot(currentSpot.value.id)
|
||||
$q.notify({ color: 'primary', message: '체크인 완료! 스팟 채팅이 열렸어요.', icon: 'place', position: 'top' })
|
||||
loadAiMates() // 체크인 후 AI 궁합 추천 (비동기)
|
||||
} catch (e) {
|
||||
notifyError(e)
|
||||
} finally {
|
||||
@@ -170,17 +247,45 @@ async function onCheckIn() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 체크인 후 AI 궁합 추천 로드 — 이 스팟의 강아지 중 내 개와 사이좋게 지낼 만한 목록. */
|
||||
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, neutered)
|
||||
} catch {
|
||||
aiMates.value = []
|
||||
} finally {
|
||||
aiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onMatch(mate: Mate) {
|
||||
if (dogs.currentDogId == null) {
|
||||
$q.notify({ color: 'warning', message: '먼저 우리 개를 등록해 주세요.', icon: 'pets', position: 'top' })
|
||||
$q.notify({ color: 'warning', message: '먼저 우리 강아지를 등록해 주세요.', icon: 'pets', position: 'top' })
|
||||
return
|
||||
}
|
||||
matchingDogId.value = mate.dogId
|
||||
try {
|
||||
await matchesApi.create(dogs.currentDogId, mate.dogId)
|
||||
$q.notify({ color: 'positive', message: `${mate.name}에게 매칭을 신청했어요!`, icon: 'favorite', position: 'top' })
|
||||
matching.markSent(mate.dogId) // 버튼 "진행중" 표시
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: `${mate.name}에게 매칭을 신청했어요! (상대 수락 시 채팅)`,
|
||||
icon: 'favorite',
|
||||
position: 'top',
|
||||
})
|
||||
} catch (e) {
|
||||
notifyError(e)
|
||||
// 429: 무료 매칭 횟수 소진
|
||||
const msg = e instanceof ApiError ? e.message : '매칭 신청에 실패했습니다.'
|
||||
$q.notify({
|
||||
color: e instanceof ApiError && e.status === 429 ? 'warning' : 'negative',
|
||||
message: msg,
|
||||
icon: e instanceof ApiError && e.status === 429 ? 'block' : 'error',
|
||||
position: 'top',
|
||||
})
|
||||
} finally {
|
||||
matchingDogId.value = null
|
||||
}
|
||||
|
||||
+29
-13
@@ -1,10 +1,13 @@
|
||||
<template>
|
||||
<q-page class="flex flex-center column q-pa-lg bg-brand">
|
||||
<!-- 로그인은 MainLayout(QLayout) 밖의 독립 화면 → QPage 를 자체 QLayout 으로 감싼다. -->
|
||||
<q-layout view="hHh lpR fFf">
|
||||
<q-page-container>
|
||||
<q-page class="flex flex-center column q-pa-lg bg-brand">
|
||||
<div class="column items-center q-mb-xl">
|
||||
<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-white q-mt-xs" style="opacity: 0.9">
|
||||
우리 개 성향에 맞는 동네 산책 메이트
|
||||
우리 강아지 성향에 맞는 동네 산책 메이트
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,8 +18,9 @@
|
||||
{{ googleError }}
|
||||
</div>
|
||||
|
||||
<!-- 애플 로그인 -->
|
||||
<!-- 애플 로그인 (네이티브 iOS 전용) -->
|
||||
<q-btn
|
||||
v-if="appleAvailable"
|
||||
class="full-width q-py-sm"
|
||||
color="black"
|
||||
text-color="white"
|
||||
@@ -46,7 +50,9 @@
|
||||
<div class="text-caption text-white q-mt-lg" style="opacity: 0.8">
|
||||
계속 진행하면 이용약관 및 개인정보 처리방침에 동의하게 됩니다.
|
||||
</div>
|
||||
</q-page>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -56,6 +62,7 @@ import { useQuasar } from 'quasar'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { isAppleSignInAvailable, signInWithApple } from '@/lib/appleAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -65,6 +72,7 @@ const auth = useAuthStore()
|
||||
const googleBtn = ref<HTMLElement | null>(null)
|
||||
const googleError = ref('')
|
||||
const appleLoading = ref(false)
|
||||
const appleAvailable = isAppleSignInAvailable()
|
||||
const devLoading = ref(false)
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
@@ -117,16 +125,24 @@ function loadGsi(): Promise<void> {
|
||||
}
|
||||
|
||||
async function onApple() {
|
||||
// 네이티브(iOS)에서는 Capacitor Sign in with Apple 플러그인으로 identityToken 을 받아
|
||||
// auth.loginApple(identityToken, name) 을 호출하도록 연동 예정.
|
||||
appleLoading.value = true
|
||||
$q.notify({
|
||||
color: 'grey-8',
|
||||
message: '애플 로그인은 네이티브(iOS) 플러그인 연동이 필요합니다.',
|
||||
icon: 'mdi-apple',
|
||||
position: 'top',
|
||||
})
|
||||
appleLoading.value = false
|
||||
try {
|
||||
const { identityToken, name } = await signInWithApple()
|
||||
await auth.loginApple(identityToken, name)
|
||||
redirectAfterLogin()
|
||||
} catch (e) {
|
||||
// 사용자가 시트를 취소한 경우는 조용히 무시.
|
||||
if (isAppleCancel(e)) return
|
||||
notifyError(e)
|
||||
} finally {
|
||||
appleLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 애플 로그인 시트 취소 판별 — 플러그인이 취소를 에러로 reject 한다. */
|
||||
function isAppleCancel(e: unknown): boolean {
|
||||
const msg = (e instanceof Error ? e.message : String(e)).toLowerCase()
|
||||
return msg.includes('cancel') || msg.includes('1001') || msg.includes('popup_closed')
|
||||
}
|
||||
|
||||
async function onDevLogin() {
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
<template v-else>
|
||||
<!-- 강아지 선택 (여러 마리) -->
|
||||
<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-btn dense flat color="primary" icon="add" label="추가" @click="openCreate" />
|
||||
</div>
|
||||
@@ -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" />
|
||||
@@ -89,7 +94,7 @@
|
||||
<!-- 강아지 등록 다이얼로그 -->
|
||||
<q-dialog v-model="createOpen">
|
||||
<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-input v-model="form.name" label="이름 *" dense outlined />
|
||||
<q-input v-model="form.breed" label="견종" dense outlined />
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
<template>
|
||||
<q-layout view="lHh Lpr lFf">
|
||||
<q-page-container>
|
||||
<q-page class="flex flex-center bg-grey-2">
|
||||
<q-card flat bordered class="login-card q-pa-lg">
|
||||
<div class="text-center q-mb-lg">
|
||||
<q-icon name="pets" size="42px" color="primary" />
|
||||
<div class="text-h6 text-weight-bold q-mt-sm">산책갈개 관리자</div>
|
||||
<div class="text-caption text-grey-6">관리자 계정으로 로그인하세요</div>
|
||||
</div>
|
||||
|
||||
<!-- 구글 로그인 버튼 렌더 위치 -->
|
||||
<div class="flex flex-center">
|
||||
<div ref="googleBtn" />
|
||||
</div>
|
||||
|
||||
<!-- 개발자 로그인 (local 백엔드 전용, dev 빌드에서만 노출) -->
|
||||
<div v-if="isDev" class="q-mt-lg">
|
||||
<q-separator class="q-mb-md" />
|
||||
<div class="text-caption text-grey-6 text-center q-mb-sm">개발용 (local 백엔드)</div>
|
||||
<q-btn
|
||||
outline
|
||||
color="primary"
|
||||
class="full-width"
|
||||
icon="developer_mode"
|
||||
label="개발자 로그인 (admin@dog.com)"
|
||||
:loading="loading"
|
||||
@click="onDevLogin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-inner-loading :showing="loading" />
|
||||
|
||||
<q-banner v-if="error" dense class="bg-red-1 text-red-8 q-mt-md rounded-borders">
|
||||
<template #avatar><q-icon name="error" /></template>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
|
||||
<div v-if="!clientId && ready" class="text-caption text-grey-6 text-center q-mt-md">
|
||||
구글 로그인이 설정되지 않았습니다. (서버 GOOGLE_CLIENT_ID 확인)
|
||||
</div>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</q-page-container>
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAdminAuthStore } from '@/stores/adminAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const admin = useAdminAuthStore()
|
||||
|
||||
const googleBtn = ref<HTMLElement | null>(null)
|
||||
const clientId = ref('')
|
||||
const ready = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const GSI_SRC = 'https://accounts.google.com/gsi/client'
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
clientId.value = (await authApi.googleClientId()).clientId
|
||||
} catch {
|
||||
error.value = '서버에 연결하지 못했습니다.'
|
||||
ready.value = true
|
||||
return
|
||||
}
|
||||
ready.value = true
|
||||
if (!clientId.value) return
|
||||
|
||||
try {
|
||||
await loadGsi()
|
||||
window.google!.accounts.id.initialize({
|
||||
client_id: clientId.value,
|
||||
callback: onCredential,
|
||||
})
|
||||
window.google!.accounts.id.renderButton(googleBtn.value as HTMLElement, {
|
||||
type: 'standard',
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'signin_with',
|
||||
shape: 'pill',
|
||||
width: 260,
|
||||
})
|
||||
} catch {
|
||||
error.value = '구글 로그인 초기화에 실패했습니다.'
|
||||
}
|
||||
})
|
||||
|
||||
function loadGsi(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.google?.accounts?.id) return resolve()
|
||||
const el = document.createElement('script')
|
||||
el.src = GSI_SRC
|
||||
el.async = true
|
||||
el.defer = true
|
||||
el.onload = () => resolve()
|
||||
el.onerror = () => reject(new Error('GSI load failed'))
|
||||
document.head.appendChild(el)
|
||||
})
|
||||
}
|
||||
|
||||
function onCredential(response: GsiCredentialResponse) {
|
||||
finishLogin(() => admin.loginWithGoogle(response.credential))
|
||||
}
|
||||
|
||||
function onDevLogin() {
|
||||
finishLogin(() => admin.loginDev('admin@dog.com'))
|
||||
}
|
||||
|
||||
/** 로그인 실행 → ADMIN 확인 → 회원관리로 이동. 공용. */
|
||||
async function finishLogin(login: () => Promise<{ role: string }>) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const member = await login()
|
||||
if (member.role !== 'ADMIN') {
|
||||
await admin.logout()
|
||||
error.value = '관리자 권한이 없는 계정입니다.'
|
||||
return
|
||||
}
|
||||
await router.replace({ name: 'admin-members' })
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : '로그인 중 오류가 발생했습니다.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-card {
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
</style>
|
||||
@@ -1,248 +0,0 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<!-- 상단 툴바 -->
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h6 text-weight-bold">회원 관리</div>
|
||||
<q-chip dense color="blue-1" text-color="primary" class="q-ml-sm">
|
||||
총 {{ members.length }}명
|
||||
</q-chip>
|
||||
<q-space />
|
||||
<q-input
|
||||
v-model="filter"
|
||||
dense
|
||||
outlined
|
||||
debounce="200"
|
||||
placeholder="이메일·닉네임 검색"
|
||||
class="q-mr-sm"
|
||||
style="min-width: 220px"
|
||||
>
|
||||
<template #prepend><q-icon name="search" /></template>
|
||||
</q-input>
|
||||
<q-btn flat round icon="refresh" :loading="loading" @click="load" />
|
||||
</div>
|
||||
|
||||
<q-banner v-if="loadError" dense class="bg-red-1 text-red-8 q-mb-md rounded-borders">
|
||||
<template #avatar><q-icon name="wifi_off" /></template>
|
||||
회원 목록을 불러오지 못했습니다.
|
||||
</q-banner>
|
||||
|
||||
<q-table
|
||||
:rows="members"
|
||||
:columns="columns"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:filter="filter"
|
||||
:pagination="{ rowsPerPage: 20 }"
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<!-- 회원(이메일+닉네임+아바타) -->
|
||||
<template #body-cell-member="props">
|
||||
<q-td :props="props">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-avatar size="32px" color="blue-1" text-color="primary">
|
||||
<img v-if="props.row.profileImage" :src="props.row.profileImage" alt="" />
|
||||
<q-icon v-else name="person" />
|
||||
</q-avatar>
|
||||
<div class="q-ml-sm">
|
||||
<div class="text-weight-medium">
|
||||
{{ props.row.nickname }}
|
||||
<q-chip v-if="isSelf(props.row)" dense size="sm" color="grey-3" text-color="grey-8">본인</q-chip>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6">{{ props.row.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- 제공자 -->
|
||||
<template #body-cell-provider="props">
|
||||
<q-td :props="props">
|
||||
<q-chip dense square size="sm" :color="props.row.provider === 'APPLE' ? 'grey-9' : 'red-2'"
|
||||
:text-color="props.row.provider === 'APPLE' ? 'white' : 'red-9'">
|
||||
{{ props.row.provider }}
|
||||
</q-chip>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- 상태 -->
|
||||
<template #body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-select
|
||||
dense options-dense outlined emit-value map-options
|
||||
:model-value="props.row.status"
|
||||
:options="statusOptions"
|
||||
:disable="isSelf(props.row) || busyId === props.row.id"
|
||||
style="min-width: 110px"
|
||||
@update:model-value="(v: MemberStatus) => onStatus(props.row, v)"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- 구독 티어 -->
|
||||
<template #body-cell-tier="props">
|
||||
<q-td :props="props">
|
||||
<q-select
|
||||
dense options-dense outlined emit-value map-options
|
||||
:model-value="props.row.subscriptionTier"
|
||||
:options="tierOptions"
|
||||
:disable="busyId === props.row.id"
|
||||
style="min-width: 120px"
|
||||
@update:model-value="(v: MemberTier) => onTier(props.row, v)"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- 권한 -->
|
||||
<template #body-cell-role="props">
|
||||
<q-td :props="props">
|
||||
<q-select
|
||||
dense options-dense outlined emit-value map-options
|
||||
:model-value="props.row.role"
|
||||
:options="roleOptions"
|
||||
:disable="isSelf(props.row) || busyId === props.row.id"
|
||||
style="min-width: 110px"
|
||||
@update:model-value="(v: MemberRole) => onRole(props.row, v)"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
|
||||
<!-- 삭제 -->
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
flat dense round icon="delete" color="negative"
|
||||
:disable="isSelf(props.row) || busyId === props.row.id"
|
||||
@click="onDelete(props.row)"
|
||||
/>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useQuasar, type QTableColumn } from 'quasar'
|
||||
import {
|
||||
adminMembersApi,
|
||||
type MemberRole,
|
||||
type MemberStatus,
|
||||
type MemberTier,
|
||||
} from '@/api/adminMembers'
|
||||
import type { Member } from '@/api/auth'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAdminAuthStore } from '@/stores/adminAuth'
|
||||
|
||||
const $q = useQuasar()
|
||||
const admin = useAdminAuthStore()
|
||||
|
||||
const members = ref<Member[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref(false)
|
||||
const filter = ref('')
|
||||
const busyId = ref<number | null>(null)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '활성', value: 'ACTIVE' },
|
||||
{ label: '휴면', value: 'DORMANT' },
|
||||
{ label: '정지', value: 'BANNED' },
|
||||
]
|
||||
const tierOptions = [
|
||||
{ label: 'FREE', value: 'FREE' },
|
||||
{ label: '광고제거', value: 'AD_FREE' },
|
||||
{ label: 'PREMIUM', value: 'PREMIUM' },
|
||||
]
|
||||
const roleOptions = [
|
||||
{ label: '일반', value: 'USER' },
|
||||
{ label: '관리자', value: 'ADMIN' },
|
||||
]
|
||||
|
||||
const columns: QTableColumn<Member>[] = [
|
||||
{ name: 'member', label: '회원', field: 'email', align: 'left', sortable: true },
|
||||
{ name: 'provider', label: '가입', field: 'provider', align: 'center' },
|
||||
{ name: 'status', label: '상태', field: 'status', align: 'center' },
|
||||
{ name: 'tier', label: '구독', field: 'subscriptionTier', align: 'center' },
|
||||
{ name: 'role', label: '권한', field: 'role', align: 'center' },
|
||||
{
|
||||
name: 'createdAt',
|
||||
label: '가입일',
|
||||
field: 'createdAt',
|
||||
align: 'center',
|
||||
sortable: true,
|
||||
format: (v: string) => new Date(v).toLocaleDateString('ko-KR'),
|
||||
},
|
||||
{ name: 'actions', label: '', field: 'id', align: 'center' },
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function isSelf(m: Member): boolean {
|
||||
return m.id === admin.member?.id
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
members.value = await adminMembersApi.list()
|
||||
} catch (e) {
|
||||
loadError.value = true
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 변경 API 공통 실행 — 성공 시 해당 행 갱신, 실패 시 알림. */
|
||||
async function run(id: number, fn: () => Promise<Member>, done: string) {
|
||||
busyId.value = id
|
||||
try {
|
||||
const updated = await fn()
|
||||
const i = members.value.findIndex((m) => m.id === id)
|
||||
if (i >= 0) members.value[i] = updated
|
||||
$q.notify({ color: 'positive', message: done, icon: 'check', position: 'top' })
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : '변경에 실패했습니다.'
|
||||
$q.notify({ color: 'negative', message: msg, icon: 'error', position: 'top' })
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onStatus(m: Member, status: MemberStatus) {
|
||||
if (status === m.status) return
|
||||
run(m.id, () => adminMembersApi.updateStatus(m.id, status), '상태를 변경했습니다.')
|
||||
}
|
||||
|
||||
function onTier(m: Member, tier: MemberTier) {
|
||||
if (tier === m.subscriptionTier) return
|
||||
run(m.id, () => adminMembersApi.updateTier(m.id, tier), '구독 티어를 변경했습니다.')
|
||||
}
|
||||
|
||||
function onRole(m: Member, role: MemberRole) {
|
||||
if (role === m.role) return
|
||||
run(m.id, () => adminMembersApi.updateRole(m.id, role), '권한을 변경했습니다.')
|
||||
}
|
||||
|
||||
function onDelete(m: Member) {
|
||||
$q.dialog({
|
||||
title: '회원 삭제',
|
||||
message: `${m.nickname}(${m.email}) 회원을 삭제할까요? 되돌릴 수 없습니다.`,
|
||||
cancel: true,
|
||||
ok: { label: '삭제', color: 'negative' },
|
||||
}).onOk(async () => {
|
||||
busyId.value = m.id
|
||||
try {
|
||||
await adminMembersApi.remove(m.id)
|
||||
members.value = members.value.filter((x) => x.id !== m.id)
|
||||
$q.notify({ color: 'positive', message: '회원을 삭제했습니다.', icon: 'check', position: 'top' })
|
||||
} catch (e) {
|
||||
const msg = e instanceof ApiError ? e.message : '삭제에 실패했습니다.'
|
||||
$q.notify({ color: 'negative', message: msg, icon: 'error', position: 'top' })
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import MainLayout from '@/layouts/MainLayout.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAdminAuthStore } from '@/stores/adminAuth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -18,25 +17,6 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
||||
],
|
||||
},
|
||||
// 관리자 콘솔 (웹 전용) — 사용자 앱과 분리
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
component: () => import('@/pages/admin/AdminLoginPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
children: [
|
||||
{ path: '', redirect: { name: 'admin-members' } },
|
||||
{
|
||||
path: 'members',
|
||||
name: 'admin-members',
|
||||
component: () => import('@/pages/admin/AdminMembersPage.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
@@ -46,10 +26,6 @@ const router = createRouter({
|
||||
|
||||
// 소셜 로그인 전용 앱: 미로그인 시 보호 경로 접근 → 로그인으로
|
||||
router.beforeEach((to) => {
|
||||
// 관리자 경로는 아래 관리자 가드가 처리 (사용자 가드는 관여하지 않음)
|
||||
if (to.matched.some((r) => r.meta.requiresAdmin) || to.name === 'admin-login') {
|
||||
return true
|
||||
}
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
@@ -59,22 +35,4 @@ router.beforeEach((to) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 관리자 콘솔 가드 — 토큰/회원 복원 후 ADMIN 여부 확인 (사용자 인증과 분리)
|
||||
router.beforeEach(async (to) => {
|
||||
const needsAdmin = to.matched.some((r) => r.meta.requiresAdmin)
|
||||
if (!needsAdmin && to.name !== 'admin-login') return true
|
||||
|
||||
const admin = useAdminAuthStore()
|
||||
if (admin.hasToken() && !admin.member) {
|
||||
await admin.fetchMe()
|
||||
}
|
||||
if (needsAdmin && !admin.isAdmin) {
|
||||
return { name: 'admin-login' }
|
||||
}
|
||||
if (to.name === 'admin-login' && admin.isAdmin) {
|
||||
return { name: 'admin-members' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { authApi, type Member } from '@/api/auth'
|
||||
import { adminAuthApi } from '@/api/adminAuth'
|
||||
import { tokenStore } from '@/lib/token'
|
||||
|
||||
/**
|
||||
* 관리자 콘솔 전용 인증 스토어 (사용자 auth 스토어와 분리).
|
||||
* 저수준 토큰 저장소(tokenStore)와 소셜 로그인/조회(authApi)는 공용을 재사용한다.
|
||||
*/
|
||||
export const useAdminAuthStore = defineStore('adminAuth', () => {
|
||||
const member = ref<Member | null>(null)
|
||||
const isAdmin = computed(() => member.value?.role === 'ADMIN')
|
||||
|
||||
function hasToken(): boolean {
|
||||
return !!tokenStore.get()
|
||||
}
|
||||
|
||||
/** 구글 로그인 → 토큰 저장 + 회원 세팅. */
|
||||
async function loginWithGoogle(idToken: string): Promise<Member> {
|
||||
const res = await authApi.google(idToken)
|
||||
tokenStore.set(res.token)
|
||||
member.value = res.member
|
||||
return res.member
|
||||
}
|
||||
|
||||
/** 개발자 로그인 (local 백엔드 전용). */
|
||||
async function loginDev(email: string): Promise<Member> {
|
||||
const res = await adminAuthApi.devLogin(email)
|
||||
tokenStore.set(res.token)
|
||||
member.value = res.member
|
||||
return res.member
|
||||
}
|
||||
|
||||
/** 토큰으로 회원 복원(새로고침/가드). 실패 시 세션 정리. */
|
||||
async function fetchMe(): Promise<Member | null> {
|
||||
if (!tokenStore.get()) return null
|
||||
try {
|
||||
member.value = await authApi.me()
|
||||
return member.value
|
||||
} catch {
|
||||
clear()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
/* 서버 실패해도 로컬 세션은 정리 */
|
||||
}
|
||||
clear()
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
tokenStore.clear()
|
||||
member.value = null
|
||||
}
|
||||
|
||||
return { member, isAdmin, hasToken, loginWithGoogle, loginDev, fetchMe, logout, clear }
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { matchesApi, type MatchNotification, type ReceivedMatch } from '@/api/matches'
|
||||
import { createUserNotifications } from '@/lib/chatSocket'
|
||||
|
||||
/**
|
||||
* 매칭 상태/알림 스토어.
|
||||
* - 로그인 시 connect(userId, myDogId): 실시간 알림 구독 + 받은/보낸 매칭 로드
|
||||
* - received: 내 개가 받은 대기중 신청 (수락/거절 대상)
|
||||
* - sentPendingTargetIds: 내가 신청한 진행중 대상 dogId (버튼 "진행중" 표시)
|
||||
* - openChatMatchId: 수락된 매칭 → 1:1 채팅 열기 신호
|
||||
*/
|
||||
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
|
||||
|
||||
async function refresh(dogId: number): Promise<void> {
|
||||
myDogId = dogId
|
||||
try {
|
||||
const [rec, snt] = await Promise.all([matchesApi.received(dogId), matchesApi.sent(dogId)])
|
||||
received.value = rec
|
||||
sentPendingTargetIds.value = new Set(snt.map((m) => m.targetDogId))
|
||||
} catch {
|
||||
// 무시 (미로그인/네트워크)
|
||||
}
|
||||
}
|
||||
|
||||
function connect(userId: number, dogId: number): void {
|
||||
disconnect()
|
||||
myDogId = dogId
|
||||
void refresh(dogId)
|
||||
socket = createUserNotifications(userId, onNotification)
|
||||
socket.activate()
|
||||
}
|
||||
|
||||
function disconnect(): void {
|
||||
socket?.deactivate()
|
||||
socket = null
|
||||
received.value = []
|
||||
sentPendingTargetIds.value = new Set()
|
||||
}
|
||||
|
||||
function onNotification(n: MatchNotification): void {
|
||||
Notify.create({
|
||||
message: n.message,
|
||||
caption: n.title,
|
||||
color: colorFor(n.type),
|
||||
icon: iconFor(n.type),
|
||||
position: 'top',
|
||||
timeout: 4000,
|
||||
})
|
||||
if (n.type === 'ACCEPTED') {
|
||||
openChatMatchId.value = n.matchId // 신청자: 수락됨 → 채팅 열기
|
||||
}
|
||||
if (n.type === 'FEEDBACK_REQUEST') {
|
||||
feedbackMatchId.value = n.matchId // 2시간 후: 찰떡지수 피드백 팝업
|
||||
}
|
||||
if (myDogId != null) void refresh(myDogId) // 목록/진행중 동기화
|
||||
}
|
||||
|
||||
async function accept(id: number): Promise<void> {
|
||||
await matchesApi.accept(id)
|
||||
received.value = received.value.filter((m) => m.id !== id)
|
||||
openChatMatchId.value = id // 수락자도 바로 채팅 열기
|
||||
}
|
||||
|
||||
async function reject(id: number): Promise<void> {
|
||||
await matchesApi.reject(id)
|
||||
received.value = received.value.filter((m) => m.id !== id)
|
||||
}
|
||||
|
||||
/** 매칭 신청 직후 낙관적으로 진행중 표시. */
|
||||
function markSent(targetDogId: number): void {
|
||||
const next = new Set(sentPendingTargetIds.value)
|
||||
next.add(targetDogId)
|
||||
sentPendingTargetIds.value = next
|
||||
}
|
||||
|
||||
function isPending(targetDogId: number): boolean {
|
||||
return sentPendingTargetIds.value.has(targetDogId)
|
||||
}
|
||||
|
||||
return {
|
||||
received,
|
||||
sentPendingTargetIds,
|
||||
openChatMatchId,
|
||||
feedbackMatchId,
|
||||
connect,
|
||||
disconnect,
|
||||
refresh,
|
||||
accept,
|
||||
reject,
|
||||
markSent,
|
||||
isPending,
|
||||
}
|
||||
})
|
||||
|
||||
function colorFor(type: MatchNotification['type']): string {
|
||||
switch (type) {
|
||||
case 'ACCEPTED':
|
||||
return 'positive'
|
||||
case 'REJECTED':
|
||||
case 'EXPIRED':
|
||||
return 'grey-8'
|
||||
case 'FEEDBACK_REQUEST':
|
||||
return 'teal'
|
||||
default:
|
||||
return 'primary'
|
||||
}
|
||||
}
|
||||
|
||||
function iconFor(type: MatchNotification['type']): string {
|
||||
switch (type) {
|
||||
case 'NEW_REQUEST':
|
||||
return 'favorite'
|
||||
case 'ACCEPTED':
|
||||
return 'celebration'
|
||||
case 'EXPIRED':
|
||||
return 'schedule'
|
||||
case 'FEEDBACK_REQUEST':
|
||||
return 'rate_review'
|
||||
default:
|
||||
return 'heart_broken'
|
||||
}
|
||||
}
|
||||
Vendored
+76
@@ -0,0 +1,76 @@
|
||||
// 카카오 지도 JS SDK 최소 타입 선언 (프로젝트에서 사용하는 API 서브셋).
|
||||
// 공식 @types 가 없어 필요한 부분만 선언한다.
|
||||
declare namespace kakao.maps {
|
||||
class LatLng {
|
||||
constructor(lat: number, lng: number)
|
||||
getLat(): number
|
||||
getLng(): number
|
||||
}
|
||||
|
||||
class Map {
|
||||
constructor(container: HTMLElement, options: { center: LatLng; level?: number })
|
||||
panTo(latlng: LatLng): void
|
||||
setCenter(latlng: LatLng): void
|
||||
setLevel(level: number): void
|
||||
getLevel(): number
|
||||
addControl(control: ZoomControl, position: ControlPosition): void
|
||||
}
|
||||
|
||||
// 지도 확대/축소 +/- 위젯
|
||||
class ZoomControl {}
|
||||
|
||||
enum ControlPosition {
|
||||
TOP,
|
||||
TOPLEFT,
|
||||
TOPRIGHT,
|
||||
LEFT,
|
||||
RIGHT,
|
||||
BOTTOMLEFT,
|
||||
BOTTOM,
|
||||
BOTTOMRIGHT,
|
||||
}
|
||||
|
||||
// 현재위치 표시용 원(파란 점)
|
||||
class Circle {
|
||||
constructor(options: {
|
||||
center: LatLng
|
||||
radius: number
|
||||
strokeWeight?: number
|
||||
strokeColor?: string
|
||||
strokeOpacity?: number
|
||||
fillColor?: string
|
||||
fillOpacity?: number
|
||||
})
|
||||
setMap(map: Map | null): void
|
||||
setPosition(latlng: LatLng): void
|
||||
}
|
||||
|
||||
class Marker {
|
||||
constructor(options: { position: LatLng; map?: Map; title?: string })
|
||||
setMap(map: Map | null): void
|
||||
getPosition(): LatLng
|
||||
}
|
||||
|
||||
class InfoWindow {
|
||||
constructor(options: { content?: string; removable?: boolean })
|
||||
setContent(content: string): void
|
||||
open(map: Map, marker?: Marker): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
// 지도 클릭 등 좌표를 포함하는 이벤트
|
||||
interface MouseEvent {
|
||||
latLng: LatLng
|
||||
}
|
||||
|
||||
namespace event {
|
||||
function addListener(
|
||||
target: unknown,
|
||||
type: string,
|
||||
handler: (event?: MouseEvent) => void,
|
||||
): void
|
||||
}
|
||||
|
||||
// autoload=false 로 로드한 뒤 SDK 초기화 콜백.
|
||||
function load(callback: () => void): void
|
||||
}
|
||||
Reference in New Issue
Block a user