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>
1481 lines
48 KiB
Vue
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">← 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>
|