feat: 매칭 플로우 프론트 (진행중·알림·수락/거절·1:1 채팅)
CI / build (push) Failing after 14m12s

- 매칭 버튼: 신청 후 "진행중" 표시, 무료 소진(429) 안내
- 헤더 알림 벨: 받은 매칭 신청 목록 + 수락/거절
- 실시간 알림: STOMP /topic/users/{id} 구독(신청/수락/거절/만료 토스트)
- 수락 시 1:1 매칭 채팅(MatchChatDialog, /topic/matches/{id})

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 15:04:21 +09:00
parent 12dd26c47f
commit cc3a0fa836
7 changed files with 459 additions and 5 deletions
+122
View File
@@ -0,0 +1,122 @@
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)
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 (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,
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'
default:
return 'primary'
}
}
function iconFor(type: MatchNotification['type']): string {
switch (type) {
case 'NEW_REQUEST':
return 'favorite'
case 'ACCEPTED':
return 'celebration'
case 'EXPIRED':
return 'schedule'
default:
return 'heart_broken'
}
}