| <template> |
| <div |
| :class="[ |
| 'group relative flex flex-col overflow-hidden rounded-2xl border transition-all', |
| 'hover:shadow-xl hover:-translate-y-0.5', |
| borderClass, |
| 'bg-white dark:bg-dark-800', |
| ]" |
| > |
| |
| <div :class="['h-1.5', accentClass]" /> |
| |
| <div class="flex flex-1 flex-col p-4"> |
| |
| <div class="mb-3 flex items-start justify-between gap-2"> |
| <div class="min-w-0 flex-1"> |
| <div class="flex items-center gap-2"> |
| <h3 class="truncate text-base font-bold text-gray-900 dark:text-white">{{ plan.name }}</h3> |
| <span :class="['shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium', badgeLightClass]"> |
| {{ pLabel }} |
| </span> |
| </div> |
| <p v-if="plan.description" class="mt-0.5 text-xs leading-relaxed text-gray-500 dark:text-dark-400 line-clamp-2"> |
| {{ plan.description }} |
| </p> |
| </div> |
| <div class="shrink-0 text-right"> |
| <div class="flex items-baseline gap-1"> |
| <span class="text-xs text-gray-400 dark:text-dark-500">$</span> |
| <span :class="['text-2xl font-extrabold tracking-tight', textClass]">{{ plan.price }}</span> |
| </div> |
| <span class="text-[11px] text-gray-400 dark:text-dark-500">/ {{ validitySuffix }}</span> |
| <div v-if="plan.original_price" class="mt-0.5 flex items-center justify-end gap-1.5"> |
| <span class="text-xs text-gray-400 line-through dark:text-dark-500">${{ plan.original_price }}</span> |
| <span :class="['rounded px-1 py-0.5 text-[10px] font-semibold', discountClass]">{{ discountText }}</span> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="mb-3 grid grid-cols-2 gap-x-3 gap-y-1 rounded-lg bg-gray-50 px-3 py-2 text-xs dark:bg-dark-700/50"> |
| <div class="flex items-center justify-between"> |
| <span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.rate') }}</span> |
| <span class="font-medium text-gray-700 dark:text-gray-300">{{ rateDisplay }}</span> |
| </div> |
| <div v-if="plan.daily_limit_usd != null" class="flex items-center justify-between"> |
| <span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.dailyLimit') }}</span> |
| <span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.daily_limit_usd }}</span> |
| </div> |
| <div v-if="plan.weekly_limit_usd != null" class="flex items-center justify-between"> |
| <span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.weeklyLimit') }}</span> |
| <span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.weekly_limit_usd }}</span> |
| </div> |
| <div v-if="plan.monthly_limit_usd != null" class="flex items-center justify-between"> |
| <span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.monthlyLimit') }}</span> |
| <span class="font-medium text-gray-700 dark:text-gray-300">${{ plan.monthly_limit_usd }}</span> |
| </div> |
| <div v-if="plan.daily_limit_usd == null && plan.weekly_limit_usd == null && plan.monthly_limit_usd == null" class="flex items-center justify-between"> |
| <span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.quota') }}</span> |
| <span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.planCard.unlimited') }}</span> |
| </div> |
| <div v-if="modelScopeLabels.length > 0" class="col-span-2 flex items-center justify-between"> |
| <span class="text-gray-400 dark:text-dark-500">{{ t('payment.planCard.models') }}</span> |
| <div class="flex flex-wrap justify-end gap-1"> |
| <span v-for="scope in modelScopeLabels" :key="scope" |
| class="rounded bg-gray-200/80 px-1.5 py-0.5 text-[10px] font-medium text-gray-600 dark:bg-dark-600 dark:text-gray-300"> |
| {{ scope }} |
| </span> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div v-if="plan.features.length > 0" class="mb-3 space-y-1"> |
| <div v-for="feature in plan.features" :key="feature" class="flex items-start gap-1.5"> |
| <svg :class="['mt-0.5 h-3.5 w-3.5 flex-shrink-0', iconClass]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> |
| </svg> |
| <span class="text-xs text-gray-600 dark:text-gray-300">{{ feature }}</span> |
| </div> |
| </div> |
| |
| <div class="flex-1" /> |
| |
| |
| <button |
| type="button" |
| :class="['w-full rounded-xl py-2.5 text-sm font-semibold transition-all active:scale-[0.98]', btnClass]" |
| @click="emit('select', plan)" |
| > |
| {{ isRenewal ? t('payment.renewNow') : t('payment.subscribeNow') }} |
| </button> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { computed } from 'vue' |
| import { useI18n } from 'vue-i18n' |
| import type { SubscriptionPlan } from '@/types/payment' |
| import type { UserSubscription } from '@/types' |
| import { |
| platformAccentBarClass, |
| platformBadgeLightClass, |
| platformBorderClass, |
| platformTextClass, |
| platformIconClass, |
| platformButtonClass, |
| platformDiscountClass, |
| platformLabel, |
| } from '@/utils/platformColors' |
| |
| const props = defineProps<{ plan: SubscriptionPlan; activeSubscriptions?: UserSubscription[] }>() |
| const emit = defineEmits<{ select: [plan: SubscriptionPlan] }>() |
| const { t } = useI18n() |
| |
| const platform = computed(() => props.plan.group_platform || '') |
| const isRenewal = computed(() => |
| props.activeSubscriptions?.some(s => s.group_id === props.plan.group_id && s.status === 'active') ?? false |
| ) |
| |
| |
| const accentClass = computed(() => platformAccentBarClass(platform.value)) |
| const borderClass = computed(() => platformBorderClass(platform.value)) |
| const badgeLightClass = computed(() => platformBadgeLightClass(platform.value)) |
| const textClass = computed(() => platformTextClass(platform.value)) |
| const iconClass = computed(() => platformIconClass(platform.value)) |
| const btnClass = computed(() => platformButtonClass(platform.value)) |
| const discountClass = computed(() => platformDiscountClass(platform.value)) |
| const pLabel = computed(() => platformLabel(platform.value)) |
| |
| 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}%` : '' |
| }) |
| |
| const rateDisplay = computed(() => { |
| const rate = props.plan.rate_multiplier ?? 1 |
| return `×${Number(rate.toPrecision(10))}` |
| }) |
| |
| const MODEL_SCOPE_LABELS: Record<string, string> = { |
| claude: 'Claude', |
| gemini_text: 'Gemini', |
| gemini_image: 'Imagen', |
| } |
| |
| const modelScopeLabels = computed(() => { |
| if (platform.value !== 'antigravity') return [] |
| const scopes = props.plan.supported_model_scopes |
| if (!scopes || scopes.length === 0) return [] |
| return scopes.map(s => MODEL_SCOPE_LABELS[s] || s) |
| }) |
| |
| const validitySuffix = computed(() => { |
| const u = props.plan.validity_unit || 'day' |
| if (u === 'month') return t('payment.perMonth') |
| if (u === 'year') return t('payment.perYear') |
| return `${props.plan.validity_days}${t('payment.days')}` |
| }) |
| </script> |
| |