diff --git a/app/Http/Controllers/Admin/AdminPromotionController.php b/app/Http/Controllers/Admin/AdminPromotionController.php new file mode 100644 index 000000000..b53ddaeef --- /dev/null +++ b/app/Http/Controllers/Admin/AdminPromotionController.php @@ -0,0 +1,312 @@ +orderBy('created_at', 'desc') + ->get(); + + return view('admin.promotions.index', [ + 'promotions' => $promotions, + ]); + } + + /** + * Show the form for creating a new promotion. + */ + public function create(): View + { + $customRoles = RolePromotion::getCustomRoles(); + + return view('admin.promotions.create', [ + 'customRoles' => $customRoles, + ]); + } + + /** + * Store a newly created promotion in storage. + */ + public function store(Request $request): RedirectResponse + { + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'applicable_roles' => 'nullable|array', + 'applicable_roles.*' => 'exists:roles,id', + 'additional_days' => 'required|integer|min:0', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'is_active' => 'boolean', + ]); + + if ($validator->fails()) { + return redirect()->back() + ->withErrors($validator) + ->withInput(); + } + + RolePromotion::create([ + 'name' => $request->input('name'), + 'description' => $request->input('description'), + 'applicable_roles' => $request->input('applicable_roles', []), + 'additional_days' => $request->input('additional_days'), + 'start_date' => $request->input('start_date'), + 'end_date' => $request->input('end_date'), + 'is_active' => $request->has('is_active'), + ]); + + return redirect()->route('admin.promotions.index') + ->with('success', 'Promotion created successfully.'); + } + + /** + * Show the form for editing the specified promotion. + */ + public function edit(int $id): View + { + $promotion = RolePromotion::findOrFail($id); + $customRoles = RolePromotion::getCustomRoles(); + + return view('admin.promotions.edit', [ + 'promotion' => $promotion, + 'customRoles' => $customRoles, + ]); + } + + /** + * Update the specified promotion in storage. + */ + public function update(Request $request, int $id): RedirectResponse + { + $promotion = RolePromotion::findOrFail($id); + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'applicable_roles' => 'nullable|array', + 'applicable_roles.*' => 'exists:roles,id', + 'additional_days' => 'required|integer|min:0', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + 'is_active' => 'boolean', + ]); + + if ($validator->fails()) { + return redirect()->back() + ->withErrors($validator) + ->withInput(); + } + + $promotion->update([ + 'name' => $request->input('name'), + 'description' => $request->input('description'), + 'applicable_roles' => $request->input('applicable_roles', []), + 'additional_days' => $request->input('additional_days'), + 'start_date' => $request->input('start_date'), + 'end_date' => $request->input('end_date'), + 'is_active' => $request->has('is_active'), + ]); + + return redirect()->route('admin.promotions.index') + ->with('success', 'Promotion updated successfully.'); + } + + /** + * Remove the specified promotion from storage. + */ + public function destroy(int $id): RedirectResponse + { + $promotion = RolePromotion::findOrFail($id); + $promotion->delete(); + + return redirect()->route('admin.promotions.index') + ->with('success', 'Promotion deleted successfully.'); + } + + /** + * Toggle the active status of the specified promotion. + */ + public function toggle(int $id): RedirectResponse + { + $promotion = RolePromotion::findOrFail($id); + $promotion->update(['is_active' => !$promotion->is_active]); + + $status = $promotion->is_active ? 'activated' : 'deactivated'; + + return redirect()->route('admin.promotions.index') + ->with('success', "Promotion {$status} successfully."); + } + + /** + * Display overall promotion statistics. + */ + public function statistics(Request $request): View + { + // Get date range filter (default to last 30 days) + $endDate = Carbon::now(); + $startDate = Carbon::now()->subDays(30); + + if ($request->has('start_date') && $request->has('end_date')) { + $startDate = Carbon::parse($request->input('start_date')); + $endDate = Carbon::parse($request->input('end_date')); + } elseif ($request->has('period')) { + switch ($request->input('period')) { + case '7days': + $startDate = Carbon::now()->subDays(7); + break; + case '30days': + $startDate = Carbon::now()->subDays(30); + break; + case '90days': + $startDate = Carbon::now()->subDays(90); + break; + case 'year': + $startDate = Carbon::now()->subYear(); + break; + case 'all': + $startDate = null; + break; + } + } + + // Get all promotions with their statistics + $promotions = RolePromotion::withCount(['statistics' => function ($query) use ($startDate, $endDate) { + if ($startDate) { + $query->whereBetween('applied_at', [$startDate, $endDate]); + } + }]) + ->with(['statistics' => function ($query) use ($startDate, $endDate) { + if ($startDate) { + $query->whereBetween('applied_at', [$startDate, $endDate]); + } + $query->with(['user', 'role']); + }]) + ->get(); + + // Calculate overall statistics + $overallStats = [ + 'total_promotions' => $promotions->count(), + 'active_promotions' => $promotions->where('is_active', true)->count(), + 'total_applications' => $promotions->sum('statistics_count'), + 'unique_users' => RolePromotionStat::query() + ->when($startDate, fn($q) => $q->whereBetween('applied_at', [$startDate, $endDate])) + ->distinct('user_id') + ->count('user_id'), + 'total_days_added' => RolePromotionStat::query() + ->when($startDate, fn($q) => $q->whereBetween('applied_at', [$startDate, $endDate])) + ->sum('days_added'), + ]; + + // Get top promotions by usage + $topPromotions = $promotions->sortByDesc('statistics_count')->take(5); + + // Get recent activity + $recentActivity = RolePromotionStat::with(['user', 'promotion', 'role']) + ->when($startDate, fn($q) => $q->whereBetween('applied_at', [$startDate, $endDate])) + ->latest('applied_at') + ->limit(10) + ->get(); + + // Get statistics by role + $statsByRole = RolePromotionStat::query() + ->selectRaw('role_id, COUNT(*) as total_upgrades, SUM(days_added) as total_days, COUNT(DISTINCT user_id) as unique_users') + ->when($startDate, fn($q) => $q->whereBetween('applied_at', [$startDate, $endDate])) + ->groupBy('role_id') + ->with('role') + ->get(); + + return view('admin.promotions.statistics', [ + 'promotions' => $promotions, + 'overallStats' => $overallStats, + 'topPromotions' => $topPromotions, + 'recentActivity' => $recentActivity, + 'statsByRole' => $statsByRole, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'selectedPeriod' => $request->input('period', '30days'), + ]); + } + + /** + * Display statistics for a specific promotion. + */ + public function showStatistics(int $id, Request $request): View + { + $promotion = RolePromotion::findOrFail($id); + + // Get date range filter + $endDate = Carbon::now(); + $startDate = Carbon::now()->subDays(30); + + if ($request->has('start_date') && $request->has('end_date')) { + $startDate = Carbon::parse($request->input('start_date')); + $endDate = Carbon::parse($request->input('end_date')); + } elseif ($request->has('period')) { + switch ($request->input('period')) { + case '7days': + $startDate = Carbon::now()->subDays(7); + break; + case '30days': + $startDate = Carbon::now()->subDays(30); + break; + case '90days': + $startDate = Carbon::now()->subDays(90); + break; + case 'year': + $startDate = Carbon::now()->subYear(); + break; + case 'all': + $startDate = null; + break; + } + } + + // Get promotion statistics + $stats = $promotion->getStatisticsForPeriod($startDate ?? Carbon::createFromTimestamp(0), $endDate); + $statsByRole = $promotion->getStatisticsByRole(); + + // Get applications with users + $applications = RolePromotionStat::forPromotion($id) + ->when($startDate, fn($q) => $q->whereBetween('applied_at', [$startDate, $endDate])) + ->with(['user', 'role']) + ->latest('applied_at') + ->paginate(20); + + // Get daily statistics for chart + $dailyStats = RolePromotionStat::forPromotion($id) + ->when($startDate, fn($q) => $q->whereBetween('applied_at', [$startDate, $endDate])) + ->selectRaw('DATE(applied_at) as date, COUNT(*) as count, SUM(days_added) as days') + ->groupBy('date') + ->orderBy('date') + ->get(); + + return view('admin.promotions.show-statistics', [ + 'promotion' => $promotion, + 'stats' => $stats, + 'statsByRole' => $statsByRole, + 'applications' => $applications, + 'dailyStats' => $dailyStats, + 'startDate' => $startDate, + 'endDate' => $endDate, + 'selectedPeriod' => $request->input('period', '30days'), + ]); + } +} + diff --git a/app/Models/RolePromotion.php b/app/Models/RolePromotion.php new file mode 100644 index 000000000..1de8bebf4 --- /dev/null +++ b/app/Models/RolePromotion.php @@ -0,0 +1,246 @@ + 'boolean', + 'start_date' => 'date', + 'end_date' => 'date', + 'additional_days' => 'integer', + 'applicable_roles' => 'array', + ]; + + /** + * Get the statistics for this promotion + */ + public function statistics(): HasMany + { + return $this->hasMany(RolePromotionStat::class); + } + + /** + * Get the roles this promotion applies to + */ + public function getApplicableRolesModels() + { + if (empty($this->applicable_roles)) { + return static::getCustomRoles(); + } + + return Role::whereIn('id', $this->applicable_roles)->get(); + } + + /** + * Scope to get only valid (non-expired) promotions + */ + public function scopeValid($query) + { + $now = Carbon::now(); + return $query->where('is_active', true) + ->where(function ($q) use ($now) { + $q->whereNull('end_date') + ->orWhere('end_date', '>=', $now); + }) + ->where(function ($q) use ($now) { + $q->whereNull('start_date') + ->orWhere('start_date', '<=', $now); + }); + } + + /** + * Get custom roles (excluding system roles) + */ + public static function getCustomRoles() + { + $systemRoles = ['Admin', 'User', 'Moderator', 'Disabled', 'Friend']; + return Role::whereNotIn('name', $systemRoles)->get(); + } + + /** + * Check if the promotion is currently active + */ + public function isCurrentlyActive(): bool + { + if (!$this->is_active) { + return false; + } + + $now = Carbon::now(); + + if ($this->start_date && $now->lt($this->start_date)) { + return false; + } + + if ($this->end_date && $now->gt($this->end_date)) { + return false; + } + + return true; + } + + /** + * Get active promotions for a specific role + */ + public static function getActivePromotions(?int $roleId = null) + { + $query = static::where('is_active', true); + + $now = Carbon::now(); + $query->where(function ($q) use ($now) { + $q->whereNull('start_date') + ->orWhere('start_date', '<=', $now); + }); + + $query->where(function ($q) use ($now) { + $q->whereNull('end_date') + ->orWhere('end_date', '>=', $now); + }); + + if ($roleId !== null) { + $query->where(function ($q) use ($roleId) { + // If applicable_roles is empty, applies to all custom roles + $q->whereJsonLength('applicable_roles', 0) + ->orWhereJsonContains('applicable_roles', $roleId); + }); + } + + return $query->get(); + } + + /** + * Calculate total additional days from active promotions + */ + public static function calculateAdditionalDays(int $roleId): int + { + $promotions = static::getActivePromotions($roleId); + + return $promotions->sum('additional_days'); + } + + /** + * Get summary statistics for this promotion + */ + public function getSummaryStatistics(): array + { + return RolePromotionStat::getPromotionSummary($this->id); + } + + /** + * Get the count of users who have received this promotion + */ + public function getTotalUpgrades(): int + { + return $this->statistics()->count(); + } + + /** + * Get the count of unique users who have received this promotion + */ + public function getUniqueUsersCount(): int + { + return $this->statistics()->distinct('user_id')->count('user_id'); + } + + /** + * Get the total days added across all applications of this promotion + */ + public function getTotalDaysAdded(): int + { + return $this->statistics()->sum('days_added'); + } + + /** + * Track a promotion application + */ + public function trackApplication( + int $userId, + int $roleId, + ?\Illuminate\Support\Carbon $previousExpiryDate = null, + ?\Illuminate\Support\Carbon $newExpiryDate = null + ): RolePromotionStat { + return RolePromotionStat::recordPromotion( + $userId, + $this->id, + $roleId, + $this->additional_days, + $previousExpiryDate, + $newExpiryDate + ); + } + + /** + * Get statistics grouped by role + */ + public function getStatisticsByRole(): array + { + return $this->statistics() + ->with('role') + ->get() + ->groupBy('role_id') + ->map(function ($stats, $roleId) { + return [ + 'role_id' => $roleId, + 'role_name' => $stats->first()->role?->name, + 'total_upgrades' => $stats->count(), + 'total_days_added' => $stats->sum('days_added'), + 'unique_users' => $stats->unique('user_id')->count(), + ]; + }) + ->values() + ->all(); + } + + /** + * Get statistics for a specific time period + */ + public function getStatisticsForPeriod(Carbon $startDate, Carbon $endDate): array + { + $stats = $this->statistics() + ->appliedBetween($startDate, $endDate) + ->get(); + + return [ + 'period' => [ + 'start' => $startDate->toDateString(), + 'end' => $endDate->toDateString(), + ], + 'total_upgrades' => $stats->count(), + 'total_days_added' => $stats->sum('days_added'), + 'unique_users' => $stats->unique('user_id')->count(), + 'roles_affected' => $stats->unique('role_id')->count(), + ]; + } +} + diff --git a/app/Models/RolePromotionStat.php b/app/Models/RolePromotionStat.php new file mode 100644 index 000000000..582950a79 --- /dev/null +++ b/app/Models/RolePromotionStat.php @@ -0,0 +1,160 @@ + 'integer', + 'previous_expiry_date' => 'datetime', + 'new_expiry_date' => 'datetime', + 'applied_at' => 'datetime', + ]; + + /** + * Get the user that received the promotion + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the promotion that was applied + */ + public function promotion(): BelongsTo + { + return $this->belongsTo(RolePromotion::class, 'role_promotion_id'); + } + + /** + * Get the role that was upgraded + */ + public function role(): BelongsTo + { + return $this->belongsTo(\Spatie\Permission\Models\Role::class); + } + + /** + * Scope to get stats for a specific promotion + */ + public function scopeForPromotion($query, int $promotionId) + { + return $query->where('role_promotion_id', $promotionId); + } + + /** + * Scope to get stats for a specific user + */ + public function scopeForUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope to get stats for a specific role + */ + public function scopeForRole($query, int $roleId) + { + return $query->where('role_id', $roleId); + } + + /** + * Scope to get stats within a date range + */ + public function scopeAppliedBetween($query, $startDate, $endDate) + { + return $query->whereBetween('applied_at', [$startDate, $endDate]); + } + + /** + * Get statistics summary for a promotion + */ + public static function getPromotionSummary(int $promotionId): array + { + $stats = static::forPromotion($promotionId)->get(); + + return [ + 'total_upgrades' => $stats->count(), + 'total_days_added' => $stats->sum('days_added'), + 'unique_users' => $stats->unique('user_id')->count(), + 'roles_affected' => $stats->unique('role_id')->count(), + 'latest_application' => $stats->max('applied_at'), + 'earliest_application' => $stats->min('applied_at'), + ]; + } + + /** + * Get statistics summary for a user + */ + public static function getUserSummary(int $userId): array + { + $stats = static::forUser($userId)->get(); + + return [ + 'total_promotions_received' => $stats->count(), + 'total_days_added' => $stats->sum('days_added'), + 'promotions' => $stats->groupBy('role_promotion_id')->map(function ($group) { + return [ + 'promotion_id' => $group->first()->role_promotion_id, + 'promotion_name' => $group->first()->promotion?->name, + 'times_applied' => $group->count(), + 'total_days' => $group->sum('days_added'), + ]; + })->values()->all(), + ]; + } + + /** + * Record a promotion application + */ + public static function recordPromotion( + int $userId, + int $promotionId, + int $roleId, + int $daysAdded, + ?\Carbon\Carbon $previousExpiryDate = null, + ?\Carbon\Carbon $newExpiryDate = null + ): self { + return static::create([ + 'user_id' => $userId, + 'role_promotion_id' => $promotionId, + 'role_id' => $roleId, + 'days_added' => $daysAdded, + 'previous_expiry_date' => $previousExpiryDate, + 'new_expiry_date' => $newExpiryDate, + 'applied_at' => now(), + ]); + } +} + diff --git a/app/Models/User.php b/app/Models/User.php index d28fc7192..57214bd11 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Jobs\SendAccountExpiredEmail; use App\Jobs\SendAccountWillExpireEmail; use App\Rules\ValidEmailDomain; +use App\Services\InvitationService; use Carbon\CarbonImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -243,6 +244,11 @@ class User extends Authenticatable return $this->hasMany(ReleaseComment::class, 'users_id'); } + public function promotionStats(): HasMany + { + return $this->hasMany(RolePromotionStat::class); + } + /** * Get the user's timezone or default to UTC */ @@ -342,7 +348,7 @@ class User extends Authenticatable return self::whereEmail($email)->first(); } - public static function updateUserRole(int $uid, int|string $role): bool + public static function updateUserRole(int $uid, int|string $role, bool $applyPromotions = true): bool { if (is_int($role)) { $roleQuery = Role::query()->where('id', $role)->first(); @@ -352,23 +358,23 @@ class User extends Authenticatable $roleName = $roleQuery->name; $user = self::find($uid); - $user->syncRoles([$roleName]); + $currentRoleId = $user->roles_id; - return self::find($uid)->update(['roles_id' => $roleQuery->id]); - } + $updated = self::find($uid)->update(['roles_id' => $roleQuery->id]); - public static function updateUserRoleChangeDate(int $uid, $date = '', int $addYear = 0): void - { - $user = self::find($uid); - $currRoleExp = $user->rolechangedate ?? now()->toDateTimeString(); - if (! empty($date)) { - $user->update(['rolechangedate' => $date]); + // Apply promotions if enabled and role is being upgraded + if ($applyPromotions && $updated && $currentRoleId !== $roleQuery->id) { + $additionalDays = RolePromotion::calculateAdditionalDays($currentRoleId, $roleQuery->id); + + if ($additionalDays > 0) { + $currentExpiry = $user->rolechangedate ? Carbon::parse($user->rolechangedate) : Carbon::now(); + $newExpiry = $currentExpiry->addDays($additionalDays); + $user->update(['rolechangedate' => $newExpiry]); + } } - if (empty($date) && ! empty($addYear)) { - $user->update(['rolechangedate' => Carbon::createFromDate($currRoleExp)->addYears($addYear)]); - } - } + return $updated; + } public static function updateExpiredRoles(): void { $now = CarbonImmutable::now(); diff --git a/app/Observers/RolePromotionObserver.php b/app/Observers/RolePromotionObserver.php new file mode 100644 index 000000000..34373eebc --- /dev/null +++ b/app/Observers/RolePromotionObserver.php @@ -0,0 +1,37 @@ +end_date && Carbon::now()->gt($promotion->end_date)) { + $promotion->is_active = false; + } + } + + /** + * Handle the RolePromotion "retrieved" event. + * This helps ensure that when a promotion is loaded from the database, + * we check if it should be disabled + */ + public function retrieved(RolePromotion $promotion): void + { + // If the promotion has an end date that has passed but is still marked as active, + // we update it (this will trigger saving event) + if ($promotion->is_active && $promotion->end_date && Carbon::now()->gt($promotion->end_date)) { + $promotion->is_active = false; + $promotion->saveQuietly(); // Save without triggering events to avoid recursion + } + } +} + diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b8431d488..43ddf33f2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,9 @@ namespace App\Providers; +use App\Models\RolePromotion; use App\Models\User; +use App\Observers\RolePromotionObserver; use Illuminate\Auth\Events\Login; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Event; @@ -26,6 +28,9 @@ class AppServiceProvider extends ServiceProvider return $user->hasRole('Admin'); }); Event::listen(Login::class, LoginViaRemember::class); + + // Register observers + RolePromotion::observe(RolePromotionObserver::class); } /** diff --git a/database/migrations/2025_11_28_000000_create_role_promotions_table.php b/database/migrations/2025_11_28_000000_create_role_promotions_table.php new file mode 100644 index 000000000..28277d1b5 --- /dev/null +++ b/database/migrations/2025_11_28_000000_create_role_promotions_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->json('applicable_roles')->comment('JSON array of role IDs this promotion applies to'); + $table->integer('additional_days')->default(0)->comment('Additional days added to role expiry'); + $table->date('start_date')->nullable()->comment('Promotion start date'); + $table->date('end_date')->nullable()->comment('Promotion end date'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('role_promotions'); + } +}; + diff --git a/database/migrations/2025_11_29_000000_create_role_promotion_stats_table.php b/database/migrations/2025_11_29_000000_create_role_promotion_stats_table.php new file mode 100644 index 000000000..762487f71 --- /dev/null +++ b/database/migrations/2025_11_29_000000_create_role_promotion_stats_table.php @@ -0,0 +1,46 @@ +id(); + $table->unsignedInteger('user_id'); + $table->unsignedBigInteger('role_promotion_id'); + $table->unsignedBigInteger('role_id'); + $table->integer('days_added')->comment('Number of days added to role expiry'); + $table->dateTime('previous_expiry_date')->nullable()->comment('Previous role expiry date before promotion'); + $table->dateTime('new_expiry_date')->nullable()->comment('New role expiry date after promotion'); + $table->timestamp('applied_at')->comment('When the promotion was applied'); + $table->timestamps(); + + // Foreign key constraints + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('role_promotion_id')->references('id')->on('role_promotions')->onDelete('cascade'); + $table->foreign('role_id')->references('id')->on('roles')->onDelete('cascade'); + + // Indexes for performance + $table->index('user_id'); + $table->index('role_promotion_id'); + $table->index('role_id'); + $table->index('applied_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('role_promotion_stats'); + } +}; + diff --git a/resources/js/csp-safe.js b/resources/js/csp-safe.js index 885cc44d6..ec5489797 100644 --- a/resources/js/csp-safe.js +++ b/resources/js/csp-safe.js @@ -44,7 +44,7 @@ function updateAllThemeUI(themePreference) { // Update ALL theme selector radio buttons (user area profile edit page) // Use a flag to prevent event loops when programmatically updating window._updatingThemeUI = true; - + const allThemeRadios = document.querySelectorAll('input[name="theme_preference"]'); let updatedCount = 0; allThemeRadios.forEach(radio => { @@ -56,7 +56,7 @@ function updateAllThemeUI(themePreference) { radio.checked = false; } }); - + if (updatedCount > 0) { console.log(`Updated ${updatedCount} theme radio button(s) to ${themePreference}`); } @@ -111,7 +111,7 @@ function updateAllThemeUI(themePreference) { if (metaTheme) { metaTheme.content = themePreference; } - + // Clear the flag after a short delay to allow all updates to complete setTimeout(() => { window._updatingThemeUI = false; @@ -148,8 +148,8 @@ function saveThemePreference(themePreference) { } updateAllThemeUI(themePreference); // Dispatch custom event for theme change - document.dispatchEvent(new CustomEvent('themeChanged', { - detail: { theme: themePreference } + document.dispatchEvent(new CustomEvent('themeChanged', { + detail: { theme: themePreference } })); return data; } else { @@ -164,8 +164,8 @@ function saveThemePreference(themePreference) { // Save to localStorage for guests localStorage.setItem('theme', themePreference); updateAllThemeUI(themePreference); - document.dispatchEvent(new CustomEvent('themeChanged', { - detail: { theme: themePreference } + document.dispatchEvent(new CustomEvent('themeChanged', { + detail: { theme: themePreference } })); return Promise.resolve({ success: true }); } @@ -208,7 +208,7 @@ function initThemeSystem() { document.addEventListener('DOMContentLoaded', function() { // Initialize theme system first initThemeSystem(); - + initEventDelegation(); initToastNotifications(); initNfoModal(); @@ -497,6 +497,50 @@ function initEventDelegation() { }); return; } + + // Handle promotion toggle (activate/deactivate) + const promotionToggleBtn = e.target.closest('.promotion-toggle-btn'); + if (promotionToggleBtn) { + e.preventDefault(); + const promotionName = promotionToggleBtn.getAttribute('data-promotion-name'); + const isActive = promotionToggleBtn.getAttribute('data-promotion-active') === '1'; + const action = isActive ? 'deactivate' : 'activate'; + + showConfirm({ + title: `${action.charAt(0).toUpperCase() + action.slice(1)} Promotion`, + message: `Are you sure you want to ${action} the promotion "${promotionName}"?`, + type: isActive ? 'warning' : 'success', + confirmText: action.charAt(0).toUpperCase() + action.slice(1), + cancelText: 'Cancel', + onConfirm: function() { + window.location.href = promotionToggleBtn.href; + } + }); + return; + } + + // Handle promotion delete + const promotionDeleteBtn = e.target.closest('.promotion-delete-btn'); + if (promotionDeleteBtn) { + e.preventDefault(); + const promotionName = promotionDeleteBtn.getAttribute('data-promotion-name'); + const form = promotionDeleteBtn.closest('form'); + + showConfirm({ + title: 'Delete Promotion', + message: `Are you sure you want to delete the promotion "${promotionName}"?`, + details: 'This action cannot be undone.', + type: 'danger', + confirmText: 'Delete', + cancelText: 'Cancel', + onConfirm: function() { + if (form) { + form.submit(); + } + } + }); + return; + } }); // Handle select redirects @@ -2669,14 +2713,14 @@ function initProfileEdit() { // Theme preference instant preview (user area - profile edit page) const allThemeRadios = document.querySelectorAll('input[name="theme_preference"]'); - + allThemeRadios.forEach(radio => { // Skip if event listener already attached (prevent duplicates) if (radio.dataset.themeListenerAttached === 'true') { return; } radio.dataset.themeListenerAttached = 'true'; - + radio.addEventListener('change', function() { // Prevent event loop if we're programmatically updating if (window._updatingThemeUI) { @@ -4414,9 +4458,9 @@ function initThemeManagement() { // Dark mode toggle (user area - only if not already handled by admin) const themeToggle = document.getElementById('theme-toggle'); // Check if this is admin area by looking for admin-specific elements - const isAdminArea = document.querySelector('aside.bg-gray-900.dark\\:bg-gray-950') && + const isAdminArea = document.querySelector('aside.bg-gray-900.dark\\:bg-gray-950') && document.querySelector('a[href*="admin"]'); - + if (themeToggle && !isAdminArea) { // Only handle if not in admin area (admin has its own handler in initAdminMenu) themeToggle.addEventListener('click', function() { diff --git a/resources/views/admin/promotions/create.blade.php b/resources/views/admin/promotions/create.blade.php new file mode 100644 index 000000000..42c51e79e --- /dev/null +++ b/resources/views/admin/promotions/create.blade.php @@ -0,0 +1,132 @@ +@extends('layouts.admin') + +@section('content') +
| Name | +Applicable Roles | +Additional Days | +Start Date | +End Date | +Status | +Actions | +
|---|---|---|---|---|---|---|
|
+ {{ $promotion->name }}
+ @if($promotion->description)
+ {{ Str::limit($promotion->description, 50) }}
+ @endif
+ |
+
+ @php
+ $roles = $promotion->getApplicableRolesModels();
+ @endphp
+ @if($roles->isEmpty())
+ All Custom Roles
+ @else
+
+ @foreach($roles as $role)
+
+ {{ $role->name }}
+
+ @endforeach
+
+ @endif
+ |
+ + {{ $promotion->additional_days }} days + | ++ {{ $promotion->start_date ? $promotion->start_date->format('Y-m-d') : 'No limit' }} + | ++ {{ $promotion->end_date ? $promotion->end_date->format('Y-m-d') : 'No limit' }} + | ++ @php + $isExpired = $promotion->end_date && \Carbon\Carbon::now()->gt($promotion->end_date); + @endphp + @if($isExpired) + + Expired + + @elseif($promotion->is_active && $promotion->isCurrentlyActive()) + + Active + + @elseif($promotion->is_active) + + Scheduled + + @else + + Inactive + + @endif + | ++ + | +
Create your first promotion to get started.
+Detailed statistics for this promotion
+Status
+ @if($promotion->is_active && $promotion->isCurrentlyActive()) + + Active + + @else + + Inactive + + @endif +Additional Days
+{{ $promotion->additional_days }} days
+Start Date
+{{ $promotion->start_date ? $promotion->start_date->format('Y-m-d') : 'No limit' }}
+End Date
+{{ $promotion->end_date ? $promotion->end_date->format('Y-m-d') : 'No limit' }}
+Description
+{{ $promotion->description }}
+Total Upgrades
+{{ number_format($stats['total_upgrades']) }}
+Unique Users
+{{ number_format($stats['unique_users']) }}
+Total Days Added
+{{ number_format($stats['total_days_added']) }}
+Roles Affected
+{{ number_format($stats['roles_affected']) }}
+| Role | +Total Upgrades | +Unique Users | +Total Days Added | +
|---|---|---|---|
| + + {{ $roleStat['role_name'] ?? 'Unknown' }} + + | ++ {{ number_format($roleStat['total_upgrades']) }} + | ++ {{ number_format($roleStat['unique_users']) }} + | ++ {{ number_format($roleStat['total_days_added']) }} + | +
| Date | +Applications | +Days Added | ++ |
|---|---|---|---|
| {{ \Carbon\Carbon::parse($dayStat->date)->format('M d, Y') }} | +{{ $dayStat->count }} | +{{ $dayStat->days }} | ++ + | +
| User | +Role | +Days Added | +Previous Expiry | +New Expiry | +Applied At | +
|---|---|---|---|---|---|
|
+
+ {{ $application->user->username ?? 'Unknown User' }}
+
+ @if($application->user)
+
+ ID: {{ $application->user->id }}
+
+ @endif
+ |
+ + + {{ $application->role->name ?? 'Unknown Role' }} + + | ++ +{{ $application->days_added }} + days + | ++ {{ $application->previous_expiry_date ? $application->previous_expiry_date->format('Y-m-d H:i') : 'N/A' }} + | ++ {{ $application->new_expiry_date ? $application->new_expiry_date->format('Y-m-d H:i') : 'N/A' }} + | +
+ {{ $application->applied_at->format('Y-m-d H:i') }}
+ {{ $application->applied_at->diffForHumans() }}
+ |
+
| + No applications found for this promotion + | +|||||
Overview of all promotion activities
+Total Promotions
+{{ $overallStats['total_promotions'] }}
+Active Promotions
+{{ $overallStats['active_promotions'] }}
+Total Applications
+{{ number_format($overallStats['total_applications']) }}
+Unique Users
+{{ number_format($overallStats['unique_users']) }}
+Total Days Added
+{{ number_format($overallStats['total_days_added']) }}
+No promotion data available
+ @else +No role data available
+ @else +Upgrades
+{{ number_format($roleStat->total_upgrades) }}
+Users
+{{ number_format($roleStat->unique_users) }}
+Days Added
+{{ number_format($roleStat->total_days) }}
+| Promotion | +Applications | +Days/Application | +Status | +Actions | +
|---|---|---|---|---|
|
+ {{ $promotion->name }}
+ @if($promotion->description)
+ {{ Str::limit($promotion->description, 50) }}
+ @endif
+ |
+ + {{ number_format($promotion->statistics_count) }} + times + | ++ {{ $promotion->additional_days }} + days + | ++ @if($promotion->is_active && $promotion->isCurrentlyActive()) + + Active + + @else + + Inactive + + @endif + | ++ + View Details + + | +
| + No promotions available + | +||||
| User | +Promotion | +Role | +Days Added | +Applied At | +
|---|---|---|---|---|
| + {{ $activity->user->username ?? 'Unknown User' }} + | ++ {{ $activity->promotion->name ?? 'Unknown Promotion' }} + | ++ + {{ $activity->role->name ?? 'Unknown Role' }} + + | ++ +{{ $activity->days_added }} days + | ++ {{ $activity->applied_at->diffForHumans() }} + | +
| + No recent activity + | +||||