@fayz-ai/plugin-conversations 0.2.4 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ConversationsContext.d.ts +18 -1
- package/dist/ConversationsContext.d.ts.map +1 -1
- package/dist/ConversationsPage.d.ts +3 -1
- package/dist/ConversationsPage.d.ts.map +1 -1
- package/dist/data/accents.d.ts +3 -0
- package/dist/data/accents.d.ts.map +1 -0
- package/dist/data/mock.d.ts +7 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/mock.test.d.ts +2 -0
- package/dist/data/mock.test.d.ts.map +1 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +5 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/types.d.ts +2 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +664 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +667 -73
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/index.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +7 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/store.d.ts +2 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/ContactPanel.d.ts.map +1 -1
- package/dist/views/ConversationList.d.ts.map +1 -1
- package/dist/views/InboxView.d.ts.map +1 -1
- package/dist/views/MessageThread.d.ts.map +1 -1
- package/dist/views/NewConversationModal.d.ts +6 -0
- package/dist/views/NewConversationModal.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/ConversationsContext.tsx +31 -1
- package/src/ConversationsPage.tsx +6 -3
- package/src/data/accents.ts +12 -0
- package/src/data/mock.test.ts +90 -0
- package/src/data/mock.ts +131 -12
- package/src/data/supabase.ts +69 -7
- package/src/data/tables.ts +7 -0
- package/src/data/types.ts +2 -0
- package/src/index.ts +69 -11
- package/src/locales/en.ts +64 -0
- package/src/locales/index.ts +2 -0
- package/src/locales/pt-BR.ts +68 -0
- package/src/migrations/001_conversations.sql +74 -0
- package/src/migrations/002_contact_person.sql +22 -0
- package/src/migrations/index.ts +108 -0
- package/src/store.ts +44 -1
- package/src/types.ts +19 -0
- package/src/views/ContactPanel.tsx +14 -12
- package/src/views/ConversationList.tsx +48 -21
- package/src/views/InboxView.tsx +3 -1
- package/src/views/MessageThread.tsx +14 -16
- package/src/views/NewConversationModal.tsx +204 -0
package/src/store.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { createStore, type StoreApi } from 'zustand/vanilla'
|
|
2
2
|
import type { ConversationsProvider } from './data/types'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Conversation,
|
|
5
|
+
Message,
|
|
6
|
+
Channel,
|
|
7
|
+
ConversationStatus,
|
|
8
|
+
CreateConversationInput,
|
|
9
|
+
} from './types'
|
|
4
10
|
|
|
5
11
|
export interface ConversationsUIState {
|
|
6
12
|
conversations: Conversation[]
|
|
@@ -16,6 +22,7 @@ export interface ConversationsUIState {
|
|
|
16
22
|
deselect(): void
|
|
17
23
|
setChannelFilter(channel: Channel | 'all'): Promise<void>
|
|
18
24
|
setSearch(search: string): Promise<void>
|
|
25
|
+
create(input: CreateConversationInput): Promise<Conversation>
|
|
19
26
|
send(body: string): Promise<void>
|
|
20
27
|
setStatus(status: ConversationStatus): Promise<void>
|
|
21
28
|
}
|
|
@@ -67,6 +74,42 @@ export function createConversationsStore(
|
|
|
67
74
|
await get().load()
|
|
68
75
|
},
|
|
69
76
|
|
|
77
|
+
async create(input) {
|
|
78
|
+
const created = await provider.createConversation(input)
|
|
79
|
+
// The thread EXISTS the moment the insert returns, so resolve on that and
|
|
80
|
+
// show it optimistically. Clear filters that would hide it, prepend it,
|
|
81
|
+
// select it — all local.
|
|
82
|
+
set((s) => ({
|
|
83
|
+
channelFilter: 'all',
|
|
84
|
+
search: '',
|
|
85
|
+
conversations: [created, ...s.conversations.filter((c) => c.id !== created.id)],
|
|
86
|
+
selectedId: created.id,
|
|
87
|
+
}))
|
|
88
|
+
|
|
89
|
+
// Reconciliation (authoritative list + the thread's messages) runs in the
|
|
90
|
+
// BACKGROUND. It used to be awaited here, which made the caller — the
|
|
91
|
+
// compose modal — hostage to two extra round-trips: on a slow pool the
|
|
92
|
+
// conversation was already in the database while the modal sat open
|
|
93
|
+
// looking broken, inviting a second click and a duplicate thread.
|
|
94
|
+
// Failures are non-fatal: the optimistic row above already renders.
|
|
95
|
+
void (async () => {
|
|
96
|
+
try {
|
|
97
|
+
const conversations = await provider.listConversations({})
|
|
98
|
+
// Read-after-write guard: a refetch that races the insert may not
|
|
99
|
+
// return the new row yet — keep the local one rather than dropping it.
|
|
100
|
+
const merged = conversations.some((c) => c.id === created.id)
|
|
101
|
+
? conversations
|
|
102
|
+
: [created, ...conversations]
|
|
103
|
+
set({ conversations: merged })
|
|
104
|
+
await get().select(created.id)
|
|
105
|
+
} catch {
|
|
106
|
+
/* optimistic state stands; the next load() reconciles */
|
|
107
|
+
}
|
|
108
|
+
})()
|
|
109
|
+
|
|
110
|
+
return created
|
|
111
|
+
},
|
|
112
|
+
|
|
70
113
|
async send(body: string) {
|
|
71
114
|
const id = get().selectedId
|
|
72
115
|
if (!id || !body.trim()) return
|
package/src/types.ts
CHANGED
|
@@ -12,6 +12,12 @@ export type MessageDirection = 'inbound' | 'outbound'
|
|
|
12
12
|
export interface Conversation {
|
|
13
13
|
id: string
|
|
14
14
|
contactName: string
|
|
15
|
+
/**
|
|
16
|
+
* `public.people` id when the thread is tied to a real contact record (the
|
|
17
|
+
* compose modal resolves one via the shared ContactPicker). Absent for legacy
|
|
18
|
+
* threads and for inbound messages from an unknown handle.
|
|
19
|
+
*/
|
|
20
|
+
contactPersonId?: string
|
|
15
21
|
/** Phone, @handle, or email depending on channel */
|
|
16
22
|
contactHandle: string
|
|
17
23
|
channel: Channel
|
|
@@ -49,6 +55,19 @@ export interface SendMessageInput {
|
|
|
49
55
|
body: string
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
export interface CreateConversationInput {
|
|
59
|
+
contactName: string
|
|
60
|
+
/** `public.people` id, when the contact was resolved/created by the picker. */
|
|
61
|
+
contactPersonId?: string
|
|
62
|
+
/** Phone, @handle, or email depending on channel. */
|
|
63
|
+
contactHandle?: string
|
|
64
|
+
channel: Channel
|
|
65
|
+
/** Optional first outbound message; stamps preview + last_message_at. */
|
|
66
|
+
firstMessage?: string
|
|
67
|
+
/** Optional free-text note surfaced in the contact panel. */
|
|
68
|
+
note?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
export const CHANNEL_LABELS: Record<Channel, string> = {
|
|
53
72
|
sms: 'SMS',
|
|
54
73
|
whatsapp: 'WhatsApp',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { MapPin, User, Tag, Link2, StickyNote, X } from 'lucide-react'
|
|
3
3
|
import { Button, cn } from '@fayz-ai/ui'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
4
5
|
import { CHANNEL_LABELS } from '../channel'
|
|
5
6
|
import type { Conversation } from '../types'
|
|
6
7
|
import { Avatar, ChannelBadge } from './shared'
|
|
@@ -25,12 +26,13 @@ export function ContactPanel({ contact, onClose, className }: {
|
|
|
25
26
|
onClose?: () => void
|
|
26
27
|
className?: string
|
|
27
28
|
}) {
|
|
29
|
+
const t = useTranslation()
|
|
28
30
|
return (
|
|
29
31
|
<aside className={cn('flex shrink-0 flex-col overflow-y-auto border-l border-border bg-card', className)}>
|
|
30
32
|
{onClose && (
|
|
31
33
|
<div className="flex items-center justify-between border-b border-border px-3 py-2 xl:hidden">
|
|
32
|
-
<span className="text-sm font-semibold text-foreground">
|
|
33
|
-
<Button variant="ghost" size="icon" onClick={onClose} aria-label=
|
|
34
|
+
<span className="text-sm font-semibold text-foreground">{t('conversations.contact.details')}</span>
|
|
35
|
+
<Button variant="ghost" size="icon" onClick={onClose} aria-label={t('conversations.contact.closeDetails')}>
|
|
34
36
|
<X className="h-4 w-4" />
|
|
35
37
|
</Button>
|
|
36
38
|
</div>
|
|
@@ -44,25 +46,25 @@ export function ContactPanel({ contact, onClose, className }: {
|
|
|
44
46
|
<ChannelBadge channel={contact.channel} />
|
|
45
47
|
</div>
|
|
46
48
|
|
|
47
|
-
<Section icon={User} title=
|
|
49
|
+
<Section icon={User} title={t('conversations.contact.details')}>
|
|
48
50
|
<dl className="space-y-1.5 text-sm">
|
|
49
51
|
<div className="flex items-center justify-between gap-2">
|
|
50
|
-
<dt className="text-muted-foreground">
|
|
52
|
+
<dt className="text-muted-foreground">{t('conversations.contact.channel')}</dt>
|
|
51
53
|
<dd className="text-foreground">{CHANNEL_LABELS[contact.channel]}</dd>
|
|
52
54
|
</div>
|
|
53
55
|
<div className="flex items-center justify-between gap-2">
|
|
54
|
-
<dt className="text-muted-foreground">
|
|
55
|
-
<dd className="
|
|
56
|
+
<dt className="text-muted-foreground">{t('conversations.contact.status')}</dt>
|
|
57
|
+
<dd className="text-foreground">{t(`conversations.status.${contact.status}`)}</dd>
|
|
56
58
|
</div>
|
|
57
59
|
{contact.assignedTo && (
|
|
58
60
|
<div className="flex items-center justify-between gap-2">
|
|
59
|
-
<dt className="text-muted-foreground">
|
|
61
|
+
<dt className="text-muted-foreground">{t('conversations.contact.assignedTo')}</dt>
|
|
60
62
|
<dd className="text-foreground">{contact.assignedTo}</dd>
|
|
61
63
|
</div>
|
|
62
64
|
)}
|
|
63
65
|
{contact.location && (
|
|
64
66
|
<div className="flex items-center justify-between gap-2">
|
|
65
|
-
<dt className="flex items-center gap-1 text-muted-foreground"><MapPin className="h-3 w-3" />
|
|
67
|
+
<dt className="flex items-center gap-1 text-muted-foreground"><MapPin className="h-3 w-3" /> {t('conversations.contact.location')}</dt>
|
|
66
68
|
<dd className="text-foreground">{contact.location}</dd>
|
|
67
69
|
</div>
|
|
68
70
|
)}
|
|
@@ -70,7 +72,7 @@ export function ContactPanel({ contact, onClose, className }: {
|
|
|
70
72
|
</Section>
|
|
71
73
|
|
|
72
74
|
{contact.tags.length > 0 && (
|
|
73
|
-
<Section icon={Tag} title=
|
|
75
|
+
<Section icon={Tag} title={t('conversations.contact.tags')}>
|
|
74
76
|
<div className="flex flex-wrap gap-1.5">
|
|
75
77
|
{contact.tags.map((tag) => (
|
|
76
78
|
<span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-foreground">
|
|
@@ -82,13 +84,13 @@ export function ContactPanel({ contact, onClose, className }: {
|
|
|
82
84
|
)}
|
|
83
85
|
|
|
84
86
|
{contact.note && (
|
|
85
|
-
<Section icon={StickyNote} title=
|
|
87
|
+
<Section icon={StickyNote} title={t('conversations.contact.note')}>
|
|
86
88
|
<p className="text-sm text-foreground">{contact.note}</p>
|
|
87
89
|
</Section>
|
|
88
90
|
)}
|
|
89
91
|
|
|
90
|
-
<Section icon={Link2} title=
|
|
91
|
-
<p className="text-xs text-muted-foreground">
|
|
92
|
+
<Section icon={Link2} title={t('conversations.contact.linkedRecords')}>
|
|
93
|
+
<p className="text-xs text-muted-foreground">{t('conversations.contact.noLinkedRecords')}</p>
|
|
92
94
|
</Section>
|
|
93
95
|
</aside>
|
|
94
96
|
)
|
|
@@ -1,63 +1,86 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { Search, Inbox as InboxIcon } from 'lucide-react'
|
|
3
|
-
import { Input, cn } from '@fayz-ai/ui'
|
|
2
|
+
import { Search, Inbox as InboxIcon, Plus } from 'lucide-react'
|
|
3
|
+
import { Button, Input, Skeleton, cn } from '@fayz-ai/ui'
|
|
4
|
+
import { PermissionGate } from '@fayz-ai/saas'
|
|
5
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
4
6
|
import { useConversationsStore } from '../ConversationsContext'
|
|
5
7
|
import type { Channel } from '../types'
|
|
6
8
|
import { Avatar, ChannelBadge, relativeTime } from './shared'
|
|
9
|
+
import { NewConversationModal } from './NewConversationModal'
|
|
7
10
|
|
|
8
|
-
const FILTERS: Array<
|
|
9
|
-
{ id: 'all', label: 'All' },
|
|
10
|
-
{ id: 'whatsapp', label: 'WhatsApp' },
|
|
11
|
-
{ id: 'sms', label: 'SMS' },
|
|
12
|
-
{ id: 'instagram', label: 'Instagram' },
|
|
13
|
-
{ id: 'email', label: 'Email' },
|
|
14
|
-
{ id: 'webchat', label: 'Web' },
|
|
15
|
-
]
|
|
11
|
+
const FILTERS: Array<Channel | 'all'> = ['all', 'whatsapp', 'sms', 'instagram', 'email', 'webchat']
|
|
16
12
|
|
|
17
13
|
export function ConversationList({ className }: { className?: string }) {
|
|
14
|
+
const t = useTranslation()
|
|
18
15
|
const {
|
|
19
16
|
conversations, selectedId, channelFilter, search, loading,
|
|
20
17
|
select, setChannelFilter, setSearch,
|
|
21
18
|
} = useConversationsStore((s) => s)
|
|
19
|
+
const [newOpen, setNewOpen] = React.useState(false)
|
|
22
20
|
|
|
23
21
|
return (
|
|
24
22
|
<aside className={cn('w-full shrink-0 flex-col border-r border-border bg-card lg:w-[320px]', className)}>
|
|
25
23
|
<div className="border-b border-border px-3 py-3">
|
|
24
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
25
|
+
<span className="text-sm font-semibold text-foreground">{t('conversations.title')}</span>
|
|
26
|
+
<PermissionGate feature="conversations" action="create">
|
|
27
|
+
<Button
|
|
28
|
+
size="sm"
|
|
29
|
+
onClick={() => setNewOpen(true)}
|
|
30
|
+
aria-label={t('conversations.list.new')}
|
|
31
|
+
data-testid="conversations-new"
|
|
32
|
+
>
|
|
33
|
+
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
|
34
|
+
<span className="hidden sm:inline">{t('conversations.list.new')}</span>
|
|
35
|
+
</Button>
|
|
36
|
+
</PermissionGate>
|
|
37
|
+
</div>
|
|
26
38
|
<div className="relative">
|
|
27
39
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
28
40
|
<Input
|
|
29
41
|
value={search}
|
|
30
42
|
onChange={(e) => setSearch(e.target.value)}
|
|
31
|
-
placeholder=
|
|
43
|
+
placeholder={t('conversations.list.search')}
|
|
32
44
|
className="pl-8"
|
|
33
45
|
/>
|
|
34
46
|
</div>
|
|
35
47
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
36
|
-
{FILTERS.map((
|
|
48
|
+
{FILTERS.map((id) => (
|
|
37
49
|
<button
|
|
38
|
-
key={
|
|
39
|
-
onClick={() => setChannelFilter(
|
|
50
|
+
key={id}
|
|
51
|
+
onClick={() => setChannelFilter(id)}
|
|
40
52
|
className={cn(
|
|
41
53
|
'rounded-full px-2.5 py-1 text-xs font-medium transition-colors',
|
|
42
|
-
channelFilter ===
|
|
54
|
+
channelFilter === id
|
|
43
55
|
? 'bg-primary text-primary-foreground'
|
|
44
56
|
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
|
45
57
|
)}
|
|
46
58
|
>
|
|
47
|
-
{
|
|
59
|
+
{t(`conversations.filter.${id}`)}
|
|
48
60
|
</button>
|
|
49
61
|
))}
|
|
50
62
|
</div>
|
|
51
63
|
</div>
|
|
52
64
|
|
|
53
65
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
54
|
-
{loading && conversations.length === 0 &&
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
{loading && conversations.length === 0 &&
|
|
67
|
+
Array.from({ length: 6 }, (_, i) => (
|
|
68
|
+
<div key={i} className="flex w-full items-start gap-3 border-b border-border/50 px-3 py-3">
|
|
69
|
+
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
|
70
|
+
<div className="min-w-0 flex-1">
|
|
71
|
+
<div className="flex items-center justify-between gap-2">
|
|
72
|
+
<Skeleton className="h-4 w-28" />
|
|
73
|
+
<Skeleton className="h-3 w-8" />
|
|
74
|
+
</div>
|
|
75
|
+
<Skeleton className="mt-1.5 h-4 w-16 rounded-full" />
|
|
76
|
+
<Skeleton className="mt-1.5 h-3 w-3/4" />
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
))}
|
|
57
80
|
{!loading && conversations.length === 0 && (
|
|
58
81
|
<div className="flex flex-col items-center gap-2 p-8 text-center text-muted-foreground">
|
|
59
82
|
<InboxIcon className="h-6 w-6" />
|
|
60
|
-
<p className="text-sm">
|
|
83
|
+
<p className="text-sm">{t('conversations.list.empty')}</p>
|
|
61
84
|
</div>
|
|
62
85
|
)}
|
|
63
86
|
{conversations.map((c) => {
|
|
@@ -85,7 +108,9 @@ export function ConversationList({ className }: { className?: string }) {
|
|
|
85
108
|
<div className="mt-1 flex items-center gap-2">
|
|
86
109
|
<ChannelBadge channel={c.channel} />
|
|
87
110
|
{c.status !== 'open' && (
|
|
88
|
-
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
|
111
|
+
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
|
112
|
+
{t(`conversations.status.${c.status}`)}
|
|
113
|
+
</span>
|
|
89
114
|
)}
|
|
90
115
|
</div>
|
|
91
116
|
<div className="mt-1 flex items-center justify-between gap-2">
|
|
@@ -103,6 +128,8 @@ export function ConversationList({ className }: { className?: string }) {
|
|
|
103
128
|
)
|
|
104
129
|
})}
|
|
105
130
|
</div>
|
|
131
|
+
|
|
132
|
+
<NewConversationModal open={newOpen} onOpenChange={setNewOpen} />
|
|
106
133
|
</aside>
|
|
107
134
|
)
|
|
108
135
|
}
|
package/src/views/InboxView.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
2
|
import { MessageSquare } from 'lucide-react'
|
|
3
3
|
import { cn } from '@fayz-ai/ui'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
4
5
|
import { useConversationsStore } from '../ConversationsContext'
|
|
5
6
|
import { ConversationList } from './ConversationList'
|
|
6
7
|
import { MessageThread } from './MessageThread'
|
|
@@ -8,6 +9,7 @@ import { ContactPanel } from './ContactPanel'
|
|
|
8
9
|
import { useMediaQuery } from './shared'
|
|
9
10
|
|
|
10
11
|
export function InboxView() {
|
|
12
|
+
const t = useTranslation()
|
|
11
13
|
const { conversations, selectedId, deselect } = useConversationsStore((s) => s)
|
|
12
14
|
// xl+ shows the contact panel inline (three panes); below that it's an
|
|
13
15
|
// on-demand slide-over so the list + thread keep room to breathe.
|
|
@@ -38,7 +40,7 @@ export function InboxView() {
|
|
|
38
40
|
) : (
|
|
39
41
|
<section className="hidden min-w-0 flex-1 flex-col items-center justify-center bg-muted/20 text-muted-foreground lg:flex">
|
|
40
42
|
<MessageSquare className="h-9 w-9" />
|
|
41
|
-
<p className="mt-2 text-sm">
|
|
43
|
+
<p className="mt-2 text-sm">{t('conversations.empty.select')}</p>
|
|
42
44
|
</section>
|
|
43
45
|
)}
|
|
44
46
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { Send, Clock, Archive, PanelRight, MessageSquare,
|
|
2
|
+
import { Send, Clock, Archive, PanelRight, MessageSquare, ChevronLeft } from 'lucide-react'
|
|
3
3
|
import { Button, cn } from '@fayz-ai/ui'
|
|
4
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
4
5
|
import { useConversationsStore } from '../ConversationsContext'
|
|
5
6
|
import { CHANNEL_ACCENT, CHANNEL_LABELS } from '../channel'
|
|
6
7
|
import type { Conversation, Message } from '../types'
|
|
@@ -37,6 +38,7 @@ export function MessageThread({ selected, onTogglePanel, panelOpen, onBack, clas
|
|
|
37
38
|
onBack?: () => void
|
|
38
39
|
className?: string
|
|
39
40
|
}) {
|
|
41
|
+
const t = useTranslation()
|
|
40
42
|
const { messages, sending, send, setStatus } = useConversationsStore((s) => s)
|
|
41
43
|
const [draft, setDraft] = React.useState('')
|
|
42
44
|
const threadRef = React.useRef<HTMLDivElement>(null)
|
|
@@ -61,7 +63,7 @@ export function MessageThread({ selected, onTogglePanel, panelOpen, onBack, clas
|
|
|
61
63
|
<div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-2.5 md:px-5">
|
|
62
64
|
<div className="flex min-w-0 items-center gap-2 md:gap-3">
|
|
63
65
|
{onBack && (
|
|
64
|
-
<Button variant="ghost" size="icon" className="-ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label=
|
|
66
|
+
<Button variant="ghost" size="icon" className="-ml-1 shrink-0 lg:hidden" onClick={onBack} aria-label={t('conversations.thread.back')}>
|
|
65
67
|
<ChevronLeft className="h-5 w-5" />
|
|
66
68
|
</Button>
|
|
67
69
|
)}
|
|
@@ -75,17 +77,17 @@ export function MessageThread({ selected, onTogglePanel, panelOpen, onBack, clas
|
|
|
75
77
|
</div>
|
|
76
78
|
</div>
|
|
77
79
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
78
|
-
<Button variant="outline" size="sm" onClick={() => setStatus('snoozed')} aria-label=
|
|
79
|
-
<Clock className="h-3.5 w-3.5 sm:mr-1" /> <span className="hidden sm:inline">
|
|
80
|
+
<Button variant="outline" size="sm" onClick={() => setStatus('snoozed')} aria-label={t('conversations.thread.snooze')}>
|
|
81
|
+
<Clock className="h-3.5 w-3.5 sm:mr-1" /> <span className="hidden sm:inline">{t('conversations.thread.snooze')}</span>
|
|
80
82
|
</Button>
|
|
81
|
-
<Button variant="outline" size="sm" onClick={() => setStatus('closed')} aria-label=
|
|
82
|
-
<Archive className="h-3.5 w-3.5 sm:mr-1" /> <span className="hidden sm:inline">
|
|
83
|
+
<Button variant="outline" size="sm" onClick={() => setStatus('closed')} aria-label={t('conversations.thread.close')}>
|
|
84
|
+
<Archive className="h-3.5 w-3.5 sm:mr-1" /> <span className="hidden sm:inline">{t('conversations.thread.close')}</span>
|
|
83
85
|
</Button>
|
|
84
86
|
<Button
|
|
85
87
|
variant={panelOpen ? 'secondary' : 'ghost'}
|
|
86
88
|
size="icon"
|
|
87
89
|
onClick={onTogglePanel}
|
|
88
|
-
aria-label=
|
|
90
|
+
aria-label={t('conversations.thread.details')}
|
|
89
91
|
>
|
|
90
92
|
<PanelRight className="h-4 w-4" />
|
|
91
93
|
</Button>
|
|
@@ -132,20 +134,16 @@ export function MessageThread({ selected, onTogglePanel, panelOpen, onBack, clas
|
|
|
132
134
|
{rows.length === 0 && (
|
|
133
135
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
|
134
136
|
<MessageSquare className="h-7 w-7" />
|
|
135
|
-
<p className="mt-2 text-sm">
|
|
137
|
+
<p className="mt-2 text-sm">{t('conversations.thread.empty')}</p>
|
|
136
138
|
</div>
|
|
137
139
|
)}
|
|
138
140
|
</div>
|
|
139
141
|
|
|
140
142
|
{/* Composer */}
|
|
143
|
+
{/* TODO(follow-up): wire real emoji picker + attachment upload (removed the
|
|
144
|
+
dead placeholder buttons rather than shipping non-functional UI). */}
|
|
141
145
|
<div className="border-t border-border bg-card px-4 py-3">
|
|
142
146
|
<div className="flex items-end gap-2">
|
|
143
|
-
<Button variant="ghost" size="icon" className="mb-0.5 text-muted-foreground" aria-label="Add emoji">
|
|
144
|
-
<Smile className="h-4 w-4" />
|
|
145
|
-
</Button>
|
|
146
|
-
<Button variant="ghost" size="icon" className="mb-0.5 text-muted-foreground" aria-label="Attach file">
|
|
147
|
-
<Paperclip className="h-4 w-4" />
|
|
148
|
-
</Button>
|
|
149
147
|
<textarea
|
|
150
148
|
value={draft}
|
|
151
149
|
onChange={(e) => setDraft(e.target.value)}
|
|
@@ -156,10 +154,10 @@ export function MessageThread({ selected, onTogglePanel, panelOpen, onBack, clas
|
|
|
156
154
|
}
|
|
157
155
|
}}
|
|
158
156
|
rows={1}
|
|
159
|
-
placeholder={
|
|
157
|
+
placeholder={t('conversations.thread.reply', { channel: CHANNEL_LABELS[selected.channel] })}
|
|
160
158
|
className="max-h-32 min-h-[40px] flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
|
161
159
|
/>
|
|
162
|
-
<Button onClick={() => void handleSend()} disabled={sending || !draft.trim()} aria-label=
|
|
160
|
+
<Button onClick={() => void handleSend()} disabled={sending || !draft.trim()} aria-label={t('conversations.thread.send')}>
|
|
163
161
|
<Send className="h-4 w-4" />
|
|
164
162
|
</Button>
|
|
165
163
|
</div>
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Button, Modal, ModalContent, cn, toast } from '@fayz-ai/ui'
|
|
3
|
+
import { useTranslation } from '@fayz-ai/core'
|
|
4
|
+
import { useLimitGuard, invalidateLimit, ContactPicker, type ContactPickerValue } from '@fayz-ai/saas'
|
|
5
|
+
import { useConversationsStore, useConversationsConfig } from '../ConversationsContext'
|
|
6
|
+
import { CHANNEL_ACCENT, CHANNEL_ICON, CHANNEL_LABELS } from '../channel'
|
|
7
|
+
import type { Channel } from '../types'
|
|
8
|
+
|
|
9
|
+
const CHANNELS: Channel[] = ['whatsapp', 'sms', 'instagram', 'email', 'webchat']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which field of a person IS the handle on a given channel. Phone channels read
|
|
13
|
+
* the phone, email reads the email; Instagram and web chat have no counterpart
|
|
14
|
+
* on a person record, so they always have to be typed. Deliberately NOT
|
|
15
|
+
* cross-filling (an email in a WhatsApp handle looked plausible on screen and
|
|
16
|
+
* was simply wrong).
|
|
17
|
+
*/
|
|
18
|
+
function personHandleFor(channel: Channel, contact: ContactPickerValue | null): string {
|
|
19
|
+
if (!contact) return ''
|
|
20
|
+
if (channel === 'email') return contact.email ?? ''
|
|
21
|
+
if (channel === 'sms' || channel === 'whatsapp') return contact.phone ?? ''
|
|
22
|
+
return ''
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HANDLE_LABEL_KEY: Record<Channel, string> = {
|
|
26
|
+
whatsapp: 'conversations.new.handleLabel.phone',
|
|
27
|
+
sms: 'conversations.new.handleLabel.phone',
|
|
28
|
+
email: 'conversations.new.handleLabel.email',
|
|
29
|
+
instagram: 'conversations.new.handleLabel.instagram',
|
|
30
|
+
webchat: 'conversations.new.handleLabel.webchat',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function NewConversationModal({
|
|
34
|
+
open,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
}: {
|
|
37
|
+
open: boolean
|
|
38
|
+
onOpenChange: (open: boolean) => void
|
|
39
|
+
}) {
|
|
40
|
+
const t = useTranslation()
|
|
41
|
+
const create = useConversationsStore((s) => s.create)
|
|
42
|
+
const config = useConversationsConfig()
|
|
43
|
+
const guardConversations = useLimitGuard('conversations_month')
|
|
44
|
+
|
|
45
|
+
const [channel, setChannel] = React.useState<Channel>('whatsapp')
|
|
46
|
+
const [contact, setContact] = React.useState<ContactPickerValue | null>(null)
|
|
47
|
+
// Handle the user typed themselves. The one taken FROM the contact is derived
|
|
48
|
+
// (see personHandleFor) and shown on the picker's chip — same as the agenda,
|
|
49
|
+
// which never had a second phone field.
|
|
50
|
+
const [typedHandle, setTypedHandle] = React.useState('')
|
|
51
|
+
// While the picker's inline create form is open it already asks for phone and
|
|
52
|
+
// email, so showing our own handle field would ask for the phone twice.
|
|
53
|
+
const [creatingContact, setCreatingContact] = React.useState(false)
|
|
54
|
+
const [firstMessage, setFirstMessage] = React.useState('')
|
|
55
|
+
const [submitting, setSubmitting] = React.useState(false)
|
|
56
|
+
|
|
57
|
+
// Reset the form each time the modal opens. `pickerKey` remounts the picker so
|
|
58
|
+
// its internal search text resets too.
|
|
59
|
+
const [pickerKey, setPickerKey] = React.useState(0)
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
if (open) {
|
|
62
|
+
setChannel('whatsapp')
|
|
63
|
+
setContact(null)
|
|
64
|
+
setTypedHandle('')
|
|
65
|
+
setCreatingContact(false)
|
|
66
|
+
setFirstMessage('')
|
|
67
|
+
setSubmitting(false)
|
|
68
|
+
setPickerKey((k) => k + 1)
|
|
69
|
+
}
|
|
70
|
+
}, [open])
|
|
71
|
+
|
|
72
|
+
// What we'd message on this channel: the contact's own datum when they have
|
|
73
|
+
// one, otherwise whatever the user typed. Recomputed on every channel switch,
|
|
74
|
+
// so flipping WhatsApp → Email swaps phone for email with no stale state.
|
|
75
|
+
const derivedHandle = personHandleFor(channel, contact)
|
|
76
|
+
const effectiveHandle = derivedHandle || typedHandle
|
|
77
|
+
const handleLabel = t(HANDLE_LABEL_KEY[channel])
|
|
78
|
+
|
|
79
|
+
const canSubmit = (contact?.name.trim().length ?? 0) > 0 && !submitting
|
|
80
|
+
|
|
81
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
82
|
+
e.preventDefault()
|
|
83
|
+
if (!canSubmit) return
|
|
84
|
+
setSubmitting(true)
|
|
85
|
+
try {
|
|
86
|
+
// Plan quantity guard (client-side, before the store call): opens the
|
|
87
|
+
// global UpgradeModal and aborts when the monthly cap is reached. Inside
|
|
88
|
+
// the try on purpose — it hits the network to count usage, and a rejection
|
|
89
|
+
// out here used to escape unhandled, leaving the modal open with no
|
|
90
|
+
// feedback at all (indistinguishable from a dead button).
|
|
91
|
+
if ((await guardConversations()) === 'blocked') return
|
|
92
|
+
await create({
|
|
93
|
+
channel,
|
|
94
|
+
contactName: contact!.name.trim(),
|
|
95
|
+
contactPersonId: contact?.id,
|
|
96
|
+
contactHandle: effectiveHandle.trim() || undefined,
|
|
97
|
+
firstMessage: firstMessage.trim() || undefined,
|
|
98
|
+
})
|
|
99
|
+
invalidateLimit('conversations_month')
|
|
100
|
+
onOpenChange(false)
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// A failed insert (unresolved tenant, RLS rejection, offline…) previously
|
|
103
|
+
// threw past the `finally` — `onOpenChange(false)` never ran, so the modal
|
|
104
|
+
// stayed open with no feedback and the thread silently vanished. Surface
|
|
105
|
+
// the failure and keep the form open so the user can retry.
|
|
106
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
107
|
+
toast.error(t('conversations.new.createFailed'), { description: message })
|
|
108
|
+
} finally {
|
|
109
|
+
setSubmitting(false)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Modal open={open} onOpenChange={onOpenChange}>
|
|
115
|
+
<ModalContent size="md">
|
|
116
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
117
|
+
<h2 className="text-base font-semibold text-foreground">{t('conversations.new.title')}</h2>
|
|
118
|
+
|
|
119
|
+
{/* Channel picker */}
|
|
120
|
+
<div>
|
|
121
|
+
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
|
122
|
+
{t('conversations.new.channel')}
|
|
123
|
+
</label>
|
|
124
|
+
<div className="flex flex-wrap gap-1.5">
|
|
125
|
+
{CHANNELS.map((ch) => {
|
|
126
|
+
const Icon = CHANNEL_ICON[ch]
|
|
127
|
+
const active = channel === ch
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
key={ch}
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={() => setChannel(ch)}
|
|
133
|
+
aria-pressed={active}
|
|
134
|
+
className={cn(
|
|
135
|
+
'inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors',
|
|
136
|
+
active
|
|
137
|
+
? 'text-white'
|
|
138
|
+
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
|
139
|
+
)}
|
|
140
|
+
style={active ? { backgroundColor: CHANNEL_ACCENT[ch].color } : undefined}
|
|
141
|
+
>
|
|
142
|
+
<Icon className="h-3 w-3" />
|
|
143
|
+
{CHANNEL_LABELS[ch]}
|
|
144
|
+
</button>
|
|
145
|
+
)
|
|
146
|
+
})}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{/* Contact — shared find-or-create flow (@fayz-ai/saas), the same one
|
|
151
|
+
the agenda uses to pick a client. The picker owns its label, the
|
|
152
|
+
search field, the chip and the channel handle, so this composer and
|
|
153
|
+
the appointment modal render the SAME thing from the same code. */}
|
|
154
|
+
<ContactPicker
|
|
155
|
+
key={pickerKey}
|
|
156
|
+
value={contact}
|
|
157
|
+
onChange={setContact}
|
|
158
|
+
kind={config.contactKind}
|
|
159
|
+
extensionTable={config.contactExtensionTable}
|
|
160
|
+
lookup={config.contactLookup}
|
|
161
|
+
allowFreeText
|
|
162
|
+
onCreatingChange={setCreatingContact}
|
|
163
|
+
autoFocus
|
|
164
|
+
label={t('conversations.new.contactName')}
|
|
165
|
+
placeholder={t('conversations.new.contactNamePlaceholder')}
|
|
166
|
+
secondaryText={derivedHandle || undefined}
|
|
167
|
+
handleField={{
|
|
168
|
+
label: handleLabel,
|
|
169
|
+
derived: derivedHandle || undefined,
|
|
170
|
+
value: typedHandle,
|
|
171
|
+
onChange: setTypedHandle,
|
|
172
|
+
fieldLabel: t('conversations.new.handle'),
|
|
173
|
+
placeholder: t('conversations.new.handlePlaceholder'),
|
|
174
|
+
}}
|
|
175
|
+
/>
|
|
176
|
+
|
|
177
|
+
{/* First message */}
|
|
178
|
+
<div>
|
|
179
|
+
<label htmlFor="conv-first-message" className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
|
180
|
+
{t('conversations.new.firstMessage')}
|
|
181
|
+
</label>
|
|
182
|
+
<textarea
|
|
183
|
+
id="conv-first-message"
|
|
184
|
+
value={firstMessage}
|
|
185
|
+
onChange={(e) => setFirstMessage(e.target.value)}
|
|
186
|
+
rows={3}
|
|
187
|
+
placeholder={t('conversations.new.firstMessagePlaceholder')}
|
|
188
|
+
className="max-h-40 min-h-[64px] w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div className="mt-1 flex justify-end gap-2">
|
|
193
|
+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
|
194
|
+
{t('conversations.new.cancel')}
|
|
195
|
+
</Button>
|
|
196
|
+
<Button type="submit" disabled={!canSubmit}>
|
|
197
|
+
{submitting ? t('conversations.new.creating') : t('conversations.new.create')}
|
|
198
|
+
</Button>
|
|
199
|
+
</div>
|
|
200
|
+
</form>
|
|
201
|
+
</ModalContent>
|
|
202
|
+
</Modal>
|
|
203
|
+
)
|
|
204
|
+
}
|