@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.
Files changed (39) hide show
  1. package/README.md +4 -4
  2. package/dist/{ConversationsContext.d.ts → context.d.ts} +2 -2
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/{channel.d.ts → lib/channel.d.ts} +1 -1
  9. package/dist/lib/channel.d.ts.map +1 -0
  10. package/dist/{ConversationsPage.d.ts → views/ConversationsPage.d.ts} +2 -2
  11. package/dist/views/ConversationsPage.d.ts.map +1 -0
  12. package/package.json +5 -7
  13. package/dist/ConversationsContext.d.ts.map +0 -1
  14. package/dist/ConversationsPage.d.ts.map +0 -1
  15. package/dist/channel.d.ts.map +0 -1
  16. package/src/ConversationsContext.tsx +0 -51
  17. package/src/ConversationsPage.tsx +0 -21
  18. package/src/channel.ts +0 -34
  19. package/src/data/accents.ts +0 -12
  20. package/src/data/mock.test.ts +0 -90
  21. package/src/data/mock.ts +0 -261
  22. package/src/data/supabase.ts +0 -264
  23. package/src/data/tables.ts +0 -7
  24. package/src/data/types.ts +0 -17
  25. package/src/index.ts +0 -190
  26. package/src/locales/en.ts +0 -68
  27. package/src/locales/index.ts +0 -7
  28. package/src/locales/pt-BR.ts +0 -68
  29. package/src/migrations/001_conversations.sql +0 -74
  30. package/src/migrations/002_contact_person.sql +0 -22
  31. package/src/migrations/index.ts +0 -108
  32. package/src/store.ts +0 -136
  33. package/src/types.ts +0 -77
  34. package/src/views/ContactPanel.tsx +0 -97
  35. package/src/views/ConversationList.tsx +0 -135
  36. package/src/views/InboxView.tsx +0 -64
  37. package/src/views/MessageThread.tsx +0 -167
  38. package/src/views/NewConversationModal.tsx +0 -204
  39. 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;
@@ -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
- }