feature (favorite and reported deals):

- add favorites and reported tabs in user profile pages
- add remove favorites
- customers can view a deal directly from profiles section and deal modal is shown in explore page
- fix formatting by pint
This commit is contained in:
kusowl 2026-01-23 16:14:04 +05:30
parent a5f2f43fb1
commit 94ef8f360d
32 changed files with 284 additions and 36 deletions

View File

@ -1,4 +1,4 @@
APP_NAME=Laravel APP_NAME=DealHub
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true

View File

@ -0,0 +1,20 @@
<?php
namespace App\Actions;
use App\Enums\InteractionType;
use App\Models\Deal;
use App\Models\User;
use Illuminate\Support\Collection;
final readonly class GetUserFavoritesAction
{
public function execute(User $user): Collection
{
return $user->interactedDeals()
->where('interactions.type', InteractionType::Favorite)
->tap(fn ($q) => (new Deal)->withActiveDeals($q))
->select('deals.id', 'deals.title')
->get();
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Actions;
use App\Models\User;
use Illuminate\Support\Collection;
final readonly class GetUserReportedDealsAction
{
public function execute(User $user): Collection
{
return $user->reports()
->select('reports.id')
->with('deals:id,title')
->get();
}
}

View File

@ -25,22 +25,22 @@ public function store(StoreRegisterdUser $request)
DB::transaction(function () use ($data) { DB::transaction(function () use ($data) {
switch ($data['role']) { switch ($data['role']) {
case UserTypes::Broker->value: case UserTypes::Broker->value:
{
$data['status'] = UserStatus::Pending->value; $data['status'] = UserStatus::Pending->value;
// Create Broker first, then link the user // Create Broker first, then link the user
$broker = Broker::create(); $broker = Broker::create();
$broker->user()->create($data); $broker->user()->create($data);
break; break;
}
case UserTypes::User->value: case UserTypes::User->value:
{
$data['status'] = UserStatus::Active->value; $data['status'] = UserStatus::Active->value;
$customer = Customer::create(); $customer = Customer::create();
$customer->user()->create($data); $customer->user()->create($data);
break; break;
}
} }
}); });

View File

@ -9,7 +9,6 @@
use App\Services\ProfileInitialsService; use App\Services\ProfileInitialsService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class BrokerProfileController extends Controller class BrokerProfileController extends Controller
{ {

View File

@ -6,6 +6,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Deal; use App\Models\Deal;
use App\Models\Interaction; use App\Models\Interaction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -15,9 +16,8 @@ class InteractionController extends Controller
* Interact to a deal by Like or Favorite state * Interact to a deal by Like or Favorite state
* *
* @param InteractionType $type [InteractionType::Like, InteractionType::Favorite] * @param InteractionType $type [InteractionType::Like, InteractionType::Favorite]
* @return \Illuminate\Http\JsonResponse
*/ */
public function togglesState(Deal $deal, InteractionType $type) public function togglesState(Deal $deal, InteractionType $type, Request $request)
{ {
if (! in_array($type, [InteractionType::Like, InteractionType::Favorite])) { if (! in_array($type, [InteractionType::Like, InteractionType::Favorite])) {
return response()->json(['error' => 'This interaction is not supported'], 400); return response()->json(['error' => 'This interaction is not supported'], 400);
@ -47,8 +47,11 @@ public function togglesState(Deal $deal, InteractionType $type)
$message = ucfirst($type->value).' added to deal'; $message = ucfirst($type->value).' added to deal';
} }
if ($request->expectsJson()) {
return response()->json(['message' => $message]); return response()->json(['message' => $message]);
} else {
return back()->with('success', $message);
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Error when liked a deal', Log::error('Error when liked a deal',
@ -60,9 +63,12 @@ public function togglesState(Deal $deal, InteractionType $type)
'trace' => $e->getTraceAsString(), 'trace' => $e->getTraceAsString(),
] ]
); );
if ($request->expectsJson()) {
return response()->json(['error' => 'Something went wrong.'], 500); return response()->json(['error' => 'Something went wrong.'], 500);
} }
return back()->with('error', 'Something went wrong.');
}
} }
public function redirect(Deal $deal) public function redirect(Deal $deal)

