diff --git a/app/Http/Controllers/Seller/ChatController.php b/app/Http/Controllers/Seller/ChatController.php new file mode 100644 index 00000000..02c266c3 --- /dev/null +++ b/app/Http/Controllers/Seller/ChatController.php @@ -0,0 +1,415 @@ +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, + ]), + ]); + } +} diff --git a/resources/views/components/seller-sidebar.blade.php b/resources/views/components/seller-sidebar.blade.php index 41c9a456..4f15ca2f 100644 --- a/resources/views/components/seller-sidebar.blade.php +++ b/resources/views/components/seller-sidebar.blade.php @@ -541,6 +541,17 @@ ═══════════════════════════════════════════════════════════ --}} @if($sidebarBusiness) + + {{-- Chat - Full-screen unified inbox (Premium) --}} + @if($sidebarBusiness->hasSalesSuite()) + + + Chat + New + + @endif +
name) + +@push('styles') + +@endpush + +@section('content') +
+ {{-- LEFT SIDEBAR: Conversation List --}} +
+ {{-- Header --}} +
+
+

Conversations

+ +
+ + {{-- Search --}} +
+ + +
+ + {{-- Filters --}} +
+ + +
+
+ + {{-- Conversation List --}} +
+ @forelse($threads as $thread) +
+
+ {{-- Avatar --}} +
+
+ {{ substr($thread->contact->name ?? '?', 0, 2) }} +
+
+ + {{-- Content --}} +
+
+ + {{ $thread->contact->name ?? 'Unknown' }} + + + {{ $thread->last_message_at?->shortRelativeDiffForHumans() }} + +
+ + @if($thread->contact->business) +
+ {{ $thread->contact->business->name }} +
+ @endif + +
+ {{ Str::limit($thread->last_message_preview, 50) }} +
+ + {{-- Tags --}} +
+ @if($thread->status === 'open') + Open + @elseif($thread->status === 'pending') + Pending + @else + Resolved + @endif + + @if($thread->last_channel_type) + {{ ucfirst($thread->last_channel_type) }} + @endif + + @if($thread->assignee) + {{ Str::limit($thread->assignee->name, 10) }} + @endif +
+
+
+
+ @empty +
+ +

No conversations yet

+
+ @endforelse +
+
+ + {{-- CENTER: Chat Thread --}} +
+ {{-- Empty State --}} + + + {{-- Active Thread --}} + + + {{-- Loading State --}} + +
+ + {{-- RIGHT SIDEBAR: Contact Details --}} +
+ +
+ + {{-- New Conversation Modal --}} + + + + +
+ +@push('scripts') + +@endpush +@endsection diff --git a/routes/seller.php b/routes/seller.php index f6aa3cd4..766f661a 100644 --- a/routes/seller.php +++ b/routes/seller.php @@ -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 // ================================================================================