feature(search deals)

- make deals reachable
- add recent search feature
- add animation in profile menu
- refactor blade markup of explore page
This commit is contained in:
kusowl 2026-01-20 18:43:13 +05:30
parent 673915887c
commit 985dd967e4
21 changed files with 358 additions and 133 deletions

View File

@ -0,0 +1,30 @@
<?php
namespace App\Actions;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
final readonly class AddRecentSearchAction
{
public function execute(User $user, array $data): void
{
try {
DB::transaction(function () use ($user, $data) {
$user->recentSearches()->updateOrcreate($data);
$recentSearchCount = $user->recentSearches()->count();
if ($recentSearchCount > 5) {
$user->recentSearches()->oldest()->limit(1)->delete();
}
});
} catch (\Throwable $e) {
Log::error('Error adding recent search',
[
'user_id' => $user->id,
'error' => $e->getMessage(),
'trace' => $e->getTrace(),
]);
}
}
}

View File

@ -2,61 +2,61 @@
namespace App\Http\Controllers;
use App\Actions\AddRecentSearchAction;
use App\Enums\ExplorePageFilters;
use App\Enums\UserTypes;
use App\Http\Requests\ExploreSearchSortRequest;
use App\Models\Deal;
use Illuminate\Http\Request;
use App\Models\DealCategory;
use App\Queries\ExplorePageDealsQuery;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
class ExplorePageController extends Controller
{
public function __invoke(Request $request)
{
$sortBy = $request->validate([
'sortBy' => ['nullable', 'string', Rule::in(ExplorePageFilters::values())],
]);
public function __invoke(
ExploreSearchSortRequest $request,
ExplorePageDealsQuery $query,
AddRecentSearchAction $addRecentSearchAction
) {
return view('explore')
->with('profileLink', $this->profileLink())
->with('deals', $this->deals(ExplorePageFilters::tryFrom($sortBy['sortBy'] ?? null)));
->with('categories', $this->categories())
->with('recentSearches', $this->recentSearches())
->with('deals', $this->deals($request, $query->builder(), $addRecentSearchAction));
}
protected function deals(?ExplorePageFilters $sortBy)
protected function deals(FormRequest $request, Builder $query, AddRecentSearchAction $action): LengthAwarePaginator
{
$query = Deal::query()
->select([
'id', 'title', 'description', 'image', 'active', 'slug', 'link',
'deal_category_id', 'user_id',
])
// Select additional details
->with([
'category:id,name',
'broker' => function ($query) {
$query->select('id', 'name', 'email', 'role_type', 'role_id')
->with('type');
},
])
// Select only admin-approved deals
->withActiveDeals()
// Check if the current user interacted with the deal
->withCurrentUserInteractions()
->withLikePerDeal()
->withRedirectionPerDeal();
// Add a search query
if ($request->has('search') && $request->get('search') !== null) {
$query->tap(fn ($q) => (new Deal)->search($q, $request->search));
// Add filters
if ($sortBy === ExplorePageFilters::Like) {
$query->orderBy('total_likes', 'desc');
} elseif ($sortBy === ExplorePageFilters::Click) {
$query->orderBy('total_redirection', 'desc');
} else {
$query->orderByRaw(
'((COALESCE(total_likes, 0) * 70.0)/100.0) + ((COALESCE(total_redirection, 0) * 30.0)/100.0) DESC'
);
\Illuminate\Support\defer(function () use ($action, $request) {
$action->execute($request->user(), ['query' => $request->search]);
});
}
return $query->latest()
->paginate();
// Add category sorting filter
if ($request->has('category') && $request->get('category') !== null) {
$query->tap(fn ($q) => (new Deal)->filterByCategory($q, $request->category));
}
// Add sorting filters
if ($request->has('sortBy')) {
$query = match (ExplorePageFilters::tryFrom($request->sortBy)) {
ExplorePageFilters::Like => $query->orderBy('total_likes', 'desc'),
ExplorePageFilters::Click => $query->orderBy('total_redirection', 'desc'),
default => $query->orderByRaw(
'((COALESCE(total_likes, 0) * 70.0) / 100.0) + ((COALESCE(total_redirection, 0) * 30.0) / 100.0) DESC'
)
};
}
return $query->latest()->paginate();
}
/**
@ -65,11 +65,23 @@ protected function deals(?ExplorePageFilters $sortBy)
*
* @return string The URL for the user's dashboard.
*/
protected function profileLink()
protected function profileLink(): string
{
$user = Auth::user();
if ($user->role === UserTypes::Broker->value) {
return route('broker.profile.show', $user);
}
return '';
}
protected function categories(): Collection
{
return DealCategory::all(['id', 'name']);
}
protected function recentSearches(): Collection
{
return Auth::user()->recentSearches()->latest()->pluck('query');
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use App\Enums\ExplorePageFilters;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ExploreSearchSortRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'sortBy' => ['nullable', 'string', Rule::in(ExplorePageFilters::values())],
'search' => ['nullable', 'string', 'min:1', 'max:255'],
'category' => ['nullable', 'exists:deal_categories,id'],
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\InteractionType;
use Illuminate\Database\Eloquent\Attributes\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -27,18 +28,25 @@ public function interactions(): HasMany
return $this->hasMany(Interaction::class);
}
public function reports(): BelongsToMany
{
return $this->belongsToMany(Report::class);
}
/**
* Get deals that are active
* Scope a query to only include active deals
*/
public function scopeWithActiveDeals(Builder $query): Builder
#[Scope]
public function WithActiveDeals(Builder $query): Builder
{
return $query->where('active', true);
}
/**
* Get if the current user has liked or favorite the deal
* Scope a query to determine if the current user has liked and favorite a deal
*/
public function scopeWithCurrentUserInteractions(Builder $query): Builder
#[Scope]
public function WithCurrentUserInteractions(Builder $query): Builder
{
return $query->withExists([
'interactions as is_liked' => function ($query) {
@ -52,7 +60,11 @@ public function scopeWithCurrentUserInteractions(Builder $query): Builder
]);
}
public function scopeWithLikePerDeal(Builder $query): Builder
/**
* Scope a query to get total like count per deal
*/
#[Scope]
public function WithLikePerDeal(Builder $query): Builder
{
return $query->withCount([
'interactions as total_likes' => function ($query) {
@ -61,7 +73,11 @@ public function scopeWithLikePerDeal(Builder $query): Builder
]);
}
public function scopeWithRedirectionPerDeal(Builder $query): Builder
/**
* Scope a query to get click count per deal
*/
#[Scope]
public function WithRedirectionPerDeal(Builder $query): Builder
{
return $query->withSum([
'interactions as total_redirection' => function ($query) {
@ -70,8 +86,21 @@ public function scopeWithRedirectionPerDeal(Builder $query): Builder
], 'count');
}
public function reports(): BelongsToMany
/**
* Scope a search in a query
*/
#[Scope]
public function search(Builder $query, string $search): Builder
{
return $this->belongsToMany(Report::class);
return $query->where('title', 'LIKE', "%$search%");
}
/**
* Scope a category filter in a query
*/
#[Scope]
public function filterByCategory(Builder $query, string $category): Builder
{
return $query->where('deal_category_id', $category);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RecentSearch extends Model
{
protected $fillable = ['query'];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -73,4 +73,9 @@ public function interactions(): HasMany
{
return $this->hasMany(User::class);
}
public function recentSearches(): HasMany
{
return $this->hasMany(RecentSearch::class);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Queries;
use App\Models\Deal;
use Illuminate\Database\Eloquent\Builder;
final readonly class ExplorePageDealsQuery
{
/**
* @return Builder<Deal>
*/
public function builder(): Builder
{
return Deal::query()
->select([
'id', 'title', 'description', 'image', 'active', 'slug', 'link',
'deal_category_id', 'user_id',
])
// Select additional details
->with([
'category:id,name',
'broker' => function ($query) {
$query->select('id', 'name', 'email', 'role_type', 'role_id')
->with('type');
},
])
// Select only admin-approved deals
->tap(fn ($q) => (new Deal)->withActiveDeals($q))
// Check if the current user interacted with the deal
->tap(fn ($q) => (new Deal)->withCurrentUserInteractions($q))
->tap(fn ($q) => (new Deal)->withLikePerDeal($q))
->tap(fn ($q) => (new Deal)->withRedirectionPerDeal($q));
}
}

View File

@ -0,0 +1,30 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('recent_searches', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class);
$table->string('query');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('recent_searches');
}
};

View File

@ -1,6 +1,7 @@
function showMenu(e){
const menu = e.nextElementSibling;
menu.classList.toggle('invisible');
menu.classList.toggle('opacity-100');
menu.classList.toggle('scale-100');
}
document.showMenu = showMenu;

View File

@ -3,6 +3,6 @@
@forelse($deals as $deal)
<x-dashboard.user.listing-card :deal="$deal" :broker="$deal->broker"/>
@empty
<p class="col-span-2 text-sm text-center text-accent-600">No Deals found till now !</p>
<p class="col-span-2 text-sm text-center text-accent-600 mt-12">No Deals found !</p>
@endforelse
</div>

View File

@ -0,0 +1,6 @@
@props(['searches'])
<ol class="absolute bg-white shadow-xl border border-gray-300 -top-15 w-full rounded-xl py-4 px-4 flex flex-col opacity-0 scale-y-17 group-focus-within:scale-y-100 group-focus-within:top-15 group-focus-within:opacity-100 transition-all duration-300 ease-out">
@foreach($searches as $search)
<x-dashboard.user.recent-search.recent-search-item :item="$search" />
@endforeach
</ol>

View File

@ -0,0 +1,7 @@
@props(['item'])
<li>
<a class="p-2 md:p-4 flex items-center text-gray-600 hover:bg-gray-100 hover:font-bold hover:text-gray-900 rounded-xl" href="{{route('explore', ['search' => $item])}}">
{{$item}}
<x-heroicon-o-arrow-up-right class="w-3 ml-2" />
</a>
</li>

View File

@ -0,0 +1,37 @@
@props(['profileLink' => ''])
<div class="flex items-center">
<div class="relative group">
<x-ui.button icon="user-circle" class="cursor-pointer" onclick="showMenu(this)"></x-ui.button>
<ul class="menu opacity-0 z-10 scale-10 group-hover:scale-100 group-hover:opacity-100 transition-all duration-300 ease-in-out w-48 absolute right-0 bg-white border border-gray-300 rounded-md shadow-xl py-2 text-accent-600">
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{$profileLink}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900">
<x-heroicon-o-user class="w-4"/>
</div>
<p>Profile</p>
</a>
</li>
@if(auth()->user()->role === \App\Enums\UserTypes::Broker->value)
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{route('broker.dashboard')}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900">
<x-heroicon-o-adjustments-horizontal class="w-4"/>
</div>
<p>Control Panel</p>
</a>
</li>
@endif
</ul>
</div>
<form method="post" action="{{route('logout')}}">
@csrf
@method('delete')
<x-ui.button
class="flex space-x-3 hover:bg-red-50 hover:border-red-100 hover:text-red-500 border border-white">
<x-heroicon-o-arrow-right-start-on-rectangle class="w-4 stroke-2 mr-2"/>
<p class="hidden sm:block">Logout</p>
</x-ui.button>
</form>
</div>

View File

@ -0,0 +1,14 @@
@props(['recentSearches' => [], 'categories' => []])
<x-dashboard.card>
<form action="{{route('explore')}}" method="get">
<div class="flex gap-4 flex-col sm:flex-row">
<x-ui.input class="flex-1 relative group " name="search"
placeholder="Search deals, services, places">
<x-dashboard.user.recent-search :searches="$recentSearches"/>
</x-ui.input>
<x-ui.select name="category" :options="$categories" value-key="id" label-key="name"
placeholder="All Categories" selectable-placeholder/>
<x-ui.button icon="magnifying-glass" type="submit" variant="neutral" round/>
</div>
</form>
</x-dashboard.card>

View File

@ -0,0 +1,22 @@
<x-ui.toggle-button-group>
<x-ui.toggle-button :active="request('sortBy') == null">
<a href="{{route('explore')}}" class="flex items-center px-2 py-1 space-x-2">
<x-heroicon-o-clock class="w-4 stroke-2"/>
<p class="font-bold text-xs sm:text-sm md:text-md">All Deals</p>
</a>
</x-ui.toggle-button>
<x-ui.toggle-button :active="request('sortBy') === \App\Enums\ExplorePageFilters::Like->value">
<a href="{{route('explore', ['sortBy' => 'like'])}}" class="flex items-center px-2 py-1 space-x-2">
<x-heroicon-o-arrow-trending-up class="w-4 stroke-2"/>
<p class="font-bold text-xs sm:text-sm md:text-md">Most Liked</p>
</a>
</x-ui.toggle-button>
<x-ui.toggle-button :active="request('sortBy') === \App\Enums\ExplorePageFilters::Click->value">
<a href="{{route('explore', ['sortBy'=>'click'])}}" class="flex items-center px-2 py-1 space-x-2">
<x-heroicon-o-star class="w-4 stroke-2"/>
<p class="font-bold text-xs sm:text-sm md:text-md">Most Clicked</p>
</a>
</x-ui.toggle-button>
</x-ui.toggle-button-group>

View File

@ -1,4 +1,4 @@
@props(['variant' => '', 'icon' => '', 'link' => '', 'external' => false])
@props(['variant' => '', 'icon' => '', 'link' => '', 'external' => false, 'round' => false])
@php
$variants = [
'neutral' => 'bg-primary-600 text-white',
@ -7,13 +7,18 @@
];
$variantClass = $variants[$variant] ?? '';
$roundedClass = $round ? ' rounded-full p-3' : ' rounded-lg magnifying-glass px-4 py-2 ';
$variantClass.= $roundedClass;
@endphp
@if($link !== '')
<a
@if($external)
target="_blank"
@endif
{{$attributes->merge(['class' => "block px-4 py-2 rounded-lg font-medium hover:opacity-80 active:scale-80 transition-all ease-in-out duration-300 $variantClass", 'href' => $link])}}>
{{$attributes->merge([
'class' => "block px-4 py-2 font-medium hover:opacity-80 active:scale-80 transition-all ease-in-out duration-300 $variantClass",
'href' => $link]
)}}>
<div class="flex justify-center items-center space-x-2">
@if($icon !=='')
@svg("heroicon-o-$icon", 'w-5 h-5')
@ -22,13 +27,14 @@
</div>
</a>
@else
<button {{$attributes->merge(['class' => "px-4 py-2 rounded-lg font-medium hover:opacity-80 active:scale-80 transition-all ease-in-out duration-300 $variantClass", 'type'=>'submit'])}}>
<button {{$attributes->merge(['class' => "font-medium hover:opacity-80 active:scale-80 transition-all ease-in-out duration-300 $variantClass", 'type'=>'submit'])}}>
<div class="flex justify-center items-center space-x-2">
@if($icon !=='')
@svg("heroicon-o-$icon", 'w-5 h-5')
@endif
@if(filled($slot))
<p>{{$slot}}</p>
@endif
</div>
</button>
@endif

View File

@ -1,7 +1,7 @@
@props(['label' => '', 'name' => '', 'placeholder' => '', 'type' => 'text', 'description' => '', 'required' => false, 'value' => ''])
<div {{$attributes->merge(['class' => 'flex flex-col space-y-2'])}}>
<div {{$attributes->merge(['class' => 'flex flex-col'])}}>
@if($label !== '')
<label class="text-sm font-bold" for="{{$name}}">
<label class="text-sm font-bold mb-2" for="{{$name}}">
{{$label}}
@if($required)
*
@ -10,10 +10,11 @@
@endif
<input class="bg-[#F3F3F5] py-2 px-4 rounded-lg"
type="{{$type}}" placeholder="{{$placeholder}}"
name="{{$name}}" value="{{old($name, $value)}}"
name="{{$name}}" value="{{old($name, request($name, $value))}}"
{{$required?'required':''}}
{{$attributes}}
>
{{$slot}}
@if($description !== '')
<p class="text-accent-600 text-xs">{{$description}}</p>
@endif

View File

@ -6,7 +6,8 @@
'valueKey' => 'value',
'label' => '',
'required' => false,
'selected' => ''
'selected' => '',
'selectablePlaceholder' => false
]
)
<div class="flex flex-col space-y-2">
@ -26,12 +27,14 @@
class="bg-[#F3F3F5] py-2 px-4 rounded-lg text-sm font-bold invalid:text-accent-600 text-black h-full"
>
@if($placeholder !== '')
<option {{old($name) === '' || $selected === '' ? 'selected' : ''}} disabled>{{$placeholder}}</option>
<option
value=" "
{{old($name, request($name, $selected)) === '' || $selected === '' ? 'selected' : ''}} {{$selectablePlaceholder ? '' : 'disabled'}} >{{$placeholder}}</option>
@endif
@foreach($options as $option)
<option
value="{{$option[$valueKey]}}" {{$option[$valueKey] == old($name, $selected) ? 'selected' : ''}}> {{$option[$labelKey]}} </option>
value="{{$option[$valueKey]}}" {{$option[$valueKey] == old($name, request($name, $selected)) ? 'selected' : ''}}> {{$option[$labelKey]}} </option>
@endforeach
</select>

View File

@ -1,4 +1,4 @@
@props(['activeColor' => 'bg-white'])
<div {{$attributes->merge(['class' => 'flex bg-[#ececf0] p-1 rounded-full w-fit'])}}>
<div {{$attributes->merge(['class' => 'flex gap-2 bg-[#ececf0] p-1 rounded-full w-fit'])}}>
{{$slot}}
</div>

View File

@ -2,7 +2,7 @@
@aware(['activeColor' => 'bg-white'])
<div
{{$attributes}}
{{$attributes->class(["rounded-full", $activeColor => $active])}}
{{$attributes->class(["rounded-full hover:bg-gray-100 hover:border-gray-300 border border-transparent transition-colors duration-300 ease-in-out", $activeColor => $active])}}
>
{{ $slot }}
</div>

View File

@ -1,51 +1,15 @@
@php
$categories = [0 => ['name' => 'All Categories', 'value' => '0']];
@endphp
<x-layout title="Deals">
<x-dashboard.page-heading
title="Explore Deals"
description="Discover trusted recommendation"
>
<x-slot:end>
<div class="flex items-center">
<div class="relative group">
<x-ui.button icon="user-circle" class="cursor-pointer" onclick="showMenu(this)"></x-ui.button>
<ul class="menu invisible group-hover:visible transition-all duration-300 ease-in-out w-48 absolute right-0 bg-white border border-gray-300 rounded-md shadow-xl py-2 text-accent-600">
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{$profileLink}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900">
<x-heroicon-o-user class="w-4"/>
</div>
<p>Profile</p>
</a>
</li>
@if(auth()->user()->role === \App\Enums\UserTypes::Broker->value)
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{route('broker.dashboard')}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900">
<x-heroicon-o-adjustments-horizontal class="w-4"/>
</div>
<p>Control Panel</p>
</a>
</li>
@endif
</ul>
</div>
<form method="post" action="{{route('logout')}}">
@csrf
@method('delete')
<x-ui.button class="flex space-x-3 hover:bg-red-50 hover:border-red-100 hover:text-red-500 border border-white">
<x-heroicon-o-arrow-right-start-on-rectangle class="w-4 stroke-2 mr-2"/>
<p class="hidden sm:block">Logout</p>
</x-ui.button>
</form>
</div>
<x-explore.profile-menu :profile-link="$profileLink"/>
</x-slot:end>
</x-dashboard.page-heading>
<section class="flex flex-col space-y-8 bg-[#F9FAFB] wrapper mt-2 pb-6">
<!-- Session messages -->
<div>
@session('success')
<x-ui.alert variant="success">{{$value}}</x-ui.alert>
@ -56,35 +20,10 @@
@endsession
</div>
<x-dashboard.card>
<div class="flex gap-4 flex-col sm:flex-row">
<x-ui.input class="flex-1" name="search" placeholder="Search deals, services, places"/>
<x-ui.select name="category" :options="$categories" value-key="value" label-key="name"/>
</div>
</x-dashboard.card>
<x-explore.search :recent-searches="$recentSearches" :categories="$categories" />
<x-ui.toggle-button-group>
<x-ui.toggle-button :active="request('sortBy') == null">
<a href="{{route('explore')}}" class="flex items-center px-2 py-1 space-x-2">
<x-heroicon-o-clock class="w-4 stroke-2"/>
<p class="font-bold text-xs sm:text-sm md:text-md">All Deals</p>
</a>
</x-ui.toggle-button>
<x-ui.toggle-button :active="request('sortBy') === \App\Enums\ExplorePageFilters::Like->value" >
<a href="{{route('explore', ['sortBy' => 'like'])}}" class="flex items-center px-2 py-1 space-x-2">
<x-heroicon-o-arrow-trending-up class="w-4 stroke-2"/>
<p class="font-bold text-xs sm:text-sm md:text-md">Most Liked</p>
</a>
</x-ui.toggle-button>
<x-ui.toggle-button :active="request('sortBy') === \App\Enums\ExplorePageFilters::Click->value" >
<a href="{{route('explore', ['sortBy'=>'click'])}}" class="flex items-center px-2 py-1 space-x-2">
<x-heroicon-o-star class="w-4 stroke-2"/>
<p class="font-bold text-xs sm:text-sm md:text-md">Most Clicked</p>
</a>
</x-ui.toggle-button>
</x-ui.toggle-button-group>
<!-- Sorting buttons -->
<x-explore.toggle-buttons />
<x-dashboard.user.listing :deals="$deals"/>
<x-dashboard.user.report-modal/>