mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
feat(payment): redesign subscription plan cards and fix features parsing
- Fix features field sent as raw string causing character-by-character iteration - Redesign SubscriptionPlanCard with gradient accent bar, checkmark feature list, discount badge, and polished hover/button effects - Adaptive plan grid layout (1/2/3 columns based on plan count) - Improve subscription confirm dialog with better price display
This commit is contained in:
@@ -115,7 +115,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
|
||||
planList = append(planList, checkoutPlan{
|
||||
ID: int64(p.ID), GroupID: p.GroupID, GroupPlatform: platformMap[p.GroupID],
|
||||
Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
|
||||
ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: p.Features,
|
||||
ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: parseFeatures(p.Features),
|
||||
ProductName: p.ProductName,
|
||||
})
|
||||
}
|
||||
@@ -151,10 +151,27 @@ type checkoutPlan struct {
|
||||
OriginalPrice *float64 `json:"original_price,omitempty"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
ValidityUnit string `json:"validity_unit"`
|
||||
Features string `json:"features"`
|
||||
Features []string `json:"features"`
|
||||
ProductName string `json:"product_name"`
|
||||
}
|
||||
|
||||
// parseFeatures splits a newline-separated features string into a string slice.
|
||||
func parseFeatures(raw string) []string {
|
||||
if raw == "" {
|
||||
return []string{}
|
||||
}
|
||||
var out []string
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
if s := strings.TrimSpace(line); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
return []string{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetLimits returns per-payment-type limits derived from enabled provider instances.
|
||||
// GET /api/v1/payment/limits
|
||||
func (h *PaymentHandler) GetLimits(c *gin.Context) {
|
||||
|
||||
@@ -1,81 +1,76 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-col rounded-2xl border p-6 transition-shadow hover:shadow-lg',
|
||||
'group relative flex flex-col overflow-hidden rounded-2xl border transition-all',
|
||||
'hover:shadow-xl hover:-translate-y-0.5',
|
||||
cardBorderClass,
|
||||
'bg-white dark:bg-dark-800/70',
|
||||
'bg-white dark:bg-dark-800',
|
||||
]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3>
|
||||
<span
|
||||
:class="[
|
||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
badgeClass,
|
||||
]"
|
||||
>
|
||||
{{ validityLabel }}
|
||||
</span>
|
||||
<!-- Colored top accent bar -->
|
||||
<div :class="['h-1.5', accentBarClass]" />
|
||||
|
||||
<div class="flex flex-1 flex-col p-5">
|
||||
<!-- Header: name + validity badge -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3>
|
||||
<span :class="['rounded-full px-2 py-0.5 text-[11px] font-medium', badgeClass]">
|
||||
{{ validityLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="plan.description" class="mt-1.5 text-sm leading-relaxed text-gray-500 dark:text-dark-400">
|
||||
{{ plan.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span
|
||||
v-if="plan.original_price"
|
||||
class="text-sm line-through text-gray-400 dark:text-dark-500"
|
||||
>
|
||||
¥{{ plan.original_price }}
|
||||
</span>
|
||||
<span :class="['text-3xl font-bold', priceClass]">
|
||||
¥{{ plan.price }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 dark:text-dark-400">
|
||||
/ {{ validitySuffix }}
|
||||
</span>
|
||||
<!-- Price section -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-sm text-gray-400 dark:text-dark-500">¥</span>
|
||||
<span :class="['text-4xl font-extrabold tracking-tight', priceClass]">{{ plan.price }}</span>
|
||||
<span class="text-sm text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span>
|
||||
</div>
|
||||
<div v-if="plan.original_price" class="mt-1 flex items-center gap-2">
|
||||
<span class="text-sm text-gray-400 line-through dark:text-dark-500">
|
||||
¥{{ plan.original_price }}
|
||||
</span>
|
||||
<span :class="['rounded px-1.5 py-0.5 text-[11px] font-semibold', discountBadgeClass]">
|
||||
{{ discountText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="plan.description"
|
||||
class="mb-4 text-sm leading-relaxed text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ plan.description }}
|
||||
</p>
|
||||
|
||||
<!-- Features -->
|
||||
<div v-if="plan.features.length > 0" class="mb-5">
|
||||
<p class="mb-2 text-xs text-gray-400 dark:text-dark-500">
|
||||
{{ t('payment.planFeatures') }}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
<!-- Features list with checkmarks -->
|
||||
<div v-if="plan.features.length > 0" class="mb-4 space-y-2">
|
||||
<div
|
||||
v-for="feature in plan.features"
|
||||
:key="feature"
|
||||
:class="[
|
||||
'rounded-md px-2 py-1 text-xs',
|
||||
featureClass,
|
||||
]"
|
||||
class="flex items-start gap-2"
|
||||
>
|
||||
{{ feature }}
|
||||
</span>
|
||||
<svg :class="['mt-0.5 h-4 w-4 flex-shrink-0', checkIconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Spacer pushes button to bottom -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Subscribe Button -->
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'mt-3 w-full rounded-xl py-2.5 text-sm font-semibold transition-all',
|
||||
'active:scale-[0.98]',
|
||||
buttonClass,
|
||||
]"
|
||||
@click="emit('select', plan)"
|
||||
>
|
||||
{{ t('payment.subscribeNow') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Subscribe Button -->
|
||||
<button
|
||||
type="button"
|
||||
:class="['mt-2 w-full rounded-xl py-3 text-sm font-semibold text-white transition-colors', buttonClass]"
|
||||
@click="emit('select', plan)"
|
||||
>
|
||||
<Icon name="bolt" size="sm" class="mr-1.5" />
|
||||
{{ t('payment.subscribeNow') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -83,7 +78,6 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { SubscriptionPlan } from '@/types/payment'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
plan: SubscriptionPlan
|
||||
@@ -97,24 +91,41 @@ const { t } = useI18n()
|
||||
|
||||
const platform = computed(() => props.plan.group_platform || '')
|
||||
|
||||
// Color scheme per platform
|
||||
// Discount percentage
|
||||
const discountText = computed(() => {
|
||||
if (!props.plan.original_price || props.plan.original_price <= 0) return ''
|
||||
const pct = Math.round((1 - props.plan.price / props.plan.original_price) * 100)
|
||||
return pct > 0 ? `-${pct}%` : ''
|
||||
})
|
||||
|
||||
// Color schemes per platform
|
||||
const accentBarClass = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'anthropic': return 'bg-gradient-to-r from-amber-400 to-amber-500'
|
||||
case 'openai': return 'bg-gradient-to-r from-emerald-400 to-emerald-500'
|
||||
case 'antigravity': return 'bg-gradient-to-r from-purple-400 to-purple-500'
|
||||
case 'gemini': return 'bg-gradient-to-r from-blue-400 to-blue-500'
|
||||
default: return 'bg-gradient-to-r from-primary-400 to-primary-500'
|
||||
}
|
||||
})
|
||||
|
||||
const cardBorderClass = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'anthropic': return 'border-amber-200 dark:border-amber-800/40'
|
||||
case 'openai': return 'border-emerald-200 dark:border-emerald-800/40'
|
||||
case 'antigravity': return 'border-purple-200 dark:border-purple-800/40'
|
||||
case 'gemini': return 'border-blue-200 dark:border-blue-800/40'
|
||||
case 'anthropic': return 'border-amber-100 dark:border-amber-900/30'
|
||||
case 'openai': return 'border-emerald-100 dark:border-emerald-900/30'
|
||||
case 'antigravity': return 'border-purple-100 dark:border-purple-900/30'
|
||||
case 'gemini': return 'border-blue-100 dark:border-blue-900/30'
|
||||
default: return 'border-gray-200 dark:border-dark-700'
|
||||
}
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'anthropic': return 'bg-amber-50 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
case 'openai': return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
case 'antigravity': return 'bg-purple-50 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
case 'gemini': return 'bg-blue-50 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
default: return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
case 'anthropic': return 'bg-amber-50 text-amber-600 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
case 'openai': return 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
case 'antigravity': return 'bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
case 'gemini': return 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
default: return 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -128,23 +139,33 @@ const priceClass = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const featureClass = computed(() => {
|
||||
const discountBadgeClass = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'anthropic': return 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
||||
case 'openai': return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
case 'antigravity': return 'bg-purple-50 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400'
|
||||
case 'gemini': return 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400'
|
||||
default: return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||
case 'anthropic': return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
||||
case 'openai': return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
case 'antigravity': return 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
case 'gemini': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
default: return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
}
|
||||
})
|
||||
|
||||
const checkIconClass = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'anthropic': return 'text-amber-500 dark:text-amber-400'
|
||||
case 'openai': return 'text-emerald-500 dark:text-emerald-400'
|
||||
case 'antigravity': return 'text-purple-500 dark:text-purple-400'
|
||||
case 'gemini': return 'text-blue-500 dark:text-blue-400'
|
||||
default: return 'text-primary-500 dark:text-primary-400'
|
||||
}
|
||||
})
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
switch (platform.value) {
|
||||
case 'anthropic': return 'bg-amber-500 hover:bg-amber-600'
|
||||
case 'openai': return 'bg-emerald-500 hover:bg-emerald-600'
|
||||
case 'antigravity': return 'bg-purple-500 hover:bg-purple-600'
|
||||
case 'gemini': return 'bg-blue-500 hover:bg-blue-600'
|
||||
default: return 'bg-primary-500 hover:bg-primary-600'
|
||||
case 'anthropic': return 'bg-amber-500 text-white hover:bg-amber-600 dark:bg-amber-600 dark:hover:bg-amber-500'
|
||||
case 'openai': return 'bg-emerald-500 text-white hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-500'
|
||||
case 'antigravity': return 'bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-500'
|
||||
case 'gemini': return 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-500'
|
||||
default: return 'bg-primary-500 text-white hover:bg-primary-600 dark:bg-primary-600 dark:hover:bg-primary-500'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -72,8 +72,11 @@
|
||||
</template>
|
||||
<!-- Subscribe Tab -->
|
||||
<template v-else-if="activeTab === 'subscription'">
|
||||
<div v-if="checkout.plans.length === 0" class="py-16 text-center text-gray-500 dark:text-gray-400">{{ t('payment.noPlans') }}</div>
|
||||
<div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div v-if="checkout.plans.length === 0" class="card py-16 text-center">
|
||||
<Icon name="gift" size="xl" class="mx-auto mb-3 text-gray-300 dark:text-dark-600" />
|
||||
<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="openSubscribeDialog" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -85,10 +88,13 @@
|
||||
<!-- Subscription Confirm Dialog -->
|
||||
<BaseDialog :show="!!selectedPlan" :title="t('payment.confirmSubscription')" @close="selectedPlan = null">
|
||||
<div v-if="selectedPlan" class="space-y-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedPlan.name }}</p>
|
||||
<p class="mt-1 text-2xl font-bold text-primary-600 dark:text-primary-400">¥{{ selectedPlan.price }}</p>
|
||||
<p v-if="selectedPlan.description" class="mt-1 text-xs text-gray-500 dark:text-dark-400">{{ selectedPlan.description }}</p>
|
||||
<div class="rounded-xl border border-gray-100 bg-gray-50 p-5 dark:border-dark-700 dark:bg-dark-800">
|
||||
<p class="font-semibold text-gray-900 dark:text-white">{{ selectedPlan.name }}</p>
|
||||
<div class="mt-2 flex items-baseline gap-1.5">
|
||||
<span class="text-3xl font-extrabold text-primary-600 dark:text-primary-400">¥{{ selectedPlan.price }}</span>
|
||||
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through">¥{{ selectedPlan.original_price }}</span>
|
||||
</div>
|
||||
<p v-if="selectedPlan.description" class="mt-2 text-sm text-gray-500 dark:text-dark-400">{{ selectedPlan.description }}</p>
|
||||
</div>
|
||||
<PaymentMethodSelector
|
||||
v-if="enabledMethods.length > 1"
|
||||
@@ -125,6 +131,7 @@ import AmountInput from '@/components/payment/AmountInput.vue'
|
||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||
import { METHOD_ORDER } from '@/components/payment/providerConfig'
|
||||
import SubscriptionPlanCard from '@/components/payment/SubscriptionPlanCard.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { PaymentMethodOption } from '@/components/payment/PaymentMethodSelector.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -159,6 +166,14 @@ const tabs = computed(() => {
|
||||
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
|
||||
const validAmount = computed(() => amount.value ?? 0)
|
||||
|
||||
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
|
||||
const planGridClass = computed(() => {
|
||||
const n = checkout.value.plans.length
|
||||
if (n === 1) return 'mx-auto grid max-w-sm grid-cols-1 gap-5'
|
||||
if (n === 2) return 'mx-auto grid max-w-2xl grid-cols-1 gap-5 sm:grid-cols-2'
|
||||
return 'grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3'
|
||||
})
|
||||
|
||||
// Check if an amount fits a method's [min, max]. 0 = no limit.
|
||||
function amountFitsMethod(amt: number, methodType: string): boolean {
|
||||
if (amt <= 0) return true
|
||||
|
||||
Reference in New Issue
Block a user