Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions app/Livewire/Auth/Dashboard.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class Dashboard extends Component

public bool $showClaimConfirm = false;

public array $selectedRewardIds = [];

/**
* Sanitize showCreateForm input to prevent array injection attacks.
*/
Expand Down Expand Up @@ -590,6 +592,103 @@ public function claimReward(): void
}
}

/**
* Toggle select/deselect all rewards for a game account.
*/
public function toggleSelectAll(int $gameAccountId): void
{
$gameAccount = GameAccount::where('id', $gameAccountId)
->where('user_id', auth()->id())
->first();

if (! $gameAccount) {
return;
}

$isXileRetro = $gameAccount->server === GameAccount::SERVER_XILERETRO;
$rewardService = app(DonationRewardService::class);
$pendingRewards = $rewardService->getUserPendingRewards(auth()->user())
->filter(function ($reward) use ($isXileRetro) {
return $isXileRetro ? $reward->is_xileretro : $reward->is_xilero;
});

$allRewardIds = $pendingRewards->pluck('id')->toArray();

// If all are selected, deselect all. Otherwise, select all.
$allSelected = ! empty($allRewardIds) && count(array_intersect($this->selectedRewardIds, $allRewardIds)) === count($allRewardIds);

if ($allSelected) {
// Deselect all
$this->selectedRewardIds = array_values(array_diff($this->selectedRewardIds, $allRewardIds));
} else {
// Select all
$this->selectedRewardIds = array_values(array_unique(array_merge($this->selectedRewardIds, $allRewardIds)));
}
}

/**
* Claim multiple selected rewards at once.
*/
public function claimSelectedRewards(int $gameAccountId): void
{
if (empty($this->selectedRewardIds)) {
session()->flash('error', 'Please select at least one reward to claim.');

return;
}

$gameAccount = GameAccount::where('id', $gameAccountId)
->where('user_id', auth()->id())
->first();

if (! $gameAccount) {
session()->flash('error', 'Game account not found.');

return;
}

$rewards = DonationRewardClaim::with(['tier', 'item'])
->whereIn('id', $this->selectedRewardIds)
->where('user_id', auth()->id())
->get();

if ($rewards->isEmpty()) {
session()->flash('error', 'No valid rewards found.');
$this->selectedRewardIds = [];

return;
}

$rewardService = app(DonationRewardService::class);
$successCount = 0;
$errors = [];

foreach ($rewards as $reward) {
if (! $reward->canBeClaimedBy($gameAccount)) {
$errors[] = "{$reward->item->name} cannot be claimed on this account.";

continue;
}

try {
$rewardService->claimReward($reward, $gameAccount);
$successCount++;
} catch (Exception $e) {
$errors[] = "Failed to claim {$reward->item->name}: {$e->getMessage()}";
}
}

$this->selectedRewardIds = [];

if ($successCount > 0) {
session()->flash('success', "Successfully claimed {$successCount} reward(s)! Items will be delivered to {$gameAccount->userid} on next login.");
}

if (! empty($errors)) {
session()->flash('error', implode(' ', $errors));
}
}

public function render()
{
$gameAccounts = auth()->user()->gameAccounts()
Expand Down
101 changes: 73 additions & 28 deletions app/Livewire/DonateShop.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ class DonateShop extends Component

public bool $showPurchaseConfirm = false;

public int $purchaseQuantity = 1;

/**
* Sanitize category input to prevent array injection attacks.
*/
Expand Down Expand Up @@ -232,12 +234,41 @@ public function selectItem(?int $itemId): void
{
$this->selectedItemId = $itemId;
$this->showPurchaseConfirm = false;
$this->purchaseQuantity = 1; // Reset quantity when selecting new item

if ($itemId) {
UberShopItem::where('id', $itemId)->increment('views');
}
}

/**
* Increment purchase quantity.
*/
public function incrementQuantity(): void
{
$item = $this->selectedItem();
if (! $item) {
return;
}

// If item has stock limit, don't exceed it
if ($item->stock !== null && $this->purchaseQuantity >= $item->stock) {
return;
}

$this->purchaseQuantity++;
}

/**
* Decrement purchase quantity.
*/
public function decrementQuantity(): void
{
if ($this->purchaseQuantity > 1) {
$this->purchaseQuantity--;
}
}

/**
* Validate game account selection when updated via wire:model.
*/
Expand Down Expand Up @@ -629,15 +660,18 @@ public function purchase(): void
$user->refresh();
$currentBalance = $user->uber_balance;

// Calculate total cost based on quantity
$totalCost = $item->uber_cost * $this->purchaseQuantity;

// Validate sufficient balance
if ($currentBalance < $item->uber_cost) {
session()->flash('error', 'Insufficient Ubers. You need '.($item->uber_cost - $currentBalance).' more.');
if ($currentBalance < $totalCost) {
session()->flash('error', 'Insufficient Ubers. You need '.($totalCost - $currentBalance).' more.');

return;
}

try {
DB::transaction(function () use ($user, $gameAccount, $item, $currentBalance) {
DB::transaction(function () use ($user, $gameAccount, $item, $currentBalance, $totalCost) {
// Lock the item row to prevent race conditions with stock
$lockedItem = UberShopItem::with('item')->where('id', $item->id)->lockForUpdate()->first();

Expand All @@ -646,47 +680,58 @@ public function purchase(): void
throw new Exception('Item is no longer available.');
}

// Re-check stock for the requested quantity
if ($lockedItem->stock !== null && $lockedItem->stock < $this->purchaseQuantity) {
throw new Exception('Insufficient stock. Only '.$lockedItem->stock.' available.');
}

// Calculate new balance
$newBalance = $currentBalance - $lockedItem->uber_cost;
$newBalance = $currentBalance - $totalCost;

// Deduct ubers from user's balance
$user->uber_balance = $newBalance;
$user->save();

// Decrement stock if item has limited stock
if ($lockedItem->stock !== null) {
$lockedItem->decrement('stock');
$lockedItem->decrement('stock', $this->purchaseQuantity);
}

// Create purchase record
$purchase = UberShopPurchase::create([
'account_id' => $gameAccount->ragnarok_account_id,
'account_name' => $gameAccount->userid,
'shop_item_id' => $lockedItem->id,
'item_id' => $lockedItem->item->item_id,
'item_name' => $lockedItem->item->name,
'refine_level' => $lockedItem->refine_level,
'quantity' => $lockedItem->quantity,
'uber_cost' => $lockedItem->uber_cost,
'uber_balance_after' => $newBalance,
'status' => UberShopPurchase::STATUS_PENDING,
'is_xileretro' => $gameAccount->server === 'xileretro',
'purchased_at' => now(),
]);

// Send purchase confirmation email
$user->notify(new UberShopPurchaseNotification(
$purchase,
$gameAccount->userid,
$gameAccount->serverName()
));
// Create purchase records (one for each quantity)
for ($i = 0; $i < $this->purchaseQuantity; $i++) {
$purchase = UberShopPurchase::create([
'account_id' => $gameAccount->ragnarok_account_id,
'account_name' => $gameAccount->userid,
'shop_item_id' => $lockedItem->id,
'item_id' => $lockedItem->item->item_id,
'item_name' => $lockedItem->item->name,
'refine_level' => $lockedItem->refine_level,
'quantity' => $lockedItem->quantity,
'uber_cost' => $lockedItem->uber_cost,
'uber_balance_after' => $newBalance,
'status' => UberShopPurchase::STATUS_PENDING,
'is_xileretro' => $gameAccount->server === 'xileretro',
'purchased_at' => now(),
]);

// Send purchase confirmation email for first purchase only
if ($i === 0) {
$user->notify(new UberShopPurchaseNotification(
$purchase,
$gameAccount->userid,
$gameAccount->serverName()
));
}
}
});

session()->flash('success', "Successfully purchased {$item->display_name} for {$item->uber_cost} Ubers! The item will be delivered to {$gameAccount->userid} on next login.");
$quantityText = $this->purchaseQuantity > 1 ? " (x{$this->purchaseQuantity})" : '';
session()->flash('success', "Successfully purchased {$item->display_name}{$quantityText} for {$totalCost} Ubers! The item will be delivered to {$gameAccount->userid} on next login.");

// Reset state
$this->selectedItemId = null;
$this->showPurchaseConfirm = false;
$this->purchaseQuantity = 1;

} catch (Exception $e) {
session()->flash('error', 'Purchase failed: '.$e->getMessage());
Expand Down
55 changes: 49 additions & 6 deletions resources/views/livewire/auth/partials/game-account-card.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,32 +115,75 @@ class="px-3 py-1.5 text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-l
{{-- Pending Rewards --}}
@if($hasPendingRewards)
<div class="py-4 border-t border-gray-800/50">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">Pending Rewards</p>
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-medium text-gray-500 uppercase tracking-wider">Pending Rewards</p>
<div class="flex items-center gap-2">
{{-- Select/Deselect All Button --}}
<button
wire:click="toggleSelectAll({{ $account->id }})"
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 font-medium text-xs rounded-lg transition-colors flex items-center gap-1.5"
>
<i class="fas fa-check-double text-xs"></i>
@php
$accountRewardIds = $pendingRewards->pluck('id')->toArray();
$allSelected = !empty($accountRewardIds) && count(array_intersect($this->selectedRewardIds ?? [], $accountRewardIds)) === count($accountRewardIds);
@endphp
{{ $allSelected ? 'Deselect All' : 'Select All' }}
</button>

{{-- Claim Selected Button --}}
@if($this->selectedRewardIds && count($this->selectedRewardIds) > 0)
<button
wire:click="claimSelectedRewards({{ $account->id }})"
wire:loading.attr="disabled"
class="group px-4 py-2 bg-gradient-to-r from-purple-600 via-purple-500 to-blue-600 hover:from-purple-500 hover:via-purple-400 hover:to-blue-500 text-white font-bold text-xs rounded-lg transition-all duration-300 flex items-center gap-2 shadow-lg shadow-purple-500/20 hover:shadow-purple-500/40 hover:-translate-y-0.5"
>
<i class="fas fa-gifts transition-transform group-hover:scale-110"></i>
<span wire:loading.remove wire:target="claimSelectedRewards">Claim {{ count($this->selectedRewardIds) }} Selected</span>
<span wire:loading wire:target="claimSelectedRewards"><i class="fas fa-spinner fa-spin"></i> Claiming...</span>
</button>
@endif
</div>
</div>
<div class="space-y-2">
@foreach($pendingRewards as $reward)
<div class="flex items-center justify-between p-3 bg-amber-500/5 rounded-lg border border-amber-500/20">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center overflow-hidden border border-gray-700">
<label class="flex items-center justify-between p-3 bg-amber-500/5 rounded-lg border border-amber-500/20 cursor-pointer hover:bg-amber-500/10 transition-colors group">
<div class="flex items-center gap-3 flex-1">
{{-- Checkbox --}}
<input
type="checkbox"
wire:model.live="selectedRewardIds"
value="{{ $reward->id }}"
class="w-5 h-5 rounded border-2 border-gray-600 bg-gray-800/50 text-amber-500 focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 focus:ring-offset-gray-900 cursor-pointer transition-all checked:border-amber-500 checked:bg-amber-500"
>

{{-- Item Icon --}}
<div class="w-10 h-10 bg-gray-800 rounded-lg flex items-center justify-center overflow-hidden border border-gray-700 group-hover:border-amber-500/30 transition-colors">
@if($reward->item)
<img src="{{ $reward->item->icon() }}" alt="" class="max-h-full max-w-full object-contain">
@else
<i class="fas fa-box text-gray-500"></i>
@endif
</div>

{{-- Item Details --}}
<div>
<p class="text-gray-100 text-sm font-medium">
@if($reward->refine_level > 0)<span class="text-amber-400">+{{ $reward->refine_level }}</span> @endif{{ $reward->item?->name ?? 'Unknown' }}
</p>
<p class="text-xs text-gray-500">x{{ $reward->quantity }} &bull; {{ $reward->tier?->name ?? 'Bonus' }}</p>
</div>
</div>

{{-- Individual Claim Button --}}
<button
wire:click="startRewardClaim({{ $reward->id }}, {{ $account->id }})"
type="button"
wire:click.stop="startRewardClaim({{ $reward->id }}, {{ $account->id }})"
class="px-4 py-2 bg-amber-500 hover:bg-amber-400 text-gray-900 font-bold text-xs rounded-lg transition-colors"
>
Claim
</button>
</div>
</label>
@endforeach
</div>
</div>
Expand Down
Loading