php-commerce/View/Modal/Admin/CouponManager.php
Snider a774f4e285 refactor: migrate namespace from Core\Commerce to Core\Mod\Commerce
Align commerce module with the monorepo module structure by updating
all namespaces to use the Core\Mod\Commerce convention. This change
supports the recent monorepo separation and ensures consistency with
other modules.

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

608 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\View\Modal\Admin;
use Core\Mod\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']);
}
}