feature(report a deal): users can report a deal

This commit is contained in:
kusowl 2026-01-16 19:38:30 +05:30
parent f33f68cd3e
commit 3750638122
28 changed files with 467 additions and 58 deletions

View File

@ -0,0 +1,14 @@
<?php
namespace App\Enums;
use App\Traits\EnumAsArray;
enum ReportStatus: string
{
use EnumAsArray;
case Pending = 'pending';
case Resolved = 'resolved';
case Rejected = 'rejected';
}

17
app/Enums/ReportType.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace App\Enums;
use App\Traits\EnumAsArray;
enum ReportType: string
{
use EnumAsArray;
case Spam = 'spam';
case Harmful = 'harmful';
case Nudity = 'nudity';
case Misinformation = 'misinformation';
case Unauthorized = 'unauthorized';
}

View File

@ -12,7 +12,7 @@ class InteractionController extends Controller
{ {
/** /**
* Interact to a deal by Like or Favorite state * Interact to a deal by Like or Favorite state
* @param Deal $deal *
* @param InteractionType $type [InteractionType::Like, InteractionType::Favorite] * @param InteractionType $type [InteractionType::Like, InteractionType::Favorite]
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*/ */
@ -46,6 +46,7 @@ public function togglesState(Deal $deal, InteractionType $type)
$message = "{$type->value} added to deal"; $message = "{$type->value} added to deal";
} }
return response()->json(['message' => $message]); return response()->json(['message' => $message]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -62,5 +63,4 @@ public function togglesState(Deal $deal, InteractionType $type)
return response()->json(['error' => 'Something went wrong.'], 500); return response()->json(['error' => 'Something went wrong.'], 500);
} }
} }
} }

View File

@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreReportRequest;
use App\Models\Deal;
use App\Models\Report;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ReportController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreReportRequest $request, Deal $deal)
{
$data = $request->validated();
$data['user_id'] = Auth::id();
// Check if the user already reported the deal
$alreadyReported = $deal->reports()->where('user_id', Auth::id())->first();
if ($alreadyReported) {
return response()->json(['message' => 'You already reported this report'], 405);
}
try {
DB::transaction(function () use ($data, $deal) {
Report::unguard();
Report::create($data)->deals()->attach($deal);
Report::reguard();
});
return response()->json(['message' => 'Report created'], 201);
} catch (\Throwable $exception) {
Log::error('Error creating report', [
'user_id' => Auth::id(),
'deal_id' => $deal->id,
'error' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
return response()->json(['message' => 'Error creating report'], 500);
}
}
/**
* Display the specified resource.
*/
public function show(Report $report)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Report $report)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Report $report)
{
//
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Enums\ReportType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreReportRequest 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 [
'type' => ['required', Rule::in(ReportType::values())],
'description' => 'required|string|min:10|max:500',
];
}
}

View File

@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Builder; 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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -28,8 +29,6 @@ public function interactions(): HasMany
/** /**
* Get deals that are active * Get deals that are active
* @param Builder $query
* @return Builder
*/ */
public function scopeWithActiveDeals(Builder $query): Builder public function scopeWithActiveDeals(Builder $query): Builder
{ {
@ -38,8 +37,6 @@ public function scopeWithActiveDeals(Builder $query): Builder
/** /**
* Get if the current user has liked or favorite the deal * Get if the current user has liked or favorite the deal
* @param Builder $query
* @return Builder
*/ */
public function scopeWithCurrentUserInteractions(Builder $query): Builder public function scopeWithCurrentUserInteractions(Builder $query): Builder
{ {
@ -51,7 +48,7 @@ public function scopeWithCurrentUserInteractions(Builder $query): Builder
'interactions as is_favorite' => function ($query) { 'interactions as is_favorite' => function ($query) {
$query->where('user_id', Auth::id()) $query->where('user_id', Auth::id())
->where('type', InteractionType::Favorite); ->where('type', InteractionType::Favorite);
} },
]); ]);
} }
@ -60,7 +57,12 @@ public function scopeWithLikes(Builder $query): Builder
return $query->withCount([ return $query->withCount([
'interactions as total_likes' => function ($query) { 'interactions as total_likes' => function ($query) {
$query->where('type', InteractionType::Like); $query->where('type', InteractionType::Like);
} },
]); ]);
} }
public function reports(): BelongsToMany
{
return $this->belongsToMany(Report::class);
}
} }

