@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.4

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 (53) hide show
  1. package/dist/chunk-4R6Y43DQ.js +726 -0
  2. package/dist/chunk-NV2RZ5KT.js +56 -0
  3. package/dist/{chunk-ZDURDZTM.js → chunk-OGRRHQQW.js} +1 -41
  4. package/dist/flows/index.js +6 -4
  5. package/dist/index.d.ts +323 -111
  6. package/dist/index.js +1032 -954
  7. package/dist/preview/index.d.ts +172 -0
  8. package/dist/preview/index.js +576 -0
  9. package/dist/styles.css +198 -0
  10. package/dist/types-C0PtaO7S.d.ts +207 -0
  11. package/package.json +10 -3
  12. package/src/Avatar.tsx +18 -3
  13. package/src/ChannelIcon.tsx +87 -0
  14. package/src/ConversationContextPanel.tsx +106 -0
  15. package/src/ConversationDocumentsPanel.tsx +107 -0
  16. package/src/ConversationHeader.tsx +239 -0
  17. package/src/ConversationListItem.tsx +36 -5
  18. package/src/ConversationLocalesProvider.tsx +16 -0
  19. package/src/ConversationRow.tsx +137 -0
  20. package/src/DateDivider.tsx +16 -3
  21. package/src/MediaRenderer.tsx +9 -9
  22. package/src/MessageBubble.tsx +24 -2
  23. package/src/MessageComposer.tsx +15 -2
  24. package/src/Wallpaper.tsx +4 -2
  25. package/src/WindowExpiredNotice.tsx +57 -0
  26. package/src/conversationChannel.test.ts +53 -0
  27. package/src/conversationChannel.ts +146 -0
  28. package/src/conversationTranscript.test.ts +65 -0
  29. package/src/conversationTranscript.ts +64 -0
  30. package/src/conversationWindow.test.ts +90 -0
  31. package/src/conversationWindow.ts +78 -0
  32. package/src/flows/FlowMapCanvas.tsx +2 -2
  33. package/src/hooks/useConversationDocuments.ts +4 -2
  34. package/src/index.ts +73 -4
  35. package/src/lib/cn.ts +15 -0
  36. package/src/lib/phone.ts +34 -0
  37. package/src/preview/ConversationPreview.tsx +148 -0
  38. package/src/preview/createMockConversationsApi.ts +111 -0
  39. package/src/preview/createMockSSEProvider.ts +40 -0
  40. package/src/preview/createPreviewWebhookClient.test.ts +105 -0
  41. package/src/preview/createPreviewWebhookClient.ts +99 -0
  42. package/src/preview/index.ts +40 -0
  43. package/src/preview/mockEventSource.ts +53 -0
  44. package/src/preview/preview.test.ts +175 -0
  45. package/src/preview/previewFixtures.ts +153 -0
  46. package/src/preview/previewStore.ts +193 -0
  47. package/src/preview/startPreviewScript.ts +60 -0
  48. package/src/providers/types.ts +36 -2
  49. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  50. package/src/styles.css +136 -0
  51. package/src/types.ts +8 -0
  52. package/src/useDarkMode.ts +26 -0
  53. package/src/useIsNarrow.ts +29 -0
package/dist/styles.css CHANGED
@@ -9,3 +9,201 @@
9
9
  background-color: #0b141a;
10
10
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%2319232a' stroke-width='1.2' opacity='0.9'%3E%3Ccircle cx='15' cy='15' r='3'/%3E%3Cpath d='M40 10 q5 8 0 16 q-5 -8 0 -16z'/%3E%3Ccircle cx='70' cy='28' r='2'/%3E%3Cpath d='M18 55 l6 6 m-6 0 l6 -6'/%3E%3Ccircle cx='55' cy='68' r='2.5'/%3E%3Cpath d='M85 58 q6 6 0 12 q-6 -6 0 -12z'/%3E%3Ccircle cx='8' cy='85' r='2'/%3E%3Cpath d='M65 90 l5 5 m-5 0 l5 -5'/%3E%3C/g%3E%3C/svg%3E");
11
11
  }
