2026-05-30 21:18:13 +09:00
|
|
|
<script setup>
|
2026-06-29 08:33:20 +09:00
|
|
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
2026-06-28 07:49:37 +09:00
|
|
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
2026-05-31 15:42:52 +09:00
|
|
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
|
|
|
|
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
2026-06-06 14:08:01 +09:00
|
|
|
import AppBottomNav from '@/components/layout/AppBottomNav.vue'
|
2026-06-28 16:38:07 +09:00
|
|
|
import AdBanner from '@/components/AdBanner.vue'
|
2026-05-31 15:42:52 +09:00
|
|
|
import LoginModal from '@/components/LoginModal.vue'
|
|
|
|
|
import SignupModal from '@/components/SignupModal.vue'
|
2026-06-06 00:16:50 +09:00
|
|
|
import WebOnlyNotice from '@/components/WebOnlyNotice.vue'
|
2026-06-24 21:57:50 +09:00
|
|
|
import AppDialog from '@/components/ui/AppDialog.vue'
|
2026-06-29 08:45:38 +09:00
|
|
|
import AppLockScreen from '@/components/AppLockScreen.vue'
|
|
|
|
|
import { appLock } from '@/composables/appLock'
|
2026-06-06 00:16:50 +09:00
|
|
|
import { Capacitor } from '@capacitor/core'
|
2026-06-28 17:42:00 +09:00
|
|
|
import { reminders } from '@/native/reminders'
|
2026-05-31 15:42:52 +09:00
|
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
|
import { useUiStore } from '@/stores/ui'
|
2026-06-28 07:49:37 +09:00
|
|
|
import { demo, exitDemo } from '@/demo'
|
2026-06-28 20:24:25 +09:00
|
|
|
import { ADS_ENABLED } from '@/config/features'
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
const ui = useUiStore()
|
|
|
|
|
const route = useRoute()
|
2026-06-28 07:49:37 +09:00
|
|
|
const router = useRouter()
|
|
|
|
|
|
|
|
|
|
// 로그인 또는 둘러보기(데모) 상태면 사이드바/전체 셸 표시
|
|
|
|
|
const authed = computed(() => auth.isAuthenticated || demo.on)
|
2026-06-28 20:24:25 +09:00
|
|
|
// 광고: 광고 기능이 켜져 있고 유료(PREMIUM) 회원이 아닐 때만 표시 (레이아웃 하단 여백도 함께 제어)
|
|
|
|
|
const showAds = computed(() => ADS_ENABLED && !auth.isPremium)
|
2026-06-28 17:09:35 +09:00
|
|
|
// 공개 법적 고지(개인정보처리방침·약관)는 웹 브라우저에서도 접근 허용 (앱 마켓 정책 URL)
|
|
|
|
|
const isPublicLegal = computed(() => !!route.meta.public)
|
2026-06-28 07:49:37 +09:00
|
|
|
|
|
|
|
|
function leaveDemo() {
|
|
|
|
|
exitDemo()
|
|
|
|
|
router.push('/')
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
|
2026-06-07 16:20:55 +09:00
|
|
|
// 앱(Capacitor 네이티브) 또는 데스크톱(Electron) 클라이언트에서 전체 이용.
|
|
|
|
|
// 웹(브라우저)은 안내 페이지만 노출. 개발 모드(npm run dev)는 예외로 전체 앱 표시.
|
|
|
|
|
const isDesktop = /electron/i.test(navigator.userAgent)
|
|
|
|
|
const isApp = Capacitor.isNativePlatform() || import.meta.env.DEV || isDesktop
|
2026-06-06 00:16:50 +09:00
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
// http.js 의 401 처리에서 발생시키는 이벤트 → 세션 정리 후 로그인 팝업
|
|
|
|
|
function onUnauthorized() {
|
|
|
|
|
auth.clear()
|
|
|
|
|
ui.openLogin()
|
|
|
|
|
}
|
2026-06-28 11:02:44 +09:00
|
|
|
// http.js 의 403 PREMIUM_REQUIRED 처리 → 업그레이드 안내 화면으로
|
|
|
|
|
function onPremiumRequired() {
|
|
|
|
|
if (route.name !== 'upgrade') router.push({ name: 'upgrade' })
|
|
|
|
|
}
|
2026-06-29 08:33:20 +09:00
|
|
|
|
|
|
|
|
// ===== 네트워크 상태 배너 =====
|
|
|
|
|
// 오프라인이거나(서버 연결 끊김) 서버 응답이 없을 때 상단에 안내 배너를 띄운다.
|
|
|
|
|
const online = ref(typeof navigator !== 'undefined' ? navigator.onLine : true)
|
|
|
|
|
const netTrouble = ref(false) // 온라인인데 서버 응답이 없는 경우(타임아웃/서버다운) 잠깐 표시
|
|
|
|
|
let netTimer = null
|
|
|
|
|
function onOnline() { online.value = true }
|
|
|
|
|
function onOffline() { online.value = false }
|
|
|
|
|
function onNetError() {
|
|
|
|
|
if (typeof navigator !== 'undefined' && !navigator.onLine) { online.value = false; return }
|
|
|
|
|
netTrouble.value = true
|
|
|
|
|
clearTimeout(netTimer)
|
|
|
|
|
netTimer = setTimeout(() => { netTrouble.value = false }, 5000)
|
|
|
|
|
}
|
|
|
|
|
const showNetBanner = computed(() => !online.value || netTrouble.value)
|
|
|
|
|
const netBannerText = computed(() =>
|
|
|
|
|
!online.value
|
|
|
|
|
? '📡 인터넷 연결이 끊겼어요. 연결 상태를 확인해 주세요.'
|
|
|
|
|
: '⚠️ 서버에 연결할 수 없어요. 잠시 후 다시 시도해 주세요.',
|
|
|
|
|
)
|
|
|
|
|
function retryReload() {
|
|
|
|
|
window.location.reload()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 08:45:38 +09:00
|
|
|
// ===== 앱 잠금(PIN) =====
|
|
|
|
|
// 백그라운드로 가면 잠가서, 복귀 시 잠금 화면이 떠 있도록 한다.
|
|
|
|
|
function onVisibility() {
|
|
|
|
|
if (document.hidden) appLock.lock()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 00:16:50 +09:00
|
|
|
onMounted(() => {
|
|
|
|
|
if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략
|
|
|
|
|
window.addEventListener('auth:unauthorized', onUnauthorized)
|
2026-06-28 11:02:44 +09:00
|
|
|
window.addEventListener('premium:required', onPremiumRequired)
|
2026-06-29 08:33:20 +09:00
|
|
|
window.addEventListener('online', onOnline)
|
|
|
|
|
window.addEventListener('offline', onOffline)
|
|
|
|
|
window.addEventListener('net:error', onNetError)
|
2026-06-29 08:45:38 +09:00
|
|
|
// 앱 잠금: 저장된 세션으로 재진입하는 경우 시작 시 잠금(새 로그인 직후는 잠그지 않음)
|
|
|
|
|
if (appLock.state.enabled && appLock.hasSession()) appLock.lock()
|
|
|
|
|
document.addEventListener('visibilitychange', onVisibility)
|
2026-06-06 00:16:50 +09:00
|
|
|
ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영)
|
2026-06-28 13:21:37 +09:00
|
|
|
// 로그인 상태면 전체 프로필(아바타·포인트) 최신화 — 재로그인 없이 헤더 아바타 반영
|
2026-06-28 17:42:00 +09:00
|
|
|
if (auth.isAuthenticated) {
|
|
|
|
|
auth.refreshProfile().then(() => {
|
|
|
|
|
if (reminders.isNative()) {
|
|
|
|
|
reminders.scheduleExpiryReminder({
|
|
|
|
|
premium: auth.isPremium,
|
|
|
|
|
autoRenew: !!auth.user?.planAutoRenew,
|
|
|
|
|
expiresAt: auth.user?.planExpiresAt,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
if (reminders.isNative()) reminders.scheduleEntryReminder(false) // 오늘 기록 여부는 홈/내역에서 보정
|
|
|
|
|
}
|
2026-06-06 00:16:50 +09:00
|
|
|
})
|
2026-06-28 11:02:44 +09:00
|
|
|
onUnmounted(() => {
|
|
|
|
|
window.removeEventListener('auth:unauthorized', onUnauthorized)
|
|
|
|
|
window.removeEventListener('premium:required', onPremiumRequired)
|
2026-06-29 08:33:20 +09:00
|
|
|
window.removeEventListener('online', onOnline)
|
|
|
|
|
window.removeEventListener('offline', onOffline)
|
|
|
|
|
window.removeEventListener('net:error', onNetError)
|
2026-06-29 08:45:38 +09:00
|
|
|
document.removeEventListener('visibilitychange', onVisibility)
|
2026-06-29 08:33:20 +09:00
|
|
|
clearTimeout(netTimer)
|
2026-06-28 11:02:44 +09:00
|
|
|
})
|
2026-05-31 15:42:52 +09:00
|
|
|
|
|
|
|
|
// 페이지 이동 시 모바일 사이드바 자동 닫힘
|
|
|
|
|
watch(() => route.fullPath, () => ui.closeSidebar())
|
2026-05-30 21:18:13 +09:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-06-28 17:09:35 +09:00
|
|
|
<!-- 웹(브라우저): 공개 법적 고지는 노출, 그 외엔 안내 페이지 -->
|
|
|
|
|
<template v-if="!isApp">
|
|
|
|
|
<RouterView v-if="isPublicLegal" />
|
|
|
|
|
<WebOnlyNotice v-else />
|
|
|
|
|
</template>
|
2026-06-06 00:16:50 +09:00
|
|
|
|
|
|
|
|
<!-- 앱(Capacitor): 전체 기능 -->
|
|
|
|
|
<template v-else>
|
2026-06-29 08:45:38 +09:00
|
|
|
<!-- 앱 잠금 화면 (로그인 상태 + 잠금 활성 시) -->
|
|
|
|
|
<AppLockScreen v-if="appLock.state.locked && auth.isAuthenticated" />
|
|
|
|
|
|
2026-06-29 08:33:20 +09:00
|
|
|
<!-- 네트워크 오류/오프라인 안내 배너 -->
|
|
|
|
|
<Transition name="netbar">
|
|
|
|
|
<div v-if="showNetBanner" class="net-banner" role="alert">
|
|
|
|
|
<span class="net-text">{{ netBannerText }}</span>
|
|
|
|
|
<button type="button" class="net-retry" @click="retryReload">새로고침</button>
|
|
|
|
|
</div>
|
|
|
|
|
</Transition>
|
|
|
|
|
|
2026-06-28 16:38:07 +09:00
|
|
|
<div class="layout" :class="{ 'sidebar-open': ui.sidebarOpen, 'no-sidebar': !authed, 'has-ad': showAds }">
|
2026-06-06 00:16:50 +09:00
|
|
|
<AppHeader class="layout-top" />
|
2026-06-28 07:49:37 +09:00
|
|
|
<!-- 로그인/둘러보기 전에는 메뉴가 없어 사이드바를 숨김(빈 칸 제거·중앙 정렬) -->
|
|
|
|
|
<template v-if="authed">
|
2026-06-07 17:17:42 +09:00
|
|
|
<AppSidebar class="layout-left" />
|
|
|
|
|
<div class="sidebar-backdrop" @click="ui.closeSidebar()"></div>
|
|
|
|
|
</template>
|
2026-06-06 00:16:50 +09:00
|
|
|
<main class="layout-body">
|
2026-06-28 07:49:37 +09:00
|
|
|
<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>
|
2026-06-06 00:16:50 +09:00
|
|
|
<RouterView />
|
|
|
|
|
</main>
|
2026-06-28 18:29:10 +09:00
|
|
|
<!-- 하단 내비 위 광고 (유료 회원 제외). 앱은 320x50 모바일 배너(임시) -->
|
|
|
|
|
<AdBanner v-if="showAds" class="app-ad" unit="DAN-qd8As3nqJjXTQKa6" :width="320" :height="50" />
|
2026-06-06 14:08:01 +09:00
|
|
|
<AppBottomNav class="layout-bottom" />
|
2026-06-06 00:16:50 +09:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<LoginModal />
|
|
|
|
|
<SignupModal />
|
|
|
|
|
</template>
|
2026-06-24 21:57:50 +09:00
|
|
|
|
|
|
|
|
<!-- 공용 인앱 다이얼로그 (네이티브 alert/confirm/prompt 대체) -->
|
|
|
|
|
<AppDialog />
|
2026-05-30 21:18:13 +09:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-05-31 15:42:52 +09:00
|
|
|
.layout {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-areas:
|
2026-06-27 23:49:02 +09:00
|
|
|
'left top'
|
2026-05-31 15:42:52 +09:00
|
|
|
'left body'
|
|
|
|
|
'bottom bottom';
|
|
|
|
|
grid-template-columns: 220px 1fr;
|
|
|
|
|
grid-template-rows: auto 1fr auto;
|
|
|
|
|
min-height: 100vh;
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
.layout-top {
|
|
|
|
|
grid-area: top;
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
.layout-left {
|
|
|
|
|
grid-area: left;
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
|
|
|
|
|
2026-06-28 07:49:37 +09:00
|
|
|
.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;
|
|
|
|
|
}
|
2026-06-29 08:33:20 +09:00
|
|
|
|
|
|
|
|
/* 네트워크 오류/오프라인 배너 — 화면 최상단 고정 */
|
|
|
|
|
.net-banner {
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
z-index: 2000;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
padding: calc(env(safe-area-inset-top, 0) + 0.5rem) 0.9rem 0.5rem;
|
|
|
|
|
background: #c0392b;
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
|
|
|
|
}
|
|
|
|
|
.net-text {
|
|
|
|
|
word-break: keep-all;
|
|
|
|
|
}
|
|
|
|
|
.net-retry {
|
|
|
|
|
flex: none;
|
|
|
|
|
padding: 0.25rem 0.7rem;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: #fff;
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
.net-retry:hover {
|
|
|
|
|
background: rgba(255, 255, 255, 0.15);
|
|
|
|
|
}
|
|
|
|
|
.netbar-enter-active,
|
|
|
|
|
.netbar-leave-active {
|
|
|
|
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
.netbar-enter-from,
|
|
|
|
|
.netbar-leave-to {
|
|
|
|
|
transform: translateY(-100%);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
2026-06-28 07:49:37 +09:00
|
|
|
.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);
|
|
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
.layout-body {
|
|
|
|
|
grid-area: body;
|
|
|
|
|
padding: 1.5rem 2rem;
|
2026-06-06 15:49:07 +09:00
|
|
|
/* 하단 고정 내비(56px)+소프트키 인셋만큼 비워 콘텐츠가 가리지 않게 */
|
|
|
|
|
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0) + 1rem);
|
2026-05-31 15:42:52 +09:00
|
|
|
overflow: auto;
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
.layout-bottom {
|
|
|
|
|
grid-area: bottom;
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
|
|
|
|
|
2026-06-28 16:38:07 +09:00
|
|
|
/* 하단 내비(56px) 바로 위 고정 광고 */
|
|
|
|
|
.app-ad {
|
|
|
|
|
position: fixed;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
bottom: calc(56px + env(safe-area-inset-bottom, 0));
|
|
|
|
|
z-index: 999;
|
|
|
|
|
background: var(--color-background);
|
|
|
|
|
border-top: 1px solid var(--color-border);
|
|
|
|
|
}
|
2026-06-28 18:29:10 +09:00
|
|
|
/* 광고가 있을 땐 본문 하단 여백을 광고 높이(320x50 배너)만큼 더 확보 */
|
2026-06-28 16:38:07 +09:00
|
|
|
.layout.has-ad .layout-body {
|
2026-06-28 18:29:10 +09:00
|
|
|
padding-bottom: calc(56px + 62px + env(safe-area-inset-bottom, 0) + 1rem);
|
2026-06-28 16:38:07 +09:00
|
|
|
}
|
|
|
|
|
|
2026-06-07 17:17:42 +09:00
|
|
|
/* 로그인 전: 사이드바 없이 단일 컬럼 — 콘텐츠가 창 중앙에 오도록 */
|
|
|
|
|
.layout.no-sidebar {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
grid-template-areas:
|
|
|
|
|
'top'
|
|
|
|
|
'body'
|
|
|
|
|
'bottom';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
.sidebar-backdrop {
|
|
|
|
|
display: none;
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 15:42:52 +09:00
|
|
|
/* 모바일: 사이드바를 오프캔버스 드로어로 */
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
.layout {
|
|
|
|
|
grid-template-areas:
|
|
|
|
|
'top'
|
|
|
|
|
'body'
|
|
|
|
|
'bottom';
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
grid-template-rows: auto 1fr auto;
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
.layout-left {
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
z-index: 1200;
|
|
|
|
|
width: 78%;
|
|
|
|
|
max-width: 280px;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
transform: translateX(-100%);
|
|
|
|
|
transition: transform 0.22s ease;
|
|
|
|
|
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.25);
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
.layout.sidebar-open .layout-left {
|
|
|
|
|
transform: translateX(0);
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
2026-05-31 15:42:52 +09:00
|
|
|
.layout.sidebar-open .sidebar-backdrop {
|
|
|
|
|
display: block;
|
|
|
|
|
position: fixed;
|
|
|
|
|
inset: 0;
|
|
|
|
|
z-index: 1150;
|
|
|
|
|
background: rgba(0, 0, 0, 0.45);
|
|
|
|
|
}
|
|
|
|
|
.layout-body {
|
|
|
|
|
padding: 1rem;
|
2026-06-06 15:49:07 +09:00
|
|
|
padding-bottom: calc(56px + env(safe-area-inset-bottom, 0) + 1rem);
|
2026-05-30 21:18:13 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|