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