@fayz-ai/plugin-conversations 0.2.0
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/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/ConversationsContext.d.ts +9 -0
- package/dist/ConversationsContext.d.ts.map +1 -0
- package/dist/ConversationsPage.d.ts +7 -0
- package/dist/ConversationsPage.d.ts.map +1 -0
- package/dist/channel.d.ts +14 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +11 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/types.d.ts +9 -0
- package/dist/data/types.d.ts.map +1 -0
- package/dist/index.cjs +904 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +896 -0
- package/dist/index.js.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/store.d.ts +21 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/views/ContactPanel.d.ts +8 -0
- package/dist/views/ContactPanel.d.ts.map +1 -0
- package/dist/views/ConversationList.d.ts +5 -0
- package/dist/views/ConversationList.d.ts.map +1 -0
- package/dist/views/InboxView.d.ts +3 -0
- package/dist/views/InboxView.d.ts.map +1 -0
- package/dist/views/MessageThread.d.ts +10 -0
- package/dist/views/MessageThread.d.ts.map +1 -0
- package/dist/views/shared.d.ts +20 -0
- package/dist/views/shared.d.ts.map +1 -0
- package/package.json +54 -0
- package/src/ConversationsContext.tsx +21 -0
- package/src/ConversationsPage.tsx +18 -0
- package/src/channel.ts +34 -0
- package/src/data/mock.ts +142 -0
- package/src/data/supabase.ts +202 -0
- package/src/data/types.ts +15 -0
- package/src/index.ts +132 -0
- package/src/locales/en.ts +4 -0
- package/src/locales/index.ts +5 -0
- package/src/store.ts +93 -0
- package/src/types.ts +58 -0
- package/src/views/ContactPanel.tsx +95 -0
- package/src/views/ConversationList.tsx +108 -0
- package/src/views/InboxView.tsx +62 -0
- package/src/views/MessageThread.tsx +169 -0
- package/src/views/shared.tsx +97 -0
package/src/data/mock.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { ConversationsProvider } from './types'
|
|
2
|
+
import type {
|
|
3
|
+
Conversation,
|
|
4
|
+
Message,
|
|
5
|
+
ListConversationsQuery,
|
|
6
|
+
SendMessageInput,
|
|
7
|
+
ConversationStatus,
|
|
8
|
+
} from '../types'
|
|
9
|
+
|
|
10
|
+
// Deterministic relative timestamps (no Date.now at module init for stable seeds).
|
|
11
|
+
function minutesAgo(base: number, mins: number): string {
|
|
12
|
+
return new Date(base - mins * 60_000).toISOString()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function seed(): { conversations: Conversation[]; messages: Message[] } {
|
|
16
|
+
const base = new Date('2026-06-16T14:00:00Z').getTime()
|
|
17
|
+
|
|
18
|
+
const conversations: Conversation[] = [
|
|
19
|
+
{
|
|
20
|
+
id: 'c1', contactName: 'Marina Alves', contactHandle: '+55 11 99876-1020', channel: 'whatsapp',
|
|
21
|
+
lastMessagePreview: 'Perfect, can we book for Friday at 3pm?', lastMessageAt: minutesAgo(base, 4),
|
|
22
|
+
unreadCount: 2, status: 'open', assignedTo: 'You', accent: '#22c55e', tags: ['Hot lead'],
|
|
23
|
+
location: 'São Paulo, BR', note: 'Referred by Instagram ad — interested in full color + cut.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'c2', contactName: 'Jordan Pierce', contactHandle: '+1 (415) 555-0142', channel: 'sms',
|
|
27
|
+
lastMessagePreview: 'Got it — sending the deposit now.', lastMessageAt: minutesAgo(base, 22),
|
|
28
|
+
unreadCount: 0, status: 'open', assignedTo: 'You', accent: '#6366f1', tags: ['Customer'],
|
|
29
|
+
location: 'San Francisco, US',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'c3', contactName: '@thehairloft', contactHandle: 'thehairloft', channel: 'instagram',
|
|
33
|
+
lastMessagePreview: 'Do you offer balayage on weekends?', lastMessageAt: minutesAgo(base, 51),
|
|
34
|
+
unreadCount: 1, status: 'open', accent: '#ec4899', tags: ['New'],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'c4', contactName: 'David Whitman', contactHandle: 'david@whitman.co', channel: 'email',
|
|
38
|
+
lastMessagePreview: 'Re: Proposal — looks great, one question on pricing…', lastMessageAt: minutesAgo(base, 95),
|
|
39
|
+
unreadCount: 0, status: 'open', assignedTo: 'Sofia', accent: '#0ea5e9', tags: ['Proposal'],
|
|
40
|
+
location: 'Austin, US', note: 'Evaluating the retainer tier. Decision expected this week.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'c5', contactName: 'Website visitor', contactHandle: 'live chat · acme.com', channel: 'webchat',
|
|
44
|
+
lastMessagePreview: 'Is anyone available to chat?', lastMessageAt: minutesAgo(base, 140),
|
|
45
|
+
unreadCount: 0, status: 'snoozed', accent: '#f59e0b', tags: [],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'c6', contactName: 'Priya Nair', contactHandle: '+44 7700 900123', channel: 'whatsapp',
|
|
49
|
+
lastMessagePreview: 'Thank you! See you next week 🙌', lastMessageAt: minutesAgo(base, 1440),
|
|
50
|
+
unreadCount: 0, status: 'closed', assignedTo: 'You', accent: '#14b8a6', tags: ['Customer'],
|
|
51
|
+
location: 'London, UK',
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
const messages: Message[] = [
|
|
56
|
+
msg('m1', 'c1', 'whatsapp', 'inbound', 'Hi! I saw your ad — do you have availability this week?', 'Marina Alves', minutesAgo(base, 18)),
|
|
57
|
+
msg('m2', 'c1', 'whatsapp', 'outbound', 'Hi Marina! Yes, we do. What service are you interested in?', 'You', minutesAgo(base, 15)),
|
|
58
|
+
msg('m3', 'c1', 'whatsapp', 'inbound', 'A full color + cut.', 'Marina Alves', minutesAgo(base, 9)),
|
|
59
|
+
msg('m4', 'c1', 'whatsapp', 'inbound', 'Perfect, can we book for Friday at 3pm?', 'Marina Alves', minutesAgo(base, 4)),
|
|
60
|
+
|
|
61
|
+
msg('m5', 'c2', 'sms', 'outbound', 'Your appointment is confirmed for tomorrow at 10am.', 'You', minutesAgo(base, 40)),
|
|
62
|
+
msg('m6', 'c2', 'sms', 'inbound', 'Got it — sending the deposit now.', 'Jordan Pierce', minutesAgo(base, 22)),
|
|
63
|
+
|
|
64
|
+
msg('m7', 'c3', 'instagram', 'inbound', 'Do you offer balayage on weekends?', '@thehairloft', minutesAgo(base, 51)),
|
|
65
|
+
|
|
66
|
+
msg('m8', 'c4', 'email', 'inbound', 'Re: Proposal — looks great, one question on pricing for the retainer tier.', 'David Whitman', minutesAgo(base, 95)),
|
|
67
|
+
msg('m9', 'c4', 'email', 'outbound', 'Happy to walk you through it — are you free for a quick call tomorrow?', 'Sofia', minutesAgo(base, 80)),
|
|
68
|
+
|
|
69
|
+
msg('m10', 'c5', 'webchat', 'inbound', 'Is anyone available to chat?', 'Website visitor', minutesAgo(base, 140)),
|
|
70
|
+
|
|
71
|
+
msg('m11', 'c6', 'whatsapp', 'outbound', 'You are all set for next Tuesday. Anything else?', 'You', minutesAgo(base, 1500)),
|
|
72
|
+
msg('m12', 'c6', 'whatsapp', 'inbound', 'Thank you! See you next week 🙌', 'Priya Nair', minutesAgo(base, 1440)),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
return { conversations, messages }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function msg(
|
|
79
|
+
id: string, conversationId: string, channel: Message['channel'],
|
|
80
|
+
direction: Message['direction'], body: string, author: string, at: string,
|
|
81
|
+
): Message {
|
|
82
|
+
return { id, conversationId, channel, direction, body, author, at }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createMockConversationsProvider(): ConversationsProvider {
|
|
86
|
+
const { conversations, messages } = seed()
|
|
87
|
+
let counter = 100
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
async listConversations(query?: ListConversationsQuery): Promise<Conversation[]> {
|
|
91
|
+
let list = [...conversations]
|
|
92
|
+
if (query?.channel && query.channel !== 'all') list = list.filter((c) => c.channel === query.channel)
|
|
93
|
+
if (query?.status && query.status !== 'all') list = list.filter((c) => c.status === query.status)
|
|
94
|
+
if (query?.search) {
|
|
95
|
+
const q = query.search.toLowerCase()
|
|
96
|
+
list = list.filter(
|
|
97
|
+
(c) => c.contactName.toLowerCase().includes(q) || c.lastMessagePreview.toLowerCase().includes(q),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return list.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt))
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async getMessages(conversationId: string): Promise<Message[]> {
|
|
104
|
+
return messages
|
|
105
|
+
.filter((m) => m.conversationId === conversationId)
|
|
106
|
+
.sort((a, b) => a.at.localeCompare(b.at))
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async sendMessage(input: SendMessageInput): Promise<Message> {
|
|
110
|
+
const conv = conversations.find((c) => c.id === input.conversationId)
|
|
111
|
+
const created: Message = {
|
|
112
|
+
id: `m${++counter}`,
|
|
113
|
+
conversationId: input.conversationId,
|
|
114
|
+
channel: conv?.channel ?? 'sms',
|
|
115
|
+
direction: 'outbound',
|
|
116
|
+
body: input.body,
|
|
117
|
+
author: 'You',
|
|
118
|
+
at: new Date().toISOString(),
|
|
119
|
+
}
|
|
120
|
+
messages.push(created)
|
|
121
|
+
if (conv) {
|
|
122
|
+
conv.lastMessagePreview = input.body
|
|
123
|
+
conv.lastMessageAt = created.at
|
|
124
|
+
conv.unreadCount = 0
|
|
125
|
+
if (conv.status === 'closed') conv.status = 'open'
|
|
126
|
+
}
|
|
127
|
+
return created
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async markRead(conversationId: string): Promise<void> {
|
|
131
|
+
const conv = conversations.find((c) => c.id === conversationId)
|
|
132
|
+
if (conv) conv.unreadCount = 0
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async setStatus(conversationId: string, status: ConversationStatus): Promise<Conversation> {
|
|
136
|
+
const conv = conversations.find((c) => c.id === conversationId)
|
|
137
|
+
if (!conv) throw new Error('Conversation not found')
|
|
138
|
+
conv.status = status
|
|
139
|
+
return conv
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { getSupabaseClientOptional } from '@fayz-ai/core'
|
|
2
|
+
import type { ConversationsProvider } from './types'
|
|
3
|
+
import type {
|
|
4
|
+
Conversation,
|
|
5
|
+
Message,
|
|
6
|
+
ListConversationsQuery,
|
|
7
|
+
SendMessageInput,
|
|
8
|
+
ConversationStatus,
|
|
9
|
+
} from '../types'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Supabase-backed conversations provider — reads/writes the project's
|
|
13
|
+
// `conversations` + `conversation_messages` tables (tenant-scoped via RLS +
|
|
14
|
+
// an explicit tenant filter from the active-org context). Mirrors the inline
|
|
15
|
+
// client-casting style of @fayz-ai/core's createSupabaseProvider; the global
|
|
16
|
+
// client is `unknown`-typed so each call narrows the shape it needs.
|
|
17
|
+
//
|
|
18
|
+
// Real channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver inbound
|
|
19
|
+
// rows into these tables out-of-band; this provider is the read/compose surface.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface SupabaseConversationsConfig {
|
|
23
|
+
/** Supabase client; defaults to the global one registered by createFayzApp. */
|
|
24
|
+
supabaseClient?: unknown
|
|
25
|
+
/** Active tenant id (value or getter) used to scope reads and stamp writes. */
|
|
26
|
+
tenantId?: string | (() => string | undefined)
|
|
27
|
+
/** Display name stamped as the author of outbound messages. */
|
|
28
|
+
selfAuthor?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Row = Record<string, unknown>
|
|
32
|
+
|
|
33
|
+
function mapConversation(r: Row): Conversation {
|
|
34
|
+
return {
|
|
35
|
+
id: String(r.id),
|
|
36
|
+
contactName: (r.contact_name as string) ?? '',
|
|
37
|
+
contactHandle: (r.contact_handle as string) ?? '',
|
|
38
|
+
channel: (r.channel as Conversation['channel']) ?? 'sms',
|
|
39
|
+
lastMessagePreview: (r.last_message_preview as string) ?? '',
|
|
40
|
+
lastMessageAt: (r.last_message_at as string) ?? '',
|
|
41
|
+
unreadCount: Number(r.unread_count ?? 0),
|
|
42
|
+
status: (r.status as ConversationStatus) ?? 'open',
|
|
43
|
+
assignedTo: (r.assigned_to as string | null) ?? undefined,
|
|
44
|
+
accent: (r.accent as string) ?? '#6366f1',
|
|
45
|
+
tags: (r.tags as string[] | null) ?? [],
|
|
46
|
+
location: (r.location as string | null) ?? undefined,
|
|
47
|
+
note: (r.note as string | null) ?? undefined,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function mapMessage(r: Row): Message {
|
|
52
|
+
return {
|
|
53
|
+
id: String(r.id),
|
|
54
|
+
conversationId: String(r.conversation_id),
|
|
55
|
+
channel: (r.channel as Message['channel']) ?? 'sms',
|
|
56
|
+
direction: (r.direction as Message['direction']) ?? 'inbound',
|
|
57
|
+
body: (r.body as string) ?? '',
|
|
58
|
+
at: (r.at as string) ?? '',
|
|
59
|
+
author: (r.author as string) ?? '',
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createSupabaseConversationsProvider(
|
|
64
|
+
config?: SupabaseConversationsConfig,
|
|
65
|
+
): ConversationsProvider {
|
|
66
|
+
const selfAuthor = config?.selfAuthor ?? 'You'
|
|
67
|
+
|
|
68
|
+
function resolveTenantId(): string | undefined {
|
|
69
|
+
if (!config?.tenantId) return undefined
|
|
70
|
+
return typeof config.tenantId === 'function' ? config.tenantId() : config.tenantId
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function client(): { from: (t: string) => Row } {
|
|
74
|
+
const supabase = (config?.supabaseClient ?? getSupabaseClientOptional()) as
|
|
75
|
+
| { from: (t: string) => Row }
|
|
76
|
+
| null
|
|
77
|
+
if (!supabase) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'[plugin-conversations] Supabase client not available. Pass supabaseClient or register the global client via createFayzApp.',
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
return supabase
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
async listConversations(query?: ListConversationsQuery): Promise<Conversation[]> {
|
|
87
|
+
let q = (client().from('conversations') as { select: (s: string) => Row }).select('*')
|
|
88
|
+
|
|
89
|
+
const tenantId = resolveTenantId()
|
|
90
|
+
if (tenantId) q = (q as { eq: (c: string, v: string) => Row }).eq('tenant_id', tenantId)
|
|
91
|
+
if (query?.channel && query.channel !== 'all') {
|
|
92
|
+
q = (q as { eq: (c: string, v: string) => Row }).eq('channel', query.channel)
|
|
93
|
+
}
|
|
94
|
+
if (query?.status && query.status !== 'all') {
|
|
95
|
+
q = (q as { eq: (c: string, v: string) => Row }).eq('status', query.status)
|
|
96
|
+
}
|
|
97
|
+
if (query?.search) {
|
|
98
|
+
const term = `%${query.search}%`
|
|
99
|
+
q = (q as { or: (c: string) => Row }).or(
|
|
100
|
+
`contact_name.ilike.${term},last_message_preview.ilike.${term}`,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
q = (q as { order: (c: string, o: unknown) => Row }).order('last_message_at', {
|
|
104
|
+
ascending: false,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const { data, error } = (await q) as { data: Row[] | null; error: unknown }
|
|
108
|
+
if (error) throw error
|
|
109
|
+
return (data ?? []).map(mapConversation)
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async getMessages(conversationId: string): Promise<Message[]> {
|
|
113
|
+
const selected = (
|
|
114
|
+
client().from('conversation_messages') as { select: (s: string) => Row }
|
|
115
|
+
).select('*')
|
|
116
|
+
const filtered = (selected as { eq: (c: string, v: string) => Row }).eq(
|
|
117
|
+
'conversation_id',
|
|
118
|
+
conversationId,
|
|
119
|
+
)
|
|
120
|
+
const ordered = (filtered as { order: (c: string, o: unknown) => Row }).order('at', {
|
|
121
|
+
ascending: true,
|
|
122
|
+
})
|
|
123
|
+
const { data, error } = (await ordered) as { data: Row[] | null; error: unknown }
|
|
124
|
+
if (error) throw error
|
|
125
|
+
return (data ?? []).map(mapMessage)
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async sendMessage(input: SendMessageInput): Promise<Message> {
|
|
129
|
+
const tenantId = resolveTenantId()
|
|
130
|
+
|
|
131
|
+
// Resolve the channel from the parent conversation so the message matches.
|
|
132
|
+
const convSelected = (client().from('conversations') as { select: (s: string) => Row }).select(
|
|
133
|
+
'channel',
|
|
134
|
+
)
|
|
135
|
+
const convFiltered = (convSelected as { eq: (c: string, v: string) => Row }).eq(
|
|
136
|
+
'id',
|
|
137
|
+
input.conversationId,
|
|
138
|
+
)
|
|
139
|
+
const { data: conv } = (await (
|
|
140
|
+
convFiltered as { maybeSingle: () => Promise<{ data: Row | null }> }
|
|
141
|
+
).maybeSingle()) as { data: Row | null }
|
|
142
|
+
const channel = (conv?.channel as Message['channel']) ?? 'sms'
|
|
143
|
+
|
|
144
|
+
const at = new Date().toISOString()
|
|
145
|
+
const row: Row = {
|
|
146
|
+
conversation_id: input.conversationId,
|
|
147
|
+
channel,
|
|
148
|
+
direction: 'outbound',
|
|
149
|
+
body: input.body,
|
|
150
|
+
author: selfAuthor,
|
|
151
|
+
at,
|
|
152
|
+
}
|
|
153
|
+
if (tenantId) row.tenant_id = tenantId
|
|
154
|
+
|
|
155
|
+
const { data: created, error } = (await (
|
|
156
|
+
(client().from('conversation_messages') as { insert: (r: Row) => Row }).insert(row) as {
|
|
157
|
+
select: () => { single: () => Promise<{ data: Row | null; error: unknown }> }
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
.select()
|
|
161
|
+
.single()) as { data: Row | null; error: unknown }
|
|
162
|
+
if (error) throw error
|
|
163
|
+
|
|
164
|
+
// Roll the parent conversation forward (preview / timestamp / unread / reopen).
|
|
165
|
+
await (
|
|
166
|
+
(client().from('conversations') as {
|
|
167
|
+
update: (r: Row) => Row
|
|
168
|
+
}).update({
|
|
169
|
+
last_message_preview: input.body,
|
|
170
|
+
last_message_at: at,
|
|
171
|
+
unread_count: 0,
|
|
172
|
+
status: 'open',
|
|
173
|
+
}) as { eq: (c: string, v: string) => Promise<unknown> }
|
|
174
|
+
).eq('id', input.conversationId)
|
|
175
|
+
|
|
176
|
+
return mapMessage(created ?? row)
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
async markRead(conversationId: string): Promise<void> {
|
|
180
|
+
const { error } = (await (
|
|
181
|
+
(client().from('conversations') as { update: (r: Row) => Row }).update({
|
|
182
|
+
unread_count: 0,
|
|
183
|
+
}) as { eq: (c: string, v: string) => Promise<{ error: unknown }> }
|
|
184
|
+
).eq('id', conversationId)) as { error: unknown }
|
|
185
|
+
if (error) throw error
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
async setStatus(conversationId: string, status: ConversationStatus): Promise<Conversation> {
|
|
189
|
+
const updated = (client().from('conversations') as { update: (r: Row) => Row }).update({
|
|
190
|
+
status,
|
|
191
|
+
})
|
|
192
|
+
const filtered = (updated as { eq: (c: string, v: string) => Row }).eq('id', conversationId)
|
|
193
|
+
const selected = (filtered as { select: () => Row }).select()
|
|
194
|
+
const { data, error } = (await (
|
|
195
|
+
selected as { single: () => Promise<{ data: Row | null; error: unknown }> }
|
|
196
|
+
).single()) as { data: Row | null; error: unknown }
|
|
197
|
+
if (error) throw error
|
|
198
|
+
if (!data) throw new Error('Conversation not found')
|
|
199
|
+
return mapConversation(data)
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Conversation,
|
|
3
|
+
Message,
|
|
4
|
+
ListConversationsQuery,
|
|
5
|
+
SendMessageInput,
|
|
6
|
+
ConversationStatus,
|
|
7
|
+
} from '../types'
|
|
8
|
+
|
|
9
|
+
export interface ConversationsProvider {
|
|
10
|
+
listConversations(query?: ListConversationsQuery): Promise<Conversation[]>
|
|
11
|
+
getMessages(conversationId: string): Promise<Message[]>
|
|
12
|
+
sendMessage(input: SendMessageInput): Promise<Message>
|
|
13
|
+
markRead(conversationId: string): Promise<void>
|
|
14
|
+
setStatus(conversationId: string, status: ConversationStatus): Promise<Conversation>
|
|
15
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { PluginManifest, PluginScope, VerticalId } from '@fayz-ai/core'
|
|
3
|
+
import { getActiveTenantId, getSupabaseClientOptional, registerTranslations } from '@fayz-ai/core'
|
|
4
|
+
import { ConversationsPage } from './ConversationsPage'
|
|
5
|
+
import type { ConversationsProvider } from './data/types'
|
|
6
|
+
import { createMockConversationsProvider } from './data/mock'
|
|
7
|
+
import { createSupabaseConversationsProvider } from './data/supabase'
|
|
8
|
+
import { createConversationsStore } from './store'
|
|
9
|
+
import { conversationsLocales } from './locales'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// @fayz-ai/plugin-conversations — the GoHighLevel "Conversations" equivalent:
|
|
13
|
+
// one omni-channel inbox (SMS / WhatsApp / Instagram / Email / Web chat).
|
|
14
|
+
// Universal plugin — reusable by any vertical (beauty, resto, agency…).
|
|
15
|
+
//
|
|
16
|
+
// M1 ships a full mock inbox. Real channel connectors (Twilio, WhatsApp Cloud,
|
|
17
|
+
// Meta, IMAP) + Supabase-backed threads land in a later milestone.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export interface ConversationsPluginOptions {
|
|
21
|
+
navPosition?: number
|
|
22
|
+
navSection?: 'main' | 'secondary' | 'settings'
|
|
23
|
+
navLabel?: string
|
|
24
|
+
scope?: PluginScope
|
|
25
|
+
verticalId?: VerticalId
|
|
26
|
+
dataProvider?: ConversationsProvider
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createSafeProvider(): ConversationsProvider {
|
|
30
|
+
// Real data when a Supabase client is registered (reads/writes the
|
|
31
|
+
// `conversations` + `conversation_messages` tables, tenant-scoped via the
|
|
32
|
+
// active-org context); falls back to the mock inbox when no backend is wired.
|
|
33
|
+
if (getSupabaseClientOptional()) {
|
|
34
|
+
return createSupabaseConversationsProvider({ tenantId: () => getActiveTenantId() })
|
|
35
|
+
}
|
|
36
|
+
return createMockConversationsProvider()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createConversationsPlugin(options?: ConversationsPluginOptions): PluginManifest {
|
|
40
|
+
registerTranslations(conversationsLocales)
|
|
41
|
+
const provider = options?.dataProvider ?? createSafeProvider()
|
|
42
|
+
const store = createConversationsStore(provider)
|
|
43
|
+
|
|
44
|
+
const PageComponent: React.ComponentType<unknown> = () =>
|
|
45
|
+
React.createElement(ConversationsPage, { store })
|
|
46
|
+
PageComponent.displayName = 'ConversationsPage'
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
id: 'conversations',
|
|
50
|
+
name: options?.navLabel ?? 'Conversations',
|
|
51
|
+
icon: 'MessageCircle',
|
|
52
|
+
version: '1.0.0',
|
|
53
|
+
scope: options?.scope ?? 'universal',
|
|
54
|
+
verticalId: options?.verticalId,
|
|
55
|
+
defaultEnabled: true,
|
|
56
|
+
dependencies: [],
|
|
57
|
+
declaredFeatures: [{ id: 'conversations', label: 'Conversations', group: 'Engage' }],
|
|
58
|
+
navigation: [
|
|
59
|
+
{
|
|
60
|
+
section: options?.navSection ?? 'main',
|
|
61
|
+
position: options?.navPosition ?? 1,
|
|
62
|
+
label: options?.navLabel ?? 'Conversations',
|
|
63
|
+
route: '/conversations',
|
|
64
|
+
icon: 'MessageCircle',
|
|
65
|
+
permission: { feature: 'conversations', action: 'read' as const },
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
routes: [
|
|
69
|
+
{
|
|
70
|
+
path: '/conversations',
|
|
71
|
+
component: PageComponent,
|
|
72
|
+
fullBleed: true,
|
|
73
|
+
permission: { feature: 'conversations', action: 'read' as const },
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
widgets: [],
|
|
77
|
+
events: [
|
|
78
|
+
{ name: 'conversations.message.received', description: 'An inbound message arrived on any channel' },
|
|
79
|
+
{ name: 'conversations.message.sent', description: 'An outbound message was sent' },
|
|
80
|
+
],
|
|
81
|
+
aiTools: [
|
|
82
|
+
{
|
|
83
|
+
id: 'conversations.list-threads',
|
|
84
|
+
name: 'listConversations',
|
|
85
|
+
description: 'Lists open conversations across all channels, optionally filtered by channel.',
|
|
86
|
+
icon: 'MessageCircle',
|
|
87
|
+
mode: 'read' as const,
|
|
88
|
+
category: 'Conversations',
|
|
89
|
+
parameters: {
|
|
90
|
+
type: 'object' as const,
|
|
91
|
+
properties: {
|
|
92
|
+
channel: {
|
|
93
|
+
type: 'string' as const,
|
|
94
|
+
enum: ['all', 'sms', 'whatsapp', 'instagram', 'email', 'webchat'],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
suggestions: [
|
|
99
|
+
{ label: 'Show unread conversations' },
|
|
100
|
+
{ label: 'Any new WhatsApp messages?' },
|
|
101
|
+
],
|
|
102
|
+
permission: { feature: 'conversations', action: 'read' as const },
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: 'conversations.send-message',
|
|
106
|
+
name: 'sendMessage',
|
|
107
|
+
description: 'Sends a reply in a conversation thread.',
|
|
108
|
+
icon: 'Send',
|
|
109
|
+
mode: 'persist' as const,
|
|
110
|
+
category: 'Conversations',
|
|
111
|
+
parameters: {
|
|
112
|
+
type: 'object' as const,
|
|
113
|
+
properties: {
|
|
114
|
+
conversationId: { type: 'string' as const, description: 'Conversation id' },
|
|
115
|
+
body: { type: 'string' as const, description: 'Message body' },
|
|
116
|
+
},
|
|
117
|
+
required: ['conversationId', 'body'],
|
|
118
|
+
},
|
|
119
|
+
permission: { feature: 'conversations', action: 'create' as const },
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
locales: conversationsLocales,
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type { ConversationsProvider } from './data/types'
|
|
127
|
+
export type { Conversation, Message, Channel } from './types'
|
|
128
|
+
export { createMockConversationsProvider } from './data/mock'
|
|
129
|
+
export {
|
|
130
|
+
createSupabaseConversationsProvider,
|
|
131
|
+
type SupabaseConversationsConfig,
|
|
132
|
+
} from './data/supabase'
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createStore, type StoreApi } from 'zustand/vanilla'
|
|
2
|
+
import type { ConversationsProvider } from './data/types'
|
|
3
|
+
import type { Conversation, Message, Channel, ConversationStatus } from './types'
|
|
4
|
+
|
|
5
|
+
export interface ConversationsUIState {
|
|
6
|
+
conversations: Conversation[]
|
|
7
|
+
messages: Message[]
|
|
8
|
+
selectedId: string | null
|
|
9
|
+
channelFilter: Channel | 'all'
|
|
10
|
+
search: string
|
|
11
|
+
loading: boolean
|
|
12
|
+
sending: boolean
|
|
13
|
+
|
|
14
|
+
load(): Promise<void>
|
|
15
|
+
select(id: string): Promise<void>
|
|
16
|
+
deselect(): void
|
|
17
|
+
setChannelFilter(channel: Channel | 'all'): Promise<void>
|
|
18
|
+
setSearch(search: string): Promise<void>
|
|
19
|
+
send(body: string): Promise<void>
|
|
20
|
+
setStatus(status: ConversationStatus): Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createConversationsStore(
|
|
24
|
+
provider: ConversationsProvider,
|
|
25
|
+
): StoreApi<ConversationsUIState> {
|
|
26
|
+
return createStore<ConversationsUIState>((set, get) => ({
|
|
27
|
+
conversations: [],
|
|
28
|
+
messages: [],
|
|
29
|
+
selectedId: null,
|
|
30
|
+
channelFilter: 'all',
|
|
31
|
+
search: '',
|
|
32
|
+
loading: false,
|
|
33
|
+
sending: false,
|
|
34
|
+
|
|
35
|
+
async load() {
|
|
36
|
+
set({ loading: true })
|
|
37
|
+
const conversations = await provider.listConversations({
|
|
38
|
+
channel: get().channelFilter,
|
|
39
|
+
search: get().search || undefined,
|
|
40
|
+
})
|
|
41
|
+
const selectedId = get().selectedId ?? conversations[0]?.id ?? null
|
|
42
|
+
set({ conversations, loading: false, selectedId })
|
|
43
|
+
if (selectedId) await get().select(selectedId)
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async select(id: string) {
|
|
47
|
+
set({ selectedId: id })
|
|
48
|
+
const messages = await provider.getMessages(id)
|
|
49
|
+
set({ messages })
|
|
50
|
+
await provider.markRead(id)
|
|
51
|
+
set((s) => ({
|
|
52
|
+
conversations: s.conversations.map((c) => (c.id === id ? { ...c, unreadCount: 0 } : c)),
|
|
53
|
+
}))
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
deselect() {
|
|
57
|
+
set({ selectedId: null, messages: [] })
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
async setChannelFilter(channel) {
|
|
61
|
+
set({ channelFilter: channel })
|
|
62
|
+
await get().load()
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async setSearch(search) {
|
|
66
|
+
set({ search })
|
|
67
|
+
await get().load()
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async send(body: string) {
|
|
71
|
+
const id = get().selectedId
|
|
72
|
+
if (!id || !body.trim()) return
|
|
73
|
+
set({ sending: true })
|
|
74
|
+
const created = await provider.sendMessage({ conversationId: id, body: body.trim() })
|
|
75
|
+
set((s) => ({
|
|
76
|
+
sending: false,
|
|
77
|
+
messages: [...s.messages, created],
|
|
78
|
+
conversations: s.conversations.map((c) =>
|
|
79
|
+
c.id === id ? { ...c, lastMessagePreview: created.body, lastMessageAt: created.at } : c,
|
|
80
|
+
),
|
|
81
|
+
}))
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
async setStatus(status) {
|
|
85
|
+
const id = get().selectedId
|
|
86
|
+
if (!id) return
|
|
87
|
+
const updated = await provider.setStatus(id, status)
|
|
88
|
+
set((s) => ({
|
|
89
|
+
conversations: s.conversations.map((c) => (c.id === id ? updated : c)),
|
|
90
|
+
}))
|
|
91
|
+
},
|
|
92
|
+
}))
|
|
93
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// @fayz-ai/plugin-conversations — domain types for the unified omni-channel
|
|
3
|
+
// inbox (the GoHighLevel "Conversations" equivalent). Provider-agnostic.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
|
|
6
|
+
export type Channel = 'sms' | 'whatsapp' | 'instagram' | 'email' | 'webchat'
|
|
7
|
+
|
|
8
|
+
export type ConversationStatus = 'open' | 'snoozed' | 'closed'
|
|
9
|
+
|
|
10
|
+
export type MessageDirection = 'inbound' | 'outbound'
|
|
11
|
+
|
|
12
|
+
export interface Conversation {
|
|
13
|
+
id: string
|
|
14
|
+
contactName: string
|
|
15
|
+
/** Phone, @handle, or email depending on channel */
|
|
16
|
+
contactHandle: string
|
|
17
|
+
channel: Channel
|
|
18
|
+
lastMessagePreview: string
|
|
19
|
+
lastMessageAt: string
|
|
20
|
+
unreadCount: number
|
|
21
|
+
status: ConversationStatus
|
|
22
|
+
assignedTo?: string
|
|
23
|
+
/** Tailwind-ish accent for the avatar bubble */
|
|
24
|
+
accent: string
|
|
25
|
+
tags: string[]
|
|
26
|
+
// Optional context shown in the contact panel.
|
|
27
|
+
location?: string
|
|
28
|
+
note?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Message {
|
|
32
|
+
id: string
|
|
33
|
+
conversationId: string
|
|
34
|
+
channel: Channel
|
|
35
|
+
direction: MessageDirection
|
|
36
|
+
body: string
|
|
37
|
+
at: string
|
|
38
|
+
author: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ListConversationsQuery {
|
|
42
|
+
channel?: Channel | 'all'
|
|
43
|
+
status?: ConversationStatus | 'all'
|
|
44
|
+
search?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SendMessageInput {
|
|
48
|
+
conversationId: string
|
|
49
|
+
body: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const CHANNEL_LABELS: Record<Channel, string> = {
|
|
53
|
+
sms: 'SMS',
|
|
54
|
+
whatsapp: 'WhatsApp',
|
|
55
|
+
instagram: 'Instagram',
|
|
56
|
+
email: 'Email',
|
|
57
|
+
webchat: 'Web Chat',
|
|
58
|
+
}
|