mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -1 +1 @@
|
||||
0.1.108.149
|
||||
0.1.108.150
|
||||
|
||||
@@ -5173,6 +5173,7 @@ export default {
|
||||
},
|
||||
subscribeNow: 'Subscribe Now',
|
||||
renewNow: 'Renew',
|
||||
selectPlan: 'Select Plan',
|
||||
planFeatures: 'Features',
|
||||
planCard: {
|
||||
rate: 'Rate',
|
||||
|
||||
@@ -5370,6 +5370,7 @@ export default {
|
||||
},
|
||||
subscribeNow: '立即开通',
|
||||
renewNow: '续费',
|
||||
selectPlan: '选择套餐',
|
||||
planFeatures: '功能特性',
|
||||
planCard: {
|
||||
rate: '倍率',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user