diff --git a/src/api/billingApi.js b/src/api/billingApi.js index 12eff03..73b619c 100644 --- a/src/api/billingApi.js +++ b/src/api/billingApi.js @@ -10,4 +10,16 @@ export const billingApi = { verifyGoogle(payload) { return http.post('/billing/google/verify', payload) }, + // 현재 구독 현황 (플랜·만료일·다음 결제일·자동갱신) + subscription() { + return http.get('/billing/subscription') + }, + // 구독 해지 (자동 갱신 중단) + cancel() { + return http.post('/billing/cancel') + }, + // 구독 재개 (만료 전 자동 갱신 재개) + resume() { + return http.post('/billing/resume') + }, } diff --git a/src/views/settings/AccountInfoView.vue b/src/views/settings/AccountInfoView.vue index c5540b1..41a8b72 100644 --- a/src/views/settings/AccountInfoView.vue +++ b/src/views/settings/AccountInfoView.vue @@ -3,6 +3,8 @@ 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' @@ -49,11 +51,89 @@ function fmtDateTime(s) { 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 = '' + } +} + +// 플랜 구독/변경 — 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)) @@ -164,6 +244,74 @@ function goEdit() { + +
+

정기결제 관리

+ + + + + + +
+

{{ sub && sub.premium ? '플랜 변경' : '구독 플랜' }}

+ +

📱 구독 결제·변경은 모바일 앱에서 진행됩니다.

+
+ +

{{ subMsg }}

+

{{ subErr }}

+
+
@@ -276,6 +424,125 @@ function goEdit() { 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;