feat: add unified chat UI at /s/{business}/chat
Chatwoot-style 3-panel chat interface:
- Left panel: conversation list with filters (status, assignee, search)
- Center panel: message thread with reply box and AI draft
- Right panel: contact details, assignment, internal notes
Features:
- Real-time thread loading via fetch API
- Keyboard shortcuts (Cmd+Enter to send)
- Collision detection heartbeat
- New conversation modal
- Thread status management (close/reopen)
- AI reply generation
- Internal notes
Routes added at /s/{business}/chat/*
Sidebar link added under Inbox section (requires Sales Suite)
This commit is contained in:
410
app/Http/Controllers/Seller/ChatController.php
Normal file
410
app/Http/Controllers/Seller/ChatController.php
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Seller;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Business;
|
||||||
|
use App\Models\Crm\CrmActiveView;
|
||||||
|
use App\Models\Crm\CrmChannel;
|
||||||
|
use App\Models\Crm\CrmInternalNote;
|
||||||
|
use App\Models\Crm\CrmThread;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Crm\CrmAiService;
|
||||||
|
use App\Services\Crm\CrmChannelService;
|
||||||
|
use App\Services\Crm\CrmSlaService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ChatController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected CrmChannelService $channelService,
|
||||||
|
protected CrmSlaService $slaService,
|
||||||
|
protected CrmAiService $aiService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified chat inbox view (Chatwoot-style)
|
||||||
|
*/
|
||||||
|
public function index(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$query = CrmThread::forBusiness($business->id)
|
||||||
|
->with(['contact', 'assignee', 'brand', 'channel', 'messages' => fn ($q) => $q->latest()->limit(1)])
|
||||||
|
->withCount('messages');
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('assigned_to')) {
|
||||||
|
if ($request->assigned_to === 'unassigned') {
|
||||||
|
$query->unassigned();
|
||||||
|
} else {
|
||||||
|
$query->assignedTo($request->assigned_to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('department')) {
|
||||||
|
$query->forDepartment($request->department);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('brand_id')) {
|
||||||
|
$query->forBrand($request->brand_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$query->where(function ($q) use ($request) {
|
||||||
|
$q->where('subject', 'ilike', "%{$request->search}%")
|
||||||
|
->orWhere('last_message_preview', 'ilike', "%{$request->search}%")
|
||||||
|
->orWhereHas('contact', fn ($c) => $c->where('name', 'ilike', "%{$request->search}%"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$threads = $query->orderByDesc('last_message_at')->paginate(50);
|
||||||
|
|
||||||
|
// Get team members for assignment dropdown
|
||||||
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||||
|
|
||||||
|
// Get available channels
|
||||||
|
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||||
|
|
||||||
|
// Get brands for filter dropdown
|
||||||
|
$brands = \App\Models\Brand::where('business_id', $business->id)
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Get departments for filter dropdown
|
||||||
|
$departments = CrmChannel::DEPARTMENTS;
|
||||||
|
|
||||||
|
// Get contacts for new conversation modal
|
||||||
|
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->pluck('business_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
$contacts = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||||
|
->with('business:id,name')
|
||||||
|
->orderBy('first_name')
|
||||||
|
->limit(200)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('seller.chat.index', compact(
|
||||||
|
'business',
|
||||||
|
'threads',
|
||||||
|
'teamMembers',
|
||||||
|
'channels',
|
||||||
|
'brands',
|
||||||
|
'departments',
|
||||||
|
'contacts'
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Get thread data for inline loading
|
||||||
|
*/
|
||||||
|
public function getThread(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->load([
|
||||||
|
'contact',
|
||||||
|
'account',
|
||||||
|
'assignee',
|
||||||
|
'brand',
|
||||||
|
'channel',
|
||||||
|
'messages.attachments',
|
||||||
|
'messages.user',
|
||||||
|
'deals',
|
||||||
|
'internalNotes.user',
|
||||||
|
'tags.tag',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
$thread->markAsRead($request->user());
|
||||||
|
|
||||||
|
// Start viewing (collision detection)
|
||||||
|
CrmActiveView::startViewing($thread, $request->user());
|
||||||
|
|
||||||
|
// Get other viewers
|
||||||
|
$otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id);
|
||||||
|
|
||||||
|
// Get SLA status
|
||||||
|
$slaStatus = $this->slaService->getThreadSlaStatus($thread);
|
||||||
|
|
||||||
|
// Get AI suggestions
|
||||||
|
$suggestions = $thread->aiSuggestions()->pending()->notExpired()->get();
|
||||||
|
|
||||||
|
// Get available channels for reply
|
||||||
|
$channels = $this->channelService->getAvailableChannels($business->id);
|
||||||
|
|
||||||
|
// Get team members for assignment dropdown
|
||||||
|
$teamMembers = User::whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))->get();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'thread' => $thread,
|
||||||
|
'otherViewers' => $otherViewers->map(fn ($v) => [
|
||||||
|
'id' => $v->user->id,
|
||||||
|
'name' => $v->user->name,
|
||||||
|
'type' => $v->view_type,
|
||||||
|
]),
|
||||||
|
'slaStatus' => $slaStatus,
|
||||||
|
'suggestions' => $suggestions,
|
||||||
|
'channels' => $channels,
|
||||||
|
'teamMembers' => $teamMembers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Send reply in thread
|
||||||
|
*/
|
||||||
|
public function reply(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'body' => 'required|string|max:10000',
|
||||||
|
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$contact = $thread->contact;
|
||||||
|
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||||
|
? $contact->email
|
||||||
|
: $contact->phone;
|
||||||
|
|
||||||
|
if (! $to) {
|
||||||
|
return response()->json(['error' => 'Contact does not have required contact info for this channel.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$success = $this->channelService->sendMessage(
|
||||||
|
businessId: $business->id,
|
||||||
|
channelType: $validated['channel_type'],
|
||||||
|
to: $to,
|
||||||
|
body: $validated['body'],
|
||||||
|
subject: null,
|
||||||
|
threadId: $thread->id,
|
||||||
|
contactId: $contact->id,
|
||||||
|
userId: $request->user()->id,
|
||||||
|
attachments: []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $success) {
|
||||||
|
return response()->json(['error' => 'Failed to send message.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-assign thread to sender if unassigned
|
||||||
|
if ($thread->assigned_to === null) {
|
||||||
|
$thread->assigned_to = $request->user()->id;
|
||||||
|
$thread->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SLA
|
||||||
|
$this->slaService->handleOutboundMessage($thread);
|
||||||
|
|
||||||
|
// Reload messages
|
||||||
|
$thread->load(['messages.attachments', 'messages.user']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'messages' => $thread->messages,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Create new thread
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Business $business)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'contact_id' => 'required|exists:contacts,id',
|
||||||
|
'channel_type' => 'required|string|in:sms,email,whatsapp,instagram,in_app',
|
||||||
|
'body' => 'required|string|max:10000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get customer business IDs
|
||||||
|
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||||
|
->pluck('business_id')
|
||||||
|
->unique();
|
||||||
|
|
||||||
|
// SECURITY: Verify contact belongs to a customer business
|
||||||
|
$contact = \App\Models\Contact::whereIn('business_id', $customerBusinessIds)
|
||||||
|
->findOrFail($validated['contact_id']);
|
||||||
|
|
||||||
|
$to = $validated['channel_type'] === CrmChannel::TYPE_EMAIL
|
||||||
|
? $contact->email
|
||||||
|
: $contact->phone;
|
||||||
|
|
||||||
|
if (! $to) {
|
||||||
|
return response()->json(['error' => 'Contact does not have the required contact info for this channel.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create thread
|
||||||
|
$thread = CrmThread::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'contact_id' => $contact->id,
|
||||||
|
'account_id' => $contact->account_id,
|
||||||
|
'status' => 'open',
|
||||||
|
'priority' => 'normal',
|
||||||
|
'last_channel_type' => $validated['channel_type'],
|
||||||
|
'assigned_to' => $request->user()->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send the message
|
||||||
|
$success = $this->channelService->sendMessage(
|
||||||
|
businessId: $business->id,
|
||||||
|
channelType: $validated['channel_type'],
|
||||||
|
to: $to,
|
||||||
|
body: $validated['body'],
|
||||||
|
subject: null,
|
||||||
|
threadId: $thread->id,
|
||||||
|
contactId: $contact->id,
|
||||||
|
userId: $request->user()->id,
|
||||||
|
attachments: []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $success) {
|
||||||
|
$thread->delete();
|
||||||
|
|
||||||
|
return response()->json(['error' => 'Failed to send message.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->load(['contact', 'messages']);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'thread' => $thread,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Assign thread
|
||||||
|
*/
|
||||||
|
public function assign(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'assigned_to' => 'nullable|exists:users,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validated['assigned_to']) {
|
||||||
|
$assignee = User::where('id', $validated['assigned_to'])
|
||||||
|
->whereHas('businesses', fn ($q) => $q->where('businesses.id', $business->id))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $assignee) {
|
||||||
|
return response()->json(['error' => 'Invalid user.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->assignTo($assignee, $request->user());
|
||||||
|
} else {
|
||||||
|
$thread->assigned_to = null;
|
||||||
|
$thread->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Close thread
|
||||||
|
*/
|
||||||
|
public function close(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->close($request->user());
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'status' => 'closed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Reopen thread
|
||||||
|
*/
|
||||||
|
public function reopen(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thread->reopen($request->user());
|
||||||
|
$this->slaService->resumeTimers($thread);
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'status' => 'open']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Add internal note
|
||||||
|
*/
|
||||||
|
public function addNote(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'content' => 'required|string|max:5000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$note = CrmInternalNote::create([
|
||||||
|
'business_id' => $business->id,
|
||||||
|
'user_id' => $request->user()->id,
|
||||||
|
'notable_type' => CrmThread::class,
|
||||||
|
'notable_id' => $thread->id,
|
||||||
|
'content' => $validated['content'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$note->load('user');
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'note' => $note]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Generate AI reply
|
||||||
|
*/
|
||||||
|
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$suggestion = $this->aiService->generateReplyDraft($thread, $request->input('tone', 'professional'));
|
||||||
|
|
||||||
|
if (! $suggestion) {
|
||||||
|
return response()->json(['error' => 'Failed to generate reply.'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'content' => $suggestion->content,
|
||||||
|
'suggestion_id' => $suggestion->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API: Heartbeat for active viewing
|
||||||
|
*/
|
||||||
|
public function heartbeat(Request $request, Business $business, CrmThread $thread)
|
||||||
|
{
|
||||||
|
if ($thread->business_id !== $business->id) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
CrmActiveView::startViewing($thread, $request->user(), $request->input('view_type', 'viewing'));
|
||||||
|
|
||||||
|
$otherViewers = CrmActiveView::getActiveViewers($thread, $request->user()->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'other_viewers' => $otherViewers->map(fn ($v) => [
|
||||||
|
'id' => $v->user->id,
|
||||||
|
'name' => $v->user->name,
|
||||||
|
'type' => $v->view_type,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -541,6 +541,17 @@
|
|||||||
═══════════════════════════════════════════════════════════ --}}
|
═══════════════════════════════════════════════════════════ --}}
|
||||||
@if($sidebarBusiness)
|
@if($sidebarBusiness)
|
||||||
<p class="menu-label px-2.5 pt-3 pb-1.5">Inbox</p>
|
<p class="menu-label px-2.5 pt-3 pb-1.5">Inbox</p>
|
||||||
|
|
||||||
|
{{-- Chat - Full-screen unified inbox (Premium) --}}
|
||||||
|
@if($sidebarBusiness->hasSalesSuite())
|
||||||
|
<a class="menu-item {{ request()->routeIs('seller.business.chat.*') ? 'active' : '' }}"
|
||||||
|
href="{{ route('seller.business.chat.index', $sidebarBusiness->slug) }}">
|
||||||
|
<span class="icon-[lucide--message-circle] size-4"></span>
|
||||||
|
<span class="grow">Chat</span>
|
||||||
|
<span class="badge badge-primary badge-xs">New</span>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="group collapse">
|
<div class="group collapse">
|
||||||
<input
|
<input
|
||||||
aria-label="Sidemenu item trigger"
|
aria-label="Sidemenu item trigger"
|
||||||
|
|||||||
814
resources/views/seller/chat/index.blade.php
Normal file
814
resources/views/seller/chat/index.blade.php
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
@extends('layouts.seller')
|
||||||
|
|
||||||
|
@section('title', 'Chat - ' . $business->name)
|
||||||
|
|
||||||
|
@push('styles')
|
||||||
|
<style>
|
||||||
|
/* Full height chat layout */
|
||||||
|
.chat-container {
|
||||||
|
height: calc(100vh - 64px);
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left sidebar - conversation list */
|
||||||
|
.chat-sidebar {
|
||||||
|
width: 340px;
|
||||||
|
min-width: 340px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid oklch(var(--b3));
|
||||||
|
background: oklch(var(--b2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center - message thread */
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: oklch(var(--b1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right sidebar - contact details */
|
||||||
|
.chat-details {
|
||||||
|
width: 320px;
|
||||||
|
min-width: 320px;
|
||||||
|
border-left: 1px solid oklch(var(--b3));
|
||||||
|
background: oklch(var(--b2));
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Conversation list item */
|
||||||
|
.conversation-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid oklch(var(--b3));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.conversation-item:hover {
|
||||||
|
background: oklch(var(--b3));
|
||||||
|
}
|
||||||
|
.conversation-item.active {
|
||||||
|
background: oklch(var(--p) / 0.1);
|
||||||
|
border-left: 3px solid oklch(var(--p));
|
||||||
|
}
|
||||||
|
.conversation-item.unread {
|
||||||
|
background: oklch(var(--p) / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages area */
|
||||||
|
.messages-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message bubbles */
|
||||||
|
.message-bubble {
|
||||||
|
max-width: 65%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.message-bubble.outbound {
|
||||||
|
background: oklch(var(--p));
|
||||||
|
color: oklch(var(--pc));
|
||||||
|
margin-left: auto;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.message-bubble.inbound {
|
||||||
|
background: oklch(var(--b2));
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reply box */
|
||||||
|
.reply-box {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid oklch(var(--b3));
|
||||||
|
background: oklch(var(--b2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.hide-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.hide-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: oklch(var(--bc) / 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.hide-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: oklch(var(--bc) / 0.3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="chat-container" x-data="chatApp()" x-init="init()">
|
||||||
|
{{-- LEFT SIDEBAR: Conversation List --}}
|
||||||
|
<div class="chat-sidebar">
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="p-4 border-b border-base-300">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h1 class="text-lg font-bold">Conversations</h1>
|
||||||
|
<button @click="showNewConversation = true" class="btn btn-primary btn-sm gap-1">
|
||||||
|
<span class="icon-[heroicons--plus] size-4"></span>
|
||||||
|
New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Search --}}
|
||||||
|
<div class="relative mb-3">
|
||||||
|
<span class="icon-[heroicons--magnifying-glass] size-4 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"></span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="searchQuery"
|
||||||
|
@input.debounce.300ms="filterConversations()"
|
||||||
|
placeholder="Search conversations..."
|
||||||
|
class="input input-sm input-bordered w-full pl-9"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Filters --}}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select x-model="statusFilter" @change="filterConversations()" class="select select-bordered select-xs flex-1">
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="open">Open</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="closed">Resolved</option>
|
||||||
|
</select>
|
||||||
|
<select x-model="assigneeFilter" @change="filterConversations()" class="select select-bordered select-xs flex-1">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="mine">Mine</option>
|
||||||
|
<option value="unassigned">Unassigned</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Conversation List --}}
|
||||||
|
<div class="flex-1 overflow-y-auto hide-scrollbar">
|
||||||
|
@forelse($threads as $thread)
|
||||||
|
<div
|
||||||
|
class="conversation-item"
|
||||||
|
:class="{
|
||||||
|
'active': selectedThreadId === {{ $thread->id }},
|
||||||
|
'unread': {{ $thread->is_unread ? 'true' : 'false' }} && selectedThreadId !== {{ $thread->id }}
|
||||||
|
}"
|
||||||
|
@click="selectThread({{ $thread->id }})"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
{{-- Avatar --}}
|
||||||
|
<div class="avatar placeholder flex-shrink-0">
|
||||||
|
<div class="bg-neutral text-neutral-content rounded-full w-10 h-10">
|
||||||
|
<span class="text-sm">{{ substr($thread->contact->name ?? '?', 0, 2) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Content --}}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-medium truncate text-sm {{ $thread->is_unread ? 'text-primary' : '' }}">
|
||||||
|
{{ $thread->contact->name ?? 'Unknown' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-base-content/50 flex-shrink-0">
|
||||||
|
{{ $thread->last_message_at?->shortRelativeDiffForHumans() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($thread->contact->business)
|
||||||
|
<div class="text-xs text-base-content/60 truncate">
|
||||||
|
{{ $thread->contact->business->name }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="text-xs text-base-content/50 truncate mt-0.5">
|
||||||
|
{{ Str::limit($thread->last_message_preview, 50) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Tags --}}
|
||||||
|
<div class="flex items-center gap-1 mt-1.5 flex-wrap">
|
||||||
|
@if($thread->status === 'open')
|
||||||
|
<span class="badge badge-success badge-xs">Open</span>
|
||||||
|
@elseif($thread->status === 'pending')
|
||||||
|
<span class="badge badge-warning badge-xs">Pending</span>
|
||||||
|
@else
|
||||||
|
<span class="badge badge-ghost badge-xs">Resolved</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($thread->last_channel_type)
|
||||||
|
<span class="badge badge-outline badge-xs">{{ ucfirst($thread->last_channel_type) }}</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($thread->assignee)
|
||||||
|
<span class="text-xs text-base-content/40">{{ Str::limit($thread->assignee->name, 10) }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="p-8 text-center text-base-content/50">
|
||||||
|
<span class="icon-[heroicons--chat-bubble-left-right] size-12 mx-auto mb-3 opacity-50"></span>
|
||||||
|
<p class="text-sm">No conversations yet</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- CENTER: Chat Thread --}}
|
||||||
|
<div class="chat-main">
|
||||||
|
{{-- Empty State --}}
|
||||||
|
<template x-if="!selectedThreadId">
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="icon-[heroicons--chat-bubble-left-right] size-16 text-base-content/20 mx-auto mb-4"></span>
|
||||||
|
<h3 class="text-lg font-medium mb-2">Select a conversation</h3>
|
||||||
|
<p class="text-base-content/50 text-sm">Choose a conversation from the list to start chatting</p>
|
||||||
|
<button @click="showNewConversation = true" class="btn btn-primary btn-sm mt-4 gap-1">
|
||||||
|
<span class="icon-[heroicons--plus] size-4"></span>
|
||||||
|
Start New Conversation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{{-- Active Thread --}}
|
||||||
|
<template x-if="selectedThreadId && currentThread">
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
{{-- Thread Header --}}
|
||||||
|
<div class="p-4 border-b border-base-300 bg-base-100">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-neutral text-neutral-content rounded-full w-10 h-10">
|
||||||
|
<span x-text="currentThread.contact?.name?.substring(0, 2) || '?'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold" x-text="currentThread.contact?.name || 'Unknown'"></h2>
|
||||||
|
<div class="text-xs text-base-content/60" x-text="currentThread.contact?.email || currentThread.contact?.phone"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{{-- Status badge --}}
|
||||||
|
<span
|
||||||
|
class="badge"
|
||||||
|
:class="{
|
||||||
|
'badge-success': currentThread.status === 'open',
|
||||||
|
'badge-warning': currentThread.status === 'pending',
|
||||||
|
'badge-ghost': currentThread.status === 'closed'
|
||||||
|
}"
|
||||||
|
x-text="currentThread.status === 'closed' ? 'Resolved' : (currentThread.status?.charAt(0).toUpperCase() + currentThread.status?.slice(1))"
|
||||||
|
></span>
|
||||||
|
|
||||||
|
{{-- Actions --}}
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<button tabindex="0" class="btn btn-ghost btn-sm btn-square">
|
||||||
|
<span class="icon-[heroicons--ellipsis-vertical] size-5"></span>
|
||||||
|
</button>
|
||||||
|
<ul tabindex="0" class="dropdown-content menu menu-sm p-2 shadow-lg bg-base-100 rounded-lg w-48 border border-base-300 z-50">
|
||||||
|
<li x-show="currentThread.status !== 'closed'">
|
||||||
|
<button @click="closeThread()">
|
||||||
|
<span class="icon-[heroicons--check-circle] size-4"></span>
|
||||||
|
Mark Resolved
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li x-show="currentThread.status === 'closed'">
|
||||||
|
<button @click="reopenThread()">
|
||||||
|
<span class="icon-[heroicons--arrow-path] size-4"></span>
|
||||||
|
Reopen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Toggle details panel --}}
|
||||||
|
<button
|
||||||
|
@click="showDetails = !showDetails"
|
||||||
|
class="btn btn-ghost btn-sm btn-square"
|
||||||
|
:class="{ 'btn-active': showDetails }"
|
||||||
|
>
|
||||||
|
<span class="icon-[heroicons--information-circle] size-5"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Messages --}}
|
||||||
|
<div class="messages-container hide-scrollbar" id="messages-container">
|
||||||
|
<template x-for="message in currentThread.messages" :key="message.id">
|
||||||
|
<div class="flex" :class="message.direction === 'outbound' ? 'justify-end' : 'justify-start'">
|
||||||
|
<div class="message-bubble" :class="message.direction === 'outbound' ? 'outbound' : 'inbound'">
|
||||||
|
<div class="text-xs opacity-70 mb-1 flex items-center gap-2">
|
||||||
|
<span x-text="message.direction === 'outbound' ? (message.user?.name || 'You') : currentThread.contact?.name"></span>
|
||||||
|
<span>•</span>
|
||||||
|
<span x-text="formatTime(message.created_at)"></span>
|
||||||
|
</div>
|
||||||
|
<div class="whitespace-pre-wrap text-sm" x-text="message.body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{{-- Typing indicator --}}
|
||||||
|
<div x-show="sending" class="flex justify-end">
|
||||||
|
<div class="message-bubble outbound opacity-60">
|
||||||
|
<span class="loading loading-dots loading-xs"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Reply Box --}}
|
||||||
|
<div class="reply-box">
|
||||||
|
<form @submit.prevent="sendReply()">
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<select x-model="replyChannel" class="select select-bordered select-sm">
|
||||||
|
@foreach($channels as $channel)
|
||||||
|
<option value="{{ $channel->type }}">{{ ucfirst($channel->type) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
<button type="button" @click="generateAiReply()" class="btn btn-ghost btn-sm gap-1" :disabled="generatingAi">
|
||||||
|
<span class="icon-[heroicons--sparkles] size-4"></span>
|
||||||
|
<span x-show="!generatingAi">AI Draft</span>
|
||||||
|
<span x-show="generatingAi" class="loading loading-spinner loading-xs"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
x-model="replyMessage"
|
||||||
|
@keydown.meta.enter="sendReply()"
|
||||||
|
@keydown.ctrl.enter="sendReply()"
|
||||||
|
placeholder="Type your message... (Cmd+Enter to send)"
|
||||||
|
rows="2"
|
||||||
|
class="textarea textarea-bordered flex-1 text-sm resize-none"
|
||||||
|
:disabled="sending"
|
||||||
|
></textarea>
|
||||||
|
<button type="submit" class="btn btn-primary self-end" :disabled="!replyMessage.trim() || sending">
|
||||||
|
<span x-show="!sending" class="icon-[heroicons--paper-airplane] size-5"></span>
|
||||||
|
<span x-show="sending" class="loading loading-spinner loading-sm"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{{-- Loading State --}}
|
||||||
|
<template x-if="selectedThreadId && !currentThread && loading">
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- RIGHT SIDEBAR: Contact Details --}}
|
||||||
|
<div class="chat-details" x-show="showDetails && currentThread" x-cloak>
|
||||||
|
<template x-if="currentThread">
|
||||||
|
<div class="p-4">
|
||||||
|
{{-- Contact Card --}}
|
||||||
|
<div class="text-center mb-6 pb-6 border-b border-base-300">
|
||||||
|
<div class="avatar placeholder mb-3">
|
||||||
|
<div class="bg-neutral text-neutral-content rounded-full w-16 h-16">
|
||||||
|
<span class="text-xl" x-text="currentThread.contact?.name?.substring(0, 2) || '?'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-semibold" x-text="currentThread.contact?.name"></h3>
|
||||||
|
<p class="text-sm text-base-content/60" x-text="currentThread.account?.name || currentThread.contact?.business?.name"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Contact Info --}}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-xs font-semibold text-base-content/50 uppercase tracking-wide mb-3">Contact Info</h4>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center gap-2" x-show="currentThread.contact?.email">
|
||||||
|
<span class="icon-[heroicons--envelope] size-4 text-base-content/50"></span>
|
||||||
|
<span x-text="currentThread.contact?.email"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2" x-show="currentThread.contact?.phone">
|
||||||
|
<span class="icon-[heroicons--phone] size-4 text-base-content/50"></span>
|
||||||
|
<span x-text="currentThread.contact?.phone"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Assignment --}}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-xs font-semibold text-base-content/50 uppercase tracking-wide mb-3">Assignment</h4>
|
||||||
|
<select x-model="currentThread.assigned_to" @change="assignThread()" class="select select-bordered select-sm w-full">
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
@foreach($teamMembers as $member)
|
||||||
|
<option value="{{ $member->id }}">{{ $member->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Channel --}}
|
||||||
|
<div class="mb-6" x-show="currentThread.last_channel_type">
|
||||||
|
<h4 class="text-xs font-semibold text-base-content/50 uppercase tracking-wide mb-3">Channel</h4>
|
||||||
|
<span class="badge badge-outline" x-text="currentThread.last_channel_type?.charAt(0).toUpperCase() + currentThread.last_channel_type?.slice(1)"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Related Deals --}}
|
||||||
|
<template x-if="currentThread.deals?.length > 0">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-xs font-semibold text-base-content/50 uppercase tracking-wide mb-3">Related Deals</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<template x-for="deal in currentThread.deals" :key="deal.id">
|
||||||
|
<a :href="`{{ route('seller.business.crm.deals.index', $business->slug) }}/${deal.id}`" class="block p-2 bg-base-100 rounded-lg hover:bg-base-300 text-sm">
|
||||||
|
<div class="font-medium" x-text="deal.name"></div>
|
||||||
|
<div class="text-xs text-base-content/60">
|
||||||
|
$<span x-text="deal.value?.toLocaleString()"></span> - <span x-text="deal.stage"></span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{{-- Internal Notes --}}
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-base-content/50 uppercase tracking-wide mb-3">Internal Notes</h4>
|
||||||
|
<form @submit.prevent="addNote()" class="mb-3">
|
||||||
|
<textarea x-model="newNote" rows="2" placeholder="Add a note..." class="textarea textarea-bordered textarea-sm w-full mb-2"></textarea>
|
||||||
|
<button type="submit" class="btn btn-xs btn-outline" :disabled="!newNote.trim()">Add Note</button>
|
||||||
|
</form>
|
||||||
|
<div class="space-y-2 max-h-48 overflow-y-auto">
|
||||||
|
<template x-for="note in currentThread.internal_notes" :key="note.id">
|
||||||
|
<div class="p-2 bg-base-100 rounded-lg text-xs">
|
||||||
|
<div class="text-base-content/50 mb-1">
|
||||||
|
<span x-text="note.user?.name"></span> • <span x-text="formatTime(note.created_at)"></span>
|
||||||
|
</div>
|
||||||
|
<div x-text="note.content"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- New Conversation Modal --}}
|
||||||
|
<dialog id="new-conversation-modal" class="modal" :class="{ 'modal-open': showNewConversation }">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">New Conversation</h3>
|
||||||
|
<form @submit.prevent="createConversation()">
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Contact</span>
|
||||||
|
</label>
|
||||||
|
<select x-model="newConversation.contact_id" class="select select-bordered w-full" required>
|
||||||
|
<option value="">Select a contact...</option>
|
||||||
|
@foreach($contacts as $contact)
|
||||||
|
<option value="{{ $contact->id }}">
|
||||||
|
{{ $contact->name }} {{ $contact->business ? '(' . $contact->business->name . ')' : '' }}
|
||||||
|
</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Channel</span>
|
||||||
|
</label>
|
||||||
|
<select x-model="newConversation.channel_type" class="select select-bordered w-full" required>
|
||||||
|
@foreach($channels as $channel)
|
||||||
|
<option value="{{ $channel->type }}">{{ ucfirst($channel->type) }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Message</span>
|
||||||
|
</label>
|
||||||
|
<textarea x-model="newConversation.body" rows="4" class="textarea textarea-bordered w-full" placeholder="Type your message..." required></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" @click="showNewConversation = false" class="btn btn-ghost">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="creatingConversation">
|
||||||
|
<span x-show="!creatingConversation">Start Conversation</span>
|
||||||
|
<span x-show="creatingConversation" class="loading loading-spinner loading-sm"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button @click="showNewConversation = false">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
function chatApp() {
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
selectedThreadId: null,
|
||||||
|
currentThread: null,
|
||||||
|
loading: false,
|
||||||
|
sending: false,
|
||||||
|
showDetails: true,
|
||||||
|
showNewConversation: false,
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
searchQuery: '',
|
||||||
|
statusFilter: '',
|
||||||
|
assigneeFilter: '',
|
||||||
|
|
||||||
|
// Reply
|
||||||
|
replyMessage: '',
|
||||||
|
replyChannel: '{{ $channels->first()?->type ?? 'email' }}',
|
||||||
|
generatingAi: false,
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
newNote: '',
|
||||||
|
|
||||||
|
// New conversation
|
||||||
|
newConversation: {
|
||||||
|
contact_id: '',
|
||||||
|
channel_type: '{{ $channels->first()?->type ?? 'email' }}',
|
||||||
|
body: ''
|
||||||
|
},
|
||||||
|
creatingConversation: false,
|
||||||
|
|
||||||
|
// Heartbeat interval
|
||||||
|
heartbeatInterval: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Check URL for thread ID
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const threadId = urlParams.get('thread');
|
||||||
|
if (threadId) {
|
||||||
|
this.selectThread(parseInt(threadId));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async selectThread(threadId) {
|
||||||
|
if (this.selectedThreadId === threadId) return;
|
||||||
|
|
||||||
|
// Clear previous heartbeat
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedThreadId = threadId;
|
||||||
|
this.loading = true;
|
||||||
|
this.currentThread = null;
|
||||||
|
|
||||||
|
// Update URL
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('thread', threadId);
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{{ route('seller.business.chat.index', $business->slug) }}/${threadId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
this.currentThread = data.thread;
|
||||||
|
this.replyChannel = data.thread.last_channel_type || '{{ $channels->first()?->type ?? 'email' }}';
|
||||||
|
|
||||||
|
// Scroll to bottom
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.scrollToBottom();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start heartbeat
|
||||||
|
this.heartbeatInterval = setInterval(() => this.sendHeartbeat(), 30000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load thread:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendReply() {
|
||||||
|
if (!this.replyMessage.trim() || this.sending) return;
|
||||||
|
|
||||||
|
this.sending = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{{ route('seller.business.chat.index', $business->slug) }}/${this.selectedThreadId}/reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
body: this.replyMessage,
|
||||||
|
channel_type: this.replyChannel
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.currentThread.messages = data.messages;
|
||||||
|
this.replyMessage = '';
|
||||||
|
this.$nextTick(() => this.scrollToBottom());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send reply:', error);
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateAiReply() {
|
||||||
|
if (this.generatingAi) return;
|
||||||
|
|
||||||
|
this.generatingAi = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{{ route('seller.business.chat.index', $business->slug) }}/${this.selectedThreadId}/ai-reply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.content) {
|
||||||
|
this.replyMessage = data.content;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate AI reply:', error);
|
||||||
|
} finally {
|
||||||
|
this.generatingAi = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async closeThread() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{{ route('seller.business.chat.index', $business->slug) }}/${this.selectedThreadId}/close`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.currentThread.status = 'closed';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to close thread:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async reopenThread() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{{ route('seller.business.chat.index', $business->slug) }}/${this.selectedThreadId}/reopen`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.currentThread.status = 'open';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reopen thread:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignThread() {
|
||||||
|
try {
|
||||||
|
await fetch(`{{ route('seller.business.chat.index', $business->slug) }}/${this.selectedThreadId}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
assigned_to: this.currentThread.assigned_to || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to assign thread:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addNote() {
|
||||||
|
if (!this.newNote.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{{ route('seller.business.chat.index', $business->slug) }}/${this.selectedThreadId}/note`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: this.newNote
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.currentThread.internal_notes.push(data.note);
|
||||||
|
this.newNote = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add note:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createConversation() {
|
||||||
|
if (this.creatingConversation) return;
|
||||||
|
|
||||||
|
this.creatingConversation = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`{{ route('seller.business.chat.index', $business->slug) }}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(this.newConversation)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNewConversation = false;
|
||||||
|
this.newConversation = { contact_id: '', channel_type: '{{ $channels->first()?->type ?? 'email' }}', body: '' };
|
||||||
|
// Reload page to get new thread in list
|
||||||
|
window.location.href = `{{ route('seller.business.chat.index', $business->slug) }}?thread=${data.thread.id}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create conversation:', error);
|
||||||
|
} finally {
|
||||||
|
this.creatingConversation = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendHeartbeat() {
|
||||||
|
if (!this.selectedThreadId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`{{ route('seller.business.chat.index', $business->slug) }}/${this.selectedThreadId}/heartbeat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Heartbeat failed:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
filterConversations() {
|
||||||
|
// Redirect with filters
|
||||||
|
const url = new URL(window.location);
|
||||||
|
|
||||||
|
if (this.searchQuery) url.searchParams.set('search', this.searchQuery);
|
||||||
|
else url.searchParams.delete('search');
|
||||||
|
|
||||||
|
if (this.statusFilter) url.searchParams.set('status', this.statusFilter);
|
||||||
|
else url.searchParams.delete('status');
|
||||||
|
|
||||||
|
if (this.assigneeFilter) {
|
||||||
|
if (this.assigneeFilter === 'mine') {
|
||||||
|
url.searchParams.set('assigned_to', '{{ auth()->id() }}');
|
||||||
|
} else {
|
||||||
|
url.searchParams.set('assigned_to', this.assigneeFilter);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('assigned_to');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = url.toString();
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
const container = document.getElementById('messages-container');
|
||||||
|
if (container) {
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(dateString) {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
@endsection
|
||||||
@@ -709,6 +709,25 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
|
|||||||
Route::delete('/{expense}', [\App\Http\Controllers\Seller\ExpensesController::class, 'destroy'])->name('destroy');
|
Route::delete('/{expense}', [\App\Http\Controllers\Seller\ExpensesController::class, 'destroy'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ================================================================================
|
||||||
|
// CHAT - Unified Inbox (Chatwoot-style)
|
||||||
|
// ================================================================================
|
||||||
|
// Access: /s/{business}/chat
|
||||||
|
// Features: Full-screen 3-panel chat interface with conversation list,
|
||||||
|
// message thread, and contact details sidebar.
|
||||||
|
Route::prefix('chat')->name('chat.')->middleware('suite:sales')->group(function () {
|
||||||
|
Route::get('/', [\App\Http\Controllers\Seller\ChatController::class, 'index'])->name('index');
|
||||||
|
Route::post('/', [\App\Http\Controllers\Seller\ChatController::class, 'store'])->name('store');
|
||||||
|
Route::get('/{thread}', [\App\Http\Controllers\Seller\ChatController::class, 'getThread'])->name('show');
|
||||||
|
Route::post('/{thread}/reply', [\App\Http\Controllers\Seller\ChatController::class, 'reply'])->name('reply');
|
||||||
|
Route::post('/{thread}/assign', [\App\Http\Controllers\Seller\ChatController::class, 'assign'])->name('assign');
|
||||||
|
Route::post('/{thread}/close', [\App\Http\Controllers\Seller\ChatController::class, 'close'])->name('close');
|
||||||
|
Route::post('/{thread}/reopen', [\App\Http\Controllers\Seller\ChatController::class, 'reopen'])->name('reopen');
|
||||||
|
Route::post('/{thread}/note', [\App\Http\Controllers\Seller\ChatController::class, 'addNote'])->name('note');
|
||||||
|
Route::post('/{thread}/ai-reply', [\App\Http\Controllers\Seller\ChatController::class, 'generateAiReply'])->name('ai-reply');
|
||||||
|
Route::post('/{thread}/heartbeat', [\App\Http\Controllers\Seller\ChatController::class, 'heartbeat'])->name('heartbeat');
|
||||||
|
});
|
||||||
|
|
||||||
// ================================================================================
|
// ================================================================================
|
||||||
// CRM MODULE
|
// CRM MODULE
|
||||||
// ================================================================================
|
// ================================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user