From 960380392870037c165cc4c6090afc7df07caa9df7fbe13fc5a6aa5ce94430b8 Mon Sep 17 00:00:00 2001 From: Claude Dev Date: Mon, 9 Feb 2026 13:55:27 -0500 Subject: [PATCH] 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 --- .../Controllers/Account/UpgradeController.php | 138 +++++ .../Admin/ImpersonationController.php | 67 +++ .../Controllers/Admin/OrderController.php | 140 +++++ .../Marketing/ContactController.php | 31 ++ .../Http/Middleware/HandleInertiaRequests.php | 2 + .../Middleware/ImpersonationMiddleware.php | 22 + website/app/Http/Requests/ContactRequest.php | 26 + website/app/Models/Order.php | 60 ++ website/app/Models/Plan.php | 5 + website/app/Models/User.php | 5 + website/database/factories/OrderFactory.php | 48 ++ .../2026_02_09_190000_create_orders_table.php | 36 ++ .../resources/ts/Components/FlashMessages.vue | 3 + .../resources/ts/Layouts/AccountLayout.vue | 28 + .../ts/Pages/Admin/Customers/Show.vue | 11 +- .../resources/ts/Pages/Admin/Orders/Index.vue | 225 ++++++++ .../resources/ts/Pages/Admin/Orders/Show.vue | 520 ++++++++++++++++++ website/resources/ts/Pages/Services/Show.vue | 60 +- .../resources/ts/Pages/Services/Upgrade.vue | 468 ++++++++++++++++ website/resources/ts/navigation/admin.ts | 1 + website/resources/ts/utils/resolvers.ts | 11 + website/routes/account.php | 3 + website/routes/admin.php | 12 + website/routes/marketing.php | 2 + 24 files changed, 1911 insertions(+), 13 deletions(-) create mode 100644 website/app/Http/Controllers/Account/UpgradeController.php create mode 100644 website/app/Http/Controllers/Admin/ImpersonationController.php create mode 100644 website/app/Http/Controllers/Admin/OrderController.php create mode 100644 website/app/Http/Controllers/Marketing/ContactController.php create mode 100644 website/app/Http/Middleware/ImpersonationMiddleware.php create mode 100644 website/app/Http/Requests/ContactRequest.php create mode 100644 website/app/Models/Order.php create mode 100644 website/database/factories/OrderFactory.php create mode 100644 website/database/migrations/2026_02_09_190000_create_orders_table.php create mode 100644 website/resources/ts/Pages/Admin/Orders/Index.vue create mode 100644 website/resources/ts/Pages/Admin/Orders/Show.vue create mode 100644 website/resources/ts/Pages/Services/Upgrade.vue diff --git a/website/app/Http/Controllers/Account/UpgradeController.php b/website/app/Http/Controllers/Account/UpgradeController.php new file mode 100644 index 0000000..798dae7 --- /dev/null +++ b/website/app/Http/Controllers/Account/UpgradeController.php @@ -0,0 +1,138 @@ +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}."); + } +} diff --git a/website/app/Http/Controllers/Admin/ImpersonationController.php b/website/app/Http/Controllers/Admin/ImpersonationController.php new file mode 100644 index 0000000..b794c54 --- /dev/null +++ b/website/app/Http/Controllers/Admin/ImpersonationController.php @@ -0,0 +1,67 @@ +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.'); + } +} diff --git a/website/app/Http/Controllers/Admin/OrderController.php b/website/app/Http/Controllers/Admin/OrderController.php new file mode 100644 index 0000000..b220c83 --- /dev/null +++ b/website/app/Http/Controllers/Admin/OrderController.php @@ -0,0 +1,140 @@ +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.'); + } +} diff --git a/website/app/Http/Controllers/Marketing/ContactController.php b/website/app/Http/Controllers/Marketing/ContactController.php new file mode 100644 index 0000000..7c5f703 --- /dev/null +++ b/website/app/Http/Controllers/Marketing/ContactController.php @@ -0,0 +1,31 @@ +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.'); + } +} diff --git a/website/app/Http/Middleware/HandleInertiaRequests.php b/website/app/Http/Middleware/HandleInertiaRequests.php index 9e3b7c8..fea525d 100644 --- a/website/app/Http/Middleware/HandleInertiaRequests.php +++ b/website/app/Http/Middleware/HandleInertiaRequests.php @@ -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'), diff --git a/website/app/Http/Middleware/ImpersonationMiddleware.php b/website/app/Http/Middleware/ImpersonationMiddleware.php new file mode 100644 index 0000000..f3322c1 --- /dev/null +++ b/website/app/Http/Middleware/ImpersonationMiddleware.php @@ -0,0 +1,22 @@ +session()->has('impersonator_id')) { + $request->merge(['is_impersonating' => true]); + $request->merge(['impersonator_id' => $request->session()->get('impersonator_id')]); + } + + return $next($request); + } +} diff --git a/website/app/Http/Requests/ContactRequest.php b/website/app/Http/Requests/ContactRequest.php new file mode 100644 index 0000000..1faa10e --- /dev/null +++ b/website/app/Http/Requests/ContactRequest.php @@ -0,0 +1,26 @@ +> */ + 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'], + ]; + } +} diff --git a/website/app/Models/Order.php b/website/app/Models/Order.php new file mode 100644 index 0000000..173ade2 --- /dev/null +++ b/website/app/Models/Order.php @@ -0,0 +1,60 @@ + '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); + } +} diff --git a/website/app/Models/Plan.php b/website/app/Models/Plan.php index edfa7ce..d285bf7 100644 --- a/website/app/Models/Plan.php +++ b/website/app/Models/Plan.php @@ -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); diff --git a/website/app/Models/User.php b/website/app/Models/User.php index 5fa0a43..8684400 100644 --- a/website/app/Models/User.php +++ b/website/app/Models/User.php @@ -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); diff --git a/website/database/factories/OrderFactory.php b/website/database/factories/OrderFactory.php new file mode 100644 index 0000000..44736d6 --- /dev/null +++ b/website/database/factories/OrderFactory.php @@ -0,0 +1,48 @@ + */ +class OrderFactory extends Factory +{ + /** @return array */ + 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()]); + } +} diff --git a/website/database/migrations/2026_02_09_190000_create_orders_table.php b/website/database/migrations/2026_02_09_190000_create_orders_table.php new file mode 100644 index 0000000..e56756b --- /dev/null +++ b/website/database/migrations/2026_02_09_190000_create_orders_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/website/resources/ts/Components/FlashMessages.vue b/website/resources/ts/Components/FlashMessages.vue index 07c35c9..d1ffd04 100644 --- a/website/resources/ts/Components/FlashMessages.vue +++ b/website/resources/ts/Components/FlashMessages.vue @@ -13,4 +13,7 @@ const flash = computed(() => (page.props as Record).flash as Re {{ flash.error }} + + {{ flash.info }} + diff --git a/website/resources/ts/Layouts/AccountLayout.vue b/website/resources/ts/Layouts/AccountLayout.vue index 05bf6e6..b63d45f 100644 --- a/website/resources/ts/Layouts/AccountLayout.vue +++ b/website/resources/ts/Layouts/AccountLayout.vue @@ -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 { + + + + + + diff --git a/website/resources/ts/Pages/Admin/Customers/Show.vue b/website/resources/ts/Pages/Admin/Customers/Show.vue index 8044c24..39c5ecf 100644 --- a/website/resources/ts/Pages/Admin/Customers/Show.vue +++ b/website/resources/ts/Pages/Admin/Customers/Show.vue @@ -1,5 +1,5 @@ + + diff --git a/website/resources/ts/Pages/Admin/Orders/Show.vue b/website/resources/ts/Pages/Admin/Orders/Show.vue new file mode 100644 index 0000000..352e1ee --- /dev/null +++ b/website/resources/ts/Pages/Admin/Orders/Show.vue @@ -0,0 +1,520 @@ + + + diff --git a/website/resources/ts/Pages/Services/Show.vue b/website/resources/ts/Pages/Services/Show.vue index f54b634..5f8018d 100644 --- a/website/resources/ts/Pages/Services/Show.vue +++ b/website/resources/ts/Pages/Services/Show.vue @@ -83,18 +83,36 @@ const platformLabel = computed(() => { Managed by {{ platformLabel }} - - - Open Control Panel - +
+ + + + Upgrade / Downgrade + + + + + Open Control Panel + +
@@ -334,6 +352,24 @@ const platformLabel = computed(() => { Quick Actions
+ + + + Upgrade / Downgrade + + + +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() + +const showConfirmDialog = ref(false) +const selectedPlan = ref(null) + +const form = useForm({ + plan_id: 0, +}) + +const upgradePlans = computed(() => + props.availablePlans.filter(plan => plan.is_upgrade), +) + +const downgradePlans = computed(() => + props.availablePlans.filter(plan => !plan.is_upgrade), +) + +const serviceLabel = computed(() => + props.service.hostname || props.service.domain || `Service #${props.service.id}`, +) + +const confirmActionLabel = computed(() => { + if (!selectedPlan.value) return '' + return selectedPlan.value.is_upgrade ? 'Upgrade' : 'Downgrade' +}) + +const confirmActionColor = computed(() => { + 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 + }, + }) +} + + + diff --git a/website/resources/ts/navigation/admin.ts b/website/resources/ts/navigation/admin.ts index c446a74..ae5c32a 100644 --- a/website/resources/ts/navigation/admin.ts +++ b/website/resources/ts/navigation/admin.ts @@ -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' }, diff --git a/website/resources/ts/utils/resolvers.ts b/website/resources/ts/utils/resolvers.ts index dc868ce..f42799e 100644 --- a/website/resources/ts/utils/resolvers.ts +++ b/website/resources/ts/utils/resolvers.ts @@ -31,6 +31,17 @@ export function resolveTransactionStatusColor(status: string): StatusColor { return map[status] ?? 'secondary' } +export function resolveOrderStatusColor(status: string): StatusColor { + const map: Record = { + pending: 'warning', + processing: 'info', + completed: 'success', + cancelled: 'error', + failed: 'error', + } + return map[status] ?? 'secondary' +} + export function resolveServiceStatusColor(status: string): StatusColor { const map: Record = { active: 'success', diff --git a/website/routes/account.php b/website/routes/account.php index e5b603f..292e643 100644 --- a/website/routes/account.php +++ b/website/routes/account.php @@ -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'); diff --git a/website/routes/admin.php b/website/routes/admin.php index a0d05a1..d6e529b 100644 --- a/website/routes/admin.php +++ b/website/routes/admin.php @@ -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'); diff --git a/website/routes/marketing.php b/website/routes/marketing.php index 0152adc..2ad051f 100644 --- a/website/routes/marketing.php +++ b/website/routes/marketing.php @@ -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');