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:
138
website/app/Http/Controllers/Account/UpgradeController.php
Normal file
138
website/app/Http/Controllers/Account/UpgradeController.php
Normal 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}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
140
website/app/Http/Controllers/Admin/OrderController.php
Normal file
140
website/app/Http/Controllers/Admin/OrderController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
website/app/Http/Controllers/Marketing/ContactController.php
Normal file
31
website/app/Http/Controllers/Marketing/ContactController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'flash' => fn () => [
|
'flash' => fn () => [
|
||||||
'success' => $request->session()->get('success'),
|
'success' => $request->session()->get('success'),
|
||||||
'error' => $request->session()->get('error'),
|
'error' => $request->session()->get('error'),
|
||||||
|
'info' => $request->session()->get('info'),
|
||||||
],
|
],
|
||||||
|
'impersonating' => fn () => $request->session()->has('impersonator_id'),
|
||||||
'domains' => fn () => [
|
'domains' => fn () => [
|
||||||
'marketing' => config('app.domains.marketing'),
|
'marketing' => config('app.domains.marketing'),
|
||||||
'account' => config('app.domains.account'),
|
'account' => config('app.domains.account'),
|
||||||
|
|||||||
22
website/app/Http/Middleware/ImpersonationMiddleware.php
Normal file
22
website/app/Http/Middleware/ImpersonationMiddleware.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
website/app/Http/Requests/ContactRequest.php
Normal file
26
website/app/Http/Requests/ContactRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
60
website/app/Models/Order.php
Normal file
60
website/app/Models/Order.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ class Plan extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function orders(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Order::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function services(): HasMany
|
public function services(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Service::class);
|
return $this->hasMany(Service::class);
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return $this->hasMany(SupportTicket::class);
|
return $this->hasMany(SupportTicket::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function orders(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Order::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function couponRedemptions(): HasMany
|
public function couponRedemptions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(CouponRedemption::class);
|
return $this->hasMany(CouponRedemption::class);
|
||||||
|
|||||||
48
website/database/factories/OrderFactory.php
Normal file
48
website/database/factories/OrderFactory.php
Normal 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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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">
|
<VAlert v-if="flash.error" type="error" variant="tonal" closable class="mb-4">
|
||||||
{{ flash.error }}
|
{{ flash.error }}
|
||||||
</VAlert>
|
</VAlert>
|
||||||
|
<VAlert v-if="flash.info" type="info" variant="tonal" closable class="mb-4">
|
||||||
|
{{ flash.info }}
|
||||||
|
</VAlert>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,11 +19,14 @@ interface AuthUser {
|
|||||||
interface PageProps {
|
interface PageProps {
|
||||||
auth: { user: AuthUser | null }
|
auth: { user: AuthUser | null }
|
||||||
domains: { marketing: string; account: string; admin: string }
|
domains: { marketing: string; account: string; admin: string }
|
||||||
|
impersonating: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = usePage()
|
const page = usePage()
|
||||||
const props = computed(() => page.props as unknown as PageProps)
|
const props = computed(() => page.props as unknown as PageProps)
|
||||||
const user = computed(() => props.value.auth?.user)
|
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)
|
const currentUrl = computed(() => page.url)
|
||||||
|
|
||||||
function isActive(matchPrefix: string): boolean {
|
function isActive(matchPrefix: string): boolean {
|
||||||
@@ -90,6 +93,31 @@ function isActive(matchPrefix: string): boolean {
|
|||||||
</VContainer>
|
</VContainer>
|
||||||
</VAppBar>
|
</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>
|
<VMain>
|
||||||
<VContainer>
|
<VContainer>
|
||||||
<FlashMessages />
|
<FlashMessages />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Link, useForm } from '@inertiajs/vue3'
|
import { Link, router, useForm } from '@inertiajs/vue3'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
|
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
|
||||||
@@ -230,6 +230,15 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-center ga-2">
|
<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
|
<VBtn
|
||||||
v-if="customer.status !== 'suspended'"
|
v-if="customer.status !== 'suspended'"
|
||||||
color="warning"
|
color="warning"
|
||||||
|
|||||||
225
website/resources/ts/Pages/Admin/Orders/Index.vue
Normal file
225
website/resources/ts/Pages/Admin/Orders/Index.vue
Normal 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>
|
||||||
520
website/resources/ts/Pages/Admin/Orders/Show.vue
Normal file
520
website/resources/ts/Pages/Admin/Orders/Show.vue
Normal 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' }} · {{ 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>
|
||||||
@@ -83,18 +83,36 @@ const platformLabel = computed<string>(() => {
|
|||||||
Managed by {{ platformLabel }}
|
Managed by {{ platformLabel }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VBtn
|
<div class="d-flex ga-3">
|
||||||
v-if="controlPanelUrl && !isTerminated"
|
<Link
|
||||||
:href="controlPanelUrl"
|
v-if="service.status === 'active' && service.plan"
|
||||||
target="_blank"
|
:href="`/services/${service.id}/upgrade`"
|
||||||
rel="noopener noreferrer"
|
class="text-decoration-none"
|
||||||
>
|
>
|
||||||
<VIcon
|
<VBtn
|
||||||
icon="tabler-external-link"
|
color="primary"
|
||||||
start
|
variant="tonal"
|
||||||
/>
|
>
|
||||||
Open Control Panel
|
<VIcon
|
||||||
</VBtn>
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Suspended Notice -->
|
<!-- Suspended Notice -->
|
||||||
@@ -334,6 +352,24 @@ const platformLabel = computed<string>(() => {
|
|||||||
<VCardTitle>Quick Actions</VCardTitle>
|
<VCardTitle>Quick Actions</VCardTitle>
|
||||||
<VCardText>
|
<VCardText>
|
||||||
<div class="d-flex flex-column ga-3">
|
<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
|
<VBtn
|
||||||
v-if="controlPanelUrl"
|
v-if="controlPanelUrl"
|
||||||
:href="controlPanelUrl"
|
:href="controlPanelUrl"
|
||||||
|
|||||||
468
website/resources/ts/Pages/Services/Upgrade.vue
Normal file
468
website/resources/ts/Pages/Services/Upgrade.vue
Normal 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"
|
||||||
|
>
|
||||||
|
← 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 }} — 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>
|
||||||
@@ -5,6 +5,7 @@ export const adminNavItems: NavItem[] = [
|
|||||||
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
||||||
{ title: 'Customers', href: '/customers', icon: 'tabler-users', matchPrefix: '/customers' },
|
{ title: 'Customers', href: '/customers', icon: 'tabler-users', matchPrefix: '/customers' },
|
||||||
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
|
{ 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: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
|
||||||
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
|
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
|
||||||
{ title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' },
|
{ title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' },
|
||||||
|
|||||||
@@ -31,6 +31,17 @@ export function resolveTransactionStatusColor(status: string): StatusColor {
|
|||||||
return map[status] ?? 'secondary'
|
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 {
|
export function resolveServiceStatusColor(status: string): StatusColor {
|
||||||
const map: Record<string, StatusColor> = {
|
const map: Record<string, StatusColor> = {
|
||||||
active: 'success',
|
active: 'success',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\Account\PlanController;
|
|||||||
use App\Http\Controllers\Account\ProfileController;
|
use App\Http\Controllers\Account\ProfileController;
|
||||||
use App\Http\Controllers\Account\ServiceController;
|
use App\Http\Controllers\Account\ServiceController;
|
||||||
use App\Http\Controllers\Account\SubscriptionController;
|
use App\Http\Controllers\Account\SubscriptionController;
|
||||||
|
use App\Http\Controllers\Account\UpgradeController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
|
||||||
@@ -32,6 +33,8 @@ Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('acc
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
Route::resource('services', ServiceController::class)->only(['index', 'show'])->names('account.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
|
// Subscriptions
|
||||||
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');
|
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ use App\Http\Controllers\Admin\AuditLogController;
|
|||||||
use App\Http\Controllers\Admin\CouponController;
|
use App\Http\Controllers\Admin\CouponController;
|
||||||
use App\Http\Controllers\Admin\CustomerController;
|
use App\Http\Controllers\Admin\CustomerController;
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
|
use App\Http\Controllers\Admin\ImpersonationController;
|
||||||
use App\Http\Controllers\Admin\InvoiceController;
|
use App\Http\Controllers\Admin\InvoiceController;
|
||||||
|
use App\Http\Controllers\Admin\OrderController;
|
||||||
use App\Http\Controllers\Admin\PlanController;
|
use App\Http\Controllers\Admin\PlanController;
|
||||||
use App\Http\Controllers\Admin\ServiceController;
|
use App\Http\Controllers\Admin\ServiceController;
|
||||||
use App\Http\Controllers\Admin\SettingsController;
|
use App\Http\Controllers\Admin\SettingsController;
|
||||||
@@ -44,7 +46,17 @@ Route::resource('coupons', CouponController::class)->names([
|
|||||||
'destroy' => 'admin.coupons.destroy',
|
'destroy' => 'admin.coupons.destroy',
|
||||||
])->except(['show']);
|
])->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('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs.index');
|
||||||
|
|
||||||
Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index');
|
Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index');
|
||||||
Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update');
|
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');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Http\Controllers\Marketing\ContactController;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -25,6 +26,7 @@ Route::get('/pricing', function () {
|
|||||||
})->name('pricing');
|
})->name('pricing');
|
||||||
Route::get('/about', fn () => Inertia::render('Marketing/About'))->name('about');
|
Route::get('/about', fn () => Inertia::render('Marketing/About'))->name('about');
|
||||||
Route::get('/contact', fn () => Inertia::render('Marketing/Contact'))->name('contact');
|
Route::get('/contact', fn () => Inertia::render('Marketing/Contact'))->name('contact');
|
||||||
|
Route::post('/contact', [ContactController::class, 'store'])->name('contact.store');
|
||||||
|
|
||||||
// Legal pages
|
// Legal pages
|
||||||
Route::get('/terms-of-service', fn () => Inertia::render('Marketing/TermsOfService'))->name('terms');
|
Route::get('/terms-of-service', fn () => Inertia::render('Marketing/TermsOfService'))->name('terms');
|
||||||
|
|||||||
Reference in New Issue
Block a user