- accountApi: shopStatus(), buyShopItem(item) 추가; buyShopItem은 DEMO_WRITES에 등록 - router: /settings/shop → ShopView.vue 라우트 추가 - ShopView.vue: 포인트 잔액·상품 카드(계좌 슬롯/AI 통계) 구매 화면, PREMIUM 회원 안내 분기 - AccountInfoView: '기프트콘 교환' → '포인트 상점' 로 이름 변경 + 클릭 시 /settings/shop 이동 - AccountDashboardView: AI 코멘트 402 응답 시 크레딧 소진 안내 + 상점 이동 버튼 노출 - LAUNCH-PROGRESS: 포인트 상점 ✅ 완료 표시, 다음 재개 지점 현행화 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const now = new Date()
|
||||
const year = ref(now.getFullYear())
|
||||
const month = ref(now.getMonth() + 1)
|
||||
@@ -32,6 +35,7 @@ const forecastHint = ref('') // 초반(표본 부족)엔 값 대신 안내
|
||||
const FORECAST_MIN_DAYS = 10 // 이 날짜(경과일)부터 예상 지출 노출(초반 표본 부족·튐 방지)
|
||||
const forecastLoading = ref(false)
|
||||
const aiLoading = ref(false)
|
||||
const aiNoCredit = ref(false) // FREE 회원 크레딧 소진 상태
|
||||
|
||||
const periodLabel = computed(() => `${year.value}년 ${String(month.value).padStart(2, '0')}월`)
|
||||
|
||||
@@ -334,6 +338,7 @@ async function load() {
|
||||
// 이번 달 집계만 백엔드로 보내 AI 재무 코멘트를 받는다(거래 원문 미전송, 실패해도 무중단)
|
||||
async function loadAiComment() {
|
||||
aiLoading.value = true
|
||||
aiNoCredit.value = false
|
||||
aiBullets.value = []
|
||||
aiTips.value = []
|
||||
try {
|
||||
@@ -349,7 +354,10 @@ async function loadAiComment() {
|
||||
})
|
||||
aiBullets.value = res?.bullets || []
|
||||
aiTips.value = res?.tips || []
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e.response?.status === 402) {
|
||||
aiNoCredit.value = true
|
||||
}
|
||||
aiBullets.value = []
|
||||
aiTips.value = []
|
||||
} finally {
|
||||
@@ -421,6 +429,13 @@ onMounted(async () => {
|
||||
<p v-if="loading" class="msg">불러오는 중...</p>
|
||||
|
||||
<template v-else>
|
||||
<!-- 무료 회원 AI 크레딧 소진 안내 -->
|
||||
<div v-if="aiNoCredit" class="ai-comment ai-no-credit">
|
||||
<div class="ai-comment-head">✨ AI 코멘트</div>
|
||||
<p class="ai-credit-msg">AI 통계 분석 이용권이 소진되었습니다.<br>포인트 상점에서 추가 구매할 수 있어요.</p>
|
||||
<button type="button" class="ai-shop-btn" @click="router.push('/settings/shop')">포인트 상점 ›</button>
|
||||
</div>
|
||||
|
||||
<!-- AI 재무 코멘트 + 절약 가이드: 이번 달 집계 해석 -->
|
||||
<div v-if="aiLoading || aiBullets.length || aiTips.length" class="ai-comment">
|
||||
<div class="ai-comment-head">✨ 이번 달 AI 코멘트</div>
|
||||
@@ -725,6 +740,26 @@ h2 {
|
||||
color: #b8860b;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.ai-no-credit {
|
||||
border-color: hsla(220, 80%, 60%, 0.4);
|
||||
}
|
||||
.ai-credit-msg {
|
||||
font-size: 0.88rem;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.5;
|
||||
margin: 0.4rem 0 0.7rem;
|
||||
}
|
||||
.ai-shop-btn {
|
||||
display: inline-block;
|
||||
padding: 6px 14px;
|
||||
border-radius: 7px;
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -161,9 +161,8 @@ async function restoreSub() {
|
||||
}
|
||||
}
|
||||
|
||||
// 포인트 기프트콘 교환 — 업데이트 예정
|
||||
function exchangeGiftcon() {
|
||||
dialog.alert('포인트 기프트콘 교환은 업데이트 예정입니다. 조금만 기다려 주세요!')
|
||||
function goShop() {
|
||||
router.push('/settings/shop')
|
||||
}
|
||||
|
||||
// 플랜 구독/변경 — Android 만 결제, PC 는 업그레이드(앱 안내) 화면으로
|
||||
@@ -323,9 +322,9 @@ async function withdraw() {
|
||||
<span class="point-more">내역 ›</span>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" class="row row-point" @click="exchangeGiftcon">
|
||||
<span class="k">기프트콘 교환</span>
|
||||
<span class="v"><span class="soon">업데이트 예정</span></span>
|
||||
<button type="button" class="row row-point" @click="goShop">
|
||||
<span class="k">포인트 상점</span>
|
||||
<span class="v"><span class="point-more">›</span></span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<div class="shop-wrap">
|
||||
<header class="shop-header">
|
||||
<button type="button" class="back-btn" @click="router.back()">‹</button>
|
||||
<h1 class="shop-title">포인트 상점</h1>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="shop-loading">불러오는 중…</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 포인트 잔액 -->
|
||||
<section class="points-bar">
|
||||
<span class="points-label">보유 포인트</span>
|
||||
<span class="points-value">{{ points.toLocaleString('ko-KR') }} P</span>
|
||||
</section>
|
||||
|
||||
<!-- 유료 회원 안내 -->
|
||||
<div v-if="isPremium" class="premium-notice">
|
||||
유료 멤버십 이용 중 — 모든 기능 무제한 사용 가능합니다.<br>
|
||||
포인트 상점 아이템은 무료 회원 전용입니다.
|
||||
</div>
|
||||
|
||||
<!-- 상품 목록 -->
|
||||
<section v-else class="product-list">
|
||||
<!-- 추가 계좌 슬롯 -->
|
||||
<div class="product-card">
|
||||
<div class="product-icon">🏦</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">추가 계좌 슬롯</div>
|
||||
<div class="product-desc">
|
||||
무료 회원은 종류별 계좌 2개까지 등록 가능합니다.<br>
|
||||
슬롯 1개 구매 시 모든 종류에 +1개 추가 (최대 3개 구매 가능).
|
||||
</div>
|
||||
<div class="product-status">
|
||||
보유 슬롯: <strong>{{ status.extraWalletSlots }}</strong> / {{ status.maxExtraSlots }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-buy">
|
||||
<div class="product-price">{{ status.walletSlotPrice.toLocaleString('ko-KR') }} P</div>
|
||||
<button
|
||||
type="button"
|
||||
class="buy-btn"
|
||||
:disabled="status.extraWalletSlots >= status.maxExtraSlots || buying === 'WALLET_SLOT'"
|
||||
@click="buy('WALLET_SLOT')"
|
||||
>
|
||||
<template v-if="status.extraWalletSlots >= status.maxExtraSlots">최대 구매</template>
|
||||
<template v-else-if="buying === 'WALLET_SLOT'">구매 중…</template>
|
||||
<template v-else>구매</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 통계 분석 -->
|
||||
<div class="product-card">
|
||||
<div class="product-icon">✨</div>
|
||||
<div class="product-info">
|
||||
<div class="product-name">AI 통계 분석 {{ status.aiStatPackSize }}회</div>
|
||||
<div class="product-desc">
|
||||
통계 화면에서 AI 재무 코멘트를 받을 수 있습니다.<br>
|
||||
{{ status.aiStatPackSize }}회 이용권 구매 (소진 후 재구매 가능).
|
||||
</div>
|
||||
<div class="product-status">
|
||||
남은 이용권: <strong>{{ status.aiStatCredits }}</strong>회
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-buy">
|
||||
<div class="product-price">{{ status.aiStatPrice.toLocaleString('ko-KR') }} P</div>
|
||||
<button
|
||||
type="button"
|
||||
class="buy-btn"
|
||||
:disabled="buying === 'AI_STAT'"
|
||||
@click="buy('AI_STAT')"
|
||||
>
|
||||
<template v-if="buying === 'AI_STAT'">구매 중…</template>
|
||||
<template v-else>구매</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 포인트 적립 안내 -->
|
||||
<section class="earn-guide">
|
||||
<h3 class="earn-title">포인트 적립 방법</h3>
|
||||
<ul class="earn-list">
|
||||
<li>가계부 내역 작성: <strong>10P</strong> (일 5회 한도)</li>
|
||||
<li>게시판 글 작성: <strong>10P</strong> (일 5회 한도)</li>
|
||||
<li>게시글 추천받기: <strong>5P</strong></li>
|
||||
<li>남의 글 추천하기: <strong>1P</strong></li>
|
||||
<li>한 달 개근 보너스: <strong>최대 100P</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDialog } from '@/composables/dialog'
|
||||
import { accountApi } from '@/api/accountApi'
|
||||
import { authApi } from '@/api/authApi'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const dialog = useDialog()
|
||||
|
||||
const loading = ref(true)
|
||||
const buying = ref('')
|
||||
const points = ref(0)
|
||||
const status = ref({
|
||||
extraWalletSlots: 0,
|
||||
aiStatCredits: 0,
|
||||
maxExtraSlots: 3,
|
||||
walletSlotPrice: 500,
|
||||
aiStatPrice: 1000,
|
||||
aiStatPackSize: 10,
|
||||
})
|
||||
|
||||
const isPremium = computed(() => auth.user?.plan === 'PREMIUM')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [pt, st] = await Promise.all([authApi.points(), accountApi.shopStatus()])
|
||||
points.value = pt.points ?? 0
|
||||
status.value = st
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function buy(item) {
|
||||
const labels = { WALLET_SLOT: '추가 계좌 슬롯', AI_STAT: `AI 통계 분석 ${status.value.aiStatPackSize}회` }
|
||||
const prices = { WALLET_SLOT: status.value.walletSlotPrice, AI_STAT: status.value.aiStatPrice }
|
||||
|
||||
const ok = await dialog.confirm(
|
||||
`${labels[item]} (${prices[item].toLocaleString('ko-KR')}P)를 구매하시겠습니까?`
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
buying.value = item
|
||||
try {
|
||||
status.value = await accountApi.buyShopItem(item)
|
||||
const pt = await authApi.points()
|
||||
points.value = pt.points ?? 0
|
||||
dialog.alert('구매가 완료되었습니다!')
|
||||
} catch (e) {
|
||||
const msg = e.response?.data?.message
|
||||
if (msg === 'SHOP_NOT_ENOUGH_POINTS') {
|
||||
dialog.alert('포인트가 부족합니다.')
|
||||
} else if (msg === 'SHOP_WALLET_SLOT_MAX') {
|
||||
dialog.alert('추가 계좌 슬롯은 최대 3개까지 구매할 수 있습니다.')
|
||||
} else {
|
||||
dialog.alert('구매 중 오류가 발생했습니다.')
|
||||
}
|
||||
} finally {
|
||||
buying.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shop-wrap {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 40px;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg, #f7f8fa);
|
||||
}
|
||||
|
||||
.shop-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 12px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-bottom: 1px solid var(--color-border, #e8eaed);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #222);
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.shop-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shop-loading {
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
|
||||
.points-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
background: var(--color-surface, #fff);
|
||||
margin: 12px 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.points-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-muted, #888);
|
||||
}
|
||||
|
||||
.points-value {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.premium-notice {
|
||||
margin: 12px 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
border: 1px solid #bbf7d0;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.product-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.product-status {
|
||||
font-size: 13px;
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
|
||||
.product-buy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #2563eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
padding: 7px 16px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.buy-btn:disabled {
|
||||
background: #c0c8d8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.earn-guide {
|
||||
margin: 20px 16px 0;
|
||||
padding: 16px;
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
}
|
||||
|
||||
.earn-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.earn-list {
|
||||
margin: 0;
|
||||
padding: 0 0 0 18px;
|
||||
list-style: disc;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted, #888);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.earn-list strong {
|
||||
color: var(--color-text, #222);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.shop-wrap { background: #111; }
|
||||
.shop-header { background: #1c1c1e; border-color: #2c2c2e; }
|
||||
.points-bar, .product-card, .earn-guide { background: #1c1c1e; box-shadow: none; }
|
||||
.premium-notice { background: #052e16; color: #bbf7d0; border-color: #166534; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user