Add plan upgrade/downgrade, order management, impersonation, and contact form

- Plan upgrade/downgrade flow: UpgradeController with price difference
  calculations, Upgrade.vue with feature comparison and confirmation dialog
- Admin order management: Order model/migration/factory, OrderController
  with process/complete/cancel/notes, Index and Show pages with filters
- Admin impersonation: start/stop endpoints, session-based tracking,
  impersonation banner in AccountLayout, audit logging
- Contact form: ContactRequest validation, ContactController with email,
  marketing route for form submission
- FlashMessages now supports info alerts
- Inertia shared data includes impersonation state
- 114 tests passing (623 assertions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 13:55:27 -05:00
parent 89fac519c3
commit 9603803928
24 changed files with 1911 additions and 13 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { Link, router, useForm } from '@inertiajs/vue3'
import { ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
@@ -230,6 +230,15 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
</div>
<div class="d-flex align-center ga-2">
<VBtn
color="info"
variant="tonal"
size="small"
@click="router.post(`/impersonate/${customer.id}`)"
>
<VIcon icon="tabler-user-shield" start />
Impersonate
</VBtn>
<VBtn
v-if="customer.status !== 'suspended'"
color="warning"

View File

@@ -0,0 +1,225 @@
<script lang="ts" setup>
import { Link, router } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { formatPrice } from '@/utils/resolvers'
import type { PaginatedResponse, StatusColor } from '@/types'
interface OrderUser {
id: number
name: string
email: string
}
interface OrderPlan {
id: number
name: string
service_type: string
price: string
billing_cycle: string
}
interface OrderItem {
id: number
user_id: number
plan_id: number
order_number: string
status: string
total: string
currency: string
payment_gateway: string | null
created_at: string
user: OrderUser | null
plan: OrderPlan | null
}
interface Filters {
search: string
status: string
}
interface Props {
orders: PaginatedResponse<OrderItem>
filters: Filters
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const search = ref<string>(props.filters.search)
const status = ref<string>(props.filters.status)
const statusOptions = [
{ title: 'All Statuses', value: '' },
{ title: 'Pending', value: 'pending' },
{ title: 'Processing', value: 'processing' },
{ title: 'Completed', value: 'completed' },
{ title: 'Cancelled', value: 'cancelled' },
{ title: 'Failed', value: 'failed' },
]
let searchTimeout: ReturnType<typeof setTimeout> | null = null
function applyFilters(): void {
router.get('/orders', {
search: search.value || undefined,
status: status.value || undefined,
}, {
preserveState: true,
preserveScroll: true,
})
}
watch(search, () => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(applyFilters, 300)
})
watch(status, () => {
applyFilters()
})
function resolveOrderStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
pending: 'warning',
processing: 'info',
completed: 'success',
cancelled: 'error',
failed: 'error',
}
return map[statusVal] ?? 'secondary'
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
}
</script>
<template>
<div>
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="text-h4 font-weight-bold">
Orders
</div>
<div class="text-body-2 text-medium-emphasis">
Manage all customer orders
</div>
</div>
</div>
<!-- Filters -->
<VCard class="mb-6">
<VCardText>
<VRow>
<VCol cols="12" md="8">
<VTextField
v-model="search"
prepend-inner-icon="tabler-search"
placeholder="Search by order number, customer name, or email..."
density="compact"
clearable
hide-details
@click:clear="search = ''"
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="status"
:items="statusOptions"
density="compact"
hide-details
label="Status"
/>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Orders Table -->
<VCard>
<VCardText v-if="orders.data.length === 0" class="text-center py-12">
<VIcon icon="tabler-shopping-cart-off" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No orders found.
</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Order #</th>
<th>Customer</th>
<th>Plan</th>
<th class="text-end">
Total
</th>
<th>Status</th>
<th>Gateway</th>
<th>Created</th>
<th class="text-center">
Actions
</th>
</tr>
</thead>
<tbody>
<tr v-for="order in orders.data" :key="order.id">
<td class="text-body-2 font-weight-medium">
{{ order.order_number }}
</td>
<td>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ order.user?.name ?? 'Unknown' }}</span>
<span class="text-caption text-medium-emphasis">{{ order.user?.email ?? '' }}</span>
</div>
</td>
<td class="text-body-2">
{{ order.plan?.name ?? 'N/A' }}
</td>
<td class="text-end text-body-2 font-weight-medium">
{{ formatPrice(order.total) }}
</td>
<td>
<VChip
:color="resolveOrderStatusColor(order.status)"
size="small"
class="text-capitalize"
>
{{ order.status }}
</VChip>
</td>
<td class="text-body-2 text-capitalize">
{{ order.payment_gateway ?? '---' }}
</td>
<td class="text-body-2">
{{ formatDate(order.created_at) }}
</td>
<td class="text-center">
<Link :href="`/orders/${order.id}`">
<VBtn variant="text" size="small" color="primary">
<VIcon icon="tabler-eye" size="18" />
</VBtn>
</Link>
</td>
</tr>
</tbody>
</VTable>
<!-- Pagination -->
<VCardText v-if="orders.last_page > 1" class="d-flex align-center justify-center pt-2">
<VPagination
:model-value="orders.data.length > 0 ? Math.ceil((orders.from ?? 1) / 25) : 1"
:length="orders.last_page"
:total-visible="7"
@update:model-value="(page: number) => router.get('/orders', { ...props.filters, page }, { preserveState: true, preserveScroll: true })"
/>
</VCardText>
<VCardText v-if="orders.total > 0" class="text-center text-caption text-medium-emphasis">
Showing {{ orders.from }} to {{ orders.to }} of {{ orders.total }} orders
</VCardText>
</VCard>
</div>
</template>

