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:
Claude Dev
2026-02-09 10:38:12 -05:00
parent 2061b1f3e3
commit d9ec414264
18 changed files with 1639 additions and 54 deletions

108
TASKS.md
View File

@@ -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

View 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,
]);
}
}

View 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.');
}
}

View 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.',
];
}
}

View File

@@ -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;
} }

View File

@@ -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'),
]; ];
} }

View File

@@ -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');
});
}
};

View 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>

View 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>

View 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 ?? '&infin;' }}
</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>

View 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>

View 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"
>
&larr; 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>

View File

@@ -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' },

View File

@@ -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' },
] ]

View File

@@ -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'

View File

@@ -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}`

View File

@@ -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');

View File

@@ -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']);