Compare commits

..

10 Commits

Author SHA1 Message Date
kusowl
b2132f8c02 refactor: chat time, grouping and toast
- show message time
- group messages by date
- show toast when message sent is failed
2026-02-18 11:38:03 +05:30
kusowl
44b13a5ebf fix: disply chats in sidebar, minor ui issues 2026-02-17 11:49:49 +05:30
kusowl
964d7a8936 feature: user can chat to each other 2026-02-16 12:30:57 +05:30
kusowl
12726c93b5 chore: formatting via pint 2026-02-16 12:30:07 +05:30
kusowl
82d61e757f fix: schema so that created_at uses current timestamp 2026-02-16 12:28:06 +05:30
kusowl
59b74a6905 wip: live chat
1.1 User can request for message from explore page
1.2User cannot message broker if not following
1.3 prepare action which handles creating inbox between user and broker if not exists
2026-02-13 12:03:23 +05:30
kusowl
a853b58f48 wip live chat
- add individual user chat view
- add routes
- wip: action to check and get inbox
2026-02-12 12:37:59 +05:30
kusowl
587893511c wip: live support chat
- add migration
- setup models
- generate phpdoc via helper
2026-02-11 12:27:58 +05:30
kusowl
976ea01c89 wip: add laravel reverb and echo
fix env
install the dependecies
2026-02-10 19:15:50 +05:30
kusowl
2f566cc4d8 feature(Live Support): add chat ui
- add chat ui for live support
- fix misc bugs
2026-02-09 19:07:16 +05:30
60 changed files with 2563 additions and 184 deletions

View File

@ -75,4 +75,16 @@ VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
# Same as the VAPID_PUBLIC_KEY # Same as the VAPID_PUBLIC_KEY
VITE_VAPID_PUBLIC_KEY=BOBjjU2E-h8pDCV13yPwvMDR_WZwEhFmQY90gr16oJ5L1mpJ5qc7-0WzXcD1Z9D0Ozz0cLZxTe0_7nnDK3VFMP4 VITE_VAPID_PUBLIC_KEY="${VAPID_PUBLIC_KEY}"
REVERB_APP_ID=
REVERB_APP_KEY=
REVERB_APP_SECRET=
REVERB_HOST=
REVERB_PORT=
REVERB_SCHEME=
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

View File

@ -79,11 +79,22 @@ ## ⚙️ Installation
```bash ```bash
git clone https://git.sentientgeeks.us/joydip.manna/dealhub.git git clone https://git.sentientgeeks.us/joydip.manna/dealhub.git
cd dealhub cd dealhub
# Install dependencies
composer install composer install
npm install npm install
# Set VAPID for webpush
php artisan webpush:vapid
# Install Reverb
php artisan install:broadcasting
npm run dev npm run dev
php artisan migrate php artisan migrate
php artisan serve php artisan serve
# Start Reverb Server ( Another terminal )
php artisan reverb:start
``` ```
--- ---

4
TODO.md Normal file
View File

@ -0,0 +1,4 @@
- [ ] Live Support.
- [ ] Add an Audit Log table to save all actions.
- [ ] Link foreign keys properly with related tables in the database.
- [ ] Add debouncing to the search feature and create proper database indexes to improve search performance.

View File

