UnifiedInbox.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755
  1. 'use client'
  2. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
  3. import { useRouter } from 'next/navigation'
  4. import Link from 'next/link'
  5. import { useTranslations } from 'next-intl'
  6. import { useNotificationStore } from '@/features/notifications/store/notificationStore'
  7. import { cn } from '@/lib/utils'
  8. import {
  9. ArrowLeft,
  10. ChevronDown,
  11. Inbox,
  12. Loader2,
  13. MailOpen,
  14. MoreVertical,
  15. Plus,
  16. Search,
  17. Trash2,
  18. } from 'lucide-react'
  19. import { Button } from '@/components/ui/button'
  20. import { Input } from '@/components/ui/input'
  21. import {
  22. Dialog,
  23. DialogContent,
  24. DialogDescription,
  25. DialogHeader,
  26. DialogTitle,
  27. } from '@/components/ui/dialog'
  28. import {
  29. AlertDialog,
  30. AlertDialogAction,
  31. AlertDialogCancel,
  32. AlertDialogContent,
  33. AlertDialogDescription,
  34. AlertDialogFooter,
  35. AlertDialogHeader,
  36. AlertDialogTitle,
  37. } from '@/components/ui/alert-dialog'
  38. import {
  39. DropdownMenu,
  40. DropdownMenuContent,
  41. DropdownMenuItem,
  42. DropdownMenuSeparator,
  43. DropdownMenuTrigger,
  44. } from '@/components/ui/dropdown-menu'
  45. import { toast } from 'sonner'
  46. import { deleteConversation, getConversation } from '@/features/sms/Actions/smsActions'
  47. import {
  48. deleteTelegramConversation,
  49. getTelegramConversation,
  50. } from '@/features/telegram/Actions/telegramActions'
  51. import { SmsConversation } from '@/features/sms/Components/SmsConversation'
  52. import { NewSmsDialog } from '@/features/sms/Components/NewSmsDialog'
  53. import { TelegramConversation } from '@/features/telegram/Components/TelegramConversation'
  54. import { WhatsappConversation } from '@/features/whatsapp/Components/WhatsappConversation'
  55. import { NewWhatsappDialog } from '@/features/whatsapp/Components/NewWhatsappDialog'
  56. import {
  57. deleteWhatsappConversation,
  58. deleteWhatsappConversationByPhone,
  59. } from '@/features/whatsapp/Actions/whatsappActions'
  60. import {
  61. getInboxThreads,
  62. markThreadRead,
  63. markThreadUnread,
  64. type EmailChannelState,
  65. type InboxThread,
  66. type MessagingChannel,
  67. } from '../Actions/inboxActions'
  68. import { deleteEmailConversation } from '../Actions/emailThreadActions'
  69. import { ChannelBadge, channelLabel } from './ChannelBadge'
  70. import { EmailConversation } from './EmailConversation'
  71. import { NewEmailDialog } from './NewEmailDialog'
  72. import { avatarTint, initials } from '../Lib/threadDisplay'
  73. import { useDebouncedSearch } from '@/hooks/use-debounced-search'
  74. /**
  75. * Every conversation, whatever it arrived on.
  76. *
  77. * A workshop has one relationship with a customer and several ways of reaching
  78. * them, so the list is one and the channel is a badge on the row. Replies go
  79. * back out on the channel the thread is on, which is why each row keeps its
  80. * own conversation component rather than sharing a compose box.
  81. */
  82. /**
  83. * A conversation named by the page's address, so a link can open it: a
  84. * notification about a message lands on that thread instead of on an inbox
  85. * with nothing selected. The name comes from the server, because the thread
  86. * may not be on the first page of the list.
  87. */
  88. export interface InboxFocus {
  89. channel: MessagingChannel
  90. customerId: string | null
  91. name: string
  92. contact: string
  93. }
  94. /** Channels a workshop can start a conversation on. */
  95. const INITIABLE: MessagingChannel[] = ['sms', 'whatsapp', 'email']
  96. /**
  97. * Taken from what the actions actually return rather than declared here.
  98. * Hand-written shapes plus a cast let this drift: the pane read a name and a
  99. * phone number the action never sent, so an SMS thread claimed the customer
  100. * had no number at all.
  101. */
  102. type SmsConversationData = NonNullable<Awaited<ReturnType<typeof getConversation>>['data']>
  103. type TelegramConversationData = NonNullable<
  104. Awaited<ReturnType<typeof getTelegramConversation>>['data']
  105. >
  106. export function UnifiedInbox({
  107. threads: initialThreads,
  108. initialCursor = null,
  109. channels,
  110. emailState,
  111. onChanged,
  112. focus = null,
  113. }: {
  114. threads: InboxThread[]
  115. /** Where the next page starts, or null when the first page is all of it. */
  116. initialCursor?: string | null
  117. channels: MessagingChannel[]
  118. /**
  119. * Where email replies land. With the workshop's own vendor they go to its
  120. * own mailbox, so email shows as a channel that is answered elsewhere.
  121. */
  122. emailState: EmailChannelState
  123. /** Asks the page to reload its server data after something is sent. */
  124. onChanged?: () => void
  125. /** The conversation the address asks for, opened on arrival. */
  126. focus?: InboxFocus | null
  127. }) {
  128. const t = useTranslations('messaging.inbox')
  129. const te = useTranslations('messaging.email')
  130. const router = useRouter()
  131. const [threads, setThreads] = useState(initialThreads)
  132. const [cursor, setCursor] = useState<string | null>(initialCursor)
  133. const [loadingMore, setLoadingMore] = useState(false)
  134. const sentinelRef = useRef<HTMLDivElement | null>(null)
  135. const [selected, setSelected] = useState<InboxThread | null>(null)
  136. const [loadingConversation, setLoadingConversation] = useState(false)
  137. const [smsData, setSmsData] = useState<SmsConversationData | null>(null)
  138. const [telegramData, setTelegramData] = useState<TelegramConversationData | null>(null)
  139. const [deleteTarget, setDeleteTarget] = useState<InboxThread | null>(null)
  140. const [isDeleting, setIsDeleting] = useState(false)
  141. const [showChannelChoice, setShowChannelChoice] = useState(false)
  142. const [composeChannel, setComposeChannel] = useState<MessagingChannel | null>(null)
  143. // Search runs on the server: filtering only what is loaded would quietly
  144. // answer "no results" for a conversation two pages down.
  145. const runSearch = useCallback(async (term?: string) => {
  146. setLoadingMore(true)
  147. const result = await getInboxThreads({ search: term })
  148. if (result.success && result.data) {
  149. setThreads(result.data.threads)
  150. setCursor(result.data.nextCursor)
  151. }
  152. setLoadingMore(false)
  153. }, [])
  154. const { value: search, setValue: setSearch } = useDebouncedSearch('', runSearch)
  155. // A send or a delete reloads the page's data; adopt it and start again.
  156. useEffect(() => {
  157. if (search.trim()) return
  158. setThreads(initialThreads)
  159. setCursor(initialCursor)
  160. }, [initialThreads, initialCursor, search])
  161. const loadMore = useCallback(async () => {
  162. if (!cursor || loadingMore) return
  163. setLoadingMore(true)
  164. const result = await getInboxThreads({ cursor, search: search.trim() || undefined })
  165. if (result.success && result.data) {
  166. const page = result.data
  167. setThreads((previous) => {
  168. const seen = new Set(previous.map((thread) => thread.key))
  169. return [...previous, ...page.threads.filter((thread) => !seen.has(thread.key))]
  170. })
  171. setCursor(page.nextCursor)
  172. }
  173. setLoadingMore(false)
  174. }, [cursor, loadingMore, search])
  175. // Continuous scroll: the button below stays for keyboards and for the case
  176. // where the observer never fires.
  177. useEffect(() => {
  178. const sentinel = sentinelRef.current
  179. if (!sentinel || !cursor) return
  180. const observer = new IntersectionObserver(
  181. (entries) => {
  182. if (entries[0]?.isIntersecting) loadMore()
  183. },
  184. { rootMargin: '200px' }
  185. )
  186. observer.observe(sentinel)
  187. return () => observer.disconnect()
  188. }, [cursor, loadMore])
  189. const visibleThreads = threads
  190. /**
  191. * Opening a thread is reading it. The row loses its marker at once, the
  192. * server is told, and the layout is refreshed so the sidebar pill follows.
  193. */
  194. const markRead = useCallback(
  195. async (thread: InboxThread) => {
  196. if (thread.unread === 0) return
  197. setThreads((previous) =>
  198. previous.map((row) => (row.key === thread.key ? { ...row, unread: 0 } : row))
  199. )
  200. const result = await markThreadRead({
  201. channel: thread.channel,
  202. customerId: thread.customerId,
  203. contact: thread.contact,
  204. })
  205. if (result.success && result.data && result.data.marked > 0) router.refresh()
  206. },
  207. [router]
  208. )
  209. /**
  210. * The open thread as the list now has it. The list is replaced whenever
  211. * the page's data reloads, and a reply that arrived meanwhile shows there
  212. * first: a newer `lastAt` on the open row means the conversation has
  213. * something to re-read, and that it has been read by being on screen.
  214. */
  215. const current = useMemo(
  216. () => (selected ? (threads.find((row) => row.key === selected.key) ?? selected) : null),
  217. [threads, selected]
  218. )
  219. const seen = useRef<{ key: string; lastAt: string } | null>(null)
  220. useEffect(() => {
  221. if (!current) {
  222. seen.current = null
  223. return
  224. }
  225. if (seen.current?.key !== current.key) {
  226. // Opening it marked it read already.
  227. seen.current = { key: current.key, lastAt: current.lastAt }
  228. return
  229. }
  230. if (seen.current.lastAt === current.lastAt) return
  231. seen.current = { key: current.key, lastAt: current.lastAt }
  232. if (current.unread > 0) void markRead(current)
  233. }, [current, markRead])
  234. // A message arriving on any channel is announced to the workshop's
  235. // notification feed over the socket. That is the inbox's cue to reload
  236. // its data; the feed's own first fill, of older notifications, is not.
  237. const latest = useNotificationStore((state) => state.notifications[0])
  238. const mountedAt = useRef(Date.now())
  239. const announced = useRef<string | null>(null)
  240. useEffect(() => {
  241. if (!latest || announced.current === latest.id) return
  242. announced.current = latest.id
  243. if (!latest.type.endsWith('_inbound')) return
  244. if (new Date(latest.createdAt).getTime() < mountedAt.current - 60_000) return
  245. onChanged?.()
  246. }, [latest, onChanged])
  247. /**
  248. * The opposite gesture, for "come back to this later". The thread stays
  249. * open; it is only the marker and the pill that come back. Selecting it
  250. * again reads it again, which is what a mail client does too.
  251. */
  252. const markUnread = useCallback(
  253. async (thread: InboxThread) => {
  254. const result = await markThreadUnread({
  255. channel: thread.channel,
  256. customerId: thread.customerId,
  257. contact: thread.contact,
  258. })
  259. if (!result.success) {
  260. toast.error(result.error ?? t('markUnreadFailed'))
  261. return
  262. }
  263. if (!result.data || result.data.marked === 0) {
  264. toast.info(t('nothingToMarkUnread'))
  265. return
  266. }
  267. setThreads((previous) =>
  268. previous.map((row) => (row.key === thread.key ? { ...row, unread: 1 } : row))
  269. )
  270. router.refresh()
  271. },
  272. [router, t]
  273. )
  274. /**
  275. * SMS and Telegram hand their history over as props, so it loads on select.
  276. * WhatsApp and email load their own.
  277. */
  278. const select = useCallback(
  279. async (thread: InboxThread) => {
  280. setSelected(thread)
  281. setSmsData(null)
  282. setTelegramData(null)
  283. void markRead(thread)
  284. if (!thread.customerId) return
  285. if (thread.channel === 'sms') {
  286. setLoadingConversation(true)
  287. const result = await getConversation(thread.customerId)
  288. if (result.success && result.data) setSmsData(result.data)
  289. setLoadingConversation(false)
  290. } else if (thread.channel === 'telegram') {
  291. setLoadingConversation(true)
  292. const result = await getTelegramConversation(thread.customerId)
  293. if (result.success && result.data) setTelegramData(result.data)
  294. setLoadingConversation(false)
  295. }
  296. },
  297. [markRead]
  298. )
  299. // Opened whenever the address names a different conversation, which is also
  300. // what a notification clicked while this page is already open does.
  301. const focusKey = focus ? `${focus.channel}:${focus.customerId ?? focus.contact}` : null
  302. useEffect(() => {
  303. if (!focus || !focusKey) return
  304. const existing = threads.find(
  305. (thread) =>
  306. thread.channel === focus.channel &&
  307. (focus.customerId
  308. ? thread.customerId === focus.customerId
  309. : !thread.customerId && thread.contact === focus.contact)
  310. )
  311. select(
  312. existing ?? {
  313. key: focusKey,
  314. channel: focus.channel,
  315. customerId: focus.customerId,
  316. name: focus.name,
  317. contact: focus.contact,
  318. lastMessage: '',
  319. lastDirection: 'inbound',
  320. lastAt: new Date(0).toISOString(),
  321. unread: 0,
  322. }
  323. )
  324. }, [focusKey])
  325. /** Removes our copy of a conversation; the customer's phone keeps theirs. */
  326. const confirmDelete = async () => {
  327. const thread = deleteTarget
  328. if (!thread) return
  329. setIsDeleting(true)
  330. // A WhatsApp thread can exist under a bare number, and those are the ones
  331. // most worth clearing away: test sends and wrong numbers. Email threads
  332. // are filed by customer or by address, and one action handles both.
  333. const result =
  334. thread.channel === 'email'
  335. ? await deleteEmailConversation({ customerId: thread.customerId, contact: thread.contact })
  336. : !thread.customerId
  337. ? await deleteWhatsappConversationByPhone(thread.contact)
  338. : thread.channel === 'sms'
  339. ? await deleteConversation(thread.customerId)
  340. : thread.channel === 'telegram'
  341. ? await deleteTelegramConversation(thread.customerId)
  342. : await deleteWhatsappConversation(thread.customerId)
  343. setIsDeleting(false)
  344. setDeleteTarget(null)
  345. if (result.success) {
  346. if (selected?.key === thread.key) setSelected(null)
  347. onChanged?.()
  348. } else {
  349. toast.error(result.error ?? t('deleteFailed'))
  350. }
  351. }
  352. const startCompose = () => {
  353. const options = channels.filter((channel) => INITIABLE.includes(channel))
  354. // The dialog is where a workshop on its own email vendor learns why email
  355. // is not offered, so it is always shown to them, even with one choice.
  356. if (options.length === 1 && emailState !== 'ownVendor') {
  357. setComposeChannel(options[0])
  358. return
  359. }
  360. setShowChannelChoice(true)
  361. }
  362. const openThreadFor = (
  363. channel: MessagingChannel,
  364. customer: { id: string; name: string; phone?: string | null; email?: string | null }
  365. ) => {
  366. const existing = threads.find(
  367. (thread) => thread.channel === channel && thread.customerId === customer.id
  368. )
  369. select(
  370. existing ?? {
  371. key: `${channel}:${customer.id}`,
  372. channel,
  373. customerId: customer.id,
  374. name: customer.name,
  375. contact: (channel === 'email' ? customer.email : customer.phone) ?? '',
  376. lastMessage: '',
  377. lastDirection: 'outbound',
  378. lastAt: new Date(0).toISOString(),
  379. unread: 0,
  380. }
  381. )
  382. }
  383. const composeOptions = channels.filter((channel) => INITIABLE.includes(channel))
  384. const canCompose = composeOptions.length > 0 || emailState === 'ownVendor'
  385. return (
  386. <>
  387. {/* Thread list — full width on mobile, hidden when a conversation is open */}
  388. <div
  389. className={cn(
  390. 'flex w-full shrink-0 flex-col sm:w-80 sm:border-r',
  391. selected ? 'hidden sm:flex' : 'flex'
  392. )}
  393. >
  394. <div className="shrink-0 space-y-2 border-b px-3 py-2.5">
  395. <div className="flex items-center justify-between gap-2">
  396. <div className="flex items-center gap-2">
  397. <h2 className="text-sm font-semibold">{t('title')}</h2>
  398. <span className="text-xs tabular-nums text-muted-foreground">{threads.length}</span>
  399. </div>
  400. {/* Starting a conversation used to be offered only by the empty
  401. state, so it disappeared as soon as there was anything to read. */}
  402. {canCompose && (
  403. <Button size="sm" variant="ghost" className="h-7 px-2" onClick={startCompose}>
  404. <Plus className="mr-1 h-3.5 w-3.5" />
  405. {t('newMessage')}
  406. </Button>
  407. )}
  408. </div>
  409. <div className="relative">
  410. <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
  411. <Input
  412. value={search}
  413. onChange={(event) => setSearch(event.target.value)}
  414. placeholder={t('searchPlaceholder')}
  415. className="h-8 pl-8 text-sm"
  416. />
  417. </div>
  418. </div>
  419. <div className="min-h-0 flex-1 overflow-y-auto">
  420. {visibleThreads.length === 0 ? (
  421. <div className="flex flex-col items-center justify-center px-4 py-12">
  422. <Inbox className="mb-3 h-10 w-10 text-muted-foreground/40" />
  423. <p className="text-center text-sm text-muted-foreground">
  424. {search ? t('noSearchResults') : t('empty')}
  425. </p>
  426. {!search && canCompose && (
  427. <Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
  428. <Plus className="mr-1 h-3.5 w-3.5" />
  429. {t('newMessage')}
  430. </Button>
  431. )}
  432. </div>
  433. ) : (
  434. visibleThreads.map((thread) => {
  435. const isInbound = thread.lastDirection === 'inbound'
  436. const isUnread = thread.unread > 0
  437. return (
  438. <button
  439. key={thread.key}
  440. type="button"
  441. onClick={() => select(thread)}
  442. className={cn(
  443. 'w-full border-b px-3 py-2.5 text-left transition-colors hover:bg-muted/50',
  444. selected?.key === thread.key && 'bg-muted'
  445. )}
  446. >
  447. <div className="flex items-center gap-3">
  448. <div
  449. className={cn(
  450. 'flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
  451. avatarTint(thread.customerId ?? thread.contact)
  452. )}
  453. >
  454. {initials(thread.name)}
  455. </div>
  456. <div className="min-w-0 flex-1">
  457. <div className="flex items-baseline justify-between gap-2">
  458. <span
  459. className={cn(
  460. 'truncate text-sm',
  461. isUnread ? 'font-semibold' : 'font-medium'
  462. )}
  463. >
  464. {thread.name}
  465. </span>
  466. <ChannelBadge channel={thread.channel} />
  467. </div>
  468. <div className="mt-0.5 flex items-center gap-2">
  469. <p
  470. className={cn(
  471. 'min-w-0 flex-1 truncate text-xs',
  472. isInbound ? 'text-foreground' : 'text-muted-foreground',
  473. isUnread && 'font-medium'
  474. )}
  475. >
  476. {thread.lastMessage}
  477. </p>
  478. {/* Waiting messages get a primary dot; the count is
  479. on the sidebar, where it sums every thread. */}
  480. {isUnread && (
  481. <span
  482. aria-label={t('unread', { count: thread.unread })}
  483. className="size-2 shrink-0 rounded-full bg-primary"
  484. />
  485. )}
  486. </div>
  487. </div>
  488. </div>
  489. </button>
  490. )
  491. })
  492. )}
  493. {cursor && (
  494. <div ref={sentinelRef} className="p-3">
  495. <Button
  496. variant="outline"
  497. size="sm"
  498. className="w-full"
  499. onClick={loadMore}
  500. disabled={loadingMore}
  501. >
  502. {loadingMore ? (
  503. <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
  504. ) : (
  505. <ChevronDown className="mr-2 h-3.5 w-3.5" />
  506. )}
  507. {t('loadMore')}
  508. </Button>
  509. </div>
  510. )}
  511. </div>
  512. </div>
  513. {/* Conversation pane */}
  514. <div className={cn('flex min-w-0 flex-1 flex-col', selected ? 'flex' : 'hidden sm:flex')}>
  515. {selected ? (
  516. <>
  517. <div className="flex shrink-0 items-center gap-3 border-b px-4 py-2.5">
  518. <Button
  519. variant="ghost"
  520. size="icon"
  521. className="h-8 w-8 shrink-0 sm:hidden"
  522. onClick={() => setSelected(null)}
  523. aria-label={t('back')}
  524. >
  525. <ArrowLeft className="h-4 w-4" />
  526. </Button>
  527. <div className="min-w-0 flex-1">
  528. <div className="flex items-center gap-2">
  529. <span className="truncate text-sm font-medium">{selected.name}</span>
  530. <ChannelBadge channel={selected.channel} withIcon />
  531. </div>
  532. <p className="truncate text-xs text-muted-foreground">{selected.contact}</p>
  533. </div>
  534. {(selected.customerId ||
  535. selected.channel === 'whatsapp' ||
  536. selected.channel === 'email') && (
  537. <DropdownMenu>
  538. <DropdownMenuTrigger asChild>
  539. <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
  540. <MoreVertical className="h-4 w-4" />
  541. </Button>
  542. </DropdownMenuTrigger>
  543. <DropdownMenuContent align="end">
  544. <DropdownMenuItem onClick={() => markUnread(selected)}>
  545. <MailOpen className="mr-2 h-4 w-4" />
  546. {t('markUnread')}
  547. </DropdownMenuItem>
  548. <DropdownMenuSeparator />
  549. <DropdownMenuItem
  550. className="text-destructive"
  551. onClick={() => setDeleteTarget(selected)}
  552. >
  553. <Trash2 className="mr-2 h-4 w-4" />
  554. {t('deleteConversation')}
  555. </DropdownMenuItem>
  556. </DropdownMenuContent>
  557. </DropdownMenu>
  558. )}
  559. </div>
  560. {loadingConversation ? (
  561. <div className="flex flex-1 items-center justify-center">
  562. <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
  563. </div>
  564. ) : selected.channel === 'whatsapp' ? (
  565. <WhatsappConversation
  566. key={selected.key}
  567. thread={{
  568. key: selected.key,
  569. customerId: selected.customerId,
  570. name: selected.name,
  571. phone: selected.contact,
  572. }}
  573. onSent={onChanged}
  574. className="h-auto min-h-0 flex-1"
  575. />
  576. ) : selected.channel === 'email' ? (
  577. <EmailConversation
  578. key={selected.key}
  579. thread={{
  580. key: selected.key,
  581. customerId: selected.customerId,
  582. name: selected.name,
  583. address: selected.contact,
  584. }}
  585. emailState={emailState}
  586. onSent={onChanged}
  587. refreshKey={current?.lastAt ?? selected.lastAt}
  588. className="h-auto min-h-0 flex-1"
  589. />
  590. ) : selected.channel === 'sms' && smsData && selected.customerId ? (
  591. <SmsConversation
  592. key={selected.key}
  593. customerId={selected.customerId}
  594. customerName={smsData.customerName}
  595. customerPhone={smsData.customerPhone}
  596. initialMessages={smsData.messages}
  597. initialNextCursor={smsData.nextCursor}
  598. className="h-auto min-h-0 flex-1"
  599. />
  600. ) : selected.channel === 'telegram' && telegramData && selected.customerId ? (
  601. <TelegramConversation
  602. key={selected.key}
  603. customerId={selected.customerId}
  604. customerName={telegramData.customerName}
  605. telegramChatId={telegramData.telegramChatId}
  606. initialMessages={telegramData.messages}
  607. initialNextCursor={telegramData.nextCursor}
  608. className="h-auto min-h-0 flex-1"
  609. />
  610. ) : (
  611. <div className="flex flex-1 items-center justify-center px-4 text-center text-sm text-muted-foreground">
  612. {t('unknownContact')}
  613. </div>
  614. )}
  615. </>
  616. ) : (
  617. <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
  618. <Inbox className="mb-3 h-10 w-10 text-muted-foreground/40" />
  619. <p className="text-sm">{t('selectConversation')}</p>
  620. {canCompose && (
  621. <Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
  622. <Plus className="mr-1 h-3.5 w-3.5" />
  623. {t('newMessage')}
  624. </Button>
  625. )}
  626. </div>
  627. )}
  628. </div>
  629. {/* Which channel, asked when there is a choice to make or when email
  630. has to explain that it is answered elsewhere */}
  631. <Dialog open={showChannelChoice} onOpenChange={setShowChannelChoice}>
  632. <DialogContent className="sm:max-w-sm">
  633. <DialogHeader>
  634. <DialogTitle>{t('chooseChannel.title')}</DialogTitle>
  635. <DialogDescription>{t('chooseChannel.description')}</DialogDescription>
  636. </DialogHeader>
  637. <div className="space-y-2">
  638. {composeOptions.map((channel) => (
  639. <Button
  640. key={channel}
  641. type="button"
  642. variant="outline"
  643. className="w-full justify-start"
  644. onClick={() => {
  645. setShowChannelChoice(false)
  646. setComposeChannel(channel)
  647. }}
  648. >
  649. <ChannelBadge channel={channel} withIcon className="mr-2" />
  650. {channelLabel(channel)}
  651. </Button>
  652. ))}
  653. {/* Shown, not hidden: a workshop that expects email here should
  654. learn why it is missing rather than wonder. */}
  655. {emailState === 'ownVendor' && (
  656. <div className="space-y-1.5">
  657. <Button type="button" variant="outline" className="w-full justify-start" disabled>
  658. <ChannelBadge channel="email" withIcon className="mr-2" />
  659. {channelLabel('email')}
  660. </Button>
  661. <p className="px-1 text-xs text-muted-foreground">
  662. {te('ownVendorChoice')}
  663. <Link
  664. href="/settings/integrations"
  665. className="ml-1 font-medium text-primary hover:underline"
  666. onClick={() => setShowChannelChoice(false)}
  667. >
  668. {te('ownVendorSettings')}
  669. </Link>
  670. </p>
  671. </div>
  672. )}
  673. </div>
  674. </DialogContent>
  675. </Dialog>
  676. <AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
  677. <AlertDialogContent>
  678. <AlertDialogHeader>
  679. <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
  680. <AlertDialogDescription>
  681. {t('deleteDescription', { name: deleteTarget?.name ?? '' })}
  682. </AlertDialogDescription>
  683. </AlertDialogHeader>
  684. <AlertDialogFooter>
  685. <AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
  686. <AlertDialogAction
  687. onClick={confirmDelete}
  688. disabled={isDeleting}
  689. className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
  690. >
  691. {isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
  692. {t('delete')}
  693. </AlertDialogAction>
  694. </AlertDialogFooter>
  695. </AlertDialogContent>
  696. </AlertDialog>
  697. <NewSmsDialog
  698. open={composeChannel === 'sms'}
  699. onOpenChange={(open) => !open && setComposeChannel(null)}
  700. onSent={(customerId) => {
  701. onChanged?.()
  702. const thread = threads.find((t) => t.channel === 'sms' && t.customerId === customerId)
  703. if (thread) select(thread)
  704. }}
  705. />
  706. <NewWhatsappDialog
  707. open={composeChannel === 'whatsapp'}
  708. onOpenChange={(open) => !open && setComposeChannel(null)}
  709. onSelect={(recipient) => openThreadFor('whatsapp', recipient)}
  710. />
  711. <NewEmailDialog
  712. open={composeChannel === 'email'}
  713. onOpenChange={(open) => !open && setComposeChannel(null)}
  714. onSelect={(recipient) => openThreadFor('email', recipient)}
  715. />
  716. </>
  717. )
  718. }