30
app/Models/Report.php Normal file
View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Enums\ReportStatus;
use App\Enums\ReportType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Report extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function deals(): BelongsToMany
{
return $this->belongsToMany(Deal::class);
}
protected function casts(): array
{
return [
'type' => ReportType::class,
'status' => ReportStatus::class,
];
}
}

View File

@ -8,4 +8,11 @@ public static function values(): array
{ {
return array_column(self::cases(), 'value'); return array_column(self::cases(), 'value');
} }
public static function assocValues(): array
{
return array_map(function ($enum) {
return ['name' => $enum->name, 'value' => $enum->value];
}, self::cases());
}
} }

View File

@ -0,0 +1,34 @@
<?php
use App\Enums\ReportStatus;
use App\Enums\ReportType;
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('reports', function (Blueprint $table) {
$table->id();
$table->enum('type', ReportType::values());
$table->enum('status', ReportStatus::values())->default(ReportStatus::Pending);
$table->text('description');
$table->foreignIdFor(User::class);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('reports');
}
};

View File

@ -0,0 +1,31 @@
<?php
use App\Models\Deal;
use App\Models\Report;
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('deal_report', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(Deal::class);
$table->foreignIdFor(Report::class);
$table->timestamp('created_at')->useCurrent();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('deal_report');
}
};

10
resources/js/modal.js Normal file
View File

@ -0,0 +1,10 @@
export function showModal(id){
let modal = document.getElementById(id);
modal.showModal();
}
export function closeModal(id){
let modal = document.getElementById(id);
modal.close();
}
document.showModal = showModal;
document.closeModal = closeModal;

View File

@ -0,0 +1,60 @@
import {closeModal, showModal} from './modal.js';
import {showToast} from './toast.js';
const reportModal = document.getElementById('report-modal');
const reportForm = document.getElementById('report-form');
function showReportModal(dealId, dealTitle) {
// Clear the fields
reportForm.reset();
const oldErrors = reportForm.querySelectorAll('.text-red-500');
oldErrors.forEach(error => error.remove());
reportModal.dataset.dealId = dealId;
const reportModalTitle = document.getElementById('report-modal-title');
const reportModalId = document.getElementById('report-modal-id');
reportModalTitle.innerText = dealTitle;
reportModalId.innerText = dealId;
showModal('report-modal');
}
reportForm.addEventListener('submit', async function (form) {
form.preventDefault();
const formData = new FormData(this);
const dealId = reportModal.dataset.dealId
try {
let response = await axios.post(
`report/${dealId}`,
formData
);
showToast('Report submitted. Thank you for keeping DealHub safe!');
closeModal('report-modal')
} catch (error) {
if (error.response.status === 405) {
closeModal('report-modal');
showToast('You already have reported this deal !');
return;
}
// Iterate over the all error messages spans and show validation errors
if (error.response.status === 422) {
let errors = error.response.data.errors;
Object.keys(errors).forEach(error => {
let errorField = reportForm.querySelector(`[name="${error}"]`)
const errorSpan = document.createElement('span');
errorSpan.textContent = errors[error][0];
errorSpan.classList.add('text-red-500');
errorSpan.classList.add('text-sm');
errorField.insertAdjacentElement('afterend', errorSpan);
})
}
}
});
document.showReportModal = showReportModal;

15
resources/js/toast.js Normal file
View File

@ -0,0 +1,15 @@
const toast = document.querySelector('.toast');
const toastBtn = document.querySelector('#toast-btn');
let toastMessage = toast.querySelector('#toast-message');
export function showToast(message) {
toast.classList.toggle('hidden')
toastMessage.textContent = message;
setTimeout(() => {
toast.classList.toggle('hidden');
}, 3000)
}
function hideToast() {
toast.classList.toggle('hidden')
}
document.hideToast = hideToast;
document.showToast = showToast;

View File

@ -1,6 +1,6 @@
@props(['id', 'like' => false, 'favourite' => false]) @props(['deal_id', 'deal_title', 'like' => false, 'favourite' => false])
<div class=""> <div class="">
<x-ui.button-sm @class(["text-accent-600", 'liked' => $like]) onclick="like(this, {{$id}})"> <x-ui.button-sm @class(["text-accent-600", 'liked' => $like]) onclick="like(this, {{$deal_id}})">
<x-heroicon-o-heart <x-heroicon-o-heart
@class([ @class([
"like w-4", "like w-4",
@ -15,7 +15,7 @@
/> />
</x-ui.button-sm> </x-ui.button-sm>
<x-ui.button-sm class="text-accent-600" onclick="favorite(this, {{$id}})"> <x-ui.button-sm class="text-accent-600" onclick="favorite(this, {{$deal_id}})">
<x-heroicon-o-star <x-heroicon-o-star
@class([ @class([
"favorite w-4", "favorite w-4",
@ -32,7 +32,7 @@
</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="showReportModal({{$deal_id}}, '{{$deal_title}}')">
<x-heroicon-o-exclamation-circle class="w-4"/> <x-heroicon-o-exclamation-circle class="w-4"/>
</x-ui.button-sm> </x-ui.button-sm>
</div> </div>

View File

@ -6,7 +6,7 @@
{{$deal->category->name}} {{$deal->category->name}}
</x-ui.button-sm> </x-ui.button-sm>
@ds($deal) @ds($deal)
<x-dashboard.user.action-toolbar :id="$deal->id" :like="$deal->is_liked" :favourite="$deal->is_favorite" /> <x-dashboard.user.action-toolbar :deal_title="$deal->title" :deal_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>

View File

@ -0,0 +1,36 @@
<x-ui.modal id="report-modal" class="w-130">
<form class="flex justify-between items-start" method="dialog">
<div class="flex space-x-2 items-center">
<x-icon-square variant="red" class="p-2!">
<x-heroicon-o-exclamation-circle class="w-6" />
</x-icon-square>
<p class="text-xl font-bold">Report Deal</p>
</div>
<button type="submit" class="" >
<x-heroicon-o-x-mark class="w-4" />
</button>
</form>
<p class="text-accent-600/80 mt-2 text-sm">Help us maintain a trusted platform by reporting inappropriate or suspicious content.</p>
<p class="text-accent-600 mt-2 text-sm font-bold">Deal being reported</p>
<div class="w-fit wrap-break-word px-2 py-4">
<p class="font-bold text-sm" id="report-modal-title"></p>
<p class="text-xs text-accent-600/80 pt-2">ID: #<span id="report-modal-id"></span></p>
</div>
<form action="" method="post" id="report-form" class="mt-4 flex flex-col space-y-4">
@csrf
<x-ui.select :options="\App\Enums\ReportType::assocValues()" label-key="name" value-key="value" name="type" placeholder="Select one reason" required label="Reason for report" />
<x-ui.textarea name="description" label="Additional Details" required placeholder="Please provide more details about why you're reporting this deal..." description="Minimum 10 characters" />
<p class="text-blue-700 bg-blue-100 text-xs p-4 rounded-xl">
<span class="font-bold">Note:</span> Your report will be reviewed by out moderation team within 24-48 hours.<br />
False reports may result in account restrictions.
</p>
<div class="w-full flex justify-end space-x-4">
<x-ui.button class="border border-accent-600/20" onclick="closeModal('report-modal')">Cancel</x-ui.button>
<x-ui.button variant="red" type="submit">Submit report</x-ui.button>
</div>
</form>
</x-ui.modal>

View File

@ -2,12 +2,13 @@
@php @php
$variants = [ $variants = [
'blue' => "bg-blue-100 text-blue-700", 'blue' => "bg-blue-100 text-blue-700",
'red' => 'bg-red-100 text-red-700',
'purple' => "bg-purple-100 text-purple-700", 'purple' => "bg-purple-100 text-purple-700",
'pink' => "bg-pink-100 text-pink-700", 'pink' => "bg-pink-100 text-pink-700",
'green' => "bg-green-100 text-green-700", 'green' => "bg-green-100 text-green-700",
'ghost' => 'bg-gray-100 text-gray-900' 'ghost' => 'bg-gray-100 text-gray-900'
] ]
@endphp @endphp
<div class="p-4 {{$variants[$variant]}} w-fit rounded-xl"> <div {{$attributes->merge(["class" => "$variants[$variant] p-4 w-fit rounded-xl"])}}>
{{$slot}} {{$slot}}
</div> </div>

View File

@ -18,7 +18,7 @@
</div> </div>
</a> </a>
@else @else
<button {{$attributes->merge(['class' => "px-4 py-2 rounded-lg font-medium hover:opacity-80 $variantClass"])}}> <button {{$attributes->merge(['class' => "px-4 py-2 rounded-lg font-medium hover:opacity-80 $variantClass", 'type'=>'submit'])}}>
<div class="flex justify-center items-center space-x-2"> <div class="flex justify-center items-center space-x-2">
@if($icon !=='') @if($icon !=='')
@svg("heroicon-o-$icon", 'w-5 h-5') @svg("heroicon-o-$icon", 'w-5 h-5')

View File

@ -1,4 +1,4 @@
@props(['name' => '']) @props(['name' => ''])
@error($name) @error($name)
<span class="text-xs text-red-500">{{ $message }}</span> <span class="text-xs text-red-500 error-message">{{ $message }}</span>
@enderror @enderror

View File

@ -0,0 +1,6 @@
<dialog {{$attributes->merge(["class"=>"fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-4 shadow-lg"])}} >
<div>
{{$slot}}
</div>
</dialog>
@vite('resources/js/modal.js')

View File

@ -26,7 +26,7 @@
class="bg-[#F3F3F5] py-2 px-4 rounded-lg text-sm font-bold invalid:text-accent-600 text-black h-full" class="bg-[#F3F3F5] py-2 px-4 rounded-lg text-sm font-bold invalid:text-accent-600 text-black h-full"
> >
@if($placeholder !== '') @if($placeholder !== '')
<option {{old($name) === ''? 'selected' : ''}} disabled>{{$placeholder}}</option> <option {{old($name) === '' || $selected === '' ? 'selected' : ''}} disabled>{{$placeholder}}</option>
@endif @endif
@foreach($options as $option) @foreach($options as $option)

View File

@ -1,4 +1,4 @@
@props(['label' => '', 'name' => '', 'placeholder' => '', 'required' => false, 'value' => '']) @props(['label' => '', 'name' => '', 'placeholder' => '', 'required' => false, 'value' => '', 'description'])
@if($label !== '') @if($label !== '')
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
@ -11,11 +11,15 @@
</label> </label>
<textarea <textarea
class="bg-[#F3F3F5] py-2 px-4 rounded-lg" class="bg-[#F3F3F5] py-2 px-4 rounded-lg h-40"
name="{{$name}}" placeholder="{{$placeholder}}" name="{{$name}}" placeholder="{{$placeholder}}"
required="{{$required?'required':''}}" required="{{$required?'required':''}}"
>{{old($name, $value)}}</textarea> >{{old($name, $value)}}</textarea>
@isset($description)
<p class="text-accent-600 text-xs">{{$description}}</p>
@endisset
<x-ui.inline-error :name="$name"/> <x-ui.inline-error :name="$name"/>
</div> </div>
@endif @endif

View File

@ -0,0 +1,6 @@
<div class="toast hidden fixed top-10 right-5 md:right-10 bg-black text-white rounded-xl shadow-lg p-4 flex space-x-2">
<p id="toast-message" class="max-w-70 wrap-break-word"></p>
<x-ui.button-sm onclick="hideToast()">
<x-heroicon-o-x-mark class="w-4" />
</x-ui.button-sm>
</div>

View File

@ -87,6 +87,8 @@
</x-ui.toggle-button-group> </x-ui.toggle-button-group>
<x-dashboard.user.listing :deals="$deals"/> <x-dashboard.user.listing :deals="$deals"/>
<x-dashboard.user.report-modal />
</section> </section>
@vite(['resources/js/menu.js', 'resources/js/interaction.js']) <x-ui.toast />
@vite(['resources/js/menu.js', 'resources/js/interaction.js', 'resources/js/report-deal.js', 'resources/js/toast.js'])
</x-layout> </x-layout>

View File

@ -1,54 +1,21 @@
<?php <?php
use App\Enums\InteractionType;
use App\Enums\UserTypes; use App\Enums\UserTypes;
use App\Http\Controllers\AuthenticatedUserController;
use App\Http\Controllers\Broker\BrokerDashboardController;
use App\Http\Controllers\Broker\BrokerProfileController;
use App\Http\Controllers\BrokerDealController;
use App\Http\Controllers\ExplorePageController; use App\Http\Controllers\ExplorePageController;
use App\Http\Controllers\HomeController; use App\Http\Controllers\HomeController;
use App\Http\Controllers\InteractionController;
use App\Http\Controllers\RegisteredUserController;
use App\Http\Middleware\HasRole; use App\Http\Middleware\HasRole;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
require __DIR__.'/web/auth.php';
require __DIR__.'/web/broker.php';
require __DIR__.'/web/interaction.php';
Route::get('/', HomeController::class)->name('home'); Route::get('/', HomeController::class)->name('home');
Route::middleware('guest')->group(function () {
Route::resource('/login', AuthenticatedUserController::class)
->middleware('throttle:6,1')
->only(['create', 'store']);
Route::resource('/register', RegisteredUserController::class)->only(['create', 'store']);
});
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
Route::delete('/logout', [AuthenticatedUserController::class, 'destroy'])->name('logout');
Route::get('/explore', ExplorePageController::class)->name('explore'); Route::get('/explore', ExplorePageController::class)->name('explore');
Route::view('/admin/dashboard', 'dashboards.admin.index') Route::view('/admin/dashboard', 'dashboards.admin.index')
->middleware(HasRole::class.':'.UserTypes::Admin->value) ->middleware(HasRole::class.':'.UserTypes::Admin->value)
->name('admin.dashboard'); ->name('admin.dashboard');
Route::prefix('/broker')
->name('broker.')
->middleware(HasRole::class.':'.UserTypes::Broker->value)
->group(function () {
Route::get('dashboard', [BrokerDashboardController::class, 'index'])->name('dashboard');
Route::resource('deals', BrokerDealController::class)->except('show');
Route::resource('profile', BrokerProfileController::class)->except('index', 'store', 'create');
});
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')
->name('like');
}); });

15
routes/web/auth.php Normal file
View File

@ -0,0 +1,15 @@
<?php
use App\Http\Controllers\AuthenticatedUserController;
use App\Http\Controllers\RegisteredUserController;
Route::middleware('guest')->group(function () {
Route::resource('/login', AuthenticatedUserController::class)
->middleware('throttle:6,1')
->only(['create', 'store']);
Route::resource('/register', RegisteredUserController::class)->only(['create', 'store']);
});
Route::delete('/logout', [AuthenticatedUserController::class, 'destroy'])
->middleware('auth')
->name('logout');

18
routes/web/broker.php Normal file
View File

@ -0,0 +1,18 @@
<?php
use App\Enums\UserTypes;
use App\Http\Controllers\Broker\BrokerDashboardController;
use App\Http\Controllers\Broker\BrokerProfileController;
use App\Http\Controllers\BrokerDealController;
use App\Http\Middleware\HasRole;
Route::prefix('/broker')
->name('broker.')
->middleware([HasRole::class.':'.UserTypes::Broker->value, 'auth'])
->group(function () {
Route::get('dashboard', [BrokerDashboardController::class, 'index'])->name('dashboard');
Route::resource('deals', BrokerDealController::class)->except('show');
Route::resource('profile', BrokerProfileController::class)->except('index', 'store', 'create');
});

View File

@ -0,0 +1,21 @@
<?php
use App\Enums\InteractionType;
use App\Http\Controllers\InteractionController;
use App\Http\Controllers\ReportController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function () {
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')
->name('favorite');
Route::post('/report/{deal}', [ReportController::class, 'store'])->name('report.store');
});