12
+ .cv-header-action {
13
+ border: 1px solid rgb(203 213 225);
14
+ border-radius: 0.375rem;
15
+ padding: 0.375rem 0.75rem;
16
+ font-size: 0.8125rem;
17
+ background: transparent;
18
+ cursor: pointer;
19
+ }
20
+ .cv-header-action:disabled {
21
+ opacity: 0.5;
22
+ cursor: not-allowed;
23
+ }
24
+ .dark .cv-header-action {
25
+ border-color: rgb(71 85 105);
26
+ color: rgb(226 232 240);
27
+ }
28
+ .cv-header-icon {
29
+ border: 1px solid rgb(203 213 225);
30
+ border-radius: 0.5rem;
31
+ padding: 0.375rem 0.5rem;
32
+ background: transparent;
33
+ cursor: pointer;
34
+ line-height: 1;
35
+ }
36
+ .cv-header-icon--active {
37
+ background: rgb(241 245 249);
38
+ border-color: rgb(148 163 184);
39
+ }
40
+ .cv-header-icon:disabled {
41
+ opacity: 0.5;
42
+ cursor: not-allowed;
43
+ }
44
+ .dark .cv-header-icon {
45
+ border-color: rgb(71 85 105);
46
+ }
47
+ .dark .cv-header-icon--active {
48
+ background: rgb(51 65 85);
49
+ }
50
+ .cv-header-action--primary {
51
+ background: rgb(37 99 235);
52
+ border-color: rgb(37 99 235);
53
+ color: white;
54
+ font-weight: 500;
55
+ }
56
+ .cv-header-action--danger {
57
+ background: rgb(239 68 68);
58
+ border-color: rgb(239 68 68);
59
+ color: white;
60
+ font-weight: 500;
61
+ }
62
+ .cv-subtab {
63
+ padding: 0.5rem 0.75rem;
64
+ font-size: 0.8125rem;
65
+ color: rgb(100 116 139);
66
+ background: transparent;
67
+ border: 0;
68
+ border-bottom: 2px solid transparent;
69
+ cursor: pointer;
70
+ }
71
+ .cv-subtab--active {
72
+ color: inherit;
73
+ font-weight: 500;
74
+ border-bottom-color: rgb(37 99 235);
75
+ }
76
+ .cv-pill {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ gap: 0.25rem;
80
+ font-size: 0.6875rem;
81
+ font-weight: 500;
82
+ line-height: 1;
83
+ padding: 0.25rem 0.5rem;
84
+ border-radius: 9999px;
85
+ background: rgb(241 245 249);
86
+ color: rgb(71 85 105);
87
+ }
88
+ .cv-pill--success {
89
+ background: rgb(220 252 231);
90
+ color: rgb(21 128 61);
91
+ }
92
+ .cv-pill--warning {
93
+ background: rgb(254 243 199);
94
+ color: rgb(146 64 14);
95
+ }
96
+ .cv-pill--danger {
97
+ background: rgb(255 237 213);
98
+ color: rgb(194 65 12);
99
+ }
100
+ .dark .cv-pill {
101
+ background: rgb(51 65 85);
102
+ color: rgb(203 213 225);
103
+ }
104
+ .dark .cv-pill--success {
105
+ background: rgb(20 83 45);
106
+ color: rgb(187 247 208);
107
+ }
108
+ .dark .cv-pill--warning {
109
+ background: rgb(120 53 15);
110
+ color: rgb(253 230 138);
111
+ }
112
+ .dark .cv-pill--danger {
113
+ background: rgb(124 45 18);
114
+ color: rgb(254 215 170);
115
+ }
116
+ .cv-row:hover {
117
+ background: #f0f2f5;
118
+ }
119
+ .cv-row--active,
120
+ .cv-row--active:hover {
121
+ background: #f0f2f5;
122
+ }
123
+ .dark .cv-row:hover {
124
+ background: rgb(30 41 59);
125
+ }
126
+ .dark .cv-row--active,
127
+ .dark .cv-row--active:hover {
128
+ background: rgb(30 41 59);
129
+ }
130
+ .cv-back {
131
+ border: 1px solid rgb(203 213 225);
132
+ border-radius: 0.5rem;
133
+ padding: 0.25rem 0.5rem;
134
+ background: transparent;
135
+ cursor: pointer;
136
+ line-height: 1;
137
+ }
138
+ @media (min-width: 1024px) {
139
+ .cv-back {
140
+ display: none;
141
+ }
142
+ }
143
+ .dark .cv-back {
144
+ border-color: rgb(71 85 105);
145
+ color: rgb(226 232 240);
146
+ }
147
+ .cv-only-wide {
148
+ display: none;
149
+ }
150
+ @media (min-width: 1024px) {
151
+ .cv-only-wide {
152
+ display: inline;
153
+ }
154
+ }
155
+ .cv-only-narrow {
156
+ display: inline-flex;
157
+ }
158
+ @media (min-width: 1024px) {
159
+ .cv-only-narrow {
160
+ display: none;
161
+ }
162
+ }
163
+ .cv-menu {
164
+ position: relative;
165
+ }
166
+ .cv-menu-panel {
167
+ position: absolute;
168
+ right: 0;
169
+ top: calc(100% + 0.25rem);
170
+ z-index: 20;
171
+ min-width: 12rem;
172
+ display: flex;
173
+ flex-direction: column;
174
+ gap: 0.25rem;
175
+ padding: 0.375rem;
176
+ border: 1px solid rgb(203 213 225);
177
+ border-radius: 0.5rem;
178
+ background: #fff;
179
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
180
+ }
181
+ .dark .cv-menu-panel {
182
+ background: rgb(30 41 59);
183
+ border-color: rgb(71 85 105);
184
+ }
185
+ .cv-menu-panel .cv-header-action {
186
+ text-align: left;
187
+ }
188
+ @media (max-width: 1023px) {
189
+ .cv-touch {
190
+ min-width: 44px;
191
+ min-height: 44px;
192
+ }
193
+ .cv-back {
194
+ min-width: 44px;
195
+ min-height: 44px;
196
+ }
197
+ .cv-menu-panel .cv-touch {
198
+ min-height: 44px;
199
+ }
200
+ }
201
+ @media (max-width: 1023px) {
202
+ .cv-menu-panel {
203
+ position: fixed;
204
+ right: 0.75rem;
205
+ left: auto;
206
+ top: auto;
207
+ max-width: calc(100vw - 1.5rem);
208
+ }
209
+ }
@@ -0,0 +1,207 @@
1
+ interface ConversationsUIConfig {
2
+ apiBaseUrl: string;
3
+ theme?: ConversationsTheme;
4
+ features?: ConversationsFeatures;
5
+ }
6
+ interface ConversationsTheme {
7
+ primaryColor?: string;
8
+ backgroundColor?: string;
9
+ bubbleSent?: string;
10
+ bubbleReceived?: string;
11
+ textPrimary?: string;
12
+ textSecondary?: string;
13
+ }
14
+ interface ConversationsFeatures {
15
+ audio?: boolean;
16
+ documents?: boolean;
17
+ emoji?: boolean;
18
+ darkMode?: boolean;
19
+ }
20
+ interface MessagePayload {
21
+ id: string;
22
+ type: 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'template';
23
+ content?: string;
24
+ caption?: string;
25
+ mediaUrl?: string;
26
+ base64?: string;
27
+ uploadId?: string;
28
+ mediaId?: string;
29
+ mimeType?: string;
30
+ filename?: string;
31
+ sizeBytes?: number;
32
+ direction: 'inbound' | 'outbound';
33
+ sender: 'bot' | 'customer' | 'agent';
34
+ timestamp: string;
35
+ status?: 'sent' | 'delivered' | 'read' | 'failed';
36
+ readAt?: string;
37
+ agentName?: string | null;
38
+ templateName?: string;
39
+ /**
40
+ * Veredito de moderação vindo do backend — a UI só exibe, nunca calcula. Dicionário no browser
41
+ * seria peso morto e daria veredito diferente por versão de cliente.
42
+ *
43
+ * `null`/ausente = não avaliado (moderação desligada, ou mensagem anterior ao recurso), que é
44
+ * diferente de avaliado e limpo.
45
+ */
46
+ moderation?: {
47
+ isOffensive: boolean;
48
+ terms: string[];
49
+ } | null;
50
+ isFirstInGroup?: boolean;
51
+ isLastInGroup?: boolean;
52
+ }
53
+
54
+ /**
55
+ * Canal de origem da conversa e o que cada um permite.
56
+ *
57
+ * Existe porque as regras que a inbox precisa respeitar não são do WhatsApp, são **de cada canal**:
58
+ * a janela de sessão, o jeito de reabrir a conversa e o formato do identificador do contato mudam
59
+ * entre WhatsApp, Messenger, Instagram e chat de site. Tratar a regra do WhatsApp como universal
60
+ * faria a UI bloquear o composer num chat de site, onde janela nenhuma existe.
61
+ *
62
+ * `whatsapp` é o padrão em todo lugar: instalações que ainda não informam canal continuam
63
+ * funcionando exatamente como antes.
64
+ */
65
+ declare const CONVERSATION_CHANNEL: {
66
+ readonly WHATSAPP: "whatsapp";
67
+ readonly MESSENGER: "messenger";
68
+ readonly INSTAGRAM: "instagram";
69
+ readonly WEBCHAT: "webchat";
70
+ };
71
+ type ConversationChannel = (typeof CONVERSATION_CHANNEL)[keyof typeof CONVERSATION_CHANNEL];
72
+ declare const DEFAULT_CONVERSATION_CHANNEL: ConversationChannel;
73
+ /** Como o canal reabre uma conversa fora da janela de sessão. */
74
+ declare const REOPEN_MECHANISM: {
75
+ readonly TEMPLATE: "template";
76
+ readonly TAG: "tag";
77
+ readonly NONE: "none";
78
+ };
79
+ type ReopenMechanism = (typeof REOPEN_MECHANISM)[keyof typeof REOPEN_MECHANISM];
80
+ /** Natureza do identificador do contato — decide como exibi-lo. */
81
+ declare const HANDLE_KIND: {
82
+ readonly PHONE: "phone";
83
+ readonly USERNAME: "username";
84
+ readonly SESSION: "session";
85
+ };
86
+ type HandleKind = (typeof HANDLE_KIND)[keyof typeof HANDLE_KIND];
87
+ type ChannelCapabilities = {
88
+ readonly label: string;
89
+ readonly icon: string;
90
+ readonly hasSessionWindow: boolean;
91
+ readonly windowHours: number;
92
+ readonly reopenMechanism: ReopenMechanism;
93
+ readonly handleKind: HandleKind;
94
+ };
95
+ declare const CHANNEL_CAPABILITIES: Readonly<Record<ConversationChannel, ChannelCapabilities>>;
96
+ declare function capabilitiesOf(channel: ConversationChannel | undefined): ChannelCapabilities;
97
+ declare const CHANNEL_FILTER_ALL = "all";
98
+ type ChannelFilter = ConversationChannel | typeof CHANNEL_FILTER_ALL;
99
+ type ChannelFilterOption = {
100
+ readonly value: ChannelFilter;
101
+ readonly label: string;
102
+ };
103
+ /**
104
+ * Opções derivadas do que existe na lista, não do catálogo inteiro: oferecer Instagram numa conta
105
+ * que só tem WhatsApp promete um recorte que nunca traz resultado.
106
+ *
107
+ * Devolve vazio com menos de dois canais — um filtro de opção única não filtra nada, e a barra só
108
+ * ocuparia espaço. O host usa isso para esconder a seção.
109
+ */
110
+ declare function channelFiltersFor(conversations: readonly {
111
+ readonly channel?: ConversationChannel | undefined;
112
+ }[]): ChannelFilterOption[];
113
+ type FormatContactHandleParams = {
114
+ readonly handle: string;
115
+ readonly channel?: ConversationChannel | undefined;
116
+ };
117
+ /**
118
+ * Exibição do identificador conforme a natureza dele. Formatar tudo como telefone — o que a UI
119
+ * fazia — transforma um `@perfil` do Instagram em dígitos sem sentido.
120
+ */
121
+ declare function formatContactHandle(params: FormatContactHandleParams): string;
122
+ /** Bandeira só faz sentido quando o identificador é telefone. */
123
+ declare function contactFlag(params: FormatContactHandleParams): string;
124
+
125
+ interface ConversationsApi {
126
+ fetchMessages(conversationId: string, params?: {
127
+ limit?: number;
128
+ before?: string;
129
+ }): Promise<MessagePayload[]>;
130
+ fetchConversations(params?: {
131
+ page?: number;
132
+ limit?: number;
133
+ waitingHuman?: boolean;
134
+ search?: string;
135
+ }): Promise<ConversationSummary[]>;
136
+ sendMessage(conversationId: string, text: string): Promise<MessagePayload>;
137
+ sendMedia(conversationId: string, data: {
138
+ base64: string;
139
+ mimeType: string;
140
+ filename: string;
141
+ caption?: string;
142
+ }): Promise<MessagePayload>;
143
+ sendTemplate(conversationId: string, data: {
144
+ templateName: string;
145
+ languageCode?: string;
146
+ bodyParams?: string[];
147
+ }): Promise<void>;
148
+ markRead(conversationId: string): Promise<void>;
149
+ getContext(conversationId: string): Promise<Record<string, unknown>>;
150
+ getDocuments(conversationId: string, params?: {
151
+ search?: string;
152
+ page?: number;
153
+ }): Promise<ConversationDocument[]>;
154
+ getDocumentUrl(uploadId: string): Promise<string>;
155
+ getMediaProxyUrl(mediaId: string): Promise<{
156
+ mimeType: string;
157
+ data: string;
158
+ }>;
159
+ }
160
+ /**
161
+ * Superfície mínima de stream que o pacote consome — exatamente o que `useConversationRealtime`
162
+ * usa: assinar 'message', desassinar e fechar. Deliberadamente estrutural em vez de
163
+ * `EventSource`: sem servidor HTTP não existe `EventSource`, e é isso que impediria alimentar a
164
+ * inbox com dados mockados em desenvolvimento. Um `EventSource` nativo satisfaz este tipo, então
165
+ * quem já implementa `SSEProvider` continua válido sem mudança.
166
+ */
167
+ interface ConversationEventSource {
168
+ addEventListener(type: string, listener: (event: MessageEvent) => void): void;
169
+ removeEventListener(type: string, listener: (event: MessageEvent) => void): void;
170
+ close(): void;
171
+ }
172
+ interface SSEProvider {
173
+ connectConversationStream(conversationId: string): ConversationEventSource;
174
+ connectGlobalStream(): ConversationEventSource;
175
+ }
176
+ interface ConversationSummary {
177
+ id: string;
178
+ /**
179
+ * @deprecated Use `contactId` com `channel`. Mantido obrigatório para não quebrar quem já
180
+ * consome; some quando o segundo canal entrar em produção.
181
+ */
182
+ whatsappNumber: string;
183
+ /** Identificador neutro do contato. Ausente = usa `whatsappNumber`. */
184
+ contactId?: string;
185
+ /** Ausente = `whatsapp`, o comportamento de antes desta mudança. */
186
+ channel?: ConversationChannel;
187
+ clientName?: string;
188
+ lastContent?: string;
189
+ lastDirection?: 'inbound' | 'outbound';
190
+ lastAt: string;
191
+ lastInboundAt: string | null;
192
+ mode: 'bot' | 'human';
193
+ assignedUserId: string | null;
194
+ waitingHuman: boolean;
195
+ unread: number;
196
+ currentState: string;
197
+ }
198
+ interface ConversationDocument {
199
+ id: string;
200
+ filename: string;
201
+ mimeType: string;
202
+ sizeBytes: number;
203
+ source: string;
204
+ linkedAt: string;
205
+ }
206
+
207
+ export { CHANNEL_CAPABILITIES as C, DEFAULT_CONVERSATION_CHANNEL as D, type FormatContactHandleParams as F, HANDLE_KIND as H, type MessagePayload as M, REOPEN_MECHANISM as R, type SSEProvider as S, CHANNEL_FILTER_ALL as a, CONVERSATION_CHANNEL as b, type ChannelCapabilities as c, type ChannelFilter as d, type ChannelFilterOption as e, type ConversationChannel as f, type ConversationDocument as g, type ConversationEventSource as h, type ConversationSummary as i, type ConversationsApi as j, type ConversationsFeatures as k, type ConversationsTheme as l, type ConversationsUIConfig as m, type HandleKind as n, type ReopenMechanism as o, capabilitiesOf as p, channelFiltersFor as q, contactFlag as r, formatContactHandle as s };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.2",
3
+ "version": "0.1.0-rc.4",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,13 +19,19 @@
19
19
  "import": "./dist/channel/index.js",
