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:
kelly
2025-12-16 10:29:24 -07:00
parent e0caa83325
commit 5b1b085e06
4 changed files with 1254 additions and 0 deletions

View 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,
]),
]);
}
}

View File

@@ -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"

View 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>&bull;</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> &bull; <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

View File

@@ -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
// ================================================================================