Merge branch 'refs/heads/main' into bugfix/dealhub

This commit is contained in:
kusowl 2026-01-22 17:34:38 +05:30
commit 4060866b2d
31 changed files with 528 additions and 161 deletions

View File

@ -11,4 +11,5 @@ enum InteractionType: string
case Like = 'like';
case Favorite = 'favorite';
case Redirection = 'redirect';
case View = 'view';
}

View File

@ -97,4 +97,36 @@ public function redirect(Deal $deal)
return redirect()->away($deal->link);
}
public function view(Deal $deal)
{
ds('hi');
try {
$interaction = $deal->interactions()->firstOrCreate([
'type' => InteractionType::View,
'user_id' => Auth::id(),
]);
if (! $interaction->wasRecentlyCreated) {
$interaction->increment('count');
}
ds($interaction);
} catch (\Throwable $e) {
Log::error('Error when view a deal',
[
'deal_id' => $deal->id,
'user_id' => Auth::id(),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]
);
return response()->json(['error' => 'Something went wrong.'], 500);
}
return response()->json([
'message' => 'View counted',
]);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BrokerRoleResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'bio' => $this->bio,
'location' => $this->location,
'phone' => $this->phone,
];
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\URL;
class DealResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'image' => asset('storage/'.$this->image),
'link' => $this->link !== null ? URL::signedRoute('redirect', $this->id) : null,
'category' => $this->whenLoaded('category'),
'broker' => new UserResource($this->whenLoaded('broker')),
'totalLikes' => $this->total_likes,
'totalRedirection' => $this->total_redirection,
'isLiked' => $this->is_liked,
'isFavorite' => $this->is_favorite,
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => new BrokerRoleResource($this->whenLoaded('role')),
];
}
}

View File