20
20
  "types": "./dist/channel/index.d.ts"
21
21
  },
22
+ "./preview": {
23
+ "import": "./dist/preview/index.js",
24
+ "types": "./dist/preview/index.d.ts"
25
+ },
22
26
  "./styles.css": "./dist/styles.css"
23
27
  },
24
28
  "main": "dist/index.js",
25
29
  "types": "dist/index.d.ts",
26
30
  "dependencies": {
31
+ "clsx": "^2.1.1",
27
32
  "lucide-react": "^1.21.0",
28
- "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.2"
33
+ "tailwind-merge": "^3.6.0",
34
+ "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.4"
29
35
  },
30
36
  "peerDependencies": {
31
37
  "react": "^18 || ^19",
@@ -38,13 +44,14 @@
38
44
  }
39
45
  },
40
46
  "devDependencies": {
47
+ "@types/bun": "1.3.14",
41
48
  "tsup": "^8.5.1",
42
49
  "typescript": "^5.9.3",
43
50
  "@types/react": "^18 || ^19",
44
51
  "@types/react-dom": "^18 || ^19"
45
52
  },
46
53
  "scripts": {
47
- "build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/styles.css --dts --format esm --external react --external react-dom --external @xyflow/react",
54
+ "build": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/preview/index.ts src/styles.css --dts --format esm --external react --external react-dom --external @xyflow/react",
48
55
  "build:watch": "tsup src/index.ts src/flows/index.ts src/channel/index.ts src/styles.css --watch --dts --format esm --external react --external react-dom --external @xyflow/react",
49
56
  "check": "tsc -p tsconfig.json --noEmit",
50
57
  "test": "bun test"
package/src/Avatar.tsx CHANGED
@@ -49,15 +49,30 @@ export function Avatar({ name, avatarUrl, size = 'md', className = '' }: AvatarP
49
49
  )
50
50
  }
