From f0aea13ded158ddade531f7f87b0e8500fd15c96 Mon Sep 17 00:00:00 2001 From: erio Date: Wed, 8 Apr 2026 13:07:06 +0800 Subject: [PATCH] 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 --- backend/internal/handler/payment_handler.go | 21 +- .../payment/SubscriptionPlanCard.vue | 191 ++++++++++-------- frontend/src/views/user/PaymentView.vue | 27 ++- 3 files changed, 146 insertions(+), 93 deletions(-) diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 151750c144..b2e0d7114a 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -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) { diff --git a/frontend/src/components/payment/SubscriptionPlanCard.vue b/frontend/src/components/payment/SubscriptionPlanCard.vue index de1399470b..0a92f07ecd 100644 --- a/frontend/src/components/payment/SubscriptionPlanCard.vue +++ b/frontend/src/components/payment/SubscriptionPlanCard.vue @@ -1,81 +1,76 @@ @@ -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' } }) diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 441e4fb09c..50555c4d39 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -72,8 +72,11 @@ @@ -85,10 +88,13 @@
-
-

{{ selectedPlan.name }}

-

¥{{ selectedPlan.price }}

-

{{ selectedPlan.description }}

+
+

{{ selectedPlan.name }}

+
+ ¥{{ selectedPlan.price }} + ¥{{ selectedPlan.original_price }} +
+

{{ selectedPlan.description }}

{ 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