chat support

This commit is contained in:
Abhishek Mali
2025-12-15 11:03:30 +05:30
parent 0a65d5f596
commit 1aad6b231e
31 changed files with 4670 additions and 35 deletions

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Events;
use App\Models\ChatMessage;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Queue\SerializesModels;
class NewChatMessage implements ShouldBroadcastNow
{
use SerializesModels;
public $message;
/**
* Create a new event instance.
*/
public function __construct(ChatMessage $message)
{
// Also load sender polymorphic relationship
$message->load('sender');
$this->message = $message;
}
/**
* The channel the event should broadcast on.
*/
public function broadcastOn()
{
return [
new PrivateChannel('ticket.' . $this->message->ticket_id),
new PrivateChannel('admin.chat') // 👈 ADD THIS
];
}
/**
* Data sent to frontend (Blade + Flutter)
*/
public function broadcastWith()
{
\Log::info("DEBUG: NewChatMessage broadcasting on channel ticket.".$this->message->ticket_id);
\Log::info("EVENT BROADCAST FIRED", [
'channel' => 'ticket.'.$this->message->ticket_id,
'sender_type' => $this->message->sender_type,
'sender_id' => $this->message->sender_id,
]);
return [
'id' => $this->message->id,
'ticket_id' => $this->message->ticket_id,
'sender_id' => $this->message->sender_id,
'sender_type' => $this->message->sender_type,
'message' => $this->message->message,
'file_url' => $this->message->file_path
? asset('storage/' . $this->message->file_path)
: null,
'file_type' => $this->message->file_type,
'sender' => [
'id' => $this->message->sender->id,
'name' => $this->getSenderName(),
'is_admin' => $this->message->sender_type === \App\Models\Admin::class,
],
'created_at' => $this->message->created_at->toDateTimeString(),
];
}
/**
* Helper to extract sender name
*/
private function getSenderName()
{
$sender = $this->message->sender;
// User has customer_name (in your app)
if ($this->message->sender_type === \App\Models\User::class) {
return $sender->customer_name ?? $sender->name ?? "User";
}
// Admin model has ->name
return $sender->name ?? "Admin";
}
public function broadcastAs()
{
return 'NewChatMessage';
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\SupportTicket;
use App\Models\ChatMessage;
use App\Events\NewChatMessage;
class AdminChatController extends Controller
{
/**
* Page 1: List all active user chats
*/
public function index()
{
$tickets = SupportTicket::with('user')
->withCount([
'messages as unread_count' => function ($q) {
$q->where('sender_type', \App\Models\User::class)
->where('read_by_admin', false);
}
])
->orderBy('updated_at', 'desc')
->get();
return view('admin.chat_support', compact('tickets'));
}
/**
* Page 2: Open chat window for a specific user
*/
public function openChat($ticketId)
{
$ticket = SupportTicket::with('user')->findOrFail($ticketId);
// ✅ MARK USER MESSAGES AS READ FOR ADMIN
ChatMessage::where('ticket_id', $ticketId)
->where('sender_type', \App\Models\User::class)
->where('read_by_admin', false)
->update(['read_by_admin' => true]);
$messages = ChatMessage::where('ticket_id', $ticketId)
->orderBy('created_at', 'asc')
->with('sender')
->get();
return view('admin.chat_window', compact('ticket', 'messages'));
}
/**
* Admin sends a message to the user
*/
public function sendMessage(Request $request, $ticketId)
{
$request->validate([
'message' => 'nullable|string',
'file' => 'nullable|file|max:20480', // 20 MB
]);
$ticket = SupportTicket::findOrFail($ticketId);
$admin = auth('admin')->user();
$data = [
'ticket_id' => $ticketId,
'sender_id' => $admin->id,
'sender_type' => \App\Models\Admin::class,
'message' => $request->message,
'read_by_admin' => true,
'read_by_user' => false,
];
// File Upload
if ($request->hasFile('file')) {
$path = $request->file('file')->store('chat', 'public');
$data['file_path'] = $path;
$data['file_type'] = $request->file('file')->getMimeType();
}
// Save message
$message = ChatMessage::create($data);
$message->load('sender');
\Log::info("DEBUG: ChatController sendMessage called", [
'ticket_id' => $ticketId,
'payload' => $request->all()
]);
// Broadcast real-time
broadcast(new NewChatMessage($message));
\Log::info("DEBUG: ChatController sendMessage called 79", [
'ticket_id' => $ticketId,
'payload' => $request->all()
]);
return response()->json([
'success' => true,
'message' => $message
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\SupportTicket;
use App\Models\ChatMessage;
use App\Events\NewChatMessage;
class ChatController extends Controller
{
/**
* Start chat or return existing ticket for this user
*/
public function startChat()
{
// One chat ticket per user
$ticket = SupportTicket::firstOrCreate([
'user_id' => auth()->id(),
]);
return response()->json([
'success' => true,
'ticket' => $ticket
]);
}
/**
* Load all messages for this ticket
*/
public function getMessages($ticketId)
{
// Ensure this ticket belongs to the logged-in user
$ticket = SupportTicket::where('id', $ticketId)
->where('user_id', auth()->id())
->firstOrFail();
$messages = ChatMessage::where('ticket_id', $ticketId)
->orderBy('created_at', 'asc')
->with('sender')
->get();
return response()->json([
'success' => true,
'messages' => $messages
]);
}
/**
* Send text or file message from user admin/staff
*/
public function sendMessage(Request $request, $ticketId)
{
$request->validate([
'message' => 'nullable|string',
'file' => 'nullable|file|max:20480', // 20MB limit
]);
// Validate ticket ownership
$ticket = SupportTicket::where('id', $ticketId)
->where('user_id', auth()->id())
->firstOrFail();
$data = [
'ticket_id' => $ticketId,
'sender_id' => auth()->id(),
'sender_type' => \App\Models\User::class,
'message' => $request->message,
'read_by_admin' => false,
'read_by_user' => true,
];
// Handle file upload
if ($request->hasFile('file')) {
$path = $request->file('file')->store('chat', 'public');
$data['file_path'] = $path;
$data['file_type'] = $request->file('file')->getMimeType();
}
// Save message
$message = ChatMessage::create($data);
// Load sender info for broadcast
$message->load('sender');
// Fire real-time event
broadcast(new NewChatMessage($message));
return response()->json([
'success' => true,
'message' => $message
]);
}
}

View File

@@ -12,6 +12,7 @@ class JwtRefreshMiddleware
{ {
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
try { try {
JWTAuth::parseToken()->authenticate(); JWTAuth::parseToken()->authenticate();
} catch (TokenExpiredException $e) { } catch (TokenExpiredException $e) {

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class ChatMessage extends Model
{
use HasFactory;
protected $fillable = [
'ticket_id',
'sender_id',
'sender_type',
'message',
'file_path',
'file_type',
'read_by_admin',
'read_by_user',
];
/**
* The ticket this message belongs to.
*/
public function ticket()
{
return $this->belongsTo(SupportTicket::class, 'ticket_id');
}
/**
* Polymorphic sender (User or Admin)
*/
public function sender()
{
return $this->morphTo();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class SupportTicket extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'status',
];
/**
* The user (customer) who owns this ticket.
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* All chat messages for this ticket.
*/
public function messages()
{
return $this->hasMany(ChatMessage::class, 'ticket_id')->orderBy('created_at', 'asc');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class BroadcastServiceProvider extends ServiceProvider
{
public function boot(): void
{
Broadcast::routes([
'middleware' => ['web'],
]);
// 👇 FORCE admin guard for broadcasting
Auth::shouldUse('admin');
require base_path('routes/channels.php');
}
}

View File

@@ -6,13 +6,18 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: [
__DIR__.'/../routes/web.php',
__DIR__.'/../routes/channels.php',
],
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware) {
// //
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions) {
// //
})->create(); })
->create();

View File

@@ -4,4 +4,6 @@ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\RouteServiceProvider::class, App\Providers\RouteServiceProvider::class,
App\Providers\AuthServiceProvider::class, App\Providers\AuthServiceProvider::class,
App\Providers\BroadcastServiceProvider::class,
]; ];

View File

@@ -9,6 +9,7 @@
"php": "^8.2", "php": "^8.2",
"barryvdh/laravel-dompdf": "^3.1", "barryvdh/laravel-dompdf": "^3.1",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/reverb": "^1.6",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"maatwebsite/excel": "^1.1", "maatwebsite/excel": "^1.1",
"mpdf/mpdf": "^8.2", "mpdf/mpdf": "^8.2",

1007
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,8 @@ return [
'model' => App\Models\Staff::class, 'model' => App\Models\Staff::class,
], ],
], ],
/* /*

31
config/broadcasting.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
return [
'default' => env('BROADCAST_DRIVER', 'null'),
'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'),
'scheme' => env('REVERB_SCHEME'),
'useTLS' => false,
],
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@@ -89,7 +89,7 @@ return [
| |
*/ */
'ttl' => (int) env('JWT_TTL', 60), 'ttl' => (int) env('JWT_TTL', 86400),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -108,7 +108,7 @@ return [
| |
*/ */
'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 20160), 'refresh_ttl' => (int) env('JWT_REFRESH_TTL', 129600),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

96
config/reverb.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), // WebSocket listens here
'port' => env('REVERB_SERVER_PORT', 8080), // WebSocket port
'path' => env('REVERB_SERVER_PATH', ''),
// Used for Echo client hostname
'hostname' => env('REVERB_HOST', 'localhost'),
'options' => [
'tls' => [], // No TLS for localhost
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
/*
|--------------------------------------------------------------------------
| Echo + Flutter Client Options
|--------------------------------------------------------------------------
*/
'options' => [
'host' => env('REVERB_HOST', 'localhost'), // for client connections
'port' => env('REVERB_PORT', 8080), // SAME as WebSocket server port
'scheme' => env('REVERB_SCHEME', 'http'),
'useTLS' => false,
],
/*
|--------------------------------------------------------------------------
| Allowed Origins (Important)
|--------------------------------------------------------------------------
|
| "*" allows all origins:
| - Flutter (Android/iOS/Web)
| - Admin Panel
| - Localhost
|
*/
'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', 10000),
],
],
],
];