@ -0,0 +1,31 @@
<?php
namespace App\Actions;
use App\Models\Inbox;
use App\Models\User;
use DB;
final readonly class CreateOrGetInboxAction
{
/**
* @throws \Throwable
*/
public function execute(User $recipient, User $sender): Inbox
{
$existingInbox = Inbox::whereHas('users', fn ($q) => $q->where('users.id', $sender->id))
->whereHas('users', fn ($q) => $q->where('users.id', $recipient->id))
->first();
if ($existingInbox) {
return $existingInbox;
}
return DB::transaction(function () use ($sender, $recipient) {
$inbox = Inbox::create();
$inbox->users()->attach([$sender->id, $recipient->id]);
return $inbox;
}, 2);
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Actions;
use App\Events\MessageSent;
use App\Exceptions\MessageNotSendException;
use App\Models\User;
use DB;
final readonly class SendMessageAction
{
public function __construct(private CreateOrGetInboxAction $inboxAction) {}
/**
* @throws \Throwable
*/
public function execute(User $sender, User $recipient, array $data): void
{
try {
// find the inbox between the two users
DB::beginTransaction();
$inbox = $this->inboxAction->execute($recipient, $sender);
// update the inbox with the last message and last user as current user
$inbox->last_message = $data['message'];
$inbox->last_user_id = $sender->id;
$inbox->save();
// create a new message in the inbox
$message = $inbox->messages()->create([
'message' => $data['message'],
'user_id' => $sender->id,
]);
// Send the message to all other users in the inbox
broadcast(new MessageSent($message))->toOthers();
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw new MessageNotSendException('Message not sent.', previous: $e);
}
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Message $message)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
$users = $this->message->inbox->users->pluck('id')->toArray();
sort($users);
return [
new PrivateChannel("chat.{$users[0]}.{$users[1]}"),
];
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use Exception;
class MessageNotSendException extends Exception {}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers;
use App\Actions\CreateOrGetInboxAction;
use App\Actions\SendMessageAction;
use App\Exceptions\MessageNotSendException;
use App\Http\Requests\SendMessageRequest;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Log;
use Throwable;
class ChatController extends Controller
{
//
public function index(#[CurrentUser] User $user)
{
return view('dashboards.user.chat')
->with('inboxes', $user->inboxes);
}
public function show(#[CurrentUser] User $sender, User $recipient, CreateOrGetInboxAction $action)
{
try {
$inbox = $action->execute($recipient, $sender);
} catch (Throwable $e) {
Log::error('Inbox instantiation Failed: ', [$e->getMessage()]);
abort(500);
}
return view('dashboards.user.chat')
->with('recipient', $recipient)
->with('inboxes', $sender->inboxes)
->with('messages', $inbox->messages()->latest()->get());
}
/**
* @throws Throwable
*/
public function store(
#[CurrentUser] User $sender,
User $recipient,
SendMessageRequest $request,
SendMessageAction $action
) {
try {
$action->execute($sender, $recipient, $request->validated());
return response()->json(['message' => 'Message sent successfully.']);
} catch (MessageNotSendException $e) {
Log::error('Message send failed', [$e->getMessage()]);
return response()->json(['message' => 'Message sent failed.'], 500);
}
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserFollowedBroker
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$sender = \Auth::user();
if ($sender->isBroker()) {
return $next($request);
}
$recipientUser = $request->route('recipient');
if ($recipientUser->isBroker()) {
$recipient = $recipientUser->type;
$isFollowing = $recipient->followers->contains($sender->id);
if ($isFollowing) {
return $next($request);
}
abort(403, 'You are not following this broker.');
}
abort('404', 'Broker not found.');
}
}

View File

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

View File

@ -14,6 +14,9 @@
* @property bool $verified * @property bool $verified
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Follow|null $pivot
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Customer> $followers
* @property-read int|null $followers_count
* @property-read \App\Models\User|null $user * @property-read \App\Models\User|null $user
* *
* @method static \Illuminate\Database\Eloquent\Builder<static>|Broker newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder<static>|Broker newModelQuery()

View File

@ -5,6 +5,28 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property string $text
* @property int $deal_id
* @property int $user_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Deal|null $deal
* @property-read \App\Models\User|null $user
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment whereDealId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment whereText($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Comment whereUserId($value)
*
* @mixin \Eloquent
*/
class Comment extends Model class Comment extends Model
{ {
protected $fillable = [ protected $fillable = [

View File

@ -13,6 +13,8 @@
* @property string|null $phone * @property string|null $phone
* @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Follow> $followings
* @property-read int|null $followings_count
* @property-read \App\Models\User|null $user * @property-read \App\Models\User|null $user
* *
* @method static \Illuminate\Database\Eloquent\Builder<static>|Customer newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder<static>|Customer newModelQuery()

View File

@ -25,6 +25,8 @@
* @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\User|null $broker * @property-read \App\Models\User|null $broker
* @property-read \App\Models\DealCategory|null $category * @property-read \App\Models\DealCategory|null $category
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Comment> $comments
* @property-read int|null $comments_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Interaction> $interactions * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Interaction> $interactions
* @property-read int|null $interactions_count * @property-read int|null $interactions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Report> $reports * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Report> $reports
@ -50,6 +52,7 @@
* @method static Builder<static>|Deal whereTitle($value) * @method static Builder<static>|Deal whereTitle($value)
* @method static Builder<static>|Deal whereUpdatedAt($value) * @method static Builder<static>|Deal whereUpdatedAt($value)
* @method static Builder<static>|Deal whereUserId($value) * @method static Builder<static>|Deal whereUserId($value)
* @method static Builder<static>|Deal withIsFollowedByCurrentUser()
* @method static Builder<static>|Deal withViewPerDeal() * @method static Builder<static>|Deal withViewPerDeal()
* *
* @mixin \Eloquent * @mixin \Eloquent

View File

@ -5,6 +5,26 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* @property int $id
* @property int $customer_id
* @property int $broker_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Broker|null $broker
* @property-read \App\Models\Customer|null $customer
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Follow newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Follow newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Follow query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Follow whereBrokerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Follow whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Follow whereCustomerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Follow whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Follow whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class Follow extends Pivot class Follow extends Pivot
{ {
protected $table = 'follows'; protected $table = 'follows';

55
app/Models/Inbox.php Normal file
View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string|null $last_message
* @property int|null $last_user_id
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\User|null $lastUser
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\User> $users
* @property-read int|null $users_count
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Inbox newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Inbox newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Inbox query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Inbox whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Inbox whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Inbox whereLastMessage($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Inbox whereLastUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Inbox whereUpdatedAt($value)
*
* @mixin \Eloquent
*/
class Inbox extends Model
{
protected $fillable = ['last_user_id', 'last_message'];
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
}
public function lastUser(): BelongsTo
{
return $this->belongsTo(User::class, 'id', 'last_user_id');
}
public function messages(): HasMany
{
return $this->hasMany(Message::class, 'inbox_id');
}
public function getRecipientAttribute(): User
{
// first user in the relationship that is NOT the authenticated user
return $this->users->where('id', '!=', auth()->id())->first();
}
}

62
app/Models/Message.php Normal file
View File

@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Eloquent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property int $inbox_id
* @property string $message
* @property string|null $read_at
* @property string|null $delivered_at
* @property string|null $deleted_at
* @property string|null $failed_at
* @property Carbon $created_at
* @property-read Inbox|null $inbox
* @property-read User|null $user
*
* @method static Builder<static>|Message newModelQuery()
* @method static Builder<static>|Message newQuery()
* @method static Builder<static>|Message query()
* @method static Builder<static>|Message whereCreatedAt($value)
* @method static Builder<static>|Message whereDeletedAt($value)
* @method static Builder<static>|Message whereDeliveredAt($value)
* @method static Builder<static>|Message whereFailedAt($value)
* @method static Builder<static>|Message whereId($value)
* @method static Builder<static>|Message whereInboxId($value)
* @method static Builder<static>|Message whereMessage($value)
* @method static Builder<static>|Message whereReadAt($value)
* @method static Builder<static>|Message whereUserId($value)
*
* @mixin Eloquent
*/
class Message extends Model
{
public $timestamps = false;
protected $fillable = [
'inbox_id', 'user_id', 'message',
'read_at', 'deleted_at', 'failed_at',
'created_at', 'delivered_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function inbox(): BelongsTo
{
return $this->belongsTo(Inbox::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -6,11 +6,20 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
* @property UserTypes $user_type * @property int $id
* @property int|null $user_id
* @property string $page
* @property UserTypes|null $user_type
* @property string $created_at
* *
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit newQuery() * @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit query() * @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit wherePage($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit whereUserId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|PageVisit whereUserType($value)
* *
* @mixin \Eloquent * @mixin \Eloquent
*/ */

View File

@ -5,6 +5,7 @@
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@ -25,16 +26,24 @@
* @property string $role * @property string $role
* @property string|null $role_type * @property string|null $role_type
* @property int|null $role_id * @property int|null $role_id
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Comment> $comments
* @property-read int|null $comments_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Deal> $deals * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Deal> $deals
* @property-read int|null $deals_count * @property-read int|null $deals_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Interaction> $dealsInteractions * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Interaction> $dealsInteractions
* @property-read int|null $deals_interactions_count * @property-read int|null $deals_interactions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Inbox> $inboxes
* @property-read int|null $inboxes_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Deal> $interactedDeals * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Deal> $interactedDeals
* @property-read int|null $interacted_deals_count * @property-read int|null $interacted_deals_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, User> $interactions * @property-read \Illuminate\Database\Eloquent\Collection<int, User> $interactions
* @property-read int|null $interactions_count * @property-read int|null $interactions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Message> $messages
* @property-read int|null $messages_count
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications * @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
* @property-read int|null $notifications_count * @property-read int|null $notifications_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \NotificationChannels\WebPush\PushSubscription> $pushSubscriptions
* @property-read int|null $push_subscriptions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\RecentSearch> $recentSearches * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\RecentSearch> $recentSearches
* @property-read int|null $recent_searches_count * @property-read int|null $recent_searches_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Report> $reports * @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Report> $reports
@ -158,4 +167,14 @@ public function comments(): HasMany
{ {
return $this->hasMany(Comment::class); return $this->hasMany(Comment::class);
} }
public function inboxes(): BelongsToMany
{
return $this->belongsToMany(Inbox::class);
}
public function messages(): HasMany
{
return $this->hasMany(Message::class);
}
} }

View File

@ -8,6 +8,7 @@
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {

15
bootstrap/cache/packages.php vendored Normal file → Executable file
View File

@ -20,6 +20,13 @@
0 => 'BladeUI\\Icons\\BladeIconsServiceProvider', 0 => 'BladeUI\\Icons\\BladeIconsServiceProvider',
), ),
), ),
'laradumps/laradumps' =>
array (
'providers' =>
array (
0 => 'LaraDumps\\LaraDumps\\LaraDumpsServiceProvider',
),
),
'laravel-notification-channels/webpush' => 'laravel-notification-channels/webpush' =>
array ( array (
'providers' => 'providers' =>
@ -34,6 +41,14 @@
0 => 'Laravel\\Pail\\PailServiceProvider', 0 => 'Laravel\\Pail\\PailServiceProvider',
), ),
), ),
'laravel/reverb' =>
array (
'providers' =>
array (
0 => 'Laravel\\Reverb\\ApplicationManagerServiceProvider',
1 => 'Laravel\\Reverb\\ReverbServiceProvider',
),
),
'laravel/sail' => 'laravel/sail' =>
array ( array (
'providers' => 'providers' =>

46
bootstrap/cache/services.php vendored Normal file → Executable file
View File

@ -27,16 +27,19 @@
23 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider', 23 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
24 => 'BladeUI\\Heroicons\\BladeHeroiconsServiceProvider', 24 => 'BladeUI\\Heroicons\\BladeHeroiconsServiceProvider',
25 => 'BladeUI\\Icons\\BladeIconsServiceProvider', 25 => 'BladeUI\\Icons\\BladeIconsServiceProvider',
26 => 'NotificationChannels\\WebPush\\WebPushServiceProvider', 26 => 'LaraDumps\\LaraDumps\\LaraDumpsServiceProvider',
27 => 'Laravel\\Pail\\PailServiceProvider', 27 => 'NotificationChannels\\WebPush\\WebPushServiceProvider',
28 => 'Laravel\\Sail\\SailServiceProvider', 28 => 'Laravel\\Pail\\PailServiceProvider',
29 => 'Laravel\\Tinker\\TinkerServiceProvider', 29 => 'Laravel\\Reverb\\ApplicationManagerServiceProvider',
30 => 'Carbon\\Laravel\\ServiceProvider', 30 => 'Laravel\\Reverb\\ReverbServiceProvider',
31 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', 31 => 'Laravel\\Sail\\SailServiceProvider',
32 => 'Termwind\\Laravel\\TermwindServiceProvider', 32 => 'Laravel\\Tinker\\TinkerServiceProvider',
33 => 'Opcodes\\LogViewer\\LogViewerServiceProvider', 33 => 'Carbon\\Laravel\\ServiceProvider',
34 => 'Pest\\Laravel\\PestServiceProvider', 34 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
35 => 'App\\Providers\\AppServiceProvider', 35 => 'Termwind\\Laravel\\TermwindServiceProvider',
36 => 'Opcodes\\LogViewer\\LogViewerServiceProvider',
37 => 'Pest\\Laravel\\PestServiceProvider',
38 => 'App\\Providers\\AppServiceProvider',
), ),
'eager' => 'eager' =>
array ( array (
@ -52,14 +55,16 @@
9 => 'Illuminate\\View\\ViewServiceProvider', 9 => 'Illuminate\\View\\ViewServiceProvider',
10 => 'BladeUI\\Heroicons\\BladeHeroiconsServiceProvider', 10 => 'BladeUI\\Heroicons\\BladeHeroiconsServiceProvider',
11 => 'BladeUI\\Icons\\BladeIconsServiceProvider', 11 => 'BladeUI\\Icons\\BladeIconsServiceProvider',
12 => 'NotificationChannels\\WebPush\\WebPushServiceProvider', 12 => 'LaraDumps\\LaraDumps\\LaraDumpsServiceProvider',
13 => 'Laravel\\Pail\\PailServiceProvider', 13 => 'NotificationChannels\\WebPush\\WebPushServiceProvider',
14 => 'Carbon\\Laravel\\ServiceProvider', 14 => 'Laravel\\Pail\\PailServiceProvider',
15 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider', 15 => 'Laravel\\Reverb\\ReverbServiceProvider',
16 => 'Termwind\\Laravel\\TermwindServiceProvider', 16 => 'Carbon\\Laravel\\ServiceProvider',
17 => 'Opcodes\\LogViewer\\LogViewerServiceProvider', 17 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
18 => 'Pest\\Laravel\\PestServiceProvider', 18 => 'Termwind\\Laravel\\TermwindServiceProvider',
19 => 'App\\Providers\\AppServiceProvider', 19 => 'Opcodes\\LogViewer\\LogViewerServiceProvider',
20 => 'Pest\\Laravel\\PestServiceProvider',
21 => 'App\\Providers\\AppServiceProvider',
), ),
'deferred' => 'deferred' =>
array ( array (
@ -221,6 +226,8 @@
'Barryvdh\\LaravelIdeHelper\\Console\\ModelsCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider', 'Barryvdh\\LaravelIdeHelper\\Console\\ModelsCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
'Barryvdh\\LaravelIdeHelper\\Console\\MetaCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider', 'Barryvdh\\LaravelIdeHelper\\Console\\MetaCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
'Barryvdh\\LaravelIdeHelper\\Console\\EloquentCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider', 'Barryvdh\\LaravelIdeHelper\\Console\\EloquentCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
'Laravel\\Reverb\\ApplicationManager' => 'Laravel\\Reverb\\ApplicationManagerServiceProvider',
'Laravel\\Reverb\\Contracts\\ApplicationProvider' => 'Laravel\\Reverb\\ApplicationManagerServiceProvider',
'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider', 'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider',
'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider', 'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider',
'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider', 'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider',
@ -269,6 +276,9 @@
'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider' =>
array ( array (
), ),
'Laravel\\Reverb\\ApplicationManagerServiceProvider' =>
array (
),
'Laravel\\Sail\\SailServiceProvider' => 'Laravel\\Sail\\SailServiceProvider' =>
array ( array (
), ),

View File

@ -13,12 +13,14 @@
"blade-ui-kit/blade-heroicons": "^2.6", "blade-ui-kit/blade-heroicons": "^2.6",
"laravel-notification-channels/webpush": "^10.4", "laravel-notification-channels/webpush": "^10.4",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/reverb": "^1.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"twilio/sdk": "^8.10" "twilio/sdk": "^8.10"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-ide-helper": "^3.6", "barryvdh/laravel-ide-helper": "^3.6",
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"laradumps/laradumps": "^5.0",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",
"laravel/sail": "^1.41", "laravel/sail": "^1.41",

1296
composer.lock generated

File diff suppressed because it is too large Load Diff

82
config/broadcasting.php Normal file
View File

@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

95
config/reverb.php Normal file
View File

@ -0,0 +1,95 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
],
],
],
];

View File

@ -0,0 +1,32 @@
<?php
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('inboxes', function (Blueprint $table) {
$table->id()->index();
$table->text('last_message')->nullable();
$table->foreignIdFor(User::class, 'last_user_id')->nullable();
$table->timestamps();
$table->index(['last_user_id', 'created_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('inboxes');
}
};

View File

@ -0,0 +1,36 @@
<?php
use App\Models\Inbox;
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('messages', function (Blueprint $table) {
$table->id()->index();
$table->foreignIdFor(User::class)->index();
$table->foreignIdFor(Inbox::class)->index();
$table->text('message');
$table->time('read_at')->nullable();
$table->time('delivered_at')->nullable();
$table->time('deleted_at')->nullable();
$table->time('failed_at')->nullable();
$table->timestamp('created_at')->useCurrent()->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('messages');
}
};

View File

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

159
package-lock.json generated
View File

@ -11,7 +11,9 @@
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"pusher-js": "^8.4.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^7.0.7" "vite": "^7.0.7"
} }
@ -864,6 +866,14 @@
"win32" "win32"
] ]
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@ -1317,6 +1327,25 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1" "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
} }
}, },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -1359,6 +1388,32 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.4", "version": "5.18.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz",
@ -1696,6 +1751,20 @@
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
}, },
"node_modules/laravel-echo": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.0.tgz",
"integrity": "sha512-wgHPnnBvfHmu2I58xJ4asZH37Nu6P0472ku6zuoGRLc3zEWwIbpovDLYTiOshDH1SM7rA6AjZTKuu+jYoM1tpQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"pusher-js": "*",
"socket.io-client": "*"
}
},
"node_modules/laravel-vite-plugin": { "node_modules/laravel-vite-plugin": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
@ -2020,6 +2089,14 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@ -2095,6 +2172,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pusher-js": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tweetnacl": "^1.0.3"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -2173,6 +2260,38 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -2282,6 +2401,13 @@
"dev": true, "dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tweetnacl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
"dev": true,
"license": "Unlicense"
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
@ -2399,6 +2525,39 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"dev": true,
"peer": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -10,7 +10,9 @@
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"pusher-js": "^8.4.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^7.0.7" "vite": "^7.0.7"
}, },