View File

@ -78,6 +78,21 @@ public function update(Request $request, Report $report)
*/ */
public function destroy(Report $report) public function destroy(Report $report)
{ {
// try {
DB::transaction(function () use ($report) {
$report->deals()->detach();
$report->delete();
});
return back()->with('success', 'Report deleted successfully.');
} catch (\Throwable $e) {
Log::error('Error deleting report', [
'user_id' => Auth::id(),
'report_id' => $report->id,
'error' => $e->getMessage(),
]);
}
return back()->with('error', 'Something went wrong.');
} }
} }

View File

@ -2,13 +2,12 @@
namespace App\Http\Controllers\User; namespace App\Http\Controllers\User;
use App\Actions\GetUserFavoritesAction;
use App\Actions\GetUserReportedDealsAction;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreBrokerProfileRequest;
use App\Http\Requests\StoreCustomerProfileRequest; use App\Http\Requests\StoreCustomerProfileRequest;
use App\Models\Broker;
use App\Models\User; use App\Models\User;
use App\Services\ProfileInitialsService; use App\Services\ProfileInitialsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -17,8 +16,12 @@ class UserProfileController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(User $profile, ProfileInitialsService $service) public function show(
{ User $profile,
ProfileInitialsService $service,
GetUserFavoritesAction $favoritesAction,
GetUserReportedDealsAction $reportedDealsAction
) {
// Get the user profile // Get the user profile
$user = $profile->type; $user = $profile->type;
@ -32,7 +35,9 @@ public function show(User $profile, ProfileInitialsService $service)
->with('initials', $initials) ->with('initials', $initials)
->with('location', $user->location) ->with('location', $user->location)
->with('bio', $user->bio) ->with('bio', $user->bio)
->with('phone', $user->phone); ->with('phone', $user->phone)
->with('favorites', $favoritesAction->execute($profile))
->with('reported', $reportedDealsAction->execute($profile));
} }
/** /**

View File

@ -8,6 +8,7 @@
class Customer extends Model class Customer extends Model
{ {
protected $fillable = ['bio', 'location', 'phone']; protected $fillable = ['bio', 'location', 'phone'];
public function user(): MorphOne public function user(): MorphOne
{ {
return $this->morphOne(User::class, 'role'); return $this->morphOne(User::class, 'role');

View File

@ -118,5 +118,4 @@ public function filterByCategory(Builder $query, string $category): Builder
{ {
return $query->where('deal_category_id', $category); return $query->where('deal_category_id', $category);
} }
} }

View File

@ -89,4 +89,14 @@ public function dealsInteractions(): HasManyThrough
{ {
return $this->hasManyThrough(Interaction::class, Deal::class); return $this->hasManyThrough(Interaction::class, Deal::class);
} }
public function interactedDeals(): HasManyThrough
{
return $this->hasManyThrough(Deal::class, Interaction::class, 'user_id', 'id', 'id', 'deal_id');
}
public function reports(): HasMany
{
return $this->hasMany(Report::class);
}
} }

View File

@ -6,7 +6,6 @@
class ProfileInitialsService class ProfileInitialsService
{ {
/** /**
* Create the initials from a full name (e.g. John Doe, Alex Mark, jane clerk) * Create the initials from a full name (e.g. John Doe, Alex Mark, jane clerk)
* to display on the profile page (e.g. JD, AM, JC). * to display on the profile page (e.g. JD, AM, JC).

View File

@ -4,7 +4,8 @@
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration { return new class extends Migration
{
/** /**
* Run the migrations. * Run the migrations.
*/ */

View File

