@traky-app/ui 1.6.1 → 1.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -1
- package/registry/traky/json/city-select.json +19 -0
- package/registry/traky/json/country-select.json +24 -0
- package/registry/traky/json/geo-search-select.json +24 -0
- package/registry/traky/json/geo-selectors.json +21 -0
- package/registry/traky/json/google-maps-address-picker.json +22 -0
- package/registry/traky/json/internal-chat-mention.json +1 -1
- package/registry/traky/json/internal-chat-panel.json +1 -1
- package/registry/traky/json/lib-geo-city-codes.json +14 -0
- package/registry/traky/json/lib-geo-countries.json +14 -0
- package/registry/traky/json/lib-geo-csc.json +17 -0
- package/registry/traky/json/lib-geo-locations.json +14 -0
- package/registry/traky/json/lib-geo-overrides.json +14 -0
- package/registry/traky/json/lib-geo-types.json +14 -0
- package/registry/traky/json/lib-google-maps-address.json +14 -0
- package/registry/traky/json/lib-phone-countries.json +14 -0
- package/registry/traky/json/lib-phone-e164.json +14 -0
- package/registry/traky/json/lib-utils.json +1 -1
- package/registry/traky/json/office-address-form-dialog.json +17 -0
- package/registry/traky/json/office-address-form.json +31 -0
- package/registry/traky/json/phone-number-input.json +25 -0
- package/registry/traky/json/state-select.json +19 -0
- package/registry/traky/lib/geo-city-codes.ts +234 -0
- package/registry/traky/lib/geo-countries.ts +75 -0
- package/registry/traky/lib/geo-csc.ts +82 -0
- package/registry/traky/lib/geo-locations.ts +171 -0
- package/registry/traky/lib/geo-overrides.ts +375 -0
- package/registry/traky/lib/geo-types.ts +34 -0
- package/registry/traky/lib/google-maps-address.ts +100 -0
- package/registry/traky/lib/phone-countries.ts +380 -0
- package/registry/traky/lib/phone-e164.ts +66 -0
- package/registry/traky/lib/utils.ts +24 -0
- package/registry/traky/registry.json +180 -0
- package/registry/traky/ui/city-select.tsx +113 -0
- package/registry/traky/ui/country-select.tsx +233 -0
- package/registry/traky/ui/geo-search-select.tsx +136 -0
- package/registry/traky/ui/geo-selectors.tsx +162 -0
- package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
- package/registry/traky/ui/internal-chat-mention.ts +34 -3
- package/registry/traky/ui/internal-chat-panel.tsx +147 -12
- package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
- package/registry/traky/ui/office-address-form.tsx +868 -0
- package/registry/traky/ui/phone-number-input.tsx +253 -0
- package/registry/traky/ui/state-select.tsx +107 -0
- package/src/lib/geo-city-codes.ts +234 -0
- package/src/lib/geo-countries.ts +75 -0
- package/src/lib/geo-csc.ts +82 -0
- package/src/lib/geo-locations.ts +171 -0
- package/src/lib/geo-overrides.ts +375 -0
- package/src/lib/geo-types.ts +34 -0
- package/src/lib/google-maps-address.ts +100 -0
- package/src/lib/phone-countries.ts +380 -0
- package/src/lib/phone-e164.ts +66 -0
- package/src/test/setup.ts +31 -0
- package/src/ui/__tests__/geo-selects.test.tsx +504 -0
- package/src/ui/city-select.tsx +113 -0
- package/src/ui/country-select.tsx +233 -0
- package/src/ui/geo-search-select.tsx +136 -0
- package/src/ui/geo-selectors.tsx +162 -0
- package/src/ui/google-maps-address-picker.tsx +422 -0
- package/src/ui/internal-chat-mention.ts +34 -3
- package/src/ui/internal-chat-panel.tsx +147 -12
- package/src/ui/office-address-form-dialog.tsx +16 -0
- package/src/ui/office-address-form.tsx +877 -0
- package/src/ui/phone-number-input.tsx +253 -0
- package/src/ui/state-select.tsx +107 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@traky-app/ui",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.4",
|
|
4
4
|
"description": "Componentes Traky UI para importar desde @traky-app/ui, más el registro copy-paste.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
"./registry/*": "./registry/*",
|
|
15
15
|
"./schema/components.json": "./schema/components.json",
|
|
16
|
+
"./lib/*": "./src/lib/*.ts",
|
|
16
17
|
"./*": "./src/ui/*.tsx"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
20
|
"registry:build": "node ./scripts/build-registry.mjs",
|
|
21
|
+
"test": "vitest run",
|
|
20
22
|
"prepack": "pnpm registry:build"
|
|
21
23
|
},
|
|
22
24
|
"repository": {
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
"react-dom": "^19.0.0"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
40
|
+
"@countrystatecity/countries-browser": "^1.0.4",
|
|
38
41
|
"@radix-ui/react-accordion": "^1.2.20",
|
|
39
42
|
"@radix-ui/react-alert-dialog": "^1.1.23",
|
|
40
43
|
"@radix-ui/react-avatar": "^1.2.6",
|
|
@@ -53,8 +56,10 @@
|
|
|
53
56
|
"@radix-ui/react-toggle": "^1.1.18",
|
|
54
57
|
"@radix-ui/react-toggle-group": "^1.1.19",
|
|
55
58
|
"@radix-ui/react-tooltip": "^1.2.16",
|
|
59
|
+
"@react-google-maps/api": "^2.20.8",
|
|
56
60
|
"@tabler/icons-react": "^3.46.0",
|
|
57
61
|
"@types/geojson": "^7946.0.16",
|
|
62
|
+
"@types/google.maps": "^3.58.1",
|
|
58
63
|
"@types/react": "^19.3.0",
|
|
59
64
|
"@types/react-dom": "^19.3.0",
|
|
60
65
|
"class-variance-authority": "^0.7.1",
|
|
@@ -71,5 +76,14 @@
|
|
|
71
76
|
"sonner": "^2.0.8",
|
|
72
77
|
"tailwind-merge": "^3.7.0",
|
|
73
78
|
"vaul": "^1.1.2"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
82
|
+
"@testing-library/react": "^16.3.3",
|
|
83
|
+
"@testing-library/user-event": "^14.6.8",
|
|
84
|
+
"jsdom": "^31.0.0",
|
|
85
|
+
"react": "^19.0.0",
|
|
86
|
+
"react-dom": "^19.0.0",
|
|
87
|
+
"vitest": "^5.1.0"
|
|
74
88
|
}
|
|
75
89
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "city-select",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"geo-search-select",
|
|
8
|
+
"lib-geo-locations",
|
|
9
|
+
"lib-geo-types"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "ui/city-select.tsx",
|
|
14
|
+
"content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport { fetchCities } from \"@/lib/geo-locations\";\nimport type { GeoCityOption, GeoOption } from \"@/lib/geo-types\";\nimport { GeoSearchSelect } from \"@/components/ui/geo-search-select\";\n\nexport type CitySelectProps = {\n id?: string;\n /** ISO-2 del país. */\n countryCode?: string | null;\n /** Código de provincia / estado. */\n stateCode?: string | null;\n /** Código o nombre controlado. `undefined` deja el selector sin controlar; `\"\"` lo deja vacío. */\n value?: string;\n /** Código o nombre inicial cuando `value` no se pasa. */\n defaultValue?: string;\n onChange?: (option: GeoCityOption | null) => void;\n disabled?: boolean;\n className?: string;\n placeholder?: string;\n showCode?: boolean;\n};\n\nexport function CitySelect({\n id,\n countryCode,\n stateCode,\n value,\n defaultValue,\n onChange,\n disabled = false,\n className,\n placeholder = \"Buscar ciudad, pueblo o distrito…\",\n showCode = true,\n}: CitySelectProps) {\n const cc = countryCode?.trim().toUpperCase() ?? \"\";\n const sc = stateCode?.trim() ?? \"\";\n const ready = Boolean(cc && sc);\n const isControlled = value !== undefined;\n const [options, setOptions] = useState<GeoCityOption[]>([]);\n const [isLoading, setIsLoading] = useState(false);\n const [internal, setInternal] = useState(defaultValue ?? \"\");\n const onChangeRef = useRef(onChange);\n const controlledRef = useRef(isControlled);\n const selectionRef = useRef(\"\");\n const prevContext = useRef({ cc, sc });\n\n onChangeRef.current = onChange;\n controlledRef.current = isControlled;\n selectionRef.current = (isControlled ? value : internal) ?? \"\";\n\n useEffect(() => {\n const previous = prevContext.current;\n if (previous.cc === cc && previous.sc === sc) return;\n prevContext.current = { cc, sc };\n if (!previous.cc || !previous.sc) return;\n if (!controlledRef.current) setInternal(\"\");\n if (selectionRef.current) onChangeRef.current?.(null);\n }, [cc, sc]);\n\n useEffect(() => {\n if (!ready) {\n setOptions([]);\n setIsLoading(false);\n return;\n }\n\n let cancelled = false;\n setOptions([]);\n setIsLoading(true);\n void fetchCities(cc, sc)\n .then((list) => {\n if (!cancelled) setOptions(list);\n })\n .finally(() => {\n if (!cancelled) setIsLoading(false);\n });\n\n return () => {\n cancelled = true;\n };\n }, [cc, sc, ready]);\n\n const current = isControlled ? (value ?? \"\") : internal;\n const selected = useMemo(() => {\n if (!current) return undefined;\n return options.find(\n (option) => option.code === current || option.name === current,\n );\n }, [current, options]);\n\n return (\n <GeoSearchSelect\n id={id}\n className={className}\n placeholder={ready ? placeholder : \"Indica país y estado, región o municipio\"}\n searchPlaceholder=\"Buscar ciudad, pueblo o distrito…\"\n emptyMessage=\"Sin ciudades, pueblos ni distritos.\"\n options={options}\n value={selected?.code}\n displayValue={selected?.name}\n onChange={(option: GeoOption) => {\n const city = option as GeoCityOption;\n if (!isControlled) setInternal(city.code);\n onChange?.(city);\n }}\n disabled={disabled || !ready}\n isLoading={ready && isLoading}\n showCode={showCode}\n />\n );\n}\n",
|
|
15
|
+
"type": "registry:ui",
|
|
16
|
+
"target": ""
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "country-select",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"button",
|
|
11
|
+
"command",
|
|
12
|
+
"lib-geo-countries",
|
|
13
|
+
"lib-geo-csc",
|
|
14
|
+
"popover"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "ui/country-select.tsx",
|
|
19
|
+
"content": "\"use client\";\n\nimport { IconCheck, IconSelector } from \"@tabler/icons-react\";\n\nimport { useEffect, useMemo, useState } from \"react\";\nimport { getGeoCountries } from \"@/lib/geo-csc\";\nimport { cn, commandFilterAccentInsensitive } from \"@/lib/utils\";\nimport {\n getCountryByCode,\n mapGeoCountries,\n type CountryOption,\n} from \"@/lib/geo-countries\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\n\nexport type Country = CountryOption;\n\nfunction countryLabel(c: Country, size: \"sm\" | \"md\" | \"lg\"): string {\n if (size === \"sm\") return c.short;\n if (size === \"md\") return c.code;\n return c.long;\n}\n\n/** Bandera local `/assets/flags/{ISO}.svg`; si no existe, no muestra nada. */\nfunction CountryFlag({\n code,\n name,\n}: {\n code: string;\n name: string;\n}) {\n const [visible, setVisible] = useState(true);\n if (!visible || !code) return null;\n\n return (\n <img\n src={`/assets/flags/${code.toUpperCase()}.svg`}\n alt=\"\"\n title={name}\n width={22}\n height={14}\n className=\"h-[14px] w-[22px] shrink-0 rounded object-cover\"\n onError={() => setVisible(false)}\n />\n );\n}\n\nexport type CountrySelectProps = {\n id?: string;\n /** ISO-2 controlado. `undefined` deja el selector sin controlar; `\"\"` lo deja vacío. */\n value?: string;\n /** ISO-2 inicial cuando `value` no se pasa. */\n defaultValue?: string;\n onChange?: (value: Country) => void;\n /** Si se elige la opción vacía (requiere `allowClear`). */\n onClear?: () => void;\n disabled?: boolean;\n fileName?: string;\n className?: string;\n size?: \"sm\" | \"md\" | \"lg\";\n searchPlaceholder?: string;\n /** Si se define, solo se listan estos ISO-2 (en este orden). */\n allowedCodes?: readonly string[];\n allowClear?: boolean;\n clearLabel?: string;\n};\n\nexport function CountrySelect({\n id,\n value,\n defaultValue,\n onChange,\n onClear,\n disabled,\n fileName,\n className,\n size = \"md\",\n searchPlaceholder = \"Escribe o busca un país...\",\n allowedCodes,\n allowClear = false,\n clearLabel = \"Sin país\",\n}: CountrySelectProps) {\n const [open, setOpen] = useState(false);\n const [catalog, setCatalog] = useState<Country[]>([]);\n const [loadingCatalog, setLoadingCatalog] = useState(true);\n const isControlled = value !== undefined;\n const [uncontrolled, setUncontrolled] = useState<Country | undefined>(() =>\n getCountryByCode(defaultValue),\n );\n\n useEffect(() => {\n let cancelled = false;\n setLoadingCatalog(true);\n void (async () => {\n const apiCountries = await getGeoCountries();\n if (cancelled) return;\n setCatalog(mapGeoCountries(apiCountries));\n setLoadingCatalog(false);\n })();\n return () => {\n cancelled = true;\n };\n }, []);\n\n const countries = useMemo(() => {\n if (!allowedCodes?.length) return catalog;\n const byCode = new Map(catalog.map((country) => [country.code, country]));\n return allowedCodes\n .map((code) => byCode.get(code.trim().toUpperCase()))\n .filter((country): country is Country => Boolean(country));\n }, [allowedCodes, catalog]);\n\n const selected = useMemo(() => {\n if (isControlled) {\n const code = value?.trim().toUpperCase() ?? \"\";\n if (!code) return undefined;\n return catalog.find((c) => c.code === code) ?? getCountryByCode(code);\n }\n if (!uncontrolled) return undefined;\n return catalog.find((c) => c.code === uncontrolled.code) ?? uncontrolled;\n }, [catalog, isControlled, uncontrolled, value]);\n\n const placeholder = fileName || \"País\";\n\n const triggerLabel = useMemo(() => {\n if (!selected) return null;\n return countryLabel(selected, size);\n }, [selected, size]);\n\n const handleSelect = (country: Country) => {\n if (!isControlled) setUncontrolled(country);\n setOpen(false);\n onChange?.(country);\n };\n\n const handleClear = () => {\n if (!isControlled) setUncontrolled(undefined);\n setOpen(false);\n onClear?.();\n };\n\n return (\n <Popover open={open} onOpenChange={disabled ? undefined : setOpen}>\n <PopoverTrigger asChild>\n <Button\n id={id}\n type=\"button\"\n variant=\"outline\"\n disabled={disabled}\n role=\"combobox\"\n aria-expanded={open}\n className={cn(\n \"h-10 w-full justify-between gap-2 font-normal\",\n className,\n )}\n >\n {selected && triggerLabel ? (\n <span className=\"flex min-w-0 flex-1 items-center gap-2 overflow-hidden\">\n <CountryFlag code={selected.code} name={selected.long} />\n <span className=\"truncate\">{triggerLabel}</span>\n </span>\n ) : (\n <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n )}\n <IconSelector className=\"size-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n\n {!disabled && (\n <PopoverContent className=\"w-[var(--radix-popover-trigger-width)] p-0\" align=\"start\">\n <Command\n className=\"border border-border\"\n filter={commandFilterAccentInsensitive}\n >\n <CommandInput placeholder={searchPlaceholder} />\n <CommandList>\n <CommandEmpty>\n {loadingCatalog\n ? \"Cargando países…\"\n : \"No se encontró el país.\"}\n </CommandEmpty>\n {allowClear ? (\n <CommandGroup>\n <CommandItem\n value={`${clearLabel} sin pais none`}\n onSelect={handleClear}\n className=\"gap-2\"\n >\n <span className=\"min-w-0 flex-1 truncate\">{clearLabel}</span>\n {!selected ? (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n ) : null}\n </CommandItem>\n </CommandGroup>\n ) : null}\n {allowClear ? <CommandSeparator /> : null}\n <CommandGroup>\n {countries.map((c) => (\n <CommandItem\n key={c.code}\n value={`${c.long} ${c.code} ${c.short}`}\n onSelect={() => handleSelect(c)}\n className=\"gap-2\"\n >\n <CountryFlag code={c.code} name={c.long} />\n <span className=\"min-w-0 flex-1 truncate\">{c.long}</span>\n <span className=\"text-xs text-muted-foreground\">{c.code}</span>\n {selected?.code === c.code ? (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n ) : null}\n </CommandItem>\n ))}\n </CommandGroup>\n </CommandList>\n </Command>\n </PopoverContent>\n )}\n </Popover>\n );\n}\n",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"target": ""
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "geo-search-select",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@tabler/icons-react"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"button",
|
|
11
|
+
"command",
|
|
12
|
+
"lib-geo-types",
|
|
13
|
+
"popover",
|
|
14
|
+
"spinner"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "ui/geo-search-select.tsx",
|
|
19
|
+
"content": "\"use client\";\n\nimport { IconCheck, IconSelector } from \"@tabler/icons-react\";\nimport { useMemo, useState } from \"react\";\nimport { cn, commandFilterAccentInsensitive } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Spinner } from \"@/components/ui/spinner\";\nimport type { GeoOption } from \"@/lib/geo-types\";\n\ntype GeoSearchSelectProps = {\n id?: string;\n label?: string;\n placeholder?: string;\n searchPlaceholder?: string;\n emptyMessage?: string;\n options: GeoOption[];\n value?: string;\n displayValue?: string;\n onChange?: (option: GeoOption) => void;\n disabled?: boolean;\n isLoading?: boolean;\n className?: string;\n showCode?: boolean;\n};\n\nexport function GeoSearchSelect({\n id,\n placeholder = \"Seleccionar…\",\n searchPlaceholder = \"Buscar…\",\n emptyMessage = \"No se encontraron resultados.\",\n options,\n value,\n displayValue,\n onChange,\n disabled = false,\n isLoading = false,\n className,\n showCode = false,\n}: GeoSearchSelectProps) {\n const [open, setOpen] = useState(false);\n\n const selected = useMemo(() => {\n if (!value && !displayValue) return undefined;\n if (value) {\n return options.find((o) => o.code === value || o.name === value);\n }\n return options.find((o) => o.name === displayValue);\n }, [options, value, displayValue]);\n\n const triggerLabel = selected?.name ?? displayValue;\n\n return (\n <Popover modal open={open} onOpenChange={disabled ? undefined : setOpen}>\n <PopoverTrigger asChild>\n <Button\n id={id}\n type=\"button\"\n variant=\"outline\"\n disabled={disabled || isLoading}\n role=\"combobox\"\n aria-expanded={open}\n className={cn(\n \"h-10 w-full justify-between gap-2 font-normal\",\n className,\n )}\n >\n {isLoading ? (\n <span className=\"flex items-center gap-2 text-muted-foreground\">\n <Spinner className=\"size-4\" />\n Cargando…\n </span>\n ) : triggerLabel ? (\n <span className=\"truncate\">{triggerLabel}</span>\n ) : (\n <span className=\"truncate text-muted-foreground\">{placeholder}</span>\n )}\n <IconSelector className=\"size-4 shrink-0 opacity-50\" />\n </Button>\n </PopoverTrigger>\n\n {!disabled && !isLoading && (\n <PopoverContent\n className=\"w-[var(--radix-popover-trigger-width)] p-0\"\n align=\"start\"\n >\n <Command\n className=\"border border-border\"\n filter={commandFilterAccentInsensitive}\n >\n <CommandInput placeholder={searchPlaceholder} />\n <CommandList>\n <CommandEmpty>{emptyMessage}</CommandEmpty>\n <CommandGroup>\n {options.map((option) => (\n <CommandItem\n key={`${option.code}-${option.name}`}\n value={`${option.name} ${option.code}`}\n onSelect={() => {\n onChange?.(option);\n setOpen(false);\n }}\n className=\"gap-2\"\n >\n <span className=\"min-w-0 flex-1 truncate\">{option.name}</span>\n {showCode ? (\n <span className=\"text-xs text-muted-foreground\">\n {option.code}\n </span>\n ) : null}\n {selected?.code === option.code &&\n selected?.name === option.name ? (\n <IconCheck className=\"size-4 shrink-0 text-primary\" />\n ) : null}\n </CommandItem>\n ))}\n </CommandGroup>\n </CommandList>\n </Command>\n </PopoverContent>\n )}\n </Popover>\n );\n}\n",
|
|
20
|
+
"type": "registry:ui",
|
|
21
|
+
"target": ""
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "geo-selectors",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"registryDependencies": [
|
|
7
|
+
"city-select",
|
|
8
|
+
"country-select",
|
|
9
|
+
"label",
|
|
10
|
+
"lib-geo-types",
|
|
11
|
+
"state-select"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "ui/geo-selectors.tsx",
|
|
16
|
+
"content": "\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport { CountrySelect, type Country } from \"@/components/ui/country-select\";\nimport { StateSelect } from \"@/components/ui/state-select\";\nimport { CitySelect } from \"@/components/ui/city-select\";\nimport type { GeoCityOption, GeoOption } from \"@/lib/geo-types\";\nimport { cn } from \"@/lib/utils\";\nimport { Label } from \"@/components/ui/label\";\n\nexport type GeoSelectorLevel = \"country\" | \"state\" | \"city\";\n\nexport type GeoSelectorsValue = {\n country: Country | null;\n state: GeoOption | null;\n city: GeoCityOption | null;\n};\n\nconst ALL_LEVELS: readonly GeoSelectorLevel[] = [\"country\", \"state\", \"city\"];\n\nconst EMPTY_VALUE: GeoSelectorsValue = {\n country: null,\n state: null,\n city: null,\n};\n\ntype GeoSelectorsProps = {\n /**\n * Niveles visibles. Omite los que no quieras.\n * Si no está \"country\", pasa `countryCode`.\n * Si no está \"state\" y sí \"city\", pasa `stateCode`.\n */\n levels?: readonly GeoSelectorLevel[];\n /** País fijo cuando el select de país no se muestra. */\n countryCode?: string | null;\n /** Provincia fija cuando el select de provincia no se muestra. */\n stateCode?: string | null;\n onChange?: (value: GeoSelectorsValue) => void;\n disabled?: boolean;\n className?: string;\n idPrefix?: string;\n};\n\nexport function GeoSelectors({\n levels = ALL_LEVELS,\n countryCode,\n stateCode,\n onChange,\n disabled = false,\n className,\n idPrefix = \"geo\",\n}: GeoSelectorsProps) {\n const showCountry = levels.includes(\"country\");\n const showState = levels.includes(\"state\");\n const showCity = levels.includes(\"city\");\n const [selection, setSelection] = useState<GeoSelectorsValue>(EMPTY_VALUE);\n\n const resolvedCountry = showCountry\n ? (selection.country?.code ?? \"\")\n : (countryCode?.trim().toUpperCase() ?? \"\");\n const resolvedState = showState\n ? (selection.state?.code ?? \"\")\n : (stateCode?.trim() ?? \"\");\n\n const externalCountry = countryCode?.trim().toUpperCase() ?? \"\";\n const externalState = stateCode?.trim() ?? \"\";\n\n useEffect(() => {\n if (showCountry) return;\n setSelection((prev) => {\n if (!prev.state && !prev.city) return prev;\n return { ...prev, state: null, city: null };\n });\n }, [externalCountry, showCountry]);\n\n useEffect(() => {\n if (showState) return;\n setSelection((prev) => {\n if (!prev.city) return prev;\n return { ...prev, city: null };\n });\n }, [externalState, showState]);\n\n const publish = (next: GeoSelectorsValue) => {\n setSelection(next);\n onChange?.(next);\n };\n\n const visible = [showCountry, showState, showCity].filter(Boolean).length;\n if (visible === 0) return null;\n\n return (\n <div\n className={cn(\n \"grid gap-4\",\n visible >= 3\n ? \"md:grid-cols-3\"\n : visible === 2\n ? \"md:grid-cols-2\"\n : \"max-w-sm\",\n className,\n )}\n >\n {showCountry ? (\n <div className=\"space-y-1\">\n <Label htmlFor={`${idPrefix}-country`}>País</Label>\n <CountrySelect\n id={`${idPrefix}-country`}\n className=\"w-full\"\n size=\"lg\"\n value={selection.country?.code}\n disabled={disabled}\n onChange={(country) =>\n publish({ country, state: null, city: null })\n }\n allowClear\n onClear={() => publish(EMPTY_VALUE)}\n />\n </div>\n ) : null}\n\n {showState ? (\n <div className=\"space-y-1\">\n <Label htmlFor={`${idPrefix}-state`}>Estado / Región / Municipio</Label>\n <StateSelect\n id={`${idPrefix}-state`}\n countryCode={resolvedCountry}\n value={selection.state?.code ?? \"\"}\n disabled={disabled}\n onChange={(state) =>\n publish({\n country: selection.country,\n state,\n city: null,\n })\n }\n />\n </div>\n ) : null}\n\n {showCity ? (\n <div className=\"space-y-1\">\n <Label htmlFor={`${idPrefix}-city`}>Ciudad / Pueblo / Distrito</Label>\n <CitySelect\n id={`${idPrefix}-city`}\n countryCode={resolvedCountry}\n stateCode={resolvedState}\n value={selection.city?.code ?? selection.city?.name ?? \"\"}\n disabled={disabled}\n onChange={(city) =>\n publish({\n country: selection.country,\n state: selection.state,\n city,\n })\n }\n />\n </div>\n ) : null}\n </div>\n );\n}\n",
|
|
17
|
+
"type": "registry:ui",
|
|
18
|
+
"target": ""
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "google-maps-address-picker",
|
|
4
|
+
"type": "registry:ui",
|
|
5
|
+
"author": "Traky (https://traky.app)",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@react-google-maps/api",
|
|
8
|
+
"@tabler/icons-react"
|
|
9
|
+
],
|
|
10
|
+
"registryDependencies": [
|
|
11
|
+
"label",
|
|
12
|
+
"lib-google-maps-address"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "ui/google-maps-address-picker.tsx",
|
|
17
|
+
"content": "\"use client\"\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useJsApiLoader, GoogleMap, Marker } from \"@react-google-maps/api\"\nimport { IconLoader } from \"@tabler/icons-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n parseGoogleAddressComponents,\n type GoogleMapsAddressSelection,\n} from \"@/lib/google-maps-address\"\nimport { Label } from \"@/components/ui/label\"\n\nconst MAP_LIBRARIES: (\"places\")[] = [\"places\"]\nconst DEFAULT_CENTER = { lat: -1.8312, lng: -78.1834 }\nconst DEFAULT_ZOOM = 6\nconst SELECTED_ZOOM = 16\nconst MAP_CONTAINER_STYLE = { width: \"100%\", height: \"min(52dvh, 360px)\" }\n\nfunction applyGooglePlacesLightTheme(\n element: google.maps.places.PlaceAutocompleteElement,\n) {\n element.style.setProperty(\"color-scheme\", \"light\")\n element.style.setProperty(\"background-color\", \"#ffffff\")\n element.style.setProperty(\"color\", \"#141414\")\n element.style.setProperty(\"border\", \"1px solid #e2e8f0\")\n element.style.setProperty(\"border-radius\", \"0.375rem\")\n element.style.setProperty(\"width\", \"100%\")\n element.style.setProperty(\"min-height\", \"2.75rem\")\n element.style.setProperty(\"font-size\", \"0.875rem\")\n}\n\nfunction parsePlaceInstance(\n place: google.maps.places.Place,\n): GoogleMapsAddressSelection | null {\n const lat = place.location?.lat()\n const lng = place.location?.lng()\n if (lat == null || lng == null) return null\n\n const parsed = parseGoogleAddressComponents(\n place.addressComponents as Parameters<\n typeof parseGoogleAddressComponents\n >[0],\n place.displayName || place.formattedAddress || \"\",\n lat,\n lng,\n )\n if (!parsed) return null\n return {\n ...parsed,\n formattedAddress: place.formattedAddress?.trim() || undefined,\n }\n}\n\nexport type GoogleMapsAddressPickerProps = {\n onLocationSelect: (address: GoogleMapsAddressSelection) => void\n onLocationClear?: () => void\n regionCode?: string\n className?: string\n initialLatitude?: number\n initialLongitude?: number\n initialFormattedAddress?: string\n /** Prioridad sobre `NEXT_PUBLIC_GOOGLE_MAPS_API_KEY`. */\n apiKey?: string\n}\n\nfunction GoogleMapsAddressPickerInner({\n onLocationSelect,\n onLocationClear,\n regionCode,\n className,\n initialLatitude,\n initialLongitude,\n initialFormattedAddress,\n}: Omit<GoogleMapsAddressPickerProps, \"apiKey\">) {\n const autocompleteHostRef = useRef<HTMLDivElement>(null)\n const autocompleteElementRef =\n useRef<google.maps.places.PlaceAutocompleteElement | null>(null)\n const geocoderRef = useRef<google.maps.Geocoder | null>(null)\n\n const [searchLabel, setSearchLabel] = useState(\"\")\n const [isGeocoding, setIsGeocoding] = useState(false)\n const [geocodeError, setGeocodeError] = useState<string | null>(null)\n const [marker, setMarker] = useState<google.maps.LatLngLiteral | null>(() => {\n if (\n typeof initialLatitude === \"number\" &&\n typeof initialLongitude === \"number\"\n ) {\n return { lat: initialLatitude, lng: initialLongitude }\n }\n return null\n })\n const [mapCenter, setMapCenter] = useState<google.maps.LatLngLiteral>(\n marker ?? DEFAULT_CENTER,\n )\n const [mapZoom, setMapZoom] = useState(marker ? SELECTED_ZOOM : DEFAULT_ZOOM)\n\n useEffect(() => {\n const lat =\n typeof initialLatitude === \"number\" && Number.isFinite(initialLatitude)\n ? initialLatitude\n : null\n const lng =\n typeof initialLongitude === \"number\" && Number.isFinite(initialLongitude)\n ? initialLongitude\n : null\n if (lat == null || lng == null) return\n const position = { lat, lng }\n setMarker(position)\n setMapCenter(position)\n setMapZoom(SELECTED_ZOOM)\n }, [initialLatitude, initialLongitude])\n\n useEffect(() => {\n const label = initialFormattedAddress?.trim()\n if (!label) return\n setSearchLabel(label)\n if (autocompleteElementRef.current) {\n autocompleteElementRef.current.value = label\n }\n }, [initialFormattedAddress])\n\n const mapOptions = useMemo<google.maps.MapOptions>(\n () => ({\n streetViewControl: false,\n mapTypeControl: false,\n fullscreenControl: false,\n zoomControl: true,\n }),\n [],\n )\n\n const applySelection = useCallback(\n (address: GoogleMapsAddressSelection, label: string) => {\n const position = { lat: address.latitude, lng: address.longitude }\n setMarker(position)\n setMapCenter(position)\n setMapZoom(SELECTED_ZOOM)\n setSearchLabel(label)\n setGeocodeError(null)\n if (autocompleteElementRef.current) {\n autocompleteElementRef.current.value = label\n }\n onLocationSelect({\n ...address,\n formattedAddress: label.trim() || address.formattedAddress,\n })\n },\n [onLocationSelect],\n )\n\n const reverseGeocode = useCallback(\n (position: google.maps.LatLngLiteral) =>\n new Promise<{ address: GoogleMapsAddressSelection; label: string } | null>(\n (resolve) => {\n if (!geocoderRef.current) {\n geocoderRef.current = new google.maps.Geocoder()\n }\n geocoderRef.current.geocode({ location: position }, (results, status) => {\n if (status !== \"OK\" || !results?.[0]) {\n resolve(null)\n return\n }\n const result = results[0]\n const parsed = parseGoogleAddressComponents(\n result.address_components,\n result.formatted_address || \"\",\n position.lat,\n position.lng,\n )\n if (!parsed) {\n resolve(null)\n return\n }\n const label = result.formatted_address?.trim() || \"\"\n resolve({\n address: {\n ...parsed,\n formattedAddress: label || undefined,\n },\n label,\n })\n })\n },\n ),\n [],\n )\n\n const handlePositionChange = useCallback(\n async (position: google.maps.LatLngLiteral) => {\n setMarker(position)\n setMapCenter(position)\n setMapZoom(SELECTED_ZOOM)\n setIsGeocoding(true)\n setGeocodeError(null)\n\n const resolved = await reverseGeocode(position)\n setIsGeocoding(false)\n\n if (resolved) {\n applySelection(resolved.address, resolved.label)\n return\n }\n\n setGeocodeError(\n \"No pudimos resolver esa ubicación. Elige una dirección en el buscador o activa Geocoding API en Google Cloud.\",\n )\n },\n [applySelection, reverseGeocode],\n )\n\n useEffect(() => {\n const host = autocompleteHostRef.current\n if (!host) return\n\n let cancelled = false\n let placeAutocomplete: google.maps.places.PlaceAutocompleteElement | null =\n null\n\n const onSelect: EventListener = (event) => {\n void (async () => {\n const selectEvent =\n event as google.maps.places.PlacePredictionSelectEvent\n const place = selectEvent.placePrediction.toPlace()\n await place.fetchFields({\n fields: [\n \"displayName\",\n \"formattedAddress\",\n \"location\",\n \"addressComponents\",\n ],\n })\n\n const parsed = parsePlaceInstance(place)\n if (!parsed) return\n\n const label =\n place.formattedAddress?.trim() ||\n [\n parsed.streetNumber,\n parsed.street,\n parsed.city,\n parsed.state,\n parsed.postalCode,\n ]\n .filter(Boolean)\n .join(\", \")\n\n applySelection(parsed, label)\n })()\n }\n\n void (async () => {\n await google.maps.importLibrary(\"places\")\n\n if (cancelled || !autocompleteHostRef.current) return\n\n const region = regionCode?.trim().toLowerCase()\n placeAutocomplete = new google.maps.places.PlaceAutocompleteElement({\n ...(region ? { includedRegionCodes: [region] } : {}),\n placeholder: \"Busca tu dirección en Google Maps…\",\n })\n\n placeAutocomplete.className = \"gmp-place-autocomplete-host block w-full\"\n applyGooglePlacesLightTheme(placeAutocomplete)\n\n const savedLabel = initialFormattedAddress?.trim()\n if (savedLabel) {\n placeAutocomplete.value = savedLabel\n setSearchLabel(savedLabel)\n }\n\n host.replaceChildren(placeAutocomplete)\n autocompleteElementRef.current = placeAutocomplete\n placeAutocomplete.addEventListener(\"gmp-select\", onSelect)\n })()\n\n return () => {\n cancelled = true\n if (placeAutocomplete) {\n placeAutocomplete.removeEventListener(\"gmp-select\", onSelect)\n }\n host.replaceChildren()\n autocompleteElementRef.current = null\n }\n }, [applySelection, initialFormattedAddress, regionCode])\n\n const handleClear = () => {\n setMarker(null)\n setSearchLabel(\"\")\n setGeocodeError(null)\n setIsGeocoding(false)\n setMapCenter(DEFAULT_CENTER)\n setMapZoom(DEFAULT_ZOOM)\n if (autocompleteElementRef.current) {\n autocompleteElementRef.current.value = \"\"\n }\n onLocationClear?.()\n }\n\n return (\n <div className={cn(\"space-y-3\", className)}>\n <div className=\"space-y-1.5\">\n <Label>Buscar dirección</Label>\n <div\n ref={autocompleteHostRef}\n className=\"gmp-autocomplete-wrapper w-full rounded-md bg-background\"\n />\n </div>\n\n <div className=\"overflow-hidden rounded-lg border border-border\">\n <GoogleMap\n mapContainerStyle={MAP_CONTAINER_STYLE}\n center={mapCenter}\n zoom={mapZoom}\n onClick={(event) => {\n const latLng = event.latLng\n if (!latLng) return\n void handlePositionChange({ lat: latLng.lat(), lng: latLng.lng() })\n }}\n options={mapOptions}\n >\n {marker ? (\n <Marker\n position={marker}\n draggable\n onDragEnd={(event) => {\n const latLng = event.latLng\n if (!latLng) return\n void handlePositionChange({\n lat: latLng.lat(),\n lng: latLng.lng(),\n })\n }}\n />\n ) : null}\n </GoogleMap>\n </div>\n\n {geocodeError ? (\n <p className=\"text-xs text-destructive\">{geocodeError}</p>\n ) : null}\n\n {marker ? (\n <div className=\"flex flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground\">\n <span className=\"min-w-0 break-words\">\n {isGeocoding ? (\n <>\n <IconLoader className=\"mr-1 inline size-3 animate-spin\" />\n Actualizando dirección…\n </>\n ) : (\n searchLabel ||\n `${marker.lat.toFixed(5)}, ${marker.lng.toFixed(5)}`\n )}\n </span>\n <button\n type=\"button\"\n className=\"shrink-0 text-primary underline underline-offset-2\"\n onClick={handleClear}\n >\n Limpiar\n </button>\n </div>\n ) : (\n <p className=\"text-xs text-muted-foreground\">\n Busca una dirección o haz clic en el mapa para fijar el punto.\n </p>\n )}\n </div>\n )\n}\n\nfunction resolveGoogleMapsApiKey(apiKey?: string): string {\n if (apiKey?.trim()) return apiKey.trim()\n if (typeof process !== \"undefined\" && process.env) {\n return process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?.trim() ?? \"\"\n }\n return \"\"\n}\n\nexport function GoogleMapsAddressPicker({\n apiKey,\n ...props\n}: GoogleMapsAddressPickerProps) {\n const resolvedKey = resolveGoogleMapsApiKey(apiKey)\n\n const { isLoaded, loadError } = useJsApiLoader({\n googleMapsApiKey: resolvedKey,\n libraries: MAP_LIBRARIES,\n })\n\n if (!resolvedKey) {\n return (\n <div className=\"rounded-lg border border-dashed border-border bg-muted/30 p-4 text-sm text-muted-foreground\">\n Configura{\" \"}\n <code className=\"text-xs\">NEXT_PUBLIC_GOOGLE_MAPS_API_KEY</code> (solo\n desarrollo) para usar búsqueda y mapa de Google.\n </div>\n )\n }\n\n if (loadError) {\n return (\n <div className=\"rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive\">\n No se pudo cargar Google Maps. Habilita Maps JavaScript API, Places API\n (New) y Geocoding API para esta clave.\n </div>\n )\n }\n\n if (!isLoaded) {\n return (\n <div className=\"flex h-[min(52dvh,360px)] items-center justify-center rounded-lg border bg-muted/30 text-sm text-muted-foreground\">\n <IconLoader className=\"mr-2 size-4 animate-spin\" />\n Cargando mapa…\n </div>\n )\n }\n\n return <GoogleMapsAddressPickerInner {...props} />\n}\n",
|
|
18
|
+
"type": "registry:ui",
|
|
19
|
+
"target": ""
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
{
|
|
11
11
|
"path": "ui/internal-chat-mention.ts",
|
|
12
|
-
"content": "import type { Icon } from \"@tabler/icons-react\";\n\nexport type InternalChatMentionKind = \"guide\" | \"package\";\n\nexport type InternalChatMentionItem = {\n id: string;\n kind: InternalChatMentionKind;\n label: string;\n subtitle: string;\n insertLabel: string;\n icon: Icon;\n};\n\nexport type InternalChatMentionCategoryMeta = {\n label: string;\n hint: string;\n keywords: string[];\n};\n\nexport type InternalChatMentionListEntry =\n | {\n type: \"category\";\n kind: InternalChatMentionKind;\n label: string;\n hint: string;\n icon: Icon;\n }\n | { type: \"item\"; data: InternalChatMentionItem };\n\nexport const DEFAULT_MENTION_KIND_ORDER: InternalChatMentionKind[] = [\n \"guide\",\n \"package\",\n];\n\nexport const DEFAULT_MENTION_KIND_LABEL: Record<\n InternalChatMentionKind,\n string\n> = {\n guide: \"Guías\",\n package: \"Paquetes\",\n};\n\nexport type InternalChatMentionConfig = {\n kindOrder: InternalChatMentionKind[];\n categoryMeta: Record<\n InternalChatMentionKind,\n InternalChatMentionCategoryMeta\n >;\n kindIcons: Record<InternalChatMentionKind, Icon>;\n kindAliases?: Record<string, InternalChatMentionKind>;\n};\n\nfunction resolveKindAlias(\n raw: string,\n config: InternalChatMentionConfig,\n): InternalChatMentionKind | null {\n const s = raw.trim().toLowerCase();\n if (!s) return null;\n\n const aliases: Record<string, InternalChatMentionKind> = {\n guide: \"guide\",\n guía: \"guide\",\n guia: \"guide\",\n paquete: \"package\",\n paquetes: \"package\",\n package: \"package\",\n ...config.kindAliases,\n };\n\n const direct = aliases[s];\n if (direct) return direct;\n\n for (const kind of config.kindOrder) {\n const meta = config.categoryMeta[kind];\n if (kind === s) return kind;\n if (meta.label.toLowerCase() === s) return kind;\n if (meta.keywords.some((k) => k === s)) return kind;\n }\n\n return null;\n}\n\nexport function parseMentionSegment(\n query: string,\n config: InternalChatMentionConfig,\n):\n | { phase: \"category\"; filter: string }\n | { phase: \"item\"; kind: InternalChatMentionKind; filter: string } {\n const colon = query.indexOf(\":\");\n if (colon === -1) {\n return { phase: \"category\", filter: query };\n }\n const head = query.slice(0, colon).trim().toLowerCase();\n const tail = query.slice(colon + 1);\n if (head === \"\") {\n return { phase: \"category\", filter: query.replace(\":\", \"\") };\n }\n const kind = resolveKindAlias(head, config);\n if (!kind) {\n return { phase: \"category\", filter: query };\n }\n return { phase: \"item\", kind, filter: tail };\n}\n\nexport function parseSlashMention(\n draft: string,\n cursor: number,\n): { start: number; query: string } | null {\n const before = draft.slice(0, cursor);\n const lastSlash = before.lastIndexOf(\"/\");\n if (lastSlash === -1) return null;\n const charBefore = lastSlash === 0 ? \" \" : draft[lastSlash - 1];\n if (charBefore !== \" \" && charBefore !== \"\\n\" && lastSlash !== 0) {\n return null;\n }\n const afterSlash = before.slice(lastSlash + 1);\n if (afterSlash.includes(\"\\n\")) return null;\n return { start: lastSlash, query: afterSlash };\n}\n\nfunction filterCategoryEntries(\n filterText: string,\n config: InternalChatMentionConfig,\n): InternalChatMentionListEntry[] {\n const q = filterText.trim().toLowerCase();\n const rows: InternalChatMentionListEntry[] = config.kindOrder.map((kind) => ({\n type: \"category\",\n kind,\n label: config.categoryMeta[kind].label,\n hint: config.categoryMeta[kind].hint,\n icon: config.kindIcons[kind],\n }));\n if (!q) return rows;\n const byAlias = resolveKindAlias(q, config);\n return rows.filter((row) => {\n if (row.type !== \"category\") return false;\n if (byAlias && row.kind === byAlias) return true;\n return (\n row.label.toLowerCase().includes(q) ||\n row.hint.toLowerCase().includes(q) ||\n config.categoryMeta[row.kind].keywords.some(\n (k) => k.includes(q) || q.includes(k),\n )\n );\n });\n}\n\nfunction filterItemsByKind(\n kind: InternalChatMentionKind,\n filterText: string,\n catalog: InternalChatMentionItem[],\n): InternalChatMentionListEntry[] {\n const q = filterText.trim().toLowerCase();\n let items = catalog.filter((i) => i.kind === kind);\n if (q) {\n items = items.filter(\n (item) =>\n item.label.toLowerCase().includes(q) ||\n item.subtitle.toLowerCase().includes(q) ||\n item.insertLabel.toLowerCase().includes(q),\n );\n }\n return items.map((data) => ({ type: \"item\", data }));\n}\n\nexport function buildMentionList(\n query: string,\n guideItems: InternalChatMentionItem[],\n packageItems: InternalChatMentionItem[],\n config: InternalChatMentionConfig,\n): InternalChatMentionListEntry[] {\n const seg = parseMentionSegment(query, config);\n if (seg.phase === \"category\") {\n return filterCategoryEntries(seg.filter, config);\n }\n const pool = seg.kind === \"guide\" ? guideItems : packageItems;\n return filterItemsByKind(seg.kind
|
|
12
|
+
"content": "import type { Icon } from \"@tabler/icons-react\";\n\nexport type InternalChatMentionKind = \"guide\" | \"package\" | \"ai\";\n\nexport type InternalChatMentionItem = {\n id: string;\n kind: InternalChatMentionKind;\n label: string;\n subtitle: string;\n insertLabel: string;\n icon: Icon;\n};\n\nexport type InternalChatMentionCategoryMeta = {\n label: string;\n hint: string;\n keywords: string[];\n /** Visible en el menú de tipo pero no seleccionable (p. ej. próximamente). */\n disabled?: boolean;\n};\n\nexport type InternalChatMentionListEntry =\n | {\n type: \"category\";\n kind: InternalChatMentionKind;\n label: string;\n hint: string;\n icon: Icon;\n disabled?: boolean;\n }\n | { type: \"item\"; data: InternalChatMentionItem };\n\nexport type InternalChatMentionCatalogState = {\n open: boolean;\n phase: \"category\" | \"item\";\n kind?: InternalChatMentionKind;\n filter: string;\n};\n\nexport type BuildMentionListOptions = {\n /** Si true, los ítems ya vienen filtrados del servidor (sin filtro local por texto). */\n serverFilteredItems?: boolean;\n};\n\nexport const DEFAULT_MENTION_KIND_ORDER: InternalChatMentionKind[] = [\n \"guide\",\n \"package\",\n \"ai\",\n];\n\nexport const DEFAULT_MENTION_KIND_LABEL: Record<\n InternalChatMentionKind,\n string\n> = {\n guide: \"Guías\",\n package: \"Paquetes\",\n ai: \"Asistencia de IA\",\n};\n\nexport type InternalChatMentionConfig = {\n kindOrder: InternalChatMentionKind[];\n categoryMeta: Record<\n InternalChatMentionKind,\n InternalChatMentionCategoryMeta\n >;\n kindIcons: Record<InternalChatMentionKind, Icon>;\n kindAliases?: Record<string, InternalChatMentionKind>;\n};\n\nfunction resolveKindAlias(\n raw: string,\n config: InternalChatMentionConfig,\n): InternalChatMentionKind | null {\n const s = raw.trim().toLowerCase();\n if (!s) return null;\n\n const aliases: Record<string, InternalChatMentionKind> = {\n guide: \"guide\",\n guía: \"guide\",\n guia: \"guide\",\n paquete: \"package\",\n paquetes: \"package\",\n package: \"package\",\n ia: \"ai\",\n ai: \"ai\",\n asistencia: \"ai\",\n ...config.kindAliases,\n };\n\n const direct = aliases[s];\n if (direct) return direct;\n\n for (const kind of config.kindOrder) {\n const meta = config.categoryMeta[kind];\n if (kind === s) return kind;\n if (meta.label.toLowerCase() === s) return kind;\n if (meta.keywords.some((k) => k === s)) return kind;\n }\n\n return null;\n}\n\nexport function parseMentionSegment(\n query: string,\n config: InternalChatMentionConfig,\n):\n | { phase: \"category\"; filter: string }\n | { phase: \"item\"; kind: InternalChatMentionKind; filter: string } {\n const colon = query.indexOf(\":\");\n if (colon === -1) {\n return { phase: \"category\", filter: query };\n }\n const head = query.slice(0, colon).trim().toLowerCase();\n const tail = query.slice(colon + 1);\n if (head === \"\") {\n return { phase: \"category\", filter: query.replace(\":\", \"\") };\n }\n const kind = resolveKindAlias(head, config);\n if (!kind) {\n return { phase: \"category\", filter: query };\n }\n return { phase: \"item\", kind, filter: tail };\n}\n\nexport function parseSlashMention(\n draft: string,\n cursor: number,\n): { start: number; query: string } | null {\n const before = draft.slice(0, cursor);\n const lastSlash = before.lastIndexOf(\"/\");\n if (lastSlash === -1) return null;\n const charBefore = lastSlash === 0 ? \" \" : draft[lastSlash - 1];\n if (charBefore !== \" \" && charBefore !== \"\\n\" && lastSlash !== 0) {\n return null;\n }\n const afterSlash = before.slice(lastSlash + 1);\n if (afterSlash.includes(\"\\n\")) return null;\n return { start: lastSlash, query: afterSlash };\n}\n\nfunction filterCategoryEntries(\n filterText: string,\n config: InternalChatMentionConfig,\n): InternalChatMentionListEntry[] {\n const q = filterText.trim().toLowerCase();\n const rows: InternalChatMentionListEntry[] = config.kindOrder.map((kind) => ({\n type: \"category\",\n kind,\n label: config.categoryMeta[kind].label,\n hint: config.categoryMeta[kind].hint,\n icon: config.kindIcons[kind],\n disabled: config.categoryMeta[kind].disabled,\n }));\n if (!q) return rows;\n const byAlias = resolveKindAlias(q, config);\n return rows.filter((row) => {\n if (row.type !== \"category\") return false;\n if (byAlias && row.kind === byAlias) return true;\n return (\n row.label.toLowerCase().includes(q) ||\n row.hint.toLowerCase().includes(q) ||\n config.categoryMeta[row.kind].keywords.some(\n (k) => k.includes(q) || q.includes(k),\n )\n );\n });\n}\n\nfunction filterItemsByKind(\n kind: InternalChatMentionKind,\n filterText: string,\n catalog: InternalChatMentionItem[],\n skipTextFilter = false,\n): InternalChatMentionListEntry[] {\n const q = filterText.trim().toLowerCase();\n let items = catalog.filter((i) => i.kind === kind);\n if (!skipTextFilter && q) {\n items = items.filter(\n (item) =>\n item.label.toLowerCase().includes(q) ||\n item.subtitle.toLowerCase().includes(q) ||\n item.insertLabel.toLowerCase().includes(q),\n );\n }\n return items.map((data) => ({ type: \"item\", data }));\n}\n\nexport function buildMentionList(\n query: string,\n guideItems: InternalChatMentionItem[],\n packageItems: InternalChatMentionItem[],\n config: InternalChatMentionConfig,\n options?: BuildMentionListOptions,\n): InternalChatMentionListEntry[] {\n const seg = parseMentionSegment(query, config);\n if (seg.phase === \"category\") {\n return filterCategoryEntries(seg.filter, config);\n }\n if (seg.kind === \"ai\" || config.categoryMeta[seg.kind]?.disabled) {\n return [];\n }\n const pool = seg.kind === \"guide\" ? guideItems : packageItems;\n return filterItemsByKind(\n seg.kind,\n seg.filter,\n pool,\n Boolean(options?.serverFilteredItems),\n );\n}\n\nexport function roomInitialLetter(name: string): string {\n const t = name.trim();\n if (!t) return \"?\";\n const ch = [...t][0];\n return ch && /\\p{L}/u.test(ch) ? ch.toUpperCase() : \"?\";\n}\n\nexport function initialsFromDisplayName(name: string): string {\n const t = name.trim();\n if (!t) return \"?\";\n const parts = t.split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return \"?\";\n if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase();\n return `${parts[0]![0]!}${parts[parts.length - 1]![0]!}`.toUpperCase();\n}\n\nexport function formatMessageTime(iso: string, locale = \"es\"): string {\n return new Intl.DateTimeFormat(locale, {\n hour: \"2-digit\",\n minute: \"2-digit\",\n hour12: false,\n }).format(new Date(iso));\n}\n",
|
|
13
13
|
"type": "registry:ui",
|
|
14
14
|
"target": ""
|
|
15
15
|
}
|
|
@@ -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 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 InternalChatMentionConfig,\n type InternalChatMentionItem,\n type InternalChatMentionKind,\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 },\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 },\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 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 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 mentionEntries = useMemo(\n () =>\n buildMentionList(\n mentionQuery,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n ),\n [mentionQuery, mentionGuideItems, mentionPackageItems, mentionConfig],\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\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 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 entries = buildMentionList(\n m.query,\n mentionGuideItems,\n mentionPackageItems,\n mentionConfig,\n );\n if (entries.length === 0) {\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 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 {mentionEntries.map((entry, index) => {\n if (entry.type === \"category\") {\n const IconComp = entry.icon;\n return (\n <button\n key={`cat-${entry.kind}`}\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 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 (h + 1) % mentionEntries.length,\n );\n return;\n }\n if (event.key === \"ArrowUp\") {\n event.preventDefault();\n setMentionHighlight((h) =>\n (h - 1 + mentionEntries.length) %\n mentionEntries.length,\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) return;\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) return;\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 InternalChatMentionConfig,\n InternalChatMentionItem,\n InternalChatMentionKind,\n} from \"@/components/ui/internal-chat-mention\";\n",
|
|
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",
|
|
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
|
+
}
|