feat: 프론트엔드 초기 스캐폴딩 (Vue3+TS+Quasar+Capacitor)

- 하늘색 파스텔 브랜드 테마(quasar-variables)
- 스팟 지도/체크인/한줄평/메이트 홈 화면
- 강아지 프로필 + 댕비티아이 성향 태그 화면
- 구독 티어 스토어(Pinia) 및 FREE 티어 배너 광고(v-if 제어)
- Capacitor(iOS/AOS) 설정, 빌드/타입체크 통과

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 07:50:47 +09:00
commit 3130ce3fb7
20 changed files with 4239 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# 백엔드 API 주소 (dognation_BT)
VITE_API_BASE_URL=http://localhost:8080
+26
View File
@@ -0,0 +1,26 @@
# dependencies
node_modules/
# build output
dist/
dist-ssr/
# env
.env
.env.*
!.env.example
# capacitor native (별도 관리)
/ios/App/Pods/
/android/.gradle/
/android/app/build/
# logs
*.log
npm-debug.log*
# editor / OS
.vscode/
.idea/
.DS_Store
*.local
+39
View File
@@ -0,0 +1,39 @@
# 산책갈개 · dognation_PT (Frontend)
우리 개 성향에 맞는 동네 산책 메이트를 안전하게 찾는 iOS/AOS 앱의 프론트엔드.
## 스택
- **Vue 3 + TypeScript**
- **Quasar** (UI, 하늘색 파스텔 테마)
- **Vite** (번들러)
- **Pinia** (상태관리 · 구독 티어)
- **Capacitor** (iOS/AOS 네이티브 패키징)
## 시작하기
```bash
npm install
cp .env.example .env # VITE_API_BASE_URL 설정
npm run dev # 웹 개발 서버 (http://localhost:9000)
```
## 빌드
```bash
npm run build # 타입체크 + 웹 빌드
npm run build:app # Capacitor용 빌드 (상대경로)
```
## 네이티브(추가 예정)
```bash
npx cap add ios
npx cap add android
npx cap sync
```
## 구조
- `src/pages/HomePage.vue` — 스팟 지도 / 체크인 / 한줄평 / 메이트
- `src/pages/ProfilePage.vue` — 강아지 프로필 / 댕비티아이 성향 태그 / 구독 티어
- `src/stores/subscription.ts` — 구독 티어 상태 (광고 노출·매칭 제한 파생)
- `src/components/AdBanner.vue` — FREE 티어 배너 광고 (v-if 제어)
- `src/quasar-variables.scss` — 하늘색 파스텔 브랜드 컬러
> 백엔드: [dognation_BT](https://gitlab.sblog.kr/sb/dognation_BT)
+15
View File
@@ -0,0 +1,15 @@
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'kr.sblog.dognation',
appName: '산책갈개',
webDir: 'dist',
ios: {
contentInset: 'always',
},
server: {
androidScheme: 'https',
},
}
export default config
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#6DB3E8" />
<title>산책갈개</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3699
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "dognation_pt",
"version": "0.0.1",
"private": true,
"description": "산책갈개 - 우리 개 성향에 맞는 동네 산책 메이트 (iOS/AOS)",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:app": "vite build --mode capacitor",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.3.4",
"@quasar/extras": "^1.16.12",
"pinia": "^2.3.0",
"quasar": "^2.17.4",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@capacitor/android": "^8.3.4",
"@capacitor/cli": "^8.3.4",
"@capacitor/ios": "^8.3.4",
"@quasar/vite-plugin": "^1.9.0",
"@vitejs/plugin-vue": "^5.2.1",
"sass-embedded": "^1.83.0",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// 루트 컴포넌트
</script>
+22
View File
@@ -0,0 +1,22 @@
<template>
<!-- FREE 티어에서만 노출. AD_FREE/PREMIUM hideAds 숨김 -->
<div v-if="!subscription.hideAds" class="ad-banner row items-center justify-center">
<q-icon name="campaign" size="20px" class="q-mr-sm" />
<span class="text-caption">광고 · 광고 없이 쓰려면 광고 제거 패스</span>
</div>
</template>
<script setup lang="ts">
import { useSubscriptionStore } from '@/stores/subscription'
const subscription = useSubscriptionStore()
</script>
<style scoped>
.ad-banner {
height: 56px;
background: #e8f4fd;
color: #3e92cc;
border-top: 1px solid #cfe8fb;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
// 전역 스타일 — 하늘색 파스텔 배경
body {
background: #F4FAFF;
}
.text-brand {
color: #3E92CC;
}
// iOS 노치/홈바 safe-area 대응
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
+15
View File
@@ -0,0 +1,15 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+53
View File
@@ -0,0 +1,53 @@
<template>
<q-layout view="hHh lpR fFf">
<q-header class="bg-primary text-white safe-top">
<q-toolbar>
<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="bolt">
{{ tierLabel }}
</q-chip>
</q-toolbar>
</q-header>
<q-page-container>
<router-view />
</q-page-container>
<!-- FREE 티어 하단 배너 광고 -->
<AdBanner />
<q-footer class="bg-white text-grey-8 safe-bottom">
<q-tabs
v-model="tab"
active-color="primary"
indicator-color="primary"
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-tabs>
</q-footer>
</q-layout>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import AdBanner from '@/components/AdBanner.vue'
import { useSubscriptionStore } from '@/stores/subscription'
const tab = ref('home')
const subscription = useSubscriptionStore()
const tierLabel = computed(() => {
switch (subscription.tier) {
case 'PREMIUM':
return 'PREMIUM'
case 'AD_FREE':
return '광고제거'
default:
return 'FREE'
}
})
</script>
+28
View File
@@ -0,0 +1,28 @@
import { createApp } from 'vue'
import { Quasar, Notify } from 'quasar'
import { createPinia } from 'pinia'
// Quasar 필수 스타일 & 아이콘
import '@quasar/extras/material-icons/material-icons.css'
import '@quasar/extras/mdi-v7/mdi-v7.css'
import 'quasar/src/css/index.sass'
import App from './App.vue'
import router from './router'
import './css/app.scss'
const app = createApp(App)
app.use(Quasar, {
plugins: { Notify },
config: {
brand: {
// quasar-variables.scss 와 일치 (런타임 참고용)
primary: '#6DB3E8',
},
},
})
app.use(createPinia())
app.use(router)
app.mount('#app')
+92
View File
@@ -0,0 +1,92 @@
<template>
<q-page class="q-pa-md">
<!-- 지도 영역 (추후 네이버/카카오 지도 SDK 연동 자리) -->
<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>
</q-card>
<!-- 체크인 -->
<q-btn
color="primary"
class="full-width q-py-sm"
unelevated
icon="place"
:label="checkedIn ? '체크인 완료 · 여기 있어요!' : '나 지금 여기 도착!'"
@click="onCheckIn"
/>
<!-- 스팟 한줄평 (네이버 지도식 태그형) -->
<div class="text-subtitle1 text-weight-bold q-mt-lg q-mb-sm">
한강공원 산책로 · 오늘의 한줄평
</div>
<div class="row q-gutter-sm">
<q-chip
v-for="review in spotReviews"
:key="review"
color="secondary"
text-color="accent"
icon="tag"
>
{{ review }}
</q-chip>
</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"
>
<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-section>
<q-item-section side>
<q-btn dense flat round color="primary" icon="chat_bubble_outline" />
</q-item-section>
</q-item>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useQuasar } from 'quasar'
const $q = useQuasar()
const checkedIn = ref(false)
const spotReviews = ['진드기 많아요', '풀숲 무성함', '그늘 시원해요', '물그릇 있음']
const mates = [
{ name: '콩이 (말티즈)', traits: ['대형견 무서워함', '사회화 훈련 중'] },
{ name: '보리 (리트리버)', traits: ['터그놀이 매니아', '사람 좋아함'] },
]
function onCheckIn() {
checkedIn.value = !checkedIn.value
$q.notify({
color: checkedIn.value ? 'primary' : 'grey-6',
message: checkedIn.value ? '체크인 완료! 스팟 채팅이 열렸어요.' : '체크아웃 되었습니다.',
icon: checkedIn.value ? 'place' : 'logout',
position: 'top',
})
}
</script>
<style scoped>
.map-placeholder {
height: 240px;
background: linear-gradient(160deg, #eaf5fe 0%, #d7ecfb 100%);
}
</style>
+73
View File
@@ -0,0 +1,73 @@
<template>
<q-page class="q-pa-md">
<!-- 강아지 프로필 카드 -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="row items-center">
<q-avatar size="64px" color="secondary" text-color="accent" icon="pets" />
<div class="q-ml-md">
<div class="text-h6">몽이</div>
<div class="text-caption text-grey-7">푸들 · 3 · 소형견</div>
</div>
</q-card-section>
</q-card>
<!-- 댕비티아이(성향 태그) -->
<div class="text-subtitle1 text-weight-bold q-mb-sm">댕비티아이 · 성향 태그</div>
<div class="row q-gutter-sm q-mb-lg">
<q-chip
v-for="trait in traits"
:key="trait.code"
:color="trait.selected ? 'primary' : 'grey-3'"
:text-color="trait.selected ? 'white' : 'grey-7'"
clickable
@click="trait.selected = !trait.selected"
>
{{ trait.label }}
</q-chip>
</div>
<!-- 구독 티어 전환 (개발용 데모) -->
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle2 q-mb-sm">구독 티어 (데모)</div>
<q-btn-toggle
v-model="tier"
spread
no-caps
toggle-color="primary"
color="grey-3"
text-color="grey-8"
:options="[
{ label: 'FREE', value: 'FREE' },
{ label: '광고제거', value: 'AD_FREE' },
{ label: 'PREMIUM', value: 'PREMIUM' },
]"
@update:model-value="onTierChange"
/>
<div class="text-caption text-grey-6 q-mt-sm">
FREE 에서만 하단 배너 광고가 노출됩니다.
</div>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useSubscriptionStore, type SubscriptionTier } from '@/stores/subscription'
const subscription = useSubscriptionStore()
const tier = ref<SubscriptionTier>(subscription.tier)
const traits = ref([
{ code: 'FEAR_BIG_DOG', label: '대형견 무서워함', selected: true },
{ code: 'TUG_LOVER', label: '터그놀이 매니아', selected: true },
{ code: 'SOCIALIZING', label: '사회화 훈련 중', selected: false },
{ code: 'LOVE_PEOPLE', label: '사람 좋아함', selected: true },
{ code: 'SHY', label: '낯가림 있음', selected: false },
])
function onTierChange(next: SubscriptionTier) {
subscription.setTier(next)
}
</script>
+14
View File
@@ -0,0 +1,14 @@
// ============================================================
// 산책갈개 브랜드 컬러 — 하늘색 파스텔톤
// ============================================================
$primary : #6DB3E8; // 메인 하늘색
$secondary : #BFE3FB; // 파스텔 라이트 블루
$accent : #3E92CC; // 포인트(진한 하늘색)
$dark : #1D1D1D;
$dark-page : #121212;
$positive : #7FD1AE;
$negative : #F2A6A6;
$info : #A9D6F5;
$warning : #FCE5A6;
+20
View File
@@ -0,0 +1,20 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import MainLayout from '@/layouts/MainLayout.vue'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: MainLayout,
children: [
{ path: '', name: 'home', component: () => import('@/pages/HomePage.vue') },
{ path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') },
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
+28
View File
@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export type SubscriptionTier = 'FREE' | 'AD_FREE' | 'PREMIUM'
/**
* 구독 상태 스토어.
* - 광고 노출 여부(hideAds) 와 매칭 무제한 여부(unlimitedMatching)를 파생.
* - AdBanner 등 광고 컴포넌트는 hideAds 로 v-if 제어.
*/
export const useSubscriptionStore = defineStore('subscription', () => {
const tier = ref<SubscriptionTier>('FREE')
// FREE 만 광고 노출, AD_FREE·PREMIUM 은 광고 제거
const hideAds = computed(() => tier.value !== 'FREE')
// PREMIUM 만 매칭 무제한
const unlimitedMatching = computed(() => tier.value === 'PREMIUM')
// 하루 무료 매칭 잔여(무제한이면 null)
const remainingMatches = ref<number | null>(3)
function setTier(next: SubscriptionTier) {
tier.value = next
}
return { tier, hideAds, unlimitedMatching, remainingMatches, setTier }
})
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client", "quasar"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts", "capacitor.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { quasar, transformAssetUrls } from '@quasar/vite-plugin'
// Capacitor(네이티브 앱) 빌드 시 상대 경로 사용
export default defineConfig(({ mode }) => ({
base: mode === 'capacitor' ? './' : '/',
plugins: [
vue({ template: { transformAssetUrls } }),
quasar({ sassVariables: fileURLToPath(new URL('./src/quasar-variables.scss', import.meta.url)) }),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 9000,
},
}))