@fayz-ai/plugin-conversations 0.9.0-next.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +4 -4
  2. package/dist/{ConversationsContext.d.ts → context.d.ts} +2 -2
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/{channel.d.ts → lib/channel.d.ts} +1 -1
  9. package/dist/lib/channel.d.ts.map +1 -0
  10. package/dist/{ConversationsPage.d.ts → views/ConversationsPage.d.ts} +2 -2
  11. package/dist/views/ConversationsPage.d.ts.map +1 -0
  12. package/package.json +5 -7
  13. package/dist/ConversationsContext.d.ts.map +0 -1
  14. package/dist/ConversationsPage.d.ts.map +0 -1
  15. package/dist/channel.d.ts.map +0 -1
  16. package/src/ConversationsContext.tsx +0 -51
  17. package/src/ConversationsPage.tsx +0 -21
  18. package/src/channel.ts +0 -34
  19. package/src/data/accents.ts +0 -12
  20. package/src/data/mock.test.ts +0 -90
  21. package/src/data/mock.ts +0 -261
  22. package/src/data/supabase.ts +0 -264
  23. package/src/data/tables.ts +0 -7
  24. package/src/data/types.ts +0 -17
  25. package/src/index.ts +0 -190
  26. package/src/locales/en.ts +0 -68
  27. package/src/locales/index.ts +0 -7
  28. package/src/locales/pt-BR.ts +0 -68
  29. package/src/migrations/001_conversations.sql +0 -74
  30. package/src/migrations/002_contact_person.sql +0 -22
  31. package/src/migrations/index.ts +0 -108
  32. package/src/store.ts +0 -136
  33. package/src/types.ts +0 -77
  34. package/src/views/ContactPanel.tsx +0 -97
  35. package/src/views/ConversationList.tsx +0 -135
  36. package/src/views/InboxView.tsx +0 -64
  37. package/src/views/MessageThread.tsx +0 -167
  38. package/src/views/NewConversationModal.tsx +0 -204
  39. package/src/views/shared.tsx +0 -97
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { CHANNEL_LABELS, type Channel } from './types';
2
+ import { CHANNEL_LABELS, type Channel } from '../types';
3
3
  export declare const CHANNEL_ICON: Record<Channel, React.ComponentType<{
4
4
  className?: string;
5
5
  }>>;
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/lib/channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,OAAO,EAAE,cAAc,EAAE,KAAK,OAAO,EAAE,MAAM,UAAU,CAAA;AAQvD,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,OAAO,EAAE,KAAK,CAAC,aAAa,CAAC;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAMrF,CAAA;AAED,MAAM,WAAW,aAAa;IAC5B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAA;CACd;AAED,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,OAAO,EAAE,aAAa,CAMzD,CAAA;AAED,OAAO,EAAE,cAAc,EAAE,CAAA"}
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import type { StoreApi } from 'zustand/vanilla';
3
- import { type ResolvedConversationsConfig } from './ConversationsContext';
4
- import type { ConversationsUIState } from './store';
3
+ import { type ResolvedConversationsConfig } from '../context';
4
+ import type { ConversationsUIState } from '../store';
5
5
  export declare function ConversationsPage({ store, config }: {
6
6
  store: StoreApi<ConversationsUIState>;
7
7
  config?: ResolvedConversationsConfig;
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ConversationsPage.d.ts","sourceRoot":"","sources":["../../src/views/ConversationsPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC/C,OAAO,EAAgC,KAAK,2BAA2B,EAAE,MAAM,YAAY,CAAA;AAC3F,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AAGpD,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IACnD,KAAK,EAAE,QAAQ,CAAC,oBAAoB,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,2BAA2B,CAAA;CACrC,qBAWA"}
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "preview"
5
5
  },
6
- "version": "0.9.0-next.1",
6
+ "version": "0.9.1",
7
7
  "description": "[experimental] Fayz SDK — unified conversations / omni-channel inbox plugin",
8
8
  "type": "module",
9
9
  "sideEffects": false,
@@ -12,14 +12,12 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "exports": {
14
14
  ".": {
15
- "source": "./src/index.ts",
16
15
  "types": "./dist/index.d.ts",
17
16
  "import": "./dist/index.js"
18
17
  }
19
18
  },
20
19
  "files": [
21
- "dist",
22
- "src"
20
+ "dist"
23
21
  ],
24
22
  "peerDependencies": {
25
23
  "react": "^18.0.0 || ^19.0.0",
@@ -28,9 +26,9 @@
28
26
  "lucide-react": ">=0.400.0 <1.0.0"
29
27
  },
30
28
  "dependencies": {
31
- "@fayz-ai/core": "^0.9.0-next.1",
32
- "@fayz-ai/ui": "^0.9.0-next.1",
33
- "@fayz-ai/saas": "^0.9.0-next.1"
29
+ "@fayz-ai/core": "^0.10.0",
30
+ "@fayz-ai/ui": "^0.10.0",
31
+ "@fayz-ai/admin": "^0.10.0"
34
32
  },
35
33
  "devDependencies": {
36
34
  "@types/react": "^18.3.0",
@@ -1 +0,0 @@
1
- {"version":3,"file":"ConversationsContext.d.ts","sourceRoot":"","sources":["../src/ConversationsContext.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,SAAS,CAAA;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AACjD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAEnD;;;;GAIG;AACH,MAAM,WAAW,2BAA2B;IAC1C,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAA;IACnB,gFAAgF;IAChF,qBAAqB,CAAC,EAAE,MAAM,CAAA;IAC9B,8EAA8E;IAC9E,aAAa,CAAC,EAAE,YAAY,CAAA;CAC7B;AAED,eAAO,MAAM,4BAA4B,EAAE,2BAE1C,CAAA;AAKD,wBAAgB,4BAA4B,CAAC,EAC3C,KAAK,EACL,MAAqC,EACrC,QAAQ,GACT,EAAE;IACD,KAAK,EAAE,QAAQ,CAAC,oBAAoB,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,2BAA2B,CAAA;IACpC,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC3B,qBAMA;AAED,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,CAAC,GAAG,CAAC,CAIxF;AAED,wBAAgB,sBAAsB,IAAI,2BAA2B,CAEpE"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"ConversationsPage.d.ts","sourceRoot":"","sources":["../src/ConversationsPage.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAA;AAC/C,OAAO,EAAgC,KAAK,2BAA2B,EAAE,MAAM,wBAAwB,CAAA;AACvG,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAA;AAGnD,wBAAgB,iBAAiB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;IACnD,KAAK,EAAE,QAAQ,CAAC,oBAAoB,CAAC,CAAA;IACrC,MAAM,CAAC,EAAE,2BAA2B,CAAA;CACrC,qBAWA"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAEzB,OAAO,EAAE,cAAc,EAAE,KAAK,OAAO,EAAE,MAAM,SAAS,CAAA;AAQtD,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,OAAO,EAAE,KAAK,CAAC,aAAa,CAAC;IAAE,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAMrF,CAAA;AAED,MAAM,WAAW,aAAa;IAC5B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAA;CACd;AAED,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,OAAO,EAAE,aAAa,CAMzD,CAAA;AAED,OAAO,EAAE,cAAc,EAAE,CAAA"}
@@ -1,51 +0,0 @@
1
- import React from 'react'
2
- import { useStore, type StoreApi } from 'zustand'
3
- import type { EntityLookup } from '@fayz-ai/saas'
4
- import type { ConversationsUIState } from './store'
5
-
6
- /**
7
- * App-tunable knobs the inbox UI reads. Kept tiny on purpose: everything here
8
- * describes the APP's data shape (which people are "contacts" in this vertical),
9
- * never product policy.
10
- */
11
- export interface ResolvedConversationsConfig {
12
- /** `people.kind` used when the compose modal creates a contact. */
13
- contactKind: string
14
- /** Per-vertical extension table linked by `person_id` (skipped when absent). */
15
- contactExtensionTable?: string
16
- /** Search source for the contact picker. Defaults to the person archetype. */
17
- contactLookup?: EntityLookup
18
- }
19
-
20
- export const DEFAULT_CONVERSATIONS_CONFIG: ResolvedConversationsConfig = {
21
- contactKind: 'contact',
22
- }
23
-
24
- const StoreContext = React.createContext<StoreApi<ConversationsUIState> | null>(null)
25
- const ConfigContext = React.createContext<ResolvedConversationsConfig>(DEFAULT_CONVERSATIONS_CONFIG)
26
-
27
- export function ConversationsContextProvider({
28
- store,
29
- config = DEFAULT_CONVERSATIONS_CONFIG,
30
- children,
31
- }: {
32
- store: StoreApi<ConversationsUIState>
33
- config?: ResolvedConversationsConfig
34
- children?: React.ReactNode
35
- }) {
36
- return (
37
- <StoreContext.Provider value={store}>
38
- <ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
39
- </StoreContext.Provider>
40
- )
41
- }
42
-
43
- export function useConversationsStore<T>(selector: (state: ConversationsUIState) => T): T {
44
- const store = React.useContext(StoreContext)
45
- if (!store) throw new Error('useConversationsStore must be used within ConversationsPage')
46
- return useStore(store, selector)
47
- }
48
-
49
- export function useConversationsConfig(): ResolvedConversationsConfig {
50
- return React.useContext(ConfigContext)
51
- }
@@ -1,21 +0,0 @@
1
- import React from 'react'
2
- import type { StoreApi } from 'zustand/vanilla'
3
- import { ConversationsContextProvider, type ResolvedConversationsConfig } from './ConversationsContext'
4
- import type { ConversationsUIState } from './store'
5
- import { InboxView } from './views/InboxView'
6
-
7
- export function ConversationsPage({ store, config }: {
8
- store: StoreApi<ConversationsUIState>
9
- config?: ResolvedConversationsConfig
10
- }) {
11
- React.useEffect(() => {
12
- void store.getState().load()
13
- // eslint-disable-next-line react-hooks/exhaustive-deps
14
- }, [])
15
-
16
- return (
17
- <ConversationsContextProvider store={store} config={config}>
18
- <InboxView />
19
- </ConversationsContextProvider>
20
- )
21
- }
package/src/channel.ts DELETED
@@ -1,34 +0,0 @@
1
- import React from 'react'
2
- import { MessageSquare, Phone, Instagram, Mail, Globe } from 'lucide-react'
3
- import { CHANNEL_LABELS, type Channel } from './types'
4
-
5
- // ---------------------------------------------------------------------------
6
- // Shared channel visuals — one source of truth for the icon + accent color of
7
- // each channel, reused by the conversation list, thread header and contact
8
- // panel. Keeps the omni-channel inbox visually consistent.
9
- // ---------------------------------------------------------------------------
10
-
11
- export const CHANNEL_ICON: Record<Channel, React.ComponentType<{ className?: string }>> = {
12
- sms: Phone,
13
- whatsapp: MessageSquare,
14
- instagram: Instagram,
15
- email: Mail,
16
- webchat: Globe,
17
- }
18
-
19
- export interface ChannelAccent {
20
- /** Solid brand color (hex) — bubbles, dots, header chips. */
21
- color: string
22
- /** Soft tinted background classes for badges. */
23
- badge: string
24
- }
25
-
26
- export const CHANNEL_ACCENT: Record<Channel, ChannelAccent> = {
27
- whatsapp: { color: '#22c55e', badge: 'bg-[#22c55e]/12 text-[#15803d] dark:text-[#4ade80]' },
28
- sms: { color: '#6366f1', badge: 'bg-[#6366f1]/12 text-[#4338ca] dark:text-[#a5b4fc]' },
29
- instagram: { color: '#ec4899', badge: 'bg-[#ec4899]/12 text-[#be185d] dark:text-[#f9a8d4]' },
30
- email: { color: '#0ea5e9', badge: 'bg-[#0ea5e9]/12 text-[#0369a1] dark:text-[#7dd3fc]' },
31
- webchat: { color: '#f59e0b', badge: 'bg-[#f59e0b]/12 text-[#b45309] dark:text-[#fcd34d]' },
32
- }
33
-
34
- export { CHANNEL_LABELS }
@@ -1,12 +0,0 @@
1
- import type { Channel } from '../types'
2
-
3
- // Solid brand hex per channel — mirrors CHANNEL_ACCENT in ../channel.ts but
4
- // React-free so the data providers can stamp a sensible avatar accent on new
5
- // conversations without importing the icon layer.
6
- export const CHANNEL_ACCENT_HEX: Record<Channel, string> = {
7
- whatsapp: '#22c55e',
8
- sms: '#6366f1',
9
- instagram: '#ec4899',
10
- email: '#0ea5e9',
11
- webchat: '#f59e0b',
12
- }
@@ -1,90 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { createMockConversationsProvider } from './mock'
3
-
4
- // Minimal localStorage shim so we can exercise the persistence path (the mock
5
- // guards on `typeof window`, degrading to pure memory when absent).
6
- function installLocalStorage(): void {
7
- const store = new Map<string, string>()
8
- const localStorage = {
9
- getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
10
- setItem: (k: string, v: string) => void store.set(k, String(v)),
11
- removeItem: (k: string) => void store.delete(k),
12
- clear: () => void store.clear(),
13
- }
14
- ;(globalThis as unknown as { window: unknown }).window = { localStorage }
15
- }
16
-
17
- function uninstallLocalStorage(): void {
18
- delete (globalThis as unknown as { window?: unknown }).window
19
- }
20
-
21
- describe('plugin-conversations · mock provider', () => {
22
- it('ships a typed seed', async () => {
23
- const provider = createMockConversationsProvider()
24
- const list = await provider.listConversations()
25
- expect(list.length).toBeGreaterThan(0)
26
- expect(list[0].contactName).toBeTruthy()
27
- })
28
-
29
- it('createConversation → sendMessage → list persists within the session', async () => {
30
- const provider = createMockConversationsProvider({ tenantId: 'test-tenant' })
31
-
32
- const created = await provider.createConversation({
33
- channel: 'sms',
34
- contactName: 'Ada Lovelace',
35
- contactHandle: '+1 555 010 0000',
36
- firstMessage: 'Hello there',
37
- })
38
- expect(created.id).toBeTruthy()
39
- expect(created.channel).toBe('sms')
40
- expect(created.contactName).toBe('Ada Lovelace')
41
- expect(created.lastMessagePreview).toBe('Hello there')
42
-
43
- // The seeded first message is present.
44
- const seededMsgs = await provider.getMessages(created.id)
45
- expect(seededMsgs).toHaveLength(1)
46
- expect(seededMsgs[0].direction).toBe('outbound')
47
- expect(seededMsgs[0].body).toBe('Hello there')
48
-
49
- // A follow-up reply rolls the thread + preview forward.
50
- const reply = await provider.sendMessage({ conversationId: created.id, body: 'How are you?' })
51
- expect(reply.direction).toBe('outbound')
52
-
53
- const msgs = await provider.getMessages(created.id)
54
- expect(msgs.map((m) => m.body)).toEqual(['Hello there', 'How are you?'])
55
-
56
- // The new conversation surfaces in list() with the latest preview.
57
- const list = await provider.listConversations()
58
- const found = list.find((c) => c.id === created.id)
59
- expect(found).toBeDefined()
60
- expect(found!.lastMessagePreview).toBe('How are you?')
61
- })
62
-
63
- it('persists across provider instances via localStorage (reload survives)', async () => {
64
- installLocalStorage()
65
- try {
66
- const first = createMockConversationsProvider({ tenantId: 'reload-tenant' })
67
- const created = await first.createConversation({
68
- channel: 'whatsapp',
69
- contactName: 'Grace Hopper',
70
- firstMessage: 'Compiling',
71
- })
72
-
73
- // Simulate a reload: a brand-new provider for the same tenant.
74
- const second = createMockConversationsProvider({ tenantId: 'reload-tenant' })
75
- const list = await second.listConversations()
76
- const found = list.find((c) => c.id === created.id)
77
- expect(found).toBeDefined()
78
- expect(found!.contactName).toBe('Grace Hopper')
79
-
80
- const msgs = await second.getMessages(created.id)
81
- expect(msgs.map((m) => m.body)).toContain('Compiling')
82
- } finally {
83
- uninstallLocalStorage()
84
- }
85
- })
86
- })
87
-
88
- // Guard so the shim never leaks into other suites if this file is imported elsewhere.
89
- beforeEach(() => uninstallLocalStorage())
90
- afterEach(() => uninstallLocalStorage())
package/src/data/mock.ts DELETED
@@ -1,261 +0,0 @@
1
- import type { ConversationsProvider } from './types'
2
- import { CHANNEL_ACCENT_HEX } from './accents'
3
- import type {
4
- Conversation,
5
- Message,
6
- ListConversationsQuery,
7
- SendMessageInput,
8
- CreateConversationInput,
9
- ConversationStatus,
10
- } from '../types'
11
-
12
- // Deterministic relative timestamps (no Date.now at module init for stable seeds).
13
- function minutesAgo(base: number, mins: number): string {
14
- return new Date(base - mins * 60_000).toISOString()
15
- }
16
-
17
- function seed(): { conversations: Conversation[]; messages: Message[] } {
18
- const base = new Date('2026-06-16T14:00:00Z').getTime()
19
-
20
- const conversations: Conversation[] = [
21
- {
22
- id: 'c1', contactName: 'Marina Alves', contactHandle: '+55 11 99876-1020', channel: 'whatsapp',
23
- lastMessagePreview: 'Perfect, can we book for Friday at 3pm?', lastMessageAt: minutesAgo(base, 4),
24
- unreadCount: 2, status: 'open', assignedTo: 'You', accent: '#22c55e', tags: ['Hot lead'],
25
- location: 'São Paulo, BR', note: 'Referred by Instagram ad — interested in full color + cut.',
26
- },
27
- {
28
- id: 'c2', contactName: 'Jordan Pierce', contactHandle: '+1 (415) 555-0142', channel: 'sms',
29
- lastMessagePreview: 'Got it — sending the deposit now.', lastMessageAt: minutesAgo(base, 22),
30
- unreadCount: 0, status: 'open', assignedTo: 'You', accent: '#6366f1', tags: ['Customer'],
31
- location: 'San Francisco, US',
32
- },
33
- {
34
- id: 'c3', contactName: '@thehairloft', contactHandle: 'thehairloft', channel: 'instagram',
35
- lastMessagePreview: 'Do you offer balayage on weekends?', lastMessageAt: minutesAgo(base, 51),
36
- unreadCount: 1, status: 'open', accent: '#ec4899', tags: ['New'],
37
- },
38
- {
39
- id: 'c4', contactName: 'David Whitman', contactHandle: 'david@whitman.co', channel: 'email',
40
- lastMessagePreview: 'Re: Proposal — looks great, one question on pricing…', lastMessageAt: minutesAgo(base, 95),
41
- unreadCount: 0, status: 'open', assignedTo: 'Sofia', accent: '#0ea5e9', tags: ['Proposal'],
42
- location: 'Austin, US', note: 'Evaluating the retainer tier. Decision expected this week.',
43
- },
44
- {
45
- id: 'c5', contactName: 'Website visitor', contactHandle: 'live chat · acme.com', channel: 'webchat',
46
- lastMessagePreview: 'Is anyone available to chat?', lastMessageAt: minutesAgo(base, 140),
47
- unreadCount: 0, status: 'snoozed', accent: '#f59e0b', tags: [],
48
- },
49
- {
50
- id: 'c6', contactName: 'Priya Nair', contactHandle: '+44 7700 900123', channel: 'whatsapp',
51
- lastMessagePreview: 'Thank you! See you next week 🙌', lastMessageAt: minutesAgo(base, 1440),
52
- unreadCount: 0, status: 'closed', assignedTo: 'You', accent: '#14b8a6', tags: ['Customer'],
53
- location: 'London, UK',
54
- },
55
- ]
56
-
57
- const messages: Message[] = [
58
- msg('m1', 'c1', 'whatsapp', 'inbound', 'Hi! I saw your ad — do you have availability this week?', 'Marina Alves', minutesAgo(base, 18)),
59
- msg('m2', 'c1', 'whatsapp', 'outbound', 'Hi Marina! Yes, we do. What service are you interested in?', 'You', minutesAgo(base, 15)),
60
- msg('m3', 'c1', 'whatsapp', 'inbound', 'A full color + cut.', 'Marina Alves', minutesAgo(base, 9)),
61
- msg('m4', 'c1', 'whatsapp', 'inbound', 'Perfect, can we book for Friday at 3pm?', 'Marina Alves', minutesAgo(base, 4)),
62
-
63
- msg('m5', 'c2', 'sms', 'outbound', 'Your appointment is confirmed for tomorrow at 10am.', 'You', minutesAgo(base, 40)),
64
- msg('m6', 'c2', 'sms', 'inbound', 'Got it — sending the deposit now.', 'Jordan Pierce', minutesAgo(base, 22)),
65
-
66
- msg('m7', 'c3', 'instagram', 'inbound', 'Do you offer balayage on weekends?', '@thehairloft', minutesAgo(base, 51)),
67
-
68
- msg('m8', 'c4', 'email', 'inbound', 'Re: Proposal — looks great, one question on pricing for the retainer tier.', 'David Whitman', minutesAgo(base, 95)),
69
- msg('m9', 'c4', 'email', 'outbound', 'Happy to walk you through it — are you free for a quick call tomorrow?', 'Sofia', minutesAgo(base, 80)),
70
-
71
- msg('m10', 'c5', 'webchat', 'inbound', 'Is anyone available to chat?', 'Website visitor', minutesAgo(base, 140)),
72
-
73
- msg('m11', 'c6', 'whatsapp', 'outbound', 'You are all set for next Tuesday. Anything else?', 'You', minutesAgo(base, 1500)),
74
- msg('m12', 'c6', 'whatsapp', 'inbound', 'Thank you! See you next week 🙌', 'Priya Nair', minutesAgo(base, 1440)),
75
- ]
76
-
77
- return { conversations, messages }
78
- }
79
-
80
- function msg(
81
- id: string, conversationId: string, channel: Message['channel'],
82
- direction: Message['direction'], body: string, author: string, at: string,
83
- ): Message {
84
- return { id, conversationId, channel, direction, body, author, at }
85
- }
86
-
87
- export interface MockConversationsConfig {
88
- /** Tenant id (value or getter) used to namespace the localStorage snapshot. */
89
- tenantId?: string | (() => string | undefined)
90
- /** Display name stamped as the author of outbound messages. */
91
- selfAuthor?: string
92
- }
93
-
94
- interface Snapshot {
95
- conversations: Conversation[]
96
- messages: Message[]
97
- counter: number
98
- }
99
-
100
- // ---------------------------------------------------------------------------
101
- // In-memory mock inbox with best-effort localStorage persistence, so a browser
102
- // reload keeps whatever the demo created/sent within the session. The store is
103
- // namespaced per tenant; SSR / no-window environments degrade to pure memory.
104
- // ---------------------------------------------------------------------------
105
- export function createMockConversationsProvider(
106
- config?: MockConversationsConfig,
107
- ): ConversationsProvider {
108
- const selfAuthor = config?.selfAuthor ?? 'You'
109
-
110
- function resolveTenant(): string {
111
- const raw = typeof config?.tenantId === 'function' ? config.tenantId() : config?.tenantId
112
- return raw || 'default'
113
- }
114
-
115
- const storageKey = () => `saas:mock:conversations:${resolveTenant()}`
116
-
117
- function hasStorage(): boolean {
118
- try {
119
- return typeof window !== 'undefined' && !!window.localStorage
120
- } catch {
121
- return false
122
- }
123
- }
124
-
125
- function load(): Snapshot {
126
- if (hasStorage()) {
127
- try {
128
- const raw = window.localStorage.getItem(storageKey())
129
- if (raw) {
130
- const parsed = JSON.parse(raw) as Snapshot
131
- if (Array.isArray(parsed.conversations) && Array.isArray(parsed.messages)) {
132
- return {
133
- conversations: parsed.conversations,
134
- messages: parsed.messages,
135
- counter: parsed.counter ?? 100,
136
- }
137
- }
138
- }
139
- } catch {
140
- // Corrupt snapshot — fall through to a fresh seed.
141
- }
142
- }
143
- const seeded = seed()
144
- return { conversations: seeded.conversations, messages: seeded.messages, counter: 100 }
145
- }
146
-
147
- const state = load()
148
-
149
- function persist(): void {
150
- if (!hasStorage()) return
151
- try {
152
- window.localStorage.setItem(
153
- storageKey(),
154
- JSON.stringify({
155
- conversations: state.conversations,
156
- messages: state.messages,
157
- counter: state.counter,
158
- } satisfies Snapshot),
159
- )
160
- } catch {
161
- // Quota / privacy mode — keep working in memory only.
162
- }
163
- }
164
-
165
- // Persist the initial seed so a first reload is already stable.
166
- persist()
167
-
168
- return {
169
- async listConversations(query?: ListConversationsQuery): Promise<Conversation[]> {
170
- let list = [...state.conversations]
171
- if (query?.channel && query.channel !== 'all') list = list.filter((c) => c.channel === query.channel)
172
- if (query?.status && query.status !== 'all') list = list.filter((c) => c.status === query.status)
173
- if (query?.search) {
174
- const q = query.search.toLowerCase()
175
- list = list.filter(
176
- (c) => c.contactName.toLowerCase().includes(q) || c.lastMessagePreview.toLowerCase().includes(q),
177
- )
178
- }
179
- return list.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt))
180
- },
181
-
182
- async getMessages(conversationId: string): Promise<Message[]> {
183
- return state.messages
184
- .filter((m) => m.conversationId === conversationId)
185
- .sort((a, b) => a.at.localeCompare(b.at))
186
- },
187
-
188
- async createConversation(input: CreateConversationInput): Promise<Conversation> {
189
- const now = new Date().toISOString()
190
- const firstMessage = input.firstMessage?.trim()
191
- const id = `c${++state.counter}`
192
- const conversation: Conversation = {
193
- id,
194
- contactName: input.contactName.trim(),
195
- contactPersonId: input.contactPersonId,
196
- contactHandle: input.contactHandle?.trim() ?? '',
197
- channel: input.channel,
198
- lastMessagePreview: firstMessage ?? '',
199
- lastMessageAt: now,
200
- unreadCount: 0,
201
- status: 'open',
202
- assignedTo: selfAuthor,
203
- accent: CHANNEL_ACCENT_HEX[input.channel],
204
- tags: [],
205
- note: input.note?.trim() || undefined,
206
- }
207
- state.conversations.unshift(conversation)
208
- if (firstMessage) {
209
- state.messages.push({
210
- id: `m${++state.counter}`,
211
- conversationId: id,
212
- channel: input.channel,
213
- direction: 'outbound',
214
- body: firstMessage,
215
- author: selfAuthor,
216
- at: now,
217
- })
218
- }
219
- persist()
220
- return conversation
221
- },
222
-
223
- async sendMessage(input: SendMessageInput): Promise<Message> {
224
- const conv = state.conversations.find((c) => c.id === input.conversationId)
225
- const created: Message = {
226
- id: `m${++state.counter}`,
227
- conversationId: input.conversationId,
228
- channel: conv?.channel ?? 'sms',
229
- direction: 'outbound',
230
- body: input.body,
231
- author: selfAuthor,
232
- at: new Date().toISOString(),
233
- }
234
- state.messages.push(created)
235
- if (conv) {
236
- conv.lastMessagePreview = input.body
237
- conv.lastMessageAt = created.at
238
- conv.unreadCount = 0
239
- if (conv.status === 'closed') conv.status = 'open'
240
- }
241
- persist()
242
- return created
243
- },
244
-
245
- async markRead(conversationId: string): Promise<void> {
246
- const conv = state.conversations.find((c) => c.id === conversationId)
247
- if (conv && conv.unreadCount !== 0) {
248
- conv.unreadCount = 0
249
- persist()
250
- }
251
- },
252
-
253
- async setStatus(conversationId: string, status: ConversationStatus): Promise<Conversation> {
254
- const conv = state.conversations.find((c) => c.id === conversationId)
255
- if (!conv) throw new Error('Conversation not found')
256
- conv.status = status
257
- persist()
258
- return conv
259
- },
260
- }
261
- }