- Web Hosting & Game Servers now pull plans from database (same pattern as VPS/Dedicated) - Add 6 game server plans to PlanSeeder (Minecraft, Rust, ARK, Valheim, CS2, Palworld) - Create Battlefield ACP marketing page with ProCon replacement hero, 6 feature cards, ML analytics, ban appeals, PunkBuster screenshots, Discord integration sections - Add Battlefield ACP to Products dropdown navigation - Marketing pages use SectionHeader component, fade-in animations, Vuexy design patterns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
430 lines
13 KiB
Vue
430 lines
13 KiB
Vue
<script lang="ts" setup>
|
|
import { usePage } from '@inertiajs/vue3'
|
|
import { computed } from 'vue'
|
|
import MarketingLayout from '@/Layouts/MarketingLayout.vue'
|
|
import SectionHeader from '@/Components/Marketing/SectionHeader.vue'
|
|
|
|
defineOptions({ layout: MarketingLayout })
|
|
|
|
interface Plan {
|
|
id: number
|
|
name: string
|
|
slug: string
|
|
description: string | null
|
|
service_type: string
|
|
price: string
|
|
currency: string
|
|
billing_cycle: string
|
|
features: Record<string, string> | null
|
|
stock_quantity: number | null
|
|
status: string
|
|
sort_order: number
|
|
}
|
|
|
|
interface PageProps {
|
|
plans: Plan[]
|
|
domains: { marketing: string; account: string; admin: string }
|
|
}
|
|
|
|
const page = usePage()
|
|
const props = computed(() => page.props as unknown as PageProps)
|
|
const accountUrl = computed(() => `https://${props.value.domains?.account}`)
|
|
const plans = computed(() => props.value.plans || [])
|
|
|
|
// Internal provisioning keys that should never be shown to visitors
|
|
const internalKeys = new Set([
|
|
'virtfusion_package_id',
|
|
'synergy_package_id',
|
|
'enhance_package_id',
|
|
'pterodactyl_package_id',
|
|
'virtfusion_server_id',
|
|
'synergy_server_id',
|
|
])
|
|
|
|
function isDisplayableFeature(key: string): boolean {
|
|
return !internalKeys.has(key)
|
|
}
|
|
|
|
const featureLabels: Record<string, string> = {
|
|
vcpu: 'vCPU',
|
|
cpu: 'CPU',
|
|
ram: 'RAM',
|
|
storage: 'Storage',
|
|
bandwidth: 'Bandwidth',
|
|
ip_addresses: 'IP Addresses',
|
|
backups: 'Backups',
|
|
ddos_protection: 'DDoS Protection',
|
|
support: 'Support',
|
|
uptime_sla: 'Uptime SLA',
|
|
}
|
|
|
|
function humanizeFeatureKey(key: string): string {
|
|
if (featureLabels[key]) return featureLabels[key]
|
|
return key
|
|
.replace(/_/g, ' ')
|
|
.replace(/\b\w/g, c => c.toUpperCase())
|
|
}
|
|
|
|
function getDisplayFeatures(plan: Plan): Array<{ key: string; value: string }> {
|
|
if (!plan.features) return []
|
|
return Object.entries(plan.features)
|
|
.filter(([key]) => isDisplayableFeature(key))
|
|
.map(([key, value]) => ({ key, value }))
|
|
}
|
|
|
|
function getMonthlyPrice(plan: Plan): string {
|
|
const price = parseFloat(plan.price ?? '0') || 0
|
|
return price % 1 === 0 ? price.toString() : price.toFixed(2)
|
|
}
|
|
|
|
function getPlanColor(index: number): string {
|
|
const colors = ['primary', 'success', 'warning', 'error', 'info']
|
|
return colors[index % colors.length]
|
|
}
|
|
|
|
function isPopular(index: number): boolean {
|
|
return plans.value.length > 1 && index === 1
|
|
}
|
|
|
|
// Feature comparison data — excluding internal keys
|
|
const featureComparison = computed(() => {
|
|
if (plans.value.length === 0) return []
|
|
const allFeatures = new Set<string>()
|
|
plans.value.forEach(plan => {
|
|
if (plan.features) {
|
|
Object.keys(plan.features)
|
|
.filter(isDisplayableFeature)
|
|
.forEach(f => allFeatures.add(f))
|
|
}
|
|
})
|
|
return Array.from(allFeatures).map(feature => ({
|
|
feature: humanizeFeatureKey(feature),
|
|
plans: plans.value.map(plan => ({
|
|
value: plan.features?.[feature] ?? null,
|
|
})),
|
|
}))
|
|
})
|
|
|
|
const faqs = [
|
|
{
|
|
question: 'Can I upgrade my plan later?',
|
|
answer: 'Yes! You can upgrade or downgrade your plan at any time from your account dashboard. Changes take effect immediately and billing is prorated.',
|
|
},
|
|
{
|
|
question: 'What payment methods do you accept?',
|
|
answer: 'We accept all major credit cards (Visa, Mastercard, American Express) via Stripe, as well as PayPal. Your payment information is always kept safe and secure.',
|
|
},
|
|
{
|
|
question: 'Is there a money-back guarantee?',
|
|
answer: 'Yes, all plans come with a 30-day money-back guarantee. If you\'re not satisfied, contact support for a full refund.',
|
|
},
|
|
{
|
|
question: 'Do you offer custom configurations?',
|
|
answer: 'Absolutely. Contact our sales team for custom server configurations, bulk pricing, or enterprise solutions tailored to your needs.',
|
|
},
|
|
]
|
|
</script>
|
|
|
|
<template>
|
|
<div class="pricing-page">
|
|
<VCard class="pricing-card" flat>
|
|
<!-- Plan Cards Section -->
|
|
<VContainer>
|
|
<SectionHeader
|
|
label="Pricing"
|
|
title="Pricing Plans"
|
|
subtitle="All plans include 24/7 monitoring and enterprise-grade infrastructure. Choose the best plan to fit your needs."
|
|
/>
|
|
|
|
<!-- Plan Cards -->
|
|
<VRow v-if="plans.length">
|
|
<VCol
|
|
v-for="(plan, index) in plans"
|
|
:key="plan.id"
|
|
cols="12"
|
|
md="4"
|
|
>
|
|
<VCard
|
|
flat
|
|
border
|
|
class="feature-card-hover"
|
|
:class="isPopular(index) ? 'border-primary border-opacity-100' : ''"
|
|
>
|
|
<VCardText
|
|
style="block-size: 3.75rem;"
|
|
class="text-end"
|
|
>
|
|
<VChip
|
|
v-show="isPopular(index)"
|
|
label
|
|
color="primary"
|
|
size="small"
|
|
>
|
|
Popular
|
|
</VChip>
|
|
</VCardText>
|
|
|
|
<VCardText>
|
|
<!-- Plan Icon -->
|
|
<div class="text-center mb-5">
|
|
<VAvatar
|
|
:color="getPlanColor(index)"
|
|
variant="tonal"
|
|
size="80"
|
|
>
|
|
<VIcon
|
|
:icon="plan.service_type === 'vps' ? 'tabler-cloud' : plan.service_type === 'dedicated' ? 'tabler-server' : plan.service_type === 'web' ? 'tabler-world' : plan.service_type === 'game' ? 'tabler-device-gamepad-2' : 'tabler-package'"
|
|
size="40"
|
|
/>
|
|
</VAvatar>
|
|
</div>
|
|
|
|
<!-- Plan Name -->
|
|
<h4 class="text-h4 mb-1 text-center">
|
|
{{ plan.name }}
|
|
</h4>
|
|
<p class="mb-0 text-body-1 text-center">
|
|
{{ plan.description || 'High performance hosting' }}
|
|
</p>
|
|
|
|
<!-- Plan Price -->
|
|
<div class="position-relative">
|
|
<div class="d-flex justify-center pt-5 pb-10">
|
|
<div class="text-body-1 align-self-start font-weight-medium">
|
|
$
|
|
</div>
|
|
<h1 class="text-h1 font-weight-medium text-primary">
|
|
{{ getMonthlyPrice(plan) }}
|
|
</h1>
|
|
<div class="text-body-1 font-weight-medium align-self-end">
|
|
/month
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Plan Features -->
|
|
<VList class="card-list mb-4">
|
|
<VListItem
|
|
v-for="feat in getDisplayFeatures(plan)"
|
|
:key="feat.key"
|
|
>
|
|
<template #prepend>
|
|
<VIcon
|
|
size="8"
|
|
icon="tabler-circle-filled"
|
|
color="rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))"
|
|
/>
|
|
</template>
|
|
|
|
<VListItemTitle class="text-body-1">
|
|
{{ feat.value }}
|
|
</VListItemTitle>
|
|
</VListItem>
|
|
</VList>
|
|
|
|
<!-- Plan CTA -->
|
|
<a
|
|
:href="accountUrl + '/register'"
|
|
class="text-decoration-none d-block"
|
|
>
|
|
<VBtn
|
|
block
|
|
:variant="isPopular(index) ? 'elevated' : 'tonal'"
|
|
:active="false"
|
|
>
|
|
Choose Plan
|
|
</VBtn>
|
|
</a>
|
|
</VCardText>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<VCard v-else variant="outlined" class="pa-12 text-center">
|
|
<VIcon icon="tabler-package" size="48" class="text-medium-emphasis mb-4" />
|
|
<h3 class="text-h5 font-weight-bold mb-2">Plans Coming Soon</h3>
|
|
<p class="text-body-1 text-medium-emphasis">
|
|
We're finalizing our plans. Check back soon or sign up to be notified.
|
|
</p>
|
|
</VCard>
|
|
|
|
<!-- Money-back guarantee banner -->
|
|
<VCard v-if="plans.length" variant="tonal" color="primary" class="my-8">
|
|
<VCardText class="text-center py-4">
|
|
<div class="d-flex align-center justify-center ga-2">
|
|
<VIcon icon="tabler-sparkles" />
|
|
<span class="text-body-1 font-weight-medium">All plans include a 30-day money-back guarantee</span>
|
|
</div>
|
|
</VCardText>
|
|
</VCard>
|
|
</VContainer>
|
|
|
|
<!-- Feature Comparison Table -->
|
|
<VContainer v-if="plans.length && featureComparison.length">
|
|
<VCardText class="text-center py-16 pricing-section">
|
|
<SectionHeader
|
|
label="Compare Plans"
|
|
title="Pick a Plan That Works Best for You"
|
|
subtitle="Stay cool, we have a 30-day money back guarantee!"
|
|
/>
|
|
|
|
<VTable class="text-no-wrap border rounded pricing-table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" class="py-4">
|
|
<div>Features</div>
|
|
<div class="text-body-2">Plan Comparison</div>
|
|
</th>
|
|
<th
|
|
v-for="(plan, index) in plans"
|
|
:key="plan.id"
|
|
scope="col"
|
|
class="text-center py-4"
|
|
>
|
|
<div class="position-relative">
|
|
{{ plan.name }}
|
|
<VAvatar
|
|
v-if="isPopular(index)"
|
|
size="20"
|
|
class="ms-2 position-absolute"
|
|
variant="elevated"
|
|
color="primary"
|
|
style="inset-block-end: 7px;"
|
|
>
|
|
<VIcon
|
|
icon="tabler-star"
|
|
size="14"
|
|
color="white"
|
|
/>
|
|
</VAvatar>
|
|
</div>
|
|
<div class="text-body-2">
|
|
${{ getMonthlyPrice(plan) }}/Month
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
<tr
|
|
v-for="row in featureComparison"
|
|
:key="row.feature"
|
|
>
|
|
<td class="text-start text-body-1 text-high-emphasis">
|
|
{{ row.feature }}
|
|
</td>
|
|
<td
|
|
v-for="(planData, pIndex) in row.plans"
|
|
:key="pIndex"
|
|
class="text-center"
|
|
>
|
|
<span v-if="planData.value" class="text-body-1">
|
|
{{ planData.value }}
|
|
</span>
|
|
<VIcon
|
|
v-else
|
|
icon="tabler-minus"
|
|
size="14"
|
|
color="secondary"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
|
|
<tfoot>
|
|
<tr>
|
|
<td class="py-2" />
|
|
<td
|
|
v-for="(plan, index) in plans"
|
|
:key="plan.id"
|
|
class="text-center py-2"
|
|
>
|
|
<a
|
|
:href="accountUrl + '/register'"
|
|
class="text-decoration-none"
|
|
>
|
|
<VBtn
|
|
:variant="isPopular(index) ? 'elevated' : 'tonal'"
|
|
>
|
|
Choose Plan
|
|
</VBtn>
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</VTable>
|
|
</VCardText>
|
|
</VContainer>
|
|
|
|
<!-- FAQ Section -->
|
|
<div class="faq-section-bg">
|
|
<VContainer>
|
|
<VCardText class="py-10 py-sm-16 pricing-section">
|
|
<div class="text-center">
|
|
<h4 class="text-h4 mb-2">
|
|
FAQ's
|
|
</h4>
|
|
<p class="text-body-1 mb-6">
|
|
Let us help answer the most common questions.
|
|
</p>
|
|
</div>
|
|
<VRow justify="center">
|
|
<VCol cols="12" md="8">
|
|
<VExpansionPanels>
|
|
<VExpansionPanel
|
|
v-for="(faq, index) in faqs"
|
|
:key="faq.question"
|
|
:title="faq.question"
|
|
:text="faq.answer"
|
|
:value="index"
|
|
/>
|
|
</VExpansionPanels>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
</VContainer>
|
|
</div>
|
|
</VCard>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.pricing-card {
|
|
padding-block-start: 5rem !important;
|
|
}
|
|
|
|
.pricing-title {
|
|
font-weight: 800;
|
|
}
|
|
|
|
.card-list {
|
|
--v-card-list-gap: 1rem;
|
|
}
|
|
|
|
.pricing-section {
|
|
padding-block: 5.25rem !important;
|
|
padding-inline: 0 !important;
|
|
}
|
|
|
|
.faq-section-bg {
|
|
background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
|
|
}
|
|
|
|
.pricing-table {
|
|
tr:nth-child(even) {
|
|
background: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<style lang="scss">
|
|
.pricing-page {
|
|
@media (min-width: 600px) and (max-width: 960px) {
|
|
.v-container {
|
|
padding-inline: 2rem !important;
|
|
}
|
|
}
|
|
}
|
|
</style>
|