View File

@ -15,6 +15,7 @@ import {deleteRecentSearch} from "./deleteRecentSearch.js";
import {initNavMenu} from "./nav-menu.js"; import {initNavMenu} from "./nav-menu.js";
import {toggleShimmer} from "./shimmer.js"; import {toggleShimmer} from "./shimmer.js";
import {follow} from "./interaction.js"; import {follow} from "./interaction.js";
import {addMessageToChat, sendMessage} from "./message.js";
document.deleteSearch = deleteRecentSearch; document.deleteSearch = deleteRecentSearch;
document.like = like; document.like = like;
@ -23,7 +24,8 @@ document.redirect = redirect;
document.showReportModal = showReportModal; document.showReportModal = showReportModal;
window.toggleShimmer = toggleShimmer; window.toggleShimmer = toggleShimmer;
window.follow = follow; window.follow = follow;
window.sendMessage = sendMessage;
window.addMessageToChat = addMessageToChat;
window.addEventListener('load', async () => { window.addEventListener('load', async () => {
const preloader = document.getElementById('preloader'); const preloader = document.getElementById('preloader');
const content = document.getElementById('content'); const content = document.getElementById('content');

View File

@ -9,3 +9,11 @@ window.Chart = Chart;
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['Accept'] = 'application/json';
window.axios.defaults.headers.common['Content-Type'] = 'application/json'; window.axios.defaults.headers.common['Content-Type'] = 'application/json';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allow your team to quickly build robust real-time web applications.
*/
import './echo';

View File

@ -97,7 +97,7 @@ function setDealDetails(dealDetails) {
async function setComments(dealId, dealModal) { async function setComments(dealId, dealModal) {
const commentsContainer = dealModal.querySelector('.comments-container'); const commentsContainer = dealModal.querySelector('.comments-container');
toggleShimmer(false, commentsContainer); toggleShimmer(false, commentsContainer);
commentsContainer.innerHTML = await getComments(dealId); commentsContainer.outerHTML = await getComments(dealId);
toggleShimmer(true, commentsContainer); toggleShimmer(true, commentsContainer);
} }

14
resources/js/echo.js Normal file
View File

@ -0,0 +1,14 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});

51
resources/js/message.js Normal file
View File

@ -0,0 +1,51 @@
import {showToast} from "./toast.js";
export const sendMessage = async (element, event) => {
event.preventDefault();
const messageInput = element.querySelector('[name="message"]');
const message = messageInput.value;
if (!message) return;
const recipentId = element.dataset.recipientId;
messageInput.value = '';
// Capture the ID of the UI element we just added
const tempMessageId = addMessageToChat({message: message}, true);
try {
await axios.post(`/api/chat/${recipentId}/message`, {message: message});
} catch (e) {
console.error(e);
showToast('Message could not be sent.');
const failedMessage = document.getElementById(tempMessageId);
if (failedMessage) {
failedMessage.remove();
}
messageInput.value = message;
}
}
export const addMessageToChat = (message, right = false) => {
const chatContainer = document.getElementById('chat-container');
if (!chatContainer) return;
const tempId = 'msg-' + Date.now();
const placeholder = chatContainer.querySelector('#no-messages-placeholder');
if (placeholder) placeholder.remove();
const messagePlaceholder = `
<div id="${tempId}" class="grid px-4 my-1 w-full ${right ? 'place-items-end' : 'place-items-start'}">
<div class="max-w-[40vw] py-2 px-4 rounded-full ${right ? 'rounded-br-none' : 'rounded-tl-none'} bg-gray-200">
${message.message}
</div>
</div>`;
chatContainer.insertAdjacentHTML('afterbegin', messagePlaceholder);
chatContainer.scrollTop = 0;
// Return the ID so the caller can find this specific message later
return tempId;
}

View File

@ -0,0 +1,17 @@
<x-layout>
<div class="flex">
<x-dashboard.broker.sidebar.layout>
<div class="">
<div class="flex space-x-4 items-center border-b border-b-gray-300 pb-6 mb-4">
<x-logo/>
<p class="font-bold text-2xl whitespace-nowrap group-[.w-20]:hidden">Messages</p>
</div>
{{$sidebarItems ?? ''}}
</div>
</x-dashboard.broker.sidebar.layout>
<section
class="flex relative flex-col space-y-4 md:space-y-8 bg-[#F9FAFB] overflow-y-auto overflow-x-hidden h-screen w-full">
{{$slot}}
</section>
</div>
</x-layout>

View File

@ -0,0 +1,58 @@
@props(['recipient', 'messages' => []])
@php
$groupedMessages = $messages->groupBy(function($msg) {
return $msg->created_at->format('M j, Y');
});
@endphp
<x-dashboard.page-heading class="m-0 mb-0.5" :title="$recipient->name" description="offline"/>
<div class="bg-gray-50 h-full overflow-hidden flex-shrink-0">
<div id="chat-container" data-auth-id="{{ auth()->id() }}" data-partner-id="{{ $recipient->id }}"
class="text-sm flex flex-col-reverse overflow-y-scroll h-full max-h-screen pb-50 scroll-snap-y-container">
@forelse($groupedMessages as $date => $dayMessages)
@foreach($dayMessages as $message)
<x-chat.message :right="$message->user_id === auth()->id()">
<p>{{ $message->message }}</p>
<span class="text-[10px] opacity-70 block text-right mt-1">
{{ $message->created_at->format('g:i A') }}
</span>
</x-chat.message>
@endforeach
{{-- The Date Divider --}}
<div class="flex justify-center my-4 top-0">
<span class="bg-gray-200 text-gray-600 text-xs px-3 py-1 rounded-full shadow-sm">
{{ $date }}
</span>
</div>
@empty
<div id="no-messages-placeholder">No Messages Found !</div>
@endforelse
</div>
<x-chat.message-input :recipient_id="$recipient->id"/>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('chat-container');
if (container) {
const authId = parseInt(container.dataset.authId);
const partnerId = parseInt(container.dataset.partnerId);
// Sort IDs for consistent channel naming
const user1 = Math.min(authId, partnerId);
const user2 = Math.max(authId, partnerId);
window.Echo.private(`chat.${user1}.${user2}`)
.listen('MessageSent', (e) => {
const message = e.message;
if (!message) return;
// Check if user is the recipient of the message
if (message.user_id === partnerId) {
addMessageToChat({message: e.message.message}, false);
}
});
}
});
</script>

View File

@ -0,0 +1,10 @@
@props(['recipient_id'])
<div class="absolute bottom-5 w-8/12 rounded-xl left-50 p-2 bg-white border border-gray-300 shadow-xl">
<form id="messageBox" data-recipient-id="{{$recipient_id}}" onsubmit="sendMessage(this, event)" action=""
class="flex space-x-4 items-center">
<x-ui.textarea class="flex-1" rows="1" name="message" placeholder="Enter your message..."></x-ui.textarea>
<x-ui.button variant="neutral" icon="">
<x-heroicon-o-paper-airplane class="w-5 h-5"/>
</x-ui.button>
</form>
</div>

View File

@ -0,0 +1,11 @@
@props(['right' => false])
<div class="grid px-4 my-1 w-full @if($right) place-items-end @else place-items-start @endif">
<div class="max-w-[70vw] md:max-w-[40vw] py-2 px-4 bg-gray-200 rounded-3xl
@if($right)
rounded-br-none
@else
rounded-tl-none
@endif">
{{$slot}}
</div>
</div>

View File

@ -0,0 +1,8 @@
@props(['avatar', 'name', 'status' => 'inactive', 'message' => '', 'link' => ''])
<x-dashboard.broker.sidebar.item :link="$link" class="">
<x-ui.avatar class="w-12! h-12! text-xl!">{{$avatar}}</x-ui.avatar>
<div class="flex flex-col text-left max-w-24">
<p class="sidebar-text text-sm transition-opacity duration-300 ease-in-out font-bold">{{$name}}</p>
<p class="sidebar-text text-sm text-accent-600 transition-opacity duration-300 ease-in-out truncate">{{$message}}</p>
</div>
</x-dashboard.broker.sidebar.item>

View File

@ -6,7 +6,7 @@
<div id="sidebar" <div id="sidebar"
class="flex flex-col p-4 pt-6 justify-between font-medium h-full w-full transition-all duration-300 ease-in-out"> class="flex flex-col p-4 pt-6 justify-between font-medium h-full w-full transition-all duration-300 ease-in-out">
<div> <div>
<div class="flex space-x-4 border-b border-b-gray-300 pb-6 n"> <div class="flex space-x-4 border-b border-b-gray-300 pb-6 ">
<x-logo/> <x-logo/>
<a href="{{route('home')}}" class="whitespace-nowrap group-[.w-20]:hidden"> <a href="{{route('home')}}" class="whitespace-nowrap group-[.w-20]:hidden">
<p class="text-2xl font-bold">DealHub</p> <p class="text-2xl font-bold">DealHub</p>
@ -64,7 +64,7 @@ class="py-3 pl-3 border border-white hover:bg-red-50 hover:border-red-200 rounde
{{-- Toggle Button --}} {{-- Toggle Button --}}
<div <div
class="text-gray-500 cursor-pointer hover:text-gray-900 rounded-full p-1.5 bg-white border border-gray-300 absolute -right-3.5 top-21"> class="text-gray-500 cursor-pointer hover:text-gray-900 rounded-full p-1.5 bg-white border border-gray-300 absolute -right-3.5 top-21 z-20">
<x-heroicon-c-chevron-left id="closeSidebarBtn" class="w-4"/> <x-heroicon-c-chevron-left id="closeSidebarBtn" class="w-4"/>
<x-heroicon-c-chevron-right id="openSidebarBtn" class="w-4 hidden"/> <x-heroicon-c-chevron-right id="openSidebarBtn" class="w-4 hidden"/>
</div> </div>

View File

@ -1,8 +1,4 @@
@props(['activeClass' => 'bg-gray-100 text-gray-900']) <x-dashboard.broker.sidebar.layout>
<div id="sidebarWrapper" {{$attributes->merge([ 'class' => 'border-r border-r-gray-300 transition-all duration-300 ease-in-out w-64 relative'])}}>
<div class="hidden md:flex h-screen items-center">
<div id="sidebar" class="flex flex-col p-4 pt-6 justify-between font-medium h-full w-full overflow-hidden transition-all duration-300 ease-in-out">
<div class=""> <div class="">
<div class="flex space-x-4 border-b border-b-gray-300 pb-6"> <div class="flex space-x-4 border-b border-b-gray-300 pb-6">
<x-logo/> <x-logo/>
@ -39,7 +35,8 @@
<form method="post" action="{{route('logout')}}"> <form method="post" action="{{route('logout')}}">
@csrf @csrf
@method('delete') @method('delete')
<button class="py-3 pl-3 border border-white hover:bg-red-50 hover:border-red-200 rounded-xl w-full mt-4 transition-all"> <button
class="py-3 pl-3 border border-white hover:bg-red-50 hover:border-red-200 rounded-xl w-full mt-4 transition-all">
<div class="flex space-x-3 items-center text-red-500"> <div class="flex space-x-3 items-center text-red-500">
<x-heroicon-o-arrow-right-start-on-rectangle class="w-5 min-w-5"/> <x-heroicon-o-arrow-right-start-on-rectangle class="w-5 min-w-5"/>
<p class="sidebar-text transition-opacity duration-300 ease-in-out">Logout</p> <p class="sidebar-text transition-opacity duration-300 ease-in-out">Logout</p>
@ -47,15 +44,4 @@
</button> </button>
</form> </form>
</div> </div>
</div> </x-dashboard.broker.sidebar.layout>
{{-- Toggle Button --}}
<div class="text-gray-500 cursor-pointer hover:text-gray-900 rounded-full p-1.5 bg-white border border-gray-300 absolute -right-3.5 top-21">
<x-heroicon-c-chevron-left id="closeSidebarBtn" class="w-4"/>
<x-heroicon-c-chevron-right id="openSidebarBtn" class="w-4 hidden"/>
</div>
</div>
</div>
@vite(['resources/js/sidebar.js'])

View File

@ -6,9 +6,15 @@
@endphp @endphp
@aware(['activeClass' => 'bg-gray-100 text-gray-900']) @aware(['activeClass' => 'bg-gray-100 text-gray-900'])
<div class="relative group/item w-full hover:z-10"> <div class="relative group/item w-full hover:z-10">
@if(filled($link))
<a href="{{$link}}" {{$attributes->class(["flex space-x-3 items-center pl-3 py-3 rounded-xl hover:bg-gray-100 border border-transparent ease-in-out transition-all duration-300 active:scale-80 hover:border-gray-300", $activeClass => $active])}} > <a href="{{$link}}" {{$attributes->class(["flex space-x-3 items-center pl-3 py-3 rounded-xl hover:bg-gray-100 border border-transparent ease-in-out transition-all duration-300 active:scale-80 hover:border-gray-300", $activeClass => $active])}} >
{{$slot}} {{$slot}}
</a> </a>
@else
<button {{$attributes->class(["flex space-x-3 items-center pl-3 py-3 rounded-xl hover:bg-gray-100 border border-transparent ease-in-out transition-all duration-300 active:scale-80 hover:border-gray-300", $activeClass => $active])}} >
{{$slot}}
</button>
@endif
@if($tooltip !== '') @if($tooltip !== '')
<span <span
class="absolute z-10 top-[30%] hidden group-[.w-20]:group-hover/item:block left-[110%] py-1 px-2 rounded-lg bg-gray-900 text-xs whitespace-nowrap text-white"> class="absolute z-10 top-[30%] hidden group-[.w-20]:group-hover/item:block left-[110%] py-1 px-2 rounded-lg bg-gray-900 text-xs whitespace-nowrap text-white">

View File

@ -0,0 +1,19 @@
@props(['activeClass' => 'bg-gray-100 text-gray-900'])
<div id="sidebarWrapper" {{$attributes->merge([ 'class' => 'border-r border-r-gray-300 transition-all group duration-300 ease-in-out w-64 relative z-10'])}}>
<div class="hidden md:flex w-max h-screen items-center">
<div id="sidebar" class="flex flex-col p-4 pt-6 justify-between font-medium h-full w-full overflow-hidden transition-all duration-300 ease-in-out">
{{$slot}}
</div>
{{-- Toggle Button --}}
<div class="text-gray-500 cursor-pointer hover:text-gray-900 rounded-full p-1.5 bg-white border border-gray-300 absolute -right-3.5 top-21 z-30">
<x-heroicon-c-chevron-left id="closeSidebarBtn" class="w-4"/>
<x-heroicon-c-chevron-right id="openSidebarBtn" class="w-4 hidden"/>
</div>
</div>
</div>
@push('scripts')
@vite(['resources/js/sidebar.js'])
@endpush

View File

@ -1,5 +1,5 @@
@props(['title' => '', 'description' => '', 'backLink' => '']) @props(['title' => '', 'description' => '', 'backLink' => ''])
<section class="flex space-x-6 items-center justify-between wrapper py-6 shadow w-full"> <section {{$attributes->merge(['class' => 'flex space-x-6 items-center justify-between wrapper py-6 shadow w-full'])}}>
<div class="flex space-x-6 items-center"> <div class="flex space-x-6 items-center">
@if($backLink !== '') @if($backLink !== '')
<div class=""> <div class="">

View File

@ -1,11 +1,15 @@
@props(['broker' => '', 'is_followed' => false]) @props(['broker', 'is_followed' => false])
<div {{$attributes->merge(['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"])}}>
<div class="flex space-x-4 items-baseline"> <div class="flex space-x-2 items-center mb-2">
<p class="font-bold mb-2">Broker Contact</p> <p class="font-bold">Broker Contact</p>
<x-ui.button-sm data-is-loading="false" data-followed="{{$is_followed ? 'true' : 'false'}}" onclick="follow(this, {{$broker->role_id ?? ''}})" class="followBtn group p-0! mt-0.5"> <x-ui.button-sm data-is-loading="false" data-followed="{{$is_followed ? 'true' : 'false'}}"
onclick="follow(this, {{$broker->role_id ?? 0}})" class="followBtn group p-0! mt-0.5">
<span class="group-data-[followed=true]:hidden text-blue-600">Follow</span> <span class="group-data-[followed=true]:hidden text-blue-600">Follow</span>
<span class="group-data-[followed=false]:hidden text-accent-600">Unfollow</span> <span class="group-data-[followed=false]:hidden text-accent-600">Unfollow</span>
</x-ui.button-sm> </x-ui.button-sm>
<x-ui.button-sm :link="route('chat.show', ['recipient' => $broker->id ?? 0])">
<x-heroicon-o-chat-bubble-oval-left class="w-4 text-accent-600"/>
</x-ui.button-sm>
</div> </div>
<div class="text-accent-600 space-y-1"> <div class="text-accent-600 space-y-1">
<p data-is-loading="false" class="broker-name">{{$broker->name ?? ''}}</p> <p data-is-loading="false" class="broker-name">{{$broker->name ?? ''}}</p>

View File

@ -1,5 +1,5 @@
@props(['comments' => []]) @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"> <div data-is-loading="true" class="comments-container mt-2 space-y-2 max-h-40 overflow-y-auto data-[is-loading=true]:h-10">
@forelse($comments as $comment) @forelse($comments as $comment)
<x-dashboard.user.deal-comment.item :comment="$comment"/> <x-dashboard.user.deal-comment.item :comment="$comment"/>
@empty @empty

View File

@ -1,4 +1,4 @@
<x-ui.modal id="deal-modal" class="deal-identifier w-11/12 md:w-10/12 overflow-scroll"> <x-ui.modal id="deal-modal" class="deal-identifier w-11/12 md:w-10/12 overflow-y-auto">
<form class="flex justify-between items-start mb-4" method="dialog"> <form class="flex justify-between items-start mb-4" method="dialog">
<p class="text-xl font-bold">Deal Details</p> <p class="text-xl font-bold">Deal Details</p>
<button type="submit" class=""> <button type="submit" class="">

View File

@ -1,4 +1,4 @@
@props(['deal' => '', 'broker' => '']) @props(['deal'])
<x-ui.image-card class="deal-identifier deal-card shadow-lg cursor-pointer" :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}}"> 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="bg-white pt-8 p-4 h-full space-y-2 flex flex-col justify-between">
@ -24,7 +24,7 @@
</a> </a>
@endguest @endguest
@auth @auth
<x-dashboard.user.broker-contact :broker="$broker" :is_followed="$deal->is_followed"/> <x-dashboard.user.broker-contact :broker="$deal->broker" :is_followed="$deal->is_followed"/>
@endauth @endauth
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">

View File

@ -1,7 +1,7 @@
@props(['deals' => [], 'isInteractive']) @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" />
@empty @empty
<p class="col-span-2 text-sm text-center text-accent-600 mt-12">No Deals found !</p> <p class="col-span-2 text-sm text-center text-accent-600 mt-12">No Deals found !</p>
@endforelse @endforelse

View File

@ -1,3 +1,4 @@
@php use App\Enums\UserTypes; @endphp
@props(['profileLink' => '']) @props(['profileLink' => ''])
<div class="flex items-center"> <div class="flex items-center">
@auth @auth
@ -13,7 +14,7 @@
</a> </a>
</li> </li>
@if(auth()->check() && auth()->user()->role === \App\Enums\UserTypes::Broker->value) @if(auth()->check() && auth()->user()->role === UserTypes::Broker->value)
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold"> <li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{route('broker.dashboard')}}" class="flex space-x-4"> <a href="{{route('broker.dashboard')}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900"> <div class="p-1 bg-gray-200 rounded-xl text-gray-900">
@ -24,6 +25,14 @@
</li> </li>
@endif @endif
<li class="py-2 px-4 hover:bg-gray-100 hover:text-gray-900 hover:cursor-pointer hover:font-bold">
<a href="{{route('chat')}}" class="flex space-x-4">
<div class="p-1 bg-gray-200 rounded-xl text-gray-900">
<x-heroicon-o-chat-bubble-left-right class="w-4"/>
</div>
<p>Chat</p>
</a>
</li>
</ul> </ul>
</div> </div>
@endauth @endauth

View File

@ -0,0 +1,4 @@
<section
{{$attributes->merge(['class' => 'w-25 h-25 rounded-xl bg-linear-150 from-[#305afc] to-[#941dfb] text-5xl text-white flex justify-center items-center'])}}>
{{$slot}}
</section>

View File

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

View File

@ -23,10 +23,7 @@ class="border border-accent-600/40">
<div <div
class="col-span-8 place-self-start md:col-span-2 lg:col-span-1 flex items-center justify-center w-full"> class="col-span-8 place-self-start md:col-span-2 lg:col-span-1 flex items-center justify-center w-full">
<div <x-ui.avatar>{{$initials}}</x-ui.avatar>
class="w-25 h-25 rounded-xl bg-linear-150 from-[#305afc] to-[#941dfb] text-5xl text-white flex justify-center items-center">
{{$initials}}
</div>
</div> </div>
<div class="col-span-8 md:col-span-6 lg:col-span-7 flex flex-col space-y-6"> <div class="col-span-8 md:col-span-6 lg:col-span-7 flex flex-col space-y-6">

View File

@ -0,0 +1,25 @@
@php use App\Services\ProfileInitialsService; @endphp
<x-chat.layout>
<x-slot:sidebarItems>
@forelse($inboxes as $inbox)
<x-chat.sidebar-item
:name="$inbox->recipient->name"
:link="route('chat.show', $inbox->recipient->id)"
:avatar="(new ProfileInitialsService)->create($inbox->recipient->name)"
:message="$inbox->last_message"/>
@empty
No chats found !
@endforelse
</x-slot:sidebarItems>
<div class="overflow-y-hidden">
@if(isset($recipient))
<x-chat.message-box :recipient="$recipient" :messages="$messages"/>
@else
<div class="w-full h-full flex items-center justify-center">
<p class="font-bold text-5xl text-gray-400">Start a chat ! </p>
</div>
@endif
</div>
<x-ui.toast/>
</x-chat.layout>

View File

@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\ChatController;
use App\Http\Controllers\RecentSearchController; use App\Http\Controllers\RecentSearchController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -12,4 +13,6 @@
include __DIR__.'/push-notification.php'; include __DIR__.'/push-notification.php';
Route::delete('/recent-search/{recentSearch}', RecentSearchController::class)->name('recent-search.destroy'); Route::delete('/recent-search/{recentSearch}', RecentSearchController::class)->name('recent-search.destroy');
Route::post('/chat/{recipient}/message', [ChatController::class, 'store'])->name('chat.message');
}); });