View File

@@ -13,16 +13,28 @@ return new class extends Migration
{ {
Schema::create('chat_messages', function (Blueprint $table) { Schema::create('chat_messages', function (Blueprint $table) {
$table->id(); $table->id();
$table->unsignedBigInteger('ticket_id'); // support ticket ID
$table->unsignedBigInteger('sender_id'); // user or admin/staff // Chat belongs to a ticket
$table->text('message')->nullable(); // message content $table->unsignedBigInteger('ticket_id');
$table->string('file_path')->nullable(); // image/pdf/video
$table->string('file_type')->default('text'); // text/image/pdf/video // POLYMORPHIC sender (User OR Admin)
$table->unsignedBigInteger('sender_id');
$table->string('sender_type');
// Example values:
// - "App\Models\User"
// - "App\Models\Admin"
// Content
$table->text('message')->nullable();
$table->string('file_path')->nullable(); // storage/app/public/chat/...
$table->string('file_type')->default('text'); // text / image / video / pdf
$table->timestamps(); $table->timestamps();
// foreign keys // FK to tickets table
$table->foreign('ticket_id')->references('id')->on('support_tickets')->onDelete('cascade'); $table->foreign('ticket_id')
$table->foreign('sender_id')->references('id')->on('users')->onDelete('cascade'); // admin also stored in users table? If admin separate, change later. ->references('id')->on('support_tickets')
->onDelete('cascade');
}); });
} }

