Files
website/website/resources/ts/Pages/Checkout/Show.vue
Claude Dev 45d25d61ba Idempotent provisioning, service soft-delete, Plans page redesign, doc updates
Part A: Fix duplicate Service creation on provisioning retry
- All 4 provisioning services use Service::firstOrCreate() keyed on
  subscription_id+service_type to prevent duplicates on queue retries
- HandleSubscriptionCreated sends notification before provisioning,
  no longer re-throws on failure
- RetryProvisioningCommand simplified to reuse existing Service records

Part B: Plans/Pricing page complete redesign
- Service type tabs (VPS, Dedicated, Web Hosting, MySQL)
- Billing cycle segmented toggle (monthly/quarterly/semi-annual/annual)
- Feature icons per service type, Popular/Best Value badges
- Stock indicators, effective monthly price calculations

Part C: Admin service soft-delete/archive
- Service model uses SoftDeletes trait
- Admin can archive and restore services
- Show archived toggle on services list
- Migration adds deleted_at column

Docs: Updated TASKS.md, CLAUDE.md, PROJECT_DEVELOPMENT.md, MEMORY.md
- Phase 3 marked complete, test counts updated (252 passing)
- SupportPal references replaced with standalone ticket system
- Frontend design skill background rule added
- Closed GitHub issues #3, #6, #7, #8, #9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 06:30:57 -05:00

1481 lines
48 KiB
Vue

