Merge feat/chat-ui: unified chat inbox (Chatwoot-style)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
This commit is contained in:
415
app/Http/Controllers/Seller/ChatController.php
Normal file
415
app/Http/Controllers/Seller/ChatController.php
Normal file
@@ -0,0 +1,415 @@
|
||||
<?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
|
||||
// Include: 1) Customer contacts (from businesses that ordered), 2) Own business contacts (coworkers)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
|
||||
// Add the seller's own business ID to include coworkers
|
||||
$allBusinessIds = $customerBusinessIds->push($business->id)->unique();
|
||||
|
||||
$contacts = \App\Models\Contact::whereIn('business_id', $allBusinessIds)
|
||||
->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 allowed business IDs (customers + own business for coworkers)
|
||||
$customerBusinessIds = \App\Models\Order::whereHas('items.product.brand', fn ($q) => $q->where('business_id', $business->id))
|
||||
->pluck('business_id')
|
||||
->unique();
|
||||
$allBusinessIds = $customerBusinessIds->push($business->id)->unique();
|
||||
|
||||
// SECURITY: Verify contact belongs to a customer business or own business (coworker)
|
||||
$contact = \App\Models\Contact::whereIn('business_id', $allBusinessIds)
|
||||
->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)
|
||||
<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">
|
||||
<input
|
||||
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');
|
||||
});
|
||||
|
||||
// ================================================================================
|
||||
// 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
|
||||
// ================================================================================
|
||||
|
||||
Reference in New Issue
Block a user