@ -9,13 +9,15 @@ import "./toast.js"
import "./deal-view-modal.js" import "./deal-view-modal.js"
import {favorite, like, redirect} from "./interaction.js"; import {favorite, like, redirect} from "./interaction.js";
import {showReportModal} from "./report-deal.js"; import {showReportModal} from "./report-deal.js";
import {initTabs} from "./tab.js";
import {loadModalFromQuery} from "./explore-page.js";
document.like = like; document.like = like;
document.favorite = favorite; document.favorite = favorite;
document.redirect = redirect; document.redirect = redirect;
document.showReportModal = showReportModal; document.showReportModal = showReportModal;
window.addEventListener('load', () => { window.addEventListener('load', async () => {
const preloader = document.getElementById('preloader'); const preloader = document.getElementById('preloader');
const content = document.getElementById('content'); const content = document.getElementById('content');
@ -32,4 +34,8 @@ window.addEventListener('load', () => {
if (savedState) { if (savedState) {
setSidebarState(savedState); setSidebarState(savedState);
} }
initTabs();
await loadModalFromQuery();
}); });

View File

@ -1,4 +1,7 @@
import axios from 'axios'; import axios from 'axios';
window.axios = axios; window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.axios.defaults.headers.common['Accept'] = 'application/json';
window.axios.defaults.headers.common['Content-Type'] = 'application/json';

View File

@ -3,7 +3,7 @@ import {closeModal, showModal} from "@/modal.js";
import {redirect} from "./interaction.js"; import {redirect} from "./interaction.js";
import {toggleShimmer} from "./shimmer.js"; import {toggleShimmer} from "./shimmer.js";
async function showDealModal(dealId) { export async function showDealModal(dealId) {
if (!dealId) { if (!dealId) {
showToast('Something went wrong!'); showToast('Something went wrong!');
return; return;

View File

@ -0,0 +1,11 @@
import {showDealModal} from "./deal-view-modal.js";
export async function loadModalFromQuery(){
const query = new URLSearchParams(window.location.search);
if (query.has('show')) {
const dealId = query.get('show');
if(dealId){
await showDealModal(dealId);
}
}
}

32
resources/js/tab.js Normal file
View File

@ -0,0 +1,32 @@
export function initTabs() {
try {
const tabs = document.querySelectorAll('.tabs');
tabs.forEach(tab => {
const tabBtns = tab.querySelectorAll('.tab-btn');
tabBtns.forEach(tabBtn =>
tabBtn.addEventListener('click', (e) => {
// Make the current button active
tabBtns.forEach(btn => btn.classList.remove('bg-white'));
tabBtn.classList.add('bg-white');
// Hide all other tabs
const tabContents = tab.querySelectorAll('.tab-content');
tabContents.forEach(content => content.removeAttribute('data-show'));
// Show the target tab
const targetContent = tabBtn.dataset.target;
if (targetContent) {
const contentTabData = `[data-tab="${targetContent}"]`;
const tabContent = tab.querySelector(contentTabData);
if (tabContent) {
tabContent.setAttribute('data-show', '')
}
}
}
)
);
})
} catch (e) {
console.error(e)
}
}

View File

@ -2,9 +2,9 @@
<div class="p-4 text-sm bg-gray-100 border-gray-200 border rounded-xl"> <div class="p-4 text-sm bg-gray-100 border-gray-200 border rounded-xl">
<p class="font-bold mb-2">Broker Contact</p> <p class="font-bold mb-2">Broker Contact</p>
<div class="text-accent-600 space-y-1"> <div class="text-accent-600 space-y-1">
<p data-is-loading="true" class="broker-name">{{$broker->name ?? ''}}</p> <p data-is-loading="false" class="broker-name">{{$broker->name ?? ''}}</p>
<p data-is-loading="true" class="broker-email">{{$broker->email ?? ''}}</p> <p data-is-loading="false" class="broker-email">{{$broker->email ?? ''}}</p>
<p data-is-loading="true" class="broker-phone">{{$broker->role->phone ?? ''}}</p> <p data-is-loading="false" class="broker-phone">{{$broker->role->phone ?? ''}}</p>
</div> </div>
</div> </div>

View File

@ -1,4 +1,4 @@
@props(['deals' => []]) @props(['deals' => [], 'isInteractive'])
<div class="grid md:grid-cols-2 gap-6"> <div class="grid md:grid-cols-2 gap-6">
@forelse($deals as $deal) @forelse($deals as $deal)
<x-dashboard.user.listing-card :deal="$deal" :broker="$deal->broker"/> <x-dashboard.user.listing-card :deal="$deal" :broker="$deal->broker"/>

View File

@ -3,6 +3,7 @@
$variants = [ $variants = [
'neutral' => 'bg-primary-600 text-white', 'neutral' => 'bg-primary-600 text-white',
'ghost' => 'bg-gray-100 text-black text-sm', 'ghost' => 'bg-gray-100 text-black text-sm',
'red' => 'bg-red-500 text-red-100 text-sm'
]; ];
$variantClass = $variants[$variant] ?? ''; $variantClass = $variants[$variant] ?? '';

View File

@ -0,0 +1,4 @@
@props(['active' => false])
<x-ui.toggle-button :active="$active" {{$attributes->merge(['class' => 'tab-btn'])}}>
{{$slot}}
</x-ui.toggle-button>

View File

@ -0,0 +1,3 @@
<div {{$attributes->merge(['class' => 'tab-content [&:not([data-show])]:hidden'])}}>
{{$slot}}
</div>

View File

@ -0,0 +1,8 @@
<div {{$attributes->merge(['class' => 'tabs'])}} >
<x-ui.toggle-button-group class="flex-row">
{{$buttons ?? ''}}
</x-ui.toggle-button-group>
<div class="w-full">
{{$slot}}
</div>
</div>

View File

@ -0,0 +1,3 @@
<tr class="font-bold">
{{$slot}}
</tr>

View File

@ -0,0 +1,6 @@
<div class="pt-2 rounded-lg border border-gray-200">
<table class="table-auto w-full ">
{{$slot}}
</table>
</div>

View File

@ -0,0 +1,8 @@
<tr class=" border-t border-t-gray-200 ">
{{$slot}}
@if(($actions ?? '') !== '')
<td class="border-l border-l-gray-200">
{{$actions ?? ''}}
</td>
@endif
</tr>

View File

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

View File

@ -15,7 +15,23 @@ class="border border-accent-600/40">
</x-slot:end> </x-slot:end>
</x-dashboard.page-heading> </x-dashboard.page-heading>
<div class="flex items-center justify-center mt-4 md:mt-8 px-4 pb-4 pt-0 md:px-8 md:pb-8"> @session('error')
<div class="m-4">
<x-ui.alert variant="error">
{{$value}}
</x-ui.alert>
</div>
@endsession
@session('success')
<div class="m-8">
<x-ui.alert variant="success">
{{$value}}
</x-ui.alert>
</div>
@endsession
<div class="flex flex-col space-y-8 items-center justify-center mt-4 md:mt-8 px-4 pb-4 pt-0 md:px-8 md:pb-8">
<div class="flex items-center justify-center w-full"> <div class="flex items-center justify-center w-full">
<x-dashboard.card class="w-full"> <x-dashboard.card class="w-full">
<div class="grid grid-cols-8 gap-6"> <div class="grid grid-cols-8 gap-6">
@ -60,5 +76,82 @@ class="w-25 h-25 rounded-xl bg-linear-150 from-[#305afc] to-[#941dfb] text-5xl t
</div> </div>
</x-dashboard.card> </x-dashboard.card>
</div> </div>
<x-ui.tab class="self-start w-full">
<x-slot:buttons>
<x-ui.tab.button :active="true" data-target="favorites">
<div class="flex items-center cursor-default px-2 py-1 space-x-2">
<x-heroicon-o-heart class="w-4 stroke-2"/>
<p class="font-bold text-xs sm:text-sm md:text-md">Favorites</p>
</div>
</x-ui.tab.button>
<x-ui.tab.button data-target="reports">
<div class="flex items-center cursor-default px-2 py-1 space-x-2">
<x-heroicon-o-exclamation-triangle class="w-4 stroke-2"/>
<p class="font-bold text-xs sm:text-sm md:text-md">Reported Deals</p>
</div>
</x-ui.tab.button>
</x-slot:buttons>
<x-ui.tab.content data-show data-tab="favorites" class="w-full">
<x-dashboard.card class="bg-white mt-8">
<p class="font-bold mb-6">Your favorite deals</p>
<x-ui.table>
<x-ui.table.head>
<th colspan="2" class="pb-2">Deals</th>
</x-ui.table.head>
@forelse($favorites as $deal)
<x-ui.table.row>
<td class="text-sm px-4">{{$deal->title}}</td>
<x-slot:actions>
<div class="flex items-center justify-center space-x-1 md:space-x-4 py-1 px-2">
<x-ui.button-sm link="{{route('explore', ['show' => $deal->id])}}" variant="ghost">
<x-heroicon-o-eye class="w-4"/>
</x-ui.button-sm>
<form action="{{route('favorite', $deal->id)}}"
onsubmit="return confirm('Are you sure to delete this ?')" method="post"
class=" h-full items-center flex justify-center">
@csrf
<x-ui.button-sm variant="red">
<x-heroicon-o-trash class="w-4"/>
</x-ui.button-sm>
</form>
</div>
</x-slot:actions>
</x-ui.table.row>
@empty
<x-ui.table.row>
<td colspan="2" class="text-center text-sm text-accent-600 py-2">No Deals found</td>
</x-ui.table.row>
@endforelse
</x-ui.table>
</x-dashboard.card>
</x-ui.tab.content>
<x-ui.tab.content data-tab="reports">
<x-dashboard.card class="bg-white mt-8">
<p class="font-bold mb-6">Your reported deals</p>
<x-ui.table>
<x-ui.table.head>
<th colspan="2" class="pb-2">Deals</th>
</x-ui.table.head>
@forelse($reported as $report)
<x-ui.table.row>
<td class="text-sm px-4">{{$report->deals->first()->title}}</td>
<x-slot:actions>
<div class="flex items-center justify-center py-1 px-2">
<x-ui.button-sm link="{{route('explore', ['show' => $deal->id])}}" variant="ghost">
<x-heroicon-o-eye class="w-4"/>
</x-ui.button-sm>
</div>
</x-slot:actions>
</x-ui.table.row>
@empty
<x-ui.table.row>
<td colspan="2" class="text-center text-sm text-accent-600 py-2">No Deals found</td>
</x-ui.table.row>
@endforelse
</x-ui.table>
</x-dashboard.card>
</x-ui.tab.content>
</x-ui.tab>
</div> </div>
</x-layout> </x-layout>

View File

@ -5,7 +5,6 @@
use App\Queries\ExplorePageDealsQuery; use App\Queries\ExplorePageDealsQuery;
Route::get('/deals/{deal}', function (Deal $deal) { Route::get('/deals/{deal}', function (Deal $deal) {
sleep(2);
return new DealResource( return new DealResource(
(new ExplorePageDealsQuery) (new ExplorePageDealsQuery)
->builder() ->builder()

View File

@ -17,9 +17,9 @@
->middleware('throttle:30,1') ->middleware('throttle:30,1')
->name('favorite'); ->name('favorite');
Route::post('/report/{deal}', [ReportController::class, 'store']) Route::resource('report', ReportController::class)
->middleware('throttle:5,1') ->middleware('throttle:5,1')
->name('report.store'); ->only(['store', 'destroy']);
Route::get('/redirect/{deal}', [InteractionController::class, 'redirect']) Route::get('/redirect/{deal}', [InteractionController::class, 'redirect'])
->middleware(['throttle:10,1', 'signed']) ->middleware(['throttle:10,1', 'signed'])