Untitled
unknown
plain_text
a month ago
56 kB
6
Indexable
<<<<<<< HEAD
import { useState, useEffect, useMemo, useRef } from 'react'
=======
import { useState, useEffect } from 'react'
>>>>>>> develop
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { PageLayout } from '@/components/layout/page-layout'
import { Button } from '@/components/ui/button'
import { ConversationListPanel } from '@/components/whatsapp/conversation-list/ConversationListPanel'
import { buildConnectorLabel } from '@/components/whatsapp/conversation-list/ConnectorSwitcher'
import { apiRequest } from '@/lib/queryClient'
import { cn } from '@/lib/utils'
import { WHATSAPP_CONNECTORS_QUERY_KEY } from '@/lib/connectors-query'
import {
buildMarkWhatsappMessageReadRequest,
buildSendConversationMessageRequest,
buildUpdateWhatsappMessageReactionRequest,
} from '@/lib/whatsapp-message-request'
import {
isPlaceholderMediaToken,
readContentPayloadValue,
normalizeContentLibraryObjectPath,
buildReplyComposerContext,
} from '@/lib/whatsapp-message-utils'
import { useWhatsappMessages } from '@/hooks/use-whatsapp-messages'
import { useWhatsappConversations } from '@/hooks/use-whatsapp-conversations'
import { MessageCircle, ArrowLeft } from 'lucide-react'
import { useAuth } from '@/hooks/use-auth'
import { usePermissions } from '@/hooks/use-permissions'
import type { WhatsappConversation, WhatsappMessage, ReplyComposerContext } from '@/types/whatsapp'
import { toastFor } from '@/lib/toast-for'
import { formatWhatsappConnectorLabel } from '@/lib/whatsapp-connectors'
import { useWebSocket } from '@/hooks/use-websocket'
import { CustomerContextPanel } from '@/components/whatsapp/customer-context/CustomerContextPanel'
import {
shouldSendCompanionAfterMedia,
shouldSendCompanionAsCaption,
} from '@/components/whatsapp/content-library-send-behavior'
import { ChatInput } from '@/components/whatsapp/chat-input'
import { ChatHeader } from '@/components/whatsapp/chat-panel/ChatHeader'
import { MessagesArea } from '@/components/whatsapp/chat-panel/MessagesArea'
import { WindowWarningBanner } from '@/components/whatsapp/chat-panel/WindowWarningBanner'
import { NewConversation } from '@/components/whatsapp/new-conversation'
import { getContentPayloadFileName } from '@/components/whatsapp/content-library-preview-utils'
import type { ContentLibraryRecord } from '@/contracts'
import type { ConfiguredConnector } from '@/types/connectors'
<<<<<<< HEAD
interface WhatsappConversation {
id: number
customerPhone: string
customerName?: string
connectorId?: string | null
connectorName?: string | null
title: string
status: string
priority: string
isUnread: boolean
unreadCount: number
lastMessageAt?: string
lastMessagePreview?: string
orderId?: number
assignedUserId?: number
assignedTeamId?: number
tags?: string[]
isVip?: boolean
isArchived?: boolean
}
interface WhatsappMessage {
id: number | string
conversationId: number
whatsappMessageId?: string
content?: string
messageType: string
direction: 'inbound' | 'outbound'
sentByUserId?: number
senderPhone?: string
senderName?: string
deliveryStatus: string
errorMessage?: string
isSystemMessage: boolean
systemMessageType?: string
mediaUrl?: string
mediaMimeType?: string
mediaSize?: number
mediaCaption?: string
replyToWhatsappMessageId?: string
replyToSenderPhone?: string
replyToSenderName?: string
replyPreview?: string
replyMessageType?: string
replyIsOutbound?: boolean
reactionMessageId?: string
reactionEmoji?: string
metadata?: {
rawMessage?: {
context?: {
id?: string
from?: string
}
reaction?: {
message_id?: string
emoji?: string
}
}
}
createdAt: string
deliveredAt?: string
readAt?: string
}
type MessageReactionSummary = {
emoji: string
count: number
}
type AutomationAttemptButtonMapping = {
buttonId: string
buttonLabel: string
targetPipelineStageId: number
}
type AutomationAttempt = {
id: number
orderId: number
conversationId?: number | null
status: string
buttonMappings?: AutomationAttemptButtonMapping[]
selectedButtonLabel?: string | null
mediationAction?: string | null
lateResponseAt?: string | null
lateResponseButtonId?: string | null
lateResponseApplied?: boolean | null
}
type ReplyComposerContext = {
replyToWhatsappMessageId: string
replyToSenderPhone?: string
replyToSenderName?: string
replyPreview?: string
replyMessageType?: string
replyIsOutbound?: boolean
}
=======
import type { ConvoFilterValue } from '@/components/whatsapp/conversation-list/ConvoFilterTabs'
>>>>>>> develop
type SendContentItemInput = {
conversationId: number
item: ContentLibraryRecord
replyContext?: ReplyComposerContext | null
}
const WHATSAPP_WORKSPACE_CONNECTOR_STORAGE_KEY = 'whatsapp:selected-workspace-connector'
<<<<<<< HEAD
const buildConnectorWorkspaceLabel = (
connector: Pick<ConfiguredConnector, 'id' | 'name' | 'config'> | null | undefined,
) => {
if (!connector) {
return ''
}
return formatWhatsappConnectorLabel({
id: connector.id,
name: connector.name,
config: connector.config,
})
}
function TextReplyActionBanner({
attempt,
disabled,
onApplyButton,
onDismiss,
}: {
attempt: AutomationAttempt
disabled: boolean
onApplyButton: (buttonId: string) => void
onDismiss: () => void
}) {
const mappings = Array.isArray(attempt.buttonMappings) ? attempt.buttonMappings : []
return (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-950 shadow-sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="font-semibold">Reponse texte a traiter</div>
<div className="text-xs text-amber-800">
Choisissez une action prevue par le template ou continuez sans action pipeline.
</div>
</div>
<div className="flex flex-wrap gap-2">
{mappings.map((mapping) => (
<Button
key={`${attempt.id}-${mapping.buttonId}`}
size="sm"
variant="outline"
className="border-amber-300 bg-white text-amber-950 hover:bg-amber-100"
disabled={disabled}
onClick={() => {
onApplyButton(mapping.buttonId)
}}
>
{mapping.buttonLabel}
</Button>
))}
<Button size="sm" variant="ghost" disabled={disabled} onClick={onDismiss}>
Continuer sans action
</Button>
</div>
</div>
</div>
)
}
function LateButtonReviewBanner({ attempt }: { attempt: AutomationAttempt }) {
const lateResponseDate = attempt.lateResponseAt ? new Date(attempt.lateResponseAt) : null
const lateResponseLabel =
lateResponseDate && !Number.isNaN(lateResponseDate.getTime())
? format(lateResponseDate, 'dd/MM HH:mm', { locale: fr })
: null
const buttonLabel = attempt.selectedButtonLabel || attempt.lateResponseButtonId || 'bouton'
return (
<div className="rounded-md border border-orange-200 bg-orange-50 px-3 py-3 text-sm text-orange-950 shadow-sm">
<div className="flex gap-3">
<Clock className="mt-0.5 h-4 w-4 flex-shrink-0 text-orange-600" />
<div className="min-w-0">
<div className="font-semibold">Clic tardif a verifier</div>
<div className="text-xs text-orange-800">
Le client a clique {buttonLabel}
{lateResponseLabel ? ` le ${lateResponseLabel}` : ''}, mais la commande avait deja
quitte le fallback. Aucune action pipeline automatique n'a ete appliquee.
</div>
</div>
</div>
</div>
)
}
=======
>>>>>>> develop
function WhatsAppChatPage() {
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null)
const [selectedConnectorId, setSelectedConnectorId] = useState('')
const [newMessage, setNewMessage] = useState('')
const [statusFilter, setStatusFilter] = useState<ConvoFilterValue>('all')
const [searchTerm, setSearchTerm] = useState('')
const [showSidebar, setShowSidebar] = useState(true) // Sidebar droite visible par défaut
const [isMobile, setIsMobile] = useState(window.innerWidth < 768)
const [view, setView] = useState<'conversations' | 'chat' | 'sidebar'>('conversations')
const [showNewConversation, setShowNewConversation] = useState(false)
const [replyContext, setReplyContext] = useState<ReplyComposerContext | null>(null)
const queryClient = useQueryClient()
const { user } = useAuth()
const { isConnected } = useWebSocket()
const { hasPermission } = usePermissions()
const canDelete = hasPermission('whatsapp.manage')
// Responsive handler
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const { data: whatsappConnectors = [], isLoading: connectorsLoading } = useQuery<
ConfiguredConnector[]
>({
queryKey: WHATSAPP_CONNECTORS_QUERY_KEY,
select: (rows) =>
rows
.filter((connector) => connector.type === 'whatsapp')
.sort((left, right) => {
const leftIsActive = left.status === 'active' ? 1 : 0
const rightIsActive = right.status === 'active' ? 1 : 0
if (leftIsActive !== rightIsActive) {
return rightIsActive - leftIsActive
}
return right.createdAt.localeCompare(left.createdAt)
}),
})
useEffect(() => {
if (whatsappConnectors.length === 0) {
if (selectedConnectorId) {
setSelectedConnectorId('')
}
return
}
const hasSelectedConnector = whatsappConnectors.some(
(connector) => connector.id === selectedConnectorId,
)
if (hasSelectedConnector) {
return
}
const savedConnectorId =
typeof window !== 'undefined'
? window.localStorage.getItem(WHATSAPP_WORKSPACE_CONNECTOR_STORAGE_KEY)?.trim() || ''
: ''
const savedConnector = whatsappConnectors.find((connector) => connector.id === savedConnectorId)
setSelectedConnectorId(savedConnector?.id || whatsappConnectors[0].id)
}, [selectedConnectorId, whatsappConnectors])
// BP-3 fix: typeof window !== 'undefined' inutile dans un SPA React
useEffect(() => {
if (!selectedConnectorId) {
window.localStorage.removeItem(WHATSAPP_WORKSPACE_CONNECTOR_STORAGE_KEY)
return
}
window.localStorage.setItem(WHATSAPP_WORKSPACE_CONNECTOR_STORAGE_KEY, selectedConnectorId)
}, [selectedConnectorId])
const {
conversationList,
filteredConversations,
isLoading: conversationsLoading,
} = useWhatsappConversations({
connectorId: selectedConnectorId,
searchTerm,
statusFilter,
isConnected,
})
const {
visibleConversationMessages,
isLoading: messagesLoading,
messageReactions,
currentUserReactions,
resolveQuotedMessage,
customerWindowExpired,
} = useWhatsappMessages({ conversationId: selectedConversationId, userId: user?.id, isConnected })
useEffect(() => {
setReplyContext(null)
}, [selectedConversationId])
const selectedWorkspaceConnector =
whatsappConnectors.find((connector) => connector.id === selectedConnectorId) || null
const selectedConversationCandidate = conversationList.find(
(conv: WhatsappConversation) => conv.id === selectedConversationId,
)
// Mark conversation as read when selected
useEffect(() => {
if (selectedConversationId && selectedConversationCandidate?.isUnread) {
const timer = setTimeout(async () => {
try {
await apiRequest('POST', `/api/whatsapp/conversations/${selectedConversationId}/read`)
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
} catch (error) {
console.error('Error marking conversation as read:', error)
}
}, 1000)
return () => {
clearTimeout(timer)
}
}
}, [selectedConversationCandidate?.isUnread, selectedConversationId])
// Send message mutation
const sendMessageMutation = useMutation({
mutationFn: async (
data: {
conversationId: number
content: string
messageType?: string
templateId?: number
} & Partial<ReplyComposerContext>,
) => {
const request = buildSendConversationMessageRequest(data)
return apiRequest('POST', request.url, request.body)
},
onSuccess: () => {
setNewMessage('')
setReplyContext(null)
queryClient.invalidateQueries({
queryKey: ['/api/whatsapp/conversations', selectedConversationId, 'messages'],
})
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
},
onError: (error: any) => {
const raw = (error?.message || '').toString()
let hint = ''
if (raw.startsWith('402:')) hint = 'Message hors fenêtre 24h: utilisez un template Utility.'
else if (raw.startsWith('403:')) hint = "Consentement requis: obtenez l'opt-in du client."
else if (raw.startsWith('404:'))
hint = 'Conversation introuvable: réessayez après avoir actualisé.'
else if (raw.startsWith('429:')) hint = "Trop d'envois: patientez avant de réessayer."
else if (raw.startsWith('401:')) hint = 'Token expiré: reconnectez le connecteur WhatsApp.'
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: {
message: `Erreur d'envoi: ${raw || "Impossible d'envoyer le message"}${hint ? ` — ${hint}` : ''}`,
},
})
},
})
// Retry message mutation (SOLUTION: réutilise les messages existants)
const retryMessageMutation = useMutation({
mutationFn: async (messageId: string | number) => {
return apiRequest('POST', `/api/whatsapp/messages/${messageId}/retry`)
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['/api/whatsapp/conversations', selectedConversationId, 'messages'],
})
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
toastFor({
kind: 'success',
titleKey: 'common.dynamicMsg',
params: { message: 'Message renvoyé avec succès' },
})
},
onError: (error: any) => {
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: { message: error?.message || 'Impossible de renvoyer le message' },
})
},
})
const markMessageReadMutation = useMutation({
mutationFn: async (messageId: string | number) => {
const request = buildMarkWhatsappMessageReadRequest({ messageId })
return apiRequest('POST', request.url, request.body)
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['/api/whatsapp/conversations', selectedConversationId, 'messages'],
})
},
onError: (error: any) => {
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: { message: error?.message || 'Impossible de marquer le message comme lu' },
})
},
})
const updateReactionMutation = useMutation({
mutationFn: async ({
messageId,
emoji,
}: {
messageId: string | number
emoji?: string | null
}) => {
const request = buildUpdateWhatsappMessageReactionRequest({ messageId, emoji })
return apiRequest('POST', request.url, request.body)
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: ['/api/whatsapp/conversations', selectedConversationId, 'messages'],
})
toastFor({
kind: 'success',
titleKey: 'common.dynamicMsg',
params: {
message: variables.emoji ? `Réaction ${variables.emoji} ajoutée` : 'Réaction supprimée',
},
})
},
onError: (error: any) => {
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: { message: error?.message || 'Impossible de mettre à jour la réaction' },
})
},
})
const sendMediaMessageMutation = useMutation({
mutationFn: async (data: { conversationId: number; file: File; caption?: string }) => {
const formData = new FormData()
formData.append('file', data.file)
if (data.caption) {
formData.append('caption', data.caption)
}
return apiRequest(
'POST',
`/api/whatsapp/conversations/${data.conversationId}/messages/media`,
formData,
)
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['/api/whatsapp/conversations', selectedConversationId, 'messages'],
})
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
},
onError: (error: any) => {
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: { message: error?.message || "Impossible d'envoyer le fichier" },
})
},
})
const sendContentItemMutation = useMutation({
mutationFn: async ({ conversationId, item, replyContext }: SendContentItemInput) => {
const sendTextMessage = async (
content: string,
nextReplyContext?: ReplyComposerContext | null,
) => {
const request = buildSendConversationMessageRequest({
conversationId,
content,
messageType: 'text',
...(nextReplyContext || {}),
})
return apiRequest('POST', request.url, request.body)
}
const sendMediaMessage = async (params: {
objectPath: string
fileName?: string
caption?: string
messageTypeOverride?: 'document'
nextReplyContext?: ReplyComposerContext | null
}) => {
return apiRequest('POST', `/api/whatsapp/conversations/${conversationId}/media-message`, {
objectPath: params.objectPath,
fileName: params.fileName,
caption: params.caption,
messageTypeOverride: params.messageTypeOverride,
...(params.nextReplyContext || {}),
})
}
const companionText = item.companionText?.trim() || ''
const textPayload = readContentPayloadValue(item.payload, 'text')
const linkPayload = readContentPayloadValue(item.payload, 'url')
const filePayload = readContentPayloadValue(item.payload, 'fileUrl')
switch (item.primaryType) {
case 'text': {
if (!textPayload) {
throw new Error('Le contenu texte selectionne est vide.')
}
await sendTextMessage(textPayload, replyContext)
return
}
case 'link': {
if (!linkPayload) {
throw new Error('Le lien selectionne est invalide.')
}
await sendTextMessage(
companionText ? `${companionText}\n${linkPayload}` : linkPayload,
replyContext,
)
return
}
default: {
if (!filePayload) {
throw new Error('Le media selectionne ne contient aucun fichier.')
}
const objectPath = normalizeContentLibraryObjectPath(filePayload)
if (!objectPath) {
throw new Error('Le media selectionne ne peut pas etre converti pour WhatsApp.')
}
const fileName = getContentPayloadFileName(item.payload, 'file')
const shouldSendCaption = shouldSendCompanionAsCaption(item.primaryType, companionText)
const shouldSendCompanionAfter = shouldSendCompanionAfterMedia(
item.primaryType,
companionText,
)
if (shouldSendCompanionAfter) {
await sendMediaMessage({
objectPath,
fileName,
...(item.primaryType === 'document' ? { messageTypeOverride: 'document' } : {}),
nextReplyContext: replyContext,
})
await sendTextMessage(companionText)
return
}
await sendMediaMessage({
objectPath,
fileName,
caption: shouldSendCaption ? companionText : undefined,
...(item.primaryType === 'document' ? { messageTypeOverride: 'document' } : {}),
nextReplyContext: replyContext,
})
return
}
}
},
onSuccess: () => {
setNewMessage('')
setReplyContext(null)
queryClient.invalidateQueries({
queryKey: ['/api/whatsapp/conversations', selectedConversationId, 'messages'],
})
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
},
onError: (error: any) => {
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: { message: error?.message || "Impossible d'envoyer le contenu selectionne" },
})
},
})
const handleSendMessage = () => {
if (!newMessage.trim() || !selectedConversationId) return
sendMessageMutation.mutate({
conversationId: selectedConversationId,
content: newMessage.trim(),
messageType: 'text',
...(replyContext || {}),
})
}
const handleTemplateSend = (templateId: number) => {
if (!selectedConversationId) return
sendMessageMutation.mutate({
conversationId: selectedConversationId,
content: '',
messageType: 'template',
templateId,
})
}
const handleContentSend = (item: ContentLibraryRecord) => {
if (!selectedConversationId) return
sendContentItemMutation.mutate({
conversationId: selectedConversationId,
item,
replyContext,
})
}
const handleReplySelection = (message: WhatsappMessage) => {
const nextReplyContext = buildReplyComposerContext(message)
if (!nextReplyContext) {
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: { message: "Ce message n'a pas encore d'identifiant WhatsApp exploitable." },
})
return
}
setReplyContext(nextReplyContext)
}
const handleChooseTemplate = (message: WhatsappMessage) => {
setNewMessage('/templates')
}
const handleMarkMessageRead = (message: WhatsappMessage) => {
markMessageReadMutation.mutate(message.whatsappMessageId || message.id)
}
const handleReactionUpdate = (
message: WhatsappMessage,
emoji?: string | null,
currentReaction?: string | null,
) => {
updateReactionMutation.mutate({
messageId: message.whatsappMessageId || message.id,
emoji: currentReaction && currentReaction === emoji ? null : emoji,
})
}
// 🔧 NOUVEAU: Mutation pour supprimer un message
const deleteMessageMutation = useMutation({
mutationFn: async (messageId: string | number) => {
return apiRequest('DELETE', `/api/whatsapp/messages/${messageId}`)
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['/api/whatsapp/conversations', selectedConversationId, 'messages'],
})
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
toastFor({
kind: 'success',
titleKey: 'common.dynamicMsg',
params: { message: 'Message supprimé avec succès' },
})
},
onError: (error: any) => {
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: { message: error?.message || 'Impossible de supprimer le message' },
})
},
})
// La confirmation se fait dans MessageBubble (AlertDialog) — ici on exécute juste.
const handleDelete = (message: WhatsappMessage) => {
deleteMessageMutation.mutate(message.id)
}
const handleRetry = (message: WhatsappMessage) => retryMessageMutation.mutate(message.id)
const handleConversationCreated = (conversationId: number) => {
setShowNewConversation(false)
setSelectedConversationId(conversationId)
setView('chat')
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
}
const selectedConversation = filteredConversations.find(
(conv: WhatsappConversation) => conv.id === selectedConversationId,
)
const selectedConversationOrderId = selectedConversation?.orderId
const { data: selectedOrderAutomationAttempts = [] } = useQuery<AutomationAttempt[]>({
queryKey: [
'/api/message-template-automations/attempts',
selectedConversationOrderId,
selectedConversationId,
],
enabled: Boolean(selectedConversationId),
queryFn: async () => {
const params = new URLSearchParams()
if (selectedConversationOrderId) {
params.set('orderId', String(selectedConversationOrderId))
}
params.set('conversationId', String(selectedConversationId))
params.set('limit', '10')
const response = await apiRequest(
'GET',
`/api/message-template-automations/attempts?${params.toString()}`,
)
const payload = (await response.json()) as unknown
return Array.isArray(payload) ? (payload as AutomationAttempt[]) : []
},
})
const pendingTextReplyAttempt = useMemo(
() =>
selectedOrderAutomationAttempts.find(
(attempt) =>
attempt.status === 'awaiting_human_routing' &&
attempt.conversationId === selectedConversationId,
) || null,
[selectedConversationId, selectedOrderAutomationAttempts],
)
const lateButtonReviewAttempt = useMemo(
() =>
selectedOrderAutomationAttempts.find(
(attempt) =>
attempt.status === 'responded_late_ignored' &&
attempt.conversationId === selectedConversationId,
) || null,
[selectedConversationId, selectedOrderAutomationAttempts],
)
const routeTextReplyMutation = useMutation({
mutationFn: async ({
attemptId,
action,
buttonId,
}: {
attemptId: number
action: 'apply_button' | 'dismiss'
buttonId?: string
}) => {
if (!selectedConversationId) {
throw new Error('Conversation manquante')
}
return apiRequest(
'POST',
`/api/whatsapp/conversations/${String(selectedConversationId)}/route-text-reply`,
{
attemptId,
action,
...(buttonId ? { buttonId } : {}),
},
)
},
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['/api/message-template-automations/attempts', selectedConversationOrderId],
})
void queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
toastFor({
kind: 'success',
titleKey: 'common.dynamicMsg',
params: { message: 'Reponse texte traitee' },
})
},
onError: (error: unknown) => {
const message =
error instanceof Error ? error.message : 'Impossible de traiter la reponse texte'
toastFor({
kind: 'error',
titleKey: 'common.dynamicMsg',
params: { message },
})
},
})
useEffect(() => {
if (!selectedConversationId) {
return
}
const isConversationVisible = filteredConversations.some(
(conversation) => conversation.id === selectedConversationId,
)
if (isConversationVisible) {
return
}
setSelectedConversationId(null)
setReplyContext(null)
if (isMobile) {
setView('conversations')
}
}, [filteredConversations, isMobile, selectedConversationId])
// BP-1 fix: buildConnectorLabel importé depuis ConnectorSwitcher (plus de render function inline)
const connectorWorkspaceSummary = selectedWorkspaceConnector
? buildConnectorLabel(selectedWorkspaceConnector)
: 'Aucun compte WhatsApp'
<<<<<<< HEAD
const renderConnectorWorkspaceSwitcher = (compact = false) => (
<div className={compact ? 'space-y-2' : 'space-y-2 min-w-[260px]'}>
<div className="flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Numéro WhatsApp
</p>
{selectedWorkspaceConnector && (
<Badge
variant="outline"
className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200"
>
{selectedWorkspaceConnector.status}
</Badge>
)}
</div>
<Select
value={selectedConnectorId}
onValueChange={(value) => {
setSelectedConnectorId(value)
setSearchTerm('')
if (isMobile) {
setView('conversations')
}
}}
disabled={connectorsLoading || whatsappConnectors.length === 0}
>
<SelectTrigger className={compact ? 'h-9' : 'h-10'}>
<SelectValue
placeholder={
connectorsLoading
? 'Chargement des comptes...'
: whatsappConnectors.length > 0
? 'Sélectionnez un compte WhatsApp'
: 'Aucun compte WhatsApp'
}
/>
</SelectTrigger>
<SelectContent>
{whatsappConnectors.map((connector) => (
<SelectItem key={connector.id} value={connector.id}>
{buildConnectorWorkspaceLabel(connector)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
const renderConversationActionBanners = () => {
if (!pendingTextReplyAttempt && !lateButtonReviewAttempt) {
return null
}
return (
<div className="flex-shrink-0 border-b border-gray-200 bg-white px-4 py-3 dark:border-gray-700 dark:bg-gray-800">
<div className="space-y-2">
{pendingTextReplyAttempt ? (
<TextReplyActionBanner
attempt={pendingTextReplyAttempt}
disabled={routeTextReplyMutation.isPending}
onApplyButton={(buttonId) => {
routeTextReplyMutation.mutate({
attemptId: pendingTextReplyAttempt.id,
action: 'apply_button',
buttonId,
})
}}
onDismiss={() => {
routeTextReplyMutation.mutate({
attemptId: pendingTextReplyAttempt.id,
action: 'dismiss',
})
}}
/>
) : null}
{lateButtonReviewAttempt ? (
<LateButtonReviewBanner attempt={lateButtonReviewAttempt} />
) : null}
</div>
</div>
)
}
if (isMobile) {
if (view === 'conversations') {
return (
<div className="h-screen flex flex-col overflow-hidden">
<div className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-center">
=======
// ─── Rendu unifié (#25) — un seul arbre JSX ─────────────────────────────
// L'état `view` ne fait plus que basculer des classes CSS sur mobile ; les
// variantes `md:` forcent l'affichage des 3 panneaux côte à côte en desktop.
// Fini les deux arbres dupliqués où chaque feature devait être câblée 2×.
const page = (
<>
<div className="flex h-full overflow-hidden bg-[#f0f2f5] dark:bg-gray-900">
{/* Panneau 1 — Liste des conversations */}
<aside
className={cn(
'h-full w-full flex-col overflow-hidden md:flex md:w-80 md:flex-shrink-0',
view === 'conversations' ? 'flex' : 'hidden',
)}
>
{/* Barre de titre — mobile uniquement (en desktop l'en-tête de l'app la fournit) */}
<div className="flex h-16 flex-shrink-0 items-center justify-center border-b border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800 md:hidden">
>>>>>>> develop
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
WhatsApp Chat
</h1>
</div>
<div className="min-h-0 flex-1">
<ConversationListPanel
connectors={whatsappConnectors}
selectedConnectorId={selectedConnectorId}
connectorsLoading={connectorsLoading}
onConnectorChange={(value) => {
setSelectedConnectorId(value)
setSearchTerm('')
}}
connectorSummary={connectorWorkspaceSummary}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onNewConversation={() => setShowNewConversation(true)}
statusFilter={statusFilter}
onFilterChange={setStatusFilter}
conversations={filteredConversations}
selectedConversationId={selectedConversationId}
conversationsLoading={conversationsLoading}
onSelectConversation={(id) => {
setSelectedConversationId(id)
setView('chat')
}}
/>
</div>
</aside>
<<<<<<< HEAD
<div className="flex-1 overflow-hidden">
<TabsContent value="info" className="h-full m-4 mt-4 overflow-y-auto">
<CustomerInfoPanel
customerPhone={selectedConversation.customerPhone}
customerName={selectedConversation.customerName}
/>
</TabsContent>
<TabsContent value="notes" className="h-full m-4 mt-4 overflow-y-auto">
<ConversationNotes conversationId={selectedConversation.id} />
</TabsContent>
<TabsContent value="calls" className="h-full m-4 mt-4 overflow-y-auto">
<ConversationCalls
conversationId={selectedConversation.id}
customerPhone={selectedConversation.customerPhone}
customerName={selectedConversation.customerName}
/>
</TabsContent>
<TabsContent value="media" className="h-full m-4 mt-4 overflow-y-auto">
<MediaHistory conversationId={selectedConversation.id} onClose={() => {}} />
</TabsContent>
<TabsContent value="management" className="h-full m-4 mt-4 overflow-y-auto">
<ConversationManagement
conversationId={selectedConversation.id}
currentConversation={selectedConversation}
onUpdate={() => {
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
}}
/>
</TabsContent>
</div>
</Tabs>
</div>
</div>
)
}
if (view === 'chat') {
return (
<div className="h-screen flex flex-col overflow-hidden">
{/* Chat content for mobile */}
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
{selectedConversation && (
<ConversationHeader
conversation={selectedConversation}
onBack={() => {
setView('conversations')
}}
showBackButton={true}
isOnline={true}
lastSeen={
selectedConversation.lastMessageAt
? format(new Date(selectedConversation.lastMessageAt), 'HH:mm', {
locale: fr,
})
: undefined
}
/>
)}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="text-gray-600 hover:text-[#00a884]"
>
<Phone className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
setView('sidebar')
}}
className="text-gray-600 hover:text-[#00a884]"
>
<Settings className="h-5 w-5" />
</Button>
</div>
</div>
</div>
</div>
{renderConversationActionBanners()}
{/* Messages Area */}
<div className="flex-1 overflow-hidden bg-[#e5ddd5] dark:bg-gray-900">
<div className="h-full overflow-y-auto p-4">
{messagesLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Chargement des messages...</div>
</div>
) : visibleConversationMessages.length > 0 ? (
visibleConversationMessages.map((message: WhatsappMessage, index: number) => (
<MessageBubble
key={message.id}
message={message}
quotedMessage={resolveQuotedMessage(message)}
reactions={messageReactions.get(resolveMessageIdentity(message)) || []}
currentUserReaction={
currentUserReactions.get(resolveMessageIdentity(message)) || null
}
isLast={index === visibleConversationMessages.length - 1}
onRetry={() => {
retryMessageMutation.mutate(message.id)
}}
onReact={handleReactionUpdate}
onReply={handleReplySelection}
onCopy={(content: string) => {
navigator.clipboard.writeText(content)
toastFor({
kind: 'success',
titleKey: 'common.dynamicMsg',
params: { message: 'Texte copié dans le presse-papiers' },
})
}}
onDelete={handleDelete}
onChooseTemplate={handleChooseTemplate}
/>
))
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<MessageCircle className="h-16 w-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Aucun message
</h3>
<p className="text-gray-500 dark:text-gray-400">
Commencez la conversation avec votre client
</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Message Input */}
<div className="flex-shrink-0">
<ChatInput
value={newMessage}
onChange={setNewMessage}
onSend={handleSendMessage}
onTemplateSend={handleTemplateSend}
onContentSend={handleContentSend}
onMediaSend={handleMediaSend}
onAudioSend={handleAudioSend}
isLoading={
sendMessageMutation.isPending ||
sendMediaMessageMutation.isPending ||
sendContentItemMutation.isPending
}
placeholder="Tapez votre message..."
conversationId={selectedConversationId || undefined}
replyContext={replyContext}
onCancelReply={() => {
setReplyContext(null)
}}
/>
</div>
</div>
{/* Mobile Sidebar Access */}
{showSidebar && (
<div className="fixed inset-0 bg-white dark:bg-gray-800 z-50">
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">Options</h2>
<Button
variant="ghost"
size="icon"
onClick={() => {
setShowSidebar(false)
}}
>
<ArrowLeft className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 overflow-hidden">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="h-full flex flex-col"
>
<TabsList className="grid w-full grid-cols-4 m-4 mb-0">
<TabsTrigger value="notes" className="text-xs">
<StickyNote className="h-4 w-4 mr-1" />
Notes
</TabsTrigger>
<TabsTrigger value="calls" className="text-xs">
<PhoneCall className="h-4 w-4 mr-1" />
Appels
</TabsTrigger>
<TabsTrigger value="media" className="text-xs">
<ImageIcon className="h-4 w-4 mr-1" />
Médias
</TabsTrigger>
<TabsTrigger value="management" className="text-xs">
<Settings className="h-4 w-4 mr-1" />
Gestion
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-hidden">
<TabsContent value="notes" className="h-full m-4 mt-4 overflow-y-auto">
{selectedConversation && (
<ConversationNotes conversationId={selectedConversation.id} />
)}
</TabsContent>
<TabsContent value="calls" className="h-full m-4 mt-4 overflow-y-auto">
{selectedConversation && (
<ConversationCalls
conversationId={selectedConversation.id}
customerPhone={selectedConversation.customerPhone}
customerName={selectedConversation.customerName}
/>
)}
</TabsContent>
<TabsContent value="media" className="h-full m-4 mt-4 overflow-y-auto">
{selectedConversation && (
<MediaHistory conversationId={selectedConversation.id} onClose={() => {}} />
)}
</TabsContent>
<TabsContent value="management" className="h-full m-4 mt-4 overflow-y-auto">
{selectedConversation && (
<ConversationManagement
conversationId={selectedConversation.id}
currentConversation={selectedConversation}
onUpdate={() => {
queryClient.invalidateQueries({
queryKey: ['/api/whatsapp/conversations'],
})
}}
/>
)}
</TabsContent>
</div>
</Tabs>
</div>
</div>
=======
{/* Panneau 2 — Zone de chat */}
<section
className={cn(
'h-full min-w-0 flex-1 flex-col overflow-hidden md:flex',
view === 'chat' ? 'flex' : 'hidden',
>>>>>>> develop
)}
>
{!selectedConversation ? (
<div className="flex flex-1 items-center justify-center bg-[#e5ddd5] dark:bg-gray-900">
<div className="p-8 text-center">
<MessageCircle className="mx-auto mb-4 h-16 w-16 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900 dark:text-gray-100">
{selectedWorkspaceConnector
? `Workspace ${selectedWorkspaceConnector.name}`
: 'WhatsApp Business pour Sendocki'}
</h3>
<p className="text-gray-500 dark:text-gray-400">
Sélectionnez une conversation pour commencer à discuter avec vos clients
</p>
</div>
</div>
) : (
<>
<<<<<<< HEAD
{/* Chat Header */}
<div className="flex-shrink-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="p-4">
<div className="flex items-center justify-between">
<ConversationHeader
conversation={selectedConversation}
onBack={undefined}
showBackButton={false}
isOnline={true}
lastSeen={
selectedConversation.lastMessageAt
? format(new Date(selectedConversation.lastMessageAt), 'HH:mm', {
locale: fr,
})
: undefined
}
/>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="text-gray-600 hover:text-[#00a884]"
>
<Phone className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => {
setShowSidebar(!showSidebar)
}}
className={`text-gray-600 hover:text-[#00a884] ${showSidebar ? 'text-[#00a884] bg-gray-100 dark:bg-gray-700' : ''}`}
title="Informations client"
>
<Settings className="h-5 w-5" />
</Button>
</div>
</div>
</div>
{/* Quick Actions Bar */}
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-t border-gray-100 dark:border-gray-600">
<div className="flex items-center gap-2">
<CreateOrderDialog
conversationId={selectedConversation.id}
customerName={selectedConversation.customerName}
customerPhone={selectedConversation.customerPhone}
customerId={selectedConversation.orderId}
>
<Button size="sm" variant="outline" className="gap-2 text-xs">
<ShoppingCart className="h-4 w-4" />
Créer commande
</Button>
</CreateOrderDialog>
</div>
</div>
</div>
{renderConversationActionBanners()}
{/* Messages Area */}
<div className="flex-1 overflow-hidden bg-[#e5ddd5] dark:bg-gray-900">
<div className="h-full overflow-y-auto p-4">
{messagesLoading ? (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Chargement des messages...</div>
</div>
) : visibleConversationMessages.length > 0 ? (
visibleConversationMessages.map((message: WhatsappMessage, index: number) => (
<MessageBubble
key={message.id}
message={message}
quotedMessage={resolveQuotedMessage(message)}
reactions={messageReactions.get(resolveMessageIdentity(message)) || []}
currentUserReaction={
currentUserReactions.get(resolveMessageIdentity(message)) || null
}
isLast={index === visibleConversationMessages.length - 1}
onRetry={() => {
retryMessageMutation.mutate(message.id)
}}
onReact={handleReactionUpdate}
onReply={handleReplySelection}
onCopy={(content: string) => {
navigator.clipboard.writeText(content)
toastFor({
kind: 'success',
titleKey: 'common.dynamicMsg',
params: { message: 'Texte copié dans le presse-papiers' },
})
}}
onDelete={handleDelete}
onChooseTemplate={handleChooseTemplate}
/>
))
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<MessageCircle className="h-16 w-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
Aucun message
</h3>
<p className="text-gray-500 dark:text-gray-400">
Commencez la conversation avec votre client
</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
{/* Message Input */}
=======
<ChatHeader
conversation={selectedConversation}
showBackButton={isMobile}
onBack={() => setView('conversations')}
sidebarActive={!isMobile && showSidebar}
onToggleSidebar={() => {
if (isMobile) {
setView('sidebar')
} else {
setShowSidebar((previous) => !previous)
}
}}
showQuickActions={!isMobile}
/>
{customerWindowExpired && (
<WindowWarningBanner onChooseTemplate={() => setNewMessage('/templates')} />
)}
<MessagesArea
messages={visibleConversationMessages}
isLoading={messagesLoading}
messageReactions={messageReactions}
currentUserReactions={currentUserReactions}
resolveQuotedMessage={resolveQuotedMessage}
conversationId={selectedConversationId}
onRetry={handleRetry}
onReact={handleReactionUpdate}
onReply={handleReplySelection}
onChooseTemplate={handleChooseTemplate}
onDelete={canDelete ? handleDelete : undefined}
/>
>>>>>>> develop
<div className="flex-shrink-0">
<ChatInput
value={newMessage}
onChange={setNewMessage}
onSend={handleSendMessage}
onTemplateSend={handleTemplateSend}
onContentSend={handleContentSend}
isLoading={
sendMessageMutation.isPending ||
sendMediaMessageMutation.isPending ||
sendContentItemMutation.isPending
}
placeholder="Tapez votre message..."
conversationId={selectedConversationId || undefined}
replyContext={replyContext}
onCancelReply={() => setReplyContext(null)}
/>
</div>
</>
)}
</section>
{/* Panneau 3 — Contexte client */}
<aside
className={cn(
'h-full w-full flex-col overflow-hidden bg-white dark:bg-gray-800 md:w-80 md:flex-shrink-0 md:border-l md:border-gray-200 md:dark:border-gray-700',
view === 'sidebar' && selectedConversation ? 'flex' : 'hidden',
showSidebar && selectedConversation ? 'md:flex' : 'md:hidden',
)}
>
{/* En-tête retour — mobile uniquement */}
{selectedConversation && (
<div className="flex h-16 flex-shrink-0 items-center border-b border-gray-200 px-4 dark:border-gray-700 md:hidden">
<Button variant="ghost" size="icon" onClick={() => setView('chat')} className="mr-2">
<ArrowLeft className="h-5 w-5" />
</Button>
<h1 className="truncate text-lg font-semibold text-gray-900 dark:text-gray-100">
{selectedConversation.customerName || selectedConversation.customerPhone}
</h1>
</div>
)}
<div className="min-h-0 flex-1 overflow-hidden">
{selectedConversation && (
<CustomerContextPanel
conversation={selectedConversation}
onUpdate={() =>
queryClient.invalidateQueries({ queryKey: ['/api/whatsapp/conversations'] })
}
/>
)}
</div>
</aside>
</div>
{/* Nouvelle conversation — Dialog autonome (gère son propre overlay, mobile + desktop) */}
<NewConversation
open={showNewConversation}
forcedConnectorId={selectedConnectorId || undefined}
onClose={() => {
setShowNewConversation(false)
}}
onConversationCreated={handleConversationCreated}
/>
</>
)
// Coquille extérieure : plein écran sur mobile, PageLayout (nav de l'app) sur desktop.
if (isMobile) {
return <div className="h-screen overflow-hidden">{page}</div>
}
return (
<PageLayout className="overflow-hidden p-0">
<div style={{ height: 'calc(100vh - 64px)' }}>{page}</div>
</PageLayout>
)
}
export default WhatsAppChatPage
Editor is loading...
Leave a Comment