13
routes/channels.php Normal file
View File

@ -0,0 +1,13 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Broadcast::channel('chat.{user1}.{user2}', function (User $user, int $user1, int $user2) {
// Only allow the user if their ID matches one of the two in the channel name
return $user->id === $user1 || $user->id === $user2;
});

View File

@ -10,26 +10,14 @@
require __DIR__.'/web/interaction.php'; require __DIR__.'/web/interaction.php';
require __DIR__.'/web/customer.php'; require __DIR__.'/web/customer.php';
require __DIR__.'/web/admin.php'; require __DIR__.'/web/admin.php';
require __DIR__.'/web/chat.php';
Route::get('/', HomeController::class)->name('home'); Route::get('/', HomeController::class)->name('home');
Route::get('/explore', ExplorePageController::class)->name('explore'); Route::get('/explore', ExplorePageController::class)->name('explore');
Route::post('/contact', ContactController::class)->name('contact'); Route::post('/contact', ContactController::class)->name('contact');
Route::get('/test-openssl', function () {
$res = openssl_pkey_new([
'curve_name' => 'prime256v1',
'private_key_type' => OPENSSL_KEYTYPE_EC,
]);
if ($res === false) {
return 'ERROR: '.openssl_error_string(); // likely "error:02001003:system library:fopen:No such process"
}
return 'SUCCESS: OpenSSL is configured correctly.';
});
/** /**
* This routes are accessed by JS XHR requests, and is loaded here cause * This route is accessed by JS XHR requests, and is loaded here cause
* we do not want to use sanctum for web requests * we do not want to use sanctum for web requests
*/ */
// ------------- API Routes ------------ // ------------- API Routes ------------

12
routes/web/chat.php Normal file
View File

@ -0,0 +1,12 @@
<?php
use App\Http\Controllers\ChatController;
use App\Http\Middleware\EnsureUserFollowedBroker;
Route::middleware('auth')->group(function () {
Route::get('/chat', [ChatController::class, 'index'])->name('chat');
Route::get('/chat/{recipient}', [ChatController::class, 'show'])
->middleware(EnsureUserFollowedBroker::class)
->name('chat.show');
});