feat(Create deals): broker can create deals

- add deal category migration
- add deals migration and model
- add form to create deal
- add image preview modal when uploading the image
- refactor UI components to support `required` attribute
- refactor input component to support description
- fix some UI components does not support old values
- fix some UI components does not show error messages
This commit is contained in:
kusowl 2026-01-12 17:48:07 +05:30
parent 62651a8c0a
commit f43f92f365
16 changed files with 417 additions and 26 deletions

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreBrokerDeal;
use App\Models\Deal;
use App\Models\DealCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use const http\Client\Curl\AUTH_ANY;
class BrokerDealController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('dashboards.broker.deals.create')
->with('categories', DealCategory::all('id', 'name'));
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreBrokerDeal $request)
{
$data = $request->validated();
$data['slug'] = Str::slug($data['title']);
$data['user_id'] = $request->user()->id;
Deal::unguard();
Deal::create($data);
Deal::reguard();
return to_route('broker.dashboard')->with('success', 'Deal has been created.');
}
/**
* Display the specified resource.
*/
public function show(string $id)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
//
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBrokerDeal extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()->isBroker();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => 'required|min:10|max:255',
'description' => 'required|min:10|max:300',
'image' => 'required|image|mimes:jpeg,png,jpg|max:10240',
'link' => 'nullable|url',
'deal_category_id' => 'required|exists:deal_categories,id',
];
}
public function messages(): array
{
return [
'category_id.required' => 'The category field is required.',
'category_id.exists' => 'The category does not exist.',
];
}
}

10
app/Models/Deal.php Normal file
View File

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Deal extends Model
{
//
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DealCategory extends Model
{
//
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Enums\UserTypes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -47,4 +48,9 @@ protected function casts(): array
'password' => 'hashed',
];
}
public function isBroker(): bool
{
return $this->role === UserTypes::Broker->value;
}
}

View File

@ -0,0 +1,32 @@
<?php
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_categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->string('slug')->unique();
$table->boolean('active')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('deal_categories');
}
};

View File

@ -0,0 +1,37 @@
<?php
use App\Models\DealCategory;
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('deals', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug');
$table->text('description');
$table->string('image')->nullable();
$table->string('link')->nullable();
$table->boolean('active')->default(false);
$table->foreignIdFor(DealCategory::class);
$table->foreignIdFor(User::class);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('deals');
}
};

View File

@ -0,0 +1,40 @@
function upload(size) {
const imageInput = document.getElementById("image-input");
const closeModalBtn = document.getElementById("close-modal");
const cancelBtn = document.getElementById("cancel-modal");
const modal = document.getElementById("image-modal");
closeModalBtn.addEventListener('click', () => {
// this closes modal but does not remove the image from file input
modal.close();
})
cancelBtn.addEventListener('click', () => {
// clears the file from image input field and closes the modal
imageInput.value = "";
modal.close();
})
const image = imageInput.files[0];
if (!image || !image.type.includes("image")) {
alert("Please upload a valid image");
return;
}
if (image.size > size * 1000000) {
alert(`Max size of image is ${size} MB`);
return;
}
// Creating a FileReader class to convert image blob to base64
const fileReader = new FileReader();
fileReader.readAsDataURL(image);
fileReader.onload = (e) => {
const imagePlaceholder = document.getElementById("image-placeholder");
imagePlaceholder.src = e.target.result;
modal.showModal();
}
}
document.upload = upload;

View File

@ -14,7 +14,7 @@
<x-heroicon-o-user class="w-6"/>
<p>Profile</p>
</a>
<a href="" class="ui-btn ui-btn-neutral flex space-x-3 items-center">
<a href="{{route('broker.deals.create')}}" class="ui-btn ui-btn-neutral flex space-x-3 items-center">
<x-heroicon-o-plus class="w-6"/>
<p>Create Deal</p>
</a>

View File

@ -0,0 +1,17 @@
@props(['title' => '', 'description' => '', 'backLink' => ''])
<section class="flex space-x-6 items-center wrapper py-6 shadow">
@if($backLink !== '')
<div class="">
<a href="{{$backLink}}">
<x-heroicon-o-arrow-left class="w-4"/>
</a>
</div>
@endif
<div class="">
<h1 class="text-xl font-bold">{{$title}}</h1>
@if($description !== '')
<p class="text-sm text-accent-600">{{$description}}</p>
@endif
</div>
</section>

View File

@ -0,0 +1,44 @@
@props(['name' => '', 'label' => '', 'allowed' => '', 'size' => '1', 'required' => false])
<div class="flex flex-col space-y-2">
@if($label !== '')
<label class="text-sm font-bold" for="{{$name}}">
{{$label}}
@if($required)
*
@endif
</label>
@endif
<div class="relative">
<div
class="p-8 border-2 border-dashed border-accent-600/70 rounded-lg flex flex-col space-y-2 justify-center items-center">
<x-heroicon-o-arrow-up-tray class="w-8 text-accent-600/70"/>
<p class="text-sm text-accent-600/90 font-bold">Click to upload or drag and drop</p>
<p class="text-xs text-accent-600/70">{{strtoupper($allowed)}} upto {{$size}}MB</p>
</div>
<input
name="{{$name}}"
id="image-input"
class="opacity-0 absolute w-full h-full top-0 left-0"
type="file"
onchange="upload(10)"
accept="image/{{$allowed}}"
/>
<x-ui.inline-error :name="$name"/>
</div>
</div>
<dialog id="image-modal"
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>
<img src="" alt="" id="image-placeholder">
</div>
<div class="flex space-x-4 mt-6 justify-end">
<button type="button" autofocus id="cancel-modal" class="ui-btn">Close</button>
<button type="button" class="ui-btn ui-btn-neutral" id="close-modal">Submit</button>
</div>
</dialog>
@vite('resources/js/image-input.js')