@ -92,7 +92,9 @@ public function WithRedirectionPerDeal(Builder $query): Builder
#[Scope]
public function search(Builder $query, string $search): Builder
{
return $query->where('title', 'LIKE', "%$search%");
return $query
->where('title', 'LIKE', "%$search%")
->orWhereRelation('broker', 'name', 'LIKE', "%$search%");
}
/**

View File

@ -0,0 +1,27 @@
<?php
use App\Enums\InteractionType;
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::table('interactions', function (Blueprint $table) {
$table->enum('type', InteractionType::values())->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('interactions', function (Blueprint $table) {});
}
};

4
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "dealhub",
"name": "DealHub",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -2031,7 +2031,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2268,7 +2267,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",

View File

@ -1,5 +1,19 @@
import './bootstrap';
import {setSidebarState} from '@/sidebar.js';
import "./alert.js"
import "./image-input.js"
import "./menu.js"
import "./modal.js"
import "./sidebar.js"
import "./toast.js"
import "./deal-view-modal.js"
import {favorite, like, redirect} from "./interaction.js";
import {showReportModal} from "./report-deal.js";
document.like = like;
document.favorite = favorite;
document.redirect = redirect;
document.showReportModal = showReportModal;
window.addEventListener('load', () => {
const preloader = document.getElementById('preloader');

View File

@ -0,0 +1,106 @@
import {showToast} from "@/toast.js";
import {closeModal, showModal} from "@/modal.js";
import {redirect} from "./interaction.js";
async function showDealModal(dealId) {
if (dealId === null || dealId === "") {
showToast('Something went wrong!');
return;
}
// Open the deal modal and show a loading state
showModal('deal-modal')
try {
const response = await axios.get('/api/deals/' + dealId);
setDealDetails(response.data);
const dealModal = document.getElementById('deal-modal');
// Add the id so that action buttons can identify the route
dealModal.dataset.dealId = dealId;
// Increment the view count
await axios.post(`/api/view/${dealId}`);
} catch (e) {
console.error(e)
closeModal('deal-modal')
showToast('Something went wrong!');
}
}
function setDealDetails(dealDetails) {
if (dealDetails === null) {
throw new Error('DealDetails must be not null');
}
const deal = dealDetails.data
const {
id, title, description, link,
image, category, broker,
totalRedirection, totalLikes,
isLiked, isFavorite
} = deal;
const dealModal = document.getElementById('deal-modal');
dealModal.querySelector('.deal-image').src = image;
dealModal.querySelector('.deal-title').innerText = title;
dealModal.querySelector('.deal-description').innerText = description;
dealModal.querySelector('.deal-category').innerText = category.name;
dealModal.querySelector('.broker-name').innerText = broker.name;
dealModal.querySelector('.broker-email').innerText = broker.email;
dealModal.querySelector('.broker-phone').innerText = broker.role.phone;
// Set the like and click counts
let likeCountElm = dealModal.querySelector('.likeCount');
likeCountElm.querySelector('p').innerText = totalLikes ?? '0';
likeCountElm.dataset.count = totalLikes ?? '0';
dealModal.querySelector('.clickCount').querySelector('p').innerText = totalRedirection ?? '0';
// Set if current user has already liked the deal
let likeBtn = dealModal.querySelector('.likeBtn');
let likeBtnSvg = likeBtn.querySelector('svg');
if (isLiked) {
likeBtn.dataset.liked = 'true'
likeBtnSvg.classList.add('text-red-500', 'fill-current');
} else {
likeBtn.dataset.liked = 'false'
likeBtnSvg.classList.remove('text-red-500', 'fill-current');
}
// Set if current user has already favorite the deal
let favoriteBtnSvg = dealModal.querySelector('.favoriteBtn').querySelector('svg');
if (isFavorite) {
favoriteBtnSvg.classList.add('text-yellow-500', 'fill-current');
} else {
favoriteBtnSvg.classList.remove('text-yellow-500', 'fill-current');
}
const dealLink = dealModal.querySelector('.deal-link');
if (link !== null && link !== "") {
dealLink.classList.remove('hidden');
dealLink.classList.add('flex');
dealLink.addEventListener('click', () => {
redirect(link);
})
} else {
dealLink.classList.remove('flex');
dealLink.classList.add('hidden');
}
}
window.addEventListener('DOMContentLoaded', () => {
const dealCards = document.querySelectorAll('.deal-card');
if (dealCards) {
dealCards.forEach(dealCard => {
let dealId = dealCard.dataset.dealId;
let dealTitle = dealCard.querySelector('.action-toolbar').dataset.dealTitle;
dealCard.addEventListener('click', async (e) => {
if (e.target.closest('button')) {
return;
}
await showDealModal(dealId, dealTitle);
});
})
}
});

View File

@ -1,58 +1,71 @@
import {showToast} from "./toast.js";
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')
export async function like(button) {
const activeClasses = ['fill-current', 'text-red-500']
let isLiked = button.dataset.liked === 'true';
try {
// Update the like state and count
let id = button.closest('.deal-identifier').dataset.dealId;
const cardLikeBadge = document.getElementById(`likeBadge${id}`);
setLikeState(button, !isLiked);
updateLikeCount(cardLikeBadge, isLiked ? -1 : 1);
// Update the state of like button that is clicked
toggleState(button, !isLiked, activeClasses);
let response = await axios.post('/like/' + id);
if (response.status !== 200) {
showToast(response.data.message)
// Revert the ui
toggleHidden(likeBtns);
}
else{
showToast(response.data.message)
}
} catch (e) {
showToast(e.response.data.message)
toggleHidden(likeBtns);
showToast('Something went wrong!')
// Revert the states
toggleState(button, !isLiked, activeClasses);
console.error(e);
}
}
async function favorite(e, id) {
// Instant feedback for user
let favoriteBtns = e.querySelectorAll('.favorite');
toggleHidden(favoriteBtns);
export async function favorite(button) {
const activeClasses = ['fill-current', 'text-yellow-500']
let isFavorite = button.dataset.favorite === 'true';
try {
let response = await axios.post('/favorite/' + id);
let id = button.closest('.deal-identifier').dataset.dealId;
if (response.status !== 200) {
setFavoriteState(button, !isFavorite);
toggleState(button, !isFavorite, activeClasses);
let response = await axios.post('/favorite/' + id);
showToast(response.data.message)
// Revert the ui
toggleHidden(favoriteBtns);
}else{
showToast(response.data.message);
}
} catch (e) {
showToast(e.response.data.message)
toggleHidden(favoriteBtns);
toggleState(button, isFavorite, activeClasses);
console.error(e);
}
}
function toggleHidden(nodelist) {
nodelist.forEach((node) => node.classList.toggle('hidden'))
export function redirect(url, id) {
window.open(url, '_blank');
let redirectBadge = document.getElementById("redirectBadge".concat(id));
updateRedirectCount(redirectBadge, 1);
// increment the count in ui
}
export function setLikeState(button, isLiked) {
button.dataset.liked = isLiked ? 'true' : 'false';
}
export function setFavoriteState(button, isFavorite) {
button.dataset.favorite = isFavorite ? 'true' : 'false';
}
function toggleState(element, condition, activeClasses) {
const svg = element.querySelector('svg');
if (condition) {
svg.classList.add(...activeClasses);
} else {
svg.classList.remove(...activeClasses);
}
}
function updateLikeCount(badge, change) {
@ -60,28 +73,18 @@ function updateLikeCount(badge, change){
let likeCount = Math.max(parseInt(badge.dataset.count) + change, 0)
badge.querySelector('p').innerText = likeCount;
badge.dataset.count = likeCount.toString();
}
catch(e) {
} catch (e) {
console.error(e);
}
}
function redirect(url, id){
window.open(url, '_blank');
let redirectBadge = document.getElementById("redirectBadge".concat(id));
updateRedirectCount(redirectBadge, 1);
// increment the count in ui
}
function updateRedirectCount(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) {
} catch (e) {
console.error(e);
}
}
document.like = like;
document.favorite = favorite;
document.redirect = redirect;

View File

@ -3,14 +3,18 @@ const closeBtn = document.getElementById('closeBtn');
const mobileMenu = document.getElementById('mobileMenu');
const body = document.body;
if (openBtn) {
openBtn.addEventListener('click', () => {
mobileMenu.classList.remove('translate-x-full');
mobileMenu.classList.add('translate-x-0');
body.style.overflow = 'hidden';
})
}
if (closeBtn) {
closeBtn.addEventListener('click', () => {
mobileMenu.classList.add('translate-x-full');
mobileMenu.classList.remove('translate-x-0');
body.style.overflow = 'visible';
})
}

View File

@ -1,10 +1,11 @@
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) {
export function showReportModal(dealId, dealTitle) {
// Clear the fields
reportForm.reset();
const oldErrors = reportForm.querySelectorAll('.text-red-500');
@ -18,6 +19,8 @@ function showReportModal(dealId, dealTitle) {
showModal('report-modal');
}
if (reportForm) {
reportForm.addEventListener('submit', async function (form) {
form.preventDefault();
const formData = new FormData(this);
@ -56,5 +59,4 @@ reportForm.addEventListener('submit', async function (form) {
}
}
});
document.showReportModal = showReportModal;
}

View File

@ -1,7 +1,8 @@
const toast = document.querySelector('.toast');
const toastBtn = document.querySelector('#toast-btn');
let toastMessage = toast.querySelector('#toast-message');
export function showToast(message) {
let toastMessage = toast.querySelector('#toast-message');
toast.classList.remove('translate-x-[100vw]');
toast.classList.add('translate-x-0');
toastMessage.textContent = message;
@ -9,9 +10,11 @@ export function showToast(message) {
hideToast();
}, 5000)
}
function hideToast() {
toast.classList.remove('translate-x-0');
toast.classList.add('translate-x-[100vw]');
}
document.hideToast = hideToast;
document.showToast = showToast;

View File

@ -23,5 +23,4 @@ class=" flex flex-col space-y-4 md:space-y-8 bg-[#F9FAFB] overflow-y-auto overfl
{{$slot}}
</section>
</div>
@vite('resources/js/nav-menu.js')
</x-layout>

View File

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

View File

@ -0,0 +1,20 @@
@props(['isFavourite' => false, 'isInteractive'=> true])
@php
$actionAttributes = $isInteractive ? [
'data-favorite' => $isFavourite ? 'true' : 'false',
'onclick' => "favorite(this)"
] : [] ;
$buttonClasses = [
'text-accent-600 favoriteBtn',
];
@endphp
<x-ui.button-sm
{{$attributes->merge($actionAttributes)->class($buttonClasses)}}
>
<x-heroicon-o-star
@class([
"favorite w-4",
'fill-current text-yellow-500' => $isFavourite
])
/>
</x-ui.button-sm>

View File

@ -0,0 +1,6 @@
@props(['deal_id' => '', 'deal_title' => '', 'isLiked' => false, 'isFavourite' => false, 'isInteractive' => true])
<div class="action-toolbar" data-deal-title="{{$deal_title}}">
<x-dashboard.user.action-toolbar.like-button :is-liked="$isLiked" :is-interactive="$isInteractive" />
<x-dashboard.user.action-toolbar.favorite-button :is-favourite="$isFavourite" :is-interactive="$isInteractive" />
<x-dashboard.user.action-toolbar.report-button :id="$deal_id" :title="$deal_title" />
</div>

View File

@ -0,0 +1,21 @@
@props(['isLiked' => false, 'isInteractive' => true])
@php
$actionAttributes = $isInteractive ? [
'data-liked' => $isLiked ? 'true' : 'false',
'onclick' => "like(this)"
] : [] ;
$buttonClasses = [
'text-accent-600 likeBtn',
'liked' => $isLiked
];
@endphp
<x-ui.button-sm
{{$attributes->merge($actionAttributes)->class($buttonClasses)}}
>
<x-heroicon-o-heart
@class([
"like w-4",
'fill-current text-red-500' => $isLiked
])
/>
</x-ui.button-sm>

View File

@ -0,0 +1,4 @@
@props(['id', 'title'])
<x-ui.button-sm class="text-accent-600 reportBtn" onclick="showReportModal({{$id}}, '{{$title}}')">
<x-heroicon-o-exclamation-circle class="w-4"/>
</x-ui.button-sm>

View File

@ -1,10 +1,10 @@
@props(['broker'])
<div class="p-4 mb-4 text-sm bg-[#f9fafb] border-gray-100 border rounded-xl">
@props(['broker' => ''])
<div 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>{{$broker->name}}</p>
<p>{{$broker->email}}</p>
<p>{{$broker->role->phone}}</p>
<p class="broker-name">{{$broker->name ?? ''}}</p>
<p class="broker-email">{{$broker->email ?? ''}}</p>
<p class="broker-phone">{{$broker->role->phone ?? ''}}</p>
</div>
</div>

View File

@ -0,0 +1,37 @@
<x-ui.modal id="deal-modal" class="deal-identifier w-11/12 md:w-10/12">
<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 class="md:col-span-8 h-0 min-h-full">
<div class="rounded-lg bg-gray-200 h-full">
<img src="" alt="Image of the deal"
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 class="w-fit deal-category" variant="neutral"/>
<p class="deal-title font-bold text-lg "></p>
<p class="deal-description text-sm text-accent-600 wrap-break-word"></p>
<div class="flex justify-between">
<div>
<x-dashboard.user.action-toolbar.like-button :is-interactive="false"/>
<x-dashboard.user.action-toolbar.favorite-button :is-interactive="false"/>
</div>
<x-dashboard.user.deal-stats/>
</div>
<x-ui.button variant="neutral" 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/>
</div>
</div>
</x-ui.modal>

View File

@ -0,0 +1,10 @@
@props(['likeCount' => 0, 'clickCount' => 0])
<div class="flex space-x-3">
<x-dashboard.user.stat-badge class="likeCount" :count="$likeCount">
<x-heroicon-o-heart class="w-4"/>
</x-dashboard.user.stat-badge>
<x-dashboard.user.stat-badge class="clickCount" :count="$clickCount">
<x-heroicon-o-arrow-top-right-on-square class="w-4"/>
</x-dashboard.user.stat-badge>
</div>

View File

@ -1,11 +1,11 @@
@props(['deal' => '', 'broker' => ''])
<x-ui.image-card class="shadow-lg" :image="asset('storage/'.$deal->image)">
<x-ui.image-card class="deal-identifier deal-card shadow-lg cursor-pointer" :image="asset('storage/'.$deal->image)" data-deal-id="{{$deal->id}}">
<div class="bg-white pt-8 p-4 h-full space-y-2 flex flex-col justify-between">
<div class="flex justify-between">
<x-ui.button-sm variant="neutral">
{{$deal->category->name}}
</x-ui.button-sm>
<x-dashboard.user.action-toolbar :deal_title="$deal->title" :deal_id="$deal->id" :like="$deal->is_liked" :favourite="$deal->is_favorite" />
<x-dashboard.user.action-toolbar :deal_title="$deal->title" :deal_id="$deal->id" :is-liked="$deal->is_liked" :is-favourite="$deal->is_favorite" />
</div>
<p class="font-bold text-lg ">{{$deal->title}}</p>
@ -25,7 +25,7 @@
</x-dashboard.user.stat-badge>
</div>
@if(filled($deal->link))
<x-ui.button onclick="redirect('{{\Illuminate\Support\Facades\URL::signedRoute('redirect', $deal->id)}}', {{$deal->id}})" variant="neutral" class="flex space-x-2 items-center">
<x-ui.button onclick="redirect('{{\Illuminate\Support\Facades\URL::signedRoute('redirect', $deal->id)}}', {{$deal->id}})" variant="neutral" class="flex space-x-2 items-center mt-2">
<p>View Deal</p>
<x-heroicon-o-arrow-top-right-on-square class="w-5 ml-1"/>
</x-ui.button>

View File

@ -1,5 +1,5 @@
<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"])}} >
{{$attributes->merge(["class"=>"fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-lg p-2 md:p-4 shadow-lg"])}} >
<div>
{{$slot}}
</div>

View File

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

View File

@ -5,5 +5,4 @@
<x-how-it-works />
<x-get-in-touch />
<x-footer />
@vite('resources/js/nav-menu.js')
</x-layout>

10
routes/api/api.php Normal file
View File

@ -0,0 +1,10 @@
<?php
use Illuminate\Support\Facades\Route;
Route::prefix('/api')
->middleware('auth')
->group(function () {
include __DIR__.'/interactions.php';
include __DIR__.'/deals.php';
});

14
routes/api/deals.php Normal file
View File

@ -0,0 +1,14 @@
<?php
use App\Http\Resources\DealResource;
use App\Models\Deal;
use App\Queries\ExplorePageDealsQuery;
Route::get('/deals/{deal}', function (Deal $deal) {
return new DealResource(
(new ExplorePageDealsQuery)
->builder()
->where('id', $deal->id)
->first()
);
});

View File

@ -0,0 +1,7 @@
<?php
use App\Http\Controllers\InteractionController;
Route::post('/view/{deal}', [InteractionController::class, 'view'])
->middleware('throttle:30,1')
->name('view-deal');

View File

@ -19,3 +19,10 @@
->middleware(HasRole::class.':'.UserTypes::Admin->value)
->name('admin.dashboard');
});
/**
* This routes are accessed by JS XHR requests, and is loaded here cause
* we do not want to use sanctum for web requests
*/
// ------------- API Routes ------------
require __DIR__.'/api/api.php';