feat: 백엔드 API 연동 (HTTP 클라이언트 + 홈 화면 실데이터)

- VITE_API_BASE_URL 기반 fetch 클라이언트 및 ApiError 처리
- api/spots, api/matches 타입드 모듈
- HomePage: 스팟/한줄평/메이트 실제 로드, 체크인·매칭 신청 API 연동
- 임시 세션 스토어(인증 도입 전, userId/dogId)
- 로딩/에러/빈 상태 처리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 08:07:53 +09:00
parent 3130ce3fb7
commit bbafb3ed4d
5 changed files with 200 additions and 29 deletions
+37
View File
@@ -0,0 +1,37 @@
// 공통 HTTP 클라이언트 — VITE_API_BASE_URL 기준
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
body: body !== undefined ? JSON.stringify(body) : undefined,
})
const text = await res.text()
const data = text ? JSON.parse(text) : null
if (!res.ok) {
const code = (data && data.code) || 'ERROR'
const message = (data && data.message) || `요청 실패 (${res.status})`
throw new ApiError(res.status, code, message)
}
return data as T
}
export const http = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
}
+18
View File
@@ -0,0 +1,18 @@
import { http } from './http'
export interface Match {
id: number
requesterDogId: number
targetDogId: number
status: string
createdAt: string
respondedAt: string | null
}
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}`),
}
+39
View File
@@ -0,0 +1,39 @@
import { http } from './http'
export interface Spot {
id: number
name: string
type: string
latitude: number | null
longitude: number | null
}
export interface Mate {
dogId: number
name: string
breed: string | null
}
export interface Review {
id: number
tag: string | null
content: string | null
createdAt: string
}
export interface CheckInResult {
id: number
spotId: number
dogId: number
expiresAt: string
}
export const spotsApi = {
list: () => http.get<Spot[]>('/api/spots'),
mates: (spotId: number) => http.get<Mate[]>(`/api/spots/${spotId}/mates`),
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 }),
addReview: (spotId: number, userId: number, tag: string) =>
http.post<Review>(`/api/spots/${spotId}/reviews`, { userId, tag }),
}
+93 -29
View File
@@ -4,15 +4,25 @@
<q-card flat bordered class="map-placeholder flex flex-center q-mb-md">
<div class="text-center text-grey-6">
<q-icon name="map" size="48px" color="secondary" />
<div class="q-mt-sm text-caption">지도 영역 (스팟 앵커 표시 예정)</div>
<div class="q-mt-sm text-caption">
{{ currentSpot ? currentSpot.name : '스팟 불러오는 중…' }}
</div>
</div>
</q-card>
<!-- 로딩 / 에러 -->
<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-btn
color="primary"
class="full-width q-py-sm"
unelevated
:loading="checkingIn"
:disable="!currentSpot"
icon="place"
:label="checkedIn ? '체크인 완료 · 여기 있어요!' : '나 지금 여기 도착!'"
@click="onCheckIn"
@@ -20,39 +30,43 @@
<!-- 스팟 한줄평 (네이버 지도식 태그형) -->
<div class="text-subtitle1 text-weight-bold q-mt-lg q-mb-sm">
한강공원 산책로 · 오늘의 한줄평
{{ currentSpot ? currentSpot.name : '스팟' }} · 오늘의 한줄평
</div>
<div class="row q-gutter-sm">
<div v-if="reviews.length" class="row q-gutter-sm">
<q-chip
v-for="review in spotReviews"
:key="review"
v-for="review in reviews"
:key="review.id"
color="secondary"
text-color="accent"
icon="tag"
>
{{ review }}
{{ review.tag || review.content }}
</q-chip>
</div>
<div v-else class="text-caption text-grey-5">아직 한줄평이 없어요.</div>
<!-- 현재 체크인 중인 메이트 -->
<div class="text-subtitle1 text-weight-bold q-mt-lg q-mb-sm">지금 스팟의 산책 메이트</div>
<q-card
v-for="mate in mates"
:key="mate.name"
flat
bordered
class="q-mb-sm"
>
<div v-if="!mates.length" class="text-caption text-grey-5">체크인 중인 메이트가 없어요.</div>
<q-card v-for="mate in mates" :key="mate.dogId" flat bordered class="q-mb-sm">
<q-item>
<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">{{ mate.name }}</q-item-label>
<q-item-label caption>{{ mate.traits.join(' · ') }}</q-item-label>
<q-item-label caption>{{ mate.breed || '견종 미상' }}</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn dense flat round color="primary" icon="chat_bubble_outline" />
<q-btn
dense
unelevated
color="primary"
icon="favorite"
label="매칭"
:loading="matchingDogId === mate.dogId"
@click="onMatch(mate)"
/>
</q-item-section>
</q-item>
</q-card>
@@ -60,27 +74,77 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useQuasar } from 'quasar'
import { spotsApi, type Mate, type Review, type Spot } from '@/api/spots'
import { matchesApi } from '@/api/matches'
import { ApiError } from '@/api/http'
import { useSessionStore } from '@/stores/session'
const $q = useQuasar()
const session = useSessionStore()
const currentSpot = ref<Spot | null>(null)
const reviews = ref<Review[]>([])
const mates = ref<Mate[]>([])
const checkedIn = ref(false)
const checkingIn = ref(false)
const matchingDogId = ref<number | null>(null)
const loadError = ref(false)
const spotReviews = ['진드기 많아요', '풀숲 무성함', '그늘 시원해요', '물그릇 있음']
onMounted(load)
const mates = [
{ name: '콩이 (말티즈)', traits: ['대형견 무서워함', '사회화 훈련 중'] },
{ name: '보리 (리트리버)', traits: ['터그놀이 매니아', '사람 좋아함'] },
]
async function load() {
loadError.value = false
try {
const spots = await spotsApi.list()
currentSpot.value = spots[0] ?? null
if (currentSpot.value) {
await refreshSpot(currentSpot.value.id)
}
} catch (e) {
loadError.value = true
console.error(e)
}
}
function onCheckIn() {
checkedIn.value = !checkedIn.value
$q.notify({
color: checkedIn.value ? 'primary' : 'grey-6',
message: checkedIn.value ? '체크인 완료! 스팟 채팅이 열렸어요.' : '체크아웃 되었습니다.',
icon: checkedIn.value ? 'place' : 'logout',
position: 'top',
})
async function refreshSpot(spotId: number) {
const [r, m] = await Promise.all([spotsApi.reviews(spotId), spotsApi.mates(spotId)])
reviews.value = r
mates.value = m
}
async function onCheckIn() {
if (!currentSpot.value) return
checkingIn.value = true
try {
await spotsApi.checkIn(currentSpot.value.id, session.userId, session.dogId)
checkedIn.value = true
await refreshSpot(currentSpot.value.id)
$q.notify({ color: 'primary', message: '체크인 완료! 스팟 채팅이 열렸어요.', icon: 'place', position: 'top' })
} catch (e) {
notifyError(e)
} finally {
checkingIn.value = false
}
}
async function onMatch(mate: Mate) {
matchingDogId.value = mate.dogId
try {
await matchesApi.create(session.dogId, mate.dogId)
$q.notify({ color: 'positive', message: `${mate.name}에게 매칭을 신청했어요!`, icon: 'favorite', position: 'top' })
} catch (e) {
notifyError(e)
} finally {
matchingDogId.value = null
}
}
function notifyError(e: unknown) {
const message =
e instanceof ApiError ? e.message : '요청 중 오류가 발생했습니다. 서버 연결을 확인하세요.'
$q.notify({ color: 'negative', message, icon: 'error', position: 'top' })
}
</script>
+13
View File
@@ -0,0 +1,13 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
/**
* 임시 세션 스토어 (인증 도입 전까지 사용).
* local 시드 데이터 기준: 내 유저=1, 내 강아지(몽이)=1.
* 인증(4번) 도입 시 로그인 응답으로 대체.
*/
export const useSessionStore = defineStore('session', () => {
const userId = ref<number>(1)
const dogId = ref<number>(1)
return { userId, dogId }
})