@fayz-ai/plugin-conversations 0.9.0 → 0.9.2
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/README.md +4 -4
- package/dist/{ConversationsContext.d.ts → context.d.ts} +2 -2
- package/dist/context.d.ts.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/{channel.d.ts → lib/channel.d.ts} +1 -1
- package/dist/lib/channel.d.ts.map +1 -0
- package/dist/{ConversationsPage.d.ts → views/ConversationsPage.d.ts} +2 -2
- package/dist/views/ConversationsPage.d.ts.map +1 -0
- package/package.json +5 -7
- package/dist/ConversationsContext.d.ts.map +0 -1
- package/dist/ConversationsPage.d.ts.map +0 -1
- package/dist/channel.d.ts.map +0 -1
- package/src/ConversationsContext.tsx +0 -51
- package/src/ConversationsPage.tsx +0 -21
- package/src/channel.ts +0 -34
- package/src/data/accents.ts +0 -12
- package/src/data/mock.test.ts +0 -90
- package/src/data/mock.ts +0 -261
- package/src/data/supabase.ts +0 -264
- package/src/data/tables.ts +0 -7
- package/src/data/types.ts +0 -17
- package/src/index.ts +0 -190
- package/src/locales/en.ts +0 -68
- package/src/locales/index.ts +0 -7
- package/src/locales/pt-BR.ts +0 -68
- package/src/migrations/001_conversations.sql +0 -74
- package/src/migrations/002_contact_person.sql +0 -22
- package/src/migrations/index.ts +0 -108
- package/src/store.ts +0 -136
- package/src/types.ts +0 -77
- package/src/views/ContactPanel.tsx +0 -97
- package/src/views/ConversationList.tsx +0 -135
- package/src/views/InboxView.tsx +0 -64
- package/src/views/MessageThread.tsx +0 -167
- package/src/views/NewConversationModal.tsx +0 -204
- package/src/views/shared.tsx +0 -97
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- plugin-conversations 001: omni-channel inbox model (SMS / WhatsApp /
|
|
3
|
-
-- Instagram / Email / Web chat). Prefix: plg_conversations / plg_conversation_messages.
|
|
4
|
-
-- §1 plg_conversations — one thread per contact+channel
|
|
5
|
-
-- §2 plg_conversation_messages — inbound/outbound messages within a thread
|
|
6
|
-
-- §3 RLS: authenticated tenant-scoped CRUD on both tables + GRANTs
|
|
7
|
-
--
|
|
8
|
-
-- Column names mirror exactly what supabase.ts's mapConversation / mapMessage
|
|
9
|
-
-- read. Real channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver
|
|
10
|
-
-- inbound rows here out-of-band; the provider is the read/compose surface.
|
|
11
|
-
-- Idempotent + safe to re-run.
|
|
12
|
-
-- ============================================================================
|
|
13
|
-
|
|
14
|
-
-- §1 — conversations (threads)
|
|
15
|
-
CREATE TABLE IF NOT EXISTS public.plg_conversations (
|
|
16
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
17
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
18
|
-
contact_name text NOT NULL,
|
|
19
|
-
contact_handle text,
|
|
20
|
-
channel text NOT NULL
|
|
21
|
-
CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
|
|
22
|
-
last_message_preview text,
|
|
23
|
-
last_message_at timestamptz DEFAULT now(),
|
|
24
|
-
unread_count int DEFAULT 0,
|
|
25
|
-
status text DEFAULT 'open'
|
|
26
|
-
CHECK (status IN ('open', 'snoozed', 'closed')),
|
|
27
|
-
assigned_to text,
|
|
28
|
-
accent text,
|
|
29
|
-
tags text[],
|
|
30
|
-
location text,
|
|
31
|
-
note text,
|
|
32
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
33
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
34
|
-
);
|
|
35
|
-
ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
|
|
36
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant ON public.plg_conversations(tenant_id);
|
|
37
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant_recent ON public.plg_conversations(tenant_id, last_message_at DESC);
|
|
38
|
-
|
|
39
|
-
-- §2 — messages
|
|
40
|
-
CREATE TABLE IF NOT EXISTS public.plg_conversation_messages (
|
|
41
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
42
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
43
|
-
conversation_id uuid NOT NULL REFERENCES public.plg_conversations(id) ON DELETE CASCADE,
|
|
44
|
-
channel text
|
|
45
|
-
CHECK (channel IS NULL OR channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
|
|
46
|
-
direction text
|
|
47
|
-
CHECK (direction IN ('inbound', 'outbound')),
|
|
48
|
-
body text NOT NULL,
|
|
49
|
-
author text,
|
|
50
|
-
at timestamptz DEFAULT now()
|
|
51
|
-
);
|
|
52
|
-
ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
|
|
53
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_thread ON public.plg_conversation_messages(conversation_id, at);
|
|
54
|
-
|
|
55
|
-
-- §3 — RLS: authenticated tenant CRUD (the inbox reads/writes here)
|
|
56
|
-
DROP POLICY IF EXISTS plg_conversations_select ON public.plg_conversations;
|
|
57
|
-
DROP POLICY IF EXISTS plg_conversations_insert ON public.plg_conversations;
|
|
58
|
-
DROP POLICY IF EXISTS plg_conversations_update ON public.plg_conversations;
|
|
59
|
-
DROP POLICY IF EXISTS plg_conversations_delete ON public.plg_conversations;
|
|
60
|
-
CREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
61
|
-
CREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
62
|
-
CREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
63
|
-
CREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
64
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations TO authenticated;
|
|
65
|
-
|
|
66
|
-
DROP POLICY IF EXISTS plg_conversation_messages_select ON public.plg_conversation_messages;
|
|
67
|
-
DROP POLICY IF EXISTS plg_conversation_messages_insert ON public.plg_conversation_messages;
|
|
68
|
-
DROP POLICY IF EXISTS plg_conversation_messages_update ON public.plg_conversation_messages;
|
|
69
|
-
DROP POLICY IF EXISTS plg_conversation_messages_delete ON public.plg_conversation_messages;
|
|
70
|
-
CREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
71
|
-
CREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
72
|
-
CREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
73
|
-
CREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
74
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversation_messages TO authenticated;
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
-- ============================================================================
|
|
2
|
-
-- plugin-conversations 002: link a thread to a REAL person record.
|
|
3
|
-
--
|
|
4
|
-
-- The compose modal used to take a free-text name + handle, so a conversation
|
|
5
|
-
-- with "Maria" had nothing to do with the Maria in the agenda, the CRM or the
|
|
6
|
-
-- financial module. The shared ContactPicker (find-or-create over
|
|
7
|
-
-- public.people) now resolves a person, and this column stores that link.
|
|
8
|
-
--
|
|
9
|
-
-- Nullable on purpose, in both directions of time:
|
|
10
|
-
-- • rows created before this migration keep working (name/handle only);
|
|
11
|
-
-- • an inbound message from an unknown number still opens a thread with no
|
|
12
|
-
-- person attached — the contact panel can offer "create contact" later.
|
|
13
|
-
-- ON DELETE SET NULL: deleting a person must never take their history with it.
|
|
14
|
-
-- Idempotent + safe to re-run.
|
|
15
|
-
-- ============================================================================
|
|
16
|
-
|
|
17
|
-
ALTER TABLE public.plg_conversations
|
|
18
|
-
ADD COLUMN IF NOT EXISTS contact_person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
|
|
19
|
-
|
|
20
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversations_person
|
|
21
|
-
ON public.plg_conversations(tenant_id, contact_person_id)
|
|
22
|
-
WHERE contact_person_id IS NOT NULL;
|
package/src/migrations/index.ts
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
// AUTO-GENERATED from 001_conversations.sql, 002_contact_person.sql — regenerate with scripts/embed-migrations.mjs
|
|
2
|
-
// SQL files are the source of truth; this inline copy lets the manifest declare
|
|
3
|
-
// migrations as data. Do not edit by hand — run the embed script instead.
|
|
4
|
-
|
|
5
|
-
export const MIGRATION_001_CONVERSATIONS = `-- ============================================================================
|
|
6
|
-
-- plugin-conversations 001: omni-channel inbox model (SMS / WhatsApp /
|
|
7
|
-
-- Instagram / Email / Web chat). Prefix: plg_conversations / plg_conversation_messages.
|
|
8
|
-
-- §1 plg_conversations — one thread per contact+channel
|
|
9
|
-
-- §2 plg_conversation_messages — inbound/outbound messages within a thread
|
|
10
|
-
-- §3 RLS: authenticated tenant-scoped CRUD on both tables + GRANTs
|
|
11
|
-
--
|
|
12
|
-
-- Column names mirror exactly what supabase.ts's mapConversation / mapMessage
|
|
13
|
-
-- read. Real channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver
|
|
14
|
-
-- inbound rows here out-of-band; the provider is the read/compose surface.
|
|
15
|
-
-- Idempotent + safe to re-run.
|
|
16
|
-
-- ============================================================================
|
|
17
|
-
|
|
18
|
-
-- §1 — conversations (threads)
|
|
19
|
-
CREATE TABLE IF NOT EXISTS public.plg_conversations (
|
|
20
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
21
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
22
|
-
contact_name text NOT NULL,
|
|
23
|
-
contact_handle text,
|
|
24
|
-
channel text NOT NULL
|
|
25
|
-
CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
|
|
26
|
-
last_message_preview text,
|
|
27
|
-
last_message_at timestamptz DEFAULT now(),
|
|
28
|
-
unread_count int DEFAULT 0,
|
|
29
|
-
status text DEFAULT 'open'
|
|
30
|
-
CHECK (status IN ('open', 'snoozed', 'closed')),
|
|
31
|
-
assigned_to text,
|
|
32
|
-
accent text,
|
|
33
|
-
tags text[],
|
|
34
|
-
location text,
|
|
35
|
-
note text,
|
|
36
|
-
created_at timestamptz NOT NULL DEFAULT now(),
|
|
37
|
-
updated_at timestamptz NOT NULL DEFAULT now()
|
|
38
|
-
);
|
|
39
|
-
ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
|
|
40
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant ON public.plg_conversations(tenant_id);
|
|
41
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant_recent ON public.plg_conversations(tenant_id, last_message_at DESC);
|
|
42
|
-
|
|
43
|
-
-- §2 — messages
|
|
44
|
-
CREATE TABLE IF NOT EXISTS public.plg_conversation_messages (
|
|
45
|
-
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
46
|
-
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
47
|
-
conversation_id uuid NOT NULL REFERENCES public.plg_conversations(id) ON DELETE CASCADE,
|
|
48
|
-
channel text
|
|
49
|
-
CHECK (channel IS NULL OR channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
|
|
50
|
-
direction text
|
|
51
|
-
CHECK (direction IN ('inbound', 'outbound')),
|
|
52
|
-
body text NOT NULL,
|
|
53
|
-
author text,
|
|
54
|
-
at timestamptz DEFAULT now()
|
|
55
|
-
);
|
|
56
|
-
ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
|
|
57
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_thread ON public.plg_conversation_messages(conversation_id, at);
|
|
58
|
-
|
|
59
|
-
-- §3 — RLS: authenticated tenant CRUD (the inbox reads/writes here)
|
|
60
|
-
DROP POLICY IF EXISTS plg_conversations_select ON public.plg_conversations;
|
|
61
|
-
DROP POLICY IF EXISTS plg_conversations_insert ON public.plg_conversations;
|
|
62
|
-
DROP POLICY IF EXISTS plg_conversations_update ON public.plg_conversations;
|
|
63
|
-
DROP POLICY IF EXISTS plg_conversations_delete ON public.plg_conversations;
|
|
64
|
-
CREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
65
|
-
CREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
66
|
-
CREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
67
|
-
CREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
68
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations TO authenticated;
|
|
69
|
-
|
|
70
|
-
DROP POLICY IF EXISTS plg_conversation_messages_select ON public.plg_conversation_messages;
|
|
71
|
-
DROP POLICY IF EXISTS plg_conversation_messages_insert ON public.plg_conversation_messages;
|
|
72
|
-
DROP POLICY IF EXISTS plg_conversation_messages_update ON public.plg_conversation_messages;
|
|
73
|
-
DROP POLICY IF EXISTS plg_conversation_messages_delete ON public.plg_conversation_messages;
|
|
74
|
-
CREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
75
|
-
CREATE POLICY plg_conversation_messages_insert ON public.plg_conversation_messages FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
76
|
-
CREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
77
|
-
CREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
78
|
-
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversation_messages TO authenticated;
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
export const MIGRATION_002_CONTACT_PERSON = `-- ============================================================================
|
|
82
|
-
-- plugin-conversations 002: link a thread to a REAL person record.
|
|
83
|
-
--
|
|
84
|
-
-- The compose modal used to take a free-text name + handle, so a conversation
|
|
85
|
-
-- with "Maria" had nothing to do with the Maria in the agenda, the CRM or the
|
|
86
|
-
-- financial module. The shared ContactPicker (find-or-create over
|
|
87
|
-
-- public.people) now resolves a person, and this column stores that link.
|
|
88
|
-
--
|
|
89
|
-
-- Nullable on purpose, in both directions of time:
|
|
90
|
-
-- • rows created before this migration keep working (name/handle only);
|
|
91
|
-
-- • an inbound message from an unknown number still opens a thread with no
|
|
92
|
-
-- person attached — the contact panel can offer "create contact" later.
|
|
93
|
-
-- ON DELETE SET NULL: deleting a person must never take their history with it.
|
|
94
|
-
-- Idempotent + safe to re-run.
|
|
95
|
-
-- ============================================================================
|
|
96
|
-
|
|
97
|
-
ALTER TABLE public.plg_conversations
|
|
98
|
-
ADD COLUMN IF NOT EXISTS contact_person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
|
|
99
|
-
|
|
100
|
-
CREATE INDEX IF NOT EXISTS idx_plg_conversations_person
|
|
101
|
-
ON public.plg_conversations(tenant_id, contact_person_id)
|
|
102
|
-
WHERE contact_person_id IS NOT NULL;
|
|
103
|
-
`
|
|
104
|
-
|
|
105
|
-
export const MIGRATIONS: Array<{ id: string; sql: string }> = [
|
|
106
|
-
{ id: "001_conversations", sql: MIGRATION_001_CONVERSATIONS },
|
|
107
|
-
{ id: "002_contact_person", sql: MIGRATION_002_CONTACT_PERSON },
|
|
108
|
-
]
|
package/src/store.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { createStore, type StoreApi } from 'zustand/vanilla'
|
|
2
|
-
import type { ConversationsProvider } from './data/types'
|
|
3
|
-
import type {
|
|
4
|
-
Conversation,
|
|
5
|
-
Message,
|
|
6
|
-
Channel,
|
|
7
|
-
ConversationStatus,
|
|
8
|
-
CreateConversationInput,
|
|
9
|
-
} from './types'
|
|
10
|
-
|
|
11
|
-
export interface ConversationsUIState {
|
|
12
|
-
conversations: Conversation[]
|
|
13
|
-
messages: Message[]
|
|
14
|
-
selectedId: string | null
|
|
15
|
-
channelFilter: Channel | 'all'
|
|
16
|
-
search: string
|
|
17
|
-
loading: boolean
|
|
18
|
-
sending: boolean
|
|
19
|
-
|
|
20
|
-
load(): Promise<void>
|
|
21
|
-
select(id: string): Promise<void>
|
|
22
|
-
deselect(): void
|
|
23
|
-
setChannelFilter(channel: Channel | 'all'): Promise<void>
|
|
24
|
-
setSearch(search: string): Promise<void>
|
|
25
|
-
create(input: CreateConversationInput): Promise<Conversation>
|
|
26
|
-
send(body: string): Promise<void>
|
|
27
|
-
setStatus(status: ConversationStatus): Promise<void>
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function createConversationsStore(
|
|
31
|
-
provider: ConversationsProvider,
|
|
32
|
-
): StoreApi<ConversationsUIState> {
|
|
33
|
-
return createStore<ConversationsUIState>((set, get) => ({
|
|
34
|
-
conversations: [],
|
|
35
|
-
messages: [],
|
|
36
|
-
selectedId: null,
|
|
37
|
-
channelFilter: 'all',
|
|
38
|
-
search: '',
|
|
39
|
-
loading: false,
|
|
40
|
-
sending: false,
|
|
41
|
-
|
|
42
|
-
async load() {
|
|
43
|
-
set({ loading: true })
|
|
44
|
-
const conversations = await provider.listConversations({
|
|
45
|
-
channel: get().channelFilter,
|
|
46
|
-
search: get().search || undefined,
|
|
47
|
-
})
|
|
48
|
-
const selectedId = get().selectedId ?? conversations[0]?.id ?? null
|
|
49
|
-
set({ conversations, loading: false, selectedId })
|
|
50
|
-
if (selectedId) await get().select(selectedId)
|
|
51
|
-
},
|
|
52
|
-
|
|
53
|
-
async select(id: string) {
|
|
54
|
-
set({ selectedId: id })
|
|
55
|
-
const messages = await provider.getMessages(id)
|
|
56
|
-
set({ messages })
|
|
57
|
-
await provider.markRead(id)
|
|
58
|
-
set((s) => ({
|
|
59
|
-
conversations: s.conversations.map((c) => (c.id === id ? { ...c, unreadCount: 0 } : c)),
|
|
60
|
-
}))
|
|
61
|
-
},
|
|
62
|
-
|
|
63
|
-
deselect() {
|
|
64
|
-
set({ selectedId: null, messages: [] })
|
|
65
|
-
},
|
|
66
|
-
|
|
67
|
-
async setChannelFilter(channel) {
|
|
68
|
-
set({ channelFilter: channel })
|
|
69
|
-
await get().load()
|
|
70
|
-
},
|
|
71
|
-
|
|
72
|
-
async setSearch(search) {
|
|
73
|
-
set({ search })
|
|
74
|
-
await get().load()
|
|
75
|
-
},
|
|
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
|
-
|
|
113
|
-
async send(body: string) {
|
|
114
|
-
const id = get().selectedId
|
|
115
|
-
if (!id || !body.trim()) return
|
|
116
|
-
set({ sending: true })
|
|
117
|
-
const created = await provider.sendMessage({ conversationId: id, body: body.trim() })
|
|
118
|
-
set((s) => ({
|
|
119
|
-
sending: false,
|
|
120
|
-
messages: [...s.messages, created],
|
|
121
|
-
conversations: s.conversations.map((c) =>
|
|
122
|
-
c.id === id ? { ...c, lastMessagePreview: created.body, lastMessageAt: created.at } : c,
|
|
123
|
-
),
|
|
124
|
-
}))
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
async setStatus(status) {
|
|
128
|
-
const id = get().selectedId
|
|
129
|
-
if (!id) return
|
|
130
|
-
const updated = await provider.setStatus(id, status)
|
|
131
|
-
set((s) => ({
|
|
132
|
-
conversations: s.conversations.map((c) => (c.id === id ? updated : c)),
|
|
133
|
-
}))
|
|
134
|
-
},
|
|
135
|
-
}))
|
|
136
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
/**
|
|
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
|
|
21
|
-
/** Phone, @handle, or email depending on channel */
|
|
22
|
-
contactHandle: string
|
|
23
|
-
channel: Channel
|
|
24
|
-
lastMessagePreview: string
|
|
25
|
-
lastMessageAt: string
|
|
26
|
-
unreadCount: number
|
|
27
|
-
status: ConversationStatus
|
|
28
|
-
assignedTo?: string
|
|
29
|
-
/** Tailwind-ish accent for the avatar bubble */
|
|
30
|
-
accent: string
|
|
31
|
-
tags: string[]
|
|
32
|
-
// Optional context shown in the contact panel.
|
|
33
|
-
location?: string
|
|
34
|
-
note?: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface Message {
|
|
38
|
-
id: string
|
|
39
|
-
conversationId: string
|
|
40
|
-
channel: Channel
|
|
41
|
-
direction: MessageDirection
|
|
42
|
-
body: string
|
|
43
|
-
at: string
|
|
44
|
-
author: string
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface ListConversationsQuery {
|
|
48
|
-
channel?: Channel | 'all'
|
|
49
|
-
status?: ConversationStatus | 'all'
|
|
50
|
-
search?: string
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface SendMessageInput {
|
|
54
|
-
conversationId: string
|
|
55
|
-
body: string
|
|
56
|
-
}
|
|
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
|
-
|
|
71
|
-
export const CHANNEL_LABELS: Record<Channel, string> = {
|
|
72
|
-
sms: 'SMS',
|
|
73
|
-
whatsapp: 'WhatsApp',
|
|
74
|
-
instagram: 'Instagram',
|
|
75
|
-
email: 'Email',
|
|
76
|
-
webchat: 'Web Chat',
|
|
77
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { MapPin, User, Tag, Link2, StickyNote, X } from 'lucide-react'
|
|
3
|
-
import { Button, cn } from '@fayz-ai/ui'
|
|
4
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
-
import { CHANNEL_LABELS } from '../channel'
|
|
6
|
-
import type { Conversation } from '../types'
|
|
7
|
-
import { Avatar, ChannelBadge } from './shared'
|
|
8
|
-
|
|
9
|
-
function Section({ icon: Icon, title, children }: {
|
|
10
|
-
icon: React.ComponentType<{ className?: string }>
|
|
11
|
-
title: string
|
|
12
|
-
children: React.ReactNode
|
|
13
|
-
}) {
|
|
14
|
-
return (
|
|
15
|
-
<div className="border-t border-border px-4 py-3">
|
|
16
|
-
<p className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
17
|
-
<Icon className="h-3 w-3" /> {title}
|
|
18
|
-
</p>
|
|
19
|
-
{children}
|
|
20
|
-
</div>
|
|
21
|
-
)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function ContactPanel({ contact, onClose, className }: {
|
|
25
|
-
contact: Conversation
|
|
26
|
-
onClose?: () => void
|
|
27
|
-
className?: string
|
|
28
|
-
}) {
|
|
29
|
-
const t = useTranslation()
|
|
30
|
-
return (
|
|
31
|
-
<aside className={cn('flex shrink-0 flex-col overflow-y-auto border-l border-border bg-card', className)}>
|
|
32
|
-
{onClose && (
|
|
33
|
-
<div className="flex items-center justify-between border-b border-border px-3 py-2 xl:hidden">
|
|
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')}>
|
|
36
|
-
<X className="h-4 w-4" />
|
|
37
|
-
</Button>
|
|
38
|
-
</div>
|
|
39
|
-
)}
|
|
40
|
-
<div className="flex flex-col items-center gap-2 px-4 py-5 text-center">
|
|
41
|
-
<Avatar name={contact.contactName} accent={contact.accent} size="lg" channel={contact.channel} />
|
|
42
|
-
<div>
|
|
43
|
-
<p className="text-sm font-semibold text-foreground">{contact.contactName}</p>
|
|
44
|
-
<p className="text-xs text-muted-foreground">{contact.contactHandle}</p>
|
|
45
|
-
</div>
|
|
46
|
-
<ChannelBadge channel={contact.channel} />
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
<Section icon={User} title={t('conversations.contact.details')}>
|
|
50
|
-
<dl className="space-y-1.5 text-sm">
|
|
51
|
-
<div className="flex items-center justify-between gap-2">
|
|
52
|
-
<dt className="text-muted-foreground">{t('conversations.contact.channel')}</dt>
|
|
53
|
-
<dd className="text-foreground">{CHANNEL_LABELS[contact.channel]}</dd>
|
|
54
|
-
</div>
|
|
55
|
-
<div className="flex items-center justify-between gap-2">
|
|
56
|
-
<dt className="text-muted-foreground">{t('conversations.contact.status')}</dt>
|
|
57
|
-
<dd className="text-foreground">{t(`conversations.status.${contact.status}`)}</dd>
|
|
58
|
-
</div>
|
|
59
|
-
{contact.assignedTo && (
|
|
60
|
-
<div className="flex items-center justify-between gap-2">
|
|
61
|
-
<dt className="text-muted-foreground">{t('conversations.contact.assignedTo')}</dt>
|
|
62
|
-
<dd className="text-foreground">{contact.assignedTo}</dd>
|
|
63
|
-
</div>
|
|
64
|
-
)}
|
|
65
|
-
{contact.location && (
|
|
66
|
-
<div className="flex items-center justify-between gap-2">
|
|
67
|
-
<dt className="flex items-center gap-1 text-muted-foreground"><MapPin className="h-3 w-3" /> {t('conversations.contact.location')}</dt>
|
|
68
|
-
<dd className="text-foreground">{contact.location}</dd>
|
|
69
|
-
</div>
|
|
70
|
-
)}
|
|
71
|
-
</dl>
|
|
72
|
-
</Section>
|
|
73
|
-
|
|
74
|
-
{contact.tags.length > 0 && (
|
|
75
|
-
<Section icon={Tag} title={t('conversations.contact.tags')}>
|
|
76
|
-
<div className="flex flex-wrap gap-1.5">
|
|
77
|
-
{contact.tags.map((tag) => (
|
|
78
|
-
<span key={tag} className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-foreground">
|
|
79
|
-
{tag}
|
|
80
|
-
</span>
|
|
81
|
-
))}
|
|
82
|
-
</div>
|
|
83
|
-
</Section>
|
|
84
|
-
)}
|
|
85
|
-
|
|
86
|
-
{contact.note && (
|
|
87
|
-
<Section icon={StickyNote} title={t('conversations.contact.note')}>
|
|
88
|
-
<p className="text-sm text-foreground">{contact.note}</p>
|
|
89
|
-
</Section>
|
|
90
|
-
)}
|
|
91
|
-
|
|
92
|
-
<Section icon={Link2} title={t('conversations.contact.linkedRecords')}>
|
|
93
|
-
<p className="text-xs text-muted-foreground">{t('conversations.contact.noLinkedRecords')}</p>
|
|
94
|
-
</Section>
|
|
95
|
-
</aside>
|
|
96
|
-
)
|
|
97
|
-
}
|
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
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'
|
|
6
|
-
import { useConversationsStore } from '../ConversationsContext'
|
|
7
|
-
import type { Channel } from '../types'
|
|
8
|
-
import { Avatar, ChannelBadge, relativeTime } from './shared'
|
|
9
|
-
import { NewConversationModal } from './NewConversationModal'
|
|
10
|
-
|
|
11
|
-
const FILTERS: Array<Channel | 'all'> = ['all', 'whatsapp', 'sms', 'instagram', 'email', 'webchat']
|
|
12
|
-
|
|
13
|
-
export function ConversationList({ className }: { className?: string }) {
|
|
14
|
-
const t = useTranslation()
|
|
15
|
-
const {
|
|
16
|
-
conversations, selectedId, channelFilter, search, loading,
|
|
17
|
-
select, setChannelFilter, setSearch,
|
|
18
|
-
} = useConversationsStore((s) => s)
|
|
19
|
-
const [newOpen, setNewOpen] = React.useState(false)
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<aside className={cn('w-full shrink-0 flex-col border-r border-border bg-card lg:w-[320px]', className)}>
|
|
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>
|
|
38
|
-
<div className="relative">
|
|
39
|
-
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
40
|
-
<Input
|
|
41
|
-
value={search}
|
|
42
|
-
onChange={(e) => setSearch(e.target.value)}
|
|
43
|
-
placeholder={t('conversations.list.search')}
|
|
44
|
-
className="pl-8"
|
|
45
|
-
/>
|
|
46
|
-
</div>
|
|
47
|
-
<div className="mt-2 flex flex-wrap gap-1">
|
|
48
|
-
{FILTERS.map((id) => (
|
|
49
|
-
<button
|
|
50
|
-
key={id}
|
|
51
|
-
onClick={() => setChannelFilter(id)}
|
|
52
|
-
className={cn(
|
|
53
|
-
'rounded-full px-2.5 py-1 text-xs font-medium transition-colors',
|
|
54
|
-
channelFilter === id
|
|
55
|
-
? 'bg-primary text-primary-foreground'
|
|
56
|
-
: 'bg-muted text-muted-foreground hover:bg-muted/70',
|
|
57
|
-
)}
|
|
58
|
-
>
|
|
59
|
-
{t(`conversations.filter.${id}`)}
|
|
60
|
-
</button>
|
|
61
|
-
))}
|
|
62
|
-
</div>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
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
|
-
))}
|
|
80
|
-
{!loading && conversations.length === 0 && (
|
|
81
|
-
<div className="flex flex-col items-center gap-2 p-8 text-center text-muted-foreground">
|
|
82
|
-
<InboxIcon className="h-6 w-6" />
|
|
83
|
-
<p className="text-sm">{t('conversations.list.empty')}</p>
|
|
84
|
-
</div>
|
|
85
|
-
)}
|
|
86
|
-
{conversations.map((c) => {
|
|
87
|
-
const active = c.id === selectedId
|
|
88
|
-
const unread = c.unreadCount > 0
|
|
89
|
-
return (
|
|
90
|
-
<button
|
|
91
|
-
key={c.id}
|
|
92
|
-
onClick={() => select(c.id)}
|
|
93
|
-
className={cn(
|
|
94
|
-
'flex w-full items-start gap-3 border-b border-border/50 px-3 py-3 text-left transition-colors',
|
|
95
|
-
active ? 'bg-accent' : 'hover:bg-muted/50',
|
|
96
|
-
)}
|
|
97
|
-
>
|
|
98
|
-
<Avatar name={c.contactName} accent={c.accent} channel={c.channel} />
|
|
99
|
-
<div className="min-w-0 flex-1">
|
|
100
|
-
<div className="flex items-center justify-between gap-2">
|
|
101
|
-
<span className={cn('truncate text-sm text-foreground', unread ? 'font-semibold' : 'font-medium')}>
|
|
102
|
-
{c.contactName}
|
|
103
|
-
</span>
|
|
104
|
-
<span className={cn('shrink-0 text-[11px]', unread ? 'font-semibold text-primary' : 'text-muted-foreground')}>
|
|
105
|
-
{relativeTime(c.lastMessageAt)}
|
|
106
|
-
</span>
|
|
107
|
-
</div>
|
|
108
|
-
<div className="mt-1 flex items-center gap-2">
|
|
109
|
-
<ChannelBadge channel={c.channel} />
|
|
110
|
-
{c.status !== 'open' && (
|
|
111
|
-
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
|
112
|
-
{t(`conversations.status.${c.status}`)}
|
|
113
|
-
</span>
|
|
114
|
-
)}
|
|
115
|
-
</div>
|
|
116
|
-
<div className="mt-1 flex items-center justify-between gap-2">
|
|
117
|
-
<span className={cn('truncate text-xs', unread ? 'text-foreground' : 'text-muted-foreground')}>
|
|
118
|
-
{c.lastMessagePreview}
|
|
119
|
-
</span>
|
|
120
|
-
{unread && (
|
|
121
|
-
<span className="flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground">
|
|
122
|
-
{c.unreadCount}
|
|
123
|
-
</span>
|
|
124
|
-
)}
|
|
125
|
-
</div>
|
|
126
|
-
</div>
|
|
127
|
-
</button>
|
|
128
|
-
)
|
|
129
|
-
})}
|
|
130
|
-
</div>
|
|
131
|
-
|
|
132
|
-
<NewConversationModal open={newOpen} onOpenChange={setNewOpen} />
|
|
133
|
-
</aside>
|
|
134
|
-
)
|
|
135
|
-
}
|