feat(payment): renewal modal, clean confirm card, platform color refresh

- Replace gradient header in subscription confirm with clean card layout
  matching sub2apipay design (platform accent text instead of full gradient)
- Add renewal plan selection modal: when group has multiple plans show
  picker, single plan skips directly to payment method selection
- Restyle SubscriptionsView with platform-specific colors (badge, border,
  button) instead of hardcoded purple
- Update platformColors to match sub2apipay style (transparent badges,
  subtle borders with /20 opacity)
This commit is contained in:
erio
2026-04-10 16:12:28 +08:00
parent 9df96a45c7
commit 0113bf60fd
6 changed files with 144 additions and 55 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.108.149
0.1.108.150
+1
View File
@@ -5173,6 +5173,7 @@ export default {
},
subscribeNow: 'Subscribe Now',
renewNow: 'Renew',
selectPlan: 'Select Plan',
planFeatures: 'Features',
planCard: {
rate: 'Rate',
+1
View File
@@ -5370,6 +5370,7 @@ export default {
},
subscribeNow: '立即开通',
renewNow: '续费',
selectPlan: '选择套餐',
planFeatures: '功能特性',
planCard: {
rate: '倍率',
+19 -19
View File
@@ -7,29 +7,29 @@
export type Platform = 'anthropic' | 'openai' | 'antigravity' | 'gemini'
// ── Badge (bg + text) ───────────────────────────────────────────────
// ── Badge (bg + text + border, for inline badges with border) ───────
const BADGE: Record<Platform, string> = {
anthropic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
openai: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
antigravity: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
gemini: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
anthropic: 'bg-orange-500/10 text-orange-600 border-orange-500/30 dark:text-orange-400',
openai: 'bg-green-500/10 text-green-600 border-green-500/30 dark:text-green-400',
antigravity: 'bg-purple-500/10 text-purple-600 border-purple-500/30 dark:text-purple-400',
gemini: 'bg-blue-500/10 text-blue-600 border-blue-500/30 dark:text-blue-400',
}
const BADGE_DEFAULT = 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
const BADGE_DEFAULT = 'bg-slate-500/10 text-slate-600 border-slate-500/30 dark:text-slate-400'
// ── Light badge (softer bg) ─────────────────────────────────────────
// ── Light badge (softer bg, no border) ──────────────────────────────
const BADGE_LIGHT: Record<Platform, string> = {
anthropic: 'bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-300',
openai: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300',
antigravity: 'bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-300',
gemini: 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300',
anthropic: 'bg-orange-500/10 text-orange-600 dark:bg-orange-500/10 dark:text-orange-300',
openai: 'bg-green-500/10 text-green-600 dark:bg-green-500/10 dark:text-green-300',
antigravity: 'bg-purple-500/10 text-purple-600 dark:bg-purple-500/10 dark:text-purple-300',
gemini: 'bg-blue-500/10 text-blue-600 dark:bg-blue-500/10 dark:text-blue-300',
}
// ── Border ──────────────────────────────────────────────────────────
const BORDER: Record<Platform, string> = {
anthropic: 'border-orange-100 dark:border-orange-900/30',
openai: 'border-emerald-100 dark:border-emerald-900/30',
antigravity: 'border-purple-100 dark:border-purple-900/30',
gemini: 'border-blue-100 dark:border-blue-900/30',
anthropic: 'border-orange-500/20 dark:border-orange-500/20',
openai: 'border-green-500/20 dark:border-green-500/20',
antigravity: 'border-purple-500/20 dark:border-purple-500/20',
gemini: 'border-blue-500/20 dark:border-blue-500/20',
}
const BORDER_DEFAULT = 'border-gray-200 dark:border-dark-700'
@@ -62,10 +62,10 @@ const ICON_DEFAULT = 'text-primary-500 dark:text-primary-400'
// ── Button (solid bg) ───────────────────────────────────────────────
const BUTTON: Record<Platform, string> = {
anthropic: 'bg-orange-500 text-white hover:bg-orange-600 dark:bg-orange-600 dark:hover:bg-orange-500',
openai: 'bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-500',
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-500',
gemini: 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-500',
anthropic: 'bg-orange-500 text-white hover:bg-orange-600 active:bg-orange-700 dark:bg-orange-500/80 dark:hover:bg-orange-500',
openai: 'bg-green-600 text-white hover:bg-green-700 active:bg-green-800 dark:bg-green-600/80 dark:hover:bg-green-600',
antigravity: 'bg-purple-500 text-white hover:bg-purple-600 active:bg-purple-700 dark:bg-purple-500/80 dark:hover:bg-purple-500',
gemini: 'bg-blue-500 text-white hover:bg-blue-600 active:bg-blue-700 dark:bg-blue-500/80 dark:hover:bg-blue-500',
}
const BUTTON_DEFAULT = 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
+93 -19
View File
@@ -99,21 +99,49 @@
<template v-else-if="activeTab === 'subscription'">
<!-- Subscription confirm (inline, replaces plan list) -->
<template v-if="selectedPlan">
<div class="card overflow-hidden">
<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 line-through', planGradientSubtextClass]">${{ selectedPlan.original_price }}</span>
<div class="card p-5">
<!-- Header: platform badge + plan name -->
<div class="mb-3 flex flex-wrap items-center gap-2">
<span :class="['rounded-md border px-2 py-0.5 text-xs font-medium', planBadgeClass]">
{{ platformLabel(selectedPlan.group_platform || '') }}
</span>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ selectedPlan.name }}</h3>
</div>
<!-- Price -->
<div class="flex items-baseline gap-2">
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through dark:text-gray-500">
${{ selectedPlan.original_price }}
</span>
<span :class="['text-3xl font-bold', planTextClass]">${{ selectedPlan.price }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">/ {{ planValiditySuffix }}</span>
</div>
<!-- Description -->
<p v-if="selectedPlan.description" class="mt-2 text-sm leading-relaxed text-gray-500 dark:text-gray-400">
{{ selectedPlan.description }}
</p>
<!-- Rate + Limits grid -->
<div class="mt-3 grid grid-cols-2 gap-3">
<div>
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.rate') }}</span>
<div class="flex items-baseline">
<span :class="['text-lg font-bold', planTextClass]">×{{ selectedPlan.rate_multiplier ?? 1 }}</span>
</div>
</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 v-if="selectedPlan.daily_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.dailyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.daily_limit_usd }}</div>
</div>
<div v-if="selectedPlan.weekly_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.weeklyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.weekly_limit_usd }}</div>
</div>
<div v-if="selectedPlan.monthly_limit_usd != null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.monthlyLimit') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">${{ selectedPlan.monthly_limit_usd }}</div>
</div>
<div v-if="selectedPlan.daily_limit_usd == null && selectedPlan.weekly_limit_usd == null && selectedPlan.monthly_limit_usd == null">
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('payment.planCard.quota') }}</span>
<div class="text-lg font-semibold text-gray-800 dark:text-gray-200">{{ t('payment.planCard.unlimited') }}</div>
</div>
</div>
</div>
@@ -197,6 +225,23 @@
</div>
</template>
</div>
<!-- Renewal Plan Selection Modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showRenewalModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" @click.self="closeRenewalModal">
<div class="relative w-full max-w-lg rounded-2xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-dark-700 dark:bg-dark-900">
<!-- Close button -->
<button class="absolute right-4 top-4 rounded-lg p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700 dark:hover:text-gray-200" @click="closeRenewalModal">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">{{ t('payment.selectPlan') }}</h3>
<div class="space-y-4">
<SubscriptionPlanCard v-for="plan in renewalPlans" :key="plan.id" :plan="plan" :active-subscriptions="activeSubscriptions" @select="selectPlanFromModal" />
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Image Preview Overlay -->
<Teleport to="body">
<Transition name="modal">
@@ -223,7 +268,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, platformGradientClass, platformGradientTextClass, platformGradientSubtextClass } from '@/utils/platformColors'
import { platformAccentBarClass, platformBadgeLightClass, platformBadgeClass, platformTextClass, platformLabel } from '@/utils/platformColors'
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
import PaymentStatusPanel from '@/components/payment/PaymentStatusPanel.vue'
import StripePaymentInline from '@/components/payment/StripePaymentInline.vue'
@@ -425,10 +470,17 @@ const paymentButtonClass = computed(() => {
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 || ''))
// Subscription confirm: platform accent colors (clean card, no gradient)
const planBadgeClass = computed(() => platformBadgeClass(selectedPlan.value?.group_platform || ''))
const planTextClass = computed(() => platformTextClass(selectedPlan.value?.group_platform || ''))
// Renewal modal state
const showRenewalModal = ref(false)
const renewGroupId = ref<number | null>(null)
const renewalPlans = computed(() => {
if (renewGroupId.value == null) return []
return checkout.value.plans.filter(p => p.group_id === renewGroupId.value)
})
const planValiditySuffix = computed(() => {
if (!selectedPlan.value) return ''
@@ -443,6 +495,18 @@ function selectPlan(plan: SubscriptionPlan) {
errorMessage.value = ''
}
function selectPlanFromModal(plan: SubscriptionPlan) {
showRenewalModal.value = false
renewGroupId.value = null
selectedPlan.value = plan
errorMessage.value = ''
}
function closeRenewalModal() {
showRenewalModal.value = false
renewGroupId.value = null
}
async function handleSubmitRecharge() {
if (!canSubmit.value || submitting.value) return
await createOrder(validAmount.value, 'balance')
@@ -533,6 +597,16 @@ onMounted(async () => {
// Handle renewal navigation: ?tab=subscription&group=123
if (route.query.tab === 'subscription') {
activeTab.value = 'subscription'
if (route.query.group) {
const groupId = Number(route.query.group)
const groupPlans = checkout.value.plans.filter(p => p.group_id === groupId)
if (groupPlans.length === 1) {
selectedPlan.value = groupPlans[0]
} else if (groupPlans.length > 1) {
renewGroupId.value = groupId
showRenewalModal.value = true
}
}
}
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) }
finally { loading.value = false }
+29 -16
View File
@@ -28,43 +28,45 @@
<div
v-for="subscription in subscriptions"
:key="subscription.id"
class="card overflow-hidden"
class="overflow-hidden rounded-2xl border bg-white dark:bg-dark-800"
:class="platformBorderClass(subscription.group?.platform || '')"
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-gray-100 p-4 dark:border-dark-700"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 dark:bg-purple-900/30"
>
<Icon name="creditCard" size="md" class="text-purple-600 dark:text-purple-400" />
</div>
<div :class="['h-1.5 w-1.5 shrink-0 rounded-full', platformAccentDotClass(subscription.group?.platform || '')]" />
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
</h3>
<p class="text-xs text-gray-500 dark:text-dark-400">
{{ subscription.group?.description || '' }}
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ subscription.group?.name || `Group #${subscription.group_id}` }}
</h3>
<span :class="['rounded-md border px-2 py-0.5 text-[11px] font-medium', platformBadgeClass(subscription.group?.platform || '')]">
{{ platformLabel(subscription.group?.platform || '') }}
</span>
</div>
<p v-if="subscription.group?.description" class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ subscription.group.description }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
:class="[
'badge',
'rounded-full px-2 py-0.5 text-xs font-medium',
subscription.status === 'active'
? 'badge-success'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
: subscription.status === 'expired'
? 'badge-warning'
: 'badge-danger'
? 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
]"
>
{{ t(`userSubscriptions.status.${subscription.status}`) }}
</span>
<button
v-if="subscription.status === 'active'"
class="btn btn-sm btn-primary"
:class="['rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-colors', platformButtonClass(subscription.group?.platform || '')]"
@click="router.push({ path: '/purchase', query: { tab: 'subscription', group: String(subscription.group_id) } })"
>
{{ t('payment.renewNow') }}
@@ -253,6 +255,17 @@ import type { UserSubscription } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import { formatDateOnly } from '@/utils/format'
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
function platformAccentDotClass(p: string): string {
switch (p) {
case 'anthropic': return 'bg-orange-500'
case 'openai': return 'bg-emerald-500'
case 'antigravity': return 'bg-purple-500'
case 'gemini': return 'bg-blue-500'
default: return 'bg-gray-400'
}
}
const { t } = useI18n()
const router = useRouter()