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:
Claude Dev
2026-03-14 23:38:17 -04:00
parent e0e38e47c6
commit 5be235d35e
2 changed files with 89 additions and 8 deletions

View File

@@ -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

View File

@@ -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')