chat support
This commit is contained in:
93
app/Events/NewChatMessage.php
Normal file
93
app/Events/NewChatMessage.php
Normal 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
105
app/Http/Controllers/Admin/AdminChatController.php
Normal file
105
app/Http/Controllers/Admin/AdminChatController.php
Normal 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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/Http/Controllers/user/ChatController.php
Normal file
96
app/Http/Controllers/user/ChatController.php
Normal 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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
39
app/Models/ChatMessage.php
Normal file
39
app/Models/ChatMessage.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Models/SupportTicket.php
Normal file
32
app/Models/SupportTicket.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Providers/BroadcastServiceProvider.php
Normal file
22
app/Providers/BroadcastServiceProvider.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
1007
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -90,6 +90,8 @@ return [
|
|||||||
'model' => App\Models\Staff::class,
|
'model' => App\Models\Staff::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
31
config/broadcasting.php
Normal file
31
config/broadcasting.php
Normal 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',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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
96
config/reverb.php
Normal 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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
2525
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 |
BIN
public/profile_upload/profile_1765625223.jpg
Normal file
BIN
public/profile_upload/profile_1765625223.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -1 +1,6 @@
|
|||||||
import './bootstrap';
|
import "./bootstrap";
|
||||||
|
|
||||||
|
// VERY IMPORTANT — Load Echo globally
|
||||||
|
import "./echo";
|
||||||
|
|
||||||
|
console.log("[APP] app.js loaded");
|
||||||
|
|||||||
7
resources/js/bootstrap.js
vendored
7
resources/js/bootstrap.js
vendored
@@ -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
30
resources/js/echo.js
Normal 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);
|
||||||
@@ -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="card shadow-sm">
|
|
||||||
<div class="card-body">
|
<div class="container py-4">
|
||||||
<h4>Welcome to the Admin chat</h4>
|
|
||||||
<p>Here you can manage all system modules.</p>
|
<h2 class="mb-4 fw-bold">Customer Support Chat</h2>
|
||||||
</div>
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
|
||||||
|
@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>
|
||||||
|
@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
|
||||||
|
|||||||
217
resources/views/admin/chat_window.blade.php
Normal file
217
resources/views/admin/chat_window.blade.php
Normal 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
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
78
routes/channels.php
Normal 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;
|
||||||
|
// });
|
||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user