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>
|
<script lang="ts" setup>
|
||||||
import { usePage } from '@inertiajs/vue3'
|
import { usePage } from '@inertiajs/vue3'
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
||||||
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
||||||
|
|
||||||
defineOptions({ layout: MarketingLayout })
|
defineOptions({ layout: MarketingLayout })
|
||||||
|
|
||||||
|
interface PlanPrice {
|
||||||
|
id: number
|
||||||
|
plan_id: number
|
||||||
|
billing_cycle: 'monthly' | 'quarterly' | 'semi_annual' | 'annual'
|
||||||
|
price: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -19,6 +26,7 @@ interface Plan {
|
|||||||
stock_quantity: number | null
|
stock_quantity: number | null
|
||||||
status: string
|
status: string
|
||||||
sort_order: number
|
sort_order: number
|
||||||
|
prices?: PlanPrice[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
@@ -31,6 +39,37 @@ const props = computed(() => page.props as unknown as PageProps)
|
|||||||
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
|
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
|
||||||
const plans = computed(() => props.value.plans || [])
|
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
|
// Internal provisioning keys that should never be shown to visitors
|
||||||
const internalKeys = new Set([
|
const internalKeys = new Set([
|
||||||
'virtfusion_package_id',
|
'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."
|
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 -->
|
<!-- Plan Cards -->
|
||||||
<VRow v-if="plans.length">
|
<VRow v-if="plans.length">
|
||||||
<VCol
|
<VCol
|
||||||
@@ -189,18 +260,27 @@ const faqs = [
|
|||||||
|
|
||||||
<!-- Plan Price -->
|
<!-- Plan Price -->
|
||||||
<div class="position-relative">
|
<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 class="text-body-1 align-self-start font-weight-medium">
|
||||||
$
|
$
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-h1 font-weight-medium text-primary">
|
<h1 class="text-h1 font-weight-medium text-primary">
|
||||||
{{ getMonthlyPrice(plan) }}
|
{{ getCyclePrice(plan) }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="text-body-1 font-weight-medium align-self-end">
|
<div class="text-body-1 font-weight-medium align-self-end">
|
||||||
/month
|
/{{ selectedCycle === 'monthly' ? 'month' : getCycleLabel().toLowerCase() }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Plan Features -->
|
<!-- Plan Features -->
|
||||||
@@ -225,7 +305,7 @@ const faqs = [
|
|||||||
|
|
||||||
<!-- Plan CTA -->
|
<!-- Plan CTA -->
|
||||||
<a
|
<a
|
||||||
:href="accountUrl + '/checkout/' + plan.id"
|
:href="accountUrl + '/checkout/' + plan.id + '?cycle=' + selectedCycle"
|
||||||
class="text-decoration-none d-block"
|
class="text-decoration-none d-block"
|
||||||
>
|
>
|
||||||
<VBtn
|
<VBtn
|
||||||
@@ -300,7 +380,7 @@ const faqs = [
|
|||||||
</VAvatar>
|
</VAvatar>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-body-2">
|
<div class="text-body-2">
|
||||||
${{ getMonthlyPrice(plan) }}/Month
|
${{ getCyclePrice(plan) }}/{{ selectedCycle === 'monthly' ? 'Month' : getCycleLabel() }}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -341,7 +421,7 @@ const faqs = [
|
|||||||
class="text-center py-2"
|
class="text-center py-2"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
:href="accountUrl + '/checkout/' + plan.id"
|
:href="accountUrl + '/checkout/' + plan.id + '?cycle=' + selectedCycle"
|
||||||
class="text-decoration-none"
|
class="text-decoration-none"
|
||||||
>
|
>
|
||||||
<VBtn
|
<VBtn
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ Route::get('/game-servers', function () {
|
|||||||
Route::redirect('/battlefield-acp', '/game-servers', 301);
|
Route::redirect('/battlefield-acp', '/game-servers', 301);
|
||||||
Route::get('/pricing', function () {
|
Route::get('/pricing', function () {
|
||||||
$plans = Plan::query()
|
$plans = Plan::query()
|
||||||
|
->with('prices')
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->orderBy('sort_order')
|
->orderBy('sort_order')
|
||||||
->orderBy('price')
|
->orderBy('price')
|
||||||
|
|||||||
Reference in New Issue
Block a user