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:
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 루트 컴포넌트
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+15
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,14 @@
|
||||
// ============================================================
|
||||
// 산책갈개 브랜드 컬러 — 하늘색 파스텔톤
|
||||
// ============================================================
|
||||
$primary : #6DB3E8; // 메인 하늘색
|
||||
$secondary : #BFE3FB; // 파스텔 라이트 블루
|
||||
$accent : #3E92CC; // 포인트(진한 하늘색)
|
||||
|
||||
$dark : #1D1D1D;
|
||||
$dark-page : #121212;
|
||||
|
||||
$positive : #7FD1AE;
|
||||
$negative : #F2A6A6;
|
||||
$info : #A9D6F5;
|
||||
$warning : #FCE5A6;
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user