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:
erio
2026-04-08 13:07:06 +08:00
parent ffe3b07d0d
commit f0aea13ded
3 changed files with 146 additions and 93 deletions
+19 -2
View File
@@ -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"
>
&yen;{{ plan.original_price }}
</span>
<span :class="['text-3xl font-bold', priceClass]">
&yen;{{ 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">&yen;</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">
&yen;{{ 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'
}
})
+21 -6
View File
@@ -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">&yen;{{ selectedPlan.price }}</span>
<span v-if="selectedPlan.original_price" class="text-sm text-gray-400 line-through">&yen;{{ 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