feat: 둘러보기(데모)를 실제 화면 그대로 — 데모 모드 + API 더미 응답
- src/demo: demo.on 플래그 + DTO 형태 더미데이터 + mock API(읽기 더미/쓰기 차단) - accountApi: Proxy 로 데모 시 읽기→더미, 쓰기→안내. 실제 뷰 그대로 렌더 - App: authed=인증||데모 → 실제 사이드바/셸 표시 + 데모 배너(로그인/나가기) - 라우터: 데모 허용(가계부/고정/계좌/분류) 외 보호화면은 DemoLockedView(로그인 유도) - 사이드바: 데모 시 잠금(🔒) 배지, 메뉴는 showMenu(인증||데모) - 로그인/로그아웃 시 데모 자동 해제, 랜딩 '둘러보기'→enterDemo - 기존 독립 DemoView 제거 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+56
-5
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||||
import AppBottomNav from '@/components/layout/AppBottomNav.vue'
|
import AppBottomNav from '@/components/layout/AppBottomNav.vue'
|
||||||
@@ -11,10 +11,20 @@ import AppDialog from '@/components/ui/AppDialog.vue'
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
import { demo, exitDemo } from '@/demo'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 로그인 또는 둘러보기(데모) 상태면 사이드바/전체 셸 표시
|
||||||
|
const authed = computed(() => auth.isAuthenticated || demo.on)
|
||||||
|
|
||||||
|
function leaveDemo() {
|
||||||
|
exitDemo()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
// 앱(Capacitor 네이티브) 또는 데스크톱(Electron) 클라이언트에서 전체 이용.
|
// 앱(Capacitor 네이티브) 또는 데스크톱(Electron) 클라이언트에서 전체 이용.
|
||||||
// 웹(브라우저)은 안내 페이지만 노출. 개발 모드(npm run dev)는 예외로 전체 앱 표시.
|
// 웹(브라우저)은 안내 페이지만 노출. 개발 모드(npm run dev)는 예외로 전체 앱 표시.
|
||||||
@@ -43,14 +53,21 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
|||||||
|
|
||||||
<!-- 앱(Capacitor): 전체 기능 -->
|
<!-- 앱(Capacitor): 전체 기능 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !auth.isAuthenticated }">
|
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed }">
|
||||||
<AppHeader class="layout-top" />
|
<AppHeader class="layout-top" />
|
||||||
<!-- 로그인 전에는 메뉴가 없어 사이드바를 숨김(빈 칸 제거·중앙 정렬) -->
|
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김(빈 칸 제거·중앙 정렬) -->
|
||||||
<template v-if="auth.isAuthenticated">
|
<template v-if="authed">
|
||||||
<AppSidebar class="layout-left" />
|
<AppSidebar class="layout-left" />
|
||||||
<div class="sidebar-backdrop" @click="ui.closeSidebar()"></div>
|
<div class="sidebar-backdrop" @click="ui.closeSidebar()"></div>
|
||||||
</template>
|
</template>
|
||||||
<main class="layout-body">
|
<main class="layout-body">
|
||||||
|
<div v-if="demo.on" class="demo-bar">
|
||||||
|
<span>👀 <b>둘러보기 모드</b> · 샘플 데이터입니다.</span>
|
||||||
|
<span class="demo-actions">
|
||||||
|
<button type="button" class="db-login" @click="ui.openLogin('/account')">로그인</button>
|
||||||
|
<button type="button" class="db-exit" @click="leaveDemo">나가기</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</main>
|
</main>
|
||||||
<AppBottomNav class="layout-bottom" />
|
<AppBottomNav class="layout-bottom" />
|
||||||
@@ -84,6 +101,40 @@ watch(() => route.fullPath, () => ui.closeSidebar())
|
|||||||
grid-area: left;
|
grid-area: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.demo-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border: 1px solid hsla(160, 100%, 37%, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsla(160, 100%, 37%, 0.08);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.demo-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.demo-bar button {
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.db-login {
|
||||||
|
border: 1px solid hsla(160, 100%, 37%, 1);
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.db-exit {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
.layout-body {
|
.layout-body {
|
||||||
grid-area: body;
|
grid-area: body;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
|
|||||||
+37
-1
@@ -1,7 +1,8 @@
|
|||||||
import http from './http'
|
import http from './http'
|
||||||
|
import { demo, demoApi } from '@/demo'
|
||||||
|
|
||||||
// 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만)
|
// 백엔드 /api/account 엔드포인트와 매핑 (본인 데이터만)
|
||||||
export const accountApi = {
|
const realApi = {
|
||||||
list({ year, month, type, category, walletId, keyword, tagId } = {}) {
|
list({ year, month, type, category, walletId, keyword, tagId } = {}) {
|
||||||
return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } })
|
return http.get('/account/entries', { params: { year, month, type, category, walletId, keyword, tagId } })
|
||||||
},
|
},
|
||||||
@@ -198,3 +199,38 @@ export const accountApi = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 둘러보기(데모) 모드 =====
|
||||||
|
// demo.on 이면 읽기는 더미 데이터, 쓰기는 차단(안내 메시지)으로 응답한다.
|
||||||
|
const DEMO_READS = {
|
||||||
|
list: demoApi.list,
|
||||||
|
summary: demoApi.summary,
|
||||||
|
wallets: demoApi.wallets,
|
||||||
|
netWorth: demoApi.netWorth,
|
||||||
|
categories: demoApi.categories,
|
||||||
|
recurrings: demoApi.recurrings,
|
||||||
|
quickEntries: demoApi.quickEntries,
|
||||||
|
pendingCount: demoApi.pendingCount,
|
||||||
|
tags: demoApi.tags,
|
||||||
|
walletEntries: demoApi.walletEntries,
|
||||||
|
}
|
||||||
|
const DEMO_WRITES = new Set([
|
||||||
|
'create', 'update', 'remove', 'parseText', 'createQuickEntry', 'removeQuickEntry', 'repayment',
|
||||||
|
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
|
||||||
|
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
|
||||||
|
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
|
||||||
|
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt',
|
||||||
|
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
|
||||||
|
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
|
||||||
|
'refreshPrices', 'refreshAllPrices',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const accountApi = new Proxy(realApi, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (demo.on) {
|
||||||
|
if (DEMO_READS[prop]) return DEMO_READS[prop]
|
||||||
|
if (DEMO_WRITES.has(prop)) return () => demoApi.block()
|
||||||
|
}
|
||||||
|
return target[prop]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const route = useRoute()
|
|||||||
// 라우트별 헤더 타이틀(현재 화면 이름)
|
// 라우트별 헤더 타이틀(현재 화면 이름)
|
||||||
const TITLES = {
|
const TITLES = {
|
||||||
home: '돈돼지 가계부',
|
home: '돈돼지 가계부',
|
||||||
demo: '둘러보기',
|
'demo-locked': '둘러보기',
|
||||||
'account-entries': '가계부 내역',
|
'account-entries': '가계부 내역',
|
||||||
'account-stats': '통계',
|
'account-stats': '통계',
|
||||||
'account-recurrings': '고정 지출',
|
'account-recurrings': '고정 지출',
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
import { BOARDS } from '@/constants/boards'
|
import { BOARDS } from '@/constants/boards'
|
||||||
|
import { demo } from '@/demo'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
// 로그인 또는 둘러보기(데모)면 메뉴 노출. 데모에선 잠금 배지를 함께 표시.
|
||||||
|
const showMenu = computed(() => auth.isAuthenticated || demo.on)
|
||||||
|
|
||||||
// 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업.
|
// 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업.
|
||||||
const icons = {
|
const icons = {
|
||||||
@@ -33,7 +37,7 @@ const icons = {
|
|||||||
</div>
|
</div>
|
||||||
<nav class="menu">
|
<nav class="menu">
|
||||||
<!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) -->
|
<!-- 가계부 영역 (홈은 하단 내비게이션으로 이동) -->
|
||||||
<template v-if="auth.isAuthenticated">
|
<template v-if="showMenu">
|
||||||
<RouterLink to="/account/entries" class="menu-item">
|
<RouterLink to="/account/entries" class="menu-item">
|
||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.entries" />
|
||||||
<span>가계부 내역</span>
|
<span>가계부 내역</span>
|
||||||
@@ -41,6 +45,7 @@ const icons = {
|
|||||||
<RouterLink to="/account/stats" class="menu-item">
|
<RouterLink to="/account/stats" class="menu-item">
|
||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.stats" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.stats" />
|
||||||
<span>통계</span>
|
<span>통계</span>
|
||||||
|
<span v-if="demo.on" class="lock-badge">🔒</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/account/recurrings" class="menu-item">
|
<RouterLink to="/account/recurrings" class="menu-item">
|
||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.recurrings" />
|
||||||
@@ -57,10 +62,12 @@ const icons = {
|
|||||||
<RouterLink to="/account/budget" class="menu-item">
|
<RouterLink to="/account/budget" class="menu-item">
|
||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.budget" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.budget" />
|
||||||
<span>예산 설정</span>
|
<span>예산 설정</span>
|
||||||
|
<span v-if="demo.on" class="lock-badge">🔒</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/account/tags" class="menu-item">
|
<RouterLink to="/account/tags" class="menu-item">
|
||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.tags" />
|
||||||
<span>태그 관리</span>
|
<span>태그 관리</span>
|
||||||
|
<span v-if="demo.on" class="lock-badge">🔒</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<!-- 게시판 영역 -->
|
<!-- 게시판 영역 -->
|
||||||
@@ -73,6 +80,7 @@ const icons = {
|
|||||||
>
|
>
|
||||||
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.board" />
|
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.board" />
|
||||||
<span>{{ b.label }}</span>
|
<span>{{ b.label }}</span>
|
||||||
|
<span v-if="demo.on" class="lock-badge">🔒</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -169,6 +177,11 @@ const icons = {
|
|||||||
flex: none;
|
flex: none;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
.lock-badge {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
.menu-item.router-link-exact-active .menu-icon {
|
.menu-item.router-link-exact-active .menu-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// 둘러보기(데모) 모드 — 실제 화면 컴포넌트를 그대로 쓰되 API 만 더미로 응답한다.
|
||||||
|
// demo.on 이 true 면 accountApi 의 읽기는 아래 더미를, 쓰기는 block()(안내 메시지)으로 응답.
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
export const demo = reactive({ on: false })
|
||||||
|
|
||||||
|
export function enterDemo() {
|
||||||
|
demo.on = true
|
||||||
|
}
|
||||||
|
export function exitDemo() {
|
||||||
|
demo.on = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 더미 데이터 (백엔드 응답 DTO 형태와 동일) =====
|
||||||
|
const WALLETS = [
|
||||||
|
{ id: 1, type: 'BANK', name: '신한 주거래', issuer: '신한', accountNumber: null, openingBalance: 0, balance: 2540000, manualValuation: false },
|
||||||
|
{ id: 2, type: 'BANK', name: '주택청약', issuer: '국민', accountNumber: null, openingBalance: 0, balance: 5000000, manualValuation: false },
|
||||||
|
{ id: 3, type: 'CASH', name: '현금', openingBalance: 0, balance: 120000, manualValuation: false },
|
||||||
|
{ id: 4, type: 'CARD', name: '삼성카드', issuer: '삼성', cardType: 'CREDIT', openingBalance: 0, balance: -340000, manualValuation: false },
|
||||||
|
{
|
||||||
|
id: 5, type: 'INVEST', name: '연금저축', manualValuation: true,
|
||||||
|
investedAmount: 7000000, currentValue: 8200000, balance: 8200000,
|
||||||
|
valuationGain: 1200000, deposit: 0, stockValue: 8200000,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
// 지출 대분류/소분류
|
||||||
|
{ id: 10, type: 'EXPENSE', name: '식비', parentId: null },
|
||||||
|
{ id: 11, type: 'EXPENSE', name: '점심', parentId: 10 },
|
||||||
|
{ id: 12, type: 'EXPENSE', name: '저녁', parentId: 10 },
|
||||||
|
{ id: 13, type: 'EXPENSE', name: '카페/간식', parentId: 10 },
|
||||||
|
{ id: 20, type: 'EXPENSE', name: '교통', parentId: null },
|
||||||
|
{ id: 21, type: 'EXPENSE', name: '대중교통', parentId: 20 },
|
||||||
|
{ id: 22, type: 'EXPENSE', name: '택시', parentId: 20 },
|
||||||
|
{ id: 30, type: 'EXPENSE', name: '주거/통신', parentId: null },
|
||||||
|
{ id: 31, type: 'EXPENSE', name: '월세', parentId: 30 },
|
||||||
|
{ id: 32, type: 'EXPENSE', name: '통신비', parentId: 30 },
|
||||||
|
{ id: 40, type: 'EXPENSE', name: '문화/여가', parentId: null },
|
||||||
|
{ id: 41, type: 'EXPENSE', name: 'OTT/구독', parentId: 40 },
|
||||||
|
{ id: 50, type: 'EXPENSE', name: '쇼핑', parentId: null },
|
||||||
|
// 수입
|
||||||
|
{ id: 60, type: 'INCOME', name: '급여', parentId: null },
|
||||||
|
{ id: 61, type: 'INCOME', name: '용돈', parentId: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 이번 달(현 시점) 기준 샘플 내역
|
||||||
|
function ymd(day) {
|
||||||
|
const now = new Date()
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${now.getFullYear()}-${m}-${String(day).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
function E(id, day, type, category, amount, memo, walletId, walletName, opt = {}) {
|
||||||
|
return {
|
||||||
|
id, entryDate: ymd(day), type, category, amount, memo,
|
||||||
|
walletId, walletName, toWalletId: null, toWalletName: null,
|
||||||
|
installmentMonths: null, pending: !!opt.pending, tags: opt.tags || [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ENTRIES = [
|
||||||
|
E(101, 27, 'EXPENSE', '점심', 9000, '김밥천국', 4, '삼성카드'),
|
||||||
|
E(102, 27, 'EXPENSE', '카페/간식', 5300, '스타벅스', 4, '삼성카드'),
|
||||||
|
E(103, 27, 'EXPENSE', '대중교통', 2800, '지하철', 3, '현금'),
|
||||||
|
E(104, 27, 'EXPENSE', '쇼핑', 25200, '다이소', 4, '삼성카드', { pending: true }),
|
||||||
|
E(105, 25, 'INCOME', '급여', 4600000, '6월 급여', 1, '신한 주거래'),
|
||||||
|
E(106, 25, 'EXPENSE', '통신비', 45300, '휴대폰 요금', 1, '신한 주거래'),
|
||||||
|
E(107, 23, 'EXPENSE', 'OTT/구독', 17000, '넷플릭스', 4, '삼성카드', { tags: ['구독'] }),
|
||||||
|
E(108, 23, 'EXPENSE', '저녁', 23000, '배달', 4, '삼성카드'),
|
||||||
|
E(109, 20, 'EXPENSE', '쇼핑', 285000, '마트 장보기', 4, '삼성카드'),
|
||||||
|
E(110, 18, 'EXPENSE', '저녁', 62000, '친구 외식', 4, '삼성카드', { tags: ['데이트'] }),
|
||||||
|
E(111, 15, 'EXPENSE', '택시', 28000, '심야 택시', 4, '삼성카드'),
|
||||||
|
E(112, 12, 'EXPENSE', '쇼핑', 320000, '의류 구매', 4, '삼성카드'),
|
||||||
|
E(113, 8, 'EXPENSE', '대중교통', 55000, '교통카드 충전', 3, '현금'),
|
||||||
|
E(114, 5, 'EXPENSE', '카페/간식', 18500, '베이커리', 4, '삼성카드'),
|
||||||
|
E(115, 1, 'EXPENSE', '월세', 600000, '6월 월세', 1, '신한 주거래'),
|
||||||
|
]
|
||||||
|
|
||||||
|
const RECURRINGS = [
|
||||||
|
{ id: 201, title: '월세', type: 'EXPENSE', amount: 600000, category: '월세', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: null, monthOfYear: null, startDate: ymd(1), endDate: null, lastRunDate: null, nextDate: ymd(1), active: true },
|
||||||
|
{ id: 202, title: '통신비', type: 'EXPENSE', amount: 45300, category: '통신비', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 15, dayOfWeek: null, monthOfYear: null, startDate: ymd(15), endDate: null, lastRunDate: null, nextDate: ymd(15), active: true },
|
||||||
|
{ id: 203, title: '넷플릭스', type: 'EXPENSE', amount: 17000, category: 'OTT/구독', memo: null, walletId: 4, walletName: '삼성카드', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 23, dayOfWeek: null, monthOfYear: null, startDate: ymd(23), endDate: null, lastRunDate: null, nextDate: ymd(23), active: true },
|
||||||
|
{ id: 204, title: '급여', type: 'INCOME', amount: 4600000, category: '급여', memo: null, walletId: 1, walletName: '신한 주거래', toWalletId: null, toWalletName: null, frequency: 'MONTHLY', dayOfMonth: 25, dayOfWeek: null, monthOfYear: null, startDate: ymd(25), endDate: null, lastRunDate: null, nextDate: ymd(25), active: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const QUICK = [
|
||||||
|
{ id: 301, label: '점심', type: 'EXPENSE', category: '점심', amount: 9000, memo: '점심', walletId: 4, walletName: '삼성카드' },
|
||||||
|
{ id: 302, label: '커피', type: 'EXPENSE', category: '카페/간식', amount: 5300, memo: '커피', walletId: 4, walletName: '삼성카드' },
|
||||||
|
{ id: 303, label: '대중교통', type: 'EXPENSE', category: '대중교통', amount: 2800, memo: '교통', walletId: 3, walletName: '현금' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TAGS = [
|
||||||
|
{ id: 401, name: '구독' },
|
||||||
|
{ id: 402, name: '데이트' },
|
||||||
|
{ id: 403, name: '경조사' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 요약은 내역에서 계산해 일관성 보장
|
||||||
|
const SUMMARY = (() => {
|
||||||
|
let income = 0, expense = 0
|
||||||
|
for (const e of ENTRIES) {
|
||||||
|
if (e.type === 'INCOME') income += e.amount
|
||||||
|
else if (e.type === 'EXPENSE') expense += e.amount
|
||||||
|
}
|
||||||
|
return { totalIncome: income, totalExpense: expense, balance: income - expense }
|
||||||
|
})()
|
||||||
|
const NETWORTH = { totalAssets: 15860000, totalLiabilities: 340000, netWorth: 15520000 }
|
||||||
|
|
||||||
|
function ok(data) {
|
||||||
|
// 깊은 복제로 화면에서 수정해도 원본 더미는 보존
|
||||||
|
return Promise.resolve(JSON.parse(JSON.stringify(data)))
|
||||||
|
}
|
||||||
|
function block() {
|
||||||
|
return Promise.reject({ response: { data: { message: '둘러보기 모드에서는 저장되지 않습니다. 로그인 후 이용해 주세요.' } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 읽기 응답 / 쓰기 차단
|
||||||
|
export const demoApi = {
|
||||||
|
list: () => ok(ENTRIES),
|
||||||
|
summary: () => ok(SUMMARY),
|
||||||
|
wallets: () => ok(WALLETS),
|
||||||
|
netWorth: () => ok(NETWORTH),
|
||||||
|
categories: () => ok(CATEGORIES),
|
||||||
|
recurrings: () => ok(RECURRINGS),
|
||||||
|
quickEntries: () => ok(QUICK),
|
||||||
|
pendingCount: () => ok({ count: 1 }),
|
||||||
|
tags: () => ok(TAGS),
|
||||||
|
walletEntries: () => ok([]),
|
||||||
|
block,
|
||||||
|
}
|
||||||
+17
-4
@@ -2,6 +2,10 @@ import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router
|
|||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
import { demo } from '@/demo'
|
||||||
|
|
||||||
|
// 둘러보기(데모)에서 더미로 진입 가능한 화면
|
||||||
|
const DEMO_ALLOWED = new Set(['account-entries', 'account-recurrings', 'account-wallets', 'account-categories'])
|
||||||
|
|
||||||
// Electron(데스크톱)은 file:// 로 로드 → 해시 히스토리라야 새로고침/딥링크가 안전
|
// Electron(데스크톱)은 file:// 로 로드 → 해시 히스토리라야 새로고침/딥링크가 안전
|
||||||
const isDesktop = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
|
const isDesktop = typeof navigator !== 'undefined' && /electron/i.test(navigator.userAgent)
|
||||||
@@ -15,10 +19,10 @@ const router = createRouter({
|
|||||||
component: HomeView,
|
component: HomeView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 로그인 없이 둘러보기 (더미 데이터) — 공개
|
// 둘러보기(데모)에서 미지원(로그인 필요) 화면 진입 시 — 잠금 안내 (공개)
|
||||||
path: '/demo',
|
path: '/demo-locked',
|
||||||
name: 'demo',
|
name: 'demo-locked',
|
||||||
component: () => import('../views/DemoView.vue'),
|
component: () => import('../views/DemoLockedView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/users',
|
path: '/users',
|
||||||
@@ -138,6 +142,15 @@ const router = createRouter({
|
|||||||
// 전역 네비게이션 가드
|
// 전역 네비게이션 가드
|
||||||
router.beforeEach((to, from) => {
|
router.beforeEach((to, from) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
// 둘러보기(데모): 지원 화면은 더미로 진입, 그 외 보호 화면은 잠금 안내로
|
||||||
|
if (demo.on && !auth.isAuthenticated) {
|
||||||
|
if (to.name === 'home') return { name: 'account-entries' }
|
||||||
|
if (to.meta.requiresAuth) {
|
||||||
|
if (DEMO_ALLOWED.has(to.name)) return true
|
||||||
|
return { name: 'demo-locked', query: { from: to.name || '' } }
|
||||||
|
}
|
||||||
|
return true // 공개 라우트(잠금 안내 등)
|
||||||
|
}
|
||||||
// 인증이 필요한 페이지인데 미로그인 → 로그인 팝업 오픈 (원래 목적지 보존), 이동은 취소/홈
|
// 인증이 필요한 페이지인데 미로그인 → 로그인 팝업 오픈 (원래 목적지 보존), 이동은 취소/홈
|
||||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { Preferences } from '@capacitor/preferences'
|
import { Preferences } from '@capacitor/preferences'
|
||||||
import { authApi } from '@/api/authApi'
|
import { authApi } from '@/api/authApi'
|
||||||
|
import { exitDemo } from '@/demo'
|
||||||
|
|
||||||
// 세션 토큰은 Capacitor Preferences(네이티브 영속 저장 / 웹은 localStorage)로 보관한다.
|
// 세션 토큰은 Capacitor Preferences(네이티브 영속 저장 / 웹은 localStorage)로 보관한다.
|
||||||
// http.js 요청 인터셉터가 동기로 읽을 수 있도록 localStorage 에도 미러링한다.
|
// http.js 요청 인터셉터가 동기로 읽을 수 있도록 localStorage 에도 미러링한다.
|
||||||
@@ -59,6 +60,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const res = await authApi.login({ loginId, password, rememberMe })
|
const res = await authApi.login({ loginId, password, rememberMe })
|
||||||
token.value = res.token
|
token.value = res.token
|
||||||
user.value = res.member
|
user.value = res.member
|
||||||
|
exitDemo()
|
||||||
await persist()
|
await persist()
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
@@ -68,6 +70,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const res = await authApi.googleLogin({ idToken, rememberMe })
|
const res = await authApi.googleLogin({ idToken, rememberMe })
|
||||||
token.value = res.token
|
token.value = res.token
|
||||||
user.value = res.member
|
user.value = res.member
|
||||||
|
exitDemo()
|
||||||
await persist()
|
await persist()
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
@@ -100,6 +103,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clear() {
|
async function clear() {
|
||||||
|
exitDemo()
|
||||||
token.value = ''
|
token.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
await persist()
|
await persist()
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup>
|
||||||
|
// 둘러보기(데모)에서 미지원(로그인 필요) 화면에 들어왔을 때 안내.
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const ui = useUiStore()
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
'account-stats': '통계',
|
||||||
|
'account-budget': '예산 설정',
|
||||||
|
'account-tags': '태그 관리',
|
||||||
|
board: '게시판',
|
||||||
|
'board-detail': '게시판',
|
||||||
|
settings: '설정',
|
||||||
|
users: '회원 관리',
|
||||||
|
}
|
||||||
|
const label = LABELS[route.query.from] || '이 기능'
|
||||||
|
|
||||||
|
function goLogin() {
|
||||||
|
ui.openLogin('/account')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="locked">
|
||||||
|
<div class="lock-big">🔒</div>
|
||||||
|
<h2>{{ label }}는 로그인 후 이용할 수 있어요</h2>
|
||||||
|
<p class="desc">둘러보기에서는 가계부 내역 · 고정 지출 · 계좌 관리 · 분류 관리만 미리 볼 수 있습니다.<br />로그인하면 통계 · 예산 · 태그 · 게시판까지 모두 사용할 수 있어요.</p>
|
||||||
|
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.locked {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.lock-big {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
margin: 0.6rem 0 1.3rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.cta-btn {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border: 1px solid hsla(160, 100%, 37%, 1);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
// 로그인 없이 둘러보기 — 자체 메뉴로 가계부/고정지출/계좌/분류를 샘플(더미) 데이터로 보여준다.
|
|
||||||
// 나머지 메뉴는 로그인 유도 안내. API 호출/실데이터 없음.
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useUiStore } from '@/stores/ui'
|
|
||||||
|
|
||||||
const ui = useUiStore()
|
|
||||||
|
|
||||||
const MENU = [
|
|
||||||
{ key: 'account', label: '가계부 내역' },
|
|
||||||
{ key: 'stats', label: '통계', locked: true },
|
|
||||||
{ key: 'recurrings', label: '고정 지출' },
|
|
||||||
{ key: 'wallets', label: '계좌 관리' },
|
|
||||||
{ key: 'categories', label: '분류 관리' },
|
|
||||||
{ key: 'budget', label: '예산 설정', locked: true },
|
|
||||||
{ key: 'tags', label: '태그 관리', locked: true },
|
|
||||||
{ key: 'board', label: '커뮤니티', locked: true },
|
|
||||||
]
|
|
||||||
const active = ref('account')
|
|
||||||
const activeItem = computed(() => MENU.find((m) => m.key === active.value) || MENU[0])
|
|
||||||
|
|
||||||
// ===== 더미 데이터 =====
|
|
||||||
const summary = { income: 4600000, expense: 1827600, balance: 2772400 }
|
|
||||||
const accountGroups = [
|
|
||||||
{ date: '6월 27일 (금)', total: -42300, items: [
|
|
||||||
{ cat: '식비', memo: '점심 · 김밥천국', type: 'EXPENSE', amount: 9000 },
|
|
||||||
{ cat: '카페/간식', memo: '스타벅스', type: 'EXPENSE', amount: 5300 },
|
|
||||||
{ cat: '교통', memo: '지하철', type: 'EXPENSE', amount: 2800 },
|
|
||||||
{ cat: '쇼핑', memo: '다이소', type: 'EXPENSE', amount: 25200 },
|
|
||||||
] },
|
|
||||||
{ date: '6월 25일 (수)', total: 4554700, items: [
|
|
||||||
{ cat: '급여', memo: '6월 급여', type: 'INCOME', amount: 4600000 },
|
|
||||||
{ cat: '통신', memo: '휴대폰 요금', type: 'EXPENSE', amount: 45300 },
|
|
||||||
] },
|
|
||||||
{ date: '6월 23일 (월)', total: -68000, items: [
|
|
||||||
{ cat: 'OTT/구독', memo: '넷플릭스', type: 'EXPENSE', amount: 17000 },
|
|
||||||
{ cat: '식비', memo: '저녁 · 배달', type: 'EXPENSE', amount: 23000 },
|
|
||||||
{ cat: '생활', memo: '마트 장보기', type: 'EXPENSE', amount: 28000 },
|
|
||||||
] },
|
|
||||||
]
|
|
||||||
const recurrings = [
|
|
||||||
{ title: '월세', freq: '매월 1일', cat: '주거', type: 'EXPENSE', amount: 600000 },
|
|
||||||
{ title: '통신비', freq: '매월 15일', cat: '통신', type: 'EXPENSE', amount: 45300 },
|
|
||||||
{ title: '넷플릭스', freq: '매월 23일', cat: 'OTT/구독', type: 'EXPENSE', amount: 17000 },
|
|
||||||
{ title: '정기적금', freq: '매월 25일', cat: '저축', type: 'TRANSFER', amount: 300000 },
|
|
||||||
{ title: '급여', freq: '매월 25일', cat: '급여', type: 'INCOME', amount: 4600000 },
|
|
||||||
]
|
|
||||||
const wallets = {
|
|
||||||
total: 15520000,
|
|
||||||
items: [
|
|
||||||
{ name: '신한 주거래', kind: '은행', amount: 2540000 },
|
|
||||||
{ name: '주택청약', kind: '은행', amount: 5000000 },
|
|
||||||
{ name: '현금', kind: '현금', amount: 120000 },
|
|
||||||
{ name: '삼성카드', kind: '카드', amount: -340000 },
|
|
||||||
{ name: '연금저축(투자)', kind: '투자', amount: 8200000 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
const categories = [
|
|
||||||
{ major: '식비', subs: ['점심', '저녁', '카페/간식', '배달'] },
|
|
||||||
{ major: '교통', subs: ['대중교통', '택시', '주유'] },
|
|
||||||
{ major: '주거/통신', subs: ['월세', '관리비', '통신비'] },
|
|
||||||
{ major: '문화/여가', subs: ['OTT/구독', '영화', '여행'] },
|
|
||||||
{ major: '쇼핑', subs: ['생활', '의류', '온라인'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
function won(n) {
|
|
||||||
return (n < 0 ? '-' : '') + Math.abs(n).toLocaleString('ko-KR')
|
|
||||||
}
|
|
||||||
function typeLabel(t) {
|
|
||||||
return t === 'INCOME' ? '수입' : t === 'TRANSFER' ? '이체' : '지출'
|
|
||||||
}
|
|
||||||
function goLogin() {
|
|
||||||
ui.openLogin('/account')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="demo">
|
|
||||||
<div class="banner">
|
|
||||||
<span>👀 <b>둘러보기 모드</b> · 아래는 샘플 데이터입니다.</span>
|
|
||||||
<button type="button" class="login-btn" @click="goLogin">로그인하고 시작</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="demo-shell">
|
|
||||||
<!-- 데모 메뉴 -->
|
|
||||||
<nav class="demo-menu">
|
|
||||||
<button
|
|
||||||
v-for="m in MENU" :key="m.key" type="button"
|
|
||||||
class="dm-item" :class="{ active: active === m.key }"
|
|
||||||
@click="active = m.key"
|
|
||||||
>
|
|
||||||
<span>{{ m.label }}</span>
|
|
||||||
<span v-if="m.locked" class="lock">🔒</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- 콘텐츠 -->
|
|
||||||
<div class="demo-content">
|
|
||||||
<!-- 가계부 내역 -->
|
|
||||||
<template v-if="active === 'account'">
|
|
||||||
<div class="month-nav"><span class="chev">‹</span><span class="period">2026년 6월</span><span class="chev">›</span></div>
|
|
||||||
<div class="summary">
|
|
||||||
<div class="s-card"><span class="s-label">수입</span><span class="s-val income">{{ won(summary.income) }}</span></div>
|
|
||||||
<div class="s-card"><span class="s-label">지출</span><span class="s-val expense">{{ won(summary.expense) }}</span></div>
|
|
||||||
<div class="s-card"><span class="s-label">잔액</span><span class="s-val">{{ won(summary.balance) }}</span></div>
|
|
||||||
</div>
|
|
||||||
<div v-for="g in accountGroups" :key="g.date" class="card-group">
|
|
||||||
<div class="cg-head">
|
|
||||||
<span class="cg-title">{{ g.date }}</span>
|
|
||||||
<span class="cg-total" :class="g.total >= 0 ? 'income' : 'expense'">{{ won(g.total) }}</span>
|
|
||||||
</div>
|
|
||||||
<ul class="rows">
|
|
||||||
<li v-for="(e, i) in g.items" :key="i" class="row3">
|
|
||||||
<span class="tag">{{ e.cat }}</span>
|
|
||||||
<span class="ellip">{{ e.memo }}</span>
|
|
||||||
<span class="amt" :class="e.type === 'INCOME' ? 'income' : 'expense'">{{ e.type === 'INCOME' ? '+' : '-' }}{{ won(e.amount) }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 고정 지출 -->
|
|
||||||
<template v-else-if="active === 'recurrings'">
|
|
||||||
<p class="hint">등록한 주기에 맞춰 가계부에 자동으로 '확인 필요' 내역이 생성됩니다.</p>
|
|
||||||
<div class="card-group">
|
|
||||||
<ul class="rows">
|
|
||||||
<li v-for="(r, i) in recurrings" :key="i" class="row-rec">
|
|
||||||
<div class="rec-main">
|
|
||||||
<span class="rec-title">{{ r.title }}</span>
|
|
||||||
<span class="rec-sub"><span class="tag">{{ r.cat }}</span> · {{ r.freq }} · {{ typeLabel(r.type) }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="amt" :class="r.type === 'INCOME' ? 'income' : r.type === 'TRANSFER' ? '' : 'expense'">{{ won(r.amount) }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 계좌 관리 -->
|
|
||||||
<template v-else-if="active === 'wallets'">
|
|
||||||
<div class="networth">
|
|
||||||
<span class="nw-label">총자산</span>
|
|
||||||
<span class="nw-val income">{{ won(wallets.total) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="card-group">
|
|
||||||
<ul class="rows">
|
|
||||||
<li v-for="(w, i) in wallets.items" :key="i" class="row-rec">
|
|
||||||
<div class="rec-main">
|
|
||||||
<span class="rec-title">{{ w.name }}</span>
|
|
||||||
<span class="rec-sub"><span class="tag">{{ w.kind }}</span></span>
|
|
||||||
</div>
|
|
||||||
<span class="amt" :class="w.amount < 0 ? 'expense' : ''">{{ won(w.amount) }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 분류 관리 -->
|
|
||||||
<template v-else-if="active === 'categories'">
|
|
||||||
<p class="hint">대분류 아래 소분류로 묶어 관리합니다. (예산·통계는 소분류 기준)</p>
|
|
||||||
<div v-for="(c, i) in categories" :key="i" class="card-group">
|
|
||||||
<div class="cg-head"><span class="major-badge">대</span><span class="cg-title">{{ c.major }}</span></div>
|
|
||||||
<ul class="rows">
|
|
||||||
<li v-for="(s, j) in c.subs" :key="j" class="row-sub2"><span class="sub-mark">↳</span>{{ s }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 잠금(로그인 유도) -->
|
|
||||||
<div v-else class="locked-box">
|
|
||||||
<div class="lock-big">🔒</div>
|
|
||||||
<p class="lock-title">{{ activeItem.label }}는 로그인 후 이용할 수 있어요</p>
|
|
||||||
<p class="lock-desc">로그인하면 통계·예산·태그·게시판까지 모두 사용할 수 있습니다.</p>
|
|
||||||
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="cta">
|
|
||||||
<p>마음에 드시나요? 로그인하면 <b>내 가계부</b>를 바로 시작할 수 있어요.</p>
|
|
||||||
<button type="button" class="cta-btn" @click="goLogin">로그인 / 시작하기</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.demo {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
padding: 0.7rem 0.9rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid hsla(160, 100%, 37%, 0.4);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: hsla(160, 100%, 37%, 0.08);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.login-btn,
|
|
||||||
.cta-btn {
|
|
||||||
padding: 0.45rem 0.9rem;
|
|
||||||
border: 1px solid hsla(160, 100%, 37%, 1);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: hsla(160, 100%, 37%, 1);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.demo-shell {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.demo-menu {
|
|
||||||
flex: none;
|
|
||||||
width: 150px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.4rem;
|
|
||||||
background: var(--color-background-soft);
|
|
||||||
}
|
|
||||||
.dm-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 0.55rem 0.6rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.dm-item:hover {
|
|
||||||
background: var(--color-background-mute);
|
|
||||||
}
|
|
||||||
.dm-item.active {
|
|
||||||
background: hsla(160, 100%, 37%, 0.12);
|
|
||||||
color: hsla(160, 100%, 37%, 1);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.lock {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
.demo-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.month-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.chev {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
.period {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 0.6rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
.s-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.7rem 0.8rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--color-background-soft);
|
|
||||||
}
|
|
||||||
.s-label {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
.s-val {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.income {
|
|
||||||
color: hsla(160, 100%, 37%, 1);
|
|
||||||
}
|
|
||||||
.expense {
|
|
||||||
color: #c0392b;
|
|
||||||
}
|
|
||||||
.hint {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
}
|
|
||||||
.networth {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--color-background-soft);
|
|
||||||
}
|
|
||||||
.nw-label {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
.nw-val {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
.card-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.cg-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.6rem 0.9rem;
|
|
||||||
background: var(--color-background-mute);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
.cg-title {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
.cg-total {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.rows {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.row3 {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.65rem 0.9rem;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.row-rec {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.65rem 0.9rem;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
.row-rec:first-child {
|
|
||||||
border-top: 0;
|
|
||||||
}
|
|
||||||
.rec-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.2rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.rec-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
.rec-sub {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
.row-sub2 {
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.sub-mark {
|
|
||||||
opacity: 0.4;
|
|
||||||
margin-right: 0.4rem;
|
|
||||||
}
|
|
||||||
.major-badge {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 0.1rem 0.35rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: hsla(160, 100%, 37%, 0.15);
|
|
||||||
color: hsla(160, 100%, 37%, 1);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.tag {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0.1rem 0.45rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--color-background-mute);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.ellip {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
.amt {
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.locked-box {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2.5rem 1rem;
|
|
||||||
border: 1px dashed var(--color-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
.lock-big {
|
|
||||||
font-size: 2.2rem;
|
|
||||||
}
|
|
||||||
.lock-title {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.lock-desc {
|
|
||||||
margin: 0.4rem 0 1.1rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
.cta {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.cta p {
|
|
||||||
font-size: 0.92rem;
|
|
||||||
opacity: 0.8;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
.cta .cta-btn {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 320px;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.demo-shell {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.demo-menu {
|
|
||||||
width: 100%;
|
|
||||||
flex-direction: row;
|
|
||||||
overflow-x: auto;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
.dm-item {
|
|
||||||
flex: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -5,6 +5,12 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
import { accountApi } from '@/api/accountApi'
|
import { accountApi } from '@/api/accountApi'
|
||||||
import { ID_LOGIN_ENABLED } from '@/config/features'
|
import { ID_LOGIN_ENABLED } from '@/config/features'
|
||||||
|
import { enterDemo } from '@/demo'
|
||||||
|
|
||||||
|
function startDemo() {
|
||||||
|
enterDemo()
|
||||||
|
router.push('/account/entries')
|
||||||
|
}
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
@@ -299,7 +305,7 @@ onMounted(load)
|
|||||||
<div class="cta">
|
<div class="cta">
|
||||||
<button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button>
|
<button type="button" class="btn primary" @click="ui.openLogin('/account')">로그인</button>
|
||||||
<button v-if="ID_LOGIN_ENABLED && ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button>
|
<button v-if="ID_LOGIN_ENABLED && ui.signupEnabled" type="button" class="btn" @click="ui.openSignup()">회원가입</button>
|
||||||
<button type="button" class="btn" @click="router.push('/demo')">로그인 없이 둘러보기</button>
|
<button type="button" class="btn" @click="startDemo">로그인 없이 둘러보기</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="cta-note">{{ !ID_LOGIN_ENABLED || ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}</p>
|
<p class="cta-note">{{ !ID_LOGIN_ENABLED || ui.signupEnabled ? '로그인하면 나의 가계부 요약을 볼 수 있어요.' : '현재 회원가입이 제한되어 있습니다.' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user