feat(payment): subscription renewal UI, payment button colors, QR branding, refund day deduction

- Add btn-alipay/btn-wxpay CSS classes; confirm button color follows payment method
- Subscription confirm header uses platform gradient (Anthropic orange, Gemini blue, etc.)
- Subscription confirm page shows plan details (rate, limits, validity)
- SubscriptionPlanCard: show "Renew" button when user has active subscription
- SubscriptionsView: add renewal button on active subscription cards
- QR code display: brand-color border + center logo overlay (Alipay blue, WeChat green)
- StripePaymentView: WeChat QR green border + logo, Alipay spinner brand color
- Backend: fix subscription refund to deduct days (ExtendSubscription -days or Revoke)
- Backend: rollback subscription days on gateway failure
This commit is contained in:
erio
2026-04-10 10:42:06 +08:00
parent db139191e3
commit cd1bbebb77
11 changed files with 189 additions and 50 deletions
@@ -102,6 +102,15 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
if o.OrderType == payment.OrderTypeSubscription {
p.DeductionType = payment.DeductionTypeSubscription
if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
p.SubDaysToDeduct = *o.SubscriptionDays
sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
if err == nil && sub != nil {
p.SubscriptionID = sub.ID
} else if !force {
return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
}
}
return nil
}
u, err := s.userRepo.GetByID(ctx, o.UserID)
@@ -137,6 +146,22 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref
p.BalanceToDeduct = 0
}
}
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
_, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
if err != nil {
// If deducting would expire the subscription, revoke it entirely
slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
s.restoreStatus(ctx, p)
return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
}
}
} else {
slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
p.SubDaysToDeduct = 0
}
}
if err := s.gwRefund(ctx, p); err != nil {
return s.handleGwFail(ctx, p, err)
}
@@ -204,6 +229,13 @@ func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr
return false
}
}
if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
if _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, p.SubDaysToDeduct); err != nil {
slog.Error("[CRITICAL] subscription rollback failed", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct, "error", err)
s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "subDaysDeducted": p.SubDaysToDeduct})
return false
}
}
return true
}
@@ -109,6 +109,7 @@ type RefundPlan struct {
DeductionType string
BalanceToDeduct float64
SubDaysToDeduct int
SubscriptionID int64
}
type RefundResult struct {
@@ -70,8 +70,14 @@
<div class="card p-6">
<div class="flex flex-col items-center space-y-4">
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ scanTitle }}</p>
<div class="rounded-2xl bg-white p-4 shadow-sm dark:bg-dark-800">
<div :class="['relative rounded-lg border-2 p-4', qrBorderClass]">
<canvas ref="qrCanvas" class="mx-auto"></canvas>
<!-- Brand logo overlay -->
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span :class="['rounded-full p-2 shadow ring-2 ring-white', qrLogoBgClass]">
<img :src="isAlipay ? alipayIcon : wxpayIcon" alt="" class="h-5 w-5 brightness-0 invert" />
</span>
</div>
</div>
<p v-if="scanHint" class="text-center text-sm text-gray-500 dark:text-gray-400">{{ scanHint }}</p>
</div>
@@ -152,6 +158,18 @@ let countdownTimer: ReturnType<typeof setInterval> | null = null
const isAlipay = computed(() => props.paymentType.includes('alipay'))
const isWxpay = computed(() => props.paymentType.includes('wxpay'))
const qrBorderClass = computed(() => {
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
if (isWxpay.value) return 'border-[#2BB741] bg-green-50 dark:border-[#2BB741]/70 dark:bg-green-950/20'
return 'border-gray-200 bg-white dark:border-dark-600 dark:bg-dark-800'
})
const qrLogoBgClass = computed(() => {
if (isAlipay.value) return 'bg-[#00AEEF]'
if (isWxpay.value) return 'bg-[#2BB741]'
return 'bg-gray-400'
})
const scanTitle = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
@@ -179,33 +197,10 @@ function reopenPopup() {
async function renderQR() {
await nextTick()
if (!qrCanvas.value || !qrUrl.value) return
const logoSrc = isAlipay.value ? alipayIcon : isWxpay.value ? wxpayIcon : null
await QRCode.toCanvas(qrCanvas.value, qrUrl.value, {
width: 220, margin: 2,
errorCorrectionLevel: logoSrc ? 'H' : 'M',
errorCorrectionLevel: 'H',
})
if (!logoSrc) return
const canvas = qrCanvas.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const img = new Image()
img.src = logoSrc
img.onload = () => {
const logoSize = 40
const x = (canvas.width - logoSize) / 2
const y = (canvas.height - logoSize) / 2
const pad = 4
ctx.fillStyle = '#FFFFFF'
ctx.beginPath()
const r = 5
ctx.moveTo(x - pad + r, y - pad)
ctx.arcTo(x + logoSize + pad, y - pad, x + logoSize + pad, y + logoSize + pad, r)
ctx.arcTo(x + logoSize + pad, y + logoSize + pad, x - pad, y + logoSize + pad, r)
ctx.arcTo(x - pad, y + logoSize + pad, x - pad, y - pad, r)
ctx.arcTo(x - pad, y - pad, x + logoSize + pad, y - pad, r)
ctx.fill()
ctx.drawImage(img, x, y, logoSize, logoSize)
}
}
async function pollStatus() {
@@ -88,7 +88,7 @@
:class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]"
@click="emit('select', plan)"
>
{{ t('payment.subscribeNow') }}
{{ isRenewal ? t('payment.renewNow') : t('payment.subscribeNow') }}
</button>
</div>
</div>
@@ -98,6 +98,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { SubscriptionPlan } from '@/types/payment'
import type { UserSubscription } from '@/types'
import {
platformAccentBarClass,
platformBadgeLightClass,
@@ -109,11 +110,14 @@ import {
platformLabel,
} from '@/utils/platformColors'
const props = defineProps<{ plan: SubscriptionPlan }>()
const props = defineProps<{ plan: SubscriptionPlan; activeSubscriptions?: UserSubscription[] }>()
const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>()
const { t } = useI18n()
const platform = computed(() => props.plan.group_platform || '')
const isRenewal = computed(() =>
props.activeSubscriptions?.some(s => s.group_id === props.plan.group_id && s.status === 'active') ?? false
)
// Derived color classes from central config
const accentClass = computed(() => platformAccentBarClass(platform.value))
+1
View File
@@ -5172,6 +5172,7 @@ export default {
qrFailed: 'Failed to get WeChat Pay QR code',
},
subscribeNow: 'Subscribe Now',
renewNow: 'Renew',
planFeatures: 'Features',
planCard: {
rate: 'Rate',
+1
View File
@@ -5369,6 +5369,7 @@ export default {
qrFailed: '未能获取微信支付二维码',
},
subscribeNow: '立即开通',
renewNow: '续费',
planFeatures: '功能特性',
planCard: {
rate: '倍率',
+14
View File
@@ -148,6 +148,20 @@
@apply dark:hover:bg-[#635bff];
}
.btn-alipay {
@apply bg-[#00AEEF] text-white shadow-md shadow-[#00AEEF]/25;
@apply hover:bg-[#009dd6] hover:shadow-lg hover:shadow-[#00AEEF]/30;
@apply active:bg-[#008cbe];
@apply dark:shadow-[#00AEEF]/20;
}
.btn-wxpay {
@apply bg-[#2BB741] text-white shadow-md shadow-[#2BB741]/25;
@apply hover:bg-[#24a038] hover:shadow-lg hover:shadow-[#2BB741]/30;
@apply active:bg-[#1d8a2f];
@apply dark:shadow-[#2BB741]/20;
}
.btn-sm {
@apply rounded-lg px-3 py-1.5 text-xs;
}
+38
View File
@@ -78,6 +78,32 @@ const DISCOUNT: Record<Platform, string> = {
}
const DISCOUNT_DEFAULT = 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
// ── Header gradient (subscription confirm) ─────────────────────────
const GRADIENT: Record<Platform, string> = {
anthropic: 'from-orange-500 to-orange-600',
openai: 'from-emerald-500 to-emerald-600',
antigravity: 'from-purple-500 to-purple-600',
gemini: 'from-blue-500 to-blue-600',
}
const GRADIENT_DEFAULT = 'from-primary-500 to-primary-600'
// ── Header text (light text on gradient bg) ────────────────────────
const GRADIENT_TEXT: Record<Platform, string> = {
anthropic: 'text-orange-100',
openai: 'text-emerald-100',
antigravity: 'text-purple-100',
gemini: 'text-blue-100',
}
const GRADIENT_TEXT_DEFAULT = 'text-primary-100'
const GRADIENT_SUBTEXT: Record<Platform, string> = {
anthropic: 'text-orange-200',
openai: 'text-emerald-200',
antigravity: 'text-purple-200',
gemini: 'text-blue-200',
}
const GRADIENT_SUBTEXT_DEFAULT = 'text-primary-200'
// ── Public API ──────────────────────────────────────────────────────
function isPlatform(p: string): p is Platform {
@@ -116,6 +142,18 @@ export function platformDiscountClass(p: string): string {
return isPlatform(p) ? DISCOUNT[p] : DISCOUNT_DEFAULT
}
export function platformGradientClass(p: string): string {
return isPlatform(p) ? GRADIENT[p] : GRADIENT_DEFAULT
}
export function platformGradientTextClass(p: string): string {
return isPlatform(p) ? GRADIENT_TEXT[p] : GRADIENT_TEXT_DEFAULT
}
export function platformGradientSubtextClass(p: string): string {
return isPlatform(p) ? GRADIENT_SUBTEXT[p] : GRADIENT_SUBTEXT_DEFAULT
}
export function platformLabel(p: string): string {
switch (p) {
case 'anthropic': return 'Anthropic'
+45 -8
View File
@@ -83,7 +83,7 @@
</div>
</div>
</div>
<button class="btn btn-primary w-full py-3 text-base font-medium" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
<span v-if="submitting" class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
@@ -100,13 +100,21 @@
<!-- Subscription confirm (inline, replaces plan list) -->
<template v-if="selectedPlan">
<div class="card overflow-hidden">
<div class="bg-gradient-to-br from-primary-500 to-primary-600 px-6 py-5 text-center">
<p class="text-sm font-medium text-primary-100">{{ selectedPlan.name }}</p>
<div :class="['bg-gradient-to-br px-6 py-5 text-center', planGradientClass]">
<p :class="['text-sm font-medium', planGradientTextClass]">{{ selectedPlan.name }}</p>
<div class="mt-1 flex items-baseline justify-center gap-1.5">
<span class="text-3xl font-bold text-white">${{ selectedPlan.price }}</span>
<span v-if="selectedPlan.original_price" class="text-sm text-primary-200 line-through">${{ selectedPlan.original_price }}</span>
<span v-if="selectedPlan.original_price" :class="['text-sm line-through', planGradientSubtextClass]">${{ selectedPlan.original_price }}</span>
</div>
<p v-if="selectedPlan.description" :class="['mt-2 text-sm', planGradientTextClass]">{{ selectedPlan.description }}</p>
<div class="mt-3 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-white/80">
<span>{{ t('payment.planCard.rate') }}: ×{{ selectedPlan.rate_multiplier ?? 1 }}</span>
<span v-if="selectedPlan.daily_limit_usd != null">{{ t('payment.planCard.dailyLimit') }}: ${{ selectedPlan.daily_limit_usd }}</span>
<span v-if="selectedPlan.weekly_limit_usd != null">{{ t('payment.planCard.weeklyLimit') }}: ${{ selectedPlan.weekly_limit_usd }}</span>
<span v-if="selectedPlan.monthly_limit_usd != null">{{ t('payment.planCard.monthlyLimit') }}: ${{ selectedPlan.monthly_limit_usd }}</span>
<span v-if="selectedPlan.daily_limit_usd == null && selectedPlan.weekly_limit_usd == null && selectedPlan.monthly_limit_usd == null">{{ t('payment.planCard.quota') }}: {{ t('payment.planCard.unlimited') }}</span>
<span>{{ planValiditySuffix }}</span>
</div>
<p v-if="selectedPlan.description" class="mt-2 text-sm text-primary-100">{{ selectedPlan.description }}</p>
</div>
</div>
<div v-if="enabledMethods.length >= 1" class="card p-6">
@@ -132,7 +140,7 @@
</div>
</div>
</div>
<button class="btn btn-primary w-full py-3 text-base font-medium" :disabled="!canSubmitSubscription || submitting" @click="confirmSubscribe">
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmitSubscription || submitting" @click="confirmSubscribe">
<span v-if="submitting" class="flex items-center justify-center gap-2">
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }}
@@ -151,7 +159,7 @@
<p class="text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</p>
</div>
<div v-else :class="planGridClass">
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" @select="selectPlan" />
<SubscriptionPlanCard v-for="plan in checkout.plans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlan" />
</div>
<!-- Active subscriptions (compact, below plan list) -->
<div v-if="activeSubscriptions.length > 0">
@@ -203,6 +211,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { usePaymentStore } from '@/stores/payment'
import { useSubscriptionStore } from '@/stores/subscriptions'
@@ -214,7 +223,7 @@ import AppLayout from '@/components/layout/AppLayout.vue'
import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
import { METHOD_ORDER, POPUP_WINDOW_FEATURES } from '@/components/payment/providerConfig'
import { platformAccentBarClass, platformBadgeLightClass, platformLabel } from '@/utils/platformColors'
import { platformAccentBarClass, platformBadgeLightClass, platformLabel, platformGradientClass, platformGradientTextClass, platformGradientSubtextClass } from '@/utils/platformColors'
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
@@ -222,6 +231,7 @@ import Icon from '@/components/icons/Icon.vue'
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
const { t } = useI18n()
const route = useRoute()
const authStore = useAuthStore()
const paymentStore = usePaymentStore()
const subscriptionStore = useSubscriptionStore()
@@ -405,6 +415,29 @@ watch(() => [validAmount.value, selectedMethod.value] as const, ([amt, method])
if (available) selectedMethod.value = available
})
// Payment button class: follows selected payment method color
const paymentButtonClass = computed(() => {
const m = selectedMethod.value
if (!m) return 'btn-primary'
if (m.includes('alipay')) return 'btn-alipay'
if (m.includes('wxpay')) return 'btn-wxpay'
if (m === 'stripe') return 'btn-stripe'
return 'btn-primary'
})
// Subscription confirm header: platform gradient
const planGradientClass = computed(() => platformGradientClass(selectedPlan.value?.group_platform || ''))
const planGradientTextClass = computed(() => platformGradientTextClass(selectedPlan.value?.group_platform || ''))
const planGradientSubtextClass = computed(() => platformGradientSubtextClass(selectedPlan.value?.group_platform || ''))
const planValiditySuffix = computed(() => {
if (!selectedPlan.value) return ''
const u = selectedPlan.value.validity_unit || 'day'
if (u === 'month') return t('payment.perMonth')
if (u === 'year') return t('payment.perYear')
return `${selectedPlan.value.validity_days}${t('payment.days')}`
})
function selectPlan(plan: SubscriptionPlan) {
selectedPlan.value = plan
errorMessage.value = ''
@@ -497,6 +530,10 @@ onMounted(async () => {
if (checkout.value.balance_disabled) {
activeTab.value = 'subscription'
}
// Handle renewal navigation: ?tab=subscription&group=123
if (route.query.tab === 'subscription') {
activeTab.value = 'subscription'
}
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { loading.value = false }
// Fetch active subscriptions (uses cache, non-blocking)
@@ -26,8 +26,13 @@
<div class="card p-6">
<div class="flex flex-col items-center space-y-4">
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.qr.scanWxpay') }}</p>
<div class="rounded-2xl bg-white p-4 shadow-sm">
<img :src="wechatQrUrl" alt="WeChat Pay QR" class="h-56 w-56" />
<div class="relative rounded-lg border-2 border-[#2BB741] bg-green-50 p-4 dark:border-[#2BB741]/70 dark:bg-green-950/20">
<img :src="wechatQrUrl" alt="WeChat Pay QR" class="h-56 w-56 rounded" />
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<span class="rounded-full bg-[#2BB741] p-2 shadow ring-2 ring-white">
<svg class="h-5 w-5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.636 4.35c-2.084 0-3.993.672-5.363 1.844-1.188.982-2.004 2.308-2.004 3.862 0 1.207.546 2.355 1.483 3.285.114.113.238.213.358.321l-.105.42c-.021.084-.042.17-.042.253 0 .168.126.258.282.258.065 0 .126-.025.18-.058l1.27-.765a.69.69 0 0 1 .58-.086c.96.282 1.99.437 3.043.437 2.633 0 5.03-.972 6.4-2.5.782-.87 1.258-1.901 1.258-3.006 0-3.328-3.325-6.006-7.34-6.006zm-3.21 3.09c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957zm4.739 0c.52 0 .94.429.94.957a.949.949 0 0 1-.94.955.949.949 0 0 1-.94-.955c0-.528.42-.957.94-.957z"/></svg>
</span>
</div>
</div>
<p class="text-center text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.scanWxpayHint') }}</p>
</div>
@@ -41,7 +46,7 @@
<template v-else-if="redirecting">
<div class="card p-6">
<div class="flex flex-col items-center space-y-4 py-4">
<div class="h-10 w-10 animate-spin rounded-full border-4 border-primary-500 border-t-transparent"></div>
<div class="h-10 w-10 animate-spin rounded-full border-4 border-[#00AEEF] border-t-transparent"></div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('payment.qr.payInNewWindowHint') }}</p>
</div>
</div>
+23 -12
View File
@@ -49,18 +49,27 @@
</p>
</div>
</div>
<span
:class="[
'badge',
subscription.status === 'active'
? 'badge-success'
: subscription.status === 'expired'
? 'badge-warning'
: 'badge-danger'
]"
>
{{ t(`userSubscriptions.status.${subscription.status}`) }}
</span>
<div class="flex items-center gap-2">
<span
:class="[
'badge',
subscription.status === 'active'
? 'badge-success'
: subscription.status === 'expired'
? 'badge-warning'
: 'badge-danger'
]"
>
{{ t(`userSubscriptions.status.${subscription.status}`) }}
</span>
<button
v-if="subscription.status === 'active'"
class="btn btn-sm btn-primary"
@click="router.push({ path: '/purchase', query: { tab: 'subscription', group: String(subscription.group_id) } })"
>
{{ t('payment.renewNow') }}
</button>
</div>
</div>
<!-- Usage Progress -->
@@ -237,6 +246,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import subscriptionsAPI from '@/api/subscriptions'
import type { UserSubscription } from '@/types'
@@ -245,6 +255,7 @@ import Icon from '@/components/icons/Icon.vue'
import { formatDateOnly } from '@/utils/format'
const { t } = useI18n()
const router = useRouter()
const appStore = useAppStore()
const subscriptions = ref<UserSubscription[]>([])