Files
website/website/resources/ts/Pages/Marketing/Pricing.vue
Claude Dev 169b06e349 Dynamic product pages + Battlefield ACP marketing page
- 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>
2026-02-10 11:32:35 -05:00

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>