feature(users can comment on deals)

This commit is contained in:
kusowl 2026-02-04 16:50:20 +05:30
parent aa7e2f245f
commit a06fac4fef
22 changed files with 236 additions and 37 deletions

View File

@ -5,8 +5,8 @@
use App\Enums\ReportStatus;
use App\Http\Controllers\Controller;
use App\Models\Report;
use App\Notifications\ReportResolvedNotificationToBroker;
use App\Notifications\ReportRejectedNotificationToUser;
use App\Notifications\ReportResolvedNotificationToBroker;
use Illuminate\Support\Facades\DB;
class ReportController extends Controller

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\CommentRequest;
use App\Models\Comment;
use App\Models\Deal;
class CommentController extends Controller
{
public function index(Deal $deal)
{
$comments = $deal->comments()->with('user')->latest()->get();
$html = view('components.dashboard.user.deal-comment.index', compact('comments'))->render();
return response()->json(['html' => $html]);
}
public function store(Deal $deal, CommentRequest $request)
{
$data = $request->validated();
$data['user_id'] = $request->user()->id;
$data['deal_id'] = $deal->id;
Comment::create($data);
return response()->json(['message' => 'Comment created successfully.'], 201);
}
public function show(Comment $comment) {}
public function update(CommentRequest $request, Comment $comment)
{
$comment->update($request->validated());
}
public function destroy(Comment $comment)
{
$comment->delete();
return response()->json();
}
}

View File

@ -15,9 +15,11 @@ public function __invoke(ContactRequest $request)
$data = $request->validated();
$admin = Admin::first();
$admin->user->notify(new NewContactNotification($data['name'], $data['email'], $data['message']));
return back()->with('success', 'Your message has been sent successfully.');
} catch (\Throwable $e) {
\Log::error('Error sending contact message', [$e->getMessage()]);
return back()->with('error', 'Something went wrong.');
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CommentRequest extends FormRequest
{
public function rules(): array
{
return [
'text' => ['required', 'string', 'max:255'],
];
}
public function authorize(): bool
{
return true;
}
}

View File

@ -27,6 +27,7 @@ public function rules(): array
'message' => 'required|string|min:10|max:255',
];
}
protected function getRedirectUrl(): string
{
return parent::getRedirectUrl().'#contact';

25
app/Models/Comment.php Normal file
View File

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

View File

@ -76,6 +76,11 @@ public function reports(): BelongsToMany
return $this->belongsToMany(Report::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
/**
* Scope a query to only include active deals
*/

View File

@ -152,4 +152,9 @@ public function reports(): HasMany
{
return $this->hasMany(Report::class);
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
}

View File

@ -15,8 +15,7 @@ public function __construct(
private readonly string $customerName,
private readonly string $customerEmail,
private readonly string $customerMessage
) {
}
) {}
public function via($notifiable): array
{
@ -31,9 +30,8 @@ public function toMail($notifiable): MailMessage
->line('You have received a new message from your contact form:')
->line("**Name:** {$this->customerName}")
->line("**Email:** {$this->customerEmail}")
->line("**Message:**")
->line('**Message:**')
->line($this->customerMessage)
->action('Reply via Email', 'mailto:'.$this->customerEmail);;
->action('Reply via Email', 'mailto:'.$this->customerEmail);
}
}

View File

@ -13,8 +13,7 @@ class ReportRejectedNotificationToUser extends Notification implements ShouldQue
public function __construct(
private readonly string $dealTitle,
) {
}
) {}
public function via($notifiable): array
{
@ -27,9 +26,9 @@ public function toMail($notifiable): MailMessage
return (new MailMessage)
->subject('Update on Your Recent Report: '.$this->dealTitle)
->greeting('Hello!')
->line("Thank you for helping us maintain the integrity of our marketplace.")
->line('Thank you for helping us maintain the integrity of our marketplace.')
->line("We have completed our review of the deal you reported: **{$this->dealTitle}**.")
->line("Based on our moderation policy, we have rejected your report.")
->line('Based on our moderation policy, we have rejected your report.')
->action('View Marketplace', route('explore'))
->line('Your feedback helps make our community a safer place for everyone.');
}
@ -38,7 +37,7 @@ public function toArray($notifiable): array
{
return [
'report_outcome' => $this->isContentRemoved ? 'violation_confirmed' : 'no_violation_found',
'deal_title' => $this->dealTitle
'deal_title' => $this->dealTitle,
];
}
}

View File

