Add coupon management, customer services, and update TASKS.md
Phase 5 (Admin Panel): - Admin coupon management: full CRUD with create/edit/deactivate, auto-generate codes, plan restrictions multi-select, usage tracking, redemption history on edit page - Add active flag migration for coupons table Phase 4 (Customer Dashboard): - Customer services list page with plan info and status - Customer service detail page with network info, plan details, control panel links (VirtFusion/SynergyCP/Enhance), important dates - Service status/type resolver utilities Documentation: - Update TASKS.md with all completed Phase 3/4/5/8 items Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
108
TASKS.md
108
TASKS.md
@@ -64,40 +64,40 @@
|
|||||||
- [x] All 53 tests passing, build clean
|
- [x] All 53 tests passing, build clean
|
||||||
|
|
||||||
## Phase 3: Provisioning Automation
|
## Phase 3: Provisioning Automation
|
||||||
- [ ] Create `ProvisioningServiceInterface` abstraction
|
- [x] Create `ProvisioningServiceInterface` abstraction
|
||||||
- [ ] Build VirtFusion provisioning service:
|
- [x] Build VirtFusion provisioning service:
|
||||||
- [ ] Create VPS via API
|
- [x] Create VPS via API
|
||||||
- [ ] Suspend/unsuspend VPS
|
- [x] Suspend/unsuspend VPS
|
||||||
- [ ] Terminate VPS
|
- [x] Terminate VPS
|
||||||
- [ ] Get status and resource usage
|
- [x] Get status and resource usage
|
||||||
- [ ] Credential generation and secure storage
|
- [x] Credential generation and secure storage
|
||||||
- [ ] Build Pterodactyl provisioning service:
|
- [ ] Build Pterodactyl provisioning service:
|
||||||
- [ ] Create game server via API
|
- [ ] Create game server via API
|
||||||
- [ ] Suspend/unsuspend server
|
- [ ] Suspend/unsuspend server
|
||||||
- [ ] Delete server
|
- [ ] Delete server
|
||||||
- [ ] Get server status and resources
|
- [ ] Get server status and resources
|
||||||
- [ ] Build SynergyCP provisioning service:
|
- [x] Build SynergyCP provisioning service:
|
||||||
- [ ] Provision dedicated server
|
- [x] Provision dedicated server
|
||||||
- [ ] Suspend/unsuspend server
|
- [x] Suspend/unsuspend server
|
||||||
- [ ] Terminate server
|
- [x] Terminate server
|
||||||
- [ ] Get server details
|
- [x] Get server details
|
||||||
- [ ] Handle limited hardware inventory (waitlist/semi-auto)
|
- [x] Handle limited hardware inventory (waitlist/semi-auto)
|
||||||
- [ ] Build Enhance provisioning service:
|
- [x] Build Enhance provisioning service:
|
||||||
- [ ] Create web hosting account
|
- [x] Create web hosting account
|
||||||
- [ ] Suspend/delete account
|
- [x] Suspend/delete account
|
||||||
- [ ] Get account status
|
- [x] Get account status
|
||||||
- [ ] Implement event-driven provisioning (listen to `PaymentSucceeded` events)
|
- [x] Implement event-driven provisioning (listen to `PaymentSucceeded` events)
|
||||||
- [ ] Build provisioning failure handling and retry logic
|
- [ ] Build provisioning failure handling and retry logic
|
||||||
- [ ] Send credentials email on successful provisioning
|
- [ ] Send credentials email on successful provisioning
|
||||||
- [ ] Log all provisioning actions to `provisioning_logs` table
|
- [x] Log all provisioning actions to `provisioning_logs` table
|
||||||
|
|
||||||
## Phase 4: Customer Dashboard (account.ezscale.cloud)
|
## Phase 4: Customer Dashboard (account.ezscale.cloud)
|
||||||
- [ ] Build service overview dashboard:
|
- [x] Build service overview dashboard:
|
||||||
- [ ] Active services list with status indicators
|
- [x] Active services list with status indicators
|
||||||
- [ ] Resource usage widgets (CPU, RAM, disk, bandwidth)
|
- [ ] Resource usage widgets (CPU, RAM, disk, bandwidth)
|
||||||
- [ ] Next invoice and payment due date
|
- [x] Next invoice and payment due date
|
||||||
- [ ] Recent support tickets
|
- [ ] Recent support tickets
|
||||||
- [ ] Quick actions (renew, upgrade, create ticket)
|
- [x] Quick actions (renew, upgrade, create ticket)
|
||||||
- [ ] Build service detail pages:
|
- [ ] Build service detail pages:
|
||||||
- [ ] VPS details (IP, credentials, resource graphs, control buttons)
|
- [ ] VPS details (IP, credentials, resource graphs, control buttons)
|
||||||
- [ ] Game server details (connect info, resource usage, restart button)
|
- [ ] Game server details (connect info, resource usage, restart button)
|
||||||
@@ -111,61 +111,61 @@
|
|||||||
- [ ] Upcoming renewals
|
- [ ] Upcoming renewals
|
||||||
- [ ] Plan upgrade/downgrade flow (self-service with proration)
|
- [ ] Plan upgrade/downgrade flow (self-service with proration)
|
||||||
- [ ] Subscription cancellation flow (with optional survey)
|
- [ ] Subscription cancellation flow (with optional survey)
|
||||||
- [ ] Profile and account settings:
|
- [x] Profile and account settings:
|
||||||
- [ ] Contact information
|
- [x] Contact information
|
||||||
- [ ] Billing/shipping addresses
|
- [x] Billing/shipping addresses
|
||||||
- [ ] Tax ID
|
- [x] Tax ID
|
||||||
- [ ] Password change
|
- [x] Password change
|
||||||
- [ ] 2FA setup (TOTP, passkeys)
|
- [x] 2FA setup (TOTP, passkeys)
|
||||||
- [ ] SupportPal integration:
|
- [ ] SupportPal integration:
|
||||||
- [ ] SSO to SupportPal
|
- [ ] SSO to SupportPal
|
||||||
- [ ] View recent tickets widget
|
- [ ] View recent tickets widget
|
||||||
- [ ] Create ticket button (opens SupportPal or API)
|
- [ ] Create ticket button (opens SupportPal or API)
|
||||||
|
|
||||||
## Phase 5: Admin Panel (admin.ezscale.cloud)
|
## Phase 5: Admin Panel (admin.ezscale.cloud)
|
||||||
- [ ] Analytics dashboard:
|
- [x] Analytics dashboard:
|
||||||
- [ ] MRR (Monthly Recurring Revenue) graph
|
- [x] MRR (Monthly Recurring Revenue) graph
|
||||||
- [ ] ARR (Annual Recurring Revenue)
|
- [ ] ARR (Annual Recurring Revenue)
|
||||||
- [ ] Churn rate calculation and graph
|
- [ ] Churn rate calculation and graph
|
||||||
- [ ] Customer growth chart
|
- [ ] Customer growth chart
|
||||||
- [ ] Revenue trends (daily, monthly, yearly)
|
- [ ] Revenue trends (daily, monthly, yearly)
|
||||||
- [ ] Popular plans and conversion rates
|
- [x] Popular plans and conversion rates
|
||||||
- [ ] Outstanding invoices total
|
- [x] Outstanding invoices total
|
||||||
- [ ] Overdue accounts list
|
- [ ] Overdue accounts list
|
||||||
- [ ] Customer management:
|
- [x] Customer management:
|
||||||
- [ ] Customer list (searchable, filterable)
|
- [x] Customer list (searchable, filterable)
|
||||||
- [ ] Customer detail view (profile, services, billing history, notes)
|
- [x] Customer detail view (profile, services, billing history, notes)
|
||||||
- [ ] Edit customer information
|
- [ ] Edit customer information
|
||||||
- [ ] Impersonate customer (with audit logging)
|
- [ ] Impersonate customer (with audit logging)
|
||||||
- [ ] Add admin notes to customer account
|
- [ ] Add admin notes to customer account
|
||||||
- [ ] View customer audit log
|
- [ ] View customer audit log
|
||||||
- [ ] Service management:
|
- [x] Service management:
|
||||||
- [ ] All services list (filter by type, status, platform)
|
- [x] All services list (filter by type, status, platform)
|
||||||
- [ ] Manually provision service
|
- [ ] Manually provision service
|
||||||
- [ ] Suspend/unsuspend service
|
- [x] Suspend/unsuspend service
|
||||||
- [ ] Terminate service
|
- [x] Terminate service
|
||||||
- [ ] Modify service (change plan, extend expiry)
|
- [ ] Modify service (change plan, extend expiry)
|
||||||
- [ ] View provisioning logs
|
- [x] View provisioning logs
|
||||||
- [ ] Order management:
|
- [ ] Order management:
|
||||||
- [ ] Pending orders list
|
- [ ] Pending orders list
|
||||||
- [ ] Approve/reject orders (for semi-automated provisioning)
|
- [ ] Approve/reject orders (for semi-automated provisioning)
|
||||||
- [ ] View order details
|
- [ ] View order details
|
||||||
- [ ] Invoice management:
|
- [x] Invoice management:
|
||||||
- [ ] All invoices list (filter by status, date, customer)
|
- [x] All invoices list (filter by status, date, customer)
|
||||||
- [ ] Create manual invoice
|
- [ ] Create manual invoice
|
||||||
- [ ] Edit invoice (before sending)
|
- [ ] Edit invoice (before sending)
|
||||||
- [ ] Void/refund invoice
|
- [x] Void/refund invoice
|
||||||
- [ ] Resend invoice email
|
- [ ] Resend invoice email
|
||||||
- [ ] Coupon management:
|
- [ ] Coupon management:
|
||||||
- [ ] Create coupon (percentage, fixed, applies to plans)
|
- [ ] Create coupon (percentage, fixed, applies to plans)
|
||||||
- [ ] Edit coupon details
|
- [ ] Edit coupon details
|
||||||
- [ ] View redemption history
|
- [ ] View redemption history
|
||||||
- [ ] Deactivate/delete coupon
|
- [ ] Deactivate/delete coupon
|
||||||
- [ ] Plan management:
|
- [x] Plan management:
|
||||||
- [ ] Create new plan (set pricing, features, billing cycle)
|
- [x] Create new plan (set pricing, features, billing cycle)
|
||||||
- [ ] Edit existing plan
|
- [x] Edit existing plan
|
||||||
- [ ] Archive/hide plan
|
- [x] Archive/hide plan
|
||||||
- [ ] Set stock quantity (for limited dedicated servers)
|
- [x] Set stock quantity (for limited dedicated servers)
|
||||||
- [ ] System configuration:
|
- [ ] System configuration:
|
||||||
- [ ] Email template editor
|
- [ ] Email template editor
|
||||||
- [ ] Tax rate configuration (by region)
|
- [ ] Tax rate configuration (by region)
|
||||||
@@ -248,11 +248,11 @@
|
|||||||
- [ ] Tutorials
|
- [ ] Tutorials
|
||||||
- [ ] Troubleshooting
|
- [ ] Troubleshooting
|
||||||
- [ ] API documentation
|
- [ ] API documentation
|
||||||
- [ ] Legal pages:
|
- [x] Legal pages:
|
||||||
- [ ] Terms of Service
|
- [x] Terms of Service
|
||||||
- [ ] Privacy Policy
|
- [x] Privacy Policy
|
||||||
- [ ] Acceptable Use Policy
|
- [x] Acceptable Use Policy
|
||||||
- [ ] SLA (Service Level Agreement)
|
- [x] SLA (Service Level Agreement)
|
||||||
- [ ] Signup flow:
|
- [ ] Signup flow:
|
||||||
- [ ] Plan selection
|
- [ ] Plan selection
|
||||||
- [ ] Account creation
|
- [ ] Account creation
|
||||||
|
|||||||
41
website/app/Http/Controllers/Account/ServiceController.php
Normal file
41
website/app/Http/Controllers/Account/ServiceController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Account;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ServiceController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$services = $request->user()
|
||||||
|
->services()
|
||||||
|
->with('plan:id,name,service_type,price,billing_cycle')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return Inertia::render('Services/Index', [
|
||||||
|
'services' => $services,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, Service $service): Response
|
||||||
|
{
|
||||||
|
abort_unless($service->user_id === $request->user()->id, 403);
|
||||||
|
|
||||||
|
$service->load([
|
||||||
|
'plan:id,name,slug,description,service_type,price,billing_cycle,features',
|
||||||
|
'subscription',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('Services/Show', [
|
||||||
|
'service' => $service,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
website/app/Http/Controllers/Admin/CouponController.php
Normal file
102
website/app/Http/Controllers/Admin/CouponController.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\StoreCouponRequest;
|
||||||
|
use App\Models\Coupon;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class CouponController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): Response
|
||||||
|
{
|
||||||
|
$coupons = Coupon::query()
|
||||||
|
->withCount('redemptions')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->paginate(25);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Coupons/Index', [
|
||||||
|
'coupons' => $coupons,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(): Response
|
||||||
|
{
|
||||||
|
$plans = Plan::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'service_type']);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Coupons/Create', [
|
||||||
|
'plans' => $plans,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreCouponRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
Coupon::query()->create([
|
||||||
|
'code' => strtoupper($request->validated('code')),
|
||||||
|
'type' => $request->validated('type'),
|
||||||
|
'value' => $request->validated('value'),
|
||||||
|
'currency' => 'USD',
|
||||||
|
'max_uses' => $request->validated('max_uses'),
|
||||||
|
'applies_to' => $request->validated('applies_to'),
|
||||||
|
'expires_at' => $request->validated('expires_at'),
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.coupons.index')
|
||||||
|
->with('success', 'Coupon created successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(Coupon $coupon): Response
|
||||||
|
{
|
||||||
|
$plans = Plan::query()
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'service_type']);
|
||||||
|
|
||||||
|
$redemptions = $coupon->redemptions()
|
||||||
|
->with('user:id,name,email')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Coupons/Edit', [
|
||||||
|
'coupon' => $coupon,
|
||||||
|
'plans' => $plans,
|
||||||
|
'redemptions' => $redemptions,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(StoreCouponRequest $request, Coupon $coupon): RedirectResponse
|
||||||
|
{
|
||||||
|
$coupon->update([
|
||||||
|
'code' => strtoupper($request->validated('code')),
|
||||||
|
'type' => $request->validated('type'),
|
||||||
|
'value' => $request->validated('value'),
|
||||||
|
'max_uses' => $request->validated('max_uses'),
|
||||||
|
'applies_to' => $request->validated('applies_to'),
|
||||||
|
'expires_at' => $request->validated('expires_at'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.coupons.index')
|
||||||
|
->with('success', 'Coupon updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Coupon $coupon): RedirectResponse
|
||||||
|
{
|
||||||
|
$coupon->update(['active' => false]);
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('admin.coupons.index')
|
||||||
|
->with('success', 'Coupon deactivated successfully.');
|
||||||
|
}
|
||||||
|
}
|
||||||
51
website/app/Http/Requests/StoreCouponRequest.php
Normal file
51
website/app/Http/Requests/StoreCouponRequest.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreCouponRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<int, mixed>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$uniqueCodeRule = Rule::unique('coupons', 'code');
|
||||||
|
|
||||||
|
if ($this->route('coupon')) {
|
||||||
|
$uniqueCodeRule->ignore($this->route('coupon'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => ['required', 'string', 'max:50', $uniqueCodeRule],
|
||||||
|
'type' => ['required', Rule::in(['percentage', 'fixed'])],
|
||||||
|
'value' => ['required', 'numeric', 'min:0.01'],
|
||||||
|
'max_uses' => ['nullable', 'integer', 'min:1'],
|
||||||
|
'expires_at' => ['nullable', 'date', 'after:now'],
|
||||||
|
'applies_to' => ['nullable', 'array'],
|
||||||
|
'applies_to.*' => ['integer', 'exists:plans,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code.required' => 'Coupon code is required.',
|
||||||
|
'code.unique' => 'This coupon code is already in use.',
|
||||||
|
'type.required' => 'Discount type is required.',
|
||||||
|
'type.in' => 'Discount type must be percentage or fixed.',
|
||||||
|
'value.required' => 'Discount value is required.',
|
||||||
|
'value.min' => 'Discount value must be at least 0.01.',
|
||||||
|
'expires_at.after' => 'Expiry date must be in the future.',
|
||||||
|
'applies_to.*.exists' => 'One or more selected plans do not exist.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ class Coupon extends Model
|
|||||||
'applies_to',
|
'applies_to',
|
||||||
'max_uses',
|
'max_uses',
|
||||||
'times_used',
|
'times_used',
|
||||||
|
'active',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ class Coupon extends Model
|
|||||||
'applies_to' => 'array',
|
'applies_to' => 'array',
|
||||||
'max_uses' => 'integer',
|
'max_uses' => 'integer',
|
||||||
'times_used' => 'integer',
|
'times_used' => 'integer',
|
||||||
|
'active' => 'boolean',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,10 @@ class Coupon extends Model
|
|||||||
|
|
||||||
public function isValid(): bool
|
public function isValid(): bool
|
||||||
{
|
{
|
||||||
|
if (! $this->active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->expires_at && $this->expires_at->isPast()) {
|
if ($this->expires_at && $this->expires_at->isPast()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class CouponFactory extends Factory
|
|||||||
'value' => fake()->randomFloat(2, 5, 50),
|
'value' => fake()->randomFloat(2, 5, 50),
|
||||||
'max_uses' => fake()->optional()->numberBetween(10, 1000),
|
'max_uses' => fake()->optional()->numberBetween(10, 1000),
|
||||||
'times_used' => 0,
|
'times_used' => 0,
|
||||||
|
'active' => true,
|
||||||
'expires_at' => fake()->optional()->dateTimeBetween('now', '+1 year'),
|
'expires_at' => fake()->optional()->dateTimeBetween('now', '+1 year'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?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::table('coupons', function (Blueprint $table): void {
|
||||||
|
$table->boolean('active')->default(true)->after('times_used');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('coupons', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
210
website/resources/ts/Pages/Admin/Coupons/Create.vue
Normal file
210
website/resources/ts/Pages/Admin/Coupons/Create.vue
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
|
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||||
|
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||||
|
|
||||||
|
interface PlanOption {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
service_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plans: PlanOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AdminLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ title: 'Percentage', value: 'percentage' },
|
||||||
|
{ title: 'Fixed Amount', value: 'fixed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const planSelectItems = computed(() =>
|
||||||
|
props.plans.map(plan => ({
|
||||||
|
title: `${plan.name} (${plan.service_type})`,
|
||||||
|
value: plan.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
code: '',
|
||||||
|
type: '' as string,
|
||||||
|
value: '' as string | number,
|
||||||
|
max_uses: null as number | null,
|
||||||
|
expires_at: '' as string,
|
||||||
|
applies_to: [] as number[],
|
||||||
|
})
|
||||||
|
|
||||||
|
function generateCode(): void {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
let code = 'EZ-'
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||||
|
}
|
||||||
|
form.code = code
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueLabel = computed<string>(() => {
|
||||||
|
if (form.type === 'percentage') {
|
||||||
|
return 'Discount Percentage (%)'
|
||||||
|
}
|
||||||
|
return 'Discount Amount (USD)'
|
||||||
|
})
|
||||||
|
|
||||||
|
const valuePlaceholder = computed<string>(() => {
|
||||||
|
if (form.type === 'percentage') {
|
||||||
|
return 'e.g. 15'
|
||||||
|
}
|
||||||
|
return 'e.g. 5.00'
|
||||||
|
})
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
form.post('/coupons', {
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center gap-2 mb-1">
|
||||||
|
<Link href="/coupons" class="text-decoration-none">
|
||||||
|
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
|
||||||
|
</Link>
|
||||||
|
<span class="text-h4 font-weight-bold">Create Coupon</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis ms-10">
|
||||||
|
Add a new discount coupon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<VRow>
|
||||||
|
<!-- Coupon Details -->
|
||||||
|
<VCol cols="12" lg="8">
|
||||||
|
<VCard title="Coupon Details" class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="8">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.code"
|
||||||
|
label="Coupon Code"
|
||||||
|
placeholder="e.g. SAVE20"
|
||||||
|
:error-messages="form.errors.code"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="4" class="d-flex align-end">
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="tabler-refresh"
|
||||||
|
block
|
||||||
|
@click="generateCode"
|
||||||
|
>
|
||||||
|
Auto-Generate
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.type"
|
||||||
|
label="Discount Type"
|
||||||
|
:items="typeOptions"
|
||||||
|
placeholder="Select type"
|
||||||
|
:error-messages="form.errors.type"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.value"
|
||||||
|
:label="valueLabel"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
:placeholder="valuePlaceholder"
|
||||||
|
:error-messages="form.errors.value"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Plan Restrictions -->
|
||||||
|
<VCard title="Plan Restrictions" class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Optionally restrict this coupon to specific plans. Leave empty to apply to all plans.
|
||||||
|
</p>
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.applies_to"
|
||||||
|
label="Applicable Plans"
|
||||||
|
:items="planSelectItems"
|
||||||
|
placeholder="All Plans (no restriction)"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
closable-chips
|
||||||
|
:error-messages="form.errors.applies_to"
|
||||||
|
/>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<VCol cols="12" lg="4">
|
||||||
|
<VCard title="Limits & Expiry" class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.max_uses"
|
||||||
|
label="Max Uses"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Unlimited"
|
||||||
|
:error-messages="form.errors.max_uses"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.expires_at"
|
||||||
|
label="Expiry Date"
|
||||||
|
type="datetime-local"
|
||||||
|
:error-messages="form.errors.expires_at"
|
||||||
|
/>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<VCard>
|
||||||
|
<VCardText>
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
:loading="form.processing"
|
||||||
|
:disabled="form.processing"
|
||||||
|
prepend-icon="tabler-check"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
Create Coupon
|
||||||
|
</VBtn>
|
||||||
|
<Link href="/coupons" class="text-decoration-none">
|
||||||
|
<VBtn
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
300
website/resources/ts/Pages/Admin/Coupons/Edit.vue
Normal file
300
website/resources/ts/Pages/Admin/Coupons/Edit.vue
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link, useForm } from '@inertiajs/vue3'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
|
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||||
|
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||||
|
import type { Coupon, CouponRedemption, StatusColor } from '@/types'
|
||||||
|
|
||||||
|
interface PlanOption {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
service_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
coupon: Coupon
|
||||||
|
plans: PlanOption[]
|
||||||
|
redemptions: CouponRedemption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AdminLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const typeOptions = [
|
||||||
|
{ title: 'Percentage', value: 'percentage' },
|
||||||
|
{ title: 'Fixed Amount', value: 'fixed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const planSelectItems = computed(() =>
|
||||||
|
props.plans.map(plan => ({
|
||||||
|
title: `${plan.name} (${plan.service_type})`,
|
||||||
|
value: plan.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function formatExpiresAt(dateStr: string | null): string {
|
||||||
|
if (!dateStr) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
code: props.coupon.code,
|
||||||
|
type: props.coupon.type,
|
||||||
|
value: props.coupon.value,
|
||||||
|
max_uses: props.coupon.max_uses,
|
||||||
|
expires_at: formatExpiresAt(props.coupon.expires_at),
|
||||||
|
applies_to: props.coupon.applies_to ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const valueLabel = computed<string>(() => {
|
||||||
|
if (form.type === 'percentage') {
|
||||||
|
return 'Discount Percentage (%)'
|
||||||
|
}
|
||||||
|
return 'Discount Amount (USD)'
|
||||||
|
})
|
||||||
|
|
||||||
|
const valuePlaceholder = computed<string>(() => {
|
||||||
|
if (form.type === 'percentage') {
|
||||||
|
return 'e.g. 15'
|
||||||
|
}
|
||||||
|
return 'e.g. 5.00'
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolveCouponStatus(): { label: string; color: StatusColor } {
|
||||||
|
if (!props.coupon.active) {
|
||||||
|
return { label: 'Inactive', color: 'error' }
|
||||||
|
}
|
||||||
|
if (props.coupon.expires_at && new Date(props.coupon.expires_at) < new Date()) {
|
||||||
|
return { label: 'Expired', color: 'secondary' }
|
||||||
|
}
|
||||||
|
if (props.coupon.max_uses !== null && props.coupon.times_used >= props.coupon.max_uses) {
|
||||||
|
return { label: 'Exhausted', color: 'warning' }
|
||||||
|
}
|
||||||
|
return { label: 'Active', color: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedCreatedAt = computed<string>(() => formatDate(props.coupon.created_at))
|
||||||
|
|
||||||
|
const redemptionHeaders = computed(() => [
|
||||||
|
{ title: 'Customer', key: 'user', sortable: false },
|
||||||
|
{ title: 'Discount', key: 'discount_amount', sortable: true, align: 'end' as const },
|
||||||
|
{ title: 'Redeemed', key: 'created_at', sortable: true },
|
||||||
|
])
|
||||||
|
|
||||||
|
function submit(): void {
|
||||||
|
form.put(`/coupons/${props.coupon.id}`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center gap-2 mb-1">
|
||||||
|
<Link href="/coupons" class="text-decoration-none">
|
||||||
|
<VBtn icon="tabler-arrow-left" variant="text" size="small" />
|
||||||
|
</Link>
|
||||||
|
<span class="text-h4 font-weight-bold">Edit Coupon</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis ms-10">
|
||||||
|
Update coupon "{{ coupon.code }}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit">
|
||||||
|
<VRow>
|
||||||
|
<!-- Coupon Details -->
|
||||||
|
<VCol cols="12" lg="8">
|
||||||
|
<VCard title="Coupon Details" class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.code"
|
||||||
|
label="Coupon Code"
|
||||||
|
placeholder="e.g. SAVE20"
|
||||||
|
:error-messages="form.errors.code"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.type"
|
||||||
|
label="Discount Type"
|
||||||
|
:items="typeOptions"
|
||||||
|
placeholder="Select type"
|
||||||
|
:error-messages="form.errors.type"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.value"
|
||||||
|
:label="valueLabel"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
:placeholder="valuePlaceholder"
|
||||||
|
:error-messages="form.errors.value"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Plan Restrictions -->
|
||||||
|
<VCard title="Plan Restrictions" class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<p class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
Optionally restrict this coupon to specific plans. Leave empty to apply to all plans.
|
||||||
|
</p>
|
||||||
|
<AppSelect
|
||||||
|
v-model="form.applies_to"
|
||||||
|
label="Applicable Plans"
|
||||||
|
:items="planSelectItems"
|
||||||
|
placeholder="All Plans (no restriction)"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
closable-chips
|
||||||
|
:error-messages="form.errors.applies_to"
|
||||||
|
/>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Redemption History -->
|
||||||
|
<VCard title="Redemption History" class="mb-6">
|
||||||
|
<VDataTable
|
||||||
|
:headers="redemptionHeaders"
|
||||||
|
:items="redemptions"
|
||||||
|
:items-per-page="10"
|
||||||
|
hover
|
||||||
|
class="text-no-wrap"
|
||||||
|
>
|
||||||
|
<!-- Customer -->
|
||||||
|
<template #item.user="{ item }">
|
||||||
|
<div v-if="item.user" class="d-flex flex-column py-2">
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ item.user.name }}</span>
|
||||||
|
<span class="text-caption text-medium-emphasis">{{ item.user.email }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-medium-emphasis">Unknown</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Discount Amount -->
|
||||||
|
<template #item.discount_amount="{ item }">
|
||||||
|
<span class="font-weight-medium">${{ parseFloat(item.discount_amount).toFixed(2) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Created At -->
|
||||||
|
<template #item.created_at="{ item }">
|
||||||
|
{{ formatDate(item.created_at) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No data -->
|
||||||
|
<template #no-data>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<VIcon icon="tabler-receipt-off" size="40" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">
|
||||||
|
No redemptions yet.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDataTable>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<VCol cols="12" lg="4">
|
||||||
|
<!-- Coupon Info -->
|
||||||
|
<VCard title="Coupon Info" class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex justify-space-between align-center mb-3">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">Status</span>
|
||||||
|
<VChip
|
||||||
|
:color="resolveCouponStatus().color"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ resolveCouponStatus().label }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-space-between align-center mb-3">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">Times Used</span>
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ coupon.times_used }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">Created</span>
|
||||||
|
<span class="text-body-2">{{ formattedCreatedAt }}</span>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Limits & Expiry -->
|
||||||
|
<VCard title="Limits & Expiry" class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.max_uses"
|
||||||
|
label="Max Uses"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Unlimited"
|
||||||
|
:error-messages="form.errors.max_uses"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<AppTextField
|
||||||
|
v-model="form.expires_at"
|
||||||
|
label="Expiry Date"
|
||||||
|
type="datetime-local"
|
||||||
|
:error-messages="form.errors.expires_at"
|
||||||
|
/>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<VCard>
|
||||||
|
<VCardText>
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
:loading="form.processing"
|
||||||
|
:disabled="form.processing"
|
||||||
|
prepend-icon="tabler-check"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
Update Coupon
|
||||||
|
</VBtn>
|
||||||
|
<Link href="/coupons" class="text-decoration-none">
|
||||||
|
<VBtn
|
||||||
|
variant="outlined"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
210
website/resources/ts/Pages/Admin/Coupons/Index.vue
Normal file
210
website/resources/ts/Pages/Admin/Coupons/Index.vue
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link, router } from '@inertiajs/vue3'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
|
import type { Coupon, PaginatedResponse, Plan, StatusColor } from '@/types'
|
||||||
|
|
||||||
|
interface CouponWithCount extends Coupon {
|
||||||
|
redemptions_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
coupons: PaginatedResponse<CouponWithCount>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AdminLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const tableHeaders = computed(() => [
|
||||||
|
{ title: 'Code', key: 'code', sortable: true },
|
||||||
|
{ title: 'Type', key: 'type', sortable: true },
|
||||||
|
{ title: 'Value', key: 'value', sortable: true, align: 'end' as const },
|
||||||
|
{ title: 'Plans', key: 'applies_to', sortable: false },
|
||||||
|
{ title: 'Usage', key: 'usage', sortable: false, align: 'center' as const },
|
||||||
|
{ title: 'Expires', key: 'expires_at', sortable: true },
|
||||||
|
{ title: 'Status', key: 'status', sortable: false, align: 'center' as const },
|
||||||
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'center' as const },
|
||||||
|
])
|
||||||
|
|
||||||
|
function resolveTypeColor(type: string): StatusColor {
|
||||||
|
return type === 'percentage' ? 'info' : 'warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(coupon: CouponWithCount): string {
|
||||||
|
if (coupon.type === 'percentage') {
|
||||||
|
return `${parseFloat(coupon.value)}%`
|
||||||
|
}
|
||||||
|
return `$${parseFloat(coupon.value).toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string | null): string {
|
||||||
|
if (!dateString) {
|
||||||
|
return 'Never'
|
||||||
|
}
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPlansApplicable(appliesTo: number[] | null): string {
|
||||||
|
if (!appliesTo || appliesTo.length === 0) {
|
||||||
|
return 'All Plans'
|
||||||
|
}
|
||||||
|
return `${appliesTo.length} plan${appliesTo.length > 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCouponStatus(coupon: CouponWithCount): { label: string; color: StatusColor } {
|
||||||
|
if (!coupon.active) {
|
||||||
|
return { label: 'Inactive', color: 'error' }
|
||||||
|
}
|
||||||
|
if (coupon.expires_at && new Date(coupon.expires_at) < new Date()) {
|
||||||
|
return { label: 'Expired', color: 'secondary' }
|
||||||
|
}
|
||||||
|
if (coupon.max_uses !== null && coupon.times_used >= coupon.max_uses) {
|
||||||
|
return { label: 'Exhausted', color: 'warning' }
|
||||||
|
}
|
||||||
|
return { label: 'Active', color: 'success' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivateCoupon(coupon: CouponWithCount): void {
|
||||||
|
if (confirm(`Are you sure you want to deactivate coupon "${coupon.code}"?`)) {
|
||||||
|
router.delete(`/coupons/${coupon.id}`, {
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-bold">
|
||||||
|
Coupons
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Manage discount coupons and promotions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/coupons/create">
|
||||||
|
<VBtn color="primary" prepend-icon="tabler-plus">
|
||||||
|
Create Coupon
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coupons Table -->
|
||||||
|
<VCard>
|
||||||
|
<VDataTable
|
||||||
|
:headers="tableHeaders"
|
||||||
|
:items="coupons.data"
|
||||||
|
:items-per-page="25"
|
||||||
|
hover
|
||||||
|
class="text-no-wrap"
|
||||||
|
>
|
||||||
|
<!-- Code -->
|
||||||
|
<template #item.code="{ item }">
|
||||||
|
<span class="font-weight-medium font-monospace">{{ item.code }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Type -->
|
||||||
|
<template #item.type="{ item }">
|
||||||
|
<VChip
|
||||||
|
:color="resolveTypeColor(item.type)"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ item.type }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Value -->
|
||||||
|
<template #item.value="{ item }">
|
||||||
|
<span class="font-weight-medium">{{ formatValue(item) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Plans Applicable -->
|
||||||
|
<template #item.applies_to="{ item }">
|
||||||
|
<VChip
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
:color="!item.applies_to || item.applies_to.length === 0 ? 'success' : 'secondary'"
|
||||||
|
>
|
||||||
|
{{ formatPlansApplicable(item.applies_to) }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Usage -->
|
||||||
|
<template #item.usage="{ item }">
|
||||||
|
<span class="font-weight-medium">
|
||||||
|
{{ item.redemptions_count }}
|
||||||
|
</span>
|
||||||
|
<span class="text-medium-emphasis">
|
||||||
|
/ {{ item.max_uses ?? '∞' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Expires -->
|
||||||
|
<template #item.expires_at="{ item }">
|
||||||
|
<span :class="{ 'text-error': item.expires_at && new Date(item.expires_at) < new Date() }">
|
||||||
|
{{ formatDate(item.expires_at) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<template #item.status="{ item }">
|
||||||
|
<VChip
|
||||||
|
:color="resolveCouponStatus(item).color"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ resolveCouponStatus(item).label }}
|
||||||
|
</VChip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<VMenu>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<VBtn
|
||||||
|
icon="tabler-dots-vertical"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
v-bind="menuProps"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<VList density="compact">
|
||||||
|
<Link :href="`/coupons/${item.id}/edit`" class="text-decoration-none">
|
||||||
|
<VListItem prepend-icon="tabler-edit">
|
||||||
|
<VListItemTitle>Edit</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</Link>
|
||||||
|
<VListItem
|
||||||
|
v-if="item.active"
|
||||||
|
prepend-icon="tabler-ban"
|
||||||
|
@click="deactivateCoupon(item)"
|
||||||
|
>
|
||||||
|
<VListItemTitle>Deactivate</VListItemTitle>
|
||||||
|
</VListItem>
|
||||||
|
</VList>
|
||||||
|
</VMenu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- No data -->
|
||||||
|
<template #no-data>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<VIcon icon="tabler-discount-2" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">
|
||||||
|
No coupons found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VDataTable>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
150
website/resources/ts/Pages/Services/Index.vue
Normal file
150
website/resources/ts/Pages/Services/Index.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { Link } from '@inertiajs/vue3'
|
||||||
|
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||||
|
import { resolveServiceStatusColor, resolveServiceTypeColor, formatPrice } from '@/utils/resolvers'
|
||||||
|
import type { Service } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
services: Service[]
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AccountLayout })
|
||||||
|
|
||||||
|
defineProps<Props>()
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '--'
|
||||||
|
return new Date(dateStr).toLocaleDateString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div class="text-h4 font-weight-bold">
|
||||||
|
Services
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/plans"
|
||||||
|
class="text-decoration-none"
|
||||||
|
>
|
||||||
|
<VBtn>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-plus"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
Order New Service
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VCard v-if="services.length === 0">
|
||||||
|
<VCardText class="text-center py-12">
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-server-off"
|
||||||
|
size="48"
|
||||||
|
class="text-medium-emphasis mb-4"
|
||||||
|
/>
|
||||||
|
<div class="text-h6 text-medium-emphasis mb-2">
|
||||||
|
No services yet
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||||
|
You don't have any services. Browse our plans to get started.
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/plans"
|
||||||
|
class="text-decoration-none"
|
||||||
|
>
|
||||||
|
<VBtn>Browse Plans</VBtn>
|
||||||
|
</Link>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<VCard v-else>
|
||||||
|
<VTable hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Service</th>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Renewal Date</th>
|
||||||
|
<th class="text-end">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="service in services"
|
||||||
|
:key="service.id"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<div class="font-weight-medium">
|
||||||
|
{{ service.hostname || service.domain || `Service #${service.id}` }}
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
{{ service.platform }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ service.plan?.name || '--' }}
|
||||||
|
<div
|
||||||
|
v-if="service.plan"
|
||||||
|
class="text-body-2 text-medium-emphasis"
|
||||||
|
>
|
||||||
|
{{ formatPrice(service.plan.price, service.plan.billing_cycle) }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveServiceTypeColor(service.service_type)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ service.service_type }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveServiceStatusColor(service.status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ service.status }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="service.ipv4_address">{{ service.ipv4_address }}</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
>--</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ service.subscription?.current_period_end ? formatDate(service.subscription.current_period_end) : formatDate(null) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<Link
|
||||||
|
:href="`/services/${service.id}`"
|
||||||
|
class="text-decoration-none"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-eye"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
Manage
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
390
website/resources/ts/Pages/Services/Show.vue
Normal file
390
website/resources/ts/Pages/Services/Show.vue
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Link } from '@inertiajs/vue3'
|
||||||
|
import AccountLayout from '@/Layouts/AccountLayout.vue'
|
||||||
|
import {
|
||||||
|
resolveServiceStatusColor,
|
||||||
|
resolveServiceTypeColor,
|
||||||
|
resolvePlatformUrl,
|
||||||
|
formatPrice,
|
||||||
|
} from '@/utils/resolvers'
|
||||||
|
import type { Service } from '@/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
service: Service
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AccountLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '--'
|
||||||
|
return new Date(dateStr).toLocaleDateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return '--'
|
||||||
|
return new Date(dateStr).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlPanelUrl = computed<string | null>(() => {
|
||||||
|
return resolvePlatformUrl(props.service.platform, props.service.platform_service_id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSuspended = computed<boolean>(() => props.service.status === 'suspended')
|
||||||
|
const isTerminated = computed<boolean>(() => props.service.status === 'terminated')
|
||||||
|
|
||||||
|
const platformLabel = computed<string>(() => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
virtfusion: 'VirtFusion',
|
||||||
|
synergycp: 'SynergyCP',
|
||||||
|
enhance: 'Enhance',
|
||||||
|
pterodactyl: 'Pterodactyl',
|
||||||
|
}
|
||||||
|
return labels[props.service.platform] ?? props.service.platform
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<Link
|
||||||
|
href="/services"
|
||||||
|
class="text-primary text-body-2 text-decoration-none"
|
||||||
|
>
|
||||||
|
← Back to Services
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Header -->
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center ga-3">
|
||||||
|
<div class="text-h4 font-weight-bold">
|
||||||
|
{{ service.hostname || service.domain || `Service #${service.id}` }}
|
||||||
|
</div>
|
||||||
|
<VChip
|
||||||
|
:color="resolveServiceStatusColor(service.status)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ service.status }}
|
||||||
|
</VChip>
|
||||||
|
<VChip
|
||||||
|
:color="resolveServiceTypeColor(service.service_type)"
|
||||||
|
size="small"
|
||||||
|
class="text-capitalize"
|
||||||
|
>
|
||||||
|
{{ service.service_type }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||||
|
Managed by {{ platformLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VBtn
|
||||||
|
v-if="controlPanelUrl && !isTerminated"
|
||||||
|
:href="controlPanelUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-external-link"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
Open Control Panel
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Suspended Notice -->
|
||||||
|
<VAlert
|
||||||
|
v-if="isSuspended"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<VAlertTitle>Service Suspended</VAlertTitle>
|
||||||
|
This service was suspended on {{ formatDateTime(service.suspended_at) }}.
|
||||||
|
Please contact support or check your billing status to resolve this issue.
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<!-- Terminated Notice -->
|
||||||
|
<VAlert
|
||||||
|
v-if="isTerminated"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<VAlertTitle>Service Terminated</VAlertTitle>
|
||||||
|
This service was terminated on {{ formatDateTime(service.terminated_at) }}.
|
||||||
|
Data may no longer be recoverable.
|
||||||
|
</VAlert>
|
||||||
|
|
||||||
|
<VRow>
|
||||||
|
<!-- Service Details -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
lg="8"
|
||||||
|
>
|
||||||
|
<!-- Plan & Pricing -->
|
||||||
|
<VCard class="mb-6">
|
||||||
|
<VCardTitle>Plan Details</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Plan
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 font-weight-medium mt-1">
|
||||||
|
{{ service.plan?.name || '--' }}
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Price
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
{{ service.plan ? formatPrice(service.plan.price, service.plan.billing_cycle) : '--' }}
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Service Type
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 text-capitalize mt-1">
|
||||||
|
{{ service.service_type }}
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Platform
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
{{ platformLabel }}
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Billing Cycle
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 text-capitalize mt-1">
|
||||||
|
{{ service.plan?.billing_cycle || '--' }}
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Auto Renew
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
<VChip
|
||||||
|
:color="service.auto_renew ? 'success' : 'secondary'"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ service.auto_renew ? 'Enabled' : 'Disabled' }}
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<!-- Plan Features -->
|
||||||
|
<div
|
||||||
|
v-if="service.plan?.features && Object.keys(service.plan.features).length > 0"
|
||||||
|
class="mt-6"
|
||||||
|
>
|
||||||
|
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||||
|
Plan Features
|
||||||
|
</div>
|
||||||
|
<VList density="compact">
|
||||||
|
<VListItem
|
||||||
|
v-for="(value, key) in service.plan.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>
|
||||||
|
|
||||||
|
<!-- Network Information -->
|
||||||
|
<VCard class="mb-6">
|
||||||
|
<VCardTitle>Network Information</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
IPv4 Address
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
>Not assigned</span>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
IPv6 Address
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
<code v-if="service.ipv6_address">{{ service.ipv6_address }}</code>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
>Not assigned</span>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Hostname
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
<code v-if="service.hostname">{{ service.hostname }}</code>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
>Not set</span>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="6">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Domain
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
<code v-if="service.domain">{{ service.domain }}</code>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-medium-emphasis"
|
||||||
|
>Not set</span>
|
||||||
|
</div>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<VCol
|
||||||
|
cols="12"
|
||||||
|
lg="4"
|
||||||
|
>
|
||||||
|
<!-- Important Dates -->
|
||||||
|
<VCard class="mb-6">
|
||||||
|
<VCardTitle>Important Dates</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex flex-column ga-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Created
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
{{ formatDate(service.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Provisioned
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
{{ formatDate(service.provisioned_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="service.subscription?.current_period_end">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Next Renewal
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 mt-1">
|
||||||
|
{{ formatDate(service.subscription.current_period_end) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="service.suspended_at">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Suspended
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 text-error mt-1">
|
||||||
|
{{ formatDate(service.suspended_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="service.terminated_at">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Terminated
|
||||||
|
</div>
|
||||||
|
<div class="text-body-1 text-error mt-1">
|
||||||
|
{{ formatDate(service.terminated_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<VCard v-if="!isTerminated">
|
||||||
|
<VCardTitle>Quick Actions</VCardTitle>
|
||||||
|
<VCardText>
|
||||||
|
<div class="d-flex flex-column ga-3">
|
||||||
|
<VBtn
|
||||||
|
v-if="controlPanelUrl"
|
||||||
|
:href="controlPanelUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
block
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-external-link"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
Open Control Panel
|
||||||
|
</VBtn>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
v-if="service.subscription_id"
|
||||||
|
:href="`/subscriptions/${service.subscription_id}`"
|
||||||
|
class="text-decoration-none"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
block
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-receipt"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
View Subscription
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/billing"
|
||||||
|
class="text-decoration-none"
|
||||||
|
>
|
||||||
|
<VBtn
|
||||||
|
block
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<VIcon
|
||||||
|
icon="tabler-credit-card"
|
||||||
|
start
|
||||||
|
/>
|
||||||
|
Billing & Payments
|
||||||
|
</VBtn>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ export interface NavItem {
|
|||||||
|
|
||||||
export const accountNavItems: NavItem[] = [
|
export const accountNavItems: NavItem[] = [
|
||||||
{ title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' },
|
{ title: 'Dashboard', href: '/dashboard', icon: 'tabler-smart-home', matchPrefix: '/dashboard' },
|
||||||
|
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
|
||||||
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
|
{ title: 'Subscriptions', href: '/subscriptions', icon: 'tabler-receipt', matchPrefix: '/subscriptions' },
|
||||||
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
{ title: 'Billing', href: '/billing', icon: 'tabler-credit-card', matchPrefix: '/billing' },
|
||||||
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
{ title: 'Plans', href: '/plans', icon: 'tabler-package', matchPrefix: '/plans' },
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export const adminNavItems: NavItem[] = [
|
|||||||
{ 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: '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' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -111,4 +111,56 @@ export interface PaginationLink {
|
|||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
subscription_id: number | null
|
||||||
|
plan_id: number
|
||||||
|
service_type: string
|
||||||
|
platform: string
|
||||||
|
platform_service_id: string | null
|
||||||
|
status: 'pending' | 'active' | 'suspended' | 'terminated'
|
||||||
|
ipv4_address: string | null
|
||||||
|
ipv6_address: string | null
|
||||||
|
hostname: string | null
|
||||||
|
domain: string | null
|
||||||
|
auto_renew: boolean
|
||||||
|
provisioned_at: string | null
|
||||||
|
suspended_at: string | null
|
||||||
|
terminated_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
plan?: Plan
|
||||||
|
subscription?: Subscription
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Coupon {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
type: 'percentage' | 'fixed'
|
||||||
|
value: string
|
||||||
|
currency: string | null
|
||||||
|
applies_to: number[] | null
|
||||||
|
max_uses: number | null
|
||||||
|
times_used: number
|
||||||
|
active: boolean
|
||||||
|
expires_at: string | null
|
||||||
|
created_at: string
|
||||||
|
redemptions_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponRedemption {
|
||||||
|
id: number
|
||||||
|
coupon_id: number
|
||||||
|
user_id: number
|
||||||
|
subscription_id: number | null
|
||||||
|
discount_amount: string
|
||||||
|
created_at: string
|
||||||
|
user?: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary'
|
export type StatusColor = 'success' | 'error' | 'warning' | 'info' | 'secondary'
|
||||||
|
|||||||
@@ -31,6 +31,38 @@ export function resolveTransactionStatusColor(status: string): StatusColor {
|
|||||||
return map[status] ?? 'secondary'
|
return map[status] ?? 'secondary'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveServiceStatusColor(status: string): StatusColor {
|
||||||
|
const map: Record<string, StatusColor> = {
|
||||||
|
active: 'success',
|
||||||
|
pending: 'warning',
|
||||||
|
suspended: 'error',
|
||||||
|
terminated: 'secondary',
|
||||||
|
}
|
||||||
|
return map[status] ?? 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveServiceTypeColor(type: string): StatusColor {
|
||||||
|
const map: Record<string, StatusColor> = {
|
||||||
|
vps: 'info',
|
||||||
|
dedicated: 'success',
|
||||||
|
'web-hosting': 'warning',
|
||||||
|
'game-server': 'error',
|
||||||
|
}
|
||||||
|
return map[type] ?? 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePlatformUrl(platform: string, platformServiceId: string | null): string | null {
|
||||||
|
if (!platformServiceId) return null
|
||||||
|
|
||||||
|
const urls: Record<string, string> = {
|
||||||
|
virtfusion: `https://panel.ezscale.cloud/server/${platformServiceId}`,
|
||||||
|
synergycp: `https://dedicated.ezscale.cloud/server/${platformServiceId}`,
|
||||||
|
enhance: `https://hosting.ezscale.cloud/server/${platformServiceId}`,
|
||||||
|
pterodactyl: `https://game.ezscale.cloud/server/${platformServiceId}`,
|
||||||
|
}
|
||||||
|
return urls[platform] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
export function formatPrice(price: string | number, cycle?: string): string {
|
export function formatPrice(price: string | number, cycle?: string): string {
|
||||||
const amount = parseFloat(String(price)).toFixed(2)
|
const amount = parseFloat(String(price)).toFixed(2)
|
||||||
return cycle ? `$${amount}/${cycle}` : `$${amount}`
|
return cycle ? `$${amount}/${cycle}` : `$${amount}`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Account\CheckoutController;
|
|||||||
use App\Http\Controllers\Account\DashboardController;
|
use App\Http\Controllers\Account\DashboardController;
|
||||||
use App\Http\Controllers\Account\PlanController;
|
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\SubscriptionController;
|
use App\Http\Controllers\Account\SubscriptionController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -28,6 +29,9 @@ Route::get('/checkout/paypal/cancel', [CheckoutController::class, 'paypalCancel'
|
|||||||
Route::get('/checkout/{plan}', [CheckoutController::class, 'show'])->name('account.checkout.show');
|
Route::get('/checkout/{plan}', [CheckoutController::class, 'show'])->name('account.checkout.show');
|
||||||
Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('account.checkout.store');
|
Route::post('/checkout/{plan}', [CheckoutController::class, 'store'])->name('account.checkout.store');
|
||||||
|
|
||||||
|
// Services
|
||||||
|
Route::resource('services', ServiceController::class)->only(['index', 'show'])->names('account.services');
|
||||||
|
|
||||||
// Subscriptions
|
// Subscriptions
|
||||||
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');
|
Route::get('/subscriptions', [SubscriptionController::class, 'index'])->name('account.subscriptions.index');
|
||||||
Route::get('/subscriptions/{subscription}', [SubscriptionController::class, 'show'])->name('account.subscriptions.show');
|
Route::get('/subscriptions/{subscription}', [SubscriptionController::class, 'show'])->name('account.subscriptions.show');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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\InvoiceController;
|
use App\Http\Controllers\Admin\InvoiceController;
|
||||||
@@ -31,3 +32,12 @@ Route::post('services/{service}/terminate', [ServiceController::class, 'terminat
|
|||||||
|
|
||||||
Route::resource('invoices', InvoiceController::class)->only(['index', 'show']);
|
Route::resource('invoices', InvoiceController::class)->only(['index', 'show']);
|
||||||
Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void');
|
Route::post('invoices/{invoice}/void', [InvoiceController::class, 'void'])->name('invoices.void');
|
||||||
|
|
||||||
|
Route::resource('coupons', CouponController::class)->names([
|
||||||
|
'index' => 'admin.coupons.index',
|
||||||
|
'create' => 'admin.coupons.create',
|
||||||
|
'store' => 'admin.coupons.store',
|
||||||
|
'edit' => 'admin.coupons.edit',
|
||||||
|
'update' => 'admin.coupons.update',
|
||||||
|
'destroy' => 'admin.coupons.destroy',
|
||||||
|
])->except(['show']);
|
||||||
|
|||||||
Reference in New Issue
Block a user