Files
sb-front/src/views/settings/AccountInfoView.vue
T
ByungCheol fa3b12b369
CI / build (push) Failing after 13m41s
feat: 계정정보 구매 복원 + 기프트콘 교환(업데이트 예정)
- 정기결제 관리에 구매 복원 버튼(billingApi.restore)
- 포인트 옆 기프트콘 교환 항목(업데이트 예정 안내)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:29:30 +09:00

834 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/authApi'
import { billingApi } from '@/api/billingApi'
import { billing } from '@/native/billing'
import { useDialog } from '@/composables/dialog'
import { avatarSrc, avatarInitial, fileToAvatarDataUrl } from '@/utils/avatar'
const auth = useAuthStore()
const router = useRouter()
const dialog = useDialog()
// 포인트(게시판 작성으로 수시 변동) — 진입 시 최신값 조회
const points = ref(auth.user?.points ?? 0)
onMounted(async () => {
try {
const res = await authApi.points()
points.value = res.points ?? 0
await auth.applyUser({ points: points.value })
} catch {
// 조회 실패 시 캐시값 유지
}
})
// 포인트 적립내역 모달
const POINT_REASONS = { BOARD_WRITE: '게시판 작성' }
function reasonLabel(r) {
return POINT_REASONS[r] || r
}
const historyOpen = ref(false)
const history = ref([])
const historyLoading = ref(false)
async function openHistory() {
historyOpen.value = true
historyLoading.value = true
try {
history.value = await authApi.pointHistory()
} catch {
history.value = []
} finally {
historyLoading.value = false
}
}
function fmtDateTime(s) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
const p = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}.${p(d.getMonth() + 1)}.${p(d.getDate())}`
}
const u = computed(() => auth.user || {})
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
const isPremium = computed(() => auth.isPremium)
const planLabel = computed(() => (isPremium.value ? '유료 멤버십' : '무료'))
// ===== 정기결제(구독) 관리 =====
const isNative = billing.isNative()
const sub = ref(null) // { premium, planLabel, expiresAt, autoRenew, nextBillingAt }
const products = ref([])
const subBusy = ref('')
const subMsg = ref('')
const subErr = ref('')
async function loadSubscription() {
try {
const [s, p] = await Promise.all([billingApi.subscription(), billingApi.products()])
sub.value = s
products.value = p
} catch {
sub.value = null
}
}
async function cancelSub() {
const ok = await dialog.confirm(
'구독을 해지하시겠어요?\n\n· 지금 해지해도 만료일까지는 프리미엄을 그대로 이용할 수 있어요.\n· 만료일 이후 자동으로 무료로 전환됩니다.\n· 만료 전에는 언제든 다시 구독(재개)할 수 있어요.',
{ title: '구독 해지', danger: true },
)
if (!ok) return
subBusy.value = 'cancel'; subMsg.value = ''; subErr.value = ''
try {
sub.value = await billingApi.cancel()
subMsg.value = '구독이 해지되었습니다. 만료일까지 이용 가능합니다.'
} catch (e) {
subErr.value = e.response?.data?.message || '해지에 실패했습니다.'
} finally {
subBusy.value = ''
}
}
async function resumeSub() {
subBusy.value = 'resume'; subMsg.value = ''; subErr.value = ''
try {
sub.value = await billingApi.resume()
subMsg.value = '구독이 재개되었습니다.'
} catch (e) {
subErr.value = e.response?.data?.message || '재개에 실패했습니다.'
} finally {
subBusy.value = ''
}
}
async function restoreSub() {
if (subBusy.value) return
subBusy.value = 'restore'; subMsg.value = ''; subErr.value = ''
try {
sub.value = await billingApi.restore()
await auth.refreshProfile()
subMsg.value = sub.value?.premium ? '구매가 복원되었습니다.' : '복원할 결제 내역이 없습니다.'
} catch (e) {
subErr.value = e.response?.data?.message || '복원에 실패했습니다.'
} finally {
subBusy.value = ''
}
}
// 포인트 기프트콘 교환 — 업데이트 예정
function exchangeGiftcon() {
dialog.alert('포인트 기프트콘 교환은 업데이트 예정입니다. 조금만 기다려 주세요!')
}
// 플랜 구독/변경 — Android 만 결제, PC 는 업그레이드(앱 안내) 화면으로
async function choosePlan(product) {
if (!isNative) {
router.push('/upgrade')
return
}
if (subBusy.value) return
subBusy.value = product.id; subMsg.value = ''; subErr.value = ''
try {
const receipt = await billing.purchase(product.id)
await billingApi.verifyGoogle(receipt)
await auth.refreshProfile()
await loadSubscription()
subMsg.value = `${product.label}(으)로 적용되었습니다.`
} catch (e) {
subErr.value = e.response?.data?.message || '결제에 실패했습니다.'
} finally {
subBusy.value = ''
}
}
onMounted(loadSubscription)
// 아바타: 사용자 지정 > 구글 > 이니셜
const avatar = computed(() => avatarSrc(u.value))
const initial = computed(() => avatarInitial(u.value))
const hasCustom = computed(() => !!u.value.profileImage)
const hasGoogle = computed(() => !!u.value.googlePicture)
const fileInput = ref(null)
const savingImage = ref(false)
const imgError = ref('')
function pickImage() {
imgError.value = ''
fileInput.value?.click()
}
async function onImageFile(e) {
const file = e.target.files?.[0]
e.target.value = '' // 같은 파일 다시 선택 가능하도록 초기화
if (!file) return
savingImage.value = true
imgError.value = ''
try {
const dataUrl = await fileToAvatarDataUrl(file)
const updated = await authApi.updateProfileImage(dataUrl)
await auth.applyUser(updated)
} catch (err) {
imgError.value = err.response?.data?.message || '사진 변경에 실패했습니다.'
} finally {
savingImage.value = false
}
}
async function removePhoto() {
const msg = hasGoogle.value
? '등록한 사진을 삭제할까요?\n구글 계정 사진으로 표시됩니다.'
: '등록한 사진을 삭제할까요?'
if (!(await dialog.confirm(msg, { title: '사진 삭제', danger: true }))) return
savingImage.value = true
imgError.value = ''
try {
const updated = await authApi.updateProfileImage(null)
await auth.applyUser(updated)
} catch (err) {
imgError.value = err.response?.data?.message || '사진 삭제에 실패했습니다.'
} finally {
savingImage.value = false
}
}
function goEdit() {
router.push('/settings/account/edit')
}
// ===== 회원 탈퇴 =====
const withdrawing = ref(false)
async function withdraw() {
if (withdrawing.value) return
const ok = await dialog.confirm(
'정말 탈퇴하시겠어요?\n\n· 계정과 모든 데이터(가계부·계좌·게시글·댓글·포인트·결제내역)가 영구 삭제됩니다.\n· 삭제된 데이터는 복구할 수 없습니다.',
{ title: '회원 탈퇴', danger: true },
)
if (!ok) return
const ok2 = await dialog.confirm('마지막 확인입니다. 정말 탈퇴를 진행할까요?', { title: '회원 탈퇴', danger: true })
if (!ok2) return
withdrawing.value = true
try {
await authApi.withdraw()
await auth.clear()
router.replace('/')
dialog.alert('탈퇴가 완료되었습니다. 그동안 이용해 주셔서 감사합니다.')
} catch (e) {
withdrawing.value = false
alert(e.response?.data?.message || '탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.')
}
}
</script>
<template>
<div class="account-info">
<!-- 프로필 사진 -->
<div class="avatar-block">
<button type="button" class="avatar-btn" :disabled="savingImage" @click="pickImage" aria-label="프로필 사진 변경">
<img v-if="avatar" :src="avatar" class="avatar-img" alt="프로필 사진" referrerpolicy="no-referrer" />
<span v-else class="avatar-fallback">{{ initial }}</span>
<span class="avatar-cam" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
</span>
</button>
<div class="avatar-actions">
<button type="button" class="link-btn" :disabled="savingImage" @click="pickImage">
{{ savingImage ? '처리 ' : '사진 변경' }}
</button>
<button v-if="hasCustom" type="button" class="link-btn muted" :disabled="savingImage" @click="removePhoto">
삭제
</button>
</div>
<p v-if="imgError" class="avatar-err">{{ imgError }}</p>
<input ref="fileInput" type="file" accept="image/*" class="hidden-file" @change="onImageFile" />
</div>
<section class="card">
<div class="row">
<span class="k">이름</span>
<span class="v">{{ u.name || '-' }}</span>
</div>
<div class="row">
<span class="k">이메일</span>
<span class="v">{{ u.email || '-' }}</span>
</div>
<div class="row">
<span class="k">권한</span>
<span class="v">{{ roleLabel }}</span>
</div>
<div class="row">
<span class="k">멤버십</span>
<span class="v">
<span :class="{ premium: isPremium }">{{ planLabel }}</span>
<RouterLink v-if="!isPremium" to="/upgrade" class="up-link">업그레이드 </RouterLink>
</span>
</div>
<button type="button" class="row row-point" @click="openHistory">
<span class="k">포인트</span>
<span class="v">
<span class="points">{{ points.toLocaleString('ko-KR') }} P</span>
<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>
</section>
<!-- 정기결제 관리 -->
<section class="card sub-card">
<h2 class="sub-title">정기결제 관리</h2>
<template v-if="sub && sub.premium">
<div class="row">
<span class="k">결제 플랜</span>
<span class="v">{{ sub.planLabel || '프리미엄' }}</span>
</div>
<div class="row">
<span class="k">만료일</span>
<span class="v">{{ fmtDate(sub.expiresAt) || '-' }}</span>
</div>
<div class="row">
<span class="k">다음 결제일</span>
<span class="v">
<template v-if="sub.autoRenew">{{ fmtDate(sub.nextBillingAt) || '-' }}</template>
<span v-else class="canceled">해지됨 · 만료일까지 이용</span>
</span>
</div>
<!-- 해지/재개 -->
<div class="sub-actions">
<button v-if="sub.autoRenew" type="button" class="sub-btn danger" :disabled="!!subBusy" @click="cancelSub">
{{ subBusy === 'cancel' ? '처리 ' : '구독 해지' }}
</button>
<template v-else>
<p class="sub-note">구독이 해지되어 <b>{{ fmtDate(sub.expiresAt) }}</b>까지 이용 무료로 전환됩니다.</p>
<button type="button" class="sub-btn" :disabled="!!subBusy" @click="resumeSub">
{{ subBusy === 'resume' ? '처리 ' : '구독 재개' }}
</button>
</template>
</div>
</template>
<template v-else>
<p class="sub-free">현재 구독 중이 아닙니다. 프리미엄으로 많은 기능을 이용해 보세요.</p>
</template>
<!-- 플랜 선택/변경 -->
<div v-if="products.length" class="plan-list">
<p class="plan-list-title">{{ sub && sub.premium ? '플랜 변경' : '구독 플랜' }}</p>
<button
v-for="p in products"
:key="p.id"
type="button"
class="plan-row"
:class="{ current: sub && sub.planProduct === p.id }"
:disabled="!!subBusy || (sub && sub.planProduct === p.id)"
@click="choosePlan(p)"
>
<span class="plan-name">{{ p.label }}</span>
<span class="plan-price">{{ p.priceKrw.toLocaleString('ko-KR') }}<span class="plan-per"> / {{ p.months }}개월</span></span>
<span class="plan-cta">
<template v-if="sub && sub.planProduct === p.id">현재 플랜</template>
<template v-else-if="subBusy === p.id">처리 </template>
<template v-else-if="!isNative">앱에서 </template>
<template v-else-if="sub && sub.premium">변경하기</template>
<template v-else>구독하기</template>
</span>
</button>
<p v-if="!isNative" class="sub-note">📱 구독 결제·변경은 모바일 앱에서 진행됩니다.</p>
</div>
<div class="restore-row">
<button type="button" class="link-btn muted" :disabled="!!subBusy" @click="restoreSub">
{{ subBusy === 'restore' ? '복원 ' : '구매 복원' }}
</button>
</div>
<p v-if="subMsg" class="sub-ok">{{ subMsg }}</p>
<p v-if="subErr" class="sub-errmsg">{{ subErr }}</p>
</section>
<!-- 포인트 적립내역 모달 -->
<Teleport to="body">
<div v-if="historyOpen" class="ph-backdrop" @click.self="historyOpen = false">
<div class="ph-modal" role="dialog" aria-modal="true" aria-label="포인트 적립내역">
<div class="ph-head">
<strong>포인트 적립내역</strong>
<button type="button" class="ph-close" aria-label="닫기" @click="historyOpen = false">×</button>
</div>
<p v-if="historyLoading" class="ph-msg">불러오는 </p>
<p v-else-if="!history.length" class="ph-msg">적립 내역이 없습니다.</p>
<ul v-else class="ph-list">
<li v-for="(h, i) in history" :key="i">
<div class="ph-main">
<span class="ph-reason">{{ reasonLabel(h.reason) }}</span>
<span class="ph-date">{{ fmtDateTime(h.createdAt) }}</span>
</div>
<span class="ph-amount" :class="{ minus: h.amount < 0 }">{{ h.amount > 0 ? '+' : '' }}{{ h.amount }} P</span>
</li>
</ul>
</div>
</div>
</Teleport>
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
<div class="withdraw-area">
<button type="button" class="withdraw-link" :disabled="withdrawing" @click="withdraw">
{{ withdrawing ? '처리 ' : '회원 탈퇴' }}
</button>
</div>
</div>
</template>
<style scoped>
.account-info {
max-width: 560px;
margin: 0 auto;
}
.back {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-bottom: 0.75rem;
padding: 0.3rem 0.1rem;
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.75;
cursor: pointer;
font-size: 0.9rem;
}
.back:hover {
opacity: 1;
}
.back svg {
width: 18px;
height: 18px;
}
.page-title {
font-size: 1.4rem;
margin-bottom: 1rem;
}
.card {
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.95rem 1.1rem;
border-bottom: 1px solid var(--color-border);
}
.row:last-child {
border-bottom: 0;
}
.k {
font-size: 0.88rem;
opacity: 0.65;
}
.v {
font-size: 0.95rem;
font-weight: 600;
text-align: right;
word-break: break-all;
}
.v .premium {
color: #b8860b;
}
.points {
color: hsla(160, 100%, 37%, 1);
font-weight: 700;
}
.soon {
font-size: 0.74rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-background-mute);
color: var(--color-text);
opacity: 0.7;
}
.restore-row {
padding: 0.4rem 1.1rem 0.2rem;
text-align: right;
}
.row-point {
width: 100%;
border: 0;
border-bottom: 1px solid var(--color-border);
background: transparent;
cursor: pointer;
font: inherit;
text-align: left;
}
.row-point:last-child {
border-bottom: 0;
}
.row-point:hover {
background: var(--color-background-mute);
}
.point-more {
margin-left: 0.5rem;
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text);
opacity: 0.55;
}
/* 정기결제 관리 */
.sub-card {
margin-top: 1rem;
padding: 0.5rem 0;
}
.sub-title {
font-size: 0.92rem;
font-weight: 700;
padding: 0.6rem 1.1rem 0.3rem;
color: var(--color-heading);
}
.canceled {
color: #c0392b;
font-size: 0.88rem;
}
.sub-free {
padding: 0.9rem 1.1rem;
font-size: 0.9rem;
opacity: 0.85;
}
.sub-actions {
padding: 0.75rem 1.1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
border-top: 1px solid var(--color-border);
}
.sub-note {
font-size: 0.83rem;
opacity: 0.75;
line-height: 1.5;
}
.sub-btn {
align-self: flex-start;
padding: 0.5rem 1.2rem;
border: 1px solid hsla(160, 100%, 37%, 1);
border-radius: 8px;
background: var(--color-background);
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
cursor: pointer;
}
.sub-btn.danger {
border-color: #c0392b;
color: #c0392b;
}
.sub-btn:disabled {
opacity: 0.55;
cursor: default;
}
.plan-list {
padding: 0.75rem 1.1rem;
border-top: 1px solid var(--color-border);
}
.plan-list-title {
font-size: 0.85rem;
font-weight: 600;
opacity: 0.8;
margin-bottom: 0.5rem;
}
.plan-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.7rem 0.8rem;
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-background);
color: var(--color-text);
cursor: pointer;
font-size: 0.9rem;
}
.plan-row:last-child {
margin-bottom: 0;
}
.plan-row.current {
border-color: hsla(160, 100%, 37%, 0.7);
background: hsla(160, 100%, 37%, 0.06);
}
.plan-row:disabled {
cursor: default;
}
.plan-name {
font-weight: 700;
}
.plan-price {
margin-left: auto;
font-weight: 700;
color: #b8860b;
}
.plan-per {
font-weight: 400;
font-size: 0.8rem;
opacity: 0.7;
}
.plan-cta {
min-width: 56px;
text-align: right;
font-size: 0.85rem;
font-weight: 600;
color: hsla(160, 100%, 37%, 1);
}
.plan-row.current .plan-cta {
color: var(--color-text);
opacity: 0.6;
}
.sub-ok {
padding: 0 1.1rem 0.6rem;
color: hsla(160, 100%, 37%, 1);
font-weight: 600;
font-size: 0.88rem;
}
.sub-errmsg {
padding: 0 1.1rem 0.6rem;
color: #c0392b;
font-size: 0.88rem;
}
/* 포인트 내역 모달 */
.ph-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.ph-modal {
width: 100%;
max-width: 380px;
max-height: 75vh;
display: flex;
flex-direction: column;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 10px;
overflow: hidden;
}
.ph-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.ph-close {
border: 0;
background: transparent;
color: var(--color-text);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.ph-msg {
padding: 2rem 1rem;
text-align: center;
opacity: 0.7;
}
.ph-list {
list-style: none;
margin: 0;
padding: 0.25rem 0;
overflow-y: auto;
}
.ph-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.ph-list li:last-child {
border-bottom: 0;
}
.ph-main {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.ph-reason {
font-size: 0.92rem;
}
.ph-date {
font-size: 0.78rem;
opacity: 0.6;
}
.ph-amount {
font-weight: 700;
color: hsla(160, 100%, 37%, 1);
white-space: nowrap;
}
.ph-amount.minus {
color: #c0392b;
}
.up-link {
margin-left: 0.5rem;
font-size: 0.82rem;
font-weight: 600;
color: hsla(160, 100%, 37%, 1);
}
.avatar-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
margin-bottom: 1.25rem;
}
.avatar-btn {
position: relative;
width: 96px;
height: 96px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
padding: 0;
cursor: pointer;
overflow: visible;
}
.avatar-btn:disabled {
opacity: 0.6;
cursor: default;
}
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}
.avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
font-size: 2.2rem;
font-weight: 700;
color: #fff;
background: hsla(160, 100%, 37%, 1);
}
.avatar-cam {
position: absolute;
right: -2px;
bottom: -2px;
width: 30px;
height: 30px;
border-radius: 50%;
background: hsla(160, 100%, 37%, 1);
border: 2px solid var(--color-background);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.avatar-cam svg {
width: 16px;
height: 16px;
}
.avatar-actions {
display: flex;
gap: 0.85rem;
align-items: center;
}
.link-btn {
border: 0;
background: transparent;
color: hsla(160, 100%, 37%, 1);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
padding: 0.2rem 0.1rem;
}
.link-btn.muted {
color: var(--color-text);
opacity: 0.6;
}
.link-btn:disabled {
opacity: 0.5;
cursor: default;
}
.avatar-err {
font-size: 0.82rem;
color: #c0392b;
margin: 0;
}
.hidden-file {
display: none;
}
.primary-btn {
width: 100%;
margin-top: 1.25rem;
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: 0.98rem;
font-weight: 600;
cursor: pointer;
}
.withdraw-area {
margin-top: 1.5rem;
text-align: center;
}
.withdraw-link {
border: 0;
background: transparent;
color: var(--color-text);
opacity: 0.5;
font-size: 0.85rem;
text-decoration: underline;
cursor: pointer;
}
.withdraw-link:hover {
color: #c0392b;
opacity: 1;
}
.withdraw-link:disabled {
opacity: 0.4;
cursor: default;
}
.primary-btn:hover {
background: hsla(160, 100%, 32%, 1);
}
.hint {
margin-top: 1.25rem;
font-size: 0.85rem;
opacity: 0.6;
text-align: center;
}
</style>