@traky-app/ui 1.6.3 → 1.6.5

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 (63) hide show
  1. package/package.json +15 -1
  2. package/registry/traky/json/city-select.json +19 -0
  3. package/registry/traky/json/country-select.json +24 -0
  4. package/registry/traky/json/geo-search-select.json +24 -0
  5. package/registry/traky/json/geo-selectors.json +21 -0
  6. package/registry/traky/json/google-maps-address-picker.json +22 -0
  7. package/registry/traky/json/internal-chat-panel.json +1 -1
  8. package/registry/traky/json/lib-geo-city-codes.json +14 -0
  9. package/registry/traky/json/lib-geo-countries.json +14 -0
  10. package/registry/traky/json/lib-geo-csc.json +17 -0
  11. package/registry/traky/json/lib-geo-locations.json +14 -0
  12. package/registry/traky/json/lib-geo-overrides.json +14 -0
  13. package/registry/traky/json/lib-geo-types.json +14 -0
  14. package/registry/traky/json/lib-google-maps-address.json +14 -0
  15. package/registry/traky/json/lib-phone-countries.json +14 -0
  16. package/registry/traky/json/lib-phone-e164.json +14 -0
  17. package/registry/traky/json/lib-utils.json +1 -1
  18. package/registry/traky/json/office-address-form-dialog.json +17 -0
  19. package/registry/traky/json/office-address-form.json +35 -0
  20. package/registry/traky/json/phone-number-input.json +25 -0
  21. package/registry/traky/json/state-select.json +19 -0
  22. package/registry/traky/lib/geo-city-codes.ts +234 -0
  23. package/registry/traky/lib/geo-countries.ts +75 -0
  24. package/registry/traky/lib/geo-csc.ts +82 -0
  25. package/registry/traky/lib/geo-locations.ts +171 -0
  26. package/registry/traky/lib/geo-overrides.ts +375 -0
  27. package/registry/traky/lib/geo-types.ts +34 -0
  28. package/registry/traky/lib/google-maps-address.ts +100 -0
  29. package/registry/traky/lib/phone-countries.ts +380 -0
  30. package/registry/traky/lib/phone-e164.ts +66 -0
  31. package/registry/traky/lib/utils.ts +24 -0
  32. package/registry/traky/registry.json +180 -0
  33. package/registry/traky/ui/city-select.tsx +113 -0
  34. package/registry/traky/ui/country-select.tsx +233 -0
  35. package/registry/traky/ui/geo-search-select.tsx +136 -0
  36. package/registry/traky/ui/geo-selectors.tsx +162 -0
  37. package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
  38. package/registry/traky/ui/internal-chat-panel.tsx +1417 -71
  39. package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
  40. package/registry/traky/ui/office-address-form.tsx +914 -0
  41. package/registry/traky/ui/phone-number-input.tsx +253 -0
  42. package/registry/traky/ui/state-select.tsx +107 -0
  43. package/src/lib/geo-city-codes.ts +234 -0
  44. package/src/lib/geo-countries.ts +75 -0
  45. package/src/lib/geo-csc.ts +82 -0
  46. package/src/lib/geo-locations.ts +171 -0
  47. package/src/lib/geo-overrides.ts +375 -0
  48. package/src/lib/geo-types.ts +34 -0
  49. package/src/lib/google-maps-address.ts +100 -0
  50. package/src/lib/phone-countries.ts +380 -0
  51. package/src/lib/phone-e164.ts +66 -0
  52. package/src/test/setup.ts +31 -0
  53. package/src/ui/__tests__/geo-selects.test.tsx +504 -0
  54. package/src/ui/city-select.tsx +113 -0
  55. package/src/ui/country-select.tsx +233 -0
  56. package/src/ui/geo-search-select.tsx +136 -0
  57. package/src/ui/geo-selectors.tsx +162 -0
  58. package/src/ui/google-maps-address-picker.tsx +422 -0
  59. package/src/ui/internal-chat-panel.tsx +1417 -71
  60. package/src/ui/office-address-form-dialog.tsx +16 -0
  61. package/src/ui/office-address-form.tsx +914 -0
  62. package/src/ui/phone-number-input.tsx +253 -0
  63. package/src/ui/state-select.tsx +107 -0