51
51
 
52
- const initials = getInitials(name)
53
52
  const bgColor = getBgColor(name)
54
53
 
54
+ // Sem nome e sem imagem, a silhueta genérica. Iniciais exigem nome — derivá-las de um telefone
55
+ // produz rótulo sem significado ("+9"), que é pior que assumir o anonimato.
56
+ if (!name) {
57
+ return (
58
+ <div
59
+ className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white flex-shrink-0 ${className}`}
60
+ role="img"
61
+ aria-label="Contato sem nome"
62
+ >
63
+ <svg viewBox="0 0 24 24" fill="currentColor" className="h-[60%] w-[60%]" aria-hidden>
64
+ <path d="M12 12a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5Z" />
65
+ </svg>
66
+ </div>
67
+ )
68
+ }
69
+
55
70
  return (
56
71
  <div
57
72
  className={`${sizeClass} ${bgColor} rounded-full flex items-center justify-center text-white font-semibold flex-shrink-0 ${className}`}
58
- title={name || undefined}
73
+ title={name}
59
74
  >
60
- {initials}
75
+ {getInitials(name)}
61
76
  </div>
62
77
  )
63
78
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Marca visual de cada canal.
3
+ *
4
+ * SVG em vez de emoji porque emoji não tem logo de marca: 💬 e 📨 não distinguem WhatsApp de
5
+ * Messenger para quem bate o olho numa fila de conversas. São marcas **simplificadas** desenhadas
6
+ * com primitivas — reconhecíveis pela forma e pela cor oficial, sem reproduzir o logotipo original.
7
+ *
8
+ * `currentColor` não serve aqui: a cor faz parte da identificação do canal.
9
+ */
10
+
11
+ import { CONVERSATION_CHANNEL, capabilitiesOf, type ConversationChannel } from './conversationChannel'
12
+
13
+ export const CHANNEL_BRAND_COLOR: Readonly<Record<ConversationChannel, string>> = {
14
+ [CONVERSATION_CHANNEL.WHATSAPP]: '#25D366',
15
+ [CONVERSATION_CHANNEL.MESSENGER]: '#0084FF',
16
+ [CONVERSATION_CHANNEL.INSTAGRAM]: '#E4405F',
17
+ [CONVERSATION_CHANNEL.WEBCHAT]: '#64748B',
18
+ }
19
+
20
+ export interface ChannelIconProps {
21
+ channel?: ConversationChannel | undefined
22
+ size?: number
23
+ className?: string
24
+ }
25
+
26
+ function Glyph({ channel }: { channel: ConversationChannel }) {
27
+ const color = CHANNEL_BRAND_COLOR[channel]
28
+
29
+ if (channel === CONVERSATION_CHANNEL.WHATSAPP) {
30
+ return (
31
+ <>
32
+ <circle cx="12" cy="12" r="11" fill={color} />
33
+ {/* Handset estilizado, o traço que identifica a marca à distância. */}
34
+ <path
35
+ d="M8.6 7.2c.3-.1.7 0 .9.3l1 1.6c.2.3.1.7-.1.9l-.7.7c.6 1.3 1.6 2.3 2.9 2.9l.7-.7c.2-.2.6-.3.9-.1l1.6 1c.3.2.4.6.3.9-.4 1-1.4 1.6-2.4 1.4-3-.5-5.4-2.9-5.9-5.9-.2-1 .4-2 1.4-2.4Z"
36
+ fill="#fff"
37
+ />
38
+ </>
39
+ )
40
+ }
41
+
42
+ if (channel === CONVERSATION_CHANNEL.MESSENGER) {
43
+ return (
44
+ <>
45
+ <circle cx="12" cy="12" r="11" fill={color} />
46
+ {/* Raio do Messenger. */}
47
+ <path d="M6.5 15.5 11 10.8l2.4 2.4L17.5 9l-4.6 4.9-2.4-2.4-4 4Z" fill="#fff" stroke="#fff" strokeWidth="1.4" strokeLinejoin="round" />
48
+ </>
49
+ )
50
+ }
51
+
52
+ if (channel === CONVERSATION_CHANNEL.INSTAGRAM) {
53
+ return (
54
+ <>
55
+ <rect x="2" y="2" width="20" height="20" rx="6" fill={color} />
56
+ <rect x="6.5" y="6.5" width="11" height="11" rx="3.5" fill="none" stroke="#fff" strokeWidth="1.6" />
57
+ <circle cx="12" cy="12" r="2.6" fill="none" stroke="#fff" strokeWidth="1.6" />
58
+ <circle cx="16.4" cy="7.6" r="1" fill="#fff" />
59
+ </>
60
+ )
61
+ }
62
+
63
+ return (
64
+ <>
65
+ <circle cx="12" cy="12" r="11" fill={color} />
66
+ <path d="M6.5 8.5h11v7h-5.5L8.5 18v-2.5h-2v-7Z" fill="#fff" />
67
+ </>
68
+ )
69
+ }
70
+
71
+ export function ChannelIcon({ channel, size = 14, className = '' }: ChannelIconProps) {
72
+ const resolved: ConversationChannel = channel ?? CONVERSATION_CHANNEL.WHATSAPP
73
+ const { label } = capabilitiesOf(resolved)
74
+
75
+ return (
76
+ <svg
77
+ viewBox="0 0 24 24"
78
+ width={size}
79
+ height={size}
80
+ className={`inline-block shrink-0 ${className}`.trim()}
81
+ role="img"
82
+ aria-label={label}
83
+ >
84
+ <Glyph channel={resolved} />
85
+ </svg>
86
+ )
87
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * "Suas Seleções": o que o bot já coletou na conversa. Para o atendente que assume no meio, é a
3
+ * diferença entre ler o transcript inteiro e ver o estado em duas linhas.
4
+ *
5
+ * O pacote não interpreta o contexto — ele é `Record<string, unknown>` e cada produto nomeia as
6
+ * próprias chaves. O host traduz para `entries`; aqui só se decide como mostrar.
7
+ */
8
+
9
+ import { useState } from 'react'
10
+
11
+ import { cn } from './lib/cn'
12
+ import { useIsNarrow } from './useIsNarrow'
13
+
14
+ export interface ConversationContextEntry {
15
+ key: string
16
+ label: string
17
+ value?: string | undefined
18
+ icon?: string
19
+ }
20
+
21
+ export interface ConversationContextPanelLabels {
22
+ title: string
23
+ empty: string
24
+ }
25
+
26
+ export const DEFAULT_CONVERSATION_CONTEXT_LABELS: ConversationContextPanelLabels = {
27
+ title: '📋 Suas Seleções',
28
+ empty: 'Nada coletado ainda nesta conversa.',
29
+ }
30
+
31
+ export interface ConversationContextPanelClassNames {
32
+ root: string
33
+ toggle: string
34
+ counter: string
35
+ body: string
36
+ }
37
+
38
+ export interface ConversationContextPanelProps {
39
+ entries: readonly ConversationContextEntry[]
40
+ labels?: Partial<ConversationContextPanelLabels>
41
+ className?: string
42
+ classNames?: Partial<ConversationContextPanelClassNames>
43
+ }
44
+
45
+ export function ConversationContextPanel({
46
+ entries,
47
+ labels: labelsOverride,
48
+ className,
49
+ classNames,
50
+ }: ConversationContextPanelProps) {
51
+ const labels = { ...DEFAULT_CONVERSATION_CONTEXT_LABELS, ...labelsOverride }
52
+ const filled = entries.filter((entry) => Boolean(entry.value))
53
+
54
+ // Abre sozinho só quando há algo coletado: um painel de seis traços ocupa a altura da conversa
55
+ // sem informar nada. O contador continua visível fechado, que é o dado útil de relance.
56
+ //
57
+ // `undefined` = usuário ainda não mexeu. Guardar só um booleano não serve: o contexto chega
58
+ // depois da montagem, então o estado inicial seria calculado com a lista vazia e o painel ficaria
59
+ // fechado mesmo com dados.
60
+ const isNarrow = useIsNarrow()
61
+ const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined)
62
+ // No celular nasce fechado mesmo com dados: aberto, o painel consome ~150px da conversa. O
63
+ // contador no cabeçalho já entrega a informação de relance.
64
+ const open = manualOpen ?? (!isNarrow && filled.length > 0)
65
+
66
+ return (
67
+ <section className={cn('border-b', classNames?.root, className)}>
68
+ <button
69
+ type="button"
70
+ onClick={() => setManualOpen(!open)}
71
+ aria-expanded={open}
72
+ className={cn('flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium', classNames?.toggle)}
73
+ >
74
+ <span aria-hidden className="text-xs">
75
+ {open ? '▾' : '▸'}
76
+ </span>
77
+ <span>{labels.title}</span>
78
+ <span className={cn('rounded-full bg-gray-200 px-2 text-xs dark:bg-gray-700', classNames?.counter)}>
79
+ {filled.length}/{entries.length}
80
+ </span>
81
+ </button>
82
+
83
+ {open ? (
84
+ <div className={cn('px-4 pb-3', classNames?.body)}>
85
+ {entries.length === 0 ? (
86
+ <p className="text-xs text-gray-500">{labels.empty}</p>
87
+ ) : (
88
+ <dl className="grid grid-cols-2 gap-2 text-xs">
89
+ {entries.map((entry) => (
90
+ <div key={entry.key} className="min-w-0">
91
+ <dt className="truncate text-gray-500">
92
+ {entry.icon ? `${entry.icon} ` : ''}
93
+ {entry.label}
94
+ </dt>
95
+ <dd className="truncate font-medium">
96
+ {entry.value ? <span className="text-green-700 dark:text-green-400">✓ {entry.value}</span> : '—'}
97
+ </dd>
98
+ </div>
99
+ ))}
100
+ </dl>
101
+ )}
102
+ </div>
103
+ ) : null}
104
+ </section>
105
+ )
106
+ }