feat: add billing cycle toggle to pricing page
Eager-load plan prices in the pricing route and add a billing cycle toggle (monthly/quarterly/semi-annual/annual) with discount badges and per-month equivalent display for longer cycles. CTA links now pass the selected cycle as a query param. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import { usePage } from '@inertiajs/vue3'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
||||
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
||||
|
||||
defineOptions({ layout: MarketingLayout })
|
||||
|
||||
interface PlanPrice {
|
||||
id: number
|
||||
plan_id: number
|
||||
billing_cycle: 'monthly' | 'quarterly' | 'semi_annual' | 'annual'
|
||||
price: string
|
||||
}
|
||||
|
||||
interface Plan {
|
||||
id: number
|
||||
name: string
|
||||
@@ -19,6 +26,7 @@ interface Plan {
|
||||
stock_quantity: number | null
|
||||
status: string
|
||||
sort_order: number
|
||||
prices?: PlanPrice[]
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
@@ -31,6 +39,37 @@ const props = computed(() => page.props as unknown as PageProps)
|
||||
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
|
||||
const plans = computed(() => props.value.plans || [])
|
||||
|
||||
const billingCycles = [
|
||||
{ value: 'monthly', label: 'Monthly', months: 1, discount: 0 },
|
||||
{ value: 'quarterly', label: 'Quarterly', months: 3, discount: 5 },
|
||||
{ value: 'semi_annual', label: 'Semi-Annual', months: 6, discount: 10 },
|
||||
{ value: 'annual', label: 'Annual', months: 12, discount: 15 },
|
||||
] as const
|
||||
|
||||
const selectedCycle = ref<string>('monthly')
|
||||
|
||||
function getCyclePrice(plan: Plan): string {
|
||||
const planPrice = plan.prices?.find((p: PlanPrice) => p.billing_cycle === selectedCycle.value)
|
||||
if (planPrice) {
|
||||
const price = parseFloat(planPrice.price) || 0
|
||||
return price % 1 === 0 ? price.toString() : price.toFixed(2)
|
||||
}
|
||||
return getMonthlyPrice(plan)
|
||||
}
|
||||
|
||||
function getMonthlyEquivalent(plan: Plan): string {
|
||||
const planPrice = plan.prices?.find((p: PlanPrice) => p.billing_cycle === selectedCycle.value)
|
||||
const cycle = billingCycles.find(c => c.value === selectedCycle.value)
|
||||
if (planPrice && cycle && cycle.months > 1) {
|
||||
return (parseFloat(planPrice.price) / cycle.months).toFixed(2)
|
||||
}
|
||||
return getMonthlyPrice(plan)
|
||||
}
|
||||
|
||||
function getCycleLabel(): string {
|
||||
return billingCycles.find(c => c.value === selectedCycle.value)?.label ?? 'Monthly'
|
||||
}
|
||||
|
||||
// Internal provisioning keys that should never be shown to visitors
|
||||
const internalKeys = new Set([
|
||||
'virtfusion_package_id',
|
||||
@@ -136,6 +175,38 @@ const faqs = [
|
||||
subtitle="All plans include 24/7 monitoring and enterprise-grade infrastructure. Choose the best plan to fit your needs."
|
||||
/>
|
||||
|
||||
<!-- Billing Cycle Toggle -->
|
||||
<div
|
||||
v-if="plans.length"
|
||||
class="d-flex justify-center mb-8"
|
||||
>
|
||||
<div
|
||||
class="d-inline-flex rounded-pill pa-1"
|
||||
style="background: rgba(var(--v-theme-surface-variant), 0.3);"
|
||||
>
|
||||
<VBtn
|
||||
v-for="cycle in billingCycles"
|
||||
:key="cycle.value"
|
||||
:color="selectedCycle === cycle.value ? 'primary' : undefined"
|
||||
:variant="selectedCycle === cycle.value ? 'flat' : 'text'"
|
||||
rounded="pill"
|
||||
size="small"
|
||||
class="mx-1"
|
||||
@click="selectedCycle = cycle.value"
|
||||
>
|
||||
{{ cycle.label }}
|
||||
<VChip
|
||||
v-if="cycle.discount"
|
||||
size="x-small"
|
||||
color="success"
|
||||
class="ml-1"
|
||||
>
|
||||
-{{ cycle.discount }}%
|
||||
</VChip>
|
||||
</VBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan Cards -->
|
||||
<VRow v-if="plans.length">
|
||||
<VCol
|
||||
@@ -189,18 +260,27 @@ const faqs = [
|
||||
|
||||
<!-- Plan Price -->
|
||||
<div class="position-relative">
|
||||
<div class="d-flex justify-center pt-5 pb-10">
|
||||
<div class="d-flex justify-center pt-5 pb-2">
|
||||
<div class="text-body-1 align-self-start font-weight-medium">
|
||||
$
|
||||
</div>
|
||||
<h1 class="text-h1 font-weight-medium text-primary">
|
||||
{{ getMonthlyPrice(plan) }}
|
||||
{{ getCyclePrice(plan) }}
|
||||
</h1>
|
||||
<div class="text-body-1 font-weight-medium align-self-end">
|
||||
/month
|
||||
/{{ selectedCycle === 'monthly' ? 'month' : getCycleLabel().toLowerCase() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedCycle !== 'monthly'"
|
||||
class="text-center text-caption text-medium-emphasis pb-4"
|
||||
>
|
||||
${{ getMonthlyEquivalent(plan) }}/mo equivalent
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="pb-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Plan Features -->
|
||||
@@ -225,7 +305,7 @@ const faqs = [
|
||||
|
||||
<!-- Plan CTA -->
|
||||
<a
|
||||
:href="accountUrl + '/checkout/' + plan.id"
|
||||
:href="accountUrl + '/checkout/' + plan.id + '?cycle=' + selectedCycle"
|
||||
class="text-decoration-none d-block"
|
||||
>
|
||||
<VBtn
|
||||
@@ -300,7 +380,7 @@ const faqs = [
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
${{ getMonthlyPrice(plan) }}/Month
|
||||
${{ getCyclePrice(plan) }}/{{ selectedCycle === 'monthly' ? 'Month' : getCycleLabel() }}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
@@ -341,7 +421,7 @@ const faqs = [
|
||||
class="text-center py-2"
|
||||
>
|
||||
<a
|
||||
:href="accountUrl + '/checkout/' + plan.id"
|
||||
:href="accountUrl + '/checkout/' + plan.id + '?cycle=' + selectedCycle"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
<VBtn
|
||||
|
||||
@@ -60,6 +60,7 @@ Route::get('/game-servers', function () {
|
||||
Route::redirect('/battlefield-acp', '/game-servers', 301);
|
||||
Route::get('/pricing', function () {
|
||||
$plans = Plan::query()
|
||||
->with('prices')
|
||||
->where('status', 'active')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('price')
|
||||
|
||||
Reference in New Issue
Block a user