View File

@@ -0,0 +1,30 @@
<?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::table('chat_messages', function (Blueprint $table) {
$table->boolean('read_by_admin')->default(false)->after('file_type');
$table->boolean('read_by_user')->default(false)->after('read_by_admin');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('chat_messages', function (Blueprint $table) {
//
});
}
};

2525
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,5 +13,9 @@
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^7.0.7" "vite": "^7.0.7"
},
"dependencies": {
"laravel-echo": "^2.2.6",
"pusher-js": "^8.4.0"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@@ -1 +1,6 @@
import './bootstrap'; import "./bootstrap";
// VERY IMPORTANT — Load Echo globally
import "./echo";
console.log("[APP] app.js loaded");

View File

@@ -1,4 +1,9 @@
import axios from 'axios'; import axios from 'axios';
window.axios = axios; window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; axios.defaults.withCredentials = true;
axios.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest";
axios.defaults.headers.common["X-CSRF-TOKEN"] = document.querySelector(
'meta[name="csrf-token"]'
).content;

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

@@ -0,0 +1,30 @@
import Echo from "laravel-echo";
import Pusher from "pusher-js";
window.Pusher = Pusher;
console.log("[ECHO] Initializing Reverb...");
window.Echo = new Echo({
broadcaster: "reverb",
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: Number(import.meta.env.VITE_REVERB_PORT),
forceTLS: false,
disableStats: true,
authEndpoint: "/broadcasting/auth",
auth: {
headers: {
"X-CSRF-TOKEN": document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content"),
"X-Requested-With": "XMLHttpRequest",
},
},
});
console.log("[ECHO] Loaded Successfully!", window.Echo);

View File

@@ -1,12 +1,141 @@
@extends('admin.layouts.app') @extends('admin.layouts.app')
@section('page-title', 'Dashboard') @section('page-title', 'Chat Support')
@section('content') @section('content')
<div class="container py-4">
<h2 class="mb-4 fw-bold">Customer Support Chat</h2>
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body p-0">
<h4>Welcome to the Admin chat</h4>
<p>Here you can manage all system modules.</p> @if($tickets->count() === 0)
<div class="p-4 text-center text-muted">
<h5>No customer chats yet.</h5>
</div>
@else
<ul class="list-group list-group-flush">
@foreach($tickets as $ticket)
@php
// Get last message
$lastMsg = $ticket->messages()->latest()->first();
@endphp
<li class="list-group-item py-3">
<div class="d-flex align-items-center justify-content-between">
<!-- LEFT -->
<div class="d-flex align-items-center gap-3">
<!-- Avatar -->
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center"
style="width: 45px; height: 45px; font-size: 18px;">
{{ strtoupper(substr($ticket->user->customer_name ?? $ticket->user->name, 0, 1)) }}
</div>
<div>
<!-- Name + unread badge -->
<h6 class="mb-1 fw-semibold">
{{ $ticket->user->customer_name ?? $ticket->user->name }}
<span
id="badge-{{ $ticket->id }}"
class="badge bg-danger ms-2 {{ $ticket->unread_count == 0 ? 'd-none' : '' }}">
{{ $ticket->unread_count }}
</span>
</h6>
<!-- Last message -->
<small class="text-muted">
@if($lastMsg)
@if($lastMsg->message)
{{ Str::limit($lastMsg->message, 35) }}
@elseif(Str::startsWith($lastMsg->file_type, 'image'))
📷 Image
@else
📎 Attachment
@endif
@else
<i>No messages yet</i>
@endif
</small>
</div>
</div>
<!-- RIGHT -->
<div class="text-end">
<span class="badge {{ $ticket->status === 'open' ? 'bg-success' : 'bg-danger' }}">
{{ ucfirst($ticket->status) }}
</span>
<a href="{{ route('admin.chat.open', $ticket->id) }}"
class="btn btn-sm btn-primary ms-2">
Open Chat
</a>
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div> </div>
</div> </div>
</div>
@section('scripts')
<script>
// -------------------------------
// WAIT FOR ECHO READY (DEFINE IT)
// -------------------------------
function waitForEcho(callback, retries = 40) {
if (window.Echo) {
console.log('%c[ECHO] Ready (Admin List)', 'color: green; font-weight: bold;');
callback();
return;
}
if (retries <= 0) {
console.error('[ECHO] Failed to initialize');
return;
}
setTimeout(() => waitForEcho(callback, retries - 1), 200);
}
// -------------------------------
// LISTEN FOR REALTIME MESSAGES
// -------------------------------
waitForEcho(() => {
console.log('[ADMIN LIST] Listening for new messages...');
window.Echo.private('admin.chat')
.listen('.NewChatMessage', (event) => {
// only USER → ADMIN messages
if (event.sender_type !== 'App\\Models\\User') return;
const badge = document.getElementById(`badge-${event.ticket_id}`);
if (!badge) return;
let count = parseInt(badge.innerText || 0);
badge.innerText = count + 1;
badge.classList.remove('d-none');
console.log('[ADMIN LIST] Badge updated for ticket', event.ticket_id);
});
});
</script>
@endsection
@endsection @endsection

View File

@@ -0,0 +1,217 @@
@extends('admin.layouts.app')
@section('page-title', 'Chat With ' . ($ticket->user->customer_name ?? $ticket->user->name))
@section('content')
<style>
.chat-box {
height: 70vh;
overflow-y: auto;
background: #f5f6fa;
border-radius: 8px;
padding: 15px;
}
.message {
max-width: 65%;
padding: 10px 14px;
border-radius: 15px;
margin-bottom: 10px;
font-size: 0.9rem;
line-height: 1.4;
}
.message.admin {
background: #007bff;
color: white;
margin-left: auto;
border-bottom-right-radius: 0;
}
.message.user {
background: #ffffff;
border: 1px solid #ddd;
margin-right: auto;
border-bottom-left-radius: 0;
}
.chat-input {
position: fixed;
bottom: 15px;
left: 250px;
right: 20px;
}
</style>
<div class="container py-4">
<div class="d-flex align-items-center mb-3">
<h4 class="fw-bold mb-0">
Chat With: {{ $ticket->user->customer_name ?? $ticket->user->name }}
</h4>
<span class="badge ms-3 {{ $ticket->status === 'open' ? 'bg-success' : 'bg-danger' }}">
{{ ucfirst($ticket->status) }}
</span>
</div>
<div id="chatBox" class="chat-box border shadow-sm">
@foreach($messages as $msg)
<div class="message {{ $msg->sender_type === 'App\\Models\\Admin' ? 'admin' : 'user' }}">
{{-- TEXT --}}
@if($msg->message)
<div>{{ $msg->message }}</div>
@endif
{{-- FILE --}}
@if($msg->file_path)
<div class="mt-2">
@php $isImage = Str::startsWith($msg->file_type, 'image'); @endphp
@if($isImage)
<img src="{{ asset('storage/'.$msg->file_path) }}" style="max-width:150px;" class="rounded">
@else
<a href="{{ asset('storage/'.$msg->file_path) }}" target="_blank">📎 View Attachment</a>
@endif
</div>
@endif
<small class="text-muted d-block mt-1">
{{ $msg->created_at->format('d M h:i A') }}
</small>
</div>
@endforeach
</div>
<div class="chat-input">
<div class="card shadow-sm">
<div class="card-body d-flex align-items-center gap-2">
<input type="text" id="messageInput" class="form-control" placeholder="Type your message...">
<input type="file" id="fileInput" class="form-control" style="max-width:200px;">
<button class="btn btn-primary" id="sendBtn">Send</button>
</div>
</div>
</div>
</div>
@endsection
@section('scripts')
<script>
// ✅ Make current admin ID available to JS
const CURRENT_ADMIN_ID = {{ auth('admin')->id() }};
console.log("CHAT WINDOW: script loaded");
// -------------------------------
// WAIT FOR ECHO READY
// -------------------------------
function waitForEcho(callback, retries = 40) {
if (window.Echo) {
console.log("%c[ECHO] Ready!", "color: green; font-weight: bold;", window.Echo);
return callback();
}
console.warn("[ECHO] Not ready. Retrying...");
if (retries <= 0) {
console.error("[ECHO] FAILED to initialize after retry limit");
return;
}
setTimeout(() => waitForEcho(callback, retries - 1), 200);
}
// Scroll chat down
function scrollToBottom() {
const el = document.getElementById("chatBox");
if (el) el.scrollTop = el.scrollHeight;
}
scrollToBottom();
// -------------------------------
// SEND MESSAGE (WORKING PART FROM SCRIPT #1)
// -------------------------------
document.getElementById("sendBtn").addEventListener("click", function () {
console.log("[SEND] Attempting to send message...");
let msg = document.getElementById("messageInput").value;
let file = document.getElementById("fileInput").files[0];
if (!msg.trim() && !file) {
alert("Please type something or upload a file.");
return;
}
let formData = new FormData();
formData.append("message", msg);
if (file) formData.append("file", file);
fetch("{{ route('admin.chat.send', $ticket->id) }}", {
method: "POST",
headers: { "X-CSRF-TOKEN": "{{ csrf_token() }}" },
body: formData
})
.then(res => res.json())
.then((response) => {
console.log("[SEND] Message sent:", response);
document.getElementById("messageInput").value = "";
document.getElementById("fileInput").value = "";
})
.catch(err => console.error("[SEND] Error:", err));
});
// -------------------------------
// LISTEN FOR REALTIME MESSAGE (WORKING PART FROM SCRIPT #2)
// ----------------------------
waitForEcho(() => {
const ticketId = "{{ $ticket->id }}";
console.log("[ECHO] Subscribing to PRIVATE channel:", `ticket.${ticketId}`);
window.Echo.private(`ticket.${ticketId}`)
.listen(".NewChatMessage", (event) => {
console.log("%c[REALTIME RECEIVED]", "color: blue; font-weight: bold;", event);
const msg = event; // ✅ flat payload
// ✅ CHECK IF THIS MESSAGE IS SENT BY CURRENT ADMIN
const isMine =
msg.sender_type === 'App\\Models\\Admin' &&
msg.sender_id === CURRENT_ADMIN_ID;
let html = `
<div class="message ${isMine ? 'admin' : 'user'}">
${msg.message ?? ''}
`;
if (msg.file_url) {
if (msg.file_type?.startsWith("image")) {
html += `<img src="${msg.file_url}" class="rounded mt-2" style="max-width:150px;">`;
} else {
html += `<a href="${msg.file_url}" target="_blank" class="mt-2 d-block">📎 View File</a>`;
}
}
html += `
<small class="text-muted d-block mt-1">Just now</small>
</div>
`;
document
.getElementById("chatBox")
.insertAdjacentHTML("beforeend", html);
scrollToBottom();
});
});
</script>
@endsection

View File

@@ -1,7 +1,9 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Admin Panel</title> <title>Admin Panel</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
@@ -220,6 +222,7 @@
font-weight: 500; font-weight: 500;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="sidebar"> <div class="sidebar">
@@ -346,6 +349,8 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
@vite(['resources/js/app.js'])
@yield('scripts') <!-- REQUIRED FOR CHAT TO WORK -->
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const headerToggle = document.getElementById('headerToggle'); const headerToggle = document.getElementById('headerToggle');

View File

@@ -1,12 +1,17 @@
<?php <?php
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\RequestController; use App\Http\Controllers\RequestController;
use App\Http\Controllers\UserAuthController; use App\Http\Controllers\UserAuthController;
use App\Http\Controllers\MarkListController; use App\Http\Controllers\MarkListController;
use App\Http\Controllers\User\UserOrderController; use App\Http\Controllers\User\UserOrderController;
use App\Http\Controllers\User\UserProfileController; use App\Http\Controllers\User\UserProfileController;
use App\Http\Controllers\User\ChatController;
//user send request //user send request
Route::post('/signup-request', [RequestController::class, 'usersignup']); Route::post('/signup-request', [RequestController::class, 'usersignup']);
@@ -14,8 +19,6 @@ Route::post('/signup-request', [RequestController::class, 'usersignup']);
//login / logout //login / logout
Route::post('/user/login', [UserAuthController::class, 'login']); Route::post('/user/login', [UserAuthController::class, 'login']);
Route::middleware(['auth:api'])->group(function () { Route::middleware(['auth:api'])->group(function () {
//Route::post('/user/refresh', [UserAuthController::class, 'refreshToken']); //Route::post('/user/refresh', [UserAuthController::class, 'refreshToken']);
@@ -46,4 +49,40 @@ Route::middleware(['auth:api'])->group(function () {
Route::post('/user/profile-update-request', [UserProfileController::class, 'updateProfileRequest']); Route::post('/user/profile-update-request', [UserProfileController::class, 'updateProfileRequest']);
// Route::post('/user/profile/update', [UserProfileController::class, 'updateProfile']); // Route::post('/user/profile/update', [UserProfileController::class, 'updateProfile']);
// ===========================
// CHAT SUPPORT ROUTES
// ===========================
Route::get('/user/chat/start', [ChatController::class, 'startChat']);
Route::get('/user/chat/messages/{ticketId}', [ChatController::class, 'getMessages']);
Route::post('/user/chat/send/{ticketId}', [ChatController::class, 'sendMessage']);
}); });
Route::post('/broadcasting/auth', function (Request $request) {
$user = auth('api')->user(); // JWT user (Flutter)
if (! $user) {
\Log::warning('BROADCAST AUTH FAILED - NO USER');
return response()->json(['message' => 'Unauthorized'], 401);
}
\Log::info('BROADCAST AUTH OK', [
'user_id' => $user->id,
'channel' => $request->channel_name,
]);
return Broadcast::auth(
$request->setUserResolver(fn () => $user)
);
});

78
routes/channels.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
use Illuminate\Support\Facades\Broadcast;
use App\Models\SupportTicket;
use App\Models\Admin;
use Illuminate\Support\Facades\Log;
file_put_contents(storage_path('logs/broadcast_debug.log'), now()." CHANNELS LOADED\n", FILE_APPEND);
Broadcast::routes([
'middleware' => ['web', 'auth:admin'],
]);
Broadcast::channel('ticket.{ticketId}', function ($user, $ticketId) {
try {
// Very explicit logging to see what arrives here
Log::info("CHANNEL AUTH CHECK (ENTER)", [
'user_present' => $user !== null,
'user_type' => is_object($user) ? get_class($user) : gettype($user),
'user_id' => $user->id ?? null,
'ticketId' => $ticketId,
]);
// Find ticket and log
$ticket = SupportTicket::find($ticketId);
Log::info("CHANNEL AUTH: found ticket", [
'ticket_exists' => $ticket ? true : false,
'ticket_id' => $ticket?->id,
'ticket_user_id' => $ticket?->user_id,
]);
if (! $ticket) {
Log::warning("CHANNEL AUTH: ticket not found", ['ticketId' => $ticketId]);
return false;
}
// If admin, allow
if ($user instanceof Admin) {
Log::info("CHANNEL AUTH: admin allowed", ['admin_id' => $user->id]);
return true;
}
// If normal user, check ownership
if (is_object($user) && isset($user->id)) {
$allowed = $ticket->user_id === $user->id;
Log::info("CHANNEL AUTH: user allowed check", [
'ticket_user_id' => $ticket->user_id,
'current_user_id' => $user->id,
'allowed' => $allowed
]);
return $allowed;
}
Log::warning("CHANNEL AUTH: default deny");
return false;
} catch (\Throwable $e) {
Log::error("CHANNEL AUTH ERROR", [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return false;
}
});
Broadcast::channel('admin.chat', function ($admin) {
return auth('admin')->check();
});
// Broadcast::channel('ticket.{ticketId}', function ($admin, $ticketId) {
// \Log::info('CHANNEL AUTH OK', [
// 'admin_id' => $admin->id,
// 'ticketId' => $ticketId,
// ]);
// return true;
// });

View File

@@ -11,6 +11,11 @@ use App\Http\Controllers\Admin\AdminCustomerController;
use App\Http\Controllers\Admin\AdminAccountController; use App\Http\Controllers\Admin\AdminAccountController;
use App\Http\Controllers\Admin\AdminReportController; use App\Http\Controllers\Admin\AdminReportController;
use App\Http\Controllers\Admin\AdminStaffController; use App\Http\Controllers\Admin\AdminStaffController;
use App\Http\Controllers\Admin\AdminChatController;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Http\Request;
// --------------------------- // ---------------------------
// Public Front Page // Public Front Page
@@ -23,18 +28,21 @@ Route::get('/', function () {
// ADMIN LOGIN ROUTES // ADMIN LOGIN ROUTES
// --------------------------- // ---------------------------
// login routes (public) // login routes (public)
Route::prefix('admin')->group(function () { Route::prefix('admin')->middleware('web')->group(function () {
Route::get('/login', [AdminAuthController::class, 'showLoginForm'])->name('admin.login'); Route::get('/login', [AdminAuthController::class, 'showLoginForm'])->name('admin.login');
Route::post('/login', [AdminAuthController::class, 'login'])->name('admin.login.submit'); Route::post('/login', [AdminAuthController::class, 'login'])->name('admin.login.submit');
Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout'); Route::post('/logout', [AdminAuthController::class, 'logout'])->name('admin.logout');
}); });
// ========================================== // ==========================================
// PROTECTED ADMIN ROUTES (session protected) // PROTECTED ADMIN ROUTES (session protected)
// ========================================== // ==========================================
Route::prefix('admin') Route::prefix('admin')
->middleware('auth:admin') ->middleware(['web', 'auth:admin'])
->group(function () { ->group(function () {
// Dashboard // Dashboard
@@ -199,8 +207,8 @@ Route::prefix('admin')
->name('admin.invoice.installment.delete'); ->name('admin.invoice.installment.delete');
//Add New Invoice // //Add New Invoice
Route::get('/admin/invoices/create', [InvoiceController::class, 'create'])->name('admin.invoices.create'); // Route::get('/admin/invoices/create', [InvoiceController::class, 'create'])->name('admin.invoices.create');
// --------------------------- // ---------------------------
@@ -220,13 +228,26 @@ Route::prefix('admin')
Route::post('/customers/{id}/status', [AdminCustomerController::class, 'toggleStatus']) Route::post('/customers/{id}/status', [AdminCustomerController::class, 'toggleStatus'])
->name('admin.customers.status'); ->name('admin.customers.status');
// Chat list page
Route::get('/chat-support', [AdminChatController::class, 'index'])
->name('admin.chat_support');
// Chat window (open specific user's chat)
Route::get('/chat-support/{ticketId}', [AdminChatController::class, 'openChat'])
->name('admin.chat.open');
// Admin sending message
Route::post('/chat-support/{ticketId}/send', [AdminChatController::class, 'sendMessage'])
->name('admin.chat.send');
}); });
// ========================================== // ==========================================
// ADMIN ACCOUNT (AJAX) ROUTES // ADMIN ACCOUNT (AJAX) ROUTES
// ========================================== // ==========================================
Route::prefix('admin/account') Route::prefix('admin/account')
->middleware('auth:admin') ->middleware(['web', 'auth:admin'])
->name('admin.account.') ->name('admin.account.')
->group(function () { ->group(function () {
@@ -285,7 +306,7 @@ Route::prefix('admin')
->name('admin.orders.download.excel'); ->name('admin.orders.download.excel');
Route::prefix('admin/account')->middleware('auth:admin')->name('admin.account.')->group(function () { Route::prefix('admin/account')->middleware(['web', 'auth:admin'])->name('admin.account.')->group(function () {
Route::post('/toggle-payment', [AdminAccountController::class, 'togglePayment'])->name('toggle'); Route::post('/toggle-payment', [AdminAccountController::class, 'togglePayment'])->name('toggle');
}); });
@@ -293,7 +314,7 @@ Route::prefix('admin')
//Edit Button Route //Edit Button Route
//--------------------------- //---------------------------
// protected admin routes // protected admin routes
Route::middleware(['auth:admin']) Route::middleware(['web', 'auth:admin'])
->prefix('admin') ->prefix('admin')
->name('admin.') ->name('admin.')
->group(function () { ->group(function () {

View File

@@ -5,7 +5,12 @@ import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.js'], input: [
"resources/css/app.css",
"resources/js/app.js",
"resources/js/echo.js",
],
refresh: true, refresh: true,
}), }),
tailwindcss(), tailwindcss(),