View File

@@ -0,0 +1,520 @@
<script lang="ts" setup>
import { Link, useForm } from '@inertiajs/vue3'
import { computed, ref } from 'vue'
import AdminLayout from '@/Layouts/AdminLayout.vue'
import AppTextarea from '@/Components/app-form-elements/AppTextarea.vue'
import { formatPrice } from '@/utils/resolvers'
import type { StatusColor } from '@/types'
interface OrderUser {
id: number
name: string
email: string
status: string
}
interface OrderPlan {
id: number
name: string
service_type: string
price: string
billing_cycle: string
}
interface OrderInvoice {
id: number
number: string
total: string
status: string
}
interface OrderService {
id: number
hostname: string | null
status: string
ipv4_address: string | null
}
interface OrderDetail {
id: number
user_id: number
plan_id: number
invoice_id: number | null
service_id: number | null
order_number: string
status: string
total: string
currency: string
payment_gateway: string | null
configuration: Record<string, string> | null
admin_notes: string | null
completed_at: string | null
cancelled_at: string | null
created_at: string
updated_at: string
user: OrderUser | null
plan: OrderPlan | null
invoice: OrderInvoice | null
service: OrderService | null
}
interface Props {
order: OrderDetail
}
defineOptions({ layout: AdminLayout })
const props = defineProps<Props>()
const confirmDialog = ref<boolean>(false)
const confirmAction = ref<'process' | 'complete' | 'cancel'>('process')
const confirmTitle = ref<string>('')
const confirmMessage = ref<string>('')
const confirmColor = ref<string>('info')
const processForm = useForm({})
const completeForm = useForm({})
const cancelForm = useForm({})
const notesForm = useForm({
admin_notes: props.order.admin_notes ?? '',
})
const isProcessing = computed<boolean>(() =>
processForm.processing || completeForm.processing || cancelForm.processing,
)
function openConfirmDialog(action: 'process' | 'complete' | 'cancel'): void {
confirmAction.value = action
if (action === 'process') {
confirmTitle.value = 'Process Order'
confirmMessage.value = `Are you sure you want to mark order ${props.order.order_number} as processing?`
confirmColor.value = 'info'
}
else if (action === 'complete') {
confirmTitle.value = 'Complete Order'
confirmMessage.value = `Are you sure you want to mark order ${props.order.order_number} as completed?`
confirmColor.value = 'success'
}
else {
confirmTitle.value = 'Cancel Order'
confirmMessage.value = `Are you sure you want to cancel order ${props.order.order_number}? This action cannot be undone.`
confirmColor.value = 'error'
}
confirmDialog.value = true
}
function executeAction(): void {
const action = confirmAction.value
if (action === 'process') {
processForm.post(`/orders/${props.order.id}/process`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
}
else if (action === 'complete') {
completeForm.post(`/orders/${props.order.id}/complete`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
}
else {
cancelForm.post(`/orders/${props.order.id}/cancel`, {
preserveScroll: true,
onSuccess: () => { confirmDialog.value = false },
})
}
}
function saveNotes(): void {
notesForm.put(`/orders/${props.order.id}/notes`, {
preserveScroll: true,
})
}
function resolveOrderStatusColor(statusVal: string): StatusColor {
const map: Record<string, StatusColor> = {
pending: 'warning',
processing: 'info',
completed: 'success',
cancelled: 'error',
failed: 'error',
}
return map[statusVal] ?? 'secondary'
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
}
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '---'
const date = new Date(dateStr)
return date.toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatServiceType(type: string): string {
const map: Record<string, string> = {
vps: 'VPS',
dedicated: 'Dedicated',
web_hosting: 'Web Hosting',
hosting: 'Web Hosting',
game: 'Game Hosting',
game_server: 'Game Hosting',
}
return map[type] ?? type
}
function configurationEntries(): Array<{ key: string; value: string }> {
if (!props.order.configuration) return []
return Object.entries(props.order.configuration).map(([key, value]) => ({
key: key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
value: String(value),
}))
}
</script>
<template>
<div>
<!-- Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div class="d-flex align-center gap-4">
<Link href="/orders">
<VBtn variant="text" icon="tabler-arrow-left" size="small" />
</Link>
<div>
<div class="d-flex align-center gap-2">
<span class="text-h4 font-weight-bold">Order {{ order.order_number }}</span>
<VChip
:color="resolveOrderStatusColor(order.status)"
size="small"
class="text-capitalize"
>
{{ order.status }}
</VChip>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ order.user?.name ?? 'Unknown Customer' }} &middot; {{ order.user?.email ?? '' }}
</div>
</div>
</div>
<div class="d-flex gap-2">
<VBtn
v-if="order.status === 'pending'"
color="info"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('process')"
>
<VIcon icon="tabler-player-play" start />
Process
</VBtn>
<VBtn
v-if="order.status === 'processing'"
color="success"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('complete')"
>
<VIcon icon="tabler-check" start />
Complete
</VBtn>
<VBtn
v-if="order.status === 'pending' || order.status === 'processing'"
color="error"
variant="tonal"
:disabled="isProcessing"
@click="openConfirmDialog('cancel')"
>
<VIcon icon="tabler-x" start />
Cancel
</VBtn>
</div>
</div>
<VRow>
<!-- Order Details -->
<VCol cols="12" lg="6">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-shopping-cart" size="22" />
<span>Order Details</span>
</VCardTitle>
<VCardText>
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Order Number</span>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ order.order_number }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Total</span>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ formatPrice(order.total) }}
<span class="text-uppercase text-medium-emphasis ms-1">{{ order.currency }}</span>
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Gateway</span>
</template>
<VListItemTitle class="text-body-2 text-capitalize">
{{ order.payment_gateway ?? 'N/A' }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Created</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatDateTime(order.created_at) }}
</VListItemTitle>
</VListItem>
<VListItem v-if="order.completed_at">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Completed</span>
</template>
<VListItemTitle class="text-body-2 text-success">
{{ formatDateTime(order.completed_at) }}
</VListItemTitle>
</VListItem>
<VListItem v-if="order.cancelled_at">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Cancelled</span>
</template>
<VListItemTitle class="text-body-2 text-error">
{{ formatDateTime(order.cancelled_at) }}
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
</VCard>
<!-- Customer Card -->
<VCard class="mt-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-user" size="22" />
<span>Customer</span>
</VCardTitle>
<VCardText v-if="order.user">
<div class="d-flex align-center gap-3">
<VAvatar color="primary" variant="tonal" size="40">
<span class="text-body-1 font-weight-semibold">
{{ order.user.name.charAt(0).toUpperCase() }}
</span>
</VAvatar>
<div>
<div class="text-body-1 font-weight-medium">
{{ order.user.name }}
</div>
<div class="text-body-2 text-medium-emphasis">
{{ order.user.email }}
</div>
</div>
<VSpacer />
<Link :href="`/customers/${order.user.id}`">
<VBtn variant="tonal" size="small" color="primary">
View Customer
</VBtn>
</Link>
</div>
</VCardText>
</VCard>
<!-- Related Resources -->
<VCard v-if="order.invoice || order.service" class="mt-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-link" size="22" />
<span>Related Resources</span>
</VCardTitle>
<VCardText>
<VList density="compact" class="pa-0">
<VListItem v-if="order.invoice">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Invoice</span>
</template>
<VListItemTitle>
<Link :href="`/invoices/${order.invoice.id}`" class="text-body-2 font-weight-medium text-primary text-decoration-none">
{{ order.invoice.number }}
</Link>
</VListItemTitle>
</VListItem>
<VListItem v-if="order.service">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Service</span>
</template>
<VListItemTitle>
<Link :href="`/services/${order.service.id}`" class="text-body-2 font-weight-medium text-primary text-decoration-none">
Service #{{ order.service.id }}
</Link>
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
</VCard>
</VCol>
<!-- Plan & Configuration -->
<VCol cols="12" lg="6">
<!-- Plan Info -->
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-package" size="22" />
<span>Plan</span>
</VCardTitle>
<VCardText v-if="order.plan">
<VList density="compact" class="pa-0">
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Name</span>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ order.plan.name }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Service Type</span>
</template>
<VListItemTitle class="text-body-2">
{{ formatServiceType(order.plan.service_type) }}
</VListItemTitle>
</VListItem>
<VListItem>
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">Price</span>
</template>
<VListItemTitle class="text-body-2 font-weight-medium">
{{ formatPrice(order.plan.price, order.plan.billing_cycle) }}
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
<VCardText v-else class="text-center py-6">
<div class="text-medium-emphasis">
Plan information unavailable.
</div>
</VCardText>
</VCard>
<!-- Configuration -->
<VCard class="mt-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-settings" size="22" />
<span>Configuration</span>
</VCardTitle>
<VCardText v-if="configurationEntries().length > 0">
<VList density="compact" class="pa-0">
<VListItem v-for="entry in configurationEntries()" :key="entry.key">
<template #prepend>
<span class="text-body-2 text-medium-emphasis" style="min-width: 140px;">{{ entry.key }}</span>
</template>
<VListItemTitle class="text-body-2">
{{ entry.value }}
</VListItemTitle>
</VListItem>
</VList>
</VCardText>
<VCardText v-else class="text-center py-6">
<VIcon icon="tabler-inbox" size="36" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">
No configuration data.
</div>
</VCardText>
</VCard>
<!-- Admin Notes -->
<VCard class="mt-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-notes" size="22" />
<span>Admin Notes</span>
</VCardTitle>
<VCardText>
<AppTextarea
v-model="notesForm.admin_notes"
placeholder="Add internal notes about this order..."
rows="4"
:error-messages="notesForm.errors.admin_notes"
counter="1000"
maxlength="1000"
/>
<div class="d-flex justify-end mt-3">
<VBtn
color="primary"
variant="tonal"
:loading="notesForm.processing"
:disabled="notesForm.processing"
@click="saveNotes"
>
<VIcon icon="tabler-device-floppy" start />
Save Notes
</VBtn>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<!-- Confirmation Dialog -->
<VDialog v-model="confirmDialog" max-width="500" persistent>
<VCard>
<VCardTitle class="text-h5 pa-5">
{{ confirmTitle }}
</VCardTitle>
<VCardText class="px-5 pb-2">
{{ confirmMessage }}
</VCardText>
<VCardActions class="pa-5">
<VSpacer />
<VBtn variant="text" :disabled="isProcessing" @click="confirmDialog = false">
Cancel
</VBtn>
<VBtn
:color="confirmColor"
variant="flat"
:loading="isProcessing"
@click="executeAction"
>
Confirm
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>

