php-commerce/View/Modal/Admin/CouponManager.php
Snider 8f27fe85c3 refactor: update Tenant module imports after namespace migration
Updates all references from Core\Mod\Tenant to Core\Tenant following
the monorepo separation. The Tenant module now lives in its own package
with the simplified namespace.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:39:12 +00:00

608 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\View\Modal\Admin;
use Core\Tenant\Models\Package;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithPagination;
use Core\Mod\Commerce\Models\Coupon;
use Core\Mod\Commerce\Services\CouponService;
#[Layout('hub::admin.layouts.app')]
#[Title('Coupons')]
class CouponManager extends Component
{
use WithPagination;
// Bulk selection
public array $selected = [];
public bool $selectAll = false;
public bool $showBulkDeleteModal = false;
public bool $showBulkGenerateModal = false;
public bool $showModal = false;
public ?int $editingId = null;
// Filters
public string $search = '';
public string $statusFilter = '';
// Form fields
public string $code = '';
public string $name = '';
public string $description = '';
public string $type = 'percentage';
public float $value = 0;
public ?float $min_amount = null;
public ?float $max_discount = null;
public string $applies_to = 'all';
public array $package_ids = [];
public ?int $max_uses = null;
public int $max_uses_per_workspace = 1;
public string $duration = 'once';
public ?int $duration_months = null;
public ?string $valid_from = null;
public ?string $valid_until = null;
public bool $is_active = true;
// Bulk generation fields
public int $bulk_count = 10;
public string $bulk_code_prefix = '';
public string $bulk_name = '';
public string $bulk_type = 'percentage';
public float $bulk_value = 0;
public ?float $bulk_min_amount = null;
public ?float $bulk_max_discount = null;
public string $bulk_applies_to = 'all';
public array $bulk_package_ids = [];
public ?int $bulk_max_uses = 1;
public int $bulk_max_uses_per_workspace = 1;
public string $bulk_duration = 'once';
public ?int $bulk_duration_months = null;
public ?string $bulk_valid_from = null;
public ?string $bulk_valid_until = null;
public bool $bulk_is_active = true;
/**
* Authorize access - Hades tier only.
*/
public function mount(): void
{
if (! auth()->user()?->isHades()) {
abort(403, 'Hades tier required for coupon management.');
}
}
protected function rules(): array
{
$uniqueRule = $this->editingId
? 'unique:coupons,code,'.$this->editingId
: 'unique:coupons,code';
return [
'code' => ['required', 'string', 'max:50', $uniqueRule, 'regex:/^[A-Z0-9_-]+$/'],
'name' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:500'],
'type' => ['required', 'in:percentage,fixed_amount'],
'value' => ['required', 'numeric', 'min:0.01'],
'min_amount' => ['nullable', 'numeric', 'min:0'],
'max_discount' => ['nullable', 'numeric', 'min:0'],
'applies_to' => ['required', 'in:all,packages'],
'package_ids' => ['array'],
'max_uses' => ['nullable', 'integer', 'min:1'],
'max_uses_per_workspace' => ['required', 'integer', 'min:1'],
'duration' => ['required', 'in:once,repeating,forever'],
'duration_months' => ['nullable', 'integer', 'min:1', 'max:24'],
'valid_from' => ['nullable', 'date'],
'valid_until' => ['nullable', 'date', 'after_or_equal:valid_from'],
'is_active' => ['boolean'],
];
}
protected $messages = [
'code.regex' => 'Code must contain only uppercase letters, numbers, hyphens, and underscores.',
];
public function updatingSearch(): void
{
$this->resetPage();
$this->selected = [];
$this->selectAll = false;
}
public function updatedSelectAll(bool $value): void
{
if ($value) {
$this->selected = $this->coupons->pluck('id')->map(fn ($id) => (string) $id)->all();
} else {
$this->selected = [];
}
}
public function exportSelected(): void
{
if (empty($this->selected)) {
session()->flash('error', __('commerce::commerce.bulk.no_selection'));
return;
}
$coupons = Coupon::whereIn('id', $this->selected)->get();
$csv = "Code,Name,Type,Value,Duration,Max Uses,Used Count,Active,Valid From,Valid Until\n";
foreach ($coupons as $coupon) {
$csv .= sprintf(
"%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n",
$coupon->code,
str_replace(',', ' ', $coupon->name),
$coupon->type,
$coupon->value,
$coupon->duration,
$coupon->max_uses ?? 'unlimited',
$coupon->used_count,
$coupon->is_active ? 'Yes' : 'No',
$coupon->valid_from?->format('Y-m-d') ?? '',
$coupon->valid_until?->format('Y-m-d') ?? ''
);
}
$this->dispatch('download-csv', filename: 'coupons-export-'.now()->format('Y-m-d').'.csv', content: $csv);
session()->flash('message', __('commerce::commerce.bulk.export_success', ['count' => count($this->selected)]));
$this->selected = [];
$this->selectAll = false;
}
public function bulkActivate(): void
{
if (empty($this->selected)) {
session()->flash('error', __('commerce::commerce.bulk.no_selection'));
return;
}
$count = Coupon::whereIn('id', $this->selected)->update(['is_active' => true]);
session()->flash('message', __('commerce::commerce.bulk.activated', ['count' => $count]));
$this->selected = [];
$this->selectAll = false;
}
public function bulkDeactivate(): void
{
if (empty($this->selected)) {
session()->flash('error', __('commerce::commerce.bulk.no_selection'));
return;
}
$count = Coupon::whereIn('id', $this->selected)->update(['is_active' => false]);
session()->flash('message', __('commerce::commerce.bulk.deactivated', ['count' => $count]));
$this->selected = [];
$this->selectAll = false;
}
public function confirmBulkDelete(): void
{
if (empty($this->selected)) {
session()->flash('error', __('commerce::commerce.bulk.no_selection'));
return;
}
$this->showBulkDeleteModal = true;
}
public function bulkDelete(): void
{
if (empty($this->selected)) {
return;
}
// Only delete coupons that haven't been used
$deletable = Coupon::whereIn('id', $this->selected)->where('used_count', 0)->pluck('id');
$skipped = count($this->selected) - $deletable->count();
Coupon::whereIn('id', $deletable)->delete();
$message = __('commerce::commerce.bulk.deleted', ['count' => $deletable->count()]);
if ($skipped > 0) {
$message .= ' '.__('commerce::commerce.bulk.skipped_used', ['count' => $skipped]);
}
session()->flash('message', $message);
$this->selected = [];
$this->selectAll = false;
$this->showBulkDeleteModal = false;
}
public function closeBulkDeleteModal(): void
{
$this->showBulkDeleteModal = false;
}
public function openBulkGenerate(): void
{
$this->resetBulkForm();
$this->showBulkGenerateModal = true;
}
public function closeBulkGenerateModal(): void
{
$this->showBulkGenerateModal = false;
$this->resetBulkForm();
}
protected function resetBulkForm(): void
{
$this->bulk_count = 10;
$this->bulk_code_prefix = '';
$this->bulk_name = '';
$this->bulk_type = 'percentage';
$this->bulk_value = 0;
$this->bulk_min_amount = null;
$this->bulk_max_discount = null;
$this->bulk_applies_to = 'all';
$this->bulk_package_ids = [];
$this->bulk_max_uses = 1;
$this->bulk_max_uses_per_workspace = 1;
$this->bulk_duration = 'once';
$this->bulk_duration_months = null;
$this->bulk_valid_from = null;
$this->bulk_valid_until = null;
$this->bulk_is_active = true;
}
protected function bulkGenerateRules(): array
{
return [
'bulk_count' => ['required', 'integer', 'min:1', 'max:100'],
'bulk_code_prefix' => ['nullable', 'string', 'max:20', 'regex:/^[A-Z0-9_-]*$/'],
'bulk_name' => ['required', 'string', 'max:100'],
'bulk_type' => ['required', 'in:percentage,fixed_amount'],
'bulk_value' => ['required', 'numeric', 'min:0.01'],
'bulk_min_amount' => ['nullable', 'numeric', 'min:0'],
'bulk_max_discount' => ['nullable', 'numeric', 'min:0'],
'bulk_applies_to' => ['required', 'in:all,packages'],
'bulk_package_ids' => ['array'],
'bulk_max_uses' => ['nullable', 'integer', 'min:1'],
'bulk_max_uses_per_workspace' => ['required', 'integer', 'min:1'],
'bulk_duration' => ['required', 'in:once,repeating,forever'],
'bulk_duration_months' => ['nullable', 'integer', 'min:1', 'max:24'],
'bulk_valid_from' => ['nullable', 'date'],
'bulk_valid_until' => ['nullable', 'date', 'after_or_equal:bulk_valid_from'],
'bulk_is_active' => ['boolean'],
];
}
public function generateBulk(CouponService $couponService): void
{
$this->bulk_code_prefix = strtoupper($this->bulk_code_prefix);
$this->validate($this->bulkGenerateRules());
$baseData = [
'code_prefix' => $this->bulk_code_prefix ?: null,
'name' => $this->bulk_name,
'type' => $this->bulk_type,
'value' => $this->bulk_value,
'min_amount' => $this->bulk_min_amount,
'max_discount' => $this->bulk_max_discount,
'applies_to' => $this->bulk_applies_to,
'package_ids' => $this->bulk_applies_to === 'packages' ? $this->bulk_package_ids : null,
'max_uses' => $this->bulk_max_uses,
'max_uses_per_workspace' => $this->bulk_max_uses_per_workspace,
'duration' => $this->bulk_duration,
'duration_months' => $this->bulk_duration === 'repeating' ? $this->bulk_duration_months : null,
'valid_from' => $this->bulk_valid_from ? \Carbon\Carbon::parse($this->bulk_valid_from) : null,
'valid_until' => $this->bulk_valid_until ? \Carbon\Carbon::parse($this->bulk_valid_until) : null,
'is_active' => $this->bulk_is_active,
];
$coupons = $couponService->generateBulk($this->bulk_count, $baseData);
session()->flash('message', __('commerce::commerce.coupons.bulk.generated', ['count' => count($coupons)]));
$this->closeBulkGenerateModal();
}
public function openCreate(): void
{
$this->resetForm();
// Generate a random code
$this->code = strtoupper(substr(md5(uniqid()), 0, 8));
$this->showModal = true;
}
public function openEdit(int $id): void
{
$coupon = Coupon::findOrFail($id);
$this->editingId = $id;
$this->code = $coupon->code;
$this->name = $coupon->name;
$this->description = $coupon->description ?? '';
$this->type = $coupon->type;
$this->value = (float) $coupon->value;
$this->min_amount = $coupon->min_amount ? (float) $coupon->min_amount : null;
$this->max_discount = $coupon->max_discount ? (float) $coupon->max_discount : null;
$this->applies_to = $coupon->applies_to ?? 'all';
$this->package_ids = $coupon->package_ids ?? [];
$this->max_uses = $coupon->max_uses;
$this->max_uses_per_workspace = $coupon->max_uses_per_workspace ?? 1;
$this->duration = $coupon->duration ?? 'once';
$this->duration_months = $coupon->duration_months;
$this->valid_from = $coupon->valid_from?->format('Y-m-d');
$this->valid_until = $coupon->valid_until?->format('Y-m-d');
$this->is_active = $coupon->is_active;
$this->showModal = true;
}
public function save(): void
{
// Ensure code is uppercase
$this->code = strtoupper($this->code);
$this->validate();
$data = [
'code' => $this->code,
'name' => $this->name,
'description' => $this->description ?: null,
'type' => $this->type,
'value' => $this->value,
'min_amount' => $this->min_amount,
'max_discount' => $this->max_discount,
'applies_to' => $this->applies_to,
'package_ids' => $this->applies_to === 'packages' ? $this->package_ids : null,
'max_uses' => $this->max_uses,
'max_uses_per_workspace' => $this->max_uses_per_workspace,
'duration' => $this->duration,
'duration_months' => $this->duration === 'repeating' ? $this->duration_months : null,
'valid_from' => $this->valid_from ? \Carbon\Carbon::parse($this->valid_from) : null,
'valid_until' => $this->valid_until ? \Carbon\Carbon::parse($this->valid_until) : null,
'is_active' => $this->is_active,
];
if ($this->editingId) {
Coupon::findOrFail($this->editingId)->update($data);
session()->flash('message', 'Coupon updated successfully.');
} else {
Coupon::create($data);
session()->flash('message', 'Coupon created successfully.');
}
$this->closeModal();
}
public function toggleActive(int $id): void
{
$coupon = Coupon::findOrFail($id);
$coupon->update(['is_active' => ! $coupon->is_active]);
session()->flash('message', $coupon->is_active ? 'Coupon activated.' : 'Coupon deactivated.');
}
public function delete(int $id): void
{
$coupon = Coupon::findOrFail($id);
// Check if coupon has been used
if ($coupon->used_count > 0) {
session()->flash('error', 'Cannot delete coupon that has been used. Deactivate it instead.');
return;
}
$coupon->delete();
session()->flash('message', 'Coupon deleted successfully.');
}
public function closeModal(): void
{
$this->showModal = false;
$this->resetForm();
}
protected function resetForm(): void
{
$this->editingId = null;
$this->code = '';
$this->name = '';
$this->description = '';
$this->type = 'percentage';
$this->value = 0;
$this->min_amount = null;
$this->max_discount = null;
$this->applies_to = 'all';
$this->package_ids = [];
$this->max_uses = null;
$this->max_uses_per_workspace = 1;
$this->duration = 'once';
$this->duration_months = null;
$this->valid_from = null;
$this->valid_until = null;
$this->is_active = true;
}
#[Computed]
public function coupons()
{
return Coupon::query()
->when($this->search, function ($query) {
$query->where(function ($q) {
$q->where('code', 'like', "%{$this->search}%")
->orWhere('name', 'like', "%{$this->search}%");
});
})
->when($this->statusFilter === 'active', fn ($q) => $q->where('is_active', true))
->when($this->statusFilter === 'inactive', fn ($q) => $q->where('is_active', false))
->when($this->statusFilter === 'valid', fn ($q) => $q->valid())
->when($this->statusFilter === 'expired', function ($q) {
$q->where(function ($query) {
$query->where('valid_until', '<', now())
->orWhere(function ($q2) {
$q2->whereNotNull('max_uses')
->whereRaw('used_count >= max_uses');
});
});
})
->latest()
->paginate(25);
}
#[Computed]
public function packages()
{
return Package::where('is_active', true)->orderBy('name')->get(['id', 'code', 'name']);
}
#[Computed]
public function statusOptions(): array
{
return [
'active' => 'Active',
'inactive' => 'Inactive',
'valid' => 'Currently valid',
'expired' => 'Expired or maxed',
];
}
#[Computed]
public function tableColumns(): array
{
return [
'Code',
'Name',
'Discount',
'Duration',
['label' => 'Usage', 'align' => 'center'],
['label' => 'Status', 'align' => 'center'],
'Validity',
['label' => 'Actions', 'align' => 'center'],
];
}
#[Computed]
public function tableRowIds(): array
{
return $this->coupons->pluck('id')->all();
}
#[Computed]
public function tableRows(): array
{
return $this->coupons->map(function ($c) {
// Discount display
$discount = $c->isPercentage()
? "{$c->value}% off"
: 'GBP '.number_format($c->value, 2).' off';
$discountLines = [['bold' => $discount]];
if ($c->min_amount) {
$discountLines[] = ['muted' => 'Min: GBP '.number_format($c->min_amount, 2)];
}
// Duration display
$durationLabel = match ($c->duration) {
'once' => 'Once',
'repeating' => "{$c->duration_months} months",
'forever' => 'Forever',
default => ucfirst($c->duration),
};
$durationColor = match ($c->duration) {
'once' => 'gray',
'repeating' => 'blue',
'forever' => 'purple',
default => 'gray',
};
// Status display
$statusLabel = $c->is_active ? ($c->isValid() ? 'Active' : 'Exhausted') : 'Inactive';
$statusColor = $c->is_active ? ($c->isValid() ? 'green' : 'amber') : 'gray';
// Validity display
$validityLines = [];
if ($c->valid_from || $c->valid_until) {
if ($c->valid_from) {
$validityLines[] = ['muted' => 'From: '.$c->valid_from->format('d M Y')];
}
if ($c->valid_until) {
$validityLines[] = $c->valid_until->isPast()
? ['badge' => 'Until: '.$c->valid_until->format('d M Y'), 'color' => 'red']
: ['muted' => 'Until: '.$c->valid_until->format('d M Y')];
}
}
// Actions
$actions = [
['icon' => 'pencil', 'click' => "openEdit({$c->id})", 'title' => 'Edit'],
['icon' => $c->is_active ? 'pause' : 'play', 'click' => "toggleActive({$c->id})", 'title' => $c->is_active ? 'Deactivate' : 'Activate'],
];
if ($c->used_count === 0) {
$actions[] = ['icon' => 'trash', 'click' => "delete({$c->id})", 'confirm' => 'Are you sure you want to delete this coupon?', 'title' => 'Delete', 'class' => 'text-red-600'];
}
return [
['mono' => $c->code],
[
'lines' => array_filter([
['bold' => $c->name],
$c->description ? ['muted' => \Illuminate\Support\Str::limit($c->description, 30)] : null,
]),
],
['lines' => $discountLines],
['badge' => $durationLabel, 'color' => $durationColor],
$c->max_uses ? "{$c->used_count} / {$c->max_uses}" : (string) $c->used_count,
['badge' => $statusLabel, 'color' => $statusColor],
! empty($validityLines) ? ['lines' => $validityLines] : ['muted' => 'No date limits'],
['actions' => $actions],
];
})->all();
}
public function render()
{
return view('commerce::admin.coupon-manager')
->layout('hub::admin.layouts.app', ['title' => 'Coupons']);
}
}