View File

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

View File

@ -1,13 +1,28 @@
@props(['options' => [], 'name' => '', 'placeholder' => '', 'labelKey' => 'label', 'valueKey' => 'value', 'label' => ''])
@if($label !== '')
<label class="text-sm font-bold" for="{{$name}}">{{$label}}</label>
@endif
<select name="{{$name}}" class="bg-[#F3F3F5] py-2 px-4 rounded-lg text-sm font-bold">
@if($placeholder !== '')
<option>{{$placeholder}}</option>
@endif
@foreach($options as $option)
<option value="{{$option[$valueKey]}}"> {{$option[$labelKey]}} </option>
@endforeach
</select>
<x-ui.inline-error :name="$name" />
@props(['options' => [], 'name' => '', 'placeholder' => '', 'labelKey' => 'label', 'valueKey' => 'value', 'label' => '', 'required' => false])
<div class="flex flex-col space-y-2">
@if($label !== '')
<label class="text-sm font-bold" for="{{$name}}">
{{$label}}
@if($required)
*
@endif
</label>
@endif
<select
name="{{$name}}"
required="{{$required?'required':''}}"
class="bg-[#F3F3F5] py-2 px-4 rounded-lg text-sm font-bold invalid:text-accent-600 text-black"
>
@if($placeholder !== '')
<option {{old($name) === ''? 'selected' : ''}} disabled>{{$placeholder}}</option>
@endif
@foreach($options as $option)
<option value="{{$option[$valueKey]}}" {{$option[$valueKey] == old($name) ? 'selected' : ''}}> {{$option[$labelKey]}} </option>
@endforeach
</select>
<x-ui.inline-error :name="$name"/>
</div>

View File

@ -1,9 +1,21 @@
@props(['label' => '', 'name' => '', 'placeholder' => ''])
@props(['label' => '', 'name' => '', 'placeholder' => '', 'required' => false])
@if($label !== '')
<div class="flex flex-col space-y-2">
<label class="text-sm font-bold" for="{{$name}}">{{$label}}</label>
<textarea class="bg-[#F3F3F5] py-2 px-4 rounded-lg"
name="{{$name}}" placeholder="{{$placeholder}}"></textarea>
<label class="text-sm font-bold" for="{{$name}}">
{{$label}}
@if($required)
*
@endif
</label>
<textarea
class="bg-[#F3F3F5] py-2 px-4 rounded-lg"
name="{{$name}}" placeholder="{{$placeholder}}"
required="{{$required?'required':''}}"
>{{old($name)}}</textarea>
<x-ui.inline-error :name="$name"/>
</div>
@endif

View File

@ -0,0 +1,37 @@
<x-layout title="Create a new deal">
<x-dashboard.page-heading
title="Create New Deal"
description="Share a new opportunity with the community"
:back-link="route('broker.dashboard')"
/>
<div class="flex items-center justify-center mt-8">
<x-dashboard.card class="w-8/12">
<h3 class="text-md font-bold">Deal Information</h3>
<form method="post" enctype="multipart/form-data" action="{{route('broker.deals.store')}}" class="flex flex-col space-y-8 mt-4">
@csrf
<x-ui.input name="title" label="Deal Title" required placeholder="e.g., Luxury Apartment Downtown"/>
<x-ui.select :options="$categories" name="deal_category_id" label-key="name" value-key="id" label="Category"
placeholder="Select a category" required/>
<x-ui.textarea name="description" label="Description" required
placeholder="Describe your deal in detail..."/>
<x-ui.image-input name="image" label="Upload image" allowed="jpg,png" size="10"/>
<x-ui.input
name="link"
label="External Link (Optional)"
placeholder="https://example.com"
description="Add a link to your website, listing page or contact form"
/>
<div class="grid grid-cols-12 w-full space-x-4">
<x-ui.button variant="neutral" class="col-span-10">Submit</x-ui.button>
<a href="{{route('broker.dashboard')}}" class="ui-btn border border-accent-600/20 col-span-2">Cancel</a>
</div>
</form>
</x-dashboard.card>
</div>
</x-layout>

View File

@ -3,6 +3,7 @@
use App\Enums\UserTypes;
use App\Http\Controllers\AuthenticatedUserController;
use App\Http\Controllers\Broker\BrokerDashboardController;
use App\Http\Controllers\BrokerDealController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\RegisteredUserController;
use App\Http\Middleware\HasRole;
@ -30,7 +31,8 @@
->name('broker.')
->middleware(HasRole::class.':'.UserTypes::Broker->value)
->group(function () {
Route::get('dashboard', [BrokerDashboardController::class, 'index']);
Route::get('dashboard', [BrokerDashboardController::class, 'index'])->name('dashboard');
Route::resource('deals', BrokerDealController::class);
});
});