@fayz-ai/plugin-conversations 0.8.0 → 0.9.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/ConversationsContext.d.ts +18 -1
  2. package/dist/ConversationsContext.d.ts.map +1 -1
  3. package/dist/ConversationsPage.d.ts +3 -1
  4. package/dist/ConversationsPage.d.ts.map +1 -1
  5. package/dist/data/accents.d.ts +3 -0
  6. package/dist/data/accents.d.ts.map +1 -0
  7. package/dist/data/mock.d.ts +7 -1
  8. package/dist/data/mock.d.ts.map +1 -1
  9. package/dist/data/mock.test.d.ts +2 -0
  10. package/dist/data/mock.test.d.ts.map +1 -0
  11. package/dist/data/supabase.d.ts.map +1 -1
  12. package/dist/data/tables.d.ts +5 -0
  13. package/dist/data/tables.d.ts.map +1 -0
  14. package/dist/data/types.d.ts +2 -1
  15. package/dist/data/types.d.ts.map +1 -1
  16. package/dist/index.d.ts +16 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +667 -73
  19. package/dist/index.js.map +1 -1
  20. package/dist/locales/en.d.ts.map +1 -1
  21. package/dist/locales/index.d.ts.map +1 -1
  22. package/dist/locales/pt-BR.d.ts +2 -0
  23. package/dist/locales/pt-BR.d.ts.map +1 -0
  24. package/dist/migrations/index.d.ts +7 -0
  25. package/dist/migrations/index.d.ts.map +1 -0
  26. package/dist/store.d.ts +2 -1
  27. package/dist/store.d.ts.map +1 -1
  28. package/dist/types.d.ts +18 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/views/ContactPanel.d.ts.map +1 -1
  31. package/dist/views/ConversationList.d.ts.map +1 -1
  32. package/dist/views/InboxView.d.ts.map +1 -1
  33. package/dist/views/MessageThread.d.ts.map +1 -1
  34. package/dist/views/NewConversationModal.d.ts +6 -0
  35. package/dist/views/NewConversationModal.d.ts.map +1 -0
  36. package/package.json +15 -11
  37. package/src/ConversationsContext.tsx +31 -1
  38. package/src/ConversationsPage.tsx +6 -3
  39. package/src/data/accents.ts +12 -0
  40. package/src/data/mock.test.ts +90 -0
  41. package/src/data/mock.ts +131 -12
  42. package/src/data/supabase.ts +69 -7
  43. package/src/data/tables.ts +7 -0
  44. package/src/data/types.ts +2 -0
  45. package/src/index.ts +69 -11
  46. package/src/locales/en.ts +64 -0
  47. package/src/locales/index.ts +2 -0
  48. package/src/locales/pt-BR.ts +68 -0
  49. package/src/migrations/001_conversations.sql +74 -0
  50. package/src/migrations/002_contact_person.sql +22 -0
  51. package/src/migrations/index.ts +108 -0
  52. package/src/store.ts +44 -1
  53. package/src/types.ts +19 -0
  54. package/src/views/ContactPanel.tsx +14 -12
  55. package/src/views/ConversationList.tsx +48 -21
  56. package/src/views/InboxView.tsx +3 -1
  57. package/src/views/MessageThread.tsx +14 -16
  58. package/src/views/NewConversationModal.tsx +204 -0
  59. package/dist/index.cjs +0 -904
  60. package/dist/index.cjs.map +0 -1
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, Smile, Paperclip, Send, X, User, MapPin, Tag, StickyNote, Link2, Globe, Mail, Instagram, Phone } from 'lucide-react';
6
- import { cn, Input, Button } from '@fayz-ai/ui';
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 FILTERS = [
125
- { id: "all", label: "All" },
126
- { id: "whatsapp", label: "WhatsApp" },
127
- { id: "sms", label: "SMS" },
128
- { id: "instagram", label: "Instagram" },
129
- { id: "email", label: "Email" },
130
- { id: "webchat", label: "Web" }
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: "Search conversations",
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((f) => /* @__PURE__ */ jsx(
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(f.id),
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 === f.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-muted/70"
321
+ channelFilter === id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-muted/70"
164
322
  ),
165
- children: f.label
323
+ children: t(`conversations.filter.${id}`)
166
324
  },
167
- f.id
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__ */ jsx("p", { className: "p-4 text-sm text-muted-foreground", children: "Loading\u2026" }),
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: "No conversations" })
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": "Back to conversations", children: /* @__PURE__ */ jsx(ChevronLeft, { className: "h-5 w-5" }) }),
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": "Snooze", children: [
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: "Snooze" })
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": "Close conversation", children: [
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: "Close" })
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": "Toggle contact details",
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: "No messages yet" })
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: `Reply via ${CHANNEL_LABELS[selected.channel]}\u2026`,
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": "Send", children: /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" }) })
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: "Details" }),
353
- /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", onClick: onClose, "aria-label": "Close details", children: /* @__PURE__ */ jsx(X, { className: "h-4 w-4" }) })
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: "Details", children: /* @__PURE__ */ jsxs("dl", { className: "space-y-1.5 text-sm", children: [
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: "Channel" }),
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: "Status" }),
370
- /* @__PURE__ */ jsx("dd", { className: "capitalize text-foreground", children: contact.status })
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: "Assigned to" }),
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
- " Location"
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: "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)) }) }),
385
- contact.note && /* @__PURE__ */ jsx(Section, { icon: StickyNote, title: "Note", children: /* @__PURE__ */ jsx("p", { className: "text-sm text-foreground", children: contact.note }) }),
386
- /* @__PURE__ */ jsx(Section, { icon: Link2, title: "Linked records", children: /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "No linked records yet." }) })
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: "Select a conversation to start chatting" })
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 { conversations, messages } = seed();
550
- let counter = 100;
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: "You",
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) conv.unreadCount = 0;
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("conversations").select("*");
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("conversation_messages").select("*");
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("conversations").select(
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("conversation_messages").insert(row).select().single();
1017
+ const { data: created, error } = await client().from(T.messages).insert(row).select().single();
701
1018
  if (error) throw error;
702
- await client().from("conversations").update({
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("conversations").update({
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("conversations").update({
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
- if (getSupabaseClientOptional()) {
806
- return createSupabaseConversationsProvider({ tenantId: () => getActiveTenantId() });
807
- }
808
- return createMockConversationsProvider();
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 PageComponent = () => React3.createElement(ConversationsPage, { store });
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
  }