View File

@@ -83,18 +83,36 @@ const platformLabel = computed<string>(() => {
Managed by {{ platformLabel }}
</div>
</div>
<VBtn
v-if="controlPanelUrl && !isTerminated"
:href="controlPanelUrl"
target="_blank"
rel="noopener noreferrer"
>
<VIcon
icon="tabler-external-link"
start
/>
Open Control Panel
</VBtn>
<div class="d-flex ga-3">
<Link
v-if="service.status === 'active' && service.plan"
:href="`/services/${service.id}/upgrade`"
class="text-decoration-none"
>
<VBtn
color="primary"
variant="tonal"
>
<VIcon
icon="tabler-arrows-exchange"
start
/>
Upgrade / Downgrade
</VBtn>
</Link>
<VBtn
v-if="controlPanelUrl && !isTerminated"
:href="controlPanelUrl"
target="_blank"
rel="noopener noreferrer"
>
<VIcon
icon="tabler-external-link"
start
/>
Open Control Panel
</VBtn>
</div>
</div>
<!-- Suspended Notice -->
@@ -334,6 +352,24 @@ const platformLabel = computed<string>(() => {
<VCardTitle>Quick Actions</VCardTitle>
<VCardText>
<div class="d-flex flex-column ga-3">
<Link
v-if="service.status === 'active' && service.plan"
:href="`/services/${service.id}/upgrade`"
class="text-decoration-none"
>
<VBtn
block
variant="tonal"
color="primary"
>
<VIcon
icon="tabler-arrows-exchange"
start
/>
Upgrade / Downgrade
</VBtn>
</Link>
<VBtn
v-if="controlPanelUrl"
:href="controlPanelUrl"

View File

@@ -0,0 +1,468 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { Link, useForm } from '@inertiajs/vue3'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import { formatPrice } from '@/utils/resolvers'
import type { Plan } from '@/types'
interface AvailablePlan extends Plan {
price_difference: number
is_upgrade: boolean
}
interface ServiceData {
id: number
hostname: string | null
ipv4_address: string | null
domain: string | null
status: string
service_type: string
plan: Plan
}
interface Props {
service: ServiceData
currentPlan: Plan
availablePlans: AvailablePlan[]
}
defineOptions({ layout: AccountLayout })
const props = defineProps<Props>()
const showConfirmDialog = ref<boolean>(false)
const selectedPlan = ref<AvailablePlan | null>(null)
const form = useForm({
plan_id: 0,
})
const upgradePlans = computed<AvailablePlan[]>(() =>
props.availablePlans.filter(plan => plan.is_upgrade),
)
const downgradePlans = computed<AvailablePlan[]>(() =>
props.availablePlans.filter(plan => !plan.is_upgrade),
)
const serviceLabel = computed<string>(() =>
props.service.hostname || props.service.domain || `Service #${props.service.id}`,
)
const confirmActionLabel = computed<string>(() => {
if (!selectedPlan.value) return ''
return selectedPlan.value.is_upgrade ? 'Upgrade' : 'Downgrade'
})
const confirmActionColor = computed<string>(() => {
if (!selectedPlan.value) return 'primary'
return selectedPlan.value.is_upgrade ? 'success' : 'warning'
})
function formatPriceDifference(difference: number): string {
const abs = Math.abs(difference).toFixed(2)
if (difference > 0) return `+$${abs}`
if (difference < 0) return `-$${abs}`
return '$0.00'
}
function openConfirmDialog(plan: AvailablePlan): void {
selectedPlan.value = plan
form.plan_id = plan.id
showConfirmDialog.value = true
}
function submitUpgrade(): void {
form.post(`/services/${props.service.id}/upgrade`, {
onSuccess: () => {
showConfirmDialog.value = false
selectedPlan.value = null
},
})
}
</script>
<template>
<div>
<div class="mb-4">
<Link
:href="`/services/${service.id}`"
class="text-primary text-body-2 text-decoration-none"
>
&larr; Back to Service
</Link>
</div>
<!-- Page Header -->
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="text-h4 font-weight-bold">
Upgrade / Downgrade
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ serviceLabel }} &mdash; Currently on <strong>{{ currentPlan.name }}</strong>
</div>
</div>
</div>
<!-- Current Plan -->
<VCard class="mb-6">
<VCardTitle class="d-flex align-center ga-2">
<VIcon
icon="tabler-star"
color="primary"
size="20"
/>
Current Plan
</VCardTitle>
<VCardText>
<VRow>
<VCol
cols="12"
sm="4"
>
<div class="text-body-2 text-medium-emphasis">
Plan
</div>
<div class="text-h6 font-weight-bold mt-1">
{{ currentPlan.name }}
</div>
</VCol>
<VCol
cols="12"
sm="4"
>
<div class="text-body-2 text-medium-emphasis">
Price
</div>
<div class="text-h6 font-weight-bold mt-1">
{{ formatPrice(currentPlan.price, currentPlan.billing_cycle) }}
</div>
</VCol>
<VCol
cols="12"
sm="4"
>
<div class="text-body-2 text-medium-emphasis">
Service Type
</div>
<div class="text-body-1 text-capitalize mt-1">
{{ currentPlan.service_type }}
</div>
</VCol>
</VRow>
<div
v-if="currentPlan.features && Object.keys(currentPlan.features).length > 0"
class="mt-4"
>
<div class="text-body-2 text-medium-emphasis mb-2">
Features
</div>
<VList density="compact">
<VListItem
v-for="(value, key) in currentPlan.features"
:key="String(key)"
>
<template #prepend>
<VIcon
icon="tabler-check"
color="success"
size="18"
/>
</template>
<VListItemTitle class="text-body-2">
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
{{ value }}
</VListItemTitle>
</VListItem>
</VList>
</div>
</VCardText>
</VCard>
<!-- No Plans Available -->
<VCard v-if="availablePlans.length === 0">
<VCardText class="text-center py-12">
<VIcon
icon="tabler-arrows-exchange"
size="48"
class="text-medium-emphasis mb-4"
/>
<div class="text-h6 text-medium-emphasis mb-2">
No alternative plans available
</div>
<div class="text-body-2 text-medium-emphasis">
There are no other plans available for this service type.
</div>
</VCardText>
</VCard>
<!-- Upgrade Plans -->
<div v-if="upgradePlans.length > 0">
<div class="text-h5 font-weight-bold mb-4 d-flex align-center ga-2">
<VIcon
icon="tabler-arrow-up"
color="success"
size="24"
/>
Upgrade Options
</div>
<VRow class="mb-6">
<VCol
v-for="plan in upgradePlans"
:key="plan.id"
cols="12"
sm="6"
lg="4"
>
<VCard
class="h-100"
border
>
<VCardText>
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-h6 font-weight-bold">
{{ plan.name }}
</div>
<VChip
color="success"
size="small"
>
{{ formatPriceDifference(plan.price_difference) }}/{{ currentPlan.billing_cycle }}
</VChip>
</div>
<div class="text-h5 font-weight-bold mb-4">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</div>
<div
v-if="plan.description"
class="text-body-2 text-medium-emphasis mb-4"
>
{{ plan.description }}
</div>
<!-- Feature Comparison -->
<div
v-if="plan.features && Object.keys(plan.features).length > 0"
class="mb-4"
>
<VList density="compact">
<VListItem
v-for="(value, key) in plan.features"
:key="String(key)"
>
<template #prepend>
<VIcon
icon="tabler-check"
color="success"
size="16"
/>
</template>
<VListItemTitle class="text-body-2">
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
{{ value }}
<template v-if="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value">
<span class="text-medium-emphasis text-caption ml-1">(currently: {{ currentPlan.features[String(key)] }})</span>
</template>
</VListItemTitle>
</VListItem>
</VList>
</div>
<VBtn
color="success"
block
@click="openConfirmDialog(plan)"
>
<VIcon
icon="tabler-arrow-up"
start
/>
Upgrade
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
<!-- Downgrade Plans -->
<div v-if="downgradePlans.length > 0">
<div class="text-h5 font-weight-bold mb-4 d-flex align-center ga-2">
<VIcon
icon="tabler-arrow-down"
color="warning"
size="24"
/>
Downgrade Options
</div>
<VRow class="mb-6">
<VCol
v-for="plan in downgradePlans"
:key="plan.id"
cols="12"
sm="6"
lg="4"
>
<VCard
class="h-100"
border
>
<VCardText>
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-h6 font-weight-bold">
{{ plan.name }}
</div>
<VChip
color="warning"
size="small"
>
{{ formatPriceDifference(plan.price_difference) }}/{{ currentPlan.billing_cycle }}
</VChip>
</div>
<div class="text-h5 font-weight-bold mb-4">
{{ formatPrice(plan.price, plan.billing_cycle) }}
</div>
<div
v-if="plan.description"
class="text-body-2 text-medium-emphasis mb-4"
>
{{ plan.description }}
</div>
<!-- Feature Comparison -->
<div
v-if="plan.features && Object.keys(plan.features).length > 0"
class="mb-4"
>
<VList density="compact">
<VListItem
v-for="(value, key) in plan.features"
:key="String(key)"
>
<template #prepend>
<VIcon
icon="tabler-check"
:color="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value ? 'warning' : 'success'"
size="16"
/>
</template>
<VListItemTitle class="text-body-2">
<span class="font-weight-medium text-capitalize">{{ String(key).replace(/_/g, ' ') }}:</span>
{{ value }}
<template v-if="currentPlan.features && currentPlan.features[String(key)] && currentPlan.features[String(key)] !== value">
<span class="text-medium-emphasis text-caption ml-1">(currently: {{ currentPlan.features[String(key)] }})</span>
</template>
</VListItemTitle>
</VListItem>
</VList>
</div>
<VBtn
color="warning"
block
@click="openConfirmDialog(plan)"
>
<VIcon
icon="tabler-arrow-down"
start
/>
Downgrade
</VBtn>
</VCardText>
</VCard>
</VCol>
</VRow>
</div>
<!-- Confirmation Dialog -->
<VDialog
v-model="showConfirmDialog"
max-width="520"
>
<VCard v-if="selectedPlan">
<VCardTitle class="text-h5 pa-6 pb-2">
Confirm {{ confirmActionLabel }}
</VCardTitle>
<VCardText class="pa-6 pt-2">
<p class="text-body-1 mb-4">
Are you sure you want to {{ confirmActionLabel.toLowerCase() }} your service from
<strong>{{ currentPlan.name }}</strong> to <strong>{{ selectedPlan.name }}</strong>?
</p>
<VCard
variant="tonal"
:color="confirmActionColor"
class="mb-4"
>
<VCardText>
<div class="d-flex justify-space-between align-center">
<div>
<div class="text-body-2 text-medium-emphasis">
Price Change
</div>
<div class="text-h6 font-weight-bold">
{{ formatPriceDifference(selectedPlan.price_difference) }}/{{ currentPlan.billing_cycle }}
</div>
</div>
<div class="text-end">
<div class="text-body-2 text-medium-emphasis">
New Price
</div>
<div class="text-h6 font-weight-bold">
{{ formatPrice(selectedPlan.price, selectedPlan.billing_cycle) }}
</div>
</div>
</div>
</VCardText>
</VCard>
<VAlert
v-if="selectedPlan.is_upgrade"
type="info"
variant="tonal"
density="compact"
class="mb-0"
>
An invoice for the price difference will be generated.
</VAlert>
<VAlert
v-else
type="info"
variant="tonal"
density="compact"
class="mb-0"
>
A credit memo for the price difference will be applied to your account.
</VAlert>
</VCardText>
<VCardActions class="pa-6 pt-0">
<VSpacer />
<VBtn
variant="tonal"
:disabled="form.processing"
@click="showConfirmDialog = false"
>
Cancel
</VBtn>
<VBtn
:color="confirmActionColor"
:loading="form.processing"
@click="submitUpgrade"
>
<VIcon
:icon="selectedPlan.is_upgrade ? 'tabler-arrow-up' : 'tabler-arrow-down'"
start
/>
Confirm {{ confirmActionLabel }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</div>
</template>