<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useForm, Link } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
import AppStepper from '@/@core/components/AppStepper.vue'
import { VExpansionPanels, VExpansionPanel, VExpansionPanelTitle, VExpansionPanelText } from 'vuetify/components'
import type { Plan, PaymentMethod } from '@/types'
interface OSTemplate {
id: number
name: string
description?: string
icon: string
category: string
}
interface OSTemplateGroup {
name: string
description?: string
icon: string
templates: OSTemplate[]
}
interface Props {
plan: Plan
paymentMethods: PaymentMethod[]
intent: Record<string, unknown>
stripeKey: string
osTemplates: OSTemplate[]
osTemplateGroups: OSTemplateGroup[]
}
defineOptions({ layout: AccountLayout })
const props = defineProps<Props>()
const selectedGateway = ref('stripe')
const selectedPaymentMethod = ref(props.paymentMethods?.[0]?.id || '')
const couponCode = ref('')
const couponApplied = ref(false)
const couponDiscount = ref(0)
const couponError = ref('')
// Billing cycle selection
const billingCycle = ref<'monthly' | 'quarterly' | 'semi_annual' | 'annual'>('monthly')
const billingCycles = [
{ value: 'monthly', label: 'Monthly', months: 1, discount: 0, popular: false },
{ value: 'quarterly', label: 'Quarterly', months: 3, discount: 0.05, popular: false },
{ value: 'semi_annual', label: 'Semi-Annual', months: 6, discount: 0.10, popular: true },
{ value: 'annual', label: 'Annual', months: 12, discount: 0.15, popular: false },
] as const
// Server Configuration
const selectedOS = ref(props.osTemplates[0]?.id || null)
const authMethod = ref<'password' | 'ssh'>('password')
const sshKey = ref('')
const generatedPrivateKey = ref('')
const isGenerating = ref(false)
const additionalIPv4 = ref(0) // Number of additional IPv4 addresses to purchase
const expandedPanels = ref([0]) // Default to first panel (index 0) expanded
const selectedCycle = computed(() =>
billingCycles.find(c => c.value === billingCycle.value) || billingCycles[0]
)
const monthlyPrice = computed(() => parseFloat(props.plan.price))
// Calculate price with billing cycle discount
const basePriceWithCycle = computed(() => {
const monthly = monthlyPrice.value
const cycle = selectedCycle.value
const discountedMonthly = monthly * (1 - cycle.discount)
return discountedMonthly * cycle.months
})
const ipCost = computed(() => additionalIPv4.value * 3 * selectedCycle.value.months)
const total = computed(() => {
const basePrice = basePriceWithCycle.value
const additionalCosts = ipCost.value
return Math.max(0, basePrice - couponDiscount.value + additionalCosts).toFixed(2)
})
const savingsAmount = computed(() => {
if (selectedCycle.value.discount === 0) return 0
const monthly = monthlyPrice.value
const months = selectedCycle.value.months
const regularPrice = monthly * months
const discountedPrice = basePriceWithCycle.value
return regularPrice - discountedPrice
})
// Pricing display with proper suffixes
const pricingSuffix = computed(() => {
switch (billingCycle.value) {
case 'monthly':
return '/mo'
case 'quarterly':
return ' for 3 months'
case 'semi_annual':
return ' for 6 months'
case 'annual':
return '/yr'
default:
return ''
}
})
const priceDisplayText = computed(() => {
return `$${total.value}${pricingSuffix.value}`
})
const form = useForm({
gateway: 'stripe',
payment_method_id: props.paymentMethods?.[0]?.id || '',
coupon_code: '',
billing_cycle: 'monthly',
configuration: {
os_template_id: props.osTemplates[0]?.id || null,
auth_method: 'password' as 'password' | 'ssh',
ssh_key: '',
additional_ipv4: 0,
},
})
const selectedOSTemplate = computed(() =>
props.osTemplates.find(t => t.id === selectedOS.value)
)
// SSH Key Generation using Web Crypto API (RSA-4096)
const generateSSHKeyPair = async (): Promise<void> => {
isGenerating.value = true
try {
// Generate RSA-4096 key pair using Web Crypto API
const keyPair = await window.crypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify']
)
// Export public key in SPKI format
const publicKeyData = await window.crypto.subtle.exportKey('spki', keyPair.publicKey)
const publicKeyBase64 = arrayBufferToBase64(publicKeyData)
// Export private key in PKCS8 format
const privateKeyData = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
const privateKeyPEM = formatPEM(arrayBufferToBase64(privateKeyData), 'RSA PRIVATE KEY')
// Format as OpenSSH RSA public key
const publicKeySSH = `ssh-rsa ${publicKeyBase64} generated@ezscale.cloud`
// Set keys
sshKey.value = publicKeySSH
generatedPrivateKey.value = privateKeyPEM
} catch (error) {
console.error('Failed to generate SSH key pair:', error)
alert('Failed to generate SSH key pair. Please try again or use an existing key.')
} finally {
isGenerating.value = false
}
}
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
const formatPEM = (base64: string, type: string): string => {
const lines = base64.match(/.{1,64}/g) || []
return `-----BEGIN ${type}-----\n${lines.join('\n')}\n-----END ${type}-----`
}
const downloadPrivateKey = (): void => {
const blob = new Blob([generatedPrivateKey.value], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'ezscale_vps_private_key.pem'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
const applyCoupon = async (): Promise<void> => {
couponError.value = ''
couponApplied.value = false
try {
const response = await fetch('/checkout/apply-coupon', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'Accept': 'application/json',
},
body: JSON.stringify({
code: couponCode.value,
plan_id: props.plan.id,
}),
})
const data = await response.json()
if (data.valid) {
couponApplied.value = true
couponDiscount.value = data.discount
} else {
couponError.value = data.message || 'Invalid coupon.'
}
} catch {
couponError.value = 'Failed to validate coupon.'
}
}
const submit = (): void => {
form.gateway = selectedGateway.value
form.payment_method_id = selectedPaymentMethod.value
form.coupon_code = couponApplied.value ? couponCode.value : ''
form.billing_cycle = billingCycle.value
// Update configuration
form.configuration.os_template_id = selectedOS.value
form.configuration.auth_method = authMethod.value
form.configuration.ssh_key = authMethod.value === 'ssh' ? sshKey.value : ''
form.configuration.additional_ipv4 = additionalIPv4.value
sessionStorage.removeItem(PROGRESS_KEY)
form.post(`/checkout/${props.plan.id}`)
}
const isVPS = computed(() => props.plan.service_type === 'vps')
// Visual stepper state
const checkoutSteps = computed(() => {
const steps = [
{ title: 'Billing Cycle', icon: 'mdi-calendar-clock' },
{ title: 'Payment Method', icon: 'mdi-credit-card-outline' },
]
if (isVPS.value) {
steps.splice(1, 0, { title: 'Server Config', icon: 'mdi-cog-outline' })
}
return steps
})
const currentStepIndex = ref(0)
const sectionRefs = ref<HTMLElement[]>([])
const paymentStepIndex = computed(() => isVPS.value ? 2 : 1)
// SSH key validation
const sshKeyError = ref('')
const validateSSHKey = () => {
if (!sshKey.value || authMethod.value !== 'ssh') {
sshKeyError.value = ''
return
}
const sshKeyPattern = /^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+[A-Za-z0-9+/=]+(\s+.+)?$/
if (!sshKeyPattern.test(sshKey.value.trim())) {
sshKeyError.value = 'Invalid SSH key format. Must start with ssh-rsa, ssh-ed25519, or ecdsa-sha2'
} else {
sshKeyError.value = ''
}
}
// Watch SSH key changes for live validation
watch(sshKey, validateSSHKey)
watch(authMethod, validateSSHKey)
// Progress save/resume using sessionStorage
const PROGRESS_KEY = 'checkout_progress'
const saveProgress = () => {
const progress = {
billing_cycle: billingCycle.value,
os_template_id: selectedOS.value,
auth_method: authMethod.value,
additional_ipv4: additionalIPv4.value,
ssh_key: sshKey.value,
timestamp: Date.now(),
}
sessionStorage.setItem(PROGRESS_KEY, JSON.stringify(progress))
}
const loadProgress = () => {
try {
const saved = sessionStorage.getItem(PROGRESS_KEY)
if (!saved) return
const progress = JSON.parse(saved)
const oneHourAgo = Date.now() - (60 * 60 * 1000)
// Only restore if less than 1 hour old
if (progress.timestamp && progress.timestamp > oneHourAgo) {
if (progress.billing_cycle) billingCycle.value = progress.billing_cycle
if (progress.os_template_id) selectedOS.value = progress.os_template_id
if (progress.auth_method) authMethod.value = progress.auth_method
if (progress.additional_ipv4 !== undefined) additionalIPv4.value = progress.additional_ipv4
if (progress.ssh_key) sshKey.value = progress.ssh_key
} else {
// Clear expired progress
sessionStorage.removeItem(PROGRESS_KEY)
}
} catch (e) {
console.error('Failed to load checkout progress:', e)
}
}
// Save progress when key fields change
watch([billingCycle, selectedOS, authMethod, additionalIPv4, sshKey], saveProgress, { deep: true })
// Intersection Observer for stepper progress
const intersectionObserver = ref<IntersectionObserver | null>(null)
onMounted(() => {
// Load saved progress
loadProgress()
// Setup intersection observer for stepper
intersectionObserver.value = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = sectionRefs.value.indexOf(entry.target as HTMLElement)
if (index !== -1) currentStepIndex.value = index
}
})
},
{ threshold: 0.5, rootMargin: '-100px 0px' }
)
// Observe sections after a short delay to ensure refs are populated
const obs = intersectionObserver.value
setTimeout(() => {
sectionRefs.value.forEach(ref => {
if (ref) obs.observe(ref)
})
}, 100)
})
onUnmounted(() => {
intersectionObserver.value?.disconnect()
})
</script>
<template>
<div>
<div class="mb-4">
<Link href="/plans" class="text-primary text-body-2 text-decoration-none">&larr; Back to Plans</Link>
</div>
<div class="text-h4 font-weight-bold mb-6">Checkout</div>
<VRow>
<!-- Order Summary (Sticky Sidebar) -->
<VCol cols="12" lg="4" order="2" order-lg="1">
<div class="sticky-sidebar-wrapper">
<VCard elevation="2" class="summary-card mb-4">
<VCardTitle class="text-h6">Order Summary</VCardTitle>
<VCardText>
<div class="d-flex justify-space-between text-body-2 mb-2">
<span class="text-medium-emphasis">{{ plan.name }}</span>
<span class="font-weight-medium">${{ monthlyPrice.toFixed(2) }}/mo</span>
</div>
<div class="d-flex justify-space-between text-body-2 text-medium-emphasis mb-2">
<span>Billing Cycle</span>
<span class="text-capitalize">{{ selectedCycle.label }} ({{ selectedCycle.months }} {{ selectedCycle.months === 1 ? 'month' : 'months' }})</span>
</div>
<div v-if="selectedCycle.discount > 0" class="d-flex justify-space-between text-body-2 mb-2">
<span class="text-medium-emphasis">Cycle Discount ({{ (selectedCycle.discount * 100).toFixed(0) }}%)</span>
<span class="font-weight-medium text-success">-${{ savingsAmount.toFixed(2) }}</span>
</div>
<div class="d-flex justify-space-between text-body-2 mb-2">
<span class="text-medium-emphasis">Subtotal</span>
<span class="font-weight-medium">${{ basePriceWithCycle.toFixed(2) }}</span>
</div>
<div v-if="couponApplied" class="d-flex justify-space-between text-body-2 text-success mb-2">
<span>Discount</span>
<span>-${{ couponDiscount.toFixed(2) }}</span>
</div>
<div v-if="additionalIPv4 > 0" class="d-flex justify-space-between text-body-2 mb-2">
<span class="text-medium-emphasis">
Additional IPv4 ({{ additionalIPv4 }}x)
</span>
<span class="font-weight-medium">+${{ ipCost.toFixed(2) }}</span>
</div>
<VDivider class="my-4" />
<div class="d-flex justify-space-between text-h6 font-weight-bold mb-3">
<span>Total</span>
<span class="text-primary animated-price">{{ priceDisplayText }}</span>
</div>
<VDivider class="my-3" />
<div class="d-flex align-center text-caption text-medium-emphasis">
<VIcon icon="mdi-clock-outline" size="16" class="me-2" />
<span>Estimated deployment: 5-10 minutes</span>
</div>
</VCardText>
</VCard>
<!-- Configuration Preview -->
<VCard v-if="isVPS && selectedOSTemplate" elevation="1" class="config-preview">
<VCardTitle class="text-subtitle-1">Configuration</VCardTitle>
<VCardText>
<div class="d-flex align-center mb-3">
<VIcon :icon="selectedOSTemplate.icon" size="32" class="me-3 text-primary os-icon" />
<div>
<div class="text-body-2 font-weight-medium">{{ selectedOSTemplate.name }}</div>
<div class="text-caption text-medium-emphasis">Operating System</div>
</div>
</div>
<div class="d-flex align-center">
<VIcon :icon="authMethod === 'ssh' ? 'mdi-key' : 'mdi-lock'" size="28" class="me-3 text-primary" />
<div>
<div class="text-body-2 font-weight-medium">{{ authMethod === 'ssh' ? 'SSH Key Authentication' : 'Password Authentication' }}</div>
<div class="text-caption text-medium-emphasis">{{ authMethod === 'ssh' ? 'Secure key-based access' : 'Auto-generated password' }}</div>
</div>
</div>
</VCardText>
</VCard>
</div>
</VCol>
<!-- Checkout Form -->
<VCol cols="12" lg="8" order="1" order-lg="2">
<!-- Progress Stepper -->
<div class="mb-6 stepper-container">
<AppStepper
v-model:current-step="currentStepIndex"
:items="checkoutSteps"
:direction="$vuetify.display.mdAndUp ? 'horizontal' : 'vertical'"
align="center"
class="checkout-progress-stepper"
:is-active-step-valid="true"
/>
</div>
<VForm @submit.prevent="submit">
<!-- Billing Cycle Selection -->
<VCard ref="el => sectionRefs[0] = el" class="mb-6 billing-cycle-card" elevation="2" role="region" aria-labelledby="billing-cycle-heading">
<VCardTitle id="billing-cycle-heading" class="d-flex align-center">
<VIcon icon="mdi-calendar-clock" size="22" class="me-2 text-primary" />
<span class="text-h6">Select Billing Cycle</span>
</VCardTitle>
<VCardText>
<VRow class="billing-cycles">
<VCol
v-for="cycle in billingCycles"
:key="cycle.value"
cols="12"
sm="6"
md="3"
>
<div
class="billing-cycle-option"
:class="{
'billing-cycle-option--selected': billingCycle === cycle.value,
'billing-cycle-option--popular': cycle.popular
}"
@click="billingCycle = cycle.value"
>
<div v-if="cycle.popular" class="popular-badge">
<VChip color="primary" size="x-small" label>
BEST VALUE
</VChip>
</div>
<div class="billing-cycle-label">{{ cycle.label }}</div>
<div class="billing-cycle-price">
${{ (monthlyPrice * (1 - cycle.discount) * cycle.months).toFixed(2) }}
</div>
<div class="billing-cycle-per-month">
<template v-if="cycle.value === 'monthly'">
per month
</template>
<template v-else>
${{ (monthlyPrice * (1 - cycle.discount)).toFixed(2) }}/mo {{ cycle.months }} months
</template>
</div>
<div v-if="cycle.discount > 0" class="billing-cycle-savings">
Save {{ (cycle.discount * 100).toFixed(0) }}%
</div>
<div v-else class="billing-cycle-no-discount">
Standard rate
</div>
<div v-if="billingCycle === cycle.value" class="billing-cycle-check">
<VIcon icon="mdi-check-circle" size="28" color="primary" />
</div>
</div>
</VCol>
</VRow>
<VAlert
v-if="savingsAmount > 0"
type="success"
variant="tonal"
density="comfortable"
class="mt-4"
>
<div class="text-body-2">
<VIcon icon="mdi-piggy-bank" size="20" class="me-2" />
You're saving <strong>${{ savingsAmount.toFixed(2) }}</strong> with {{ selectedCycle.label }} billing!
</div>
</VAlert>
</VCardText>
</VCard>
<!-- Server Configuration (VPS Only) -->
<VExpandTransition>
<VCard v-if="isVPS" ref="el => sectionRefs[1] = el" class="mb-6 server-config-card" elevation="3" role="region" aria-labelledby="server-config-heading">
<VCardTitle id="server-config-heading" class="d-flex align-center pa-5">
<VIcon icon="mdi-cog-outline" size="24" class="me-2 text-primary" />
<span class="text-h6">Server Configuration</span>
</VCardTitle>
<VCardText class="pa-5">
<!-- Server Specifications -->
<div class="mb-7">
<label class="text-subtitle-1 font-weight-bold d-flex align-center mb-4">
<VIcon icon="mdi-server" size="22" class="me-2 text-primary" />
Server Specifications
</label>
<VRow class="spec-grid">
<VCol v-if="plan.features?.cpu" cols="6" sm="3">
<div class="spec-card">
<VIcon icon="mdi-cpu-64-bit" size="32" class="spec-icon mb-2" color="primary" />
<div class="spec-label">CPU</div>
<div class="spec-value">{{ plan.features.cpu }}</div>
</div>
</VCol>
<VCol v-if="plan.features?.ram" cols="6" sm="3">
<div class="spec-card">
<VIcon icon="mdi-memory" size="32" class="spec-icon mb-2" color="primary" />
<div class="spec-label">RAM</div>
<div class="spec-value">{{ plan.features.ram }}</div>
</div>
</VCol>
<VCol v-if="plan.features?.storage" cols="6" sm="3">
<div class="spec-card">
<VIcon icon="mdi-harddisk" size="32" class="spec-icon mb-2" color="primary" />
<div class="spec-label">Storage</div>
<div class="spec-value">{{ plan.features.storage }}</div>
</div>
</VCol>
<VCol v-if="plan.features?.bandwidth" cols="6" sm="3">
<div class="spec-card">
<VIcon icon="mdi-speedometer" size="32" class="spec-icon mb-2" color="primary" />
<div class="spec-label">Bandwidth</div>
<div class="spec-value">{{ plan.features.bandwidth }}</div>
</div>
</VCol>
</VRow>
<VRow class="mt-4">
<VCol cols="12" sm="6">
<div class="spec-card-inline">
<VIcon icon="mdi-ip-network" size="24" class="me-3" color="success" />
<div>
<div class="spec-label-inline">IPv4 Address</div>
<div class="spec-value-inline">1 IPv4 Included</div>
</div>
</div>
</VCol>
<VCol cols="12" sm="6">
<div class="spec-card-inline">
<VIcon icon="mdi-ip-network-outline" size="24" class="me-3" color="info" />
<div>
<div class="spec-label-inline">IPv6 Subnet</div>
<div class="spec-value-inline">/64 IPv6 Included</div>
</div>
</div>
</VCol>
</VRow>
</div>
<VDivider class="my-6" />
<!-- Operating System Selection -->
<div class="mb-7">
<label class="text-subtitle-1 font-weight-bold d-flex align-center mb-4">
<VIcon icon="mdi-linux" size="22" class="me-2 text-primary" />
Choose Your Operating System
</label>
<!-- OS Template Groups (Accordion) -->
<VExpansionPanels v-if="osTemplateGroups && osTemplateGroups.length > 0" v-model="expandedPanels" multiple class="os-expansion-panels">
<VExpansionPanel
v-for="(group, index) in osTemplateGroups"
:key="index"
:value="index"
class="os-group-panel"
>
<VExpansionPanelTitle class="os-group-header">
<div class="d-flex align-center">
<VIcon :icon="group.icon" size="32" class="me-3 os-group-icon" />
<div>
<div class="os-group-name">{{ group.name }}</div>
<div v-if="group.description" class="os-group-description">{{ group.description }}</div>
</div>
</div>
</VExpansionPanelTitle>
<VExpansionPanelText>
<div class="os-grid">
<div
v-for="os in group.templates"
:key="os.id"
class="os-card"
:class="{ 'os-card--selected': selectedOS === os.id }"
@click="selectedOS = os.id"
>
<div class="os-card__icon">
<VIcon :icon="os.icon" size="40" />
</div>
<div class="os-card__content">
<div class="os-card__name">{{ os.name }}</div>
<div v-if="os.description" class="os-card__description">{{ os.description }}</div>
</div>
<div v-if="selectedOS === os.id" class="os-card__check">
<VIcon icon="mdi-check-circle" size="24" color="primary" />
</div>
</div>
</div>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
<!-- Fallback if no templates -->
<VAlert v-else type="warning" variant="tonal" density="comfortable">
<div class="text-body-2">
<VIcon icon="mdi-alert" size="20" class="me-2" />
Operating system templates are currently unavailable. Please contact support.
</div>
</VAlert>
</div>
<VDivider class="my-6" />
<!-- Additional IPv4 Addresses -->
<div class="mb-7">
<label class="text-subtitle-1 font-weight-bold d-flex align-center mb-4">
<VIcon icon="mdi-ip-network" size="22" class="me-2 text-primary" />
Additional IPv4 Addresses
</label>
<VAlert type="info" variant="tonal" density="comfortable" class="mb-4">
<div class="text-body-2">
<VIcon icon="mdi-information" size="20" class="me-2" />
Each VPS includes 1 IPv4 address. Purchase additional IPv4 addresses for $3.00/month each.
</div>
</VAlert>
<div class="d-flex align-center gap-4">
<AppTextField
v-model.number="additionalIPv4"
type="number"
label="Additional IPv4 Count"
min="0"
max="10"
density="comfortable"
style="max-width: 200px;"
hint="0-10 additional IPs"
persistent-hint
>
<template #prepend-inner>
<VIcon icon="mdi-counter" size="20" />
</template>
</AppTextField>
<div v-if="additionalIPv4 > 0" class="ip-cost-badge">
<VChip color="primary" size="large" variant="tonal">
<VIcon icon="mdi-plus-circle" start size="20" />
+${{ (additionalIPv4 * 3).toFixed(2) }}/mo
</VChip>
</div>
</div>
</div>
<VDivider class="my-6" />
<!-- Authentication Method -->
<div>
<label class="text-subtitle-1 font-weight-bold d-flex align-center mb-4">
<VIcon icon="mdi-shield-key" size="22" class="me-2 text-primary" />
Authentication Method
</label>
<VBtnToggle
v-model="authMethod"
mandatory
divided
variant="outlined"
color="primary"
class="mb-4 auth-toggle"
>
<VBtn value="password" size="large" class="flex-1-1 text-none">
<VIcon icon="mdi-lock-outline" start size="20" />
Auto-Generated Password
</VBtn>
<VBtn value="ssh" size="large" class="flex-1-1 text-none">
<VIcon icon="mdi-key-variant" start size="20" />
SSH Key
</VBtn>
</VBtnToggle>
<!-- Password Info -->
<VExpandTransition>
<VAlert
v-if="authMethod === 'password'"
type="info"
variant="tonal"
density="comfortable"
class="mb-0"
>
<div class="text-body-2">
<VIcon icon="mdi-information" size="20" class="me-2" />
A secure random password will be automatically generated and emailed to you after your server is provisioned.
</div>
</VAlert>
</VExpandTransition>
<!-- SSH Key Input -->
<VExpandTransition>
<div v-if="authMethod === 'ssh'" class="ssh-section">
<!-- Generate Key Button -->
<div class="d-flex gap-3 mb-4">
<VBtn
variant="outlined"
color="primary"
prepend-icon="mdi-auto-fix"
:loading="isGenerating"
@click="generateSSHKeyPair"
>
Generate SSH Key Pair
</VBtn>
<VBtn
v-if="generatedPrivateKey"
variant="tonal"
color="success"
prepend-icon="mdi-download"
@click="downloadPrivateKey"
>
Download Private Key
</VBtn>
</div>
<!-- Warning for Generated Keys -->
<VAlert
v-if="generatedPrivateKey"
type="warning"
variant="tonal"
density="comfortable"
class="mb-4"
>
<div class="text-body-2">
<VIcon icon="mdi-alert" size="20" class="me-2" />
<strong>Important:</strong> Download and save your private key now! It will not be shown again. Keep it secure and never share it.
</div>
</VAlert>
<!-- SSH Key Textarea -->
<AppTextarea
v-model="sshKey"
label="SSH Public Key"
placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... user@hostname"
rows="5"
persistent-placeholder
:error-messages="sshKeyError"
class="ssh-key-input mb-2"
/>
<div v-if="!sshKeyError" class="text-caption text-medium-emphasis">
<VIcon icon="mdi-information-outline" size="16" />
Paste your SSH public key or generate a new RSA-4096 key above. Starts with <code class="text-primary">ssh-rsa</code>, <code class="text-primary">ssh-ed25519</code>, or <code class="text-primary">ecdsa-sha2</code>.
</div>
</div>
</VExpandTransition>
</div>
</VCardText>
</VCard>
</VExpandTransition>
<!-- Payment Gateway -->
<VCard :ref="el => sectionRefs[paymentStepIndex] = el" class="mb-6" elevation="2" role="region" aria-labelledby="payment-method-heading">
<VCardTitle id="payment-method-heading" class="text-h6">Payment Method</VCardTitle>
<VCardText>
<VRadioGroup v-model="selectedGateway" hide-details>
<VRadio value="stripe">
<template #label>
<div class="d-flex align-center">
<VIcon icon="mdi-credit-card-outline" size="20" class="me-2" />
Credit / Debit Card (Stripe)
</div>
</template>
</VRadio>
<VRadio value="paypal">
<template #label>
<div class="d-flex align-center">
<VIcon icon="mdi-paypal" size="20" class="me-2" />
PayPal
</div>
</template>
</VRadio>
</VRadioGroup>
<!-- Saved Payment Methods (Stripe) -->
<div v-if="selectedGateway === 'stripe' && paymentMethods.length > 0" class="mt-4">
<AppSelect
v-model="selectedPaymentMethod"
label="Select Card"
:items="paymentMethods.map(pm => ({
title: `${pm.brand} ending in ${pm.last_four} (${pm.exp_month}/${pm.exp_year})${pm.is_default ? ' - Default' : ''}`,
value: pm.id,
}))"
/>
</div>
<div v-if="selectedGateway === 'stripe' && paymentMethods.length === 0" class="mt-4">
<VAlert type="warning" variant="tonal" density="compact">
<div class="text-body-2">
You have no saved payment methods.
<Link href="/billing/payment-methods" class="text-primary text-decoration-none font-weight-medium">Add one first</Link>.
</div>
</VAlert>
</div>
</VCardText>
</VCard>
<!-- Coupon -->
<VCard class="mb-6" elevation="2">
<VCardTitle class="text-h6">Coupon Code</VCardTitle>
<VCardText>
<div class="d-flex gap-3">
<AppTextField
v-model="couponCode"
placeholder="Enter coupon code"
:disabled="couponApplied"
hide-details
density="comfortable"
class="flex-grow-1"
>
<template #prepend-inner>
<VIcon icon="mdi-ticket-percent-outline" size="20" />
</template>
</AppTextField>
<VBtn
variant="outlined"
color="primary"
:disabled="!couponCode || couponApplied"
@click="applyCoupon"
>
{{ couponApplied ? 'Applied' : 'Apply' }}
</VBtn>
</div>
<div v-if="couponError" class="text-body-2 text-error mt-2">{{ couponError }}</div>
<div v-if="couponApplied" class="text-body-2 text-success mt-2">
<VIcon icon="mdi-check-circle" size="16" />
Coupon applied successfully!
</div>
</VCardText>
</VCard>
<!-- Errors -->
<VAlert
v-if="form.errors && Object.keys(form.errors).length"
type="error"
variant="tonal"
class="mb-6"
>
<ul class="ps-4 mb-0">
<li v-for="(error, field) in form.errors" :key="field">{{ error }}</li>
</ul>
</VAlert>
<!-- Submit -->
<VBtn
type="submit"
block
size="x-large"
color="primary"
:loading="form.processing"
:disabled="form.processing || (selectedGateway === 'stripe' && !selectedPaymentMethod) || (authMethod === 'ssh' && !!sshKeyError)"
class="checkout-btn"
aria-label="Complete checkout order"
>
<VIcon icon="mdi-lock-outline" start size="20" />
<span v-if="form.processing">Processing Order...</span>
<span v-else>COMPLETE ORDER — {{ priceDisplayText }}</span>
</VBtn>
<div class="text-center text-caption text-medium-emphasis mt-3">
<VIcon icon="mdi-shield-check-outline" size="16" />
Secure checkout powered by Stripe. Your payment information is encrypted.
</div>
</VForm>
</VCol>
</VRow>
</div>
</template>
<style scoped>
/* Progress Stepper */
.stepper-container {
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.03) 0%, rgba(var(--v-theme-surface), 1) 100%);
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 12px rgba(115, 103, 240, 0.08);
animation: fadeInDown 0.5s ease-out;
}
.checkout-progress-stepper {
margin: 0;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Billing Cycle Selection */
.billing-cycle-card {
border-top: 3px solid rgb(var(--v-theme-primary));
background: linear-gradient(180deg, rgba(var(--v-theme-primary), 0.02) 0%, rgba(var(--v-theme-surface), 1) 15%);
animation: fadeIn 0.6s ease-out 0.1s both;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.billing-cycles {
margin: 0;
}
.billing-cycle-option {
position: relative;
padding: 1.5rem 1rem;
border: 2px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 12px;
cursor: pointer;
text-align: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: rgba(var(--v-theme-surface), 1);
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
}
.billing-cycle-option:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
transform: translateY(-4px) scale(1.02);
box-shadow: 0 12px 32px rgba(115, 103, 240, 0.2);
}
.billing-cycle-option--selected {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08) 0%, rgba(var(--v-theme-primary), 0.04) 100%);
box-shadow: 0 4px 16px rgba(115, 103, 240, 0.25);
}
.billing-cycle-option--popular {
border-color: rgba(var(--v-theme-primary), 0.4);
}
.popular-badge {
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
}
.billing-cycle-label {
font-size: 0.875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(var(--v-theme-on-surface), 0.7);
margin-bottom: 0.5rem;
}
.billing-cycle-price {
font-size: 1.5rem;
font-weight: 700;
color: rgb(var(--v-theme-primary));
margin-bottom: 0.25rem;
}
.billing-cycle-per-month {
font-size: 0.875rem;
color: rgba(var(--v-theme-on-surface), 0.6);
margin-bottom: 0.5rem;
}
.billing-cycle-savings {
font-size: 0.75rem;
font-weight: 600;
color: rgb(var(--v-theme-success));
text-transform: uppercase;
letter-spacing: 0.3px;
}
.billing-cycle-no-discount {
font-size: 0.75rem;
color: rgba(var(--v-theme-on-surface), 0.5);
}
.billing-cycle-check {
position: absolute;
top: 0.5rem;
right: 0.5rem;
animation: checkBounce 0.3s ease;
}
/* Sticky Sidebar Wrapper */
.sticky-sidebar-wrapper {
position: sticky;
top: 88px;
align-self: flex-start;
}
/* Card Enhancements */
.summary-card {
background: linear-gradient(135deg, rgba(var(--v-theme-surface), 1) 0%, rgba(var(--v-theme-surface), 0.95) 100%);
box-shadow: 0 4px 20px rgba(115, 103, 240, 0.12) !important;
}
.config-preview {
background: linear-gradient(135deg, rgba(var(--v-theme-surface), 1) 0%, rgba(var(--v-theme-surface), 0.95) 100%);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 4px 16px rgba(115, 103, 240, 0.1) !important;
}
.config-preview .os-icon {
filter: drop-shadow(0 2px 4px rgba(115, 103, 240, 0.3));
}
/* Animated Price Display */
.animated-price {
background: linear-gradient(135deg, rgb(var(--v-theme-primary)) 0%, rgba(var(--v-theme-primary), 0.8) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: priceGlow 2s ease-in-out infinite;
}
@keyframes priceGlow {
0%, 100% { opacity: 1; }
50% { opacity: 0.85; }
}
.server-config-card {
border-top: 3px solid rgb(var(--v-theme-primary));
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.02) 0%, rgba(var(--v-theme-surface), 1) 20%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeIn 0.6s ease-out 0.2s both;
}
.server-config-card:hover {
transform: translateY(-2px);
box-shadow: 0 16px 40px rgba(115, 103, 240, 0.22) !important;
}
/* OS Expansion Panels */
.os-expansion-panels {
border-radius: 12px;
overflow: hidden;
}
.os-group-panel {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
background: rgba(var(--v-theme-surface), 1);
margin-bottom: 0.75rem;
border-radius: 12px !important;
overflow: hidden;
}
.os-group-header {
padding: 1.25rem 1.5rem;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.03) 0%, rgba(var(--v-theme-surface), 1) 100%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.os-group-header:hover {
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.06) 0%, rgba(var(--v-theme-surface), 1) 100%);
}
.os-group-icon {
color: rgb(var(--v-theme-primary));
filter: drop-shadow(0 2px 4px rgba(115, 103, 240, 0.2));
}
.os-group-name {
font-size: 1rem;
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 0.125rem;
}
.os-group-description {
font-size: 0.8rem;
color: rgba(var(--v-theme-on-surface), 0.6);
line-height: 1.3;
}
/* OS Cards Grid */
.os-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem 0 0.5rem;
}
.os-card {
position: relative;
padding: 1.25rem;
border: 2px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: rgba(var(--v-theme-surface), 1);
overflow: hidden;
}
.os-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(var(--v-theme-primary), 0.1), transparent);
transition: left 0.6s ease;
}
.os-card:hover::before {
left: 100%;
}
.os-card:hover {
border-color: rgba(var(--v-theme-primary), 0.5);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(115, 103, 240, 0.15);
}
.os-card--selected {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.08) 0%, rgba(var(--v-theme-primary), 0.04) 100%);
box-shadow: 0 4px 16px rgba(115, 103, 240, 0.2);
}
.os-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
margin: 0 auto 1rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1) 0%, rgba(var(--v-theme-primary), 0.05) 100%);
color: rgb(var(--v-theme-primary));
transition: all 0.3s ease;
}
.os-card:hover .os-card__icon {
transform: scale(1.1);
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.15) 0%, rgba(var(--v-theme-primary), 0.08) 100%);
}
.os-card--selected .os-card__icon {
background: linear-gradient(135deg, rgb(var(--v-theme-primary)) 0%, rgba(var(--v-theme-primary), 0.8) 100%);
color: white;
box-shadow: 0 4px 12px rgba(115, 103, 240, 0.4);
}
.os-card__content {
text-align: center;
}
.os-card__name {
font-weight: 600;
font-size: 0.9rem;
margin-bottom: 0.25rem;
color: rgb(var(--v-theme-on-surface));
}
.os-card__description {
font-size: 0.75rem;
color: rgba(var(--v-theme-on-surface), 0.6);
line-height: 1.4;
}
.os-card__check {
position: absolute;
top: 0.5rem;
right: 0.5rem;
animation: checkBounce 0.3s ease;
}
@keyframes checkBounce {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Auth Toggle */
.auth-toggle {
width: 100%;
}
.auth-toggle :deep(.v-btn) {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 500;
}
/* SSH Section */
.ssh-section code {
padding: 2px 6px;
border-radius: 4px;
background: rgba(var(--v-theme-primary), 0.1);
font-size: 0.85em;
}
.ssh-key-input :deep(textarea) {
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
letter-spacing: -0.3px;
}
/* Server Spec Cards */
.spec-grid {
margin: 0;
}
.spec-card {
position: relative;
text-align: center;
padding: 1.25rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(var(--v-theme-primary), 0.05) 0%, rgba(var(--v-theme-surface), 1) 100%);
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.spec-card::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(var(--v-theme-primary), 0.15) 0%, transparent 70%);
transform: translate(-50%, -50%);
transition: width 0.4s ease, height 0.4s ease;
}
.spec-card:hover::after {
width: 200%;
height: 200%;
}
.spec-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(115, 103, 240, 0.15);
border-color: rgba(var(--v-theme-primary), 0.4);
}
.spec-icon {
filter: drop-shadow(0 2px 4px rgba(115, 103, 240, 0.2));
}
.spec-label {
font-size: 0.75rem;
color: rgba(var(--v-theme-on-surface), 0.6);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.25rem;
}
.spec-value {
font-size: 0.95rem;
font-weight: 700;
color: rgb(var(--v-theme-on-surface));
}
.spec-card-inline {
display: flex;
align-items: center;
padding: 1rem;
border-radius: 10px;
background: rgba(var(--v-theme-surface), 0.5);
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
transition: all 0.2s ease;
}
.spec-card-inline:hover {
background: rgba(var(--v-theme-primary), 0.03);
border-color: rgba(var(--v-theme-primary), 0.2);
}
.spec-label-inline {
font-size: 0.75rem;
color: rgba(var(--v-theme-on-surface), 0.6);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.spec-value-inline {
font-size: 0.875rem;
font-weight: 600;
color: rgb(var(--v-theme-on-surface));
}
.ip-cost-badge {
animation: fadeInScale 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Checkout Button */
.checkout-btn {
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
background: linear-gradient(135deg, rgb(var(--v-theme-primary)) 0%, rgba(var(--v-theme-primary), 0.85) 100%);
box-shadow: 0 6px 20px rgba(115, 103, 240, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.checkout-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
transition: left 0.5s;
}
.checkout-btn:hover::before {
left: 100%;
}
.checkout-btn:hover {
box-shadow: 0 8px 28px rgba(115, 103, 240, 0.5);
transform: translateY(-3px);
}
.checkout-btn:active {
transform: translateY(0);
box-shadow: 0 4px 16px rgba(115, 103, 240, 0.35);
}
/* Smooth transitions */
.v-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.v-expand-transition-enter-active,
.v-expand-transition-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Responsive adjustments */
@media (max-width: 1280px) {
/* Disable sticky on tablets and mobile */
.sticky-sidebar-wrapper {
position: static;
}
}
@media (max-width: 960px) {
.stepper-container {
padding: 1rem;
}
.os-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}
.billing-cycle-option {
min-height: 140px;
padding: 1rem 0.75rem;
}
.server-config-card {
padding: 1rem;
}
}
@media (max-width: 600px) {
.stepper-container {
padding: 0.75rem;
}
.os-grid {
grid-template-columns: 1fr 1fr;
}
.os-card {
padding: 1rem;
}
.os-card__icon {
width: 48px;
height: 48px;
}
.billing-cycle-option {
min-height: 130px;
padding: 0.75rem 0.5rem;
}
.billing-cycle-price {
font-size: 1.25rem;
}
.spec-card {
padding: 1rem;
}
.summary-card,
.config-preview {
margin-bottom: 1rem;
}
}
/* Ensure z-index layering for spec cards */
.spec-card {
isolation: isolate;
}
.spec-card > * {
position: relative;
z-index: 1;
}
</style>