@@ -23,7 +23,7 @@
23
23
  "files": [
24
24
  {
25
25
  "path": "ui/internal-chat-panel.tsx",
26
- "content": "\"use client\";\n\nimport {\n IconArrowUp,\n IconDotsVertical,\n IconLoader2,\n IconMessages,\n IconPackage,\n IconPaperclip,\n IconPlus,\n IconSparkles,\n IconTrash,\n IconTruckLoading,\n IconX,\n type Icon,\n} from \"@tabler/icons-react\";\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type FormEvent,\n type ReactNode,\n} from \"react\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerDescription,\n DrawerTitle,\n} from \"@/components/ui/drawer\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Empty,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/components/ui/empty\";\nimport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupTextarea,\n} from \"@/components/ui/input-group\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Message,\n MessageAvatar,\n MessageContent,\n MessageFooter,\n MessageHeader,\n} from \"@/components/ui/message\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport {\n buildMentionList,\n DEFAULT_MENTION_KIND_LABEL,\n DEFAULT_MENTION_KIND_ORDER,\n formatMessageTime,\n parseMentionSegment,\n parseSlashMention,\n roomInitialLetter,\n type BuildMentionListOptions,\n type InternalChatMentionCatalogState,\n type InternalChatMentionConfig,\n type InternalChatMentionItem,\n type InternalChatMentionKind,\n type InternalChatMentionListEntry,\n} from \"@/components/ui/internal-chat-mention\";\n\nexport type InternalChatRoom = {\n id: string;\n name: string;\n isOrganization?: boolean;\n};\n\nexport type InternalChatMessageLine = {\n id: string;\n role: \"me\" | \"team\";\n author: string;\n initials: string;\n text: string;\n at: string;\n};\n\nexport type InternalChatMessagesStatus =\n | \"idle\"\n | \"loading\"\n | \"error\"\n | \"empty\"\n | \"ready\";\n\nexport type InternalChatPanelLabels = {\n openChat: string;\n newRoom: string;\n roomMenu: string;\n deleteRoom: string;\n closeChat: string;\n newRoomTitle: string;\n newRoomDescription: string;\n newRoomPlaceholder: string;\n cancel: string;\n create: string;\n deleteRoomTitle: string;\n deleteRoomDescription: (roomName: string) => string;\n deleteConfirm: string;\n noRoomTitle: string;\n noRoomDescription: string;\n loadingMessages: string;\n loadMessagesErrorTitle: string;\n loadMessagesErrorFallback: string;\n emptyThreadTitle: string;\n emptyThreadDescription: (roomName: string) => string;\n loadingOlderMessages: string;\n mentionListLabel: string;\n messagePlaceholder: string;\n messagePlaceholderNoRoom: string;\n messageAriaLabel: string;\n attachAriaLabel: string;\n attachPhotos: string;\n sendSrOnly: string;\n orgRoomSuffix: string;\n orgRoomTooltip: string;\n slashMentionHint: string;\n selectRoomHint: string;\n};\n\nconst DEFAULT_LABELS: InternalChatPanelLabels = {\n openChat: \"Abrir chat de la empresa\",\n newRoom: \"Nueva sala\",\n roomMenu: \"Más acciones de salas\",\n deleteRoom: \"Eliminar sala actual\",\n closeChat: \"Cerrar chat\",\n newRoomTitle: \"Nueva sala\",\n newRoomDescription:\n \"Crea un espacio para conversar con tu equipo en tiempo real.\",\n newRoomPlaceholder: \"Nombre de la sala\",\n cancel: \"Cancelar\",\n create: \"Crear\",\n deleteRoomTitle: \"¿Eliminar esta sala?\",\n deleteRoomDescription: (roomName) =>\n `Se borrarán los mensajes de «${roomName}» en el servidor. Esta acción no se puede deshacer.`,\n deleteConfirm: \"Eliminar\",\n noRoomTitle: \"Sin sala\",\n noRoomDescription: \"Crea una sala con el botón + a la izquierda.\",\n loadingMessages: \"Cargando mensajes…\",\n loadMessagesErrorTitle: \"No se pudieron cargar los mensajes\",\n loadMessagesErrorFallback:\n \"Revisa que la API esté en marcha y que el módulo de chat interno esté migrado.\",\n emptyThreadTitle: \"¿De qué hablamos?\",\n emptyThreadDescription: (roomName) =>\n `Escribe el primer mensaje en «${roomName}».`,\n loadingOlderMessages: \"Cargando mensajes anteriores…\",\n mentionListLabel: \"Insertar mención\",\n messagePlaceholder: \"Mensaje… · / comandos\",\n messagePlaceholderNoRoom: \"Selecciona una sala…\",\n messageAriaLabel: \"Mensaje\",\n attachAriaLabel: \"Adjuntar\",\n attachPhotos: \"Fotos y archivos\",\n sendSrOnly: \"Enviar\",\n orgRoomSuffix: \" · Sala de toda la organización\",\n orgRoomTooltip: \" — toda la organización (no se elimina)\",\n slashMentionHint: \"menciones (guías y paquetes)\",\n selectRoomHint: \"Selecciona una sala o crea una nueva.\",\n};\n\nconst DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {\n kindOrder: DEFAULT_MENTION_KIND_ORDER,\n kindIcons: {\n guide: IconTruckLoading,\n package: IconPackage,\n ai: IconSparkles,\n },\n categoryMeta: {\n guide: {\n label: \"Guía\",\n hint: \"Guías de envío\",\n keywords: [\"guía\", \"guia\", \"guide\", \"envío\", \"envio\"],\n },\n package: {\n label: \"Paquete\",\n hint: \"Paquetes de la empresa (WR / tracking)\",\n keywords: [\"paquete\", \"paquetes\", \"package\", \"trk\", \"wr\"],\n },\n ai: {\n label: \"Asistencia de IA\",\n hint: \"Consultas sobre operación y datos (próximamente)\",\n keywords: [\"ia\", \"ai\", \"asistencia\", \"inteligencia\", \"asistente\"],\n disabled: true,\n },\n },\n};\n\nfunction isMentionEntryDisabled(\n entry: InternalChatMentionListEntry,\n config: InternalChatMentionConfig,\n): boolean {\n if (entry.type !== \"category\") return false;\n return Boolean(\n entry.disabled ?? config.categoryMeta[entry.kind]?.disabled,\n );\n}\n\nfunction stepMentionHighlight(\n entries: InternalChatMentionListEntry[],\n config: InternalChatMentionConfig,\n current: number,\n delta: 1 | -1,\n): number {\n if (entries.length === 0) return 0;\n let next = current;\n for (let i = 0; i < entries.length; i++) {\n next = (next + delta + entries.length) % entries.length;\n if (!isMentionEntryDisabled(entries[next]!, config)) return next;\n }\n return current;\n}\n\nfunction renderMessageTextWithMentions(text: string): ReactNode {\n const parts = text.split(/(@\\[[^\\]]+\\])/g);\n return parts.map((part, index) => {\n const mention = /^@\\[(.+)\\]$/.exec(part);\n if (mention) {\n return (\n <span\n key={index}\n className=\"inline-flex max-w-full items-center rounded-md border border-primary/35 bg-primary/12 px-1.5 py-0.5 font-medium text-primary\"\n >\n {mention[1]}\n </span>\n );\n }\n return <span key={index}>{part}</span>;\n });\n}\n\nexport type InternalChatPanelProps = {\n title: string;\n rooms: InternalChatRoom[];\n activeRoomId: string | null;\n onActiveRoomChange: (roomId: string) => void;\n messages: InternalChatMessageLine[];\n messagesStatus: InternalChatMessagesStatus;\n messagesError?: string | null;\n onLoadOlderMessages?: () => void | Promise<void>;\n isLoadingOlder?: boolean;\n hasOlderMessages?: boolean;\n mentionGuideItems?: InternalChatMentionItem[];\n mentionPackageItems?: InternalChatMentionItem[];\n mentionConfig?: InternalChatMentionConfig;\n /** Catálogo de ítems ya filtrado en servidor (guías/paquetes). */\n mentionServerFilteredItems?: boolean;\n mentionCatalogLoading?: boolean;\n onMentionCatalogChange?: (state: InternalChatMentionCatalogState) => void;\n typingLine?: string | null;\n onSendMessage: (text: string) => void | Promise<void>;\n onCreateRoom: (name: string) => void | Promise<void>;\n onDeleteRoom: (roomId: string) => void | Promise<void>;\n onOpenChange?: (open: boolean) => void;\n onDraftChange?: (draft: string) => void;\n onUserGesture?: () => void;\n isSending?: boolean;\n isCreatingRoom?: boolean;\n isDeletingRoom?: boolean;\n onDeleteRoomBlocked?: (reason: string) => void;\n labels?: Partial<InternalChatPanelLabels>;\n className?: string;\n};\n\nfunction InternalChatPanel({\n title,\n rooms,\n activeRoomId,\n onActiveRoomChange,\n messages,\n messagesStatus,\n messagesError = null,\n onLoadOlderMessages,\n isLoadingOlder = false,\n hasOlderMessages = false,\n mentionGuideItems = [],\n mentionPackageItems = [],\n mentionConfig = DEFAULT_MENTION_CONFIG,\n mentionServerFilteredItems = false,\n mentionCatalogLoading = false,\n onMentionCatalogChange,\n typingLine = null,\n onSendMessage,\n onCreateRoom,\n onDeleteRoom,\n onOpenChange,\n onDraftChange,\n onUserGesture,\n isSending = false,\n isCreatingRoom = false,\n isDeletingRoom = false,\n onDeleteRoomBlocked,\n labels: labelsPartial,\n className,\n}: InternalChatPanelProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsPartial };\n\n const [open, setOpen] = useState(false);\n const [draft, setDraft] = useState(\"\");\n const [createOpen, setCreateOpen] = useState(false);\n const [newRoomName, setNewRoomName] = useState(\"\");\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [mentionStart, setMentionStart] = useState<number | null>(null);\n const [mentionQuery, setMentionQuery] = useState(\"\");\n const [mentionHighlight, setMentionHighlight] = useState(0);\n const lastMentionQueryRef = useRef(\"\");\n const loadingOlderMessagesRef = useRef(false);\n\n const formRef = useRef<HTMLFormElement>(null);\n const scrollerRef = useRef<HTMLDivElement>(null);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const activeRoom = useMemo(\n () => rooms.find((r) => r.id === activeRoomId) ?? null,\n [rooms, activeRoomId],\n );\n\n const mentionListOptions = useMemo((): BuildMentionListOptions | undefined => {\n if (!mentionServerFilteredItems) return undefined;\n const seg = parseMentionSegment(mentionQuery, mentionConfig);\n if (seg.phase !== \"item\") return undefined;\n return { serverFilteredItems: true };\n }, [mentionServerFilteredItems, mentionQuery, mentionConfig]);\n\n const mentionEntries = useMemo(\n () =>\n buildMentionList(\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ),\n [\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ],\n );\n\n const mentionSeg = useMemo(\n () => parseMentionSegment(mentionQuery, mentionConfig),\n [mentionQuery, mentionConfig],\n );\n\n const mentionOpen =\n Boolean(activeRoom) &&\n mentionStart !== null &&\n (mentionEntries.length > 0 ||\n mentionCatalogLoading ||\n (mentionServerFilteredItems && mentionSeg.phase === \"item\"));\n\n const canSend =\n draft.trim().length > 0 &&\n !isSending &&\n Boolean(activeRoomId);\n\n const setOpenWithNotify = useCallback(\n (next: boolean) => {\n if (next) onUserGesture?.();\n setOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, onUserGesture],\n );\n\n const handleMessagesScroll = useCallback(() => {\n const node = scrollerRef.current;\n if (!node || !activeRoomId || !onLoadOlderMessages) return;\n if (loadingOlderMessagesRef.current) return;\n if (node.scrollTop > 120) return;\n if (!hasOlderMessages || isLoadingOlder) return;\n loadingOlderMessagesRef.current = true;\n const prevHeight = node.scrollHeight;\n const prevTop = node.scrollTop;\n void Promise.resolve(onLoadOlderMessages()).finally(() => {\n loadingOlderMessagesRef.current = false;\n requestAnimationFrame(() => {\n const n = scrollerRef.current;\n if (!n) return;\n const delta = n.scrollHeight - prevHeight;\n n.scrollTop = prevTop + delta;\n });\n });\n }, [\n activeRoomId,\n hasOlderMessages,\n isLoadingOlder,\n onLoadOlderMessages,\n ]);\n\n useLayoutEffect(() => {\n if (!open) return;\n const scrollToEnd = () => {\n const node = scrollerRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n };\n scrollToEnd();\n let rafNested = 0;\n const rafOuter = requestAnimationFrame(() => {\n scrollToEnd();\n rafNested = requestAnimationFrame(scrollToEnd);\n });\n const timeouts = [0, 50, 150].map((ms) =>\n window.setTimeout(scrollToEnd, ms),\n );\n return () => {\n cancelAnimationFrame(rafOuter);\n if (rafNested) cancelAnimationFrame(rafNested);\n timeouts.forEach(clearTimeout);\n };\n }, [open, activeRoomId, messages.length]);\n\n useEffect(() => {\n if (!mentionOpen || mentionEntries.length === 0) return;\n setMentionHighlight((h) => Math.min(h, mentionEntries.length - 1));\n }, [mentionOpen, mentionEntries.length]);\n\n useEffect(() => {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }, [activeRoomId]);\n\n useEffect(() => {\n if (!onMentionCatalogChange) return;\n if (mentionStart === null) {\n onMentionCatalogChange({\n open: false,\n phase: \"category\",\n filter: \"\",\n });\n return;\n }\n if (mentionSeg.phase === \"category\") {\n onMentionCatalogChange({\n open: true,\n phase: \"category\",\n filter: mentionSeg.filter,\n });\n return;\n }\n onMentionCatalogChange({\n open: true,\n phase: \"item\",\n kind: mentionSeg.kind,\n filter: mentionSeg.filter,\n });\n }, [mentionStart, mentionQuery, mentionConfig, onMentionCatalogChange]);\n\n function closeMentionMenu() {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }\n\n function syncMentionMenu(nextDraft: string, cursor: number) {\n const m = parseSlashMention(nextDraft, cursor);\n if (!m) {\n closeMentionMenu();\n return;\n }\n const seg = parseMentionSegment(m.query, mentionConfig);\n const listOptions: BuildMentionListOptions | undefined =\n mentionServerFilteredItems && seg.phase === \"item\"\n ? { serverFilteredItems: true }\n : undefined;\n const entries = buildMentionList(\n m.query,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n listOptions,\n );\n const keepOpenOnEmpty =\n mentionCatalogLoading ||\n (mentionServerFilteredItems && seg.phase === \"item\");\n if (entries.length === 0 && !keepOpenOnEmpty) {\n closeMentionMenu();\n return;\n }\n if (m.query !== lastMentionQueryRef.current) {\n setMentionHighlight(0);\n lastMentionQueryRef.current = m.query;\n }\n setMentionStart(m.start);\n setMentionQuery(m.query);\n }\n\n function replaceSlashSegment(newSegment: string) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const newDraft = `${before}/${newSegment}${after}`;\n const slashEnd = before.length + 1 + newSegment.length;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n setMentionQuery(newSegment);\n lastMentionQueryRef.current = newSegment;\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(slashEnd, slashEnd);\n });\n }\n\n function applyCategoryPick(kind: InternalChatMentionKind) {\n if (mentionConfig.categoryMeta[kind]?.disabled) return;\n replaceSlashSegment(`${kind}:`);\n setMentionHighlight(0);\n }\n\n function applyItemPick(item: InternalChatMentionItem) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const token = `@[${item.insertLabel}]`;\n const needsSpace =\n after.length > 0 && !/^\\s/.test(after) && !after.startsWith(\"\\n\");\n const newDraft = `${before}${token}${needsSpace ? \" \" : \"\"}${after}`;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n closeMentionMenu();\n const newPos = before.length + token.length + (needsSpace ? 1 : 0);\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(newPos, newPos);\n });\n }\n\n async function onSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (mentionOpen) return;\n const text = draft.trim();\n if (!text || !activeRoomId) return;\n onUserGesture?.();\n try {\n await onSendMessage(text);\n setDraft(\"\");\n onDraftChange?.(\"\");\n } catch {\n /* errores vía contenedor */\n }\n }\n\n async function handleCreateRoom() {\n const name = newRoomName.trim();\n if (!name) return;\n try {\n await onCreateRoom(name);\n setNewRoomName(\"\");\n setCreateOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n async function handleConfirmDeleteRoom() {\n if (!activeRoom) return;\n try {\n await onDeleteRoom(activeRoom.id);\n setDeleteOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n function requestDeleteRoom() {\n if (!activeRoom) return;\n if (activeRoom.isOrganization) {\n onDeleteRoomBlocked?.(\"La sala Organización no se puede eliminar.\");\n return;\n }\n if (rooms.length <= 1) {\n onDeleteRoomBlocked?.(\"Debe existir al menos una sala.\");\n return;\n }\n setDeleteOpen(true);\n }\n\n return (\n <>\n <Button\n type=\"button\"\n size=\"icon\"\n className={cn(\"relative size-9 text-primary-foreground\", className)}\n aria-label={labels.openChat}\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n </Button>\n\n <Dialog open={createOpen} onOpenChange={setCreateOpen}>\n <DialogContent className=\"sm:max-w-md\">\n <DialogHeader>\n <DialogTitle>{labels.newRoomTitle}</DialogTitle>\n <DialogDescription>{labels.newRoomDescription}</DialogDescription>\n </DialogHeader>\n <Input\n value={newRoomName}\n onChange={(e) => setNewRoomName(e.target.value)}\n placeholder={labels.newRoomPlaceholder}\n autoFocus\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n void handleCreateRoom();\n }\n }}\n />\n <DialogFooter className=\"gap-2 sm:gap-0\">\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => {\n setCreateOpen(false);\n setNewRoomName(\"\");\n }}\n >\n {labels.cancel}\n </Button>\n <Button\n type=\"button\"\n onClick={() => void handleCreateRoom()}\n disabled={isCreatingRoom}\n >\n {labels.create}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{labels.deleteRoomTitle}</AlertDialogTitle>\n <AlertDialogDescription>\n {labels.deleteRoomDescription(activeRoom?.name ?? \"la sala\")}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{labels.cancel}</AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n onClick={() => void handleConfirmDeleteRoom()}\n disabled={isDeletingRoom}\n >\n {labels.deleteConfirm}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n\n <Drawer\n open={open}\n onOpenChange={setOpenWithNotify}\n direction=\"right\"\n shouldScaleBackground={false}\n >\n <DrawerContent\n className={cn(\n \"gap-0 overflow-hidden p-0 shadow-xl\",\n \"data-[vaul-drawer-direction=right]:inset-y-3\",\n \"data-[vaul-drawer-direction=right]:right-3\",\n \"data-[vaul-drawer-direction=right]:left-auto\",\n \"data-[vaul-drawer-direction=right]:h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:max-h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:sm:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:rounded-2xl\",\n \"data-[vaul-drawer-direction=right]:border\",\n )}\n >\n <div className=\"flex h-full min-h-0 flex-row\">\n <div className=\"flex w-[3.25rem] shrink-0 flex-col items-center gap-2 border-r border-border py-4\">\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.newRoom}\n onClick={() => setCreateOpen(true)}\n >\n <IconPlus className=\"size-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">{labels.newRoom}</TooltipContent>\n </Tooltip>\n\n <div className=\"flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1\">\n {rooms.map((room) => {\n const selected = room.id === activeRoomId;\n return (\n <Tooltip key={room.id} delayDuration={300}>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={() => onActiveRoomChange(room.id)}\n className={cn(\n \"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 transition-colors\",\n selected\n ? \"border-primary bg-primary/15\"\n : \"border-transparent bg-muted hover:bg-muted/80\",\n )}\n aria-label={`Sala ${room.name}${room.isOrganization ? \" (toda la organización)\" : \"\"}`}\n aria-current={selected ? \"true\" : undefined}\n >\n <span className=\"text-xs font-semibold leading-none\">\n {roomInitialLetter(room.name)}\n </span>\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {room.isOrganization\n ? `${room.name}${labels.orgRoomTooltip}`\n : room.name}\n </TooltipContent>\n </Tooltip>\n );\n })}\n </div>\n\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.roomMenu}\n >\n <IconDotsVertical className=\"size-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"left\" align=\"end\" className=\"w-48\">\n <DropdownMenuItem onClick={() => setCreateOpen(true)}>\n <IconPlus className=\"size-4\" />\n {labels.newRoom}\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem\n disabled={\n rooms.length <= 1 || activeRoom?.isOrganization\n }\n className=\"text-destructive focus:text-destructive\"\n onClick={requestDeleteRoom}\n >\n <IconTrash className=\"size-4\" />\n {labels.deleteRoom}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <div className=\"flex items-start justify-between gap-4 px-5 pt-5 pb-3\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n <DrawerTitle className=\"flex items-center gap-2 font-medium\">\n <IconMessages className=\"size-5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{title}</span>\n </DrawerTitle>\n <DrawerDescription className=\"line-clamp-2\">\n {activeRoom ? (\n <>\n <span className=\"font-medium text-foreground\">\n {activeRoom.name}\n </span>\n {activeRoom.isOrganization ? (\n <>{labels.orgRoomSuffix}</>\n ) : null}\n {\" · \"}\n <kbd className=\"pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]\">\n /\n </kbd>{\" \"}\n {labels.slashMentionHint}\n </>\n ) : (\n labels.selectRoomHint\n )}\n </DrawerDescription>\n </div>\n <DrawerClose asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.closeChat}\n >\n <IconX className=\"size-4\" />\n </Button>\n </DrawerClose>\n </div>\n\n <div\n ref={scrollerRef}\n className=\"min-h-0 flex-1 overflow-y-auto\"\n onScroll={handleMessagesScroll}\n >\n {!activeRoom ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.noRoomTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.noRoomDescription}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"loading\" ? (\n <div className=\"flex h-full flex-col items-center justify-center gap-2 text-muted-foreground\">\n <IconLoader2 className=\"size-6 animate-spin\" aria-hidden />\n <span className=\"text-sm\">{labels.loadingMessages}</span>\n </div>\n ) : messagesStatus === \"error\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.loadMessagesErrorTitle}</EmptyTitle>\n <EmptyDescription className=\"text-left\">\n {messagesError ?? labels.loadMessagesErrorFallback}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"empty\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.emptyThreadTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.emptyThreadDescription(activeRoom.name)}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : (\n <div className=\"flex flex-col gap-6 px-5 py-2\">\n {isLoadingOlder ? (\n <div className=\"flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground\">\n <IconLoader2\n className=\"size-3.5 shrink-0 animate-spin\"\n aria-hidden\n />\n {labels.loadingOlderMessages}\n </div>\n ) : null}\n {messages.map((message) => {\n const mine = message.role === \"me\";\n return (\n <Message\n key={message.id}\n align={mine ? \"end\" : \"start\"}\n >\n {mine ? null : (\n <MessageAvatar className=\"size-8 text-xs font-medium\">\n {message.initials}\n </MessageAvatar>\n )}\n <MessageContent>\n {mine ? null : (\n <MessageHeader>{message.author}</MessageHeader>\n )}\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"break-words whitespace-pre-wrap\">\n {renderMessageTextWithMentions(message.text)}\n </BubbleContent>\n </Bubble>\n <MessageFooter>\n {formatMessageTime(message.at)}\n </MessageFooter>\n </MessageContent>\n </Message>\n );\n })}\n </div>\n )}\n {activeRoom && typingLine ? (\n <div\n className=\"border-t border-border px-5 py-2 text-xs text-muted-foreground\"\n aria-live=\"polite\"\n >\n {typingLine}\n </div>\n ) : null}\n </div>\n\n <form\n ref={formRef}\n onSubmit={onSubmit}\n className=\"relative px-5 pt-2 pb-5\"\n >\n {mentionOpen ? (\n <div\n role=\"listbox\"\n aria-label={labels.mentionListLabel}\n className=\"absolute bottom-full left-5 right-5 z-50 mb-1 flex max-h-60 flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md\"\n >\n <div className=\"shrink-0 border-b border-border px-2 py-1.5 text-[0.7rem] text-muted-foreground\">\n {mentionSeg.phase === \"category\" ? (\n <>\n <span className=\"font-medium text-foreground\">\n Tipo\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n Tab\n </kbd>\n /\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ↵\n </kbd>\n </>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {DEFAULT_MENTION_KIND_LABEL[mentionSeg.kind]}\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ←\n </kbd>\n </>\n )}\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto p-1\">\n {mentionCatalogLoading && mentionEntries.length === 0 ? (\n <div className=\"flex items-center justify-center gap-2 px-2 py-6 text-sm text-muted-foreground\">\n <IconLoader2 className=\"size-4 animate-spin\" />\n Buscando…\n </div>\n ) : null}\n {!mentionCatalogLoading &&\n mentionEntries.length === 0 &&\n mentionSeg.phase === \"item\" ? (\n <p className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n Sin resultados\n </p>\n ) : null}\n {mentionEntries.map((entry, index) => {\n if (entry.type === \"category\") {\n const IconComp = entry.icon;\n const categoryDisabled = isMentionEntryDisabled(\n entry,\n mentionConfig,\n );\n return (\n <button\n key={`cat-${entry.kind}`}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n aria-disabled={categoryDisabled}\n disabled={categoryDisabled}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n categoryDisabled &&\n \"cursor-not-allowed opacity-50\",\n !categoryDisabled &&\n (index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\"),\n )}\n onMouseEnter={() => {\n if (!categoryDisabled) setMentionHighlight(index);\n }}\n onMouseDown={(event) => {\n event.preventDefault();\n if (categoryDisabled) return;\n applyCategoryPick(entry.kind);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {entry.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {entry.hint}\n </span>\n </span>\n </button>\n );\n }\n const item = entry.data;\n const IconComp = item.icon;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\",\n )}\n onMouseEnter={() => setMentionHighlight(index)}\n onMouseDown={(event) => {\n event.preventDefault();\n applyItemPick(item);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {item.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {item.subtitle}\n </span>\n </span>\n </button>\n );\n })}\n </div>\n </div>\n ) : null}\n\n <InputGroup>\n <InputGroupTextarea\n ref={textareaRef}\n value={draft}\n onChange={(event) => {\n const v = event.target.value;\n const cursor =\n event.target.selectionStart ?? v.length;\n setDraft(v);\n onDraftChange?.(v);\n syncMentionMenu(v, cursor);\n }}\n onSelect={(event) => {\n const t = event.target as HTMLTextAreaElement;\n syncMentionMenu(\n t.value,\n t.selectionStart ?? t.value.length,\n );\n }}\n placeholder={\n activeRoom\n ? labels.messagePlaceholder\n : labels.messagePlaceholderNoRoom\n }\n aria-label={labels.messageAriaLabel}\n className=\"min-h-14\"\n disabled={!activeRoom}\n onKeyDown={(event) => {\n onUserGesture?.();\n if (mentionOpen) {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n 1,\n ),\n );\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n -1,\n ),\n );\n return;\n }\n if (event.key === \"Backspace\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"ArrowLeft\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Tab\") {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n closeMentionMenu();\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n formRef.current?.requestSubmit();\n }\n }}\n />\n <InputGroupAddon align=\"block-end\" className=\"pt-1\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <InputGroupButton\n aria-label={labels.attachAriaLabel}\n type=\"button\"\n size=\"icon-sm\"\n variant=\"outline\"\n disabled={!activeRoom}\n >\n <IconPlus className=\"size-4\" />\n </InputGroupButton>\n </DropdownMenuTrigger>\n <DropdownMenuContent\n align=\"start\"\n side=\"top\"\n className=\"w-44\"\n >\n <DropdownMenuItem>\n <IconPaperclip className=\"size-4\" />\n {labels.attachPhotos}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n <InputGroupButton\n type=\"submit\"\n variant=\"default\"\n size=\"icon-sm\"\n disabled={!canSend || !activeRoom}\n className=\"ml-auto\"\n >\n <IconArrowUp className=\"size-4\" />\n <span className=\"sr-only\">{labels.sendSrOnly}</span>\n </InputGroupButton>\n </InputGroupAddon>\n </InputGroup>\n </form>\n </div>\n </div>\n </DrawerContent>\n </Drawer>\n </>\n );\n}\n\nexport {\n InternalChatPanel,\n DEFAULT_MENTION_CONFIG,\n DEFAULT_LABELS as DEFAULT_INTERNAL_CHAT_PANEL_LABELS,\n};\n\nexport { initialsFromDisplayName } from \"@/components/ui/internal-chat-mention\";\n\nexport type {\n BuildMentionListOptions,\n InternalChatMentionCatalogState,\n InternalChatMentionConfig,\n InternalChatMentionItem,\n InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n",
26
+ "content": "\"use client\";\n\nimport {\n IconArrowUp,\n IconCheck,\n IconChecks,\n IconDotsVertical,\n IconFile,\n IconHeadset,\n IconLoader2,\n IconMicrophone,\n IconMessages,\n IconPackage,\n IconPaperclip,\n IconPhoto,\n IconPlayerPause,\n IconPlayerPlay,\n IconSquare,\n IconPlus,\n IconScreenShare,\n IconSparkles,\n IconTrash,\n IconTruckLoading,\n IconX,\n type Icon,\n} from \"@tabler/icons-react\";\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n type FormEvent,\n type ReactNode,\n} from \"react\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport { Bubble, BubbleContent } from \"@/components/ui/bubble\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport {\n Drawer,\n DrawerClose,\n DrawerContent,\n DrawerDescription,\n DrawerTitle,\n} from \"@/components/ui/drawer\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Empty,\n EmptyDescription,\n EmptyHeader,\n EmptyMedia,\n EmptyTitle,\n} from \"@/components/ui/empty\";\nimport {\n InputGroup,\n InputGroupAddon,\n InputGroupButton,\n InputGroupTextarea,\n} from \"@/components/ui/input-group\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Message,\n MessageAvatar,\n MessageContent,\n MessageFooter,\n MessageHeader,\n} from \"@/components/ui/message\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport {\n buildMentionList,\n DEFAULT_MENTION_KIND_LABEL,\n DEFAULT_MENTION_KIND_ORDER,\n formatMessageTime,\n parseMentionSegment,\n parseSlashMention,\n roomInitialLetter,\n type BuildMentionListOptions,\n type InternalChatMentionCatalogState,\n type InternalChatMentionConfig,\n type InternalChatMentionItem,\n type InternalChatMentionKind,\n type InternalChatMentionListEntry,\n} from \"@/components/ui/internal-chat-mention\";\n\nexport type InternalChatRoom = {\n id: string;\n name: string;\n isOrganization?: boolean;\n /** Canal reservado con el administrador / soporte Traky. */\n isSupport?: boolean;\n};\n\n/** Acuse de recibo estilo mensajería (1 palomita = enviado, 2 grises = llegó, 2 azules = leído). */\nexport type InternalChatMessageReceiptStatus =\n | \"pending\"\n | \"sent\"\n | \"delivered\"\n | \"read\";\n\nexport type InternalChatMessageAttachment = {\n kind: \"image\" | \"file\" | \"video\";\n name: string;\n url?: string;\n sizeLabel?: string;\n};\n\nconst SCREEN_RECORD_MAX_MS = 60_000;\n\nexport type InternalChatMessageAudio = {\n src: string;\n durationMs?: number;\n};\n\nexport type InternalChatMessageLine = {\n id: string;\n role: \"me\" | \"team\";\n author: string;\n initials: string;\n text: string;\n at: string;\n kind?: \"text\" | \"audio\";\n audio?: InternalChatMessageAudio;\n attachments?: InternalChatMessageAttachment[];\n /** Solo mensajes propios (`role: \"me\"`). */\n receiptStatus?: InternalChatMessageReceiptStatus;\n};\n\nexport type InternalChatMessagesStatus =\n | \"idle\"\n | \"loading\"\n | \"error\"\n | \"empty\"\n | \"ready\";\n\nexport type InternalChatPanelLabels = {\n openChat: string;\n newRoom: string;\n roomMenu: string;\n deleteRoom: string;\n closeChat: string;\n newRoomTitle: string;\n newRoomDescription: string;\n newRoomPlaceholder: string;\n cancel: string;\n create: string;\n deleteRoomTitle: string;\n deleteRoomDescription: (roomName: string) => string;\n deleteConfirm: string;\n noRoomTitle: string;\n noRoomDescription: string;\n loadingMessages: string;\n loadMessagesErrorTitle: string;\n loadMessagesErrorFallback: string;\n emptyThreadTitle: string;\n emptyThreadDescription: (roomName: string) => string;\n loadingOlderMessages: string;\n mentionListLabel: string;\n messagePlaceholder: string;\n messagePlaceholderNoRoom: string;\n messageAriaLabel: string;\n attachAriaLabel: string;\n attachPhotos: string;\n attachFiles: string;\n attachAudio: string;\n attachAudioFile: string;\n attachmentPending: string;\n voiceRecordTitle: string;\n voiceRecordHint: string;\n voiceRecordStart: string;\n voiceRecordStop: string;\n voiceRecordSend: string;\n voiceRecordCancel: string;\n voiceRecordPermissionError: string;\n screenCaptureAriaLabel: string;\n screenRecordTitle: string;\n screenRecordHint: string;\n screenRecordStart: string;\n screenRecordStop: string;\n screenRecordSend: string;\n screenRecordCancel: string;\n screenRecordPermissionError: string;\n screenRecordMaxReached: string;\n screenRecordBackgroundHint: string;\n screenRecordOpenChatToFinish: string;\n sendSrOnly: string;\n orgRoomSuffix: string;\n orgRoomTooltip: string;\n slashMentionHint: string;\n selectRoomHint: string;\n supportChat: string;\n supportChatTooltip: string;\n supportRoomDescription: string;\n};\n\nconst DEFAULT_LABELS: InternalChatPanelLabels = {\n openChat: \"Abrir chat de la empresa\",\n newRoom: \"Nueva sala\",\n roomMenu: \"Más acciones de salas\",\n deleteRoom: \"Eliminar sala actual\",\n closeChat: \"Cerrar chat\",\n newRoomTitle: \"Nueva sala\",\n newRoomDescription:\n \"Crea un espacio para conversar con tu equipo en tiempo real.\",\n newRoomPlaceholder: \"Nombre de la sala\",\n cancel: \"Cancelar\",\n create: \"Crear\",\n deleteRoomTitle: \"¿Eliminar esta sala?\",\n deleteRoomDescription: (roomName) =>\n `Se borrarán los mensajes de «${roomName}» en el servidor. Esta acción no se puede deshacer.`,\n deleteConfirm: \"Eliminar\",\n noRoomTitle: \"Sin sala\",\n noRoomDescription: \"Crea una sala con el botón + a la izquierda.\",\n loadingMessages: \"Cargando mensajes…\",\n loadMessagesErrorTitle: \"No se pudieron cargar los mensajes\",\n loadMessagesErrorFallback:\n \"Revisa que la API esté en marcha y que el módulo de chat interno esté migrado.\",\n emptyThreadTitle: \"¿De qué hablamos?\",\n emptyThreadDescription: (roomName) =>\n `Escribe el primer mensaje en «${roomName}».`,\n loadingOlderMessages: \"Cargando mensajes anteriores…\",\n mentionListLabel: \"Insertar mención\",\n messagePlaceholder: \"Mensaje… · / comandos\",\n messagePlaceholderNoRoom: \"Selecciona una sala…\",\n messageAriaLabel: \"Mensaje\",\n attachAriaLabel: \"Adjuntar\",\n attachPhotos: \"Fotos\",\n attachFiles: \"Archivos\",\n attachAudio: \"Grabar nota de voz\",\n attachAudioFile: \"Enviar audio guardado\",\n attachmentPending: \"Vista previa · conexión lista\",\n voiceRecordTitle: \"Grabar nota de voz\",\n voiceRecordHint: \"Pulsa grabar, habla y envía cuando termines.\",\n voiceRecordStart: \"Grabar\",\n voiceRecordStop: \"Detener\",\n voiceRecordSend: \"Enviar audio\",\n voiceRecordCancel: \"Cancelar\",\n voiceRecordPermissionError:\n \"No pudimos usar el micrófono. Revisa los permisos del navegador.\",\n screenCaptureAriaLabel: \"Capturar pantalla\",\n screenRecordTitle: \"Captura de pantalla\",\n screenRecordHint:\n \"Inicia y elige compartir la pestaña de Traky (no solo el panel del chat). Puedes cerrar el chat y seguir navegando (máx. 1 min).\",\n screenRecordStart: \"Iniciar captura\",\n screenRecordStop: \"Detener\",\n screenRecordSend: \"Enviar video\",\n screenRecordCancel: \"Cancelar\",\n screenRecordPermissionError:\n \"No se pudo iniciar la captura. Revisa los permisos del navegador.\",\n screenRecordMaxReached: \"Se alcanzó el máximo de 1 minuto.\",\n screenRecordBackgroundHint:\n \"Puedes cerrar el chat; la grabación continúa. Vuelve aquí para detener y enviar.\",\n screenRecordOpenChatToFinish: \"Abrir chat · captura en curso\",\n sendSrOnly: \"Enviar\",\n orgRoomSuffix: \" · Sala de toda la organización\",\n orgRoomTooltip: \" — toda la organización (no se elimina)\",\n slashMentionHint: \"menciones (guías y paquetes)\",\n selectRoomHint: \"Selecciona una sala o crea una nueva.\",\n supportChat: \"Soporte Traky\",\n supportChatTooltip: \"Chat con el equipo de soporte de Traky\",\n supportRoomDescription:\n \"Canal directo con soporte Traky para tu organización.\",\n};\n\nconst DEFAULT_MENTION_CONFIG: InternalChatMentionConfig = {\n kindOrder: DEFAULT_MENTION_KIND_ORDER,\n kindIcons: {\n guide: IconTruckLoading,\n package: IconPackage,\n ai: IconSparkles,\n },\n categoryMeta: {\n guide: {\n label: \"Guía\",\n hint: \"Guías de envío\",\n keywords: [\"guía\", \"guia\", \"guide\", \"envío\", \"envio\"],\n },\n package: {\n label: \"Paquete\",\n hint: \"Paquetes de la empresa (WR / tracking)\",\n keywords: [\"paquete\", \"paquetes\", \"package\", \"trk\", \"wr\"],\n },\n ai: {\n label: \"Asistencia de IA\",\n hint: \"Consultas sobre operación y datos (próximamente)\",\n keywords: [\"ia\", \"ai\", \"asistencia\", \"inteligencia\", \"asistente\"],\n disabled: true,\n },\n },\n};\n\nfunction isMentionEntryDisabled(\n entry: InternalChatMentionListEntry,\n config: InternalChatMentionConfig,\n): boolean {\n if (entry.type !== \"category\") return false;\n return Boolean(\n entry.disabled ?? config.categoryMeta[entry.kind]?.disabled,\n );\n}\n\nfunction stepMentionHighlight(\n entries: InternalChatMentionListEntry[],\n config: InternalChatMentionConfig,\n current: number,\n delta: 1 | -1,\n): number {\n if (entries.length === 0) return 0;\n let next = current;\n for (let i = 0; i < entries.length; i++) {\n next = (next + delta + entries.length) % entries.length;\n if (!isMentionEntryDisabled(entries[next]!, config)) return next;\n }\n return current;\n}\n\nfunction formatUnreadBadge(count: number): string {\n if (count <= 0) return \"\";\n if (count > 9) return \"9+\";\n return String(count);\n}\n\n/** Contador en círculo perfecto (sin `min-w`/`px` que lo ovalan). */\nfunction unreadCountBadgeClasses(label: string): string {\n const wide = label.length > 1;\n return cn(\n \"pointer-events-none absolute flex shrink-0 items-center justify-center rounded-full bg-destructive font-bold tabular-nums leading-none text-destructive-foreground shadow-sm\",\n wide\n ? \"-right-1 -top-1 size-5 text-[0.5rem]\"\n : \"-right-0.5 -top-0.5 size-4 text-[0.625rem]\",\n );\n}\n\nfunction MessageReceiptTicks({\n status,\n}: {\n status: InternalChatMessageReceiptStatus;\n}) {\n if (status === \"pending\") {\n return (\n <IconLoader2\n className=\"size-3 shrink-0 animate-spin text-muted-foreground\"\n aria-hidden\n />\n );\n }\n if (status === \"sent\") {\n return (\n <IconCheck\n className=\"size-3.5 shrink-0 text-muted-foreground\"\n aria-hidden\n />\n );\n }\n const read = status === \"read\";\n return (\n <IconChecks\n className={cn(\n \"size-3.5 shrink-0\",\n read ? \"text-sky-500 dark:text-sky-400\" : \"text-muted-foreground\",\n )}\n aria-hidden\n />\n );\n}\n\nfunction formatAudioDuration(ms: number): string {\n const totalSec = Math.max(0, Math.round(ms / 1000));\n const m = Math.floor(totalSec / 60);\n const s = totalSec % 60;\n return `${m}:${s.toString().padStart(2, \"0\")}`;\n}\n\nfunction pseudoWaveformBars(seed: string, count = 28): number[] {\n let h = 0;\n for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;\n return Array.from({ length: count }, (_, i) => {\n h = (h * 1103515245 + 12345 + i) >>> 0;\n return 0.25 + (h % 75) / 100;\n });\n}\n\nfunction InternalChatAudioBubble({\n audio,\n seed,\n mine,\n}: {\n audio: InternalChatMessageAudio;\n seed: string;\n mine: boolean;\n}) {\n const ref = useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = useState(false);\n const [durationMs, setDurationMs] = useState(audio.durationMs ?? 0);\n const [progress, setProgress] = useState(0);\n\n const bars = useMemo(() => pseudoWaveformBars(seed), [seed]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const onMeta = () => {\n if (Number.isFinite(el.duration) && el.duration > 0) {\n setDurationMs(Math.round(el.duration * 1000));\n }\n };\n const onTime = () => {\n if (!el.duration) return;\n setProgress(el.currentTime / el.duration);\n };\n const onEnd = () => {\n setPlaying(false);\n setProgress(0);\n };\n el.addEventListener(\"loadedmetadata\", onMeta);\n el.addEventListener(\"timeupdate\", onTime);\n el.addEventListener(\"ended\", onEnd);\n return () => {\n el.removeEventListener(\"loadedmetadata\", onMeta);\n el.removeEventListener(\"timeupdate\", onTime);\n el.removeEventListener(\"ended\", onEnd);\n };\n }, [audio.src]);\n\n const toggle = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n if (playing) {\n el.pause();\n setPlaying(false);\n return;\n }\n void el.play();\n setPlaying(true);\n }, [playing]);\n\n const displayMs = durationMs || audio.durationMs || 0;\n\n return (\n <div className=\"flex min-w-[min(100%,14rem)] items-center gap-2\">\n <audio ref={ref} src={audio.src} preload=\"metadata\" className=\"hidden\" />\n <Button\n type=\"button\"\n variant={mine ? \"secondary\" : \"outline\"}\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={playing ? \"Pausar audio\" : \"Reproducir audio\"}\n onClick={toggle}\n >\n {playing ? (\n <IconPlayerPause className=\"size-4\" />\n ) : (\n <IconPlayerPlay className=\"size-4\" />\n )}\n </Button>\n <div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n <div className=\"flex h-7 items-end gap-px\" aria-hidden>\n {bars.map((h, i) => {\n const lit = progress > 0 && i / bars.length <= progress;\n return (\n <span\n key={i}\n className={cn(\n \"w-[3px] shrink-0 rounded-full transition-colors\",\n lit\n ? mine\n ? \"bg-foreground/70\"\n : \"bg-primary\"\n : \"bg-muted-foreground/35\",\n )}\n style={{ height: `${Math.round(h * 100)}%` }}\n />\n );\n })}\n </div>\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {displayMs > 0 ? formatAudioDuration(displayMs) : \"0:00\"}\n </span>\n </div>\n </div>\n );\n}\n\nfunction InternalChatAttachmentStack({\n items,\n labels,\n}: {\n items: InternalChatMessageAttachment[];\n labels: InternalChatPanelLabels;\n}) {\n if (items.length === 0) return null;\n return (\n <div className=\"flex flex-col gap-1.5\">\n {items.map((att, i) => (\n <div\n key={`${att.name}-${i}`}\n className=\"overflow-hidden rounded-lg border border-border bg-muted/40\"\n >\n {att.kind === \"video\" && att.url ? (\n <video\n src={att.url}\n controls\n playsInline\n className=\"max-h-48 w-full bg-black object-contain\"\n />\n ) : att.kind === \"image\" && att.url ? (\n <img\n src={att.url}\n alt={att.name}\n className=\"max-h-40 w-full object-cover\"\n />\n ) : (\n <div className=\"flex items-center gap-2 px-3 py-2 text-sm\">\n {att.kind === \"image\" ? (\n <IconPhoto className=\"size-4 shrink-0 text-muted-foreground\" />\n ) : att.kind === \"video\" ? (\n <IconScreenShare className=\"size-4 shrink-0 text-muted-foreground\" />\n ) : (\n <IconFile className=\"size-4 shrink-0 text-muted-foreground\" />\n )}\n <span className=\"min-w-0 truncate font-medium\">{att.name}</span>\n {att.sizeLabel ? (\n <span className=\"ml-auto shrink-0 text-xs text-muted-foreground\">\n {att.sizeLabel}\n </span>\n ) : null}\n </div>\n )}\n {!att.url ? (\n <p className=\"border-t border-border px-3 py-1 text-[0.65rem] text-muted-foreground\">\n {labels.attachmentPending}\n </p>\n ) : null}\n </div>\n ))}\n </div>\n );\n}\n\nfunction InternalChatVoiceRecorderBar({\n labels,\n onClose,\n onSendBlob,\n onSendLegacy,\n}: {\n labels: InternalChatPanelLabels;\n onClose: () => void;\n onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>;\n onSendLegacy?: () => void | Promise<void>;\n}) {\n const recorderRef = useRef<MediaRecorder | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const startedAtRef = useRef(0);\n const [recording, setRecording] = useState(false);\n const [blob, setBlob] = useState<Blob | null>(null);\n const [durationMs, setDurationMs] = useState(0);\n const [error, setError] = useState<string | null>(null);\n const [sending, setSending] = useState(false);\n\n const stopTracks = useCallback((recorder: MediaRecorder | null) => {\n recorder?.stream.getTracks().forEach((t) => t.stop());\n }, []);\n\n const startRecording = useCallback(async () => {\n setError(null);\n setBlob(null);\n chunksRef.current = [];\n try {\n const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n const mime = MediaRecorder.isTypeSupported(\"audio/webm\")\n ? \"audio/webm\"\n : \"audio/mp4\";\n const recorder = new MediaRecorder(stream, { mimeType: mime });\n recorderRef.current = recorder;\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunksRef.current.push(e.data);\n };\n recorder.onstop = () => {\n const next = new Blob(chunksRef.current, { type: mime });\n setBlob(next);\n const ms = Math.max(\n 0,\n Date.now() - (startedAtRef.current || Date.now()),\n );\n setDurationMs(ms);\n stopTracks(recorder);\n recorderRef.current = null;\n };\n startedAtRef.current = Date.now();\n recorder.start();\n setRecording(true);\n } catch {\n setError(labels.voiceRecordPermissionError);\n }\n }, [labels.voiceRecordPermissionError, stopTracks]);\n\n const stopRecording = useCallback(() => {\n const rec = recorderRef.current;\n if (!rec || rec.state === \"inactive\") return;\n rec.stop();\n setRecording(false);\n }, []);\n\n const handleClose = useCallback(() => {\n if (recording) stopRecording();\n stopTracks(recorderRef.current);\n recorderRef.current = null;\n onClose();\n }, [onClose, recording, stopRecording, stopTracks]);\n\n const handleSend = useCallback(async () => {\n if (sending) return;\n setSending(true);\n try {\n if (blob && onSendBlob) {\n await onSendBlob(blob, durationMs);\n } else if (onSendLegacy) {\n await onSendLegacy();\n }\n onClose();\n } finally {\n setSending(false);\n }\n }, [blob, durationMs, onClose, onSendBlob, onSendLegacy, sending]);\n\n const previewUrl = useMemo(\n () => (blob ? URL.createObjectURL(blob) : null),\n [blob],\n );\n\n useEffect(() => {\n return () => {\n if (previewUrl) URL.revokeObjectURL(previewUrl);\n };\n }, [previewUrl]);\n\n return (\n <div\n className=\"mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2\"\n role=\"region\"\n aria-label={labels.voiceRecordTitle}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"text-xs font-medium text-foreground\">\n {labels.voiceRecordTitle}\n </p>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.voiceRecordCancel}\n onClick={handleClose}\n >\n <IconX className=\"size-4\" />\n </Button>\n </div>\n {error ? (\n <p className=\"mt-1 text-xs leading-snug text-destructive\">{error}</p>\n ) : (\n <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">\n {recording\n ? \"Pulsa el cuadrado rojo para terminar.\"\n : blob\n ? \"Escucha la vista previa y envía cuando quieras.\"\n : labels.voiceRecordHint}\n </p>\n )}\n <div className=\"mt-2 flex items-center gap-2\">\n {previewUrl && blob ? (\n <VoiceRecorderPreviewBar\n src={previewUrl}\n durationMs={durationMs}\n />\n ) : recording ? (\n <>\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"h-8 shrink-0 gap-1.5\"\n onClick={stopRecording}\n >\n <IconSquare className=\"size-3.5 fill-current\" />\n {labels.voiceRecordStop}\n </Button>\n <span className=\"inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive\">\n <span className=\"size-2 shrink-0 animate-pulse rounded-full bg-destructive\" />\n Grabando…\n </span>\n </>\n ) : (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n onClick={() => void startRecording()}\n >\n <IconMicrophone className=\"size-3.5\" />\n {labels.voiceRecordStart}\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"ml-auto h-8 shrink-0\"\n disabled={sending || (!blob && !onSendLegacy)}\n onClick={() => void handleSend()}\n >\n {labels.voiceRecordSend}\n </Button>\n </div>\n </div>\n );\n}\n\nfunction VoiceRecorderPreviewBar({\n src,\n durationMs,\n}: {\n src: string;\n durationMs: number;\n}) {\n const ref = useRef<HTMLAudioElement>(null);\n const [playing, setPlaying] = useState(false);\n const [progress, setProgress] = useState(0);\n const [resolvedMs, setResolvedMs] = useState(durationMs);\n const bars = useMemo(() => pseudoWaveformBars(\"voice-recorder-preview\"), []);\n\n const toggle = useCallback(() => {\n const el = ref.current;\n if (!el) return;\n if (playing) {\n el.pause();\n setPlaying(false);\n return;\n }\n void el.play();\n setPlaying(true);\n }, [playing]);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const onMeta = () => {\n if (Number.isFinite(el.duration) && el.duration > 0) {\n setResolvedMs(Math.round(el.duration * 1000));\n }\n };\n const onTime = () => {\n if (!el.duration) return;\n setProgress(el.currentTime / el.duration);\n };\n const onEnd = () => {\n setPlaying(false);\n setProgress(0);\n };\n el.addEventListener(\"loadedmetadata\", onMeta);\n el.addEventListener(\"timeupdate\", onTime);\n el.addEventListener(\"ended\", onEnd);\n return () => {\n el.removeEventListener(\"loadedmetadata\", onMeta);\n el.removeEventListener(\"timeupdate\", onTime);\n el.removeEventListener(\"ended\", onEnd);\n };\n }, [src]);\n\n const displayMs = resolvedMs || durationMs;\n\n return (\n <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n <audio ref={ref} src={src} preload=\"metadata\" className=\"hidden\" />\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0 rounded-full\"\n aria-label={playing ? \"Pausar vista previa\" : \"Reproducir vista previa\"}\n onClick={toggle}\n >\n {playing ? (\n <IconPlayerPause className=\"size-4\" />\n ) : (\n <IconPlayerPlay className=\"size-4\" />\n )}\n </Button>\n <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n <div className=\"flex h-6 items-end gap-px\" aria-hidden>\n {bars.map((h, i) => {\n const lit = progress > 0 && i / bars.length <= progress;\n return (\n <span\n key={i}\n className={cn(\n \"w-[2px] shrink-0 rounded-full transition-colors\",\n lit ? \"bg-primary\" : \"bg-muted-foreground/35\",\n )}\n style={{ height: `${Math.round(h * 100)}%` }}\n />\n );\n })}\n </div>\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {displayMs > 0 ? formatAudioDuration(displayMs) : \"0:00\"}\n </span>\n </div>\n </div>\n );\n}\n\nfunction pickScreenRecorderMime(): string {\n const candidates = [\n \"video/webm;codecs=vp9\",\n \"video/webm;codecs=vp8\",\n \"video/webm\",\n \"video/mp4\",\n ];\n return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? \"video/webm\";\n}\n\ntype ScreenRecorderSession = {\n recording: boolean;\n elapsedMs: number;\n blob: Blob | null;\n error: string | null;\n maxReached: boolean;\n sending: boolean;\n previewUrl: string | null;\n canSend: boolean;\n timerLabel: string;\n active: boolean;\n startCapture: () => Promise<void>;\n stopRecording: () => void;\n cancelSession: () => void;\n sendRecording: () => Promise<void>;\n};\n\nfunction useInternalChatScreenRecorderSession(\n labels: InternalChatPanelLabels,\n onSendBlob?: (blob: Blob, durationMs: number) => void | Promise<void>,\n onRecordingChange?: (recording: boolean) => void,\n): ScreenRecorderSession {\n const recorderRef = useRef<MediaRecorder | null>(null);\n const streamRef = useRef<MediaStream | null>(null);\n const chunksRef = useRef<Blob[]>([]);\n const startedAtRef = useRef(0);\n const durationMsRef = useRef(0);\n const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);\n const maxTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const previewUrlRef = useRef<string | null>(null);\n\n const [recording, setRecording] = useState(false);\n const [elapsedMs, setElapsedMs] = useState(0);\n const [blob, setBlob] = useState<Blob | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [maxReached, setMaxReached] = useState(false);\n const [sending, setSending] = useState(false);\n const [previewUrl, setPreviewUrl] = useState<string | null>(null);\n\n const clearTimers = useCallback(() => {\n if (tickRef.current) {\n clearInterval(tickRef.current);\n tickRef.current = null;\n }\n if (maxTimerRef.current) {\n clearTimeout(maxTimerRef.current);\n maxTimerRef.current = null;\n }\n }, []);\n\n const stopStream = useCallback(() => {\n streamRef.current?.getTracks().forEach((t) => t.stop());\n streamRef.current = null;\n }, []);\n\n const revokePreview = useCallback(() => {\n if (previewUrlRef.current) {\n URL.revokeObjectURL(previewUrlRef.current);\n previewUrlRef.current = null;\n }\n setPreviewUrl(null);\n }, []);\n\n const stopRecording = useCallback(\n (reason?: \"max\") => {\n const rec = recorderRef.current;\n if (rec && rec.state !== \"inactive\") {\n rec.stop();\n } else {\n clearTimers();\n stopStream();\n setRecording(false);\n onRecordingChange?.(false);\n }\n if (reason === \"max\") setMaxReached(true);\n },\n [clearTimers, onRecordingChange, stopStream],\n );\n\n const startCapture = useCallback(async () => {\n setError(null);\n revokePreview();\n setBlob(null);\n setMaxReached(false);\n setElapsedMs(0);\n chunksRef.current = [];\n try {\n const stream = await navigator.mediaDevices.getDisplayMedia({\n video: true,\n audio: false,\n preferCurrentTab: true,\n } as DisplayMediaStreamOptions);\n streamRef.current = stream;\n const videoTrack = stream.getVideoTracks()[0];\n videoTrack?.addEventListener(\"ended\", () => stopRecording());\n\n const mime = pickScreenRecorderMime();\n const recorder = new MediaRecorder(stream, { mimeType: mime });\n recorderRef.current = recorder;\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunksRef.current.push(e.data);\n };\n recorder.onstop = () => {\n clearTimers();\n const finalMs = Math.min(\n SCREEN_RECORD_MAX_MS,\n Math.max(0, Date.now() - (startedAtRef.current || Date.now())),\n );\n durationMsRef.current = finalMs;\n setElapsedMs(finalMs);\n const next = new Blob(chunksRef.current, {\n type: recorder.mimeType || mime,\n });\n setBlob(next);\n const url = URL.createObjectURL(next);\n previewUrlRef.current = url;\n setPreviewUrl(url);\n stopStream();\n recorderRef.current = null;\n setRecording(false);\n onRecordingChange?.(false);\n };\n startedAtRef.current = Date.now();\n recorder.start(500);\n setRecording(true);\n onRecordingChange?.(true);\n tickRef.current = setInterval(() => {\n const ms = Date.now() - startedAtRef.current;\n const clamped = Math.min(ms, SCREEN_RECORD_MAX_MS);\n setElapsedMs(clamped);\n if (ms >= SCREEN_RECORD_MAX_MS) stopRecording(\"max\");\n }, 200);\n maxTimerRef.current = setTimeout(\n () => stopRecording(\"max\"),\n SCREEN_RECORD_MAX_MS,\n );\n } catch {\n stopStream();\n setError(labels.screenRecordPermissionError);\n setRecording(false);\n onRecordingChange?.(false);\n }\n }, [\n clearTimers,\n labels.screenRecordPermissionError,\n onRecordingChange,\n revokePreview,\n stopRecording,\n stopStream,\n ]);\n\n const cancelSession = useCallback(() => {\n if (recording) stopRecording();\n clearTimers();\n stopStream();\n recorderRef.current = null;\n revokePreview();\n setBlob(null);\n setError(null);\n setMaxReached(false);\n setElapsedMs(0);\n durationMsRef.current = 0;\n setRecording(false);\n onRecordingChange?.(false);\n }, [\n clearTimers,\n onRecordingChange,\n recording,\n revokePreview,\n stopRecording,\n stopStream,\n ]);\n\n const sendRecording = useCallback(async () => {\n if (sending || !blob || !onSendBlob) return;\n setSending(true);\n try {\n await onSendBlob(blob, durationMsRef.current || elapsedMs);\n cancelSession();\n } finally {\n setSending(false);\n }\n }, [blob, cancelSession, elapsedMs, onSendBlob, sending]);\n\n useEffect(() => {\n return () => {\n clearTimers();\n if (recorderRef.current?.state === \"recording\") {\n recorderRef.current.stop();\n }\n stopStream();\n revokePreview();\n };\n }, [clearTimers, revokePreview, stopStream]);\n\n const timerLabel = `${formatAudioDuration(elapsedMs)} / ${formatAudioDuration(SCREEN_RECORD_MAX_MS)}`;\n const active = recording || Boolean(blob) || Boolean(error);\n\n return {\n recording,\n elapsedMs,\n blob,\n error,\n maxReached,\n sending,\n previewUrl,\n canSend: Boolean(blob) && Boolean(onSendBlob),\n timerLabel,\n active,\n startCapture,\n stopRecording: () => stopRecording(),\n cancelSession,\n sendRecording,\n };\n}\n\nfunction InternalChatScreenRecorderBar({\n labels,\n session,\n onDismiss,\n}: {\n labels: InternalChatPanelLabels;\n session: ScreenRecorderSession;\n onDismiss: () => void;\n}) {\n const {\n recording,\n blob,\n error,\n maxReached,\n sending,\n previewUrl,\n canSend,\n timerLabel,\n startCapture,\n stopRecording,\n cancelSession,\n sendRecording,\n } = session;\n\n const handleDismiss = () => {\n if (!recording && !blob) {\n cancelSession();\n }\n onDismiss();\n };\n\n return (\n <div\n className=\"mx-5 mb-2 rounded-xl border border-border bg-muted/35 px-3 py-2\"\n role=\"region\"\n aria-label={labels.screenRecordTitle}\n >\n <div className=\"flex items-center justify-between gap-2\">\n <p className=\"text-xs font-medium text-foreground\">\n {labels.screenRecordTitle}\n </p>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={\n recording\n ? \"Ocultar panel (la grabación continúa)\"\n : labels.screenRecordCancel\n }\n onClick={handleDismiss}\n >\n <IconX className=\"size-4\" />\n </Button>\n </div>\n {error ? (\n <p className=\"mt-1 text-xs leading-snug text-destructive\">{error}</p>\n ) : (\n <p className=\"mt-0.5 text-xs leading-snug text-muted-foreground\">\n {recording\n ? labels.screenRecordBackgroundHint\n : maxReached\n ? labels.screenRecordMaxReached\n : blob\n ? \"Revisa el video y envíalo al chat.\"\n : labels.screenRecordHint}\n </p>\n )}\n {recording ? (\n <p className=\"mt-1 text-xs font-medium tabular-nums text-destructive\">\n {timerLabel}\n </p>\n ) : null}\n <div className=\"mt-2 flex flex-col gap-2\">\n {previewUrl && blob ? (\n <video\n src={previewUrl}\n controls\n playsInline\n className=\"max-h-32 w-full rounded-md border border-border bg-black object-contain\"\n />\n ) : null}\n <div className=\"flex flex-wrap items-center gap-2\">\n {recording ? (\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n onClick={stopRecording}\n >\n <IconSquare className=\"size-3.5 fill-current\" />\n {labels.screenRecordStop}\n </Button>\n ) : (\n <Button\n type=\"button\"\n variant=\"secondary\"\n size=\"sm\"\n className=\"h-8 gap-1.5\"\n disabled={Boolean(blob)}\n onClick={() => void startCapture()}\n >\n <IconScreenShare className=\"size-3.5\" />\n {labels.screenRecordStart}\n </Button>\n )}\n {recording ? (\n <span className=\"inline-flex min-w-0 flex-1 items-center gap-1.5 text-xs text-destructive\">\n <span className=\"size-2 shrink-0 animate-pulse rounded-full bg-destructive\" />\n Grabando pantalla\n </span>\n ) : null}\n {(recording || blob) && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"h-8 text-xs text-muted-foreground\"\n onClick={cancelSession}\n >\n {labels.screenRecordCancel}\n </Button>\n )}\n <Button\n type=\"button\"\n size=\"sm\"\n className=\"ml-auto h-8 shrink-0\"\n disabled={sending || !canSend}\n onClick={() => void sendRecording()}\n >\n {labels.screenRecordSend}\n </Button>\n </div>\n </div>\n </div>\n );\n}\n\nfunction renderMessageTextWithMentions(text: string): ReactNode {\n const parts = text.split(/(@\\[[^\\]]+\\])/g);\n return parts.map((part, index) => {\n const mention = /^@\\[(.+)\\]$/.exec(part);\n if (mention) {\n return (\n <span\n key={index}\n className=\"inline-flex max-w-full items-center rounded-md border border-primary/35 bg-primary/12 px-1.5 py-0.5 font-medium text-primary\"\n >\n {mention[1]}\n </span>\n );\n }\n return <span key={index}>{part}</span>;\n });\n}\n\nexport type InternalChatPanelProps = {\n title: string;\n rooms: InternalChatRoom[];\n activeRoomId: string | null;\n onActiveRoomChange: (roomId: string) => void;\n messages: InternalChatMessageLine[];\n messagesStatus: InternalChatMessagesStatus;\n messagesError?: string | null;\n onLoadOlderMessages?: () => void | Promise<void>;\n isLoadingOlder?: boolean;\n hasOlderMessages?: boolean;\n mentionGuideItems?: InternalChatMentionItem[];\n mentionPackageItems?: InternalChatMentionItem[];\n mentionConfig?: InternalChatMentionConfig;\n /** Catálogo de ítems ya filtrado en servidor (guías/paquetes). */\n mentionServerFilteredItems?: boolean;\n mentionCatalogLoading?: boolean;\n onMentionCatalogChange?: (state: InternalChatMentionCatalogState) => void;\n typingLine?: string | null;\n onSendMessage: (text: string) => void | Promise<void>;\n onCreateRoom: (name: string) => void | Promise<void>;\n onDeleteRoom: (roomId: string) => void | Promise<void>;\n onOpenChange?: (open: boolean) => void;\n onDraftChange?: (draft: string) => void;\n onUserGesture?: () => void;\n isSending?: boolean;\n isCreatingRoom?: boolean;\n isDeletingRoom?: boolean;\n onDeleteRoomBlocked?: (reason: string) => void;\n labels?: Partial<InternalChatPanelLabels>;\n className?: string;\n /** Mensajes no vistos (icono del header: badge rojo, hasta «9+»). */\n unreadTotal?: number;\n /** No leídos por sala (badge en el rail de salas). */\n unreadByRoomId?: Record<string, number>;\n /** Se invoca al abrir el panel o al cambiar de sala con el chat visible. */\n onMarkRoomRead?: (roomId: string) => void;\n /** Conexión BFF: el contenedor sube el archivo. */\n onAttachPhotos?: (file: File) => void | Promise<void>;\n onAttachFiles?: (file: File) => void | Promise<void>;\n /** Tras grabar en el panel (conexión BFF / subida). */\n onSendAudioBlob?: (\n blob: Blob,\n durationMs: number,\n ) => void | Promise<void>;\n /** Atajo legacy: el host abre su propio flujo sin el grabador integrado. */\n onSendAudio?: () => void | Promise<void>;\n onAttachAudioFile?: (file: File) => void | Promise<void>;\n /** Video de captura de pantalla (máx. 1 min en el grabador integrado). */\n onSendScreenRecordingBlob?: (\n blob: Blob,\n durationMs: number,\n ) => void | Promise<void>;\n /** Abre el drawer al montar (p. ej. `/admin/.../chat`). */\n defaultOpen?: boolean;\n /** Oculta el botón flotante; usar con `defaultOpen` en página dedicada. */\n hideTrigger?: boolean;\n};\n\nfunction InternalChatPanel({\n title,\n rooms,\n activeRoomId,\n onActiveRoomChange,\n messages,\n messagesStatus,\n messagesError = null,\n onLoadOlderMessages,\n isLoadingOlder = false,\n hasOlderMessages = false,\n mentionGuideItems = [],\n mentionPackageItems = [],\n mentionConfig = DEFAULT_MENTION_CONFIG,\n mentionServerFilteredItems = false,\n mentionCatalogLoading = false,\n onMentionCatalogChange,\n typingLine = null,\n onSendMessage,\n onCreateRoom,\n onDeleteRoom,\n onOpenChange,\n onDraftChange,\n onUserGesture,\n isSending = false,\n isCreatingRoom = false,\n isDeletingRoom = false,\n onDeleteRoomBlocked,\n labels: labelsPartial,\n className,\n unreadTotal = 0,\n unreadByRoomId,\n onMarkRoomRead,\n onAttachPhotos,\n onAttachFiles,\n onSendAudioBlob,\n onSendAudio,\n onSendScreenRecordingBlob,\n defaultOpen = false,\n hideTrigger = false,\n}: InternalChatPanelProps) {\n const labels = { ...DEFAULT_LABELS, ...labelsPartial };\n\n const [open, setOpen] = useState(defaultOpen);\n const [draft, setDraft] = useState(\"\");\n const [createOpen, setCreateOpen] = useState(false);\n const [newRoomName, setNewRoomName] = useState(\"\");\n const [deleteOpen, setDeleteOpen] = useState(false);\n const [voiceRecorderOpen, setVoiceRecorderOpen] = useState(false);\n const [screenRecorderOpen, setScreenRecorderOpen] = useState(false);\n const screenRecorderSession = useInternalChatScreenRecorderSession(\n labels,\n onSendScreenRecordingBlob,\n useCallback((isRecording: boolean) => {\n if (isRecording) setScreenRecorderOpen(true);\n }, []),\n );\n const [mentionStart, setMentionStart] = useState<number | null>(null);\n const [mentionQuery, setMentionQuery] = useState(\"\");\n const [mentionHighlight, setMentionHighlight] = useState(0);\n const lastMentionQueryRef = useRef(\"\");\n const loadingOlderMessagesRef = useRef(false);\n\n const formRef = useRef<HTMLFormElement>(null);\n const scrollerRef = useRef<HTMLDivElement>(null);\n const messagesListRef = useRef<HTMLDivElement>(null);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const photoInputRef = useRef<HTMLInputElement>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n\n const supportRoom = useMemo(\n () => rooms.find((r) => r.isSupport) ?? null,\n [rooms],\n );\n\n const teamRooms = useMemo(\n () => rooms.filter((r) => !r.isSupport),\n [rooms],\n );\n\n const activeRoom = useMemo(\n () => rooms.find((r) => r.id === activeRoomId) ?? null,\n [rooms, activeRoomId],\n );\n\n const mentionListOptions = useMemo((): BuildMentionListOptions | undefined => {\n if (!mentionServerFilteredItems) return undefined;\n const seg = parseMentionSegment(mentionQuery, mentionConfig);\n if (seg.phase !== \"item\") return undefined;\n return { serverFilteredItems: true };\n }, [mentionServerFilteredItems, mentionQuery, mentionConfig]);\n\n const mentionEntries = useMemo(\n () =>\n buildMentionList(\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ),\n [\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n mentionListOptions,\n ],\n );\n\n const mentionSeg = useMemo(\n () => parseMentionSegment(mentionQuery, mentionConfig),\n [mentionQuery, mentionConfig],\n );\n\n const mentionOpen =\n Boolean(activeRoom) &&\n mentionStart !== null &&\n (mentionEntries.length > 0 ||\n mentionCatalogLoading ||\n (mentionServerFilteredItems && mentionSeg.phase === \"item\"));\n\n const canSend =\n draft.trim().length > 0 &&\n !isSending &&\n Boolean(activeRoomId);\n\n const messagesScrollTail = useMemo(() => {\n if (messages.length === 0) {\n return `empty:${messagesStatus}`;\n }\n const last = messages[messages.length - 1]!;\n const attachCount = last.attachments?.length ?? 0;\n const hasAudio = last.audio ? 1 : 0;\n return [\n messages.length,\n last.id,\n last.text.length,\n attachCount,\n hasAudio,\n last.receiptStatus ?? \"\",\n messagesStatus,\n ].join(\"|\");\n }, [messages, messagesStatus]);\n\n const scrollMessagesToBottom = useCallback(() => {\n const node = scrollerRef.current;\n if (!node) return;\n node.scrollTop = node.scrollHeight;\n }, []);\n\n const scheduleScrollMessagesToBottom = useCallback(() => {\n scrollMessagesToBottom();\n let rafNested = 0;\n const rafOuter = requestAnimationFrame(() => {\n scrollMessagesToBottom();\n rafNested = requestAnimationFrame(scrollMessagesToBottom);\n });\n const timeouts = [50, 150, 320].map((ms) =>\n window.setTimeout(scrollMessagesToBottom, ms),\n );\n return () => {\n cancelAnimationFrame(rafOuter);\n if (rafNested) cancelAnimationFrame(rafNested);\n timeouts.forEach(clearTimeout);\n };\n }, [scrollMessagesToBottom]);\n\n const setOpenWithNotify = useCallback(\n (next: boolean) => {\n if (next) onUserGesture?.();\n setOpen(next);\n onOpenChange?.(next);\n },\n [onOpenChange, onUserGesture],\n );\n\n useEffect(() => {\n if (!open || !activeRoomId) return;\n onMarkRoomRead?.(activeRoomId);\n }, [open, activeRoomId, onMarkRoomRead]);\n\n useEffect(() => {\n if (!open) return;\n if (screenRecorderSession.active) {\n setScreenRecorderOpen(true);\n }\n }, [open, screenRecorderSession.active]);\n\n const handleMessagesScroll = useCallback(() => {\n const node = scrollerRef.current;\n if (!node || !activeRoomId || !onLoadOlderMessages) return;\n if (loadingOlderMessagesRef.current) return;\n if (node.scrollTop > 120) return;\n if (!hasOlderMessages || isLoadingOlder) return;\n loadingOlderMessagesRef.current = true;\n const prevHeight = node.scrollHeight;\n const prevTop = node.scrollTop;\n void Promise.resolve(onLoadOlderMessages()).finally(() => {\n requestAnimationFrame(() => {\n const n = scrollerRef.current;\n if (!n) return;\n const delta = n.scrollHeight - prevHeight;\n n.scrollTop = prevTop + delta;\n loadingOlderMessagesRef.current = false;\n });\n });\n }, [\n activeRoomId,\n hasOlderMessages,\n isLoadingOlder,\n onLoadOlderMessages,\n ]);\n\n useLayoutEffect(() => {\n if (!open || !activeRoomId) return;\n if (loadingOlderMessagesRef.current) return;\n return scheduleScrollMessagesToBottom();\n }, [\n open,\n activeRoomId,\n messagesScrollTail,\n typingLine,\n draft,\n voiceRecorderOpen,\n screenRecorderOpen,\n scheduleScrollMessagesToBottom,\n ]);\n\n useEffect(() => {\n if (!open || !activeRoomId) return;\n const scroller = scrollerRef.current;\n const list = messagesListRef.current;\n const textarea = textareaRef.current;\n if (!scroller) return;\n\n const pinBottom = () => {\n if (loadingOlderMessagesRef.current) return;\n scrollMessagesToBottom();\n };\n\n const ro = new ResizeObserver(pinBottom);\n ro.observe(scroller);\n if (list) ro.observe(list);\n if (textarea) ro.observe(textarea);\n const form = formRef.current;\n if (form) ro.observe(form);\n\n return () => ro.disconnect();\n }, [\n open,\n activeRoomId,\n messagesScrollTail,\n scrollMessagesToBottom,\n ]);\n\n useEffect(() => {\n if (!mentionOpen || mentionEntries.length === 0) return;\n setMentionHighlight((h) => Math.min(h, mentionEntries.length - 1));\n }, [mentionOpen, mentionEntries.length]);\n\n useEffect(() => {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }, [activeRoomId]);\n\n useEffect(() => {\n if (!onMentionCatalogChange) return;\n if (mentionStart === null) {\n onMentionCatalogChange({\n open: false,\n phase: \"category\",\n filter: \"\",\n });\n return;\n }\n if (mentionSeg.phase === \"category\") {\n onMentionCatalogChange({\n open: true,\n phase: \"category\",\n filter: mentionSeg.filter,\n });\n return;\n }\n onMentionCatalogChange({\n open: true,\n phase: \"item\",\n kind: mentionSeg.kind,\n filter: mentionSeg.filter,\n });\n }, [mentionStart, mentionQuery, mentionConfig, onMentionCatalogChange]);\n\n function closeMentionMenu() {\n setMentionStart(null);\n setMentionQuery(\"\");\n lastMentionQueryRef.current = \"\";\n }\n\n function syncMentionMenu(nextDraft: string, cursor: number) {\n const m = parseSlashMention(nextDraft, cursor);\n if (!m) {\n closeMentionMenu();\n return;\n }\n const seg = parseMentionSegment(m.query, mentionConfig);\n const listOptions: BuildMentionListOptions | undefined =\n mentionServerFilteredItems && seg.phase === \"item\"\n ? { serverFilteredItems: true }\n : undefined;\n const entries = buildMentionList(\n m.query,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n listOptions,\n );\n const keepOpenOnEmpty =\n mentionCatalogLoading ||\n (mentionServerFilteredItems && seg.phase === \"item\");\n if (entries.length === 0 && !keepOpenOnEmpty) {\n closeMentionMenu();\n return;\n }\n if (m.query !== lastMentionQueryRef.current) {\n setMentionHighlight(0);\n lastMentionQueryRef.current = m.query;\n }\n setMentionStart(m.start);\n setMentionQuery(m.query);\n }\n\n function replaceSlashSegment(newSegment: string) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const newDraft = `${before}/${newSegment}${after}`;\n const slashEnd = before.length + 1 + newSegment.length;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n setMentionQuery(newSegment);\n lastMentionQueryRef.current = newSegment;\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(slashEnd, slashEnd);\n });\n }\n\n function applyCategoryPick(kind: InternalChatMentionKind) {\n if (mentionConfig.categoryMeta[kind]?.disabled) return;\n replaceSlashSegment(`${kind}:`);\n setMentionHighlight(0);\n }\n\n function applyItemPick(item: InternalChatMentionItem) {\n const el = textareaRef.current;\n if (el === null || mentionStart === null) return;\n const cursor = el.selectionStart ?? draft.length;\n const before = draft.slice(0, mentionStart);\n const after = draft.slice(cursor);\n const token = `@[${item.insertLabel}]`;\n const needsSpace =\n after.length > 0 && !/^\\s/.test(after) && !after.startsWith(\"\\n\");\n const newDraft = `${before}${token}${needsSpace ? \" \" : \"\"}${after}`;\n setDraft(newDraft);\n onDraftChange?.(newDraft);\n closeMentionMenu();\n const newPos = before.length + token.length + (needsSpace ? 1 : 0);\n requestAnimationFrame(() => {\n el.focus();\n el.setSelectionRange(newPos, newPos);\n });\n }\n\n async function onSubmit(event: FormEvent<HTMLFormElement>) {\n event.preventDefault();\n if (mentionOpen) return;\n const text = draft.trim();\n if (!text || !activeRoomId) return;\n onUserGesture?.();\n try {\n await onSendMessage(text);\n setDraft(\"\");\n onDraftChange?.(\"\");\n scheduleScrollMessagesToBottom();\n } catch {\n /* errores vía contenedor */\n }\n }\n\n async function handleCreateRoom() {\n const name = newRoomName.trim();\n if (!name) return;\n try {\n await onCreateRoom(name);\n setNewRoomName(\"\");\n setCreateOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n async function handleConfirmDeleteRoom() {\n if (!activeRoom) return;\n try {\n await onDeleteRoom(activeRoom.id);\n setDeleteOpen(false);\n } catch {\n /* contenedor */\n }\n }\n\n function requestDeleteRoom() {\n if (!activeRoom) return;\n if (activeRoom.isOrganization) {\n onDeleteRoomBlocked?.(\"La sala Organización no se puede eliminar.\");\n return;\n }\n if (activeRoom.isSupport) {\n onDeleteRoomBlocked?.(\"La sala de soporte no se puede eliminar.\");\n return;\n }\n if (teamRooms.length <= 1) {\n onDeleteRoomBlocked?.(\"Debe existir al menos una sala.\");\n return;\n }\n setDeleteOpen(true);\n }\n\n return (\n <>\n {!hideTrigger ? (\n <Button\n type=\"button\"\n size=\"icon\"\n className={cn(\"relative size-9 text-primary-foreground\", className)}\n aria-label={\n unreadTotal > 0\n ? `${labels.openChat} · ${unreadTotal} sin leer`\n : labels.openChat\n }\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n {unreadTotal > 0 ? (\n <span\n className={unreadCountBadgeClasses(formatUnreadBadge(unreadTotal))}\n aria-hidden\n >\n {formatUnreadBadge(unreadTotal)}\n </span>\n ) : null}\n {screenRecorderSession.recording ? (\n <span\n className=\"absolute -top-0.5 -left-0.5 size-2.5 animate-pulse rounded-full bg-destructive ring-2 ring-background\"\n aria-hidden\n />\n ) : null}\n </Button>\n ) : !open ? (\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n className=\"gap-2\"\n onClick={() => setOpenWithNotify(true)}\n >\n <IconMessages className=\"size-4\" />\n {labels.openChat}\n </Button>\n ) : null}\n\n {!open && screenRecorderSession.active ? (\n <Button\n type=\"button\"\n variant=\"destructive\"\n size=\"sm\"\n className=\"fixed bottom-6 right-6 z-[200] h-9 gap-2 shadow-lg\"\n onClick={() => setOpenWithNotify(true)}\n >\n <span className=\"size-2 animate-pulse rounded-full bg-destructive-foreground\" />\n <span className=\"text-xs font-medium tabular-nums\">\n {screenRecorderSession.recording\n ? `REC ${screenRecorderSession.timerLabel}`\n : screenRecorderSession.blob\n ? labels.screenRecordOpenChatToFinish\n : labels.screenRecordTitle}\n </span>\n </Button>\n ) : null}\n\n <Dialog open={createOpen} onOpenChange={setCreateOpen}>\n <DialogContent className=\"sm:max-w-md\">\n <DialogHeader>\n <DialogTitle>{labels.newRoomTitle}</DialogTitle>\n <DialogDescription>{labels.newRoomDescription}</DialogDescription>\n </DialogHeader>\n <Input\n value={newRoomName}\n onChange={(e) => setNewRoomName(e.target.value)}\n placeholder={labels.newRoomPlaceholder}\n autoFocus\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n void handleCreateRoom();\n }\n }}\n />\n <DialogFooter className=\"gap-2 sm:gap-0\">\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={() => {\n setCreateOpen(false);\n setNewRoomName(\"\");\n }}\n >\n {labels.cancel}\n </Button>\n <Button\n type=\"button\"\n onClick={() => void handleCreateRoom()}\n disabled={isCreatingRoom}\n >\n {labels.create}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n\n <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>\n <AlertDialogContent>\n <AlertDialogHeader>\n <AlertDialogTitle>{labels.deleteRoomTitle}</AlertDialogTitle>\n <AlertDialogDescription>\n {labels.deleteRoomDescription(activeRoom?.name ?? \"la sala\")}\n </AlertDialogDescription>\n </AlertDialogHeader>\n <AlertDialogFooter>\n <AlertDialogCancel>{labels.cancel}</AlertDialogCancel>\n <AlertDialogAction\n className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90\"\n onClick={() => void handleConfirmDeleteRoom()}\n disabled={isDeletingRoom}\n >\n {labels.deleteConfirm}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n\n <Drawer\n open={open}\n onOpenChange={setOpenWithNotify}\n direction=\"right\"\n shouldScaleBackground={false}\n >\n <DrawerContent\n className={cn(\n \"gap-0 overflow-hidden p-0 shadow-xl\",\n \"data-[vaul-drawer-direction=right]:inset-y-3\",\n \"data-[vaul-drawer-direction=right]:right-3\",\n \"data-[vaul-drawer-direction=right]:left-auto\",\n \"data-[vaul-drawer-direction=right]:h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:max-h-[calc(100%-1.5rem)]\",\n \"data-[vaul-drawer-direction=right]:w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:sm:max-w-[min(36rem,calc(100vw-1.5rem))]\",\n \"data-[vaul-drawer-direction=right]:rounded-2xl\",\n \"data-[vaul-drawer-direction=right]:border\",\n )}\n >\n <div className=\"flex h-full min-h-0 flex-row\">\n <div className=\"flex w-[3.25rem] shrink-0 flex-col items-center gap-2 border-r border-border py-4\">\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.newRoom}\n onClick={() => setCreateOpen(true)}\n >\n <IconPlus className=\"size-4\" />\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">{labels.newRoom}</TooltipContent>\n </Tooltip>\n\n <div className=\"flex min-h-0 flex-1 flex-col items-center gap-2 overflow-y-auto px-1 py-1\">\n {teamRooms.map((room) => {\n const selected = room.id === activeRoomId;\n return (\n <Tooltip key={room.id} delayDuration={300}>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={() => onActiveRoomChange(room.id)}\n className={cn(\n \"relative flex size-9 shrink-0 items-center justify-center rounded-full border-2 transition-colors\",\n selected\n ? \"border-primary bg-primary/15\"\n : \"border-transparent bg-muted hover:bg-muted/80\",\n )}\n aria-label={`Sala ${room.name}${room.isOrganization ? \" (toda la organización)\" : \"\"}`}\n aria-current={selected ? \"true\" : undefined}\n >\n <span className=\"text-xs font-semibold leading-none\">\n {roomInitialLetter(room.name)}\n </span>\n {(unreadByRoomId?.[room.id] ?? 0) > 0 ? (\n <span\n className={cn(\n unreadCountBadgeClasses(\n formatUnreadBadge(unreadByRoomId![room.id]!),\n ),\n \"ring-2 ring-background\",\n )}\n aria-hidden\n >\n {formatUnreadBadge(unreadByRoomId![room.id]!)}\n </span>\n ) : null}\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {room.isOrganization\n ? `${room.name}${labels.orgRoomTooltip}`\n : room.name}\n </TooltipContent>\n </Tooltip>\n );\n })}\n </div>\n\n {supportRoom ? (\n <Tooltip delayDuration={300}>\n <TooltipTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className={cn(\n \"relative size-9 shrink-0 rounded-full border shadow-sm\",\n activeRoomId === supportRoom.id\n ? \"border-sky-600 bg-sky-600 text-white hover:bg-sky-600/90 hover:text-white dark:border-sky-500 dark:bg-sky-500\"\n : \"border-sky-500/70 bg-sky-500/20 text-sky-800 hover:bg-sky-500/30 dark:border-sky-400/60 dark:bg-sky-500/25 dark:text-sky-100\",\n )}\n aria-label={labels.supportChatTooltip}\n aria-current={\n activeRoomId === supportRoom.id ? \"true\" : undefined\n }\n onClick={() => onActiveRoomChange(supportRoom.id)}\n >\n <IconHeadset className=\"size-4 stroke-[2.25]\" />\n {(unreadByRoomId?.[supportRoom.id] ?? 0) > 0 ? (\n <span\n className={cn(\n unreadCountBadgeClasses(\n formatUnreadBadge(\n unreadByRoomId![supportRoom.id]!,\n ),\n ),\n \"ring-2 ring-background\",\n )}\n aria-hidden\n >\n {formatUnreadBadge(unreadByRoomId![supportRoom.id]!)}\n </span>\n ) : null}\n </Button>\n </TooltipTrigger>\n <TooltipContent side=\"left\">\n {labels.supportChatTooltip}\n </TooltipContent>\n </Tooltip>\n ) : null}\n\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n className=\"size-9 shrink-0 rounded-full\"\n aria-label={labels.roomMenu}\n >\n <IconDotsVertical className=\"size-4\" />\n </Button>\n </DropdownMenuTrigger>\n <DropdownMenuContent side=\"left\" align=\"end\" className=\"w-48\">\n <DropdownMenuItem onClick={() => setCreateOpen(true)}>\n <IconPlus className=\"size-4\" />\n {labels.newRoom}\n </DropdownMenuItem>\n <DropdownMenuSeparator />\n <DropdownMenuItem\n disabled={\n teamRooms.length <= 1 ||\n activeRoom?.isOrganization ||\n activeRoom?.isSupport\n }\n className=\"text-destructive focus:text-destructive\"\n onClick={requestDeleteRoom}\n >\n <IconTrash className=\"size-4\" />\n {labels.deleteRoom}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n </div>\n\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n <div className=\"flex items-start justify-between gap-4 px-5 pt-5 pb-3\">\n <div className=\"flex min-w-0 flex-col gap-1\">\n <DrawerTitle className=\"flex items-center gap-2 font-medium\">\n <IconMessages className=\"size-5 shrink-0\" aria-hidden />\n <span className=\"min-w-0 truncate\">{title}</span>\n </DrawerTitle>\n <DrawerDescription className=\"line-clamp-2\">\n {activeRoom ? (\n activeRoom.isSupport ? (\n <span>{labels.supportRoomDescription}</span>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {activeRoom.name}\n </span>\n {activeRoom.isOrganization ? (\n <>{labels.orgRoomSuffix}</>\n ) : null}\n {\" · \"}\n <kbd className=\"pointer-events-none rounded border bg-muted px-0.5 font-mono text-[0.65rem]\">\n /\n </kbd>{\" \"}\n {labels.slashMentionHint}\n </>\n )\n ) : (\n labels.selectRoomHint\n )}\n </DrawerDescription>\n </div>\n <DrawerClose asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"icon\"\n className=\"size-8 shrink-0\"\n aria-label={labels.closeChat}\n >\n <IconX className=\"size-4\" />\n </Button>\n </DrawerClose>\n </div>\n\n <div\n ref={scrollerRef}\n className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n onScroll={handleMessagesScroll}\n >\n {!activeRoom ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.noRoomTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.noRoomDescription}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"loading\" ? (\n <div className=\"flex h-full flex-col items-center justify-center gap-2 text-muted-foreground\">\n <IconLoader2 className=\"size-6 animate-spin\" aria-hidden />\n <span className=\"text-sm\">{labels.loadingMessages}</span>\n </div>\n ) : messagesStatus === \"error\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.loadMessagesErrorTitle}</EmptyTitle>\n <EmptyDescription className=\"text-left\">\n {messagesError ?? labels.loadMessagesErrorFallback}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : messagesStatus === \"empty\" ? (\n <Empty className=\"h-full border-0\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\">\n <IconMessages />\n </EmptyMedia>\n <EmptyTitle>{labels.emptyThreadTitle}</EmptyTitle>\n <EmptyDescription>\n {labels.emptyThreadDescription(activeRoom.name)}\n </EmptyDescription>\n </EmptyHeader>\n </Empty>\n ) : (\n <div\n ref={messagesListRef}\n className=\"flex flex-col gap-4 px-5 pt-2 pb-6\"\n >\n {isLoadingOlder ? (\n <div className=\"flex items-center justify-center gap-2 py-2 text-xs text-muted-foreground\">\n <IconLoader2\n className=\"size-3.5 shrink-0 animate-spin\"\n aria-hidden\n />\n {labels.loadingOlderMessages}\n </div>\n ) : null}\n {messages.map((message) => {\n const mine = message.role === \"me\";\n return (\n <Message\n key={message.id}\n align={mine ? \"end\" : \"start\"}\n >\n {mine ? null : (\n <MessageAvatar className=\"size-8 text-xs font-medium\">\n {message.initials}\n </MessageAvatar>\n )}\n <MessageContent className=\"gap-0.5\">\n {mine ? null : (\n <MessageHeader className=\"px-3 pb-0 leading-snug\">\n {message.author}\n </MessageHeader>\n )}\n {message.attachments?.length ? (\n <InternalChatAttachmentStack\n items={message.attachments}\n labels={labels}\n />\n ) : null}\n {message.audio ? (\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"py-2\">\n <InternalChatAudioBubble\n audio={message.audio}\n seed={message.id}\n mine={mine}\n />\n </BubbleContent>\n </Bubble>\n ) : null}\n {message.text.trim() ? (\n <Bubble\n align={mine ? \"end\" : \"start\"}\n variant={mine ? \"muted\" : \"outline\"}\n >\n <BubbleContent className=\"break-words whitespace-pre-wrap\">\n {renderMessageTextWithMentions(message.text)}\n </BubbleContent>\n </Bubble>\n ) : null}\n <MessageFooter className=\"flex items-center justify-end gap-1 px-3 pt-0 leading-none\">\n <span className=\"text-[0.65rem] tabular-nums text-muted-foreground\">\n {formatMessageTime(message.at)}\n </span>\n {mine && message.receiptStatus ? (\n <span\n className=\"inline-flex shrink-0\"\n title={\n message.receiptStatus === \"pending\"\n ? \"Enviando\"\n : message.receiptStatus === \"sent\"\n ? \"Enviado\"\n : message.receiptStatus === \"delivered\"\n ? \"Entregado\"\n : \"Leído\"\n }\n >\n <MessageReceiptTicks\n status={message.receiptStatus}\n />\n </span>\n ) : null}\n </MessageFooter>\n </MessageContent>\n </Message>\n );\n })}\n </div>\n )}\n {activeRoom && typingLine ? (\n <div\n className=\"border-t border-border px-5 py-2 text-xs text-muted-foreground\"\n aria-live=\"polite\"\n >\n {typingLine}\n </div>\n ) : null}\n </div>\n\n {voiceRecorderOpen && activeRoom ? (\n <InternalChatVoiceRecorderBar\n labels={labels}\n onClose={() => setVoiceRecorderOpen(false)}\n onSendBlob={onSendAudioBlob}\n onSendLegacy={onSendAudio}\n />\n ) : null}\n\n {activeRoom && screenRecorderOpen ? (\n <InternalChatScreenRecorderBar\n labels={labels}\n session={screenRecorderSession}\n onDismiss={() => setScreenRecorderOpen(false)}\n />\n ) : null}\n\n <form\n ref={formRef}\n onSubmit={onSubmit}\n className=\"relative px-5 pt-2 pb-5\"\n >\n {mentionOpen ? (\n <div\n role=\"listbox\"\n aria-label={labels.mentionListLabel}\n className=\"absolute bottom-full left-5 right-5 z-50 mb-1 flex max-h-60 flex-col overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-md\"\n >\n <div className=\"shrink-0 border-b border-border px-2 py-1.5 text-[0.7rem] text-muted-foreground\">\n {mentionSeg.phase === \"category\" ? (\n <>\n <span className=\"font-medium text-foreground\">\n Tipo\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n Tab\n </kbd>\n /\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ↵\n </kbd>\n </>\n ) : (\n <>\n <span className=\"font-medium text-foreground\">\n {DEFAULT_MENTION_KIND_LABEL[mentionSeg.kind]}\n </span>\n {\" · \"}\n <kbd className=\"rounded bg-muted px-1 font-mono text-[0.65rem]\">\n ←\n </kbd>\n </>\n )}\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto p-1\">\n {mentionCatalogLoading && mentionEntries.length === 0 ? (\n <div className=\"flex items-center justify-center gap-2 px-2 py-6 text-sm text-muted-foreground\">\n <IconLoader2 className=\"size-4 animate-spin\" />\n Buscando…\n </div>\n ) : null}\n {!mentionCatalogLoading &&\n mentionEntries.length === 0 &&\n mentionSeg.phase === \"item\" ? (\n <p className=\"px-2 py-4 text-center text-sm text-muted-foreground\">\n Sin resultados\n </p>\n ) : null}\n {mentionEntries.map((entry, index) => {\n if (entry.type === \"category\") {\n const IconComp = entry.icon;\n const categoryDisabled = isMentionEntryDisabled(\n entry,\n mentionConfig,\n );\n return (\n <button\n key={`cat-${entry.kind}`}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n aria-disabled={categoryDisabled}\n disabled={categoryDisabled}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n categoryDisabled &&\n \"cursor-not-allowed opacity-50\",\n !categoryDisabled &&\n (index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\"),\n )}\n onMouseEnter={() => {\n if (!categoryDisabled) setMentionHighlight(index);\n }}\n onMouseDown={(event) => {\n event.preventDefault();\n if (categoryDisabled) return;\n applyCategoryPick(entry.kind);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {entry.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {entry.hint}\n </span>\n </span>\n </button>\n );\n }\n const item = entry.data;\n const IconComp = item.icon;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"option\"\n aria-selected={index === mentionHighlight}\n className={cn(\n \"flex w-full items-start gap-2 rounded-lg px-2 py-2 text-left text-sm\",\n index === mentionHighlight\n ? \"bg-accent text-accent-foreground\"\n : \"hover:bg-muted/80\",\n )}\n onMouseEnter={() => setMentionHighlight(index)}\n onMouseDown={(event) => {\n event.preventDefault();\n applyItemPick(item);\n }}\n >\n <IconComp className=\"mt-0.5 size-4 shrink-0 opacity-80\" />\n <span className=\"min-w-0 flex-1\">\n <span className=\"block font-medium leading-tight\">\n {item.label}\n </span>\n <span className=\"mt-0.5 block text-xs text-muted-foreground\">\n {item.subtitle}\n </span>\n </span>\n </button>\n );\n })}\n </div>\n </div>\n ) : null}\n\n <InputGroup>\n <InputGroupTextarea\n ref={textareaRef}\n value={draft}\n onChange={(event) => {\n const v = event.target.value;\n const cursor =\n event.target.selectionStart ?? v.length;\n setDraft(v);\n onDraftChange?.(v);\n syncMentionMenu(v, cursor);\n }}\n onSelect={(event) => {\n const t = event.target as HTMLTextAreaElement;\n syncMentionMenu(\n t.value,\n t.selectionStart ?? t.value.length,\n );\n }}\n placeholder={\n activeRoom\n ? labels.messagePlaceholder\n : labels.messagePlaceholderNoRoom\n }\n aria-label={labels.messageAriaLabel}\n className=\"min-h-14\"\n disabled={!activeRoom}\n onKeyDown={(event) => {\n onUserGesture?.();\n if (mentionOpen) {\n if (event.key === \"ArrowDown\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n 1,\n ),\n );\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n stepMentionHighlight(\n mentionEntries,\n mentionConfig,\n h,\n -1,\n ),\n );\n return;\n }\n if (event.key === \"Backspace\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"ArrowLeft\") {\n const seg = parseMentionSegment(\n mentionQuery,\n mentionConfig,\n );\n if (seg.phase === \"item\" && seg.filter === \"\") {\n event.preventDefault();\n replaceSlashSegment(\"\");\n setMentionHighlight(0);\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Tab\") {\n event.preventDefault();\n const pick = mentionEntries[mentionHighlight];\n if (!pick || isMentionEntryDisabled(pick, mentionConfig)) {\n return;\n }\n if (pick.type === \"category\") {\n applyCategoryPick(pick.kind);\n } else {\n applyItemPick(pick.data);\n }\n return;\n }\n if (event.key === \"Escape\") {\n event.preventDefault();\n closeMentionMenu();\n return;\n }\n }\n if (event.key === \"Enter\" && !event.shiftKey) {\n event.preventDefault();\n formRef.current?.requestSubmit();\n }\n }}\n />\n <InputGroupAddon align=\"block-end\" className=\"pt-1\">\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <InputGroupButton\n aria-label={labels.attachAriaLabel}\n type=\"button\"\n size=\"icon-sm\"\n variant=\"outline\"\n disabled={!activeRoom}\n >\n <IconPlus className=\"size-4\" />\n </InputGroupButton>\n </DropdownMenuTrigger>\n <DropdownMenuContent\n align=\"start\"\n side=\"top\"\n className=\"w-48\"\n >\n <DropdownMenuItem\n onClick={() => {\n onUserGesture?.();\n photoInputRef.current?.click();\n }}\n >\n <IconPhoto className=\"size-4\" />\n {labels.attachPhotos}\n </DropdownMenuItem>\n <DropdownMenuItem\n onClick={() => {\n onUserGesture?.();\n fileInputRef.current?.click();\n }}\n >\n <IconPaperclip className=\"size-4\" />\n {labels.attachFiles}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n <InputGroupButton\n type=\"button\"\n variant={\n screenRecorderOpen || screenRecorderSession.recording\n ? \"default\"\n : \"outline\"\n }\n size=\"icon-sm\"\n disabled={!activeRoom}\n aria-label={labels.screenCaptureAriaLabel}\n aria-pressed={\n screenRecorderOpen || screenRecorderSession.recording\n }\n className={cn(\n !screenRecorderOpen &&\n !screenRecorderSession.recording &&\n \"text-violet-600 hover:text-violet-700 dark:text-violet-400 dark:hover:text-violet-300\",\n )}\n onClick={() => {\n onUserGesture?.();\n setVoiceRecorderOpen(false);\n setScreenRecorderOpen((prev) => !prev);\n }}\n >\n <IconScreenShare className=\"size-4\" />\n </InputGroupButton>\n <input\n ref={photoInputRef}\n type=\"file\"\n accept=\"image/*\"\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file && onAttachPhotos) void onAttachPhotos(file);\n e.target.value = \"\";\n }}\n />\n <input\n ref={fileInputRef}\n type=\"file\"\n className=\"hidden\"\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file && onAttachFiles) void onAttachFiles(file);\n e.target.value = \"\";\n }}\n />\n <div className=\"ml-auto flex shrink-0 items-center gap-1\">\n <InputGroupButton\n type=\"button\"\n variant={voiceRecorderOpen ? \"default\" : \"outline\"}\n size=\"icon-sm\"\n disabled={!activeRoom}\n aria-label={labels.attachAudio}\n aria-pressed={voiceRecorderOpen}\n className={cn(\n !voiceRecorderOpen &&\n \"text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300\",\n )}\n onClick={() => {\n onUserGesture?.();\n setScreenRecorderOpen(false);\n setVoiceRecorderOpen((open) => !open);\n }}\n >\n <IconMicrophone className=\"size-4\" />\n </InputGroupButton>\n <InputGroupButton\n type=\"submit\"\n variant=\"default\"\n size=\"icon-sm\"\n disabled={!canSend || !activeRoom}\n >\n <IconArrowUp className=\"size-4\" />\n <span className=\"sr-only\">{labels.sendSrOnly}</span>\n </InputGroupButton>\n </div>\n </InputGroupAddon>\n </InputGroup>\n </form>\n </div>\n </div>\n </DrawerContent>\n </Drawer>\n </>\n );\n}\n\nexport {\n InternalChatPanel,\n DEFAULT_MENTION_CONFIG,\n DEFAULT_LABELS as DEFAULT_INTERNAL_CHAT_PANEL_LABELS,\n};\n\nexport { initialsFromDisplayName } from \"@/components/ui/internal-chat-mention\";\n\nexport type {\n BuildMentionListOptions,\n InternalChatMentionCatalogState,\n InternalChatMentionConfig,\n InternalChatMentionItem,\n InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n",
27
27
  "type": "registry:ui",
28
28
  "target": ""
29
29
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-city-codes",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-city-codes.ts",
9
+ "content": "/**\n * Códigos cortos de ciudad (logística / etiquetas).\n * Clave: `{ISO2}:{NombreCiudad}` → código de 3 letras.\n */\nconst CITY_CODE_OVERRIDES: Record<string, string> = {\n // Ecuador\n \"EC:Guayaquil\": \"GYE\",\n \"EC:Quito\": \"UIO\",\n \"EC:Cuenca\": \"CUE\",\n \"EC:Machala\": \"MCH\",\n \"EC:Manta\": \"MEC\",\n \"EC:Portoviejo\": \"PVO\",\n \"EC:Ambato\": \"ATF\",\n \"EC:Loja\": \"LOH\",\n \"EC:Esmeraldas\": \"ESM\",\n \"EC:Ibarra\": \"IBB\",\n \"EC:Riobamba\": \"RIB\",\n \"EC:Santo Domingo\": \"STD\",\n \"EC:Milagro\": \"MLG\",\n \"EC:Durán\": \"DUR\",\n \"EC:Samborondón\": \"SBO\",\n // Colombia\n \"CO:Bogotá\": \"BOG\",\n \"CO:Bogota\": \"BOG\",\n \"CO:Medellín\": \"MDE\",\n \"CO:Medellin\": \"MDE\",\n \"CO:Cali\": \"CAL\",\n \"CO:Barranquilla\": \"BAQ\",\n \"CO:Cartagena\": \"CTG\",\n \"CO:Bucaramanga\": \"BGA\",\n \"CO:Pereira\": \"PEI\",\n \"CO:Manizales\": \"MZL\",\n \"CO:Cúcuta\": \"CUC\",\n \"CO:Cucuta\": \"CUC\",\n \"CO:Ibagué\": \"IBE\",\n \"CO:Ibague\": \"IBE\",\n \"CO:Santa Marta\": \"SMR\",\n // México\n \"MX:Ciudad de México\": \"CDM\",\n \"MX:Guadalajara\": \"GDL\",\n \"MX:Monterrey\": \"MTY\",\n \"MX:Puebla\": \"PBC\",\n \"MX:Tijuana\": \"TIJ\",\n \"MX:León\": \"BJX\",\n \"MX:Leon\": \"BJX\",\n // Perú\n \"PE:Lima\": \"LIM\",\n \"PE:Arequipa\": \"AQP\",\n \"PE:Trujillo\": \"TRU\",\n \"PE:Cusco\": \"CUZ\",\n // Chile\n \"CL:Santiago\": \"SCL\",\n \"CL:Valparaíso\": \"VAP\",\n \"CL:Valparaiso\": \"VAP\",\n \"CL:Concepción\": \"CCP\",\n \"CL:Concepcion\": \"CCP\",\n // Argentina\n \"AR:Buenos Aires\": \"BUE\",\n \"AR:Córdoba\": \"COR\",\n \"AR:Cordoba\": \"COR\",\n \"AR:Rosario\": \"ROS\",\n \"AR:Mendoza\": \"MDZ\",\n // USA (casilleros)\n \"US:Miami\": \"MIA\",\n \"US:New York\": \"NYC\",\n \"US:Los Angeles\": \"LAX\",\n \"US:Houston\": \"HOU\",\n \"US:Chicago\": \"ORD\",\n // Nicaragua\n \"NI:Chontales\": \"CHO\",\n \"NI:Juigalpa\": \"JUI\",\n \"NI:Managua\": \"MGA\",\n \"NI:León\": \"LEN\",\n \"NI:Leon\": \"LEN\",\n \"NI:Granada\": \"GRA\",\n \"NI:Masaya\": \"MSY\",\n \"NI:Matagalpa\": \"MTG\",\n // Reino Unido\n \"GB:London\": \"LON\",\n \"GB:Greater London\": \"LON\",\n \"GB:Manchester\": \"MAN\",\n \"GB:Birmingham\": \"BHX\",\n \"GB:Liverpool\": \"LIV\",\n \"GB:Leeds\": \"LDS\",\n \"GB:Glasgow\": \"GLA\",\n \"GB:Edinburgh\": \"EDI\",\n \"GB:Cardiff\": \"CDF\",\n \"GB:Belfast\": \"BEL\",\n \"GB:England\": \"ENG\",\n \"GB:Scotland\": \"SCT\",\n \"GB:Wales\": \"WLS\",\n \"GB:Northern Ireland\": \"NIR\",\n // España\n \"ES:Madrid\": \"MAD\",\n \"ES:Barcelona\": \"BCN\",\n \"ES:L'Hospitalet de Llobregat\": \"LHO\",\n \"ES:LHospitalet de Llobregat\": \"LHO\",\n \"ES:Hospitalet de Llobregat\": \"LHO\",\n \"ES:Valencia\": \"VLC\",\n \"ES:Sevilla\": \"SVQ\",\n \"ES:Zaragoza\": \"ZAZ\",\n \"ES:Bilbao\": \"BIO\",\n \"ES:Malaga\": \"AGP\",\n \"ES:Málaga\": \"AGP\",\n \"ES:Murcia\": \"MJV\",\n \"ES:Palma\": \"PMI\",\n \"ES:Las Palmas de Gran Canaria\": \"LPA\",\n \"ES:Las Palmas\": \"LPA\",\n \"ES:Alicante\": \"ALC\",\n \"ES:Cordoba\": \"COR\",\n \"ES:Córdoba\": \"COR\",\n \"ES:Valladolid\": \"VLL\",\n \"ES:Vigo\": \"VGO\",\n \"ES:Gijon\": \"GIJ\",\n \"ES:Gijón\": \"GIJ\",\n \"ES:Oviedo\": \"OVD\",\n \"ES:Leon\": \"LEN\",\n \"ES:León\": \"LEN\",\n \"ES:Burgos\": \"BUR\",\n \"ES:Salamanca\": \"SLM\",\n \"ES:San Sebastian\": \"EAS\",\n \"ES:San Sebastián\": \"EAS\",\n \"ES:Santander\": \"SDR\",\n \"ES:Pamplona\": \"PNA\",\n \"ES:Granada\": \"GRX\",\n \"ES:Toledo\": \"TOL\",\n \"ES:Badajoz\": \"BJZ\",\n \"ES:Castilla y Leon\": \"CLY\",\n \"ES:Castilla y León\": \"CLY\",\n \"ES:Andalucia\": \"AND\",\n \"ES:Andalucía\": \"AND\",\n \"ES:Cataluna\": \"CAT\",\n \"ES:Cataluña\": \"CAT\",\n \"ES:Comunidad Valenciana\": \"VAL\",\n \"ES:Comunidad de Madrid\": \"MAD\",\n \"ES:Region de Murcia\": \"MUR\",\n \"ES:Región de Murcia\": \"MUR\",\n \"ES:Pais Vasco\": \"PV\",\n \"ES:País Vasco\": \"PV\",\n \"ES:Galicia\": \"GAL\",\n \"ES:Extremadura\": \"EXT\",\n \"ES:Asturias\": \"AST\",\n \"ES:Navarra\": \"NAV\",\n \"ES:Cantabria\": \"CAN\",\n \"ES:La Rioja\": \"RIO\",\n \"ES:Islas Baleares\": \"BAL\",\n \"ES:Canarias\": \"CNR\",\n \"ES:Ceuta\": \"CEU\",\n \"ES:Melilla\": \"MEL\",\n // Venezuela\n \"VE:Caracas\": \"CCS\",\n \"VE:Maracaibo\": \"MAR\",\n \"VE:Valencia\": \"VLN\",\n \"VE:Barquisimeto\": \"BRM\",\n \"VE:Maracay\": \"MYC\",\n \"VE:San Cristobal\": \"SCI\",\n \"VE:San Cristóbal\": \"SCI\",\n \"VE:Puerto La Cruz\": \"PLC\",\n \"VE:Maturin\": \"MUN\",\n \"VE:Maturín\": \"MUN\",\n \"VE:Barcelona\": \"BLA\",\n \"VE:Ciudad Guayana\": \"CGY\",\n \"VE:Puerto Ordaz\": \"PZO\",\n \"VE:Merida\": \"MER\",\n \"VE:Mérida\": \"MER\",\n \"VE:Punto Fijo\": \"PFJ\",\n \"VE:Porlamar\": \"PMV\",\n \"VE:San Fernando de Apure\": \"SFA\",\n \"VE:Naguanagua\": \"NGN\",\n \"VE:Lecheria\": \"LEC\",\n \"VE:Lechería\": \"LEC\",\n \"VE:Cumana\": \"CUM\",\n \"VE:Cumaná\": \"CUM\",\n \"VE:Coro\": \"COR\",\n \"VE:Barinas\": \"BAR\",\n \"VE:Trujillo\": \"TRU\",\n \"VE:Valera\": \"VLR\",\n \"VE:San Felipe\": \"SFP\",\n \"VE:La Guaira\": \"LGR\",\n \"VE:Maiquetia\": \"MAI\",\n \"VE:Maiquetía\": \"MAI\",\n \"VE:Distrito Capital\": \"CCS\",\n \"VE:Apure\": \"APU\",\n \"VE:Anzoategui\": \"ANZ\",\n \"VE:Anzoátegui\": \"ANZ\",\n \"VE:Aragua\": \"ARA\",\n \"VE:Bolivar\": \"BOL\",\n \"VE:Bolívar\": \"BOL\",\n \"VE:Carabobo\": \"CAR\",\n \"VE:Miranda\": \"MIR\",\n \"VE:Zulia\": \"ZUL\",\n \"VE:Tachira\": \"TAC\",\n \"VE:Táchira\": \"TAC\",\n \"VE:Lara\": \"LAR\",\n \"VE:Monagas\": \"MON\",\n \"VE:Nueva Esparta\": \"NEP\",\n \"VE:Falcon\": \"FAL\",\n \"VE:Falcón\": \"FAL\",\n \"VE:Guarico\": \"GUA\",\n \"VE:Guárico\": \"GUA\",\n \"VE:Portuguesa\": \"POR\",\n \"VE:Sucre\": \"SUC\",\n \"VE:Yaracuy\": \"YAR\",\n \"VE:Amazonas\": \"AMZ\",\n \"VE:Cojedes\": \"COJ\",\n \"VE:Delta Amacuro\": \"DAM\",\n};\n\nfunction normalizeCityKey(cityName: string): string {\n return cityName\n .normalize(\"NFD\")\n .replace(/\\p{M}/gu, \"\")\n .replace(/[''`´’]/g, \"\")\n .trim();\n}\n\nfunction fallbackCityCode(cityName: string): string {\n const letters = normalizeCityKey(cityName).replace(/[^a-zA-Z]/g, \"\");\n if (letters.length >= 3) return letters.slice(0, 3).toUpperCase();\n return letters.toUpperCase().padEnd(3, \"X\");\n}\n\nexport function resolveCityCode(\n countryCode: string,\n cityName: string,\n): string {\n const cc = countryCode.trim().toUpperCase();\n const normalized = normalizeCityKey(cityName);\n const direct = CITY_CODE_OVERRIDES[`${cc}:${normalized}`];\n if (direct) return direct;\n const withoutAccents = CITY_CODE_OVERRIDES[`${cc}:${cityName.trim()}`];\n if (withoutAccents) return withoutAccents;\n return fallbackCityCode(cityName);\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-countries",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-countries.ts",
9
+ "content": "/** Países para UI: nombres vía Intl; catálogo vía `@countrystatecity/countries-browser`. */\n\nexport interface CountryOption {\n code: string;\n short: string;\n long: string;\n}\n\n/** @deprecated Usar `CountryOption`. */\nexport type LatinCountry = CountryOption;\n\nexport function spanishCountryName(isoCode: string, fallback?: string): string {\n const code = isoCode.trim().toUpperCase();\n if (!code) return fallback?.trim() || \"\";\n try {\n const name = new Intl.DisplayNames([\"es\"], { type: \"region\" }).of(code);\n return name?.trim() || fallback?.trim() || code;\n } catch {\n return fallback?.trim() || code;\n }\n}\n\n/** Mapea la respuesta CSC a opciones de UI (nombre en español, orden A–Z). */\nexport function mapGeoCountries(\n apiCountries: Array<{ code: string; name: string }>,\n): CountryOption[] {\n const byCode = new Map<string, CountryOption>();\n\n for (const c of apiCountries) {\n const code = c.code.trim().toUpperCase();\n if (!code) continue;\n byCode.set(code, {\n code,\n short: code,\n long: spanishCountryName(code, c.name),\n });\n }\n\n return [...byCode.values()].sort((a, b) =>\n a.long.localeCompare(b.long, \"es\"),\n );\n}\n\n/** @deprecated Usar `mapGeoCountries`. */\nexport const mergeGeoCountries = mapGeoCountries;\n\nexport function getCountryByCode(\n countryCode?: string | null,\n): CountryOption | undefined {\n if (!countryCode?.trim()) return undefined;\n const normalized = countryCode.trim().toUpperCase();\n return {\n code: normalized,\n short: normalized,\n long: spanishCountryName(normalized, normalized),\n };\n}\n\n/** Nombre en español a partir del código ISO; si no hay match, usa el fallback o el código. */\nexport function getCountryDisplayName(\n countryCode?: string | null,\n fallbackName?: string | null,\n): string {\n if (!countryCode?.trim()) {\n if (fallbackName?.trim()) return fallbackName.trim();\n return \"—\";\n }\n return spanishCountryName(countryCode, fallbackName ?? undefined);\n}\n\n/** Fax en oficinas/direcciones cuando hay país ISO-2. */\nexport function isCommonFaxCountry(countryCode?: string | null): boolean {\n const code = countryCode?.trim().toUpperCase();\n return Boolean(code && /^[A-Z]{2}$/.test(code));\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-csc",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "dependencies": [
7
+ "@countrystatecity/countries-browser"
8
+ ],
9
+ "files": [
10
+ {
11
+ "path": "lib/geo-csc.ts",
12
+ "content": "/**\n * Catálogo geo vía `@countrystatecity/countries-browser` (CDN / fetch).\n * Países, provincias y ciudades; sin JSON embebido en el repo.\n */\nimport {\n getCitiesOfState,\n getCountries,\n getStatesOfCountry,\n} from \"@countrystatecity/countries-browser\";\n\nexport type GeoApiOption = {\n code: string;\n name: string;\n};\n\nexport type GeoApiCityOption = GeoApiOption & {\n stateCode?: string;\n stateName?: string;\n};\n\nexport async function getGeoCountries(): Promise<GeoApiOption[]> {\n try {\n const countries = await getCountries();\n return (countries ?? []).map((c) => ({\n code: c.iso2,\n name: c.name,\n }));\n } catch (error) {\n console.error(\"getGeoCountries:\", error);\n return [];\n }\n}\n\nexport async function getGeoStates(\n countryCode: string,\n): Promise<GeoApiOption[]> {\n const cc = countryCode.trim().toUpperCase();\n if (!cc) return [];\n try {\n const states = await getStatesOfCountry(cc);\n return (states ?? [])\n .filter((s) => Boolean(s.iso2?.trim() && s.name?.trim()))\n .map((s) => ({\n code: s.iso2.trim(),\n name: s.name.trim(),\n }));\n } catch (error) {\n console.error(\"getGeoStates:\", cc, error);\n return [];\n }\n}\n\nexport async function getGeoCities(params: {\n countryCode: string;\n stateCode?: string | null;\n q?: string | null;\n limit?: number;\n}): Promise<GeoApiCityOption[]> {\n const cc = params.countryCode.trim().toUpperCase();\n const stateCode = params.stateCode?.trim();\n if (!cc || !stateCode) return [];\n\n try {\n const cities = await getCitiesOfState(cc, stateCode);\n const q = params.q?.trim().toLowerCase();\n let list = cities ?? [];\n if (q && q.length >= 2) {\n list = list.filter((c) => c.name.toLowerCase().includes(q));\n }\n if (params.limit != null && params.limit > 0) {\n list = list.slice(0, params.limit);\n }\n return list.map((c) => ({\n code: c.name,\n name: c.name,\n stateCode,\n }));\n } catch (error) {\n console.error(\"getGeoCities:\", error);\n return [];\n }\n}\n",
13
+ "type": "registry:lib",
14
+ "target": ""
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-locations",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-locations.ts",
9
+ "content": "import { getGeoCities, getGeoStates } from \"./geo-csc\";\nimport { resolveCityCode } from \"./geo-city-codes\";\nimport {\n filterStatesForCountry,\n mergeExtraCitiesForState,\n resolveCscStateCode,\n resolveStateDisplayName,\n} from \"./geo-overrides\";\nimport {\n DEPARTMENT_NAME_AS_CITY_COUNTRY_CODES,\n type GeoCityOption,\n type GeoOption,\n} from \"./geo-types\";\nimport { normalizeSearchText } from \"./utils\";\n\nfunction mapApiCity(\n countryCode: string,\n city: {\n name: string;\n stateCode?: string;\n stateName?: string;\n },\n stateNameByCode: Map<string, string>,\n): GeoCityOption {\n const stateCode = city.stateCode ?? \"\";\n return {\n code: resolveCityCode(countryCode, city.name),\n name: city.name,\n stateCode: stateCode || undefined,\n stateName:\n city.stateName ??\n (stateCode ? stateNameByCode.get(stateCode) : undefined),\n };\n}\n\nexport async function fetchStates(countryCode: string): Promise<GeoOption[]> {\n const cc = countryCode.trim().toUpperCase();\n if (!cc) return [];\n const states = await getGeoStates(cc);\n return filterStatesForCountry(\n cc,\n states.map((s) => ({ code: s.code, name: s.name })),\n );\n}\n\nasync function fetchCitiesForStateCode(\n cc: string,\n stateCode: string,\n): Promise<\n Array<{ name: string; stateCode?: string; stateName?: string }>\n> {\n const primary = await getGeoCities({\n countryCode: cc,\n stateCode,\n });\n if (primary.length > 0) return primary;\n\n const mapped = resolveCscStateCode(cc, stateCode);\n if (mapped !== stateCode) {\n return getGeoCities({\n countryCode: cc,\n stateCode: mapped,\n });\n }\n return [];\n}\n\nexport async function fetchCities(\n countryCode: string,\n stateCode?: string | null,\n): Promise<GeoCityOption[]> {\n const cc = countryCode.trim().toUpperCase();\n if (!cc) return [];\n\n // Sin estado no listamos el país entero (catálogo grande).\n if (!stateCode?.trim()) return [];\n\n const code = stateCode.trim();\n const states = await fetchStates(cc);\n const stateNameByCode = new Map(\n states.map((s) => [\n s.code,\n resolveStateDisplayName(cc, s.code, s.name),\n ]),\n );\n\n const apiStateCode = resolveCscStateCode(cc, code);\n const state =\n states.find((s) => s.code === code) ||\n states.find((s) => s.code === apiStateCode);\n\n const fromCatalogRaw = await fetchCitiesForStateCode(cc, code);\n const fromCatalog = fromCatalogRaw.map((c) =>\n mapApiCity(cc, c, stateNameByCode),\n );\n\n const cities = mergeExtraCitiesForState(\n cc,\n code,\n mergeExtraCitiesForState(cc, apiStateCode, fromCatalog),\n );\n\n const withDepartment = (() => {\n if (\n !DEPARTMENT_NAME_AS_CITY_COUNTRY_CODES.has(cc) ||\n !state?.name?.trim()\n ) {\n return cities;\n }\n\n const departmentName = resolveStateDisplayName(\n cc,\n state.code,\n state.name,\n ).trim();\n const alreadyListed = cities.some(\n (city) => city.name.trim().toLowerCase() === departmentName.toLowerCase(),\n );\n if (alreadyListed) return cities;\n\n return [\n ...cities,\n {\n code: resolveCityCode(cc, departmentName),\n name: departmentName,\n stateCode: state.code,\n stateName: state.name,\n },\n ].sort((a, b) => a.name.localeCompare(b.name, \"es\"));\n })();\n\n if (cc === \"ES\" && code !== apiStateCode) {\n const postalName = resolveStateDisplayName(\n cc,\n code,\n state?.name ?? code,\n );\n return withDepartment.map((city) => ({\n ...city,\n stateCode: code,\n stateName: postalName,\n }));\n }\n\n return withDepartment;\n}\n\nfunction geoNameKey(value: string): string {\n return normalizeSearchText(value).replace(/[^a-z0-9]/g, \"\");\n}\n\nexport function findStateByName(\n states: GeoOption[],\n name?: string | null,\n): GeoOption | undefined {\n if (!name?.trim()) return undefined;\n const q = geoNameKey(name);\n const code = name.trim().toUpperCase();\n return states.find(\n (s) => geoNameKey(s.name) === q || s.code.toUpperCase() === code,\n );\n}\n\nexport function findCityByName(\n cities: GeoCityOption[],\n name?: string | null,\n): GeoCityOption | undefined {\n if (!name?.trim()) return undefined;\n const q = geoNameKey(name);\n return cities.find((c) => geoNameKey(c.name) === q);\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-overrides",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-overrides.ts",
9
+ "content": "import type { GeoCityOption, GeoOption } from \"./geo-types\";\n\n/**\n * Provincias de España (ISO 3166-2) para direcciones postales.\n * `cscStateCode` es el código de comunidad en el catálogo geo (API / dataset).\n * Castilla y León no trae ciudades en `CL`; el catálogo las agrupa en `LE`.\n */\nexport const ES_PROVINCES: Array<{\n code: string;\n name: string;\n cscStateCode: string;\n}> = [\n { code: \"C\", name: \"A Coruña\", cscStateCode: \"GA\" },\n { code: \"VI\", name: \"Álava\", cscStateCode: \"PV\" },\n { code: \"AB\", name: \"Albacete\", cscStateCode: \"CM\" },\n { code: \"A\", name: \"Alicante\", cscStateCode: \"VC\" },\n { code: \"AL\", name: \"Almería\", cscStateCode: \"AN\" },\n { code: \"O\", name: \"Asturias\", cscStateCode: \"AS\" },\n { code: \"AV\", name: \"Ávila\", cscStateCode: \"LE\" },\n { code: \"BA\", name: \"Badajoz\", cscStateCode: \"EX\" },\n { code: \"PM\", name: \"Baleares\", cscStateCode: \"PM\" },\n { code: \"B\", name: \"Barcelona\", cscStateCode: \"CT\" },\n { code: \"BU\", name: \"Burgos\", cscStateCode: \"LE\" },\n { code: \"CC\", name: \"Cáceres\", cscStateCode: \"EX\" },\n { code: \"CA\", name: \"Cádiz\", cscStateCode: \"AN\" },\n { code: \"S\", name: \"Cantabria\", cscStateCode: \"CB\" },\n { code: \"CS\", name: \"Castellón\", cscStateCode: \"VC\" },\n { code: \"CE\", name: \"Ceuta\", cscStateCode: \"CE\" },\n { code: \"CR\", name: \"Ciudad Real\", cscStateCode: \"CM\" },\n { code: \"CO\", name: \"Córdoba\", cscStateCode: \"AN\" },\n { code: \"CU\", name: \"Cuenca\", cscStateCode: \"CM\" },\n { code: \"SS\", name: \"Gipuzkoa\", cscStateCode: \"PV\" },\n { code: \"GI\", name: \"Girona\", cscStateCode: \"CT\" },\n { code: \"GR\", name: \"Granada\", cscStateCode: \"AN\" },\n { code: \"GU\", name: \"Guadalajara\", cscStateCode: \"CM\" },\n { code: \"H\", name: \"Huelva\", cscStateCode: \"AN\" },\n { code: \"HU\", name: \"Huesca\", cscStateCode: \"AR\" },\n { code: \"J\", name: \"Jaén\", cscStateCode: \"AN\" },\n { code: \"LO\", name: \"La Rioja\", cscStateCode: \"RI\" },\n { code: \"GC\", name: \"Las Palmas\", cscStateCode: \"CN\" },\n { code: \"LE\", name: \"León\", cscStateCode: \"LE\" },\n { code: \"L\", name: \"Lleida\", cscStateCode: \"CT\" },\n { code: \"LU\", name: \"Lugo\", cscStateCode: \"GA\" },\n { code: \"M\", name: \"Madrid\", cscStateCode: \"MD\" },\n { code: \"MA\", name: \"Málaga\", cscStateCode: \"AN\" },\n { code: \"ML\", name: \"Melilla\", cscStateCode: \"ML\" },\n { code: \"MU\", name: \"Murcia\", cscStateCode: \"MC\" },\n { code: \"NA\", name: \"Navarra\", cscStateCode: \"NC\" },\n { code: \"OR\", name: \"Ourense\", cscStateCode: \"GA\" },\n { code: \"P\", name: \"Palencia\", cscStateCode: \"LE\" },\n { code: \"PO\", name: \"Pontevedra\", cscStateCode: \"GA\" },\n { code: \"SA\", name: \"Salamanca\", cscStateCode: \"LE\" },\n { code: \"TF\", name: \"Santa Cruz de Tenerife\", cscStateCode: \"CN\" },\n { code: \"SG\", name: \"Segovia\", cscStateCode: \"LE\" },\n { code: \"SE\", name: \"Sevilla\", cscStateCode: \"AN\" },\n { code: \"SO\", name: \"Soria\", cscStateCode: \"LE\" },\n { code: \"T\", name: \"Tarragona\", cscStateCode: \"CT\" },\n { code: \"TE\", name: \"Teruel\", cscStateCode: \"AR\" },\n { code: \"TO\", name: \"Toledo\", cscStateCode: \"CM\" },\n { code: \"V\", name: \"Valencia\", cscStateCode: \"VC\" },\n { code: \"VA\", name: \"Valladolid\", cscStateCode: \"LE\" },\n { code: \"BI\", name: \"Bizkaia\", cscStateCode: \"PV\" },\n { code: \"ZA\", name: \"Zamora\", cscStateCode: \"LE\" },\n { code: \"Z\", name: \"Zaragoza\", cscStateCode: \"AR\" },\n];\n\n/** Limita el selector de región a códigos concretos (p. ej. naciones del Reino Unido). */\nexport const STATE_LIST_FILTER_BY_COUNTRY: Partial<Record<string, string[]>> = {\n GB: [\"ENG\", \"SCT\", \"WLS\", \"NIR\"],\n VE: [\n \"Z\",\n \"B\",\n \"C\",\n \"D\",\n \"E\",\n \"F\",\n \"G\",\n \"H\",\n \"Y\",\n \"A\",\n \"I\",\n \"J\",\n \"X\",\n \"K\",\n \"M\",\n \"N\",\n \"L\",\n \"O\",\n \"P\",\n \"R\",\n \"T\",\n \"S\",\n \"U\",\n \"V\",\n ],\n};\n\n/** Nombres de región en español para el selector. */\nexport const STATE_DISPLAY_NAME_OVERRIDES: Partial<\n Record<string, Record<string, string>>\n> = {\n ES: {\n AN: \"Andalucía\",\n AR: \"Aragón\",\n AS: \"Asturias\",\n PM: \"Islas Baleares\",\n PV: \"País Vasco\",\n CN: \"Canarias\",\n CB: \"Cantabria\",\n CL: \"Castilla y León\",\n CM: \"Castilla-La Mancha\",\n CT: \"Cataluña\",\n CE: \"Ceuta\",\n EX: \"Extremadura\",\n GA: \"Galicia\",\n RI: \"La Rioja\",\n MD: \"Comunidad de Madrid\",\n ML: \"Melilla\",\n MC: \"Región de Murcia\",\n NC: \"Navarra\",\n VC: \"Comunidad Valenciana\",\n },\n VE: {\n X: \"La Guaira\",\n },\n};\n\n/** Ciudades que faltan en el catálogo para una región concreta. */\nexport const EXTRA_CITIES_BY_STATE: Partial<\n Record<string, Partial<Record<string, GeoCityOption[]>>>\n> = {\n ES: {\n B: [\n {\n code: \"LHO\",\n name: \"L'Hospitalet de Llobregat\",\n stateCode: \"B\",\n stateName: \"Barcelona\",\n },\n {\n code: \"BDL\",\n name: \"Badalona\",\n stateCode: \"B\",\n stateName: \"Barcelona\",\n },\n {\n code: \"BCN\",\n name: \"Barcelona\",\n stateCode: \"B\",\n stateName: \"Barcelona\",\n },\n ],\n AS: [\n {\n code: \"OVD\",\n name: \"Oviedo\",\n stateCode: \"AS\",\n stateName: \"Asturias\",\n },\n {\n code: \"GIJ\",\n name: \"Gijón\",\n stateCode: \"AS\",\n stateName: \"Asturias\",\n },\n {\n code: \"AVS\",\n name: \"Avilés\",\n stateCode: \"AS\",\n stateName: \"Asturias\",\n },\n ],\n CL: [\n {\n code: \"LEN\",\n name: \"León\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"BUR\",\n name: \"Burgos\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"SLM\",\n name: \"Salamanca\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"VLL\",\n name: \"Valladolid\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"SEG\",\n name: \"Segovia\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"SOR\",\n name: \"Soria\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"ZMR\",\n name: \"Zamora\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"AVL\",\n name: \"Ávila\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n {\n code: \"PAL\",\n name: \"Palencia\",\n stateCode: \"CL\",\n stateName: \"Castilla y León\",\n },\n ],\n },\n VE: {\n C: [\n {\n code: \"SFA\",\n name: \"San Fernando de Apure\",\n stateCode: \"C\",\n stateName: \"Apure\",\n },\n {\n code: \"GUA\",\n name: \"Guasdualito\",\n stateCode: \"C\",\n stateName: \"Apure\",\n },\n ],\n G: [\n {\n code: \"NGN\",\n name: \"Naguanagua\",\n stateCode: \"G\",\n stateName: \"Carabobo\",\n },\n ],\n B: [\n {\n code: \"LEC\",\n name: \"Lechería\",\n stateCode: \"B\",\n stateName: \"Anzoátegui\",\n },\n ],\n F: [\n {\n code: \"PZO\",\n name: \"Puerto Ordaz\",\n stateCode: \"F\",\n stateName: \"Bolívar\",\n },\n ],\n },\n GB: {\n NIR: [\n {\n code: \"BEL\",\n name: \"Belfast\",\n stateCode: \"NIR\",\n stateName: \"Northern Ireland\",\n },\n {\n code: \"DRY\",\n name: \"Derry\",\n stateCode: \"NIR\",\n stateName: \"Northern Ireland\",\n },\n {\n code: \"LSB\",\n name: \"Lisburn\",\n stateCode: \"NIR\",\n stateName: \"Northern Ireland\",\n },\n {\n code: \"NEW\",\n name: \"Newry\",\n stateCode: \"NIR\",\n stateName: \"Northern Ireland\",\n },\n ],\n },\n};\n\nexport function resolveCscStateCode(\n countryCode: string,\n stateCode: string,\n): string {\n const cc = countryCode.trim().toUpperCase();\n const code = stateCode.trim();\n if (!code) return code;\n if (cc === \"ES\") {\n return (\n ES_PROVINCES.find((province) => province.code === code)?.cscStateCode ??\n code\n );\n }\n return code;\n}\n\nexport function resolveStateDisplayName(\n countryCode: string,\n stateCode: string,\n defaultName: string,\n): string {\n const cc = countryCode.trim().toUpperCase();\n const code = stateCode.trim();\n const provinceName = ES_PROVINCES.find(\n (province) => cc === \"ES\" && province.code === code,\n )?.name;\n return (\n provinceName ||\n STATE_DISPLAY_NAME_OVERRIDES[cc]?.[code]?.trim() ||\n defaultName\n );\n}\n\nexport function filterStatesForCountry(\n countryCode: string,\n states: GeoOption[],\n): GeoOption[] {\n const cc = countryCode.trim().toUpperCase();\n if (cc === \"ES\") {\n return ES_PROVINCES.map((province) => ({\n code: province.code,\n name: province.name,\n }));\n }\n\n const allowed = STATE_LIST_FILTER_BY_COUNTRY[cc];\n const filtered = allowed?.length\n ? states.filter((state) => new Set(allowed).has(state.code))\n : states;\n\n return filtered.map((state) => ({\n ...state,\n name: resolveStateDisplayName(cc, state.code, state.name),\n }));\n}\n\nexport function mergeExtraCitiesForState(\n countryCode: string,\n stateCode: string,\n cities: GeoCityOption[],\n): GeoCityOption[] {\n const extras = EXTRA_CITIES_BY_STATE[countryCode]?.[stateCode];\n if (!extras?.length) return cities;\n\n const seen = new Set(cities.map((city) => city.name.trim().toLowerCase()));\n const merged = [...cities];\n\n for (const city of extras) {\n const key = city.name.trim().toLowerCase();\n if (seen.has(key)) continue;\n seen.add(key);\n merged.push(city);\n }\n\n return merged.sort((a, b) => a.name.localeCompare(b.name, \"es\"));\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-geo-types",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/geo-types.ts",
9
+ "content": "export type GeoOption = {\n code: string;\n name: string;\n};\n\nexport type GeoCityOption = GeoOption & {\n stateCode?: string;\n stateName?: string;\n};\n\n/**\n * Países donde el formulario usa catálogo CSC (CDN) con provincia/estado primero.\n */\nexport function usesGeoSearch(countryCode?: string | null): boolean {\n return Boolean(countryCode?.trim() && countryCode.trim().length === 2);\n}\n\n/** Provincia/estado antes que ciudad (evita listar ciudades de país entero). */\nexport function isStateFirstCountry(countryCode?: string | null): boolean {\n return usesGeoSearch(countryCode);\n}\n\n/**\n * Países donde el nombre del departamento se ofrece también como ciudad\n * (p. ej. Chontales en Nicaragua no existe como municipio en el catálogo).\n */\nexport const DEPARTMENT_NAME_AS_CITY_COUNTRY_CODES = new Set([\n \"CO\",\n \"ES\",\n \"GB\",\n \"NI\",\n \"PE\",\n \"VE\",\n]);\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "lib-google-maps-address",
4
+ "type": "registry:lib",
5
+ "author": "Traky (https://traky.app)",
6
+ "files": [
7
+ {
8
+ "path": "lib/google-maps-address.ts",
9
+ "content": "export const OFFICE_ADDRESS_GEOCODE_SOURCE_GOOGLE_MAPS = \"google_maps\" as const\n\nexport type GoogleMapsAddressSelection = {\n street: string\n streetNumber?: string\n city: string\n state?: string\n postalCode?: string\n countryCode?: string\n formattedAddress?: string\n latitude: number\n longitude: number\n}\n\ntype AddressComponentLike = {\n types: string[]\n long_name?: string\n short_name?: string\n longText?: string\n shortText?: string\n}\n\nexport function parseGoogleAddressComponents(\n components: AddressComponentLike[] | undefined,\n fallbackStreet: string,\n latitude: number,\n longitude: number,\n): GoogleMapsAddressSelection | null {\n let streetNumber = \"\"\n let route = \"\"\n let city = \"\"\n let state = \"\"\n let postalCode = \"\"\n let countryCode = \"\"\n\n for (const component of components ?? []) {\n const types = component.types\n const longName = component.long_name ?? component.longText ?? \"\"\n const shortName = component.short_name ?? component.shortText ?? \"\"\n\n if (types.includes(\"street_number\")) streetNumber = longName\n if (types.includes(\"route\")) route = longName\n if (types.includes(\"locality\")) city = longName\n else if (\n !city &&\n (types.includes(\"sublocality\") || types.includes(\"postal_town\"))\n ) {\n city = longName\n }\n if (types.includes(\"administrative_area_level_1\")) {\n state = longName || shortName\n }\n if (types.includes(\"postal_code\")) postalCode = longName\n if (types.includes(\"country\")) countryCode = shortName.toUpperCase()\n }\n\n const street = route || fallbackStreet\n if (!street.trim() && !fallbackStreet.trim()) return null\n\n return {\n street: street.trim() || fallbackStreet.trim(),\n streetNumber: streetNumber.trim() || undefined,\n city: city.trim(),\n state: state.trim() || undefined,\n postalCode: postalCode.trim() || undefined,\n countryCode: countryCode.trim() || undefined,\n latitude,\n longitude,\n }\n}\n\nexport function googleMapsSelectionToFormPatch(\n selection: GoogleMapsAddressSelection,\n): Pick<\n GoogleMapsAddressSelection,\n \"latitude\" | \"longitude\"\n> & {\n address: string\n city: string\n stateOrProvince: string\n postalCode: string\n countryCode: string\n addressGeocodeSource: string\n} {\n const addressLine =\n [selection.streetNumber, selection.street].filter(Boolean).join(\" \").trim() ||\n selection.formattedAddress?.split(\",\")[0]?.trim() ||\n selection.street\n\n return {\n address: addressLine,\n city: selection.city,\n stateOrProvince: selection.state ?? \"\",\n postalCode: selection.postalCode ?? \"\",\n countryCode: selection.countryCode?.trim().toUpperCase() ?? \"\",\n latitude: selection.latitude,\n longitude: selection.longitude,\n addressGeocodeSource: OFFICE_ADDRESS_GEOCODE_SOURCE_GOOGLE_MAPS,\n }\n}\n",
10
+ "type": "registry:lib",
11
+ "target": ""
12
+ }
13
+ ]
14
+ }