feat(shop): 포인트 상점 프론트엔드 구현
Deploy / deploy (push) Failing after 11m25s

- 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:
ByungCheol
2026-07-10 00:32:49 +09:00
parent 00d2945e39
commit 1a85e448aa
6 changed files with 419 additions and 25 deletions
+36 -1
View File
@@ -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;
+5 -6
View File
@@ -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>
+351
View File
@@ -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>