@adatechnology/conversations-ui 0.1.0-rc.30 → 0.1.0-rc.31

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.
@@ -2270,6 +2270,7 @@ export {
2270
2270
  formatDateTime,
2271
2271
  isSameDay,
2272
2272
  formatFileSize,
2273
+ documentTypeLabel,
2273
2274
  MediaRenderer,
2274
2275
  DEFAULT_LIGHTBOX_LABELS,
2275
2276
  Lightbox,
@@ -345,13 +345,26 @@ type FlowNodePosition = {
345
345
  readonly x: number;
346
346
  readonly y: number;
347
347
  };
348
- /** Onde cada conversa viva está parada agora. */
348
+ /** Onde cada conversa viva está parada agora — uma linha por sessão. */
349
349
  type FlowLivePosition = {
350
350
  readonly currentState: string;
351
351
  readonly flow: string | null;
352
352
  readonly nodeId: string | null;
353
353
  readonly menuNodeId: string | null;
354
354
  };
355
+ /**
356
+ * Quantas conversas estão paradas num nó — o formato que o servidor já agrega.
357
+ *
358
+ * `meta-whatsapp-module` responde `GROUP BY (flowKey, currentNodeId)`, e trazer uma linha por
359
+ * sessão só para o editor recontar em memória seria pior em toda base grande. Os dois formatos
360
+ * valem porque produto que já implementou o de sessão não deve quebrar numa release de correção.
361
+ */
362
+ type FlowLiveNodeCount = {
363
+ readonly flowKey: string;
364
+ readonly nodeId: string;
365
+ readonly count: number;
366
+ };
367
+ type FlowLivePositionInput = FlowLivePosition | FlowLiveNodeCount;
355
368
  /**
356
369
  * Pseudo-nó que representa um fluxo alvo ainda não mesclado no canvas.
357
370
  *
@@ -364,14 +377,15 @@ declare const GROUP_HEADER_NODE_ID = "__group_header__";
364
377
  /**
365
378
  * Quantas conversas estão paradas em cada nó de um fluxo.
366
379
  *
367
- * A raiz é caso à parte: o servidor guarda o passo do menu em `menuNodeId`, num campo próprio, e uma
368
- * conversa no menu não carrega `flow`. Ler `nodeId` ali daria contagem zero na tela mais visitada do
369
- * editor.
380
+ * A raiz é caso à parte no formato por sessão: o servidor guarda o passo do menu em `menuNodeId`,
381
+ * num campo próprio, e uma conversa no menu não carrega `flow`. Ler `nodeId` ali daria contagem
382
+ * zero na tela mais visitada do editor. No formato agregado a chave do fluxo já vem na linha,
383
+ * então a raiz não tem exceção.
370
384
  */
371
385
  declare function countLiveByNode(params: {
372
386
  readonly flowKey: string;
373
387
  readonly rootFlowKey: string;
374
- readonly positions: readonly FlowLivePosition[] | undefined;
388
+ readonly positions: readonly FlowLivePositionInput[] | undefined;
375
389
  }): Record<string, number>;
376
390
  /**
377
391
  * Um layout só para TODOS os nós de TODOS os fluxos abertos juntos.
@@ -408,7 +422,7 @@ declare function buildFlowEdges(params: {
408
422
  readonly openKeys: readonly string[];
409
423
  readonly graphs: Readonly<Record<string, FlowGraphData>>;
410
424
  readonly rootFlowKey: string;
411
- readonly livePositions?: readonly FlowLivePosition[] | undefined;
425
+ readonly livePositions?: readonly FlowLivePositionInput[] | undefined;
412
426
  }): FlowEdgeSpec[];
413
427
  /** Nós de um fluxo que ninguém aponta — o card ganha contorno tracejado para cobrar a ligação. */
414
428
  declare function detachedNodeIds(graph: FlowGraphData): Set<string>;
@@ -453,8 +467,13 @@ interface FlowsWorkspaceApi {
453
467
  */
454
468
  createFlow?(input: CreateFlowInput): Promise<void>;
455
469
  deleteFlow?(key: string): Promise<void>;
456
- /** Contagem de conversas vivas por nó. Ausente, os cards não pulsam e nada é consultado. */
457
- getLivePositions?(): Promise<FlowLivePosition[]>;
470
+ /**
471
+ * Onde estão as conversas vivas. Ausente, os cards não pulsam e nada é consultado.
472
+ *
473
+ * Aceita a linha por sessão e a linha já agregada por nó — `meta-whatsapp-module` responde a
474
+ * segunda, e exigir a primeira deixava os cards parados em todo produto que usa o módulo.
475
+ */
476
+ getLivePositions?(): Promise<readonly FlowLivePositionInput[]>;
458
477
  }
459
478
  interface FlowsWorkspaceProps {
460
479
  readonly api: FlowsWorkspaceApi;
@@ -467,6 +486,14 @@ interface FlowsWorkspaceProps {
467
486
  readonly renderMediaPicker?: (node: FlowNodeData, graph: FlowGraphData) => ReactNode;
468
487
  /** Intervalo do polling de posições vivas. Só tem efeito com `getLivePositions`. */
469
488
  readonly livePollIntervalMs?: number;
489
+ /**
490
+ * Título e subtítulo do editor. `false` deixa só a barra de ações.
491
+ *
492
+ * Produto cuja navegação já nomeia a tela mostrava o nome duas vezes, em dois tamanhos, porque a
493
+ * tipografia daqui é do pacote e a de lá é do host. Esconder é a única saída que não força o
494
+ * pacote a adivinhar a escala tipográfica de cada produto.
495
+ */
496
+ readonly showHeader?: boolean;
470
497
  readonly className?: string;
471
498
  }
472
499
  /**
@@ -477,7 +504,7 @@ interface FlowsWorkspaceProps {
477
504
  * versão diferente do mesmo editor. Customização entra por `labels`, `actionOptions` e
478
505
  * `renderMediaPicker` — nunca por cópia do arquivo.
479
506
  */
480
- declare function FlowsWorkspace({ api, rootFlowKey, labels: labelsOverride, actionOptions, renderMediaPicker, livePollIntervalMs, className, }: FlowsWorkspaceProps): react.JSX.Element;
507
+ declare function FlowsWorkspace({ api, rootFlowKey, labels: labelsOverride, actionOptions, renderMediaPicker, livePollIntervalMs, showHeader, className, }: FlowsWorkspaceProps): react.JSX.Element;
481
508
 
482
509
  /**
483
510
  * Copyright (c) 2026 Ada Technology. MIT License.
@@ -550,4 +577,4 @@ declare function applyConnection(node: FlowNodeData, resolved: ResolvedConnectio
550
577
  */
551
578
  declare function isGraphDirty(working: FlowGraphData | undefined, published: FlowGraphData | undefined): boolean;
552
579
 
553
- export { BUILT_IN_ACTION_KINDS, CONDITION_OPERATORS, CROSS_FLOW_PREFIX, type CollectionChain, type ConnectionRequest, type CreateFlowInput, DEFAULT_FLOW_EDITOR_LABELS, type FlowEdgeKind, type FlowEdgeSpec, type FlowEditorLabels, FlowGroupFrame, type FlowGroupFrameData, FlowGroupHeader, type FlowGroupHeaderData, type FlowLivePosition, FlowMapCanvas, type FlowMapCanvasProps, FlowMapNode, type FlowMapNodeData, FlowNodeCard, type FlowNodeCardData, FlowNodePanel, type FlowNodePanelProps, type FlowNodePosition, FlowPalette, type FlowPaletteActionOption, type FlowPaletteProps, FlowPortalNode, type FlowPortalNodeData, type FlowValidationLabels, FlowWhatsAppPreview, type FlowWhatsAppPreviewProps, FlowsWorkspace, type FlowsWorkspaceApi, type FlowsWorkspaceProps, GROUP_HEADER_NODE_ID, type GraphIssue, NODE_CARD_WIDTH, type NewNodeSpec, type ResolvedConnection, WHATSAPP_LIMITS, applyConnection, buildFlowEdges, chainFrameBounds, chainFrameNodeId, computeAutoLayout, computeFlowMapLayout, computeMergedLayout, countLiveByNode, crossFlowKey, crossFlowTargetsOf, detachedNodeIds, estimateNodeHeight, findCollectionChains, flowGroupFrameNodeTypes, flowGroupHeaderNodeTypes, flowMapNodeTypes, flowNodeTypes, flowPortalNodeTypes, isCrossFlowTarget, isGraphDirty, mergeFlowEditorLabels, mergedFlowKeysFrom, namespaceNodeId, newNodeFromSpec, nodeLabel, parseNamespacedId, portalNodeId, removeNodeAndCleanRefs, rendersAsButtons, resolveConnection, slugifyNodeId, targetsOf, validateGraph };
580
+ export { BUILT_IN_ACTION_KINDS, CONDITION_OPERATORS, CROSS_FLOW_PREFIX, type CollectionChain, type ConnectionRequest, type CreateFlowInput, DEFAULT_FLOW_EDITOR_LABELS, type FlowEdgeKind, type FlowEdgeSpec, type FlowEditorLabels, FlowGroupFrame, type FlowGroupFrameData, FlowGroupHeader, type FlowGroupHeaderData, type FlowLiveNodeCount, type FlowLivePosition, type FlowLivePositionInput, FlowMapCanvas, type FlowMapCanvasProps, FlowMapNode, type FlowMapNodeData, FlowNodeCard, type FlowNodeCardData, FlowNodePanel, type FlowNodePanelProps, type FlowNodePosition, FlowPalette, type FlowPaletteActionOption, type FlowPaletteProps, FlowPortalNode, type FlowPortalNodeData, type FlowValidationLabels, FlowWhatsAppPreview, type FlowWhatsAppPreviewProps, FlowsWorkspace, type FlowsWorkspaceApi, type FlowsWorkspaceProps, GROUP_HEADER_NODE_ID, type GraphIssue, NODE_CARD_WIDTH, type NewNodeSpec, type ResolvedConnection, WHATSAPP_LIMITS, applyConnection, buildFlowEdges, chainFrameBounds, chainFrameNodeId, computeAutoLayout, computeFlowMapLayout, computeMergedLayout, countLiveByNode, crossFlowKey, crossFlowTargetsOf, detachedNodeIds, estimateNodeHeight, findCollectionChains, flowGroupFrameNodeTypes, flowGroupHeaderNodeTypes, flowMapNodeTypes, flowNodeTypes, flowPortalNodeTypes, isCrossFlowTarget, isGraphDirty, mergeFlowEditorLabels, mergedFlowKeysFrom, namespaceNodeId, newNodeFromSpec, nodeLabel, parseNamespacedId, portalNodeId, removeNodeAndCleanRefs, rendersAsButtons, resolveConnection, slugifyNodeId, targetsOf, validateGraph };
@@ -1351,6 +1351,9 @@ function isGraphDirty(working, published) {
1351
1351
  var COLUMN_GAP = 360;
1352
1352
  var ROW_GAP = 40;
1353
1353
  var CHAIN_LABEL_ROOM = 24;
1354
+ function isNodeCount(position) {
1355
+ return typeof position.count === "number";
1356
+ }
1354
1357
  function portalNodeId(sourceNodeId, target) {
1355
1358
  return `__portal__${sourceNodeId}__${target}`;
1356
1359
  }
@@ -1363,6 +1366,11 @@ function countLiveByNode(params) {
1363
1366
  const isRoot = flowKey === rootFlowKey;
1364
1367
  const counts = {};
1365
1368
  for (const position of positions ?? []) {
1369
+ if (isNodeCount(position)) {
1370
+ if (position.flowKey !== flowKey) continue;
1371
+ counts[position.nodeId] = (counts[position.nodeId] ?? 0) + position.count;
1372
+ continue;
1373
+ }
1366
1374
  if (position.flow !== flowKey && !isRoot) continue;
1367
1375
  const nodeId = isRoot ? position.menuNodeId : position.nodeId;
1368
1376
  if (!nodeId) continue;
@@ -1594,6 +1602,7 @@ function FlowsWorkspace({
1594
1602
  actionOptions,
1595
1603
  renderMediaPicker,
1596
1604
  livePollIntervalMs = 5e3,
1605
+ showHeader = true,
1597
1606
  className
1598
1607
  }) {
1599
1608
  const labels = useMemo2(() => mergeFlowEditorLabels(labelsOverride), [labelsOverride]);
@@ -2011,11 +2020,11 @@ function FlowsWorkspace({
2011
2020
  return /* @__PURE__ */ jsxs9("div", { className: `space-y-4 h-full flex flex-col ${className ?? ""}`, children: [
2012
2021
  /* @__PURE__ */ jsx10(TooltipLayer, {}),
2013
2022
  /* @__PURE__ */ jsxs9("div", { className: "flex items-center justify-between flex-wrap gap-3", children: [
2014
- /* @__PURE__ */ jsxs9("div", { children: [
2023
+ showHeader && /* @__PURE__ */ jsxs9("div", { className: "min-w-0", children: [
2015
2024
  /* @__PURE__ */ jsx10("h2", { className: "text-2xl font-bold text-gray-900 dark:text-gray-100", children: labels.workspace.title }),
2016
2025
  /* @__PURE__ */ jsx10("p", { className: "text-gray-500 dark:text-gray-400 text-sm mt-1", children: labels.workspace.subtitle })
2017
2026
  ] }),
2018
- /* @__PURE__ */ jsxs9("div", { className: "flex items-center gap-3", children: [
2027
+ /* @__PURE__ */ jsxs9("div", { className: "flex flex-wrap items-center justify-end gap-3 ml-auto", children: [
2019
2028
  saveState === "success" && /* @__PURE__ */ jsx10("span", { className: "text-sm text-green-600", children: labels.workspace.saveSuccess }),
2020
2029
  saveState === "error" && /* @__PURE__ */ jsx10("span", { className: "text-sm text-red-600", children: saveErrorMessage ?? labels.workspace.saveError }),
2021
2030
  /* @__PURE__ */ jsxs9(
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  cn,
44
44
  conversationsOf,
45
45
  createMediaUrlResolver,
46
+ documentTypeLabel,
46
47
  formatDateTime,
47
48
  formatFileSize,
48
49
  formatPhone,
@@ -57,7 +58,7 @@ import {
57
58
  useConversationDocuments,
58
59
  useConversationLocales,
59
60
  useConversations
60
- } from "./chunk-GY472G6E.js";
61
+ } from "./chunk-CUYYYZWD.js";
61
62
  import {
62
63
  ZERO_WIDTH_SPACE,
63
64
  htmlToWA,
@@ -2287,7 +2288,7 @@ function DocumentsWorkspace({
2287
2288
  onChange: (event) => setSearch(event.target.value),
2288
2289
  placeholder: labels.searchPlaceholder,
2289
2290
  "aria-label": labels.searchPlaceholder,
2290
- className: "w-full rounded-md border px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 sm:w-64"
2291
+ className: "w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 sm:w-64"
2291
2292
  }
2292
2293
  ),
2293
2294
  /* @__PURE__ */ jsx16(MultiSelectFilter, { label: labels.sourceFilter, options: sourceOptions, selected: source, onChange: setSource }),
@@ -2308,7 +2309,7 @@ function DocumentsWorkspace({
2308
2309
  value: startDate,
2309
2310
  onChange: (event) => setStartDate(event.target.value),
2310
2311
  "aria-label": labels.startDate,
2311
- className: "rounded-md border px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
2312
+ className: "rounded-md border border-gray-300 px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
2312
2313
  }
2313
2314
  ),
2314
2315
  /* @__PURE__ */ jsx16(
@@ -2318,7 +2319,7 @@ function DocumentsWorkspace({
2318
2319
  value: endDate,
2319
2320
  onChange: (event) => setEndDate(event.target.value),
2320
2321
  "aria-label": labels.endDate,
2321
- className: "rounded-md border px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
2322
+ className: "rounded-md border border-gray-300 px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
2322
2323
  }
2323
2324
  )
2324
2325
  ] }) : null,
@@ -2404,9 +2405,9 @@ function DocumentsWorkspace({
2404
2405
  ) : null,
2405
2406
  loading ? /* @__PURE__ */ jsx16("p", { className: cn("text-sm text-gray-500", classNames?.status), children: labels.loading }) : null,
2406
2407
  failed ? /* @__PURE__ */ jsx16("p", { role: "alert", className: cn("text-sm text-red-600 dark:text-red-400", classNames?.status), children: labels.failure }) : null,
2407
- /* @__PURE__ */ jsxs14("div", { className: cn("overflow-x-auto rounded-xl border dark:border-gray-700", classNames?.table), children: [
2408
- /* @__PURE__ */ jsxs14("table", { className: "w-full text-sm", children: [
2409
- /* @__PURE__ */ jsx16("thead", { className: "border-b text-gray-500 dark:border-gray-700 dark:text-gray-400", children: /* @__PURE__ */ jsxs14("tr", { children: [
2408
+ /* @__PURE__ */ jsxs14("div", { className: cn("cv-table-card", classNames?.table), children: [
2409
+ /* @__PURE__ */ jsxs14("table", { className: "cv-table", children: [
2410
+ /* @__PURE__ */ jsx16("thead", { children: /* @__PURE__ */ jsxs14("tr", { children: [
2410
2411
  canSelect ? /* @__PURE__ */ jsx16("th", { scope: "col", className: "w-10 px-3 py-2", children: /* @__PURE__ */ jsx16(
2411
2412
  "input",
2412
2413
  {
@@ -2425,7 +2426,7 @@ function DocumentsWorkspace({
2425
2426
  /* @__PURE__ */ jsx16(SortableHead, { label: labels.columnDate, field: "linkedAt", activeField: sortField, direction, onSort: toggleSort, className: "hidden xl:table-cell" }),
2426
2427
  /* @__PURE__ */ jsx16("th", { scope: "col", className: "px-3 py-2 text-left text-xs font-medium", children: labels.columnActions })
2427
2428
  ] }) }),
2428
- /* @__PURE__ */ jsx16("tbody", { children: documents.map((document2) => /* @__PURE__ */ jsxs14("tr", { className: cn("border-b last:border-0 dark:border-gray-800", classNames?.row), children: [
2429
+ /* @__PURE__ */ jsx16("tbody", { children: documents.map((document2) => /* @__PURE__ */ jsxs14("tr", { className: classNames?.row, children: [
2429
2430
  canSelect ? /* @__PURE__ */ jsx16("td", { className: "w-10 px-3 py-2", children: /* @__PURE__ */ jsx16(
2430
2431
  "input",
2431
2432
  {
@@ -2454,10 +2455,10 @@ function DocumentsWorkspace({
2454
2455
  ]
2455
2456
  }
2456
2457
  ) : /* @__PURE__ */ jsx16("span", { className: "text-xs text-gray-500", children: document2.contactName ?? formatPhone(document2.conversationId) }) }),
2457
- /* @__PURE__ */ jsx16("td", { className: "hidden px-3 py-2 font-mono text-xs md:table-cell", children: document2.mimeType }),
2458
- /* @__PURE__ */ jsx16("td", { className: "hidden px-3 py-2 text-xs lg:table-cell", children: formatFileSize(document2.sizeBytes) }),
2459
- /* @__PURE__ */ jsx16("td", { className: "hidden px-3 py-2 text-xs lg:table-cell", children: labels.sourceLabels[document2.source] ?? document2.source }),
2460
- /* @__PURE__ */ jsx16("td", { className: "hidden px-3 py-2 text-xs xl:table-cell", children: formatDateTime(document2.linkedAt) }),
2458
+ /* @__PURE__ */ jsx16("td", { className: "hidden px-3 py-2 md:table-cell", "data-cv-tooltip": document2.mimeType, children: /* @__PURE__ */ jsx16("span", { className: "cv-type-tag", children: documentTypeLabel(document2.filename, document2.mimeType) }) }),
2459
+ /* @__PURE__ */ jsx16("td", { className: "hidden whitespace-nowrap px-3 py-2 text-xs lg:table-cell", children: formatFileSize(document2.sizeBytes) }),
2460
+ /* @__PURE__ */ jsx16("td", { className: "hidden whitespace-nowrap px-3 py-2 text-xs lg:table-cell", children: labels.sourceLabels[document2.source] ?? document2.source }),
2461
+ /* @__PURE__ */ jsx16("td", { className: "hidden whitespace-nowrap px-3 py-2 text-xs xl:table-cell", children: formatDateTime(document2.linkedAt) }),
2461
2462
  /* @__PURE__ */ jsx16("td", { className: "px-3 py-2", children: /* @__PURE__ */ jsxs14("div", { className: "flex gap-1", children: [
2462
2463
  /* @__PURE__ */ jsx16(
2463
2464
  "button",
@@ -8,7 +8,7 @@ import {
8
8
  DocumentsLibrary,
9
9
  MessageBubble,
10
10
  MessageComposer
11
- } from "../chunk-GY472G6E.js";
11
+ } from "../chunk-CUYYYZWD.js";
12
12
  import "../chunk-WCBDXZ3X.js";
13
13
 
14
14
  // src/preview/previewStore.ts
package/dist/styles.css CHANGED
@@ -981,6 +981,86 @@
981
981
  .dark .cv-listing-perpage {
982
982
  border-color: rgb(71 85 105);
983
983
  }
984
+ :root {
985
+ --cv-border-color: rgb(229 231 235);
986
+ }
987
+ .dark {
988
+ --cv-border-color: rgb(51 65 85);
989
+ }
990
+ @layer base {
991
+ *,
992
+ ::before,
993
+ ::after,
994
+ ::backdrop,
995
+ ::file-selector-button {
996
+ border-color: var(--cv-border-color);
997
+ }
998
+ }
999
+ .cv-table-card {
1000
+ overflow-x: auto;
1001
+ border: 1px solid rgb(229 231 235);
1002
+ border-radius: 0.75rem;
1003
+ background: #fff;
1004
+ }
1005
+ .dark .cv-table-card {
1006
+ border-color: rgb(51 65 85);
1007
+ background: rgb(17 24 39);
1008
+ }
1009
+ .cv-table {
1010
+ width: 100%;
1011
+ border-collapse: collapse;
1012
+ font-size: 0.875rem;
1013
+ }
1014
+ .cv-table thead {
1015
+ background: rgb(248 250 252);
1016
+ color: rgb(100 116 139);
1017
+ }
1018
+ .dark .cv-table thead {
1019
+ background: rgb(30 41 59 / 60%);
1020
+ color: rgb(148 163 184);
1021
+ }
1022
+ .cv-table thead th {
1023
+ border-bottom: 1px solid rgb(226 232 240);
1024
+ white-space: nowrap;
1025
+ }
1026
+ .dark .cv-table thead th {
1027
+ border-bottom-color: rgb(51 65 85);
1028
+ }
1029
+ .cv-table tbody tr {
1030
+ border-bottom: 1px solid rgb(241 245 249);
1031
+ }
1032
+ .dark .cv-table tbody tr {
1033
+ border-bottom-color: rgb(30 41 59);
1034
+ }
1035
+ .cv-table tbody tr:last-child {
1036
+ border-bottom: 0;
1037
+ }
1038
+ .cv-table tbody tr:nth-child(even) {
1039
+ background: rgb(248 250 252 / 70%);
1040
+ }
1041
+ .dark .cv-table tbody tr:nth-child(even) {
1042
+ background: rgb(30 41 59 / 35%);
1043
+ }
1044
+ .cv-table tbody tr:hover {
1045
+ background: rgb(239 246 255);
1046
+ }
1047
+ .dark .cv-table tbody tr:hover {
1048
+ background: rgb(30 58 138 / 25%);
1049
+ }
1050
+ .cv-type-tag {
1051
+ display: inline-block;
1052
+ border-radius: 0.25rem;
1053
+ background: rgb(241 245 249);
1054
+ padding: 0.125rem 0.375rem;
1055
+ font-size: 0.6875rem;
1056
+ font-weight: 600;
1057
+ letter-spacing: 0.02em;
1058
+ color: rgb(71 85 105);
1059
+ }
1060
+ .dark .cv-type-tag {
1061
+ background: rgb(51 65 85);
1062
+ color: rgb(203 213 225);
1063
+ }
984
1064
  .cv-tooltip {
985
1065
  position: fixed;
986
1066
  z-index: 9999;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.30",
3
+ "version": "0.1.0-rc.31",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -14,6 +14,7 @@ import { Download, Eye, MessageSquare, Trash2, Upload, X } from 'lucide-react'
14
14
 
15
15
  import { useConversations } from '../providers/ConversationsProvider'
16
16
  import { FileIcon } from '../FileIcon'
17
+ import { documentTypeLabel } from '../MediaRenderer'
17
18
  import { cn } from '../lib/cn'
18
19
  import { formatDateTime, formatFileSize } from '../lib/format'
19
20
  import { formatPhone } from '../lib/phone'
@@ -281,7 +282,7 @@ export function DocumentsWorkspace({
281
282
  onChange={(event) => setSearch(event.target.value)}
282
283
  placeholder={labels.searchPlaceholder}
283
284
  aria-label={labels.searchPlaceholder}
284
- className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 sm:w-64"
285
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 sm:w-64"
285
286
  />
286
287
 
287
288
  <MultiSelectFilter label={labels.sourceFilter} options={sourceOptions} selected={source} onChange={setSource} />
@@ -302,14 +303,14 @@ export function DocumentsWorkspace({
302
303
  value={startDate}
303
304
  onChange={(event) => setStartDate(event.target.value)}
304
305
  aria-label={labels.startDate}
305
- className="rounded-md border px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
306
+ className="rounded-md border border-gray-300 px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
306
307
  />
307
308
  <input
308
309
  type="date"
309
310
  value={endDate}
310
311
  onChange={(event) => setEndDate(event.target.value)}
311
312
  aria-label={labels.endDate}
312
- className="rounded-md border px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
313
+ className="rounded-md border border-gray-300 px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
313
314
  />
314
315
  </>
315
316
  ) : null}
@@ -399,9 +400,9 @@ export function DocumentsWorkspace({
399
400
  </p>
400
401
  ) : null}
401
402
 
402
- <div className={cn('overflow-x-auto rounded-xl border dark:border-gray-700', classNames?.table)}>
403
- <table className="w-full text-sm">
404
- <thead className="border-b text-gray-500 dark:border-gray-700 dark:text-gray-400">
403
+ <div className={cn('cv-table-card', classNames?.table)}>
404
+ <table className="cv-table">
405
+ <thead>
405
406
  <tr>
406
407
  {canSelect ? (
407
408
  <th scope="col" className="w-10 px-3 py-2">
@@ -427,7 +428,7 @@ export function DocumentsWorkspace({
427
428
  </thead>
428
429
  <tbody>
429
430
  {documents.map((document) => (
430
- <tr key={`${document.conversationId}:${document.id}`} className={cn('border-b last:border-0 dark:border-gray-800', classNames?.row)}>
431
+ <tr key={`${document.conversationId}:${document.id}`} className={classNames?.row}>
431
432
  {canSelect ? (
432
433
  <td className="w-10 px-3 py-2">
433
434
  <input
@@ -467,12 +468,18 @@ export function DocumentsWorkspace({
467
468
  )}
468
469
  </td>
469
470
 
470
- <td className="hidden px-3 py-2 font-mono text-xs md:table-cell">{document.mimeType}</td>
471
- <td className="hidden px-3 py-2 text-xs lg:table-cell">{formatFileSize(document.sizeBytes)}</td>
472
- <td className="hidden px-3 py-2 text-xs lg:table-cell">
471
+ {/* O tipo curto, não o MIME cru: `.docx` responde
472
+ `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, que numa
473
+ célula sem quebra esticava a coluna a 556px e espremia todas as outras. O MIME
474
+ inteiro continua na dica. */}
475
+ <td className="hidden px-3 py-2 md:table-cell" data-cv-tooltip={document.mimeType}>
476
+ <span className="cv-type-tag">{documentTypeLabel(document.filename, document.mimeType)}</span>
477
+ </td>
478
+ <td className="hidden whitespace-nowrap px-3 py-2 text-xs lg:table-cell">{formatFileSize(document.sizeBytes)}</td>
479
+ <td className="hidden whitespace-nowrap px-3 py-2 text-xs lg:table-cell">
473
480
  {labels.sourceLabels[document.source] ?? document.source}
474
481
  </td>
475
- <td className="hidden px-3 py-2 text-xs xl:table-cell">{formatDateTime(document.linkedAt)}</td>
482
+ <td className="hidden whitespace-nowrap px-3 py-2 text-xs xl:table-cell">{formatDateTime(document.linkedAt)}</td>
476
483
 
477
484
  <td className="px-3 py-2">
478
485
  <div className="flex gap-1">
@@ -36,7 +36,7 @@ import {
36
36
  portalNodeId,
37
37
  GROUP_HEADER_NODE_ID,
38
38
  type FlowEdgeSpec,
39
- type FlowLivePosition,
39
+ type FlowLivePositionInput,
40
40
  } from './flowCanvasModel'
41
41
  import {
42
42
  applyConnection,
@@ -79,9 +79,10 @@ const EDGE_COLOR_CROSS_FLOW = '#06b6d4'
79
79
  const BACKGROUND_COLOR_LIGHT = '#cbd5e1'
80
80
  const BACKGROUND_COLOR_DARK = '#334155'
81
81
 
82
- // `FlowLivePosition` mora no modelo, que é quem conta — e é reexportado aqui porque faz parte da api
83
- // que o produto implementa. Declarar nos dois lugares faria as duas formas divergirem em silêncio.
84
- export type { FlowLivePosition } from './flowCanvasModel'
82
+ // Os formatos de posição viva moram no modelo, que é quem conta — e são reexportados aqui porque
83
+ // fazem parte da api que o produto implementa. Declarar nos dois lugares faria as duas formas
84
+ // divergirem em silêncio.
85
+ export type { FlowLivePosition, FlowLiveNodeCount, FlowLivePositionInput } from './flowCanvasModel'
85
86
 
86
87
  export interface CreateFlowInput {
87
88
  key: string
@@ -105,8 +106,13 @@ export interface FlowsWorkspaceApi {
105
106
  */
106
107
  createFlow?(input: CreateFlowInput): Promise<void>
107
108
  deleteFlow?(key: string): Promise<void>
108
- /** Contagem de conversas vivas por nó. Ausente, os cards não pulsam e nada é consultado. */
109
- getLivePositions?(): Promise<FlowLivePosition[]>
109
+ /**
110
+ * Onde estão as conversas vivas. Ausente, os cards não pulsam e nada é consultado.
111
+ *
112
+ * Aceita a linha por sessão e a linha já agregada por nó — `meta-whatsapp-module` responde a
113
+ * segunda, e exigir a primeira deixava os cards parados em todo produto que usa o módulo.
114
+ */
115
+ getLivePositions?(): Promise<readonly FlowLivePositionInput[]>
110
116
  }
111
117
 
112
118
  export interface FlowsWorkspaceProps {
@@ -122,6 +128,14 @@ export interface FlowsWorkspaceProps {
122
128
  readonly renderMediaPicker?: (node: FlowNodeData, graph: FlowGraphData) => ReactNode
123
129
  /** Intervalo do polling de posições vivas. Só tem efeito com `getLivePositions`. */
124
130
  readonly livePollIntervalMs?: number
131
+ /**
132
+ * Título e subtítulo do editor. `false` deixa só a barra de ações.
133
+ *
134
+ * Produto cuja navegação já nomeia a tela mostrava o nome duas vezes, em dois tamanhos, porque a
135
+ * tipografia daqui é do pacote e a de lá é do host. Esconder é a única saída que não força o
136
+ * pacote a adivinhar a escala tipográfica de cada produto.
137
+ */
138
+ readonly showHeader?: boolean
125
139
  readonly className?: string
126
140
  }
127
141
 
@@ -190,6 +204,7 @@ export function FlowsWorkspace({
190
204
  actionOptions,
191
205
  renderMediaPicker,
192
206
  livePollIntervalMs = 5000,
207
+ showHeader = true,
193
208
  className,
194
209
  }: FlowsWorkspaceProps) {
195
210
  const labels = useMemo(() => mergeFlowEditorLabels(labelsOverride), [labelsOverride])
@@ -197,7 +212,7 @@ export function FlowsWorkspace({
197
212
 
198
213
  const [graphs, setGraphs] = useState<Record<string, FlowGraphData> | undefined>(undefined)
199
214
  const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
200
- const [livePositions, setLivePositions] = useState<FlowLivePosition[] | undefined>(undefined)
215
+ const [livePositions, setLivePositions] = useState<readonly FlowLivePositionInput[] | undefined>(undefined)
201
216
  const [viewMode, setViewMode] = useState<'detail' | 'map'>('detail')
202
217
  const [openFlowKeys, setOpenFlowKeys] = useState<readonly string[]>([rootFlowKey])
203
218
  const [hasAutoMerged, setHasAutoMerged] = useState(false)
@@ -709,11 +724,16 @@ export function FlowsWorkspace({
709
724
  <div className={`space-y-4 h-full flex flex-col ${className ?? ''}`}>
710
725
  <TooltipLayer />
711
726
  <div className="flex items-center justify-between flex-wrap gap-3">
712
- <div>
713
- <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{labels.workspace.title}</h2>
714
- <p className="text-gray-500 dark:text-gray-400 text-sm mt-1">{labels.workspace.subtitle}</p>
715
- </div>
716
- <div className="flex items-center gap-3">
727
+ {showHeader && (
728
+ <div className="min-w-0">
729
+ <h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{labels.workspace.title}</h2>
730
+ <p className="text-gray-500 dark:text-gray-400 text-sm mt-1">{labels.workspace.subtitle}</p>
731
+ </div>
732
+ )}
733
+ {/* Quatro botões passam de 580px e a tela mais estreita é de 375px: sem `flex-wrap` a barra
734
+ empurrava a página inteira para o lado. `ml-auto` mantém tudo à direita mesmo sem o
735
+ título ao lado, que é o caso de `showHeader={false}`. */}
736
+ <div className="flex flex-wrap items-center justify-end gap-3 ml-auto">
717
737
  {saveState === 'success' && <span className="text-sm text-green-600">{labels.workspace.saveSuccess}</span>}
718
738
  {saveState === 'error' && (
719
739
  <span className="text-sm text-red-600">{saveErrorMessage ?? labels.workspace.saveError}</span>
@@ -171,6 +171,25 @@ describe('contagem ao vivo', () => {
171
171
  it('sem posições devolve vazio, em vez de estourar', () => {
172
172
  expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions: undefined })).toEqual({})
173
173
  })
174
+
175
+ it('linha já agregada pelo servidor soma pelo count, inclusive na raiz', () => {
176
+ // `meta-whatsapp-module` responde `GROUP BY (flowKey, currentNodeId)`. Contando como uma sessão
177
+ // por linha, dez conversas paradas no mesmo nó apareciam como uma — ou como nenhuma, porque a
178
+ // linha agregada não tem `menuNodeId` para a raiz ler.
179
+ const positions = [
180
+ { flowKey: ROOT, nodeId: 'saudacao', count: 7 },
181
+ { flowKey: 'sim', nodeId: 'renda', count: 3 },
182
+ ]
183
+
184
+ expect(countLiveByNode({ flowKey: ROOT, rootFlowKey: ROOT, positions })).toEqual({ saudacao: 7 })
185
+ expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 3 })
186
+ })
187
+
188
+ it('os dois formatos convivem na mesma resposta', () => {
189
+ const positions = [live({ flow: 'sim', nodeId: 'renda' }), { flowKey: 'sim', nodeId: 'renda', count: 2 }]
190
+
191
+ expect(countLiveByNode({ flowKey: 'sim', rootFlowKey: ROOT, positions })).toEqual({ renda: 3 })
192
+ })
174
193
  })
175
194
 
176
195
  describe('layout mesclado', () => {
@@ -30,7 +30,7 @@ const CHAIN_LABEL_ROOM = 24
30
30
 
31
31
  export type FlowNodePosition = { readonly x: number; readonly y: number }
32
32
 
33
- /** Onde cada conversa viva está parada agora. */
33
+ /** Onde cada conversa viva está parada agora — uma linha por sessão. */
34
34
  export type FlowLivePosition = {
35
35
  readonly currentState: string
36
36
  readonly flow: string | null
@@ -38,6 +38,25 @@ export type FlowLivePosition = {
38
38
  readonly menuNodeId: string | null
39
39
  }
40
40
 
41
+ /**
42
+ * Quantas conversas estão paradas num nó — o formato que o servidor já agrega.
43
+ *
44
+ * `meta-whatsapp-module` responde `GROUP BY (flowKey, currentNodeId)`, e trazer uma linha por
45
+ * sessão só para o editor recontar em memória seria pior em toda base grande. Os dois formatos
46
+ * valem porque produto que já implementou o de sessão não deve quebrar numa release de correção.
47
+ */
48
+ export type FlowLiveNodeCount = {
49
+ readonly flowKey: string
50
+ readonly nodeId: string
51
+ readonly count: number
52
+ }
53
+
54
+ export type FlowLivePositionInput = FlowLivePosition | FlowLiveNodeCount
55
+
56
+ function isNodeCount(position: FlowLivePositionInput): position is FlowLiveNodeCount {
57
+ return typeof (position as FlowLiveNodeCount).count === 'number'
58
+ }
59
+
41
60
  /**
42
61
  * Pseudo-nó que representa um fluxo alvo ainda não mesclado no canvas.
43
62
  *
@@ -57,20 +76,27 @@ export const GROUP_HEADER_NODE_ID = '__group_header__'
57
76
  /**
58
77
  * Quantas conversas estão paradas em cada nó de um fluxo.
59
78
  *
60
- * A raiz é caso à parte: o servidor guarda o passo do menu em `menuNodeId`, num campo próprio, e uma
61
- * conversa no menu não carrega `flow`. Ler `nodeId` ali daria contagem zero na tela mais visitada do
62
- * editor.
79
+ * A raiz é caso à parte no formato por sessão: o servidor guarda o passo do menu em `menuNodeId`,
80
+ * num campo próprio, e uma conversa no menu não carrega `flow`. Ler `nodeId` ali daria contagem
81
+ * zero na tela mais visitada do editor. No formato agregado a chave do fluxo já vem na linha,
82
+ * então a raiz não tem exceção.
63
83
  */
64
84
  export function countLiveByNode(params: {
65
85
  readonly flowKey: string
66
86
  readonly rootFlowKey: string
67
- readonly positions: readonly FlowLivePosition[] | undefined
87
+ readonly positions: readonly FlowLivePositionInput[] | undefined
68
88
  }): Record<string, number> {
69
89
  const { flowKey, rootFlowKey, positions } = params
70
90
  const isRoot = flowKey === rootFlowKey
71
91
  const counts: Record<string, number> = {}
72
92
 
73
93
  for (const position of positions ?? []) {
94
+ if (isNodeCount(position)) {
95
+ if (position.flowKey !== flowKey) continue
96
+ counts[position.nodeId] = (counts[position.nodeId] ?? 0) + position.count
97
+ continue
98
+ }
99
+
74
100
  if (position.flow !== flowKey && !isRoot) continue
75
101
  const nodeId = isRoot ? position.menuNodeId : position.nodeId
76
102
  if (!nodeId) continue
@@ -191,7 +217,7 @@ export function buildFlowEdges(params: {
191
217
  readonly openKeys: readonly string[]
192
218
  readonly graphs: Readonly<Record<string, FlowGraphData>>
193
219
  readonly rootFlowKey: string
194
- readonly livePositions?: readonly FlowLivePosition[] | undefined
220
+ readonly livePositions?: readonly FlowLivePositionInput[] | undefined
195
221
  }): FlowEdgeSpec[] {
196
222
  const { openKeys, graphs, rootFlowKey, livePositions } = params
197
223
  const open = new Set(openKeys)
@@ -45,7 +45,14 @@ export type {
45
45
  } from './flowGraph'
46
46
 
47
47
  export type { FlowEditorLabels, FlowValidationLabels } from './labels'
48
- export type { FlowsWorkspaceProps, FlowsWorkspaceApi, FlowLivePosition, CreateFlowInput } from './FlowsWorkspace'
48
+ export type {
49
+ FlowsWorkspaceProps,
50
+ FlowsWorkspaceApi,
51
+ FlowLivePosition,
52
+ FlowLiveNodeCount,
53
+ FlowLivePositionInput,
54
+ CreateFlowInput,
55
+ } from './FlowsWorkspace'
49
56
  export type { FlowNodeCardData } from './FlowNodeCard'
50
57
  export type { FlowMapNodeData } from './FlowMapNode'
51
58
  export type { FlowMapCanvasProps } from './FlowMapCanvas'
@@ -89,6 +96,6 @@ export {
89
96
  portalNodeId,
90
97
  GROUP_HEADER_NODE_ID,
91
98
  } from './flowCanvasModel'
92
- // `FlowLivePosition`sai pelo `FlowsWorkspace`, que é onde o produto a encontra ao implementar a
93
- // api — exportar de novo aqui daria dois caminhos para o mesmo tipo.
99
+ // Os formatos de posição viva saem pelo `FlowsWorkspace`, que é onde o produto os encontra ao
100
+ // implementar a api — exportar de novo aqui daria dois caminhos para o mesmo tipo.
94
101
  export type { FlowEdgeKind, FlowEdgeSpec, FlowNodePosition } from './flowCanvasModel'
package/src/styles.css CHANGED
@@ -888,6 +888,85 @@
888
888
  }
889
889
  .dark .cv-listing-perpage { border-color: rgb(71 85 105); }
890
890
 
891
+ /**
892
+ * Cor padrão de borda para as classes `border` sem cor ao lado.
893
+ *
894
+ * O Tailwind v4 trocou o padrão de `gray-200` por `currentColor`, e os componentes daqui foram
895
+ * escritos no v3. Em produto que traz a base do shadcn nada aparece — ela já redefine isto; em
896
+ * produto sem shadcn, cada `border` sai na cor do texto, e a tabela de documentos vinha com régua
897
+ * azul-quase-preta em vez de cinza. Fica na camada `base`, então qualquer `border-*` do host ou
898
+ * daqui continua ganhando, e a variável deixa o produto escolher outra cor sem editar o pacote.
899
+ */
900
+ :root { --cv-border-color: rgb(229 231 235); }
901
+ .dark { --cv-border-color: rgb(51 65 85); }
902
+
903
+ @layer base {
904
+ *,
905
+ ::before,
906
+ ::after,
907
+ ::backdrop,
908
+ ::file-selector-button {
909
+ border-color: var(--cv-border-color);
910
+ }
911
+ }
912
+
913
+ /* Tabela de listagem: a moldura, o cabeçalho e a zebra que `web.md` §7 exige, no stylesheet pelo
914
+ mesmo motivo do resto — host que não varre `node_modules` com `@source` não compila utilitário
915
+ nenhum do pacote, e a tabela sairia crua. */
916
+ .cv-table-card {
917
+ overflow-x: auto;
918
+ border: 1px solid rgb(229 231 235);
919
+ border-radius: 0.75rem;
920
+ background: #fff;
921
+ }
922
+ .dark .cv-table-card { border-color: rgb(51 65 85); background: rgb(17 24 39); }
923
+
924
+ /* `collapse` porque a régua de baixo mora no `tr`: com o `separate` padrão o navegador ignora borda
925
+ de linha e a tabela ficava sem separação nenhuma entre os registros. */
926
+ .cv-table {
927
+ width: 100%;
928
+ border-collapse: collapse;
929
+ font-size: 0.875rem;
930
+ }
931
+
932
+ .cv-table thead {
933
+ background: rgb(248 250 252);
934
+ color: rgb(100 116 139);
935
+ }
936
+ .dark .cv-table thead { background: rgb(30 41 59 / 60%); color: rgb(148 163 184); }
937
+
938
+ .cv-table thead th {
939
+ border-bottom: 1px solid rgb(226 232 240);
940
+ white-space: nowrap;
941
+ }
942
+ .dark .cv-table thead th { border-bottom-color: rgb(51 65 85); }
943
+
944
+ .cv-table tbody tr { border-bottom: 1px solid rgb(241 245 249); }
945
+ .dark .cv-table tbody tr { border-bottom-color: rgb(30 41 59); }
946
+ .cv-table tbody tr:last-child { border-bottom: 0; }
947
+
948
+ /* A alternada é o que leva o olho do nome do arquivo até a coluna de ações; em tela larga a régua
949
+ sozinha não segura a linha. */
950
+ .cv-table tbody tr:nth-child(even) { background: rgb(248 250 252 / 70%); }
951
+ .dark .cv-table tbody tr:nth-child(even) { background: rgb(30 41 59 / 35%); }
952
+
953
+ .cv-table tbody tr:hover { background: rgb(239 246 255); }
954
+ .dark .cv-table tbody tr:hover { background: rgb(30 58 138 / 25%); }
955
+
956
+ /* Extensão do arquivo como etiqueta: `PDF` e `DOCX` têm larguras parecidas e a coluna para de dançar
957
+ a cada linha, o que o MIME cru não permitia. */
958
+ .cv-type-tag {
959
+ display: inline-block;
960
+ border-radius: 0.25rem;
961
+ background: rgb(241 245 249);
962
+ padding: 0.125rem 0.375rem;
963
+ font-size: 0.6875rem;
964
+ font-weight: 600;
965
+ letter-spacing: 0.02em;
966
+ color: rgb(71 85 105);
967
+ }
968
+ .dark .cv-type-tag { background: rgb(51 65 85); color: rgb(203 213 225); }
969
+
891
970
  /* Dica de ação (ver `Tooltip.tsx`). Impressa em `document.body`, então a posição é da janela e o
892
971
  `z-index` fica acima de qualquer painel do produto. */
893
972
  .cv-tooltip {