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

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Invoice;
use App\Models\Plan;
use App\Models\Service;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class UpgradeController extends Controller
{
public function show(Request $request, Service $service): Response
{
abort_unless($service->user_id === $request->user()->id, 403);
abort_unless($service->isActive(), 403, 'Only active services can be upgraded or downgraded.');
$service->load('plan');
$currentPlan = $service->plan;
$availablePlans = Plan::query()
->where('service_type', $currentPlan->service_type)
->where('status', 'active')
->where('id', '!=', $currentPlan->id)
->where(function ($query) {
$query->whereNull('stock_quantity')
->orWhere('stock_quantity', '>', 0);
})
->orderBy('price')
->get()
->map(function (Plan $plan) use ($currentPlan): array {
$currentPrice = (float) $currentPlan->price;
$newPrice = (float) $plan->price;
$priceDifference = round($newPrice - $currentPrice, 2);
return [
...$plan->toArray(),
'price_difference' => $priceDifference,
'is_upgrade' => $priceDifference > 0,
];
});
return Inertia::render('Services/Upgrade', [
'service' => $service,
'currentPlan' => $currentPlan,
'availablePlans' => $availablePlans,
]);
}
public function store(Request $request, Service $service): RedirectResponse
{
abort_unless($service->user_id === $request->user()->id, 403);
abort_unless($service->isActive(), 403, 'Only active services can be upgraded or downgraded.');
$validated = $request->validate([
'plan_id' => ['required', 'integer', 'exists:plans,id'],
]);
$service->load('plan');
$currentPlan = $service->plan;
$newPlan = Plan::findOrFail($validated['plan_id']);
abort_unless($newPlan->service_type === $currentPlan->service_type, 422, 'Cannot switch to a plan of a different service type.');
abort_unless($newPlan->isAvailable(), 422, 'The selected plan is not available.');
abort_if($newPlan->id === $currentPlan->id, 422, 'You are already on this plan.');
$currentPrice = (float) $currentPlan->price;
$newPrice = (float) $newPlan->price;
$priceDifference = round($newPrice - $currentPrice, 2);
$isUpgrade = $priceDifference > 0;
DB::transaction(function () use ($service, $currentPlan, $newPlan, $priceDifference, $isUpgrade, $request): void {
$oldPlanId = $service->plan_id;
$service->update([
'plan_id' => $newPlan->id,
]);
if ($priceDifference !== 0.0) {
$invoiceNumber = 'INV-'.strtoupper(uniqid());
$invoiceStatus = $isUpgrade ? 'pending' : 'paid';
$invoiceTotal = abs($priceDifference);
$description = $isUpgrade
? "Upgrade from {$currentPlan->name} to {$newPlan->name}"
: "Credit: Downgrade from {$currentPlan->name} to {$newPlan->name}";
$invoice = Invoice::create([
'user_id' => $request->user()->id,
'subscription_id' => $service->subscription_id,
'gateway' => 'internal',
'number' => $invoiceNumber,
'total' => $isUpgrade ? $invoiceTotal : -$invoiceTotal,
'tax' => 0,
'currency' => 'USD',
'status' => $invoiceStatus,
'due_date' => $isUpgrade ? now()->addDays(7) : null,
'paid_at' => $isUpgrade ? null : now(),
]);
$invoice->items()->create([
'description' => $description,
'amount' => $isUpgrade ? $invoiceTotal : -$invoiceTotal,
'quantity' => 1,
]);
}
AuditLog::create([
'user_id' => $request->user()->id,
'action' => $isUpgrade ? 'service.upgrade' : 'service.downgrade',
'resource_type' => 'service',
'resource_id' => $service->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'old_plan_id' => $oldPlanId,
'new_plan_id' => $newPlan->id,
'old_plan_name' => $currentPlan->name,
'new_plan_name' => $newPlan->name,
'price_difference' => $priceDifference,
],
]);
});
$actionLabel = $isUpgrade ? 'upgraded' : 'downgraded';
return redirect()->route('account.services.show', $service)
->with('success', "Service successfully {$actionLabel} to {$newPlan->name}.");
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ImpersonationController extends Controller
{
public function start(Request $request, User $user): RedirectResponse
{
if ($user->isAdmin()) {
return redirect()
->back()
->with('error', 'Cannot impersonate admin users.');
}
AuditLog::query()->create([
'user_id' => $user->id,
'admin_id' => $request->user()->id,
'action' => 'impersonate_start',
'resource_type' => 'User',
'resource_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'admin' => $request->user()->name,
'customer' => $user->name,
],
]);
$request->session()->put('impersonator_id', $request->user()->id);
Auth::login($user);
return redirect('https://'.config('app.domains.account').'/dashboard')
->with('info', "You are now impersonating {$user->name}.");
}
public function stop(Request $request): RedirectResponse
{
$impersonatorId = $request->session()->get('impersonator_id');
if (! $impersonatorId) {
return redirect()->back();
}
$admin = User::find($impersonatorId);
if (! $admin) {
return redirect()->back()->with('error', 'Original admin user not found.');
}
$request->session()->forget('impersonator_id');
Auth::login($admin);
return redirect('https://'.config('app.domains.admin').'/dashboard')
->with('success', 'Impersonation ended.');
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use App\Models\Order;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OrderController extends Controller
{
public function index(Request $request): Response
{
$query = Order::query()
->with(['user:id,name,email', 'plan:id,name,service_type,price,billing_cycle']);
// Search by order number or customer name/email
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('order_number', 'like', "%{$search}%")
->orWhereHas('user', function ($uq) use ($search): void {
$uq->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
});
}
// Filter by status
if ($status = $request->input('status')) {
$query->where('status', $status);
}
$orders = $query->latest()->paginate(25)->withQueryString();
return Inertia::render('Admin/Orders/Index', [
'orders' => $orders,
'filters' => [
'search' => $request->input('search', ''),
'status' => $request->input('status', ''),
],
]);
}
public function show(Order $order): Response
{
$order->load([
'user:id,name,email,status',
'plan:id,name,service_type,price,billing_cycle',
'invoice:id,number,total,status',
'service:id,hostname,status,ipv4_address',
]);
return Inertia::render('Admin/Orders/Show', [
'order' => $order,
]);
}
public function process(Order $order): RedirectResponse
{
$order->update(['status' => 'processing']);
AuditLog::create([
'user_id' => $order->user_id,
'admin_id' => auth()->id(),
'action' => 'process_order',
'resource_type' => 'order',
'resource_id' => $order->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', "Order {$order->order_number} is now processing.");
}
public function complete(Order $order): RedirectResponse
{
$order->update([
'status' => 'completed',
'completed_at' => now(),
]);
AuditLog::create([
'user_id' => $order->user_id,
'admin_id' => auth()->id(),
'action' => 'complete_order',
'resource_type' => 'order',
'resource_id' => $order->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', "Order {$order->order_number} has been completed.");
}
public function cancel(Order $order): RedirectResponse
{
$order->update([
'status' => 'cancelled',
'cancelled_at' => now(),
]);
AuditLog::create([
'user_id' => $order->user_id,
'admin_id' => auth()->id(),
'action' => 'cancel_order',
'resource_type' => 'order',
'resource_id' => $order->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', "Order {$order->order_number} has been cancelled.");
}
public function updateNotes(Request $request, Order $order): RedirectResponse
{
$validated = $request->validate([
'admin_notes' => ['nullable', 'string', 'max:1000'],
]);
$order->update(['admin_notes' => $validated['admin_notes']]);
AuditLog::create([
'user_id' => $order->user_id,
'admin_id' => auth()->id(),
'action' => 'update_order_notes',
'resource_type' => 'order',
'resource_id' => $order->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', 'Admin notes updated.');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Marketing;
use App\Http\Controllers\Controller;
use App\Http\Requests\ContactRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Mail;
class ContactController extends Controller
{
public function store(ContactRequest $request): RedirectResponse
{
$data = $request->validated();
Mail::raw(
"Name: {$data['name']}\nEmail: {$data['email']}\nSubject: {$data['subject']}\n\n{$data['message']}",
function ($message) use ($data): void {
$message->to(config('mail.from.address'))
->replyTo($data['email'], $data['name'])
->subject("[EZSCALE Contact] {$data['subject']}");
}
);
return redirect()
->back()
->with('success', 'Thank you for your message! We\'ll get back to you shortly.');
}
}

View File

@@ -19,7 +19,9 @@ class HandleInertiaRequests extends Middleware
'flash' => fn () => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
'info' => $request->session()->get('info'),
],
'impersonating' => fn () => $request->session()->has('impersonator_id'),
'domains' => fn () => [
'marketing' => config('app.domains.marketing'),
'account' => config('app.domains.account'),

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ImpersonationMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if ($request->session()->has('impersonator_id')) {
$request->merge(['is_impersonating' => true]);
$request->merge(['impersonator_id' => $request->session()->get('impersonator_id')]);
}
return $next($request);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ContactRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'subject' => ['required', 'string', 'max:255'],
'message' => ['required', 'string', 'min:10', 'max:5000'],
];
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Order extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'plan_id',
'invoice_id',
'service_id',
'order_number',
'status',
'total',
'currency',
'payment_gateway',
'configuration',
'admin_notes',
'completed_at',
'cancelled_at',
];
protected function casts(): array
{
return [
'total' => 'decimal:2',
'configuration' => 'json',
'completed_at' => 'datetime',
'cancelled_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
}

View File

@@ -38,6 +38,11 @@ class Plan extends Model
];
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function services(): HasMany
{
return $this->hasMany(Service::class);

View File

@@ -79,6 +79,11 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany(SupportTicket::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function couponRedemptions(): HasMany
{
return $this->hasMany(CouponRedemption::class);

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Order> */
class OrderFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'user_id' => User::factory(),
'plan_id' => Plan::factory(),
'order_number' => 'ORD-'.strtoupper(fake()->unique()->bothify('########')),
'status' => fake()->randomElement(['pending', 'processing', 'completed', 'cancelled']),
'total' => fake()->randomFloat(2, 5, 200),
'currency' => 'USD',
'payment_gateway' => fake()->randomElement(['stripe', 'paypal']),
'configuration' => ['hostname' => fake()->domainWord().'.ezscale.cloud'],
];
}
public function pending(): static
{
return $this->state(fn () => ['status' => 'pending']);
}
public function processing(): static
{
return $this->state(fn () => ['status' => 'processing']);
}
public function completed(): static
{
return $this->state(fn () => ['status' => 'completed', 'completed_at' => now()]);
}
public function cancelled(): static
{
return $this->state(fn () => ['status' => 'cancelled', 'cancelled_at' => now()]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('plan_id')->constrained();
$table->foreignId('invoice_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('service_id')->nullable()->constrained()->nullOnDelete();
$table->string('order_number')->unique();
$table->string('status')->default('pending'); // pending, processing, completed, cancelled, failed
$table->decimal('total', 10, 2);
$table->string('currency', 3)->default('USD');
$table->string('payment_gateway')->nullable(); // stripe, paypal
$table->json('configuration')->nullable(); // hostname, OS, location, etc.
$table->text('admin_notes')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};

View File

@@ -13,4 +13,7 @@ const flash = computed(() => (page.props as Record<string, unknown>).flash as Re
<VAlert v-if="flash.error" type="error" variant="tonal" closable class="mb-4">
{{ flash.error }}
</VAlert>
<VAlert v-if="flash.info" type="info" variant="tonal" closable class="mb-4">
{{ flash.info }}
</VAlert>
</template>

View File

@@ -19,11 +19,14 @@ interface AuthUser {
interface PageProps {
auth: { user: AuthUser | null }
domains: { marketing: string; account: string; admin: string }
impersonating: boolean
}
const page = usePage()
const props = computed(() => page.props as unknown as PageProps)
const user = computed(() => props.value.auth?.user)
const isImpersonating = computed(() => props.value.impersonating)
const adminUrl = computed(() => `https://${props.value.domains?.admin}`)
const currentUrl = computed(() => page.url)
function isActive(matchPrefix: string): boolean {
@@ -90,6 +93,31 @@ function isActive(matchPrefix: string): boolean {
</VContainer>
</VAppBar>
<!-- Impersonation Banner -->
<VBanner
v-if="isImpersonating"
color="warning"
icon="tabler-user-shield"
sticky
class="impersonation-banner"
>
<template #text>
You are impersonating <strong>{{ user?.name }}</strong>. Actions will be attributed to this user.
</template>
<template #actions>
<Link
:href="adminUrl + '/impersonate/stop'"
method="post"
as="button"
class="text-decoration-none"
>
<VBtn color="warning" variant="tonal" size="small">
Stop Impersonating
</VBtn>
</Link>
</template>
</VBanner>
<VMain>
<VContainer>
<FlashMessages />

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>

View File

@@ -5,6 +5,7 @@ export const adminNavItems: NavItem[] = [
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
{ title: 'Customers', href: '/customers', icon: 'tabler-users', matchPrefix: '/customers' },
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
{ title: 'Orders', href: '/orders', icon: 'tabler-shopping-cart', matchPrefix: '/orders' },
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
{ title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' },

View File

@@ -31,6 +31,17 @@ export function resolveTransactionStatusColor(status: string): StatusColor {
return map[status] ?? 'secondary'
}
export function resolveOrderStatusColor(status: string): StatusColor {
const map: Record<string, StatusColor> = {
pending: 'warning',
processing: 'info',
completed: 'success',
cancelled: 'error',
failed: 'error',
}
return map[status] ?? 'secondary'
}
export function resolveServiceStatusColor(status: string): StatusColor {
const map: Record<string, StatusColor> = {
active: 'success',

View File

@@ -10,6 +10,7 @@ use App\Http\Controllers\Account\PlanController;
use App\Http\Controllers\Account\ProfileController;
use App\Http\Controllers\Account\ServiceController;
use App\Http\Controllers\Account\SubscriptionController;
use App\Http\Controllers\Account\UpgradeController;
use Illuminate\Support\Facades\Route;
Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
@@ -32,6 +33,8 @@ Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('acc
// Services
Route::resource('services', ServiceController::class)->only(['index', 'show'])->names('account.services');
Route::get('/services/{service}/upgrade', [UpgradeController::class, 'show'])->name('account.services.upgrade');
Route::post('/services/{service}/upgrade', [UpgradeController::class, 'store'])->name('account.services.upgrade.store');
// Subscriptions
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');

View File

@@ -6,7 +6,9 @@ use App\Http\Controllers\Admin\AuditLogController;
use App\Http\Controllers\Admin\CouponController;
use App\Http\Controllers\Admin\CustomerController;
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\ImpersonationController;
use App\Http\Controllers\Admin\InvoiceController;
use App\Http\Controllers\Admin\OrderController;
use App\Http\Controllers\Admin\PlanController;
use App\Http\Controllers\Admin\ServiceController;
use App\Http\Controllers\Admin\SettingsController;
@@ -44,7 +46,17 @@ Route::resource('coupons', CouponController::class)->names([
'destroy' => 'admin.coupons.destroy',
])->except(['show']);
Route::resource('orders', OrderController::class)->only(['index', 'show']);
Route::post('orders/{order}/process', [OrderController::class, 'process'])->name('orders.process');
Route::post('orders/{order}/complete', [OrderController::class, 'complete'])->name('orders.complete');
Route::post('orders/{order}/cancel', [OrderController::class, 'cancel'])->name('orders.cancel');
Route::put('orders/{order}/notes', [OrderController::class, 'updateNotes'])->name('orders.notes');
Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs.index');
Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index');
Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update');
// Impersonation
Route::post('impersonate/{user}', [ImpersonationController::class, 'start'])->name('impersonate.start');
Route::post('impersonate/stop', [ImpersonationController::class, 'stop'])->name('impersonate.stop');

View File

@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Http\Controllers\Marketing\ContactController;
use App\Models\Plan;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
@@ -25,6 +26,7 @@ Route::get('/pricing', function () {
})->name('pricing');
Route::get('/about', fn () => Inertia::render('Marketing/About'))->name('about');
Route::get('/contact', fn () => Inertia::render('Marketing/Contact'))->name('contact');
Route::post('/contact', [ContactController::class, 'store'])->name('contact.store');
// Legal pages
Route::get('/terms-of-service', fn () => Inertia::render('Marketing/TermsOfService'))->name('terms');