@ -14,8 +14,7 @@ class ReportResolvedNotificationToBroker extends Notification implements ShouldQ
public function __construct(
private readonly string $dealTitle,
private readonly bool $isContentRemoved
) {
}
) {}
public function via($notifiable): array
{

View File

@ -14,8 +14,7 @@ class ReportResolvedNotificationToUser extends Notification implements ShouldQue
public function __construct(
private readonly string $dealTitle,
private readonly bool $isContentRemoved
) {
}
) {}
public function via($notifiable): array
{
@ -31,7 +30,7 @@ public function toMail($notifiable): MailMessage
return (new MailMessage)
->subject('Update on Your Recent Report: '.$this->dealTitle)
->greeting('Hello!')
->line("Thank you for helping us maintain the integrity of our marketplace.")
->line('Thank you for helping us maintain the integrity of our marketplace.')
->line("We have completed our review of the deal you reported: **{$this->dealTitle}**.")
->line("Based on our moderation policy, the content {$outcome}")
->action('View Marketplace', route('explore'))
@ -42,7 +41,7 @@ public function toArray($notifiable): array
{
return [
'report_outcome' => $this->isContentRemoved ? 'violation_confirmed' : 'no_violation_found',
'deal_title' => $this->dealTitle
'deal_title' => $this->dealTitle,
];
}
}

View File

@ -0,0 +1,26 @@
<?php
use App\Models\Deal;
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->string('text');
$table->foreignIdFor(Deal::class);
$table->foreignIdFor(User::class);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('comments');
}
};

22
resources/js/comments.js Normal file
View File

@ -0,0 +1,22 @@
export const postComment = async (dealId, text) => {
try {
let data = {
deal_id: dealId,
text: text
};
const response = await axios.post(`/api/deals/${dealId}/comments`, data)
console.log(response)
} catch (e) {
console.error(e)
}
}
export const getComments = async (dealId) => {
try {
const response = await axios.get(`/api/deals/${dealId}/comments`)
return response.data.html
} catch (e) {
console.error(e)
}
}

View File

@ -2,6 +2,7 @@ import {showToast} from "@/toast.js";
import {closeModal, showModal} from "@/modal.js";
import {redirect} from "./interaction.js";
import {toggleShimmer} from "./shimmer.js";
import {getComments, postComment} from "./comments.js";
export async function showDealModal(dealId) {
if (!dealId) {
@ -11,6 +12,7 @@ export async function showDealModal(dealId) {
const dealModal = document.getElementById('deal-modal');
showModal('deal-modal');
toggleShimmer(false, dealModal);
@ -19,6 +21,8 @@ export async function showDealModal(dealId) {
const response = await axios.get('/api/deals/' + dealId);
setDealDetails(response.data);
await setComments(dealId, dealModal);
toggleShimmer(true, dealModal);
dealModal.dataset.dealId = dealId;
@ -37,10 +41,7 @@ function setDealDetails(dealDetails) {
const deal = dealDetails.data
const {
id, title, description, link,
image, category, broker,
totalRedirection, totalLikes,
isLiked, isFavorite
id, title, description, link, image, category, broker, totalRedirection, totalLikes, isLiked, isFavorite
} = deal;
const dealModal = document.getElementById('deal-modal');
@ -89,6 +90,13 @@ function setDealDetails(dealDetails) {
}
}
async function setComments(dealId, dealModal) {
const commentsContainer = dealModal.querySelector('.comments-container');
toggleShimmer(false, commentsContainer);
commentsContainer.innerHTML = await getComments(dealId);
toggleShimmer(true, commentsContainer);
}
window.addEventListener('DOMContentLoaded', () => {
const dealCards = document.querySelectorAll('.deal-card');
if (dealCards) {
@ -104,4 +112,18 @@ window.addEventListener('DOMContentLoaded', () => {
});
})
}
const commentBtn = document.getElementById('commentSubmitBtn');
if (commentBtn) {
commentBtn.addEventListener('click', async () => {
const dealModal = document.getElementById('deal-modal');
const dealId = dealModal.dataset.dealId;
const textInput = dealModal.querySelector('input[name="comment"]');
let text = textInput.value;
textInput.value = '';
await postComment(dealId, text);
await setComments(dealId, dealModal);
})
}
});

View File

@ -1,5 +1,5 @@
@props(['broker' => ''])
<div class="p-4 text-sm bg-gray-100 border-gray-200 border rounded-xl">
<div {{$attributes->merge(['class' => "p-4 text-sm bg-gray-100 border-gray-200 border rounded-xl"])}}>
<p class="font-bold mb-2">Broker Contact</p>
<div class="text-accent-600 space-y-1">
<p data-is-loading="false" class="broker-name">{{$broker->name ?? ''}}</p>

View File

@ -0,0 +1,11 @@
@props(['comments' => []])
<div data-is-loading="true" class="comments-container mt-2 space-y-2 max-h-40 overflow-y-scroll data-[is-loading=true]:h-10">
@forelse($comments as $comment)
<x-dashboard.user.deal-comment.item :comment="$comment"/>
@empty
<div class="rounded-lg bg-white border border-gray-200 py-2 px-3">
<p class="text-center text-accent-600">No comments posted !</p>
</div>
@endforelse
</div>

View File

@ -0,0 +1,5 @@
@props(['comment'])
<div class="rounded-lg bg-white border border-gray-200 py-2 px-3">
<p class="font-bold text-accent-600">{{$comment->user->name}}</p>
<p>{{$comment->text}}</p>
</div>

View File

@ -1,19 +1,21 @@
<x-ui.modal id="deal-modal" class="deal-identifier w-11/12 md:w-10/12">
<x-ui.modal id="deal-modal" class="deal-identifier w-11/12 md:w-10/12 overflow-scroll">
<form class="flex justify-between items-start mb-4" method="dialog">
<p class="text-xl font-bold">Deal Details</p>
<button type="submit" class="">
<x-heroicon-o-x-mark class="w-4"/>
</button>
</form>
<div class="grid md:grid-cols-12 gap-4 items-stretch">
<div data-is-loading="true" class="md:col-span-8 h-0 min-h-full data-[is-loading=true]:h-60">
<div class="grid md:grid-cols-12 gap-4 items-stretch mb-4">
<div data-is-loading="true" class="md:col-span-8 h-40 md:h-0 min-h-full data-[is-loading=true]:h-60">
<div class="rounded-lg bg-gray-200 h-full">
<img src="" alt=" "
class="deal-image h-full w-full object-cover rounded-lg border-none">
</div>
</div>
<div class="md:col-span-4 flex flex-col gap-y-4">
<x-ui.button-sm data-is-loading="true" class="w-fit deal-category data-[is-loading=true]:h-6 data-[is-loading=true]:w-15" variant="neutral"/>
<x-ui.button-sm data-is-loading="true"
class="w-fit deal-category data-[is-loading=true]:h-6 data-[is-loading=true]:w-15"
variant="neutral"/>
<p data-is-loading="true" class="deal-title font-bold text-lg data-[is-loading=true]:h-3 "></p>
<p data-is-loading="true" class="deal-description text-sm text-accent-600 wrap-break-word"></p>
<div class="flex items-center justify-between">
@ -23,15 +25,26 @@ class="deal-image h-full w-full object-cover rounded-lg border-none">
</div>
<x-dashboard.user.deal-stats/>
</div>
<x-ui.button variant="neutral" data-is-loading="false" class="hidden deal-link space-x-2 items-center justify-center">
<x-ui.button variant="neutral" data-is-loading="false"
class="hidden deal-link space-x-2 items-center justify-center">
<div class="flex space-x-2">
<p>View Deal</p>
<x-heroicon-o-arrow-top-right-on-square class="w-5 ml-1"/>
</div>
</x-ui.button>
<x-dashboard.user.broker-contact/>
<x-dashboard.user.broker-contact id="comments"/>
</div>
</div>
<div class="p-2 text-sm bg-gray-50 border-gray-200 border rounded-xl">
<p class="font-bold mb-2">Comments</p>
<div class="flex items-center space-x-2">
<x-ui.input class="flex-1" name="comment" placeholder="Add a comment..."/>
<x-ui.button id="commentSubmitBtn" variant="ghost" icon="paper-airplane">
<p class="hidden md:block">Comment</p>
</x-ui.button>
</div>
<x-dashboard.user.deal-comment/>
</div>
</x-ui.modal>

View File

@ -1,9 +1,9 @@
@props(['variant' => '', 'icon' => '', 'link' => '', 'external' => false, 'round' => false])
@php
$variants = [
'neutral' => 'bg-primary-600 text-white',
'red' => 'bg-red-500 text-white',
'ghost' => 'bg-gray-200 text-gray-900'
'neutral' => 'bg-primary-600 text-white hover:bg-gray-200 hover:text-gray-900',
'red' => 'bg-red-500 text-white hover:bg-red-400 hover:text-red-800',
'ghost' => 'bg-gray-200 text-gray-900 hover:bg-primary-600 hover:text-white'
];
$variantClass = $variants[$variant] ?? '';
@ -29,13 +29,13 @@
</div>
</a>
@else
<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">
<button {{$attributes->merge(['class' => "font-medium active:scale-80 transition-all ease-in-out duration-300 $variantClass", 'type'=>'submit'])}}>
<div class="flex justify-center items-center md:space-x-2">
@if($icon !=='')
@svg("heroicon-o-$icon", 'w-5 h-5')
@endif
@if(filled($slot))
<p>{{$slot}}</p>
{{$slot}}
@endif
</div>
</button>

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\CommentController;
use App\Http\Controllers\Interaction\InteractionController;
use App\Http\Controllers\Interaction\ReportController;
use Illuminate\Support\Facades\Route;
@ -12,3 +13,5 @@
Route::post('/report/{deal}', [ReportController::class, 'store'])->name('report-deal');
Route::delete('/report/{deal}', [ReportController::class, 'destroy']);
});
Route::apiResource('deals.comments', CommentController::class)->except(['update', 'edit', 'destroy']);