feat(user interaction): add favorite interaction

- refactor controller to handle favorite
- show like count and update when liked a deal
- maintain like and favorite state with server
This commit is contained in:
kusowl 2026-01-16 14:30:24 +05:30
parent 16c9ff3cee
commit f33f68cd3e
8 changed files with 179 additions and 46 deletions

View File

@ -18,18 +18,11 @@ public function __invoke()
protected function deals() protected function deals()
{ {
return Deal::query() return Deal::query()
->where('active', true)
->select([ ->select([
'id', 'id', 'title', 'description', 'image', 'active', 'slug', 'link',
'title', 'deal_category_id', 'user_id',
'description',
'image',
'active',
'slug',
'link',
'deal_category_id',
'user_id',
]) ])
// Select additional details
->with([ ->with([
'category:id,name', 'category:id,name',
'broker' => function ($query) { 'broker' => function ($query) {
@ -37,6 +30,11 @@ protected function deals()
->with('type'); ->with('type');
}, },
]) ])
// Select only admin-approved deals
->withActiveDeals()
// Check if the current user interacted with the deal
->withCurrentUserInteractions()
->withLikes()
->latest() ->latest()
->paginate(); ->paginate();
} }

View File

@ -4,44 +4,63 @@
use App\Enums\InteractionType; use App\Enums\InteractionType;
use App\Models\Deal; use App\Models\Deal;
use App\Models\Interaction;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class InteractionController extends Controller class InteractionController extends Controller
{ {
public function like(Deal $deal) /**
* Interact to a deal by Like or Favorite state
* @param Deal $deal
* @param InteractionType $type [InteractionType::Like, InteractionType::Favorite]
* @return \Illuminate\Http\JsonResponse
*/
public function togglesState(Deal $deal, InteractionType $type)
{ {
try { if (!in_array($type, [InteractionType::Like, InteractionType::Favorite])) {
// Check for existing like of the user with deal return response()->json(['error' => 'This interaction is not supported'], 400);
$user = Auth::user();
$existingLike = $deal->interactions()
->where('user_id', $user->id)
->where('type', InteractionType::Like)
->first();
// Delete the existing like if exists
if ($existingLike) {
$existingLike->delete();
return response()->json(['message' => 'Successfully unliked the post.']);
} }
// Else, create a new like try {
// Check for existing like of the user with deal
$existingInteraction = $deal->interactions()
->where('user_id', Auth::id())
->where('type', $type)
->first();
// Delete the existing like if exists, else add the like
$message = '';
if ($existingInteraction) {
$existingInteraction->delete();
$message = "{$type->value} removed from deal";
} else {
$data = [ $data = [
'type' => InteractionType::Like, 'type' => $type,
'user_id' => Auth::user()->id, 'user_id' => Auth::id(),
]; ];
Deal::unguard(); Interaction::unguard();
$deal->interactions()->create($data); $deal->interactions()->create($data);
Deal::reguard(); Interaction::reguard();
$message = "{$type->value} added to deal";
}
return response()->json(['message' => $message]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Error when liked a deal: id'.$deal->id.$e->getMessage(), $e->getTrace()); Log::error('Error when liked a deal',
[
'deal_id' => $deal->id,
'use_id' => Auth::id(),
'type' => $type,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]
);
return response()->json(['error' => 'Something went wrong.'], 500); return response()->json(['error' => 'Something went wrong.'], 500);
} }
}
return response()->json(['message' => 'Successfully liked the post.']);
}
} }

View File

@ -2,9 +2,12 @@
namespace App\Models; namespace App\Models;
use App\Enums\InteractionType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Auth;
class Deal extends Model class Deal extends Model
{ {
@ -22,4 +25,42 @@ public function interactions(): HasMany
{ {
return $this->hasMany(Interaction::class); return $this->hasMany(Interaction::class);
} }
/**
* Get deals that are active
* @param Builder $query
* @return Builder
*/
public function scopeWithActiveDeals(Builder $query): Builder
{
return $query->where('active', true);
}
/**
* Get if the current user has liked or favorite the deal
* @param Builder $query
* @return Builder
*/
public function scopeWithCurrentUserInteractions(Builder $query): Builder
{
return $query->withExists([
'interactions as is_liked' => function ($query) {
$query->where('user_id', Auth::id())
->where('type', InteractionType::Like);
},
'interactions as is_favorite' => function ($query) {
$query->where('user_id', Auth::id())
->where('type', InteractionType::Favorite);
}
]);
}
public function scopeWithLikes(Builder $query): Builder
{
return $query->withCount([
'interactions as total_likes' => function ($query) {
$query->where('type', InteractionType::Like);
}
]);
}
} }

View File

@ -1,14 +1,58 @@
async function like(e, id){ async function like(button, id) {
// Instant feedback for user
let likeBtns = button.querySelectorAll('.like');
let likeBadge = document.getElementById("likeBadge".concat(id));
toggleHidden(likeBtns);
// Check if user liked the deal
let isLiked = button.classList.contains('liked');
updateLikeCount(likeBadge, isLiked ? -1 : 1) ;
button.classList.toggle('liked')
try { try {
let response = await axios.post('/like/' + id); let response = await axios.post('/like/' + id);
if (response.status === 200) { if (response.status !== 200) {
let likeBtns = e.querySelectorAll('.like'); // Revert the ui
likeBtns.forEach((e) => e.classList.toggle('hidden')) toggleHidden(likeBtns);
} }
} catch (e) { } catch (e) {
toggleHidden(likeBtns);
console.error(e);
}
}
async function favorite(e, id) {
// Instant feedback for user
let favoriteBtns = e.querySelectorAll('.favorite');
toggleHidden(favoriteBtns);
try {
let response = await axios.post('/favorite/' + id);
if (response.status !== 200) {
// Revert the ui
toggleHidden(favoriteBtns);
}
} catch (e) {
toggleHidden(favoriteBtns);
console.error(e);
}
}
function toggleHidden(nodelist) {
nodelist.forEach((node) => node.classList.toggle('hidden'))
}
function updateLikeCount(badge, change){
try{
let likeCount = Math.max(parseInt( badge.dataset.count) + change, 0)
badge.querySelector('p').innerText = likeCount;
badge.dataset.count = likeCount.toString();
}
catch(e) {
console.error(e); console.error(e);
} }
} }
document.like = like; document.like = like;
document.favorite = favorite;

View File

@ -1,12 +1,35 @@
@props(['id']) @props(['id', 'like' => false, 'favourite' => false])
<div class=""> <div class="">
<x-ui.button-sm class="text-accent-600" onclick="like(this, {{$id}})"> <x-ui.button-sm @class(["text-accent-600", 'liked' => $like]) onclick="like(this, {{$id}})">
<x-heroicon-o-heart class="like w-4"/> <x-heroicon-o-heart
<x-heroicon-s-heart class="like w-4 hidden text-red-500"/> @class([
"like w-4",
'hidden' => $like
])
/>
<x-heroicon-s-heart
@class([
"like w-4 text-red-500",
'hidden' => !$like
])
/>
</x-ui.button-sm> </x-ui.button-sm>
<x-ui.button-sm class="text-accent-600"> <x-ui.button-sm class="text-accent-600" onclick="favorite(this, {{$id}})">
<x-heroicon-o-star class="w-4"/> <x-heroicon-o-star
@class([
"favorite w-4",
'hidden' => $favourite
])
/>
<x-heroicon-s-star
@class([
"favorite w-4 text-yellow-500",
'hidden' => !$favourite
])
/>
</x-ui.button-sm> </x-ui.button-sm>
<x-ui.button-sm class="text-accent-600"> <x-ui.button-sm class="text-accent-600">

View File

@ -5,7 +5,8 @@
<x-ui.button-sm variant="neutral"> <x-ui.button-sm variant="neutral">
{{$deal->category->name}} {{$deal->category->name}}
</x-ui.button-sm> </x-ui.button-sm>
<x-dashboard.user.action-toolbar :id="$deal->id" /> @ds($deal)
<x-dashboard.user.action-toolbar :id="$deal->id" :like="$deal->is_liked" :favourite="$deal->is_favorite" />
</div> </div>
<p class="font-bold text-lg ">{{$deal->title}}</p> <p class="font-bold text-lg ">{{$deal->title}}</p>
@ -16,7 +17,7 @@
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex space-x-3"> <div class="flex space-x-3">
<x-dashboard.user.stat-badge :count="200"> <x-dashboard.user.stat-badge id="{{'likeBadge'.$deal->id}}" :count="$deal->total_likes">
<x-heroicon-o-heart class="w-4"/> <x-heroicon-o-heart class="w-4"/>
</x-dashboard.user.stat-badge> </x-dashboard.user.stat-badge>

View File

@ -1,5 +1,5 @@
@props(['count' => 0]) @props(['count' => 0])
<div class="flex text-accent-600 space-x-1"> <div {{$attributes->merge(['class' =>"flex text-accent-600 space-x-1"])}} data-count="{{$count}}">
{{$slot}} {{$slot}}
<p>{{$count}}</p> <p>{{$count}}</p>
</div> </div>

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Enums\InteractionType;
use App\Enums\UserTypes; use App\Enums\UserTypes;
use App\Http\Controllers\AuthenticatedUserController; use App\Http\Controllers\AuthenticatedUserController;
use App\Http\Controllers\Broker\BrokerDashboardController; use App\Http\Controllers\Broker\BrokerDashboardController;
@ -41,7 +42,13 @@
Route::resource('profile', BrokerProfileController::class)->except('index', 'store', 'create'); Route::resource('profile', BrokerProfileController::class)->except('index', 'store', 'create');
}); });
Route::post('/like/{deal}', [InteractionController::class, 'like']) Route::post('/like/{deal}', [InteractionController::class, 'togglesState'])
->defaults('type', InteractionType::Like)
->middleware('throttle:30,1')
->name('like');
Route::post('/favorite/{deal}', [InteractionController::class, 'togglesState'])
->defaults('type', InteractionType::Favorite)
->middleware('throttle:30,1') ->middleware('throttle:30,1')
->name('like'); ->name('like');
}); });