@fayz-ai/plugin-conversations 0.2.4 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ConversationsContext.d.ts +18 -1
- package/dist/ConversationsContext.d.ts.map +1 -1
- package/dist/ConversationsPage.d.ts +3 -1
- package/dist/ConversationsPage.d.ts.map +1 -1
- package/dist/data/accents.d.ts +3 -0
- package/dist/data/accents.d.ts.map +1 -0
- package/dist/data/mock.d.ts +7 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/mock.test.d.ts +2 -0
- package/dist/data/mock.test.d.ts.map +1 -0
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/data/tables.d.ts +5 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/types.d.ts +2 -1
- package/dist/data/types.d.ts.map +1 -1
- package/dist/index.cjs +664 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +16 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +667 -73
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/index.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +7 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/store.d.ts +2 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/ContactPanel.d.ts.map +1 -1
- package/dist/views/ConversationList.d.ts.map +1 -1
- package/dist/views/InboxView.d.ts.map +1 -1
- package/dist/views/MessageThread.d.ts.map +1 -1
- package/dist/views/NewConversationModal.d.ts +6 -0
- package/dist/views/NewConversationModal.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/ConversationsContext.tsx +31 -1
- package/src/ConversationsPage.tsx +6 -3
- package/src/data/accents.ts +12 -0
- package/src/data/mock.test.ts +90 -0
- package/src/data/mock.ts +131 -12
- package/src/data/supabase.ts +69 -7
- package/src/data/tables.ts +7 -0
- package/src/data/types.ts +2 -0
- package/src/index.ts +69 -11
- package/src/locales/en.ts +64 -0
- package/src/locales/index.ts +2 -0
- package/src/locales/pt-BR.ts +68 -0
- package/src/migrations/001_conversations.sql +74 -0
- package/src/migrations/002_contact_person.sql +22 -0
- package/src/migrations/index.ts +108 -0
- package/src/store.ts +44 -1
- package/src/types.ts +19 -0
- package/src/views/ContactPanel.tsx +14 -12
- package/src/views/ConversationList.tsx +48 -21
- package/src/views/InboxView.tsx +3 -1
- package/src/views/MessageThread.tsx +14 -16
- package/src/views/NewConversationModal.tsx +204 -0
package/dist/index.js
CHANGED
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import React3 from 'react';
|
|
2
|
-
import { registerTranslations, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core';
|
|
2
|
+
import { registerTranslations, getSupabaseClientOptional, getActiveTenantId, useTranslation } from '@fayz-ai/core';
|
|
3
3
|
import { useStore } from 'zustand';
|
|
4
4
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
5
|
-
import { MessageSquare, Search, Inbox, ChevronLeft, Clock, Archive, PanelRight,
|
|
6
|
-
import { cn, Input,
|
|
5
|
+
import { MessageSquare, Plus, Search, Inbox, ChevronLeft, Clock, Archive, PanelRight, Send, X, User, MapPin, Tag, StickyNote, Link2, Globe, Mail, Instagram, Phone } from 'lucide-react';
|
|
6
|
+
import { cn, Button, Input, Skeleton, Modal, ModalContent, toast } from '@fayz-ai/ui';
|
|
7
|
+
import { PermissionGate, useLimitGuard, ContactPicker, invalidateLimit } from '@fayz-ai/saas';
|
|
7
8
|
import { createStore } from 'zustand/vanilla';
|
|
8
9
|
|
|
9
10
|
// src/index.ts
|
|
11
|
+
var DEFAULT_CONVERSATIONS_CONFIG = {
|
|
12
|
+
contactKind: "contact"
|
|
13
|
+
};
|
|
10
14
|
var StoreContext = React3.createContext(null);
|
|
15
|
+
var ConfigContext = React3.createContext(DEFAULT_CONVERSATIONS_CONFIG);
|
|
11
16
|
function ConversationsContextProvider({
|
|
12
17
|
store,
|
|
18
|
+
config = DEFAULT_CONVERSATIONS_CONFIG,
|
|
13
19
|
children
|
|
14
20
|
}) {
|
|
15
|
-
return /* @__PURE__ */ jsx(StoreContext.Provider, { value: store, children });
|
|
21
|
+
return /* @__PURE__ */ jsx(StoreContext.Provider, { value: store, children: /* @__PURE__ */ jsx(ConfigContext.Provider, { value: config, children }) });
|
|
16
22
|
}
|
|
17
23
|
function useConversationsStore(selector) {
|
|
18
24
|
const store = React3.useContext(StoreContext);
|
|
19
25
|
if (!store) throw new Error("useConversationsStore must be used within ConversationsPage");
|
|
20
26
|
return useStore(store, selector);
|
|
21
27
|
}
|
|
28
|
+
function useConversationsConfig() {
|
|
29
|
+
return React3.useContext(ConfigContext);
|
|
30
|
+
}
|
|
22
31
|
|
|
23
32
|
// src/types.ts
|
|
24
33
|
var CHANNEL_LABELS = {
|
|
@@ -121,15 +130,147 @@ function dayLabel(iso) {
|
|
|
121
130
|
if (dayDiff < 7) return d.toLocaleDateString([], { weekday: "long" });
|
|
122
131
|
return d.toLocaleDateString([], { month: "short", day: "numeric", year: now.getFullYear() === d.getFullYear() ? void 0 : "numeric" });
|
|
123
132
|
}
|
|
124
|
-
var
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
var CHANNELS = ["whatsapp", "sms", "instagram", "email", "webchat"];
|
|
134
|
+
function personHandleFor(channel, contact) {
|
|
135
|
+
if (!contact) return "";
|
|
136
|
+
if (channel === "email") return contact.email ?? "";
|
|
137
|
+
if (channel === "sms" || channel === "whatsapp") return contact.phone ?? "";
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
var HANDLE_LABEL_KEY = {
|
|
141
|
+
whatsapp: "conversations.new.handleLabel.phone",
|
|
142
|
+
sms: "conversations.new.handleLabel.phone",
|
|
143
|
+
email: "conversations.new.handleLabel.email",
|
|
144
|
+
instagram: "conversations.new.handleLabel.instagram",
|
|
145
|
+
webchat: "conversations.new.handleLabel.webchat"
|
|
146
|
+
};
|
|
147
|
+
function NewConversationModal({
|
|
148
|
+
open,
|
|
149
|
+
onOpenChange
|
|
150
|
+
}) {
|
|
151
|
+
const t = useTranslation();
|
|
152
|
+
const create = useConversationsStore((s) => s.create);
|
|
153
|
+
const config = useConversationsConfig();
|
|
154
|
+
const guardConversations = useLimitGuard("conversations_month");
|
|
155
|
+
const [channel, setChannel] = React3.useState("whatsapp");
|
|
156
|
+
const [contact, setContact] = React3.useState(null);
|
|
157
|
+
const [typedHandle, setTypedHandle] = React3.useState("");
|
|
158
|
+
const [creatingContact, setCreatingContact] = React3.useState(false);
|
|
159
|
+
const [firstMessage, setFirstMessage] = React3.useState("");
|
|
160
|
+
const [submitting, setSubmitting] = React3.useState(false);
|
|
161
|
+
const [pickerKey, setPickerKey] = React3.useState(0);
|
|
162
|
+
React3.useEffect(() => {
|
|
163
|
+
if (open) {
|
|
164
|
+
setChannel("whatsapp");
|
|
165
|
+
setContact(null);
|
|
166
|
+
setTypedHandle("");
|
|
167
|
+
setCreatingContact(false);
|
|
168
|
+
setFirstMessage("");
|
|
169
|
+
setSubmitting(false);
|
|
170
|
+
setPickerKey((k) => k + 1);
|
|
171
|
+
}
|
|
172
|
+
}, [open]);
|
|
173
|
+
const derivedHandle = personHandleFor(channel, contact);
|
|
174
|
+
const effectiveHandle = derivedHandle || typedHandle;
|
|
175
|
+
const handleLabel = t(HANDLE_LABEL_KEY[channel]);
|
|
176
|
+
const canSubmit = (contact?.name.trim().length ?? 0) > 0 && !submitting;
|
|
177
|
+
async function handleSubmit(e) {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
if (!canSubmit) return;
|
|
180
|
+
setSubmitting(true);
|
|
181
|
+
try {
|
|
182
|
+
if (await guardConversations() === "blocked") return;
|
|
183
|
+
await create({
|
|
184
|
+
channel,
|
|
185
|
+
contactName: contact.name.trim(),
|
|
186
|
+
contactPersonId: contact?.id,
|
|
187
|
+
contactHandle: effectiveHandle.trim() || void 0,
|
|
188
|
+
firstMessage: firstMessage.trim() || void 0
|
|
189
|
+
});
|
|
190
|
+
invalidateLimit("conversations_month");
|
|
191
|
+
onOpenChange(false);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
194
|
+
toast.error(t("conversations.new.createFailed"), { description: message });
|
|
195
|
+
} finally {
|
|
196
|
+
setSubmitting(false);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return /* @__PURE__ */ jsx(Modal, { open, onOpenChange, children: /* @__PURE__ */ jsx(ModalContent, { size: "md", children: /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className: "flex flex-col gap-4", children: [
|
|
200
|
+
/* @__PURE__ */ jsx("h2", { className: "text-base font-semibold text-foreground", children: t("conversations.new.title") }),
|
|
201
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
202
|
+
/* @__PURE__ */ jsx("label", { className: "mb-1.5 block text-xs font-medium text-muted-foreground", children: t("conversations.new.channel") }),
|
|
203
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1.5", children: CHANNELS.map((ch) => {
|
|
204
|
+
const Icon = CHANNEL_ICON[ch];
|
|
205
|
+
const active = channel === ch;
|
|
206
|
+
return /* @__PURE__ */ jsxs(
|
|
207
|
+
"button",
|
|
208
|
+
{
|
|
209
|
+
type: "button",
|
|
210
|
+
onClick: () => setChannel(ch),
|
|
211
|
+
"aria-pressed": active,
|
|
212
|
+
className: cn(
|
|
213
|
+
"inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
|
|
214
|
+
active ? "text-white" : "bg-muted text-muted-foreground hover:bg-muted/70"
|
|
215
|
+
),
|
|
216
|
+
style: active ? { backgroundColor: CHANNEL_ACCENT[ch].color } : void 0,
|
|
217
|
+
children: [
|
|
218
|
+
/* @__PURE__ */ jsx(Icon, { className: "h-3 w-3" }),
|
|
219
|
+
CHANNEL_LABELS[ch]
|
|
220
|
+
]
|
|
221
|
+
},
|
|
222
|
+
ch
|
|
223
|
+
);
|
|
224
|
+
}) })
|
|
225
|
+
] }),
|
|
226
|
+
/* @__PURE__ */ jsx(
|
|
227
|
+
ContactPicker,
|
|
228
|
+
{
|
|
229
|
+
value: contact,
|
|
230
|
+
onChange: setContact,
|
|
231
|
+
kind: config.contactKind,
|
|
232
|
+
extensionTable: config.contactExtensionTable,
|
|
233
|
+
lookup: config.contactLookup,
|
|
234
|
+
allowFreeText: true,
|
|
235
|
+
onCreatingChange: setCreatingContact,
|
|
236
|
+
autoFocus: true,
|
|
237
|
+
label: t("conversations.new.contactName"),
|
|
238
|
+
placeholder: t("conversations.new.contactNamePlaceholder"),
|
|
239
|
+
secondaryText: derivedHandle || void 0,
|
|
240
|
+
handleField: {
|
|
241
|
+
label: handleLabel,
|
|
242
|
+
derived: derivedHandle || void 0,
|
|
243
|
+
value: typedHandle,
|
|
244
|
+
onChange: setTypedHandle,
|
|
245
|
+
fieldLabel: t("conversations.new.handle"),
|
|
246
|
+
placeholder: t("conversations.new.handlePlaceholder")
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
pickerKey
|
|
250
|
+
),
|
|
251
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
252
|
+
/* @__PURE__ */ jsx("label", { htmlFor: "conv-first-message", className: "mb-1.5 block text-xs font-medium text-muted-foreground", children: t("conversations.new.firstMessage") }),
|
|
253
|
+
/* @__PURE__ */ jsx(
|
|
254
|
+
"textarea",
|
|
255
|
+
{
|
|
256
|
+
id: "conv-first-message",
|
|
257
|
+
value: firstMessage,
|
|
258
|
+
onChange: (e) => setFirstMessage(e.target.value),
|
|
259
|
+
rows: 3,
|
|
260
|
+
placeholder: t("conversations.new.firstMessagePlaceholder"),
|
|
261
|
+
className: "max-h-40 min-h-[64px] w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
] }),
|
|
265
|
+
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex justify-end gap-2", children: [
|
|
266
|
+
/* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), children: t("conversations.new.cancel") }),
|
|
267
|
+
/* @__PURE__ */ jsx(Button, { type: "submit", disabled: !canSubmit, children: submitting ? t("conversations.new.creating") : t("conversations.new.create") })
|
|
268
|
+
] })
|
|
269
|
+
] }) }) });
|
|
270
|
+
}
|
|
271
|
+
var FILTERS = ["all", "whatsapp", "sms", "instagram", "email", "webchat"];
|
|
132
272
|
function ConversationList({ className }) {
|
|
273
|
+
const t = useTranslation();
|
|
133
274
|
const {
|
|
134
275
|
conversations,
|
|
135
276
|
selectedId,
|
|
@@ -140,8 +281,25 @@ function ConversationList({ className }) {
|
|
|
140
281
|
setChannelFilter,
|
|
141
282
|
setSearch
|
|
142
283
|
} = useConversationsStore((s) => s);
|
|
284
|
+
const [newOpen, setNewOpen] = React3.useState(false);
|
|
143
285
|
return /* @__PURE__ */ jsxs("aside", { className: cn("w-full shrink-0 flex-col border-r border-border bg-card lg:w-[320px]", className), children: [
|
|
144
286
|
/* @__PURE__ */ jsxs("div", { className: "border-b border-border px-3 py-3", children: [
|
|
287
|
+
/* @__PURE__ */ jsxs("div", { className: "mb-2 flex items-center justify-between gap-2", children: [
|
|
288
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-semibold text-foreground", children: t("conversations.title") }),
|
|
289
|
+
/* @__PURE__ */ jsx(PermissionGate, { feature: "conversations", action: "create", children: /* @__PURE__ */ jsxs(
|
|
290
|
+
Button,
|
|
291
|
+
{
|
|
292
|
+
size: "sm",
|
|
293
|
+
onClick: () => setNewOpen(true),
|
|
294
|
+
"aria-label": t("conversations.list.new"),
|
|
295
|
+
"data-testid": "conversations-new",
|
|
296
|
+
children: [
|
|
297
|
+
/* @__PURE__ */ jsx(Plus, { className: "h-3.5 w-3.5 sm:mr-1" }),
|
|
298
|
+
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: t("conversations.list.new") })
|
|
299
|
+
]
|
|
300
|
+
}
|
|
301
|
+
) })
|
|
302
|
+
] }),
|
|
145
303
|
/* @__PURE__ */ jsxs("div", { className: "relative", children: [
|
|
146
304
|
/* @__PURE__ */ jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }),
|
|
147
305
|
/* @__PURE__ */ jsx(
|
|
@@ -149,29 +307,39 @@ function ConversationList({ className }) {
|
|
|
149
307
|
{
|
|
150
308
|
value: search,
|
|
151
309
|
onChange: (e) => setSearch(e.target.value),
|
|
152
|
-
placeholder: "
|
|
310
|
+
placeholder: t("conversations.list.search"),
|
|
153
311
|
className: "pl-8"
|
|
154
312
|
}
|
|
155
313
|
)
|
|
156
314
|
] }),
|
|
157
|
-
/* @__PURE__ */ jsx("div", { className: "mt-2 flex flex-wrap gap-1", children: FILTERS.map((
|
|
315
|
+
/* @__PURE__ */ jsx("div", { className: "mt-2 flex flex-wrap gap-1", children: FILTERS.map((id) => /* @__PURE__ */ jsx(
|
|
158
316
|
"button",
|
|
159
317
|
{
|
|
160
|
-
onClick: () => setChannelFilter(
|
|
318
|
+
onClick: () => setChannelFilter(id),
|
|
161
319
|
className: cn(
|
|
162
320
|
"rounded-full px-2.5 py-1 text-xs font-medium transition-colors",
|
|
163
|
-
channelFilter ===
|
|
321
|
+
channelFilter === id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-muted/70"
|
|
164
322
|
),
|
|
165
|
-
children:
|
|
323
|
+
children: t(`conversations.filter.${id}`)
|
|
166
324
|
},
|
|
167
|
-
|
|
325
|
+
id
|
|
168
326
|
)) })
|
|
169
327
|
] }),
|
|
170
328
|
/* @__PURE__ */ jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto", children: [
|
|
171
|
-
loading && conversations.length === 0 && /* @__PURE__ */
|
|
329
|
+
loading && conversations.length === 0 && Array.from({ length: 6 }, (_, i) => /* @__PURE__ */ jsxs("div", { className: "flex w-full items-start gap-3 border-b border-border/50 px-3 py-3", children: [
|
|
330
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-10 shrink-0 rounded-full" }),
|
|
331
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
|
|
332
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
333
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-28" }),
|
|
334
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "h-3 w-8" })
|
|
335
|
+
] }),
|
|
336
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "mt-1.5 h-4 w-16 rounded-full" }),
|
|
337
|
+
/* @__PURE__ */ jsx(Skeleton, { className: "mt-1.5 h-3 w-3/4" })
|
|
338
|
+
] })
|
|
339
|
+
] }, i)),
|
|
172
340
|
!loading && conversations.length === 0 && /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2 p-8 text-center text-muted-foreground", children: [
|
|
173
341
|
/* @__PURE__ */ jsx(Inbox, { className: "h-6 w-6" }),
|
|
174
|
-
/* @__PURE__ */ jsx("p", { className: "text-sm", children: "
|
|
342
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm", children: t("conversations.list.empty") })
|
|
175
343
|
] }),
|
|
176
344
|
conversations.map((c) => {
|
|
177
345
|
const active = c.id === selectedId;
|
|
@@ -193,7 +361,7 @@ function ConversationList({ className }) {
|
|
|
193
361
|
] }),
|
|
194
362
|
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center gap-2", children: [
|
|
195
363
|
/* @__PURE__ */ jsx(ChannelBadge, { channel: c.channel }),
|
|
196
|
-
c.status !== "open" && /* @__PURE__ */ jsx("span", { className: "text-[10px] uppercase tracking-wide text-muted-foreground/70", children: c.status })
|
|
364
|
+
c.status !== "open" && /* @__PURE__ */ jsx("span", { className: "text-[10px] uppercase tracking-wide text-muted-foreground/70", children: t(`conversations.status.${c.status}`) })
|
|
197
365
|
] }),
|
|
198
366
|
/* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center justify-between gap-2", children: [
|
|
199
367
|
/* @__PURE__ */ jsx("span", { className: cn("truncate text-xs", unread ? "text-foreground" : "text-muted-foreground"), children: c.lastMessagePreview }),
|
|
@@ -205,7 +373,8 @@ function ConversationList({ className }) {
|
|
|
205
373
|
c.id
|
|
206
374
|
);
|
|
207
375
|
})
|
|
208
|
-
] })
|
|
376
|
+
] }),
|
|
377
|
+
/* @__PURE__ */ jsx(NewConversationModal, { open: newOpen, onOpenChange: setNewOpen })
|
|
209
378
|
] });
|
|
210
379
|
}
|
|
211
380
|
function buildRows(messages) {
|
|
@@ -226,6 +395,7 @@ function buildRows(messages) {
|
|
|
226
395
|
return rows;
|
|
227
396
|
}
|
|
228
397
|
function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }) {
|
|
398
|
+
const t = useTranslation();
|
|
229
399
|
const { messages, sending, send, setStatus } = useConversationsStore((s) => s);
|
|
230
400
|
const [draft, setDraft] = React3.useState("");
|
|
231
401
|
const threadRef = React3.useRef(null);
|
|
@@ -243,7 +413,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
243
413
|
return /* @__PURE__ */ jsxs("section", { className: cn("flex min-w-0 flex-1 flex-col bg-muted/20", className), children: [
|
|
244
414
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-2.5 md:px-5", children: [
|
|
245
415
|
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 items-center gap-2 md:gap-3", children: [
|
|
246
|
-
onBack && /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", className: "-ml-1 shrink-0 lg:hidden", onClick: onBack, "aria-label": "
|
|
416
|
+
onBack && /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", className: "-ml-1 shrink-0 lg:hidden", onClick: onBack, "aria-label": t("conversations.thread.back"), children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-5 w-5" }) }),
|
|
247
417
|
/* @__PURE__ */ jsx(Avatar, { name: selected.contactName, accent: selected.accent, size: "sm", channel: selected.channel }),
|
|
248
418
|
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
249
419
|
/* @__PURE__ */ jsx("p", { className: "truncate text-sm font-semibold text-foreground", children: selected.contactName }),
|
|
@@ -254,15 +424,15 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
254
424
|
] })
|
|
255
425
|
] }),
|
|
256
426
|
/* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-1.5", children: [
|
|
257
|
-
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("snoozed"), "aria-label": "
|
|
427
|
+
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("snoozed"), "aria-label": t("conversations.thread.snooze"), children: [
|
|
258
428
|
/* @__PURE__ */ jsx(Clock, { className: "h-3.5 w-3.5 sm:mr-1" }),
|
|
259
429
|
" ",
|
|
260
|
-
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "
|
|
430
|
+
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: t("conversations.thread.snooze") })
|
|
261
431
|
] }),
|
|
262
|
-
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("closed"), "aria-label": "
|
|
432
|
+
/* @__PURE__ */ jsxs(Button, { variant: "outline", size: "sm", onClick: () => setStatus("closed"), "aria-label": t("conversations.thread.close"), children: [
|
|
263
433
|
/* @__PURE__ */ jsx(Archive, { className: "h-3.5 w-3.5 sm:mr-1" }),
|
|
264
434
|
" ",
|
|
265
|
-
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: "
|
|
435
|
+
/* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: t("conversations.thread.close") })
|
|
266
436
|
] }),
|
|
267
437
|
/* @__PURE__ */ jsx(
|
|
268
438
|
Button,
|
|
@@ -270,7 +440,7 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
270
440
|
variant: panelOpen ? "secondary" : "ghost",
|
|
271
441
|
size: "icon",
|
|
272
442
|
onClick: onTogglePanel,
|
|
273
|
-
"aria-label": "
|
|
443
|
+
"aria-label": t("conversations.thread.details"),
|
|
274
444
|
children: /* @__PURE__ */ jsx(PanelRight, { className: "h-4 w-4" })
|
|
275
445
|
}
|
|
276
446
|
)
|
|
@@ -310,12 +480,10 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
310
480
|
}),
|
|
311
481
|
rows.length === 0 && /* @__PURE__ */ jsxs("div", { className: "flex h-full flex-col items-center justify-center text-muted-foreground", children: [
|
|
312
482
|
/* @__PURE__ */ jsx(MessageSquare, { className: "h-7 w-7" }),
|
|
313
|
-
/* @__PURE__ */ jsx("p", { className: "mt-2 text-sm", children: "
|
|
483
|
+
/* @__PURE__ */ jsx("p", { className: "mt-2 text-sm", children: t("conversations.thread.empty") })
|
|
314
484
|
] })
|
|
315
485
|
] }),
|
|
316
486
|
/* @__PURE__ */ jsx("div", { className: "border-t border-border bg-card px-4 py-3", children: /* @__PURE__ */ jsxs("div", { className: "flex items-end gap-2", children: [
|
|
317
|
-
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", className: "mb-0.5 text-muted-foreground", "aria-label": "Add emoji", children: /* @__PURE__ */ jsx(Smile, { className: "h-4 w-4" }) }),
|
|
318
|
-
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", className: "mb-0.5 text-muted-foreground", "aria-label": "Attach file", children: /* @__PURE__ */ jsx(Paperclip, { className: "h-4 w-4" }) }),
|
|
319
487
|
/* @__PURE__ */ jsx(
|
|
320
488
|
"textarea",
|
|
321
489
|
{
|
|
@@ -328,11 +496,11 @@ function MessageThread({ selected, onTogglePanel, panelOpen, onBack, className }
|
|
|
328
496
|
}
|
|
329
497
|
},
|
|
330
498
|
rows: 1,
|
|
331
|
-
placeholder:
|
|
499
|
+
placeholder: t("conversations.thread.reply", { channel: CHANNEL_LABELS[selected.channel] }),
|
|
332
500
|
className: "max-h-32 min-h-[40px] flex-1 resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
|
|
333
501
|
}
|
|
334
502
|
),
|
|
335
|
-
/* @__PURE__ */ jsx(Button, { onClick: () => void handleSend(), disabled: sending || !draft.trim(), "aria-label": "
|
|
503
|
+
/* @__PURE__ */ jsx(Button, { onClick: () => void handleSend(), disabled: sending || !draft.trim(), "aria-label": t("conversations.thread.send"), children: /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" }) })
|
|
336
504
|
] }) })
|
|
337
505
|
] });
|
|
338
506
|
}
|
|
@@ -347,10 +515,11 @@ function Section({ icon: Icon, title, children }) {
|
|
|
347
515
|
] });
|
|
348
516
|
}
|
|
349
517
|
function ContactPanel({ contact, onClose, className }) {
|
|
518
|
+
const t = useTranslation();
|
|
350
519
|
return /* @__PURE__ */ jsxs("aside", { className: cn("flex shrink-0 flex-col overflow-y-auto border-l border-border bg-card", className), children: [
|
|
351
520
|
onClose && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between border-b border-border px-3 py-2 xl:hidden", children: [
|
|
352
|
-
/* @__PURE__ */ jsx("span", { className: "text-sm font-semibold text-foreground", children: "
|
|
353
|
-
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", onClick: onClose, "aria-label": "
|
|
521
|
+
/* @__PURE__ */ jsx("span", { className: "text-sm font-semibold text-foreground", children: t("conversations.contact.details") }),
|
|
522
|
+
/* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", onClick: onClose, "aria-label": t("conversations.contact.closeDetails"), children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4" }) })
|
|
354
523
|
] }),
|
|
355
524
|
/* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-2 px-4 py-5 text-center", children: [
|
|
356
525
|
/* @__PURE__ */ jsx(Avatar, { name: contact.contactName, accent: contact.accent, size: "lg", channel: contact.channel }),
|
|
@@ -360,33 +529,35 @@ function ContactPanel({ contact, onClose, className }) {
|
|
|
360
529
|
] }),
|
|
361
530
|
/* @__PURE__ */ jsx(ChannelBadge, { channel: contact.channel })
|
|
362
531
|
] }),
|
|
363
|
-
/* @__PURE__ */ jsx(Section, { icon: User, title: "
|
|
532
|
+
/* @__PURE__ */ jsx(Section, { icon: User, title: t("conversations.contact.details"), children: /* @__PURE__ */ jsxs("dl", { className: "space-y-1.5 text-sm", children: [
|
|
364
533
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
365
|
-
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: "
|
|
534
|
+
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.channel") }),
|
|
366
535
|
/* @__PURE__ */ jsx("dd", { className: "text-foreground", children: CHANNEL_LABELS[contact.channel] })
|
|
367
536
|
] }),
|
|
368
537
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
369
|
-
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: "
|
|
370
|
-
/* @__PURE__ */ jsx("dd", { className: "
|
|
538
|
+
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.status") }),
|
|
539
|
+
/* @__PURE__ */ jsx("dd", { className: "text-foreground", children: t(`conversations.status.${contact.status}`) })
|
|
371
540
|
] }),
|
|
372
541
|
contact.assignedTo && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
373
|
-
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: "
|
|
542
|
+
/* @__PURE__ */ jsx("dt", { className: "text-muted-foreground", children: t("conversations.contact.assignedTo") }),
|
|
374
543
|
/* @__PURE__ */ jsx("dd", { className: "text-foreground", children: contact.assignedTo })
|
|
375
544
|
] }),
|
|
376
545
|
contact.location && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
|
|
377
546
|
/* @__PURE__ */ jsxs("dt", { className: "flex items-center gap-1 text-muted-foreground", children: [
|
|
378
547
|
/* @__PURE__ */ jsx(MapPin, { className: "h-3 w-3" }),
|
|
379
|
-
"
|
|
548
|
+
" ",
|
|
549
|
+
t("conversations.contact.location")
|
|
380
550
|
] }),
|
|
381
551
|
/* @__PURE__ */ jsx("dd", { className: "text-foreground", children: contact.location })
|
|
382
552
|
] })
|
|
383
553
|
] }) }),
|
|
384
|
-
contact.tags.length > 0 && /* @__PURE__ */ jsx(Section, { icon: Tag, title: "
|
|
385
|
-
contact.note && /* @__PURE__ */ jsx(Section, { icon: StickyNote, title: "
|
|
386
|
-
/* @__PURE__ */ jsx(Section, { icon: Link2, title: "
|
|
554
|
+
contact.tags.length > 0 && /* @__PURE__ */ jsx(Section, { icon: Tag, title: t("conversations.contact.tags"), children: /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1.5", children: contact.tags.map((tag) => /* @__PURE__ */ jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-foreground", children: tag }, tag)) }) }),
|
|
555
|
+
contact.note && /* @__PURE__ */ jsx(Section, { icon: StickyNote, title: t("conversations.contact.note"), children: /* @__PURE__ */ jsx("p", { className: "text-sm text-foreground", children: contact.note }) }),
|
|
556
|
+
/* @__PURE__ */ jsx(Section, { icon: Link2, title: t("conversations.contact.linkedRecords"), children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("conversations.contact.noLinkedRecords") }) })
|
|
387
557
|
] });
|
|
388
558
|
}
|
|
389
559
|
function InboxView() {
|
|
560
|
+
const t = useTranslation();
|
|
390
561
|
const { conversations, selectedId, deselect } = useConversationsStore((s) => s);
|
|
391
562
|
const isWidePanel = useMediaQuery("(min-width: 1280px)");
|
|
392
563
|
const [panelOpen, setPanelOpen] = React3.useState(false);
|
|
@@ -407,7 +578,7 @@ function InboxView() {
|
|
|
407
578
|
}
|
|
408
579
|
) : /* @__PURE__ */ jsxs("section", { className: "hidden min-w-0 flex-1 flex-col items-center justify-center bg-muted/20 text-muted-foreground lg:flex", children: [
|
|
409
580
|
/* @__PURE__ */ jsx(MessageSquare, { className: "h-9 w-9" }),
|
|
410
|
-
/* @__PURE__ */ jsx("p", { className: "mt-2 text-sm", children: "
|
|
581
|
+
/* @__PURE__ */ jsx("p", { className: "mt-2 text-sm", children: t("conversations.empty.select") })
|
|
411
582
|
] }),
|
|
412
583
|
selected && panelOpen && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
413
584
|
/* @__PURE__ */ jsx(
|
|
@@ -429,13 +600,22 @@ function InboxView() {
|
|
|
429
600
|
] })
|
|
430
601
|
] });
|
|
431
602
|
}
|
|
432
|
-
function ConversationsPage({ store }) {
|
|
603
|
+
function ConversationsPage({ store, config }) {
|
|
433
604
|
React3.useEffect(() => {
|
|
434
605
|
void store.getState().load();
|
|
435
606
|
}, []);
|
|
436
|
-
return /* @__PURE__ */ jsx(ConversationsContextProvider, { store, children: /* @__PURE__ */ jsx(InboxView, {}) });
|
|
607
|
+
return /* @__PURE__ */ jsx(ConversationsContextProvider, { store, config, children: /* @__PURE__ */ jsx(InboxView, {}) });
|
|
437
608
|
}
|
|
438
609
|
|
|
610
|
+
// src/data/accents.ts
|
|
611
|
+
var CHANNEL_ACCENT_HEX = {
|
|
612
|
+
whatsapp: "#22c55e",
|
|
613
|
+
sms: "#6366f1",
|
|
614
|
+
instagram: "#ec4899",
|
|
615
|
+
email: "#0ea5e9",
|
|
616
|
+
webchat: "#f59e0b"
|
|
617
|
+
};
|
|
618
|
+
|
|
439
619
|
// src/data/mock.ts
|
|
440
620
|
function minutesAgo(base, mins) {
|
|
441
621
|
return new Date(base - mins * 6e4).toISOString();
|
|
@@ -545,12 +725,59 @@ function seed() {
|
|
|
545
725
|
function msg(id, conversationId, channel, direction, body, author, at) {
|
|
546
726
|
return { id, conversationId, channel, direction, body, author, at };
|
|
547
727
|
}
|
|
548
|
-
function createMockConversationsProvider() {
|
|
549
|
-
const
|
|
550
|
-
|
|
728
|
+
function createMockConversationsProvider(config) {
|
|
729
|
+
const selfAuthor = config?.selfAuthor ?? "You";
|
|
730
|
+
function resolveTenant() {
|
|
731
|
+
const raw = typeof config?.tenantId === "function" ? config.tenantId() : config?.tenantId;
|
|
732
|
+
return raw || "default";
|
|
733
|
+
}
|
|
734
|
+
const storageKey = () => `saas:mock:conversations:${resolveTenant()}`;
|
|
735
|
+
function hasStorage() {
|
|
736
|
+
try {
|
|
737
|
+
return typeof window !== "undefined" && !!window.localStorage;
|
|
738
|
+
} catch {
|
|
739
|
+
return false;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function load() {
|
|
743
|
+
if (hasStorage()) {
|
|
744
|
+
try {
|
|
745
|
+
const raw = window.localStorage.getItem(storageKey());
|
|
746
|
+
if (raw) {
|
|
747
|
+
const parsed = JSON.parse(raw);
|
|
748
|
+
if (Array.isArray(parsed.conversations) && Array.isArray(parsed.messages)) {
|
|
749
|
+
return {
|
|
750
|
+
conversations: parsed.conversations,
|
|
751
|
+
messages: parsed.messages,
|
|
752
|
+
counter: parsed.counter ?? 100
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
} catch {
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const seeded = seed();
|
|
760
|
+
return { conversations: seeded.conversations, messages: seeded.messages, counter: 100 };
|
|
761
|
+
}
|
|
762
|
+
const state = load();
|
|
763
|
+
function persist() {
|
|
764
|
+
if (!hasStorage()) return;
|
|
765
|
+
try {
|
|
766
|
+
window.localStorage.setItem(
|
|
767
|
+
storageKey(),
|
|
768
|
+
JSON.stringify({
|
|
769
|
+
conversations: state.conversations,
|
|
770
|
+
messages: state.messages,
|
|
771
|
+
counter: state.counter
|
|
772
|
+
})
|
|
773
|
+
);
|
|
774
|
+
} catch {
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
persist();
|
|
551
778
|
return {
|
|
552
779
|
async listConversations(query) {
|
|
553
|
-
let list = [...conversations];
|
|
780
|
+
let list = [...state.conversations];
|
|
554
781
|
if (query?.channel && query.channel !== "all") list = list.filter((c) => c.channel === query.channel);
|
|
555
782
|
if (query?.status && query.status !== "all") list = list.filter((c) => c.status === query.status);
|
|
556
783
|
if (query?.search) {
|
|
@@ -562,44 +789,92 @@ function createMockConversationsProvider() {
|
|
|
562
789
|
return list.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt));
|
|
563
790
|
},
|
|
564
791
|
async getMessages(conversationId) {
|
|
565
|
-
return messages.filter((m) => m.conversationId === conversationId).sort((a, b) => a.at.localeCompare(b.at));
|
|
792
|
+
return state.messages.filter((m) => m.conversationId === conversationId).sort((a, b) => a.at.localeCompare(b.at));
|
|
793
|
+
},
|
|
794
|
+
async createConversation(input) {
|
|
795
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
796
|
+
const firstMessage = input.firstMessage?.trim();
|
|
797
|
+
const id = `c${++state.counter}`;
|
|
798
|
+
const conversation = {
|
|
799
|
+
id,
|
|
800
|
+
contactName: input.contactName.trim(),
|
|
801
|
+
contactPersonId: input.contactPersonId,
|
|
802
|
+
contactHandle: input.contactHandle?.trim() ?? "",
|
|
803
|
+
channel: input.channel,
|
|
804
|
+
lastMessagePreview: firstMessage ?? "",
|
|
805
|
+
lastMessageAt: now,
|
|
806
|
+
unreadCount: 0,
|
|
807
|
+
status: "open",
|
|
808
|
+
assignedTo: selfAuthor,
|
|
809
|
+
accent: CHANNEL_ACCENT_HEX[input.channel],
|
|
810
|
+
tags: [],
|
|
811
|
+
note: input.note?.trim() || void 0
|
|
812
|
+
};
|
|
813
|
+
state.conversations.unshift(conversation);
|
|
814
|
+
if (firstMessage) {
|
|
815
|
+
state.messages.push({
|
|
816
|
+
id: `m${++state.counter}`,
|
|
817
|
+
conversationId: id,
|
|
818
|
+
channel: input.channel,
|
|
819
|
+
direction: "outbound",
|
|
820
|
+
body: firstMessage,
|
|
821
|
+
author: selfAuthor,
|
|
822
|
+
at: now
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
persist();
|
|
826
|
+
return conversation;
|
|
566
827
|
},
|
|
567
828
|
async sendMessage(input) {
|
|
568
|
-
const conv = conversations.find((c) => c.id === input.conversationId);
|
|
829
|
+
const conv = state.conversations.find((c) => c.id === input.conversationId);
|
|
569
830
|
const created = {
|
|
570
|
-
id: `m${++counter}`,
|
|
831
|
+
id: `m${++state.counter}`,
|
|
571
832
|
conversationId: input.conversationId,
|
|
572
833
|
channel: conv?.channel ?? "sms",
|
|
573
834
|
direction: "outbound",
|
|
574
835
|
body: input.body,
|
|
575
|
-
author:
|
|
836
|
+
author: selfAuthor,
|
|
576
837
|
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
577
838
|
};
|
|
578
|
-
messages.push(created);
|
|
839
|
+
state.messages.push(created);
|
|
579
840
|
if (conv) {
|
|
580
841
|
conv.lastMessagePreview = input.body;
|
|
581
842
|
conv.lastMessageAt = created.at;
|
|
582
843
|
conv.unreadCount = 0;
|
|
583
844
|
if (conv.status === "closed") conv.status = "open";
|
|
584
845
|
}
|
|
846
|
+
persist();
|
|
585
847
|
return created;
|
|
586
848
|
},
|
|
587
849
|
async markRead(conversationId) {
|
|
588
|
-
const conv = conversations.find((c) => c.id === conversationId);
|
|
589
|
-
if (conv
|
|
850
|
+
const conv = state.conversations.find((c) => c.id === conversationId);
|
|
851
|
+
if (conv && conv.unreadCount !== 0) {
|
|
852
|
+
conv.unreadCount = 0;
|
|
853
|
+
persist();
|
|
854
|
+
}
|
|
590
855
|
},
|
|
591
856
|
async setStatus(conversationId, status) {
|
|
592
|
-
const conv = conversations.find((c) => c.id === conversationId);
|
|
857
|
+
const conv = state.conversations.find((c) => c.id === conversationId);
|
|
593
858
|
if (!conv) throw new Error("Conversation not found");
|
|
594
859
|
conv.status = status;
|
|
860
|
+
persist();
|
|
595
861
|
return conv;
|
|
596
862
|
}
|
|
597
863
|
};
|
|
598
864
|
}
|
|
865
|
+
|
|
866
|
+
// src/data/tables.ts
|
|
867
|
+
var T = {
|
|
868
|
+
conversations: "plg_conversations",
|
|
869
|
+
messages: "plg_conversation_messages"
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
// src/data/supabase.ts
|
|
599
873
|
function mapConversation(r) {
|
|
600
874
|
return {
|
|
601
875
|
id: String(r.id),
|
|
602
876
|
contactName: r.contact_name ?? "",
|
|
877
|
+
contactPersonId: r.contact_person_id ?? void 0,
|
|
603
878
|
contactHandle: r.contact_handle ?? "",
|
|
604
879
|
channel: r.channel ?? "sms",
|
|
605
880
|
lastMessagePreview: r.last_message_preview ?? "",
|
|
@@ -641,7 +916,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
641
916
|
}
|
|
642
917
|
return {
|
|
643
918
|
async listConversations(query) {
|
|
644
|
-
let q = client().from(
|
|
919
|
+
let q = client().from(T.conversations).select("*");
|
|
645
920
|
const tenantId = resolveTenantId();
|
|
646
921
|
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
647
922
|
if (query?.channel && query.channel !== "all") {
|
|
@@ -664,7 +939,7 @@ function createSupabaseConversationsProvider(config) {
|
|
|
664
939
|
return (data ?? []).map(mapConversation);
|
|
665
940
|
},
|
|
666
941
|
async getMessages(conversationId) {
|
|
667
|
-
const selected = client().from(
|
|
942
|
+
const selected = client().from(T.messages).select("*");
|
|
668
943
|
const filtered = selected.eq(
|
|
669
944
|
"conversation_id",
|
|
670
945
|
conversationId
|
|
@@ -676,9 +951,51 @@ function createSupabaseConversationsProvider(config) {
|
|
|
676
951
|
if (error) throw error;
|
|
677
952
|
return (data ?? []).map(mapMessage);
|
|
678
953
|
},
|
|
954
|
+
async createConversation(input) {
|
|
955
|
+
const tenantId = resolveTenantId();
|
|
956
|
+
if (!tenantId) {
|
|
957
|
+
throw new Error("[plugin-conversations] Active tenant not resolved \u2014 cannot create conversation. Try again in a moment.");
|
|
958
|
+
}
|
|
959
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
960
|
+
const firstMessage = input.firstMessage?.trim();
|
|
961
|
+
const convRow = {
|
|
962
|
+
contact_name: input.contactName.trim(),
|
|
963
|
+
// Only written when the picker resolved a real person — an app that
|
|
964
|
+
// hasn't run migration 002 yet would reject an unknown column, and
|
|
965
|
+
// omitting it keeps the free-text path working there.
|
|
966
|
+
...input.contactPersonId ? { contact_person_id: input.contactPersonId } : {},
|
|
967
|
+
contact_handle: input.contactHandle?.trim() || null,
|
|
968
|
+
channel: input.channel,
|
|
969
|
+
last_message_preview: firstMessage || null,
|
|
970
|
+
last_message_at: now,
|
|
971
|
+
unread_count: 0,
|
|
972
|
+
status: "open",
|
|
973
|
+
assigned_to: selfAuthor,
|
|
974
|
+
accent: CHANNEL_ACCENT_HEX[input.channel],
|
|
975
|
+
tags: [],
|
|
976
|
+
note: input.note?.trim() || null
|
|
977
|
+
};
|
|
978
|
+
if (tenantId) convRow.tenant_id = tenantId;
|
|
979
|
+
const { data: created, error } = await client().from(T.conversations).insert(convRow).select().single();
|
|
980
|
+
if (error) throw error;
|
|
981
|
+
if (!created) throw new Error("Conversation not created");
|
|
982
|
+
if (firstMessage) {
|
|
983
|
+
const msgRow = {
|
|
984
|
+
conversation_id: String(created.id),
|
|
985
|
+
channel: input.channel,
|
|
986
|
+
direction: "outbound",
|
|
987
|
+
body: firstMessage,
|
|
988
|
+
author: selfAuthor,
|
|
989
|
+
at: now
|
|
990
|
+
};
|
|
991
|
+
if (tenantId) msgRow.tenant_id = tenantId;
|
|
992
|
+
await client().from(T.messages).insert(msgRow);
|
|
993
|
+
}
|
|
994
|
+
return mapConversation(created);
|
|
995
|
+
},
|
|
679
996
|
async sendMessage(input) {
|
|
680
997
|
const tenantId = resolveTenantId();
|
|
681
|
-
const convSelected = client().from(
|
|
998
|
+
const convSelected = client().from(T.conversations).select(
|
|
682
999
|
"channel"
|
|
683
1000
|
);
|
|
684
1001
|
const convFiltered = convSelected.eq(
|
|
@@ -697,9 +1014,9 @@ function createSupabaseConversationsProvider(config) {
|
|
|
697
1014
|
at
|
|
698
1015
|
};
|
|
699
1016
|
if (tenantId) row.tenant_id = tenantId;
|
|
700
|
-
const { data: created, error } = await client().from(
|
|
1017
|
+
const { data: created, error } = await client().from(T.messages).insert(row).select().single();
|
|
701
1018
|
if (error) throw error;
|
|
702
|
-
await client().from(
|
|
1019
|
+
await client().from(T.conversations).update({
|
|
703
1020
|
last_message_preview: input.body,
|
|
704
1021
|
last_message_at: at,
|
|
705
1022
|
unread_count: 0,
|
|
@@ -708,13 +1025,13 @@ function createSupabaseConversationsProvider(config) {
|
|
|
708
1025
|
return mapMessage(created ?? row);
|
|
709
1026
|
},
|
|
710
1027
|
async markRead(conversationId) {
|
|
711
|
-
const { error } = await client().from(
|
|
1028
|
+
const { error } = await client().from(T.conversations).update({
|
|
712
1029
|
unread_count: 0
|
|
713
1030
|
}).eq("id", conversationId);
|
|
714
1031
|
if (error) throw error;
|
|
715
1032
|
},
|
|
716
1033
|
async setStatus(conversationId, status) {
|
|
717
|
-
const updated = client().from(
|
|
1034
|
+
const updated = client().from(T.conversations).update({
|
|
718
1035
|
status
|
|
719
1036
|
});
|
|
720
1037
|
const filtered = updated.eq("id", conversationId);
|
|
@@ -765,6 +1082,25 @@ function createConversationsStore(provider) {
|
|
|
765
1082
|
set({ search });
|
|
766
1083
|
await get().load();
|
|
767
1084
|
},
|
|
1085
|
+
async create(input) {
|
|
1086
|
+
const created = await provider.createConversation(input);
|
|
1087
|
+
set((s) => ({
|
|
1088
|
+
channelFilter: "all",
|
|
1089
|
+
search: "",
|
|
1090
|
+
conversations: [created, ...s.conversations.filter((c) => c.id !== created.id)],
|
|
1091
|
+
selectedId: created.id
|
|
1092
|
+
}));
|
|
1093
|
+
void (async () => {
|
|
1094
|
+
try {
|
|
1095
|
+
const conversations = await provider.listConversations({});
|
|
1096
|
+
const merged = conversations.some((c) => c.id === created.id) ? conversations : [created, ...conversations];
|
|
1097
|
+
set({ conversations: merged });
|
|
1098
|
+
await get().select(created.id);
|
|
1099
|
+
} catch {
|
|
1100
|
+
}
|
|
1101
|
+
})();
|
|
1102
|
+
return created;
|
|
1103
|
+
},
|
|
768
1104
|
async send(body) {
|
|
769
1105
|
const id = get().selectedId;
|
|
770
1106
|
if (!id || !body.trim()) return;
|
|
@@ -792,26 +1128,266 @@ function createConversationsStore(provider) {
|
|
|
792
1128
|
// src/locales/en.ts
|
|
793
1129
|
var en = {
|
|
794
1130
|
"conversations.title": "Conversations",
|
|
795
|
-
"conversations.subtitle": "Unified inbox across every channel"
|
|
1131
|
+
"conversations.subtitle": "Unified inbox across every channel",
|
|
1132
|
+
// Conversation list
|
|
1133
|
+
"conversations.list.search": "Search conversations",
|
|
1134
|
+
"conversations.list.loading": "Loading\u2026",
|
|
1135
|
+
"conversations.list.empty": "No conversations",
|
|
1136
|
+
"conversations.list.new": "New conversation",
|
|
1137
|
+
// Channel filters
|
|
1138
|
+
"conversations.filter.all": "All",
|
|
1139
|
+
"conversations.filter.whatsapp": "WhatsApp",
|
|
1140
|
+
"conversations.filter.sms": "SMS",
|
|
1141
|
+
"conversations.filter.instagram": "Instagram",
|
|
1142
|
+
"conversations.filter.email": "Email",
|
|
1143
|
+
"conversations.filter.webchat": "Web",
|
|
1144
|
+
// Status labels
|
|
1145
|
+
"conversations.status.open": "Open",
|
|
1146
|
+
"conversations.status.snoozed": "Snoozed",
|
|
1147
|
+
"conversations.status.closed": "Closed",
|
|
1148
|
+
// Empty state
|
|
1149
|
+
"conversations.empty.select": "Select a conversation to start chatting",
|
|
1150
|
+
// Thread
|
|
1151
|
+
"conversations.thread.back": "Back to conversations",
|
|
1152
|
+
"conversations.thread.snooze": "Snooze",
|
|
1153
|
+
"conversations.thread.close": "Close",
|
|
1154
|
+
"conversations.thread.details": "Toggle contact details",
|
|
1155
|
+
"conversations.thread.empty": "No messages yet",
|
|
1156
|
+
"conversations.thread.reply": "Reply via {{channel}}\u2026",
|
|
1157
|
+
"conversations.thread.send": "Send",
|
|
1158
|
+
// Contact panel
|
|
1159
|
+
"conversations.contact.details": "Details",
|
|
1160
|
+
"conversations.contact.closeDetails": "Close details",
|
|
1161
|
+
"conversations.contact.channel": "Channel",
|
|
1162
|
+
"conversations.contact.status": "Status",
|
|
1163
|
+
"conversations.contact.assignedTo": "Assigned to",
|
|
1164
|
+
"conversations.contact.location": "Location",
|
|
1165
|
+
"conversations.contact.tags": "Tags",
|
|
1166
|
+
"conversations.contact.note": "Note",
|
|
1167
|
+
"conversations.contact.linkedRecords": "Linked records",
|
|
1168
|
+
"conversations.contact.noLinkedRecords": "No linked records yet.",
|
|
1169
|
+
// New-conversation modal
|
|
1170
|
+
"conversations.new.title": "New conversation",
|
|
1171
|
+
"conversations.new.channel": "Channel",
|
|
1172
|
+
"conversations.new.contactName": "Contact name",
|
|
1173
|
+
"conversations.new.contactNamePlaceholder": "e.g. Jane Doe",
|
|
1174
|
+
"conversations.new.handle": "Phone / handle / email",
|
|
1175
|
+
"conversations.new.handlePlaceholder": "+1 555 000 0000",
|
|
1176
|
+
// The handle field only surfaces when the picked contact has nothing usable
|
|
1177
|
+
// for the active channel — otherwise it is derived and shown on the chip.
|
|
1178
|
+
"conversations.new.addHandle": "Add {label}",
|
|
1179
|
+
"conversations.new.handleLabel.phone": "phone",
|
|
1180
|
+
"conversations.new.handleLabel.email": "email",
|
|
1181
|
+
"conversations.new.handleLabel.instagram": "Instagram handle",
|
|
1182
|
+
"conversations.new.handleLabel.webchat": "web chat id",
|
|
1183
|
+
"conversations.new.firstMessage": "First message",
|
|
1184
|
+
"conversations.new.firstMessagePlaceholder": "Write the first message (optional)\u2026",
|
|
1185
|
+
"conversations.new.cancel": "Cancel",
|
|
1186
|
+
"conversations.new.create": "Start conversation",
|
|
1187
|
+
"conversations.new.creating": "Starting\u2026",
|
|
1188
|
+
"conversations.new.createFailed": "Could not create the conversation"
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
// src/locales/pt-BR.ts
|
|
1192
|
+
var ptBR = {
|
|
1193
|
+
"conversations.title": "Conversas",
|
|
1194
|
+
"conversations.subtitle": "Caixa de entrada unificada de todos os canais",
|
|
1195
|
+
// Lista de conversas
|
|
1196
|
+
"conversations.list.search": "Buscar conversas",
|
|
1197
|
+
"conversations.list.loading": "Carregando\u2026",
|
|
1198
|
+
"conversations.list.empty": "Nenhuma conversa",
|
|
1199
|
+
"conversations.list.new": "Nova conversa",
|
|
1200
|
+
// Filtros de canal
|
|
1201
|
+
"conversations.filter.all": "Todas",
|
|
1202
|
+
"conversations.filter.whatsapp": "WhatsApp",
|
|
1203
|
+
"conversations.filter.sms": "SMS",
|
|
1204
|
+
"conversations.filter.instagram": "Instagram",
|
|
1205
|
+
"conversations.filter.email": "E-mail",
|
|
1206
|
+
"conversations.filter.webchat": "Web",
|
|
1207
|
+
// Rótulos de status
|
|
1208
|
+
"conversations.status.open": "Aberta",
|
|
1209
|
+
"conversations.status.snoozed": "Adiada",
|
|
1210
|
+
"conversations.status.closed": "Encerrada",
|
|
1211
|
+
// Estado vazio
|
|
1212
|
+
"conversations.empty.select": "Selecione uma conversa para come\xE7ar a conversar",
|
|
1213
|
+
// Thread
|
|
1214
|
+
"conversations.thread.back": "Voltar \xE0s conversas",
|
|
1215
|
+
"conversations.thread.snooze": "Adiar",
|
|
1216
|
+
"conversations.thread.close": "Encerrar",
|
|
1217
|
+
"conversations.thread.details": "Alternar detalhes do contato",
|
|
1218
|
+
"conversations.thread.empty": "Nenhuma mensagem ainda",
|
|
1219
|
+
"conversations.thread.reply": "Responder via {{channel}}\u2026",
|
|
1220
|
+
"conversations.thread.send": "Enviar",
|
|
1221
|
+
// Painel do contato
|
|
1222
|
+
"conversations.contact.details": "Detalhes",
|
|
1223
|
+
"conversations.contact.closeDetails": "Fechar detalhes",
|
|
1224
|
+
"conversations.contact.channel": "Canal",
|
|
1225
|
+
"conversations.contact.status": "Status",
|
|
1226
|
+
"conversations.contact.assignedTo": "Respons\xE1vel",
|
|
1227
|
+
"conversations.contact.location": "Localiza\xE7\xE3o",
|
|
1228
|
+
"conversations.contact.tags": "Etiquetas",
|
|
1229
|
+
"conversations.contact.note": "Nota",
|
|
1230
|
+
"conversations.contact.linkedRecords": "Registros vinculados",
|
|
1231
|
+
"conversations.contact.noLinkedRecords": "Nenhum registro vinculado ainda.",
|
|
1232
|
+
// Modal de nova conversa
|
|
1233
|
+
"conversations.new.title": "Nova conversa",
|
|
1234
|
+
"conversations.new.channel": "Canal",
|
|
1235
|
+
"conversations.new.contactName": "Nome do contato",
|
|
1236
|
+
"conversations.new.contactNamePlaceholder": "ex.: Maria Silva",
|
|
1237
|
+
"conversations.new.handle": "Telefone / usu\xE1rio / e-mail",
|
|
1238
|
+
"conversations.new.handlePlaceholder": "+55 11 99999-0000",
|
|
1239
|
+
// O campo de handle só aparece quando o contato escolhido não tem o dado do
|
|
1240
|
+
// canal ativo — caso contrário ele é derivado e mostrado no chip.
|
|
1241
|
+
"conversations.new.addHandle": "Adicionar {label}",
|
|
1242
|
+
"conversations.new.handleLabel.phone": "telefone",
|
|
1243
|
+
"conversations.new.handleLabel.email": "e-mail",
|
|
1244
|
+
"conversations.new.handleLabel.instagram": "@ do Instagram",
|
|
1245
|
+
"conversations.new.handleLabel.webchat": "id do chat",
|
|
1246
|
+
"conversations.new.firstMessage": "Primeira mensagem",
|
|
1247
|
+
"conversations.new.firstMessagePlaceholder": "Escreva a primeira mensagem (opcional)\u2026",
|
|
1248
|
+
"conversations.new.cancel": "Cancelar",
|
|
1249
|
+
"conversations.new.create": "Iniciar conversa",
|
|
1250
|
+
"conversations.new.creating": "Iniciando\u2026",
|
|
1251
|
+
"conversations.new.createFailed": "N\xE3o foi poss\xEDvel criar a conversa"
|
|
796
1252
|
};
|
|
797
1253
|
|
|
798
1254
|
// src/locales/index.ts
|
|
799
1255
|
var conversationsLocales = {
|
|
800
|
-
en
|
|
1256
|
+
en,
|
|
1257
|
+
"pt-BR": ptBR
|
|
801
1258
|
};
|
|
802
1259
|
|
|
1260
|
+
// src/migrations/index.ts
|
|
1261
|
+
var MIGRATION_001_CONVERSATIONS = `-- ============================================================================
|
|
1262
|
+
-- plugin-conversations 001: omni-channel inbox model (SMS / WhatsApp /
|
|
1263
|
+
-- Instagram / Email / Web chat). Prefix: plg_conversations / plg_conversation_messages.
|
|
1264
|
+
-- \xA71 plg_conversations \u2014 one thread per contact+channel
|
|
1265
|
+
-- \xA72 plg_conversation_messages \u2014 inbound/outbound messages within a thread
|
|
1266
|
+
-- \xA73 RLS: authenticated tenant-scoped CRUD on both tables + GRANTs
|
|
1267
|
+
--
|
|
1268
|
+
-- Column names mirror exactly what supabase.ts's mapConversation / mapMessage
|
|
1269
|
+
-- read. Real channel connectors (Twilio, WhatsApp Cloud, Meta, IMAP) deliver
|
|
1270
|
+
-- inbound rows here out-of-band; the provider is the read/compose surface.
|
|
1271
|
+
-- Idempotent + safe to re-run.
|
|
1272
|
+
-- ============================================================================
|
|
1273
|
+
|
|
1274
|
+
-- \xA71 \u2014 conversations (threads)
|
|
1275
|
+
CREATE TABLE IF NOT EXISTS public.plg_conversations (
|
|
1276
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1277
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
1278
|
+
contact_name text NOT NULL,
|
|
1279
|
+
contact_handle text,
|
|
1280
|
+
channel text NOT NULL
|
|
1281
|
+
CHECK (channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
|
|
1282
|
+
last_message_preview text,
|
|
1283
|
+
last_message_at timestamptz DEFAULT now(),
|
|
1284
|
+
unread_count int DEFAULT 0,
|
|
1285
|
+
status text DEFAULT 'open'
|
|
1286
|
+
CHECK (status IN ('open', 'snoozed', 'closed')),
|
|
1287
|
+
assigned_to text,
|
|
1288
|
+
accent text,
|
|
1289
|
+
tags text[],
|
|
1290
|
+
location text,
|
|
1291
|
+
note text,
|
|
1292
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
1293
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
1294
|
+
);
|
|
1295
|
+
ALTER TABLE public.plg_conversations ENABLE ROW LEVEL SECURITY;
|
|
1296
|
+
CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant ON public.plg_conversations(tenant_id);
|
|
1297
|
+
CREATE INDEX IF NOT EXISTS idx_plg_conversations_tenant_recent ON public.plg_conversations(tenant_id, last_message_at DESC);
|
|
1298
|
+
|
|
1299
|
+
-- \xA72 \u2014 messages
|
|
1300
|
+
CREATE TABLE IF NOT EXISTS public.plg_conversation_messages (
|
|
1301
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
1302
|
+
tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
|
1303
|
+
conversation_id uuid NOT NULL REFERENCES public.plg_conversations(id) ON DELETE CASCADE,
|
|
1304
|
+
channel text
|
|
1305
|
+
CHECK (channel IS NULL OR channel IN ('sms', 'whatsapp', 'instagram', 'email', 'webchat')),
|
|
1306
|
+
direction text
|
|
1307
|
+
CHECK (direction IN ('inbound', 'outbound')),
|
|
1308
|
+
body text NOT NULL,
|
|
1309
|
+
author text,
|
|
1310
|
+
at timestamptz DEFAULT now()
|
|
1311
|
+
);
|
|
1312
|
+
ALTER TABLE public.plg_conversation_messages ENABLE ROW LEVEL SECURITY;
|
|
1313
|
+
CREATE INDEX IF NOT EXISTS idx_plg_conversation_messages_thread ON public.plg_conversation_messages(conversation_id, at);
|
|
1314
|
+
|
|
1315
|
+
-- \xA73 \u2014 RLS: authenticated tenant CRUD (the inbox reads/writes here)
|
|
1316
|
+
DROP POLICY IF EXISTS plg_conversations_select ON public.plg_conversations;
|
|
1317
|
+
DROP POLICY IF EXISTS plg_conversations_insert ON public.plg_conversations;
|
|
1318
|
+
DROP POLICY IF EXISTS plg_conversations_update ON public.plg_conversations;
|
|
1319
|
+
DROP POLICY IF EXISTS plg_conversations_delete ON public.plg_conversations;
|
|
1320
|
+
CREATE POLICY plg_conversations_select ON public.plg_conversations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
1321
|
+
CREATE POLICY plg_conversations_insert ON public.plg_conversations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
1322
|
+
CREATE POLICY plg_conversations_update ON public.plg_conversations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
1323
|
+
CREATE POLICY plg_conversations_delete ON public.plg_conversations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
1324
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversations TO authenticated;
|
|
1325
|
+
|
|
1326
|
+
DROP POLICY IF EXISTS plg_conversation_messages_select ON public.plg_conversation_messages;
|
|
1327
|
+
DROP POLICY IF EXISTS plg_conversation_messages_insert ON public.plg_conversation_messages;
|
|
1328
|
+
DROP POLICY IF EXISTS plg_conversation_messages_update ON public.plg_conversation_messages;
|
|
1329
|
+
DROP POLICY IF EXISTS plg_conversation_messages_delete ON public.plg_conversation_messages;
|
|
1330
|
+
CREATE POLICY plg_conversation_messages_select ON public.plg_conversation_messages FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
1331
|
+
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()));
|
|
1332
|
+
CREATE POLICY plg_conversation_messages_update ON public.plg_conversation_messages FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
1333
|
+
CREATE POLICY plg_conversation_messages_delete ON public.plg_conversation_messages FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
|
|
1334
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_conversation_messages TO authenticated;
|
|
1335
|
+
`;
|
|
1336
|
+
var MIGRATION_002_CONTACT_PERSON = `-- ============================================================================
|
|
1337
|
+
-- plugin-conversations 002: link a thread to a REAL person record.
|
|
1338
|
+
--
|
|
1339
|
+
-- The compose modal used to take a free-text name + handle, so a conversation
|
|
1340
|
+
-- with "Maria" had nothing to do with the Maria in the agenda, the CRM or the
|
|
1341
|
+
-- financial module. The shared ContactPicker (find-or-create over
|
|
1342
|
+
-- public.people) now resolves a person, and this column stores that link.
|
|
1343
|
+
--
|
|
1344
|
+
-- Nullable on purpose, in both directions of time:
|
|
1345
|
+
-- \u2022 rows created before this migration keep working (name/handle only);
|
|
1346
|
+
-- \u2022 an inbound message from an unknown number still opens a thread with no
|
|
1347
|
+
-- person attached \u2014 the contact panel can offer "create contact" later.
|
|
1348
|
+
-- ON DELETE SET NULL: deleting a person must never take their history with it.
|
|
1349
|
+
-- Idempotent + safe to re-run.
|
|
1350
|
+
-- ============================================================================
|
|
1351
|
+
|
|
1352
|
+
ALTER TABLE public.plg_conversations
|
|
1353
|
+
ADD COLUMN IF NOT EXISTS contact_person_id uuid REFERENCES public.people(id) ON DELETE SET NULL;
|
|
1354
|
+
|
|
1355
|
+
CREATE INDEX IF NOT EXISTS idx_plg_conversations_person
|
|
1356
|
+
ON public.plg_conversations(tenant_id, contact_person_id)
|
|
1357
|
+
WHERE contact_person_id IS NOT NULL;
|
|
1358
|
+
`;
|
|
1359
|
+
|
|
803
1360
|
// src/index.ts
|
|
804
1361
|
function createSafeProvider() {
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1362
|
+
let real = null;
|
|
1363
|
+
let mock = null;
|
|
1364
|
+
const resolve = () => {
|
|
1365
|
+
if (getSupabaseClientOptional()) {
|
|
1366
|
+
real ?? (real = createSupabaseConversationsProvider({ tenantId: () => getActiveTenantId() }));
|
|
1367
|
+
return real;
|
|
1368
|
+
}
|
|
1369
|
+
mock ?? (mock = createMockConversationsProvider({ tenantId: () => getActiveTenantId() }));
|
|
1370
|
+
return mock;
|
|
1371
|
+
};
|
|
1372
|
+
return {
|
|
1373
|
+
listConversations: (...a) => resolve().listConversations(...a),
|
|
1374
|
+
getMessages: (...a) => resolve().getMessages(...a),
|
|
1375
|
+
sendMessage: (...a) => resolve().sendMessage(...a),
|
|
1376
|
+
markRead: (...a) => resolve().markRead(...a),
|
|
1377
|
+
setStatus: (...a) => resolve().setStatus(...a),
|
|
1378
|
+
createConversation: (...a) => resolve().createConversation(...a)
|
|
1379
|
+
};
|
|
809
1380
|
}
|
|
810
1381
|
function createConversationsPlugin(options) {
|
|
811
1382
|
registerTranslations(conversationsLocales);
|
|
812
1383
|
const provider = options?.dataProvider ?? createSafeProvider();
|
|
813
1384
|
const store = createConversationsStore(provider);
|
|
814
|
-
const
|
|
1385
|
+
const config = {
|
|
1386
|
+
contactKind: options?.contactKind ?? "contact",
|
|
1387
|
+
contactExtensionTable: options?.contactExtensionTable,
|
|
1388
|
+
contactLookup: options?.contactLookup
|
|
1389
|
+
};
|
|
1390
|
+
const PageComponent = () => React3.createElement(ConversationsPage, { store, config });
|
|
815
1391
|
PageComponent.displayName = "ConversationsPage";
|
|
816
1392
|
return {
|
|
817
1393
|
id: "conversations",
|
|
@@ -823,6 +1399,10 @@ function createConversationsPlugin(options) {
|
|
|
823
1399
|
defaultEnabled: true,
|
|
824
1400
|
dependencies: [],
|
|
825
1401
|
declaredFeatures: [{ id: "conversations", label: "Conversations", group: "Engage" }],
|
|
1402
|
+
// Recurring monthly quota — counts conversation threads created this month.
|
|
1403
|
+
declaredLimits: [
|
|
1404
|
+
{ key: "conversations_month", label: "Conversations this month", table: "plg_conversations", period: "month" }
|
|
1405
|
+
],
|
|
826
1406
|
navigation: [
|
|
827
1407
|
{
|
|
828
1408
|
section: options?.navSection ?? "main",
|
|
@@ -887,6 +1467,20 @@ function createConversationsPlugin(options) {
|
|
|
887
1467
|
permission: { feature: "conversations", action: "create" }
|
|
888
1468
|
}
|
|
889
1469
|
],
|
|
1470
|
+
migrations: [
|
|
1471
|
+
{
|
|
1472
|
+
id: "conversations-001-base-tables",
|
|
1473
|
+
version: "1.0.0",
|
|
1474
|
+
sql: MIGRATION_001_CONVERSATIONS,
|
|
1475
|
+
description: "Create plg_conversations and plg_conversation_messages (tenant-scoped RLS)"
|
|
1476
|
+
},
|
|
1477
|
+
{
|
|
1478
|
+
id: "conversations-002-contact-person",
|
|
1479
|
+
version: "1.1.0",
|
|
1480
|
+
sql: MIGRATION_002_CONTACT_PERSON,
|
|
1481
|
+
description: "Link threads to public.people via contact_person_id (nullable, ON DELETE SET NULL)"
|
|
1482
|
+
}
|
|
1483
|
+
],
|
|
890
1484
|
locales: conversationsLocales
|
|
891
1485
|
};
|
|
892
1486
|
}
|