| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755 |
- 'use client'
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
- import { useRouter } from 'next/navigation'
- import Link from 'next/link'
- import { useTranslations } from 'next-intl'
- import { useNotificationStore } from '@/features/notifications/store/notificationStore'
- import { cn } from '@/lib/utils'
- import {
- ArrowLeft,
- ChevronDown,
- Inbox,
- Loader2,
- MailOpen,
- MoreVertical,
- Plus,
- Search,
- Trash2,
- } from 'lucide-react'
- import { Button } from '@/components/ui/button'
- import { Input } from '@/components/ui/input'
- import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- } from '@/components/ui/dialog'
- import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
- } from '@/components/ui/alert-dialog'
- import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- } from '@/components/ui/dropdown-menu'
- import { toast } from 'sonner'
- import { deleteConversation, getConversation } from '@/features/sms/Actions/smsActions'
- import {
- deleteTelegramConversation,
- getTelegramConversation,
- } from '@/features/telegram/Actions/telegramActions'
- import { SmsConversation } from '@/features/sms/Components/SmsConversation'
- import { NewSmsDialog } from '@/features/sms/Components/NewSmsDialog'
- import { TelegramConversation } from '@/features/telegram/Components/TelegramConversation'
- import { WhatsappConversation } from '@/features/whatsapp/Components/WhatsappConversation'
- import { NewWhatsappDialog } from '@/features/whatsapp/Components/NewWhatsappDialog'
- import {
- deleteWhatsappConversation,
- deleteWhatsappConversationByPhone,
- } from '@/features/whatsapp/Actions/whatsappActions'
- import {
- getInboxThreads,
- markThreadRead,
- markThreadUnread,
- type EmailChannelState,
- type InboxThread,
- type MessagingChannel,
- } from '../Actions/inboxActions'
- import { deleteEmailConversation } from '../Actions/emailThreadActions'
- import { ChannelBadge, channelLabel } from './ChannelBadge'
- import { EmailConversation } from './EmailConversation'
- import { NewEmailDialog } from './NewEmailDialog'
- import { avatarTint, initials } from '../Lib/threadDisplay'
- import { useDebouncedSearch } from '@/hooks/use-debounced-search'
- /**
- * Every conversation, whatever it arrived on.
- *
- * A workshop has one relationship with a customer and several ways of reaching
- * them, so the list is one and the channel is a badge on the row. Replies go
- * back out on the channel the thread is on, which is why each row keeps its
- * own conversation component rather than sharing a compose box.
- */
- /**
- * A conversation named by the page's address, so a link can open it: a
- * notification about a message lands on that thread instead of on an inbox
- * with nothing selected. The name comes from the server, because the thread
- * may not be on the first page of the list.
- */
- export interface InboxFocus {
- channel: MessagingChannel
- customerId: string | null
- name: string
- contact: string
- }
- /** Channels a workshop can start a conversation on. */
- const INITIABLE: MessagingChannel[] = ['sms', 'whatsapp', 'email']
- /**
- * Taken from what the actions actually return rather than declared here.
- * Hand-written shapes plus a cast let this drift: the pane read a name and a
- * phone number the action never sent, so an SMS thread claimed the customer
- * had no number at all.
- */
- type SmsConversationData = NonNullable<Awaited<ReturnType<typeof getConversation>>['data']>
- type TelegramConversationData = NonNullable<
- Awaited<ReturnType<typeof getTelegramConversation>>['data']
- >
- export function UnifiedInbox({
- threads: initialThreads,
- initialCursor = null,
- channels,
- emailState,
- onChanged,
- focus = null,
- }: {
- threads: InboxThread[]
- /** Where the next page starts, or null when the first page is all of it. */
- initialCursor?: string | null
- channels: MessagingChannel[]
- /**
- * Where email replies land. With the workshop's own vendor they go to its
- * own mailbox, so email shows as a channel that is answered elsewhere.
- */
- emailState: EmailChannelState
- /** Asks the page to reload its server data after something is sent. */
- onChanged?: () => void
- /** The conversation the address asks for, opened on arrival. */
- focus?: InboxFocus | null
- }) {
- const t = useTranslations('messaging.inbox')
- const te = useTranslations('messaging.email')
- const router = useRouter()
- const [threads, setThreads] = useState(initialThreads)
- const [cursor, setCursor] = useState<string | null>(initialCursor)
- const [loadingMore, setLoadingMore] = useState(false)
- const sentinelRef = useRef<HTMLDivElement | null>(null)
- const [selected, setSelected] = useState<InboxThread | null>(null)
- const [loadingConversation, setLoadingConversation] = useState(false)
- const [smsData, setSmsData] = useState<SmsConversationData | null>(null)
- const [telegramData, setTelegramData] = useState<TelegramConversationData | null>(null)
- const [deleteTarget, setDeleteTarget] = useState<InboxThread | null>(null)
- const [isDeleting, setIsDeleting] = useState(false)
- const [showChannelChoice, setShowChannelChoice] = useState(false)
- const [composeChannel, setComposeChannel] = useState<MessagingChannel | null>(null)
- // Search runs on the server: filtering only what is loaded would quietly
- // answer "no results" for a conversation two pages down.
- const runSearch = useCallback(async (term?: string) => {
- setLoadingMore(true)
- const result = await getInboxThreads({ search: term })
- if (result.success && result.data) {
- setThreads(result.data.threads)
- setCursor(result.data.nextCursor)
- }
- setLoadingMore(false)
- }, [])
- const { value: search, setValue: setSearch } = useDebouncedSearch('', runSearch)
- // A send or a delete reloads the page's data; adopt it and start again.
- useEffect(() => {
- if (search.trim()) return
- setThreads(initialThreads)
- setCursor(initialCursor)
- }, [initialThreads, initialCursor, search])
- const loadMore = useCallback(async () => {
- if (!cursor || loadingMore) return
- setLoadingMore(true)
- const result = await getInboxThreads({ cursor, search: search.trim() || undefined })
- if (result.success && result.data) {
- const page = result.data
- setThreads((previous) => {
- const seen = new Set(previous.map((thread) => thread.key))
- return [...previous, ...page.threads.filter((thread) => !seen.has(thread.key))]
- })
- setCursor(page.nextCursor)
- }
- setLoadingMore(false)
- }, [cursor, loadingMore, search])
- // Continuous scroll: the button below stays for keyboards and for the case
- // where the observer never fires.
- useEffect(() => {
- const sentinel = sentinelRef.current
- if (!sentinel || !cursor) return
- const observer = new IntersectionObserver(
- (entries) => {
- if (entries[0]?.isIntersecting) loadMore()
- },
- { rootMargin: '200px' }
- )
- observer.observe(sentinel)
- return () => observer.disconnect()
- }, [cursor, loadMore])
- const visibleThreads = threads
- /**
- * Opening a thread is reading it. The row loses its marker at once, the
- * server is told, and the layout is refreshed so the sidebar pill follows.
- */
- const markRead = useCallback(
- async (thread: InboxThread) => {
- if (thread.unread === 0) return
- setThreads((previous) =>
- previous.map((row) => (row.key === thread.key ? { ...row, unread: 0 } : row))
- )
- const result = await markThreadRead({
- channel: thread.channel,
- customerId: thread.customerId,
- contact: thread.contact,
- })
- if (result.success && result.data && result.data.marked > 0) router.refresh()
- },
- [router]
- )
- /**
- * The open thread as the list now has it. The list is replaced whenever
- * the page's data reloads, and a reply that arrived meanwhile shows there
- * first: a newer `lastAt` on the open row means the conversation has
- * something to re-read, and that it has been read by being on screen.
- */
- const current = useMemo(
- () => (selected ? (threads.find((row) => row.key === selected.key) ?? selected) : null),
- [threads, selected]
- )
- const seen = useRef<{ key: string; lastAt: string } | null>(null)
- useEffect(() => {
- if (!current) {
- seen.current = null
- return
- }
- if (seen.current?.key !== current.key) {
- // Opening it marked it read already.
- seen.current = { key: current.key, lastAt: current.lastAt }
- return
- }
- if (seen.current.lastAt === current.lastAt) return
- seen.current = { key: current.key, lastAt: current.lastAt }
- if (current.unread > 0) void markRead(current)
- }, [current, markRead])
- // A message arriving on any channel is announced to the workshop's
- // notification feed over the socket. That is the inbox's cue to reload
- // its data; the feed's own first fill, of older notifications, is not.
- const latest = useNotificationStore((state) => state.notifications[0])
- const mountedAt = useRef(Date.now())
- const announced = useRef<string | null>(null)
- useEffect(() => {
- if (!latest || announced.current === latest.id) return
- announced.current = latest.id
- if (!latest.type.endsWith('_inbound')) return
- if (new Date(latest.createdAt).getTime() < mountedAt.current - 60_000) return
- onChanged?.()
- }, [latest, onChanged])
- /**
- * The opposite gesture, for "come back to this later". The thread stays
- * open; it is only the marker and the pill that come back. Selecting it
- * again reads it again, which is what a mail client does too.
- */
- const markUnread = useCallback(
- async (thread: InboxThread) => {
- const result = await markThreadUnread({
- channel: thread.channel,
- customerId: thread.customerId,
- contact: thread.contact,
- })
- if (!result.success) {
- toast.error(result.error ?? t('markUnreadFailed'))
- return
- }
- if (!result.data || result.data.marked === 0) {
- toast.info(t('nothingToMarkUnread'))
- return
- }
- setThreads((previous) =>
- previous.map((row) => (row.key === thread.key ? { ...row, unread: 1 } : row))
- )
- router.refresh()
- },
- [router, t]
- )
- /**
- * SMS and Telegram hand their history over as props, so it loads on select.
- * WhatsApp and email load their own.
- */
- const select = useCallback(
- async (thread: InboxThread) => {
- setSelected(thread)
- setSmsData(null)
- setTelegramData(null)
- void markRead(thread)
- if (!thread.customerId) return
- if (thread.channel === 'sms') {
- setLoadingConversation(true)
- const result = await getConversation(thread.customerId)
- if (result.success && result.data) setSmsData(result.data)
- setLoadingConversation(false)
- } else if (thread.channel === 'telegram') {
- setLoadingConversation(true)
- const result = await getTelegramConversation(thread.customerId)
- if (result.success && result.data) setTelegramData(result.data)
- setLoadingConversation(false)
- }
- },
- [markRead]
- )
- // Opened whenever the address names a different conversation, which is also
- // what a notification clicked while this page is already open does.
- const focusKey = focus ? `${focus.channel}:${focus.customerId ?? focus.contact}` : null
- useEffect(() => {
- if (!focus || !focusKey) return
- const existing = threads.find(
- (thread) =>
- thread.channel === focus.channel &&
- (focus.customerId
- ? thread.customerId === focus.customerId
- : !thread.customerId && thread.contact === focus.contact)
- )
- select(
- existing ?? {
- key: focusKey,
- channel: focus.channel,
- customerId: focus.customerId,
- name: focus.name,
- contact: focus.contact,
- lastMessage: '',
- lastDirection: 'inbound',
- lastAt: new Date(0).toISOString(),
- unread: 0,
- }
- )
- }, [focusKey])
- /** Removes our copy of a conversation; the customer's phone keeps theirs. */
- const confirmDelete = async () => {
- const thread = deleteTarget
- if (!thread) return
- setIsDeleting(true)
- // A WhatsApp thread can exist under a bare number, and those are the ones
- // most worth clearing away: test sends and wrong numbers. Email threads
- // are filed by customer or by address, and one action handles both.
- const result =
- thread.channel === 'email'
- ? await deleteEmailConversation({ customerId: thread.customerId, contact: thread.contact })
- : !thread.customerId
- ? await deleteWhatsappConversationByPhone(thread.contact)
- : thread.channel === 'sms'
- ? await deleteConversation(thread.customerId)
- : thread.channel === 'telegram'
- ? await deleteTelegramConversation(thread.customerId)
- : await deleteWhatsappConversation(thread.customerId)
- setIsDeleting(false)
- setDeleteTarget(null)
- if (result.success) {
- if (selected?.key === thread.key) setSelected(null)
- onChanged?.()
- } else {
- toast.error(result.error ?? t('deleteFailed'))
- }
- }
- const startCompose = () => {
- const options = channels.filter((channel) => INITIABLE.includes(channel))
- // The dialog is where a workshop on its own email vendor learns why email
- // is not offered, so it is always shown to them, even with one choice.
- if (options.length === 1 && emailState !== 'ownVendor') {
- setComposeChannel(options[0])
- return
- }
- setShowChannelChoice(true)
- }
- const openThreadFor = (
- channel: MessagingChannel,
- customer: { id: string; name: string; phone?: string | null; email?: string | null }
- ) => {
- const existing = threads.find(
- (thread) => thread.channel === channel && thread.customerId === customer.id
- )
- select(
- existing ?? {
- key: `${channel}:${customer.id}`,
- channel,
- customerId: customer.id,
- name: customer.name,
- contact: (channel === 'email' ? customer.email : customer.phone) ?? '',
- lastMessage: '',
- lastDirection: 'outbound',
- lastAt: new Date(0).toISOString(),
- unread: 0,
- }
- )
- }
- const composeOptions = channels.filter((channel) => INITIABLE.includes(channel))
- const canCompose = composeOptions.length > 0 || emailState === 'ownVendor'
- return (
- <>
- {/* Thread list — full width on mobile, hidden when a conversation is open */}
- <div
- className={cn(
- 'flex w-full shrink-0 flex-col sm:w-80 sm:border-r',
- selected ? 'hidden sm:flex' : 'flex'
- )}
- >
- <div className="shrink-0 space-y-2 border-b px-3 py-2.5">
- <div className="flex items-center justify-between gap-2">
- <div className="flex items-center gap-2">
- <h2 className="text-sm font-semibold">{t('title')}</h2>
- <span className="text-xs tabular-nums text-muted-foreground">{threads.length}</span>
- </div>
- {/* Starting a conversation used to be offered only by the empty
- state, so it disappeared as soon as there was anything to read. */}
- {canCompose && (
- <Button size="sm" variant="ghost" className="h-7 px-2" onClick={startCompose}>
- <Plus className="mr-1 h-3.5 w-3.5" />
- {t('newMessage')}
- </Button>
- )}
- </div>
- <div className="relative">
- <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
- <Input
- value={search}
- onChange={(event) => setSearch(event.target.value)}
- placeholder={t('searchPlaceholder')}
- className="h-8 pl-8 text-sm"
- />
- </div>
- </div>
- <div className="min-h-0 flex-1 overflow-y-auto">
- {visibleThreads.length === 0 ? (
- <div className="flex flex-col items-center justify-center px-4 py-12">
- <Inbox className="mb-3 h-10 w-10 text-muted-foreground/40" />
- <p className="text-center text-sm text-muted-foreground">
- {search ? t('noSearchResults') : t('empty')}
- </p>
- {!search && canCompose && (
- <Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
- <Plus className="mr-1 h-3.5 w-3.5" />
- {t('newMessage')}
- </Button>
- )}
- </div>
- ) : (
- visibleThreads.map((thread) => {
- const isInbound = thread.lastDirection === 'inbound'
- const isUnread = thread.unread > 0
- return (
- <button
- key={thread.key}
- type="button"
- onClick={() => select(thread)}
- className={cn(
- 'w-full border-b px-3 py-2.5 text-left transition-colors hover:bg-muted/50',
- selected?.key === thread.key && 'bg-muted'
- )}
- >
- <div className="flex items-center gap-3">
- <div
- className={cn(
- 'flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
- avatarTint(thread.customerId ?? thread.contact)
- )}
- >
- {initials(thread.name)}
- </div>
- <div className="min-w-0 flex-1">
- <div className="flex items-baseline justify-between gap-2">
- <span
- className={cn(
- 'truncate text-sm',
- isUnread ? 'font-semibold' : 'font-medium'
- )}
- >
- {thread.name}
- </span>
- <ChannelBadge channel={thread.channel} />
- </div>
- <div className="mt-0.5 flex items-center gap-2">
- <p
- className={cn(
- 'min-w-0 flex-1 truncate text-xs',
- isInbound ? 'text-foreground' : 'text-muted-foreground',
- isUnread && 'font-medium'
- )}
- >
- {thread.lastMessage}
- </p>
- {/* Waiting messages get a primary dot; the count is
- on the sidebar, where it sums every thread. */}
- {isUnread && (
- <span
- aria-label={t('unread', { count: thread.unread })}
- className="size-2 shrink-0 rounded-full bg-primary"
- />
- )}
- </div>
- </div>
- </div>
- </button>
- )
- })
- )}
- {cursor && (
- <div ref={sentinelRef} className="p-3">
- <Button
- variant="outline"
- size="sm"
- className="w-full"
- onClick={loadMore}
- disabled={loadingMore}
- >
- {loadingMore ? (
- <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
- ) : (
- <ChevronDown className="mr-2 h-3.5 w-3.5" />
- )}
- {t('loadMore')}
- </Button>
- </div>
- )}
- </div>
- </div>
- {/* Conversation pane */}
- <div className={cn('flex min-w-0 flex-1 flex-col', selected ? 'flex' : 'hidden sm:flex')}>
- {selected ? (
- <>
- <div className="flex shrink-0 items-center gap-3 border-b px-4 py-2.5">
- <Button
- variant="ghost"
- size="icon"
- className="h-8 w-8 shrink-0 sm:hidden"
- onClick={() => setSelected(null)}
- aria-label={t('back')}
- >
- <ArrowLeft className="h-4 w-4" />
- </Button>
- <div className="min-w-0 flex-1">
- <div className="flex items-center gap-2">
- <span className="truncate text-sm font-medium">{selected.name}</span>
- <ChannelBadge channel={selected.channel} withIcon />
- </div>
- <p className="truncate text-xs text-muted-foreground">{selected.contact}</p>
- </div>
- {(selected.customerId ||
- selected.channel === 'whatsapp' ||
- selected.channel === 'email') && (
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0">
- <MoreVertical className="h-4 w-4" />
- </Button>
- </DropdownMenuTrigger>
- <DropdownMenuContent align="end">
- <DropdownMenuItem onClick={() => markUnread(selected)}>
- <MailOpen className="mr-2 h-4 w-4" />
- {t('markUnread')}
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem
- className="text-destructive"
- onClick={() => setDeleteTarget(selected)}
- >
- <Trash2 className="mr-2 h-4 w-4" />
- {t('deleteConversation')}
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- )}
- </div>
- {loadingConversation ? (
- <div className="flex flex-1 items-center justify-center">
- <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
- </div>
- ) : selected.channel === 'whatsapp' ? (
- <WhatsappConversation
- key={selected.key}
- thread={{
- key: selected.key,
- customerId: selected.customerId,
- name: selected.name,
- phone: selected.contact,
- }}
- onSent={onChanged}
- className="h-auto min-h-0 flex-1"
- />
- ) : selected.channel === 'email' ? (
- <EmailConversation
- key={selected.key}
- thread={{
- key: selected.key,
- customerId: selected.customerId,
- name: selected.name,
- address: selected.contact,
- }}
- emailState={emailState}
- onSent={onChanged}
- refreshKey={current?.lastAt ?? selected.lastAt}
- className="h-auto min-h-0 flex-1"
- />
- ) : selected.channel === 'sms' && smsData && selected.customerId ? (
- <SmsConversation
- key={selected.key}
- customerId={selected.customerId}
- customerName={smsData.customerName}
- customerPhone={smsData.customerPhone}
- initialMessages={smsData.messages}
- initialNextCursor={smsData.nextCursor}
- className="h-auto min-h-0 flex-1"
- />
- ) : selected.channel === 'telegram' && telegramData && selected.customerId ? (
- <TelegramConversation
- key={selected.key}
- customerId={selected.customerId}
- customerName={telegramData.customerName}
- telegramChatId={telegramData.telegramChatId}
- initialMessages={telegramData.messages}
- initialNextCursor={telegramData.nextCursor}
- className="h-auto min-h-0 flex-1"
- />
- ) : (
- <div className="flex flex-1 items-center justify-center px-4 text-center text-sm text-muted-foreground">
- {t('unknownContact')}
- </div>
- )}
- </>
- ) : (
- <div className="flex flex-1 flex-col items-center justify-center text-muted-foreground">
- <Inbox className="mb-3 h-10 w-10 text-muted-foreground/40" />
- <p className="text-sm">{t('selectConversation')}</p>
- {canCompose && (
- <Button size="sm" variant="outline" className="mt-3" onClick={startCompose}>
- <Plus className="mr-1 h-3.5 w-3.5" />
- {t('newMessage')}
- </Button>
- )}
- </div>
- )}
- </div>
- {/* Which channel, asked when there is a choice to make or when email
- has to explain that it is answered elsewhere */}
- <Dialog open={showChannelChoice} onOpenChange={setShowChannelChoice}>
- <DialogContent className="sm:max-w-sm">
- <DialogHeader>
- <DialogTitle>{t('chooseChannel.title')}</DialogTitle>
- <DialogDescription>{t('chooseChannel.description')}</DialogDescription>
- </DialogHeader>
- <div className="space-y-2">
- {composeOptions.map((channel) => (
- <Button
- key={channel}
- type="button"
- variant="outline"
- className="w-full justify-start"
- onClick={() => {
- setShowChannelChoice(false)
- setComposeChannel(channel)
- }}
- >
- <ChannelBadge channel={channel} withIcon className="mr-2" />
- {channelLabel(channel)}
- </Button>
- ))}
- {/* Shown, not hidden: a workshop that expects email here should
- learn why it is missing rather than wonder. */}
- {emailState === 'ownVendor' && (
- <div className="space-y-1.5">
- <Button type="button" variant="outline" className="w-full justify-start" disabled>
- <ChannelBadge channel="email" withIcon className="mr-2" />
- {channelLabel('email')}
- </Button>
- <p className="px-1 text-xs text-muted-foreground">
- {te('ownVendorChoice')}
- <Link
- href="/settings/integrations"
- className="ml-1 font-medium text-primary hover:underline"
- onClick={() => setShowChannelChoice(false)}
- >
- {te('ownVendorSettings')}
- </Link>
- </p>
- </div>
- )}
- </div>
- </DialogContent>
- </Dialog>
- <AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
- <AlertDialogDescription>
- {t('deleteDescription', { name: deleteTarget?.name ?? '' })}
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isDeleting}>{t('cancel')}</AlertDialogCancel>
- <AlertDialogAction
- onClick={confirmDelete}
- disabled={isDeleting}
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
- >
- {isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- {t('delete')}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- <NewSmsDialog
- open={composeChannel === 'sms'}
- onOpenChange={(open) => !open && setComposeChannel(null)}
- onSent={(customerId) => {
- onChanged?.()
- const thread = threads.find((t) => t.channel === 'sms' && t.customerId === customerId)
- if (thread) select(thread)
- }}
- />
- <NewWhatsappDialog
- open={composeChannel === 'whatsapp'}
- onOpenChange={(open) => !open && setComposeChannel(null)}
- onSelect={(recipient) => openThreadFor('whatsapp', recipient)}
- />
- <NewEmailDialog
- open={composeChannel === 'email'}
- onOpenChange={(open) => !open && setComposeChannel(null)}
- onSelect={(recipient) => openThreadFor('email', recipient)}
- />
- </>
- )
- }
|