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