@devpablocristo/platform-browser 0.1.0

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 ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@devpablocristo/platform-browser",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./storage": "./src/storage.ts",
9
+ "./crud": "./src/crud/index.ts",
10
+ "./search": "./src/search/index.ts",
11
+ "./theme": "./src/theme.ts",
12
+ "./observability": "./src/observability.ts",
13
+ "./i18n": "./src/i18n/index.ts"
14
+ },
15
+ "peerDependencies": {
16
+ "react": "^18.0.0 || ^19.0.0",
17
+ "react-dom": "^18.0.0 || ^19.0.0"
18
+ },
19
+ "scripts": {
20
+ "test": "vitest run",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "devDependencies": {
24
+ "@types/react": "^19.2.14",
25
+ "@types/react-dom": "^19.2.3",
26
+ "@types/node": "^25.7.0",
27
+ "react": "^19.2.5",
28
+ "react-dom": "^19.2.6",
29
+ "jsdom": "^29.1.1",
30
+ "typescript": "^6.0.3",
31
+ "vitest": "^4.1.6"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "!src/**/*.test.ts",
36
+ "!src/**/*.test.tsx",
37
+ "!src/**/*.spec.ts",
38
+ "!src/**/*.spec.tsx"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,170 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ export type ConfirmDialogTone = "primary" | "danger";
5
+
6
+ export type ConfirmDialogOptions = {
7
+ title?: ReactNode;
8
+ description?: ReactNode;
9
+ confirmLabel?: string;
10
+ cancelLabel?: string;
11
+ tone?: ConfirmDialogTone;
12
+ allowEscapeClose?: boolean;
13
+ allowBackdropClose?: boolean;
14
+ };
15
+
16
+ type ConfirmRequest = Required<
17
+ Pick<ConfirmDialogOptions, "confirmLabel" | "cancelLabel" | "tone" | "allowEscapeClose" | "allowBackdropClose">
18
+ > &
19
+ Omit<ConfirmDialogOptions, "confirmLabel" | "cancelLabel" | "tone" | "allowEscapeClose" | "allowBackdropClose"> & {
20
+ id: number;
21
+ resolve: (confirmed: boolean) => void;
22
+ };
23
+
24
+ type ConfirmOpenHandler = (options: ConfirmDialogOptions) => Promise<boolean>;
25
+
26
+ let confirmOpenHandler: ConfirmOpenHandler | null = null;
27
+ let confirmRequestId = 0;
28
+
29
+ function normalizeConfirmOptions(options: ConfirmDialogOptions, resolve: (confirmed: boolean) => void): ConfirmRequest {
30
+ return {
31
+ id: ++confirmRequestId,
32
+ title: options.title,
33
+ description: options.description,
34
+ confirmLabel: options.confirmLabel ?? "Confirmar",
35
+ cancelLabel: options.cancelLabel ?? "Cancelar",
36
+ tone: options.tone ?? "primary",
37
+ allowEscapeClose: options.allowEscapeClose ?? true,
38
+ allowBackdropClose: options.allowBackdropClose ?? true,
39
+ resolve,
40
+ };
41
+ }
42
+
43
+ function fallbackConfirmMessage(options: ConfirmDialogOptions): string {
44
+ const title = typeof options.title === "string" ? options.title : "";
45
+ const description = typeof options.description === "string" ? options.description : "";
46
+ return [title, description].filter(Boolean).join("\n\n") || "¿Confirmar acción?";
47
+ }
48
+
49
+ export function confirmAction(options: ConfirmDialogOptions): Promise<boolean> {
50
+ if (confirmOpenHandler) {
51
+ return confirmOpenHandler(options);
52
+ }
53
+ if (typeof window !== "undefined" && typeof window.confirm === "function") {
54
+ return Promise.resolve(window.confirm(fallbackConfirmMessage(options)));
55
+ }
56
+ return Promise.resolve(false);
57
+ }
58
+
59
+ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
60
+ const [queue, setQueue] = useState<ConfirmRequest[]>([]);
61
+ const activeRequestRef = useRef<ConfirmRequest | null>(null);
62
+
63
+ const openConfirm = useCallback((options: ConfirmDialogOptions) => {
64
+ return new Promise<boolean>((resolve) => {
65
+ setQueue((current) => [...current, normalizeConfirmOptions(options, resolve)]);
66
+ });
67
+ }, []);
68
+
69
+ useEffect(() => {
70
+ confirmOpenHandler = openConfirm;
71
+ return () => {
72
+ if (confirmOpenHandler === openConfirm) {
73
+ confirmOpenHandler = null;
74
+ }
75
+ };
76
+ }, [openConfirm]);
77
+
78
+ const activeRequest = queue[0] ?? null;
79
+
80
+ useEffect(() => {
81
+ activeRequestRef.current = activeRequest;
82
+ }, [activeRequest]);
83
+
84
+ const settleActiveRequest = useCallback((confirmed: boolean) => {
85
+ const request = activeRequestRef.current;
86
+ if (!request) {
87
+ return;
88
+ }
89
+ activeRequestRef.current = null;
90
+ request.resolve(confirmed);
91
+ setQueue((current) => current.slice(1));
92
+ }, []);
93
+
94
+ useEffect(() => {
95
+ if (!activeRequest?.allowEscapeClose) {
96
+ return;
97
+ }
98
+ const handleKeyDown = (event: KeyboardEvent) => {
99
+ if (event.key !== "Escape") {
100
+ return;
101
+ }
102
+ event.preventDefault();
103
+ settleActiveRequest(false);
104
+ };
105
+ window.addEventListener("keydown", handleKeyDown);
106
+ return () => window.removeEventListener("keydown", handleKeyDown);
107
+ }, [activeRequest, settleActiveRequest]);
108
+
109
+ const dialog = useMemo(() => {
110
+ if (!activeRequest) {
111
+ return null;
112
+ }
113
+ const titleId = `confirm-dialog-title-${activeRequest.id}`;
114
+ const descriptionId = `confirm-dialog-description-${activeRequest.id}`;
115
+
116
+ return (
117
+ <div
118
+ className="confirm-dialog__backdrop"
119
+ role="presentation"
120
+ onMouseDown={(event) => {
121
+ if (event.target !== event.currentTarget || !activeRequest.allowBackdropClose) {
122
+ return;
123
+ }
124
+ settleActiveRequest(false);
125
+ }}
126
+ >
127
+ <section
128
+ className="confirm-dialog"
129
+ role="alertdialog"
130
+ aria-modal="true"
131
+ aria-labelledby={titleId}
132
+ aria-describedby={activeRequest.description != null ? descriptionId : undefined}
133
+ onMouseDown={(event) => event.stopPropagation()}
134
+ >
135
+ <div className="confirm-dialog__header">
136
+ <h2 id={titleId} className="confirm-dialog__title">
137
+ {activeRequest.title ?? "Confirmar acción"}
138
+ </h2>
139
+ </div>
140
+ {activeRequest.description != null ? (
141
+ <div className="confirm-dialog__body">
142
+ <p id={descriptionId} className="confirm-dialog__description">
143
+ {activeRequest.description}
144
+ </p>
145
+ </div>
146
+ ) : null}
147
+ <div className="confirm-dialog__footer">
148
+ <button type="button" className="btn-secondary btn-sm" onClick={() => settleActiveRequest(false)}>
149
+ {activeRequest.cancelLabel}
150
+ </button>
151
+ <button
152
+ type="button"
153
+ className={`${activeRequest.tone === "danger" ? "btn-danger" : "btn-primary"} btn-sm`}
154
+ onClick={() => settleActiveRequest(true)}
155
+ >
156
+ {activeRequest.confirmLabel}
157
+ </button>
158
+ </div>
159
+ </section>
160
+ </div>
161
+ );
162
+ }, [activeRequest, settleActiveRequest]);
163
+
164
+ return (
165
+ <>
166
+ {children}
167
+ {dialog && typeof document !== "undefined" ? createPortal(dialog, document.body) : dialog}
168
+ </>
169
+ );
170
+ }
@@ -0,0 +1,79 @@
1
+ import type { ReactNode } from "react";
2
+ import { SearchInput } from "../search";
3
+
4
+ /**
5
+ * Layout canónico de consola: cabecera (título + acciones), error, formulario,
6
+ * barra de herramientas (búsqueda / filtros) y cuerpo (spinner, vacío o tabla).
7
+ * Productos (Pymes u otros) montan datos y i18n encima; las clases CSS viven en el tema de la app.
8
+ * El host puede combinar `data-theme` y, si aplica, `data-admin-skin` en `<html>` para tokens adicionales.
9
+ */
10
+ export type CrudPageShellProps = {
11
+ title: ReactNode;
12
+ subtitle?: ReactNode;
13
+ /** Bajo el subtítulo, columna izquierda (p. ej. filtros tipo píldora). */
14
+ headerLeadSlot?: ReactNode;
15
+ /** Buscador canónico del header. */
16
+ search?: {
17
+ value: string;
18
+ onChange: (value: string) => void;
19
+ placeholder?: string;
20
+ ariaLabel?: string;
21
+ inputClassName?: string;
22
+ clearLabel?: string;
23
+ };
24
+ /** Fila de acciones bajo el buscador compartido. */
25
+ headerActions?: ReactNode;
26
+ error?: ReactNode;
27
+ /** Tarjeta de formulario alta/edición */
28
+ form?: ReactNode;
29
+ /** Fila búsqueda + archivados u otros filtros */
30
+ toolbar?: ReactNode;
31
+ children: ReactNode;
32
+ };
33
+
34
+ export function CrudPageShell({
35
+ title,
36
+ subtitle,
37
+ headerLeadSlot,
38
+ search,
39
+ headerActions,
40
+ error,
41
+ form,
42
+ toolbar,
43
+ children,
44
+ }: CrudPageShellProps) {
45
+ return (
46
+ <>
47
+ <div className="page-header crud-page-shell__header">
48
+ <div className="crud-page-shell__header-main">
49
+ <h1 className="crud-page-shell__title">{title}</h1>
50
+ {subtitle != null && subtitle !== false ? (
51
+ <p className="text-secondary">{subtitle}</p>
52
+ ) : null}
53
+ {headerLeadSlot != null ? headerLeadSlot : null}
54
+ </div>
55
+ {search != null || headerActions != null ? (
56
+ <div className="crud-page-shell__header-actions">
57
+ {search != null ? (
58
+ <div className="crud-list-header-search">
59
+ <SearchInput
60
+ value={search.value}
61
+ onChange={search.onChange}
62
+ placeholder={search.placeholder ?? "Buscar..."}
63
+ ariaLabel={search.ariaLabel}
64
+ inputClassName={search.inputClassName}
65
+ clearLabel={search.clearLabel}
66
+ />
67
+ </div>
68
+ ) : null}
69
+ {headerActions != null ? <div className="actions-row">{headerActions}</div> : null}
70
+ </div>
71
+ ) : null}
72
+ </div>
73
+ {error}
74
+ {form}
75
+ {toolbar}
76
+ {children}
77
+ </>
78
+ );
79
+ }
@@ -0,0 +1,60 @@
1
+ import type { ReactNode } from 'react';
2
+ import { CrudPageShell } from './CrudPageShell';
3
+ import { usePageSearchShellControl } from '../search/PageSearchProvider';
4
+
5
+ export type PageLayoutProps = {
6
+ /** Título principal (h1) */
7
+ title: ReactNode;
8
+ /** Párrafo lead bajo el título (opcional) */
9
+ lead?: ReactNode;
10
+ /** Botones / enlaces a la derecha; activa layout tipo split automáticamente */
11
+ actions?: ReactNode;
12
+ /** Contenido entre cabecera y body (alertas, avisos) */
13
+ banner?: ReactNode;
14
+ /** Clases extra en el contenedor (e.g. 'dash', 'gcal') */
15
+ className?: string;
16
+ /** Label del botón clear de búsqueda (default: 'Clear search') */
17
+ searchClearLabel?: string;
18
+ children: ReactNode;
19
+ };
20
+
21
+ function isPrimitiveLead(lead: ReactNode) {
22
+ return typeof lead === 'string' || typeof lead === 'number';
23
+ }
24
+
25
+ /**
26
+ * Layout estándar de página: wrapper sobre CrudPageShell con integración de PageSearch.
27
+ */
28
+ export function PageLayout({ title, lead, actions, banner, className, searchClearLabel, children }: PageLayoutProps) {
29
+ const stackClass = ['page-stack', className].filter(Boolean).join(' ');
30
+ const pageSearch = usePageSearchShellControl();
31
+ const hasSearch = pageSearch.visible;
32
+ const primitiveLead = lead != null && lead !== false && isPrimitiveLead(lead) ? lead : undefined;
33
+ const richLead =
34
+ lead != null && lead !== false && !isPrimitiveLead(lead) ? <div className="text-page-lead">{lead}</div> : undefined;
35
+ return (
36
+ <div className={stackClass}>
37
+ <CrudPageShell
38
+ title={title}
39
+ subtitle={primitiveLead}
40
+ headerLeadSlot={richLead}
41
+ search={
42
+ hasSearch
43
+ ? {
44
+ value: pageSearch.query,
45
+ onChange: pageSearch.setQuery,
46
+ placeholder: pageSearch.placeholder,
47
+ clearLabel: searchClearLabel ?? 'Clear search',
48
+ }
49
+ : undefined
50
+ }
51
+ headerActions={actions}
52
+ >
53
+ <>
54
+ {banner}
55
+ {children}
56
+ </>
57
+ </CrudPageShell>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,3 @@
1
+ export { CrudPageShell, type CrudPageShellProps } from "./CrudPageShell";
2
+ export { PageLayout, type PageLayoutProps } from "./PageLayout";
3
+ export { parseListItemsFromResponse, parsePaginatedResponse, type PaginatedList } from "./listParsing";
@@ -0,0 +1,94 @@
1
+ type ListEnvelope<T> = {
2
+ items?: T[] | null | unknown;
3
+ data?: unknown;
4
+ hasMore?: boolean;
5
+ has_more?: boolean;
6
+ nextCursor?: string;
7
+ next_cursor?: string;
8
+ };
9
+
10
+ export type PaginatedList<T> = {
11
+ items: T[];
12
+ hasMore: boolean;
13
+ nextCursor: string;
14
+ };
15
+
16
+ /**
17
+ * Normaliza respuestas de listado tipo arreglo, `{ items }` o envelopes BFF anidados con `data`.
18
+ * Go suele serializar slices nil como `null`; cualquier rama sin arreglo termina en lista vacía.
19
+ */
20
+ export function parseListItemsFromResponse<T>(input: unknown): T[] {
21
+ const queue: unknown[] = [input];
22
+ const seen = new Set<unknown>();
23
+
24
+ while (queue.length > 0) {
25
+ const current = queue.shift();
26
+ if (Array.isArray(current)) {
27
+ return current as T[];
28
+ }
29
+ if (current == null || typeof current !== "object" || seen.has(current)) {
30
+ continue;
31
+ }
32
+
33
+ seen.add(current);
34
+ const envelope = current as ListEnvelope<T>;
35
+
36
+ if (Array.isArray(envelope.items)) {
37
+ return envelope.items;
38
+ }
39
+
40
+ if ("data" in envelope) {
41
+ queue.push(envelope.data);
42
+ }
43
+ if ("items" in envelope) {
44
+ queue.push(envelope.items);
45
+ }
46
+ }
47
+
48
+ return [];
49
+ }
50
+
51
+ /**
52
+ * Como `parseListItemsFromResponse` pero conserva `has_more` y `next_cursor` del backend.
53
+ */
54
+ export function parsePaginatedResponse<T>(input: unknown): PaginatedList<T> {
55
+ const items = parseListItemsFromResponse<T>(input);
56
+ let hasMore = false;
57
+ let nextCursor = "";
58
+ const envelope = findEnvelope(input);
59
+ if (envelope) {
60
+ hasMore = Boolean(envelope.hasMore ?? envelope.has_more);
61
+ nextCursor = String(envelope.nextCursor ?? envelope.next_cursor ?? "");
62
+ } else if (input != null && typeof input === "object" && !Array.isArray(input)) {
63
+ const envelope = input as ListEnvelope<T>;
64
+ hasMore = Boolean(envelope.hasMore ?? envelope.has_more);
65
+ nextCursor = String(envelope.nextCursor ?? envelope.next_cursor ?? "");
66
+ }
67
+ return { items, hasMore, nextCursor };
68
+ }
69
+
70
+ function findEnvelope<T>(input: unknown): ListEnvelope<T> | null {
71
+ const queue: unknown[] = [input];
72
+ const seen = new Set<unknown>();
73
+
74
+ while (queue.length > 0) {
75
+ const current = queue.shift();
76
+ if (current == null || typeof current !== "object" || Array.isArray(current) || seen.has(current)) {
77
+ continue;
78
+ }
79
+ seen.add(current);
80
+ const envelope = current as ListEnvelope<T>;
81
+ if (
82
+ "hasMore" in envelope ||
83
+ "has_more" in envelope ||
84
+ "nextCursor" in envelope ||
85
+ "next_cursor" in envelope
86
+ ) {
87
+ return envelope;
88
+ }
89
+ if ("data" in envelope) queue.push(envelope.data);
90
+ if ("items" in envelope) queue.push(envelope.items);
91
+ }
92
+
93
+ return null;
94
+ }
@@ -0,0 +1,128 @@
1
+ /* eslint-disable react-refresh/only-export-components -- provider + hook en mismo archivo */
2
+ import { createContext, useContext, useEffect, useMemo, useState, type PropsWithChildren } from 'react';
3
+ import { createBrowserStorageNamespace } from '../storage';
4
+ import { formatMessage } from './formatMessage';
5
+ import { localeTagForLanguage } from './localeTag';
6
+ import { toSentenceCase } from './sentenceCase';
7
+ import type { FlatMessages, LanguageCode, TranslationVariables, TranslationsByLanguage } from './types';
8
+
9
+ export type I18nConfig = {
10
+ /** Namespace para persistir el idioma (e.g. 'pymes-ui') */
11
+ namespace: string;
12
+ /** Key de storage (e.g. 'app:language') */
13
+ storageKey?: string;
14
+ /** Idioma default (default: 'es') */
15
+ defaultLanguage?: LanguageCode;
16
+ /** Claves absolutas en localStorage (sin namespace) a leer una vez y migrar al storage con namespace */
17
+ legacyLanguageKeys?: string[];
18
+ /** Idiomas soportados con label keys */
19
+ supportedLanguages?: Array<{ code: LanguageCode; labelKey: string }>;
20
+ /** Mensajes agrupados por idioma */
21
+ messages: Record<LanguageCode, FlatMessages>;
22
+ /** Función opcional para localizar texto (e.g. vocabulary replacement) */
23
+ localizeText?: (text: string) => string;
24
+ };
25
+
26
+ export type I18nContextValue = {
27
+ language: LanguageCode;
28
+ setLanguage: (language: LanguageCode) => void;
29
+ t: (key: string, variables?: TranslationVariables) => string;
30
+ localizeText: (text: string) => string;
31
+ sentenceCase: (text: string) => string;
32
+ localizeUiText: (text: string) => string;
33
+ options: Array<{ code: LanguageCode; labelKey: string }>;
34
+ };
35
+
36
+ /**
37
+ * Merge de múltiples fuentes de traducciones.
38
+ */
39
+ export function mergeMessages(...sources: TranslationsByLanguage[]): Record<LanguageCode, FlatMessages> {
40
+ return {
41
+ es: Object.assign({}, ...sources.map((s) => s.es)),
42
+ en: Object.assign({}, ...sources.map((s) => s.en)),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Crea un provider de i18n con storage persistente.
48
+ * Retorna { Provider, useI18n } para uso en la app.
49
+ */
50
+ export function createI18nProvider(config: I18nConfig) {
51
+ const defaultLanguage = config.defaultLanguage ?? 'es';
52
+ const storageKey = config.storageKey ?? 'language';
53
+ const supportedLanguages = config.supportedLanguages ?? [
54
+ { code: 'es', labelKey: 'common.language.es' },
55
+ { code: 'en', labelKey: 'common.language.en' },
56
+ ];
57
+ const storage = createBrowserStorageNamespace({ namespace: config.namespace, hostAware: false });
58
+ const identity = (text: string) => text;
59
+ const localizeText = config.localizeText ?? identity;
60
+
61
+ function getMessage(language: LanguageCode, key: string, variables?: TranslationVariables): string {
62
+ return formatMessage(config.messages, language, defaultLanguage, key, variables);
63
+ }
64
+
65
+ function readStoredLanguage(): LanguageCode {
66
+ if (typeof window === 'undefined') return defaultLanguage;
67
+ for (const legacyKey of config.legacyLanguageKeys ?? []) {
68
+ try {
69
+ const raw = window.localStorage.getItem(legacyKey);
70
+ if (raw === 'en' || raw === 'es') {
71
+ window.localStorage.removeItem(legacyKey);
72
+ storage.setString(storageKey, raw);
73
+ return raw;
74
+ }
75
+ } catch {
76
+ /* private mode */
77
+ }
78
+ }
79
+ const stored = storage.getString(storageKey);
80
+ return stored === 'en' || stored === 'es' ? stored : defaultLanguage;
81
+ }
82
+
83
+ const defaultContext: I18nContextValue = {
84
+ language: defaultLanguage,
85
+ setLanguage: () => undefined,
86
+ t: (key, variables) => formatMessage(config.messages, defaultLanguage, defaultLanguage, key, variables),
87
+ localizeText,
88
+ sentenceCase: toSentenceCase,
89
+ localizeUiText: (text) => toSentenceCase(localizeText(text)),
90
+ options: supportedLanguages,
91
+ };
92
+
93
+ const I18nContext = createContext<I18nContextValue>(defaultContext);
94
+
95
+ function Provider({ children, initialLanguage }: PropsWithChildren<{ initialLanguage?: LanguageCode }>) {
96
+ const [language, setLanguageState] = useState<LanguageCode>(() => initialLanguage ?? readStoredLanguage());
97
+
98
+ useEffect(() => {
99
+ storage.setString(storageKey, language);
100
+ if (typeof document !== 'undefined') {
101
+ document.documentElement.lang = localeTagForLanguage(language);
102
+ }
103
+ }, [language]);
104
+
105
+ const value = useMemo<I18nContextValue>(
106
+ () => ({
107
+ language,
108
+ setLanguage: setLanguageState,
109
+ t: (key, variables) => getMessage(language, key, variables),
110
+ localizeText,
111
+ sentenceCase: toSentenceCase,
112
+ localizeUiText: (text) => toSentenceCase(localizeText(text)),
113
+ options: supportedLanguages,
114
+ }),
115
+ [language],
116
+ );
117
+
118
+ return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
119
+ }
120
+
121
+ function useI18n(): I18nContextValue {
122
+ return useContext(I18nContext);
123
+ }
124
+
125
+ return { Provider, useI18n, toSentenceCase };
126
+ }
127
+
128
+ export { toSentenceCase };
@@ -0,0 +1,17 @@
1
+ import type { FlatMessages } from "./types";
2
+
3
+ /**
4
+ * Aplana un árbol de mensajes anidados a claves con punto (p. ej. `common.loading`).
5
+ */
6
+ export function flattenNestedMessages(obj: Record<string, unknown>, prefix = ""): FlatMessages {
7
+ const out: FlatMessages = {};
8
+ for (const [k, v] of Object.entries(obj)) {
9
+ const key = prefix ? `${prefix}.${k}` : k;
10
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
11
+ Object.assign(out, flattenNestedMessages(v as Record<string, unknown>, key));
12
+ } else if (typeof v === "string") {
13
+ out[key] = v;
14
+ }
15
+ }
16
+ return out;
17
+ }
@@ -0,0 +1,23 @@
1
+ import type { FlatMessages, LanguageCode, TranslationVariables } from "./types";
2
+
3
+ /**
4
+ * Soporta `{var}` y `{{var}}` (compat con plantillas tipo i18next).
5
+ */
6
+ export function interpolate(template: string, variables?: TranslationVariables): string {
7
+ const normalized = template.replace(/\{\{(\w+)\}\}/g, "{$1}");
8
+ if (!variables) {
9
+ return normalized;
10
+ }
11
+ return normalized.replace(/\{(\w+)\}/g, (_match, key: string) => String(variables[key] ?? ""));
12
+ }
13
+
14
+ export function formatMessage(
15
+ messages: Record<LanguageCode, FlatMessages>,
16
+ language: LanguageCode,
17
+ defaultLanguage: LanguageCode,
18
+ key: string,
19
+ variables?: TranslationVariables,
20
+ ): string {
21
+ const template = messages[language]?.[key] ?? messages[defaultLanguage]?.[key] ?? key;
22
+ return interpolate(template, variables);
23
+ }
@@ -0,0 +1,5 @@
1
+ export { createI18nProvider, mergeMessages, toSentenceCase, type I18nConfig, type I18nContextValue } from './I18nProvider';
2
+ export { flattenNestedMessages } from './flattenMessages';
3
+ export { formatMessage, interpolate } from './formatMessage';
4
+ export { localeTagForLanguage } from './localeTag';
5
+ export type { LanguageCode, FlatMessages, TranslationVariables, TranslationsByLanguage } from './types';
@@ -0,0 +1,6 @@
1
+ import type { LanguageCode } from "./types";
2
+
3
+ /** Tag BCP-47 recomendado para `toLocaleString` según idioma de UI. */
4
+ export function localeTagForLanguage(code: LanguageCode): string {
5
+ return code === "es" ? "es-AR" : "en-US";
6
+ }
@@ -0,0 +1,35 @@
1
+ function hasLettersOrDigits(token: string): boolean {
2
+ return /[\p{L}\p{N}]/u.test(token);
3
+ }
4
+
5
+ function isUppercaseAcronym(token: string): boolean {
6
+ const alphanumeric = token.replace(/[^\p{L}\p{N}]/gu, '');
7
+ return alphanumeric.length >= 2 && /^[A-Z0-9]+$/u.test(alphanumeric);
8
+ }
9
+
10
+ function capitalizeFirstLetter(token: string): string {
11
+ return token.replace(/\p{L}/u, (char) => char.toLocaleUpperCase());
12
+ }
13
+
14
+ /**
15
+ * Convierte texto a sentence case, preservando acrónimos (API, CRUD, etc.).
16
+ */
17
+ export function toSentenceCase(text: string): string {
18
+ let seenFirstWord = false;
19
+ return text
20
+ .split(/(\s+)/)
21
+ .map((token) => {
22
+ if (/^\s+$/u.test(token) || !hasLettersOrDigits(token)) return token;
23
+ if (isUppercaseAcronym(token)) {
24
+ seenFirstWord = true;
25
+ return token;
26
+ }
27
+ const normalized = token.toLocaleLowerCase();
28
+ if (!seenFirstWord) {
29
+ seenFirstWord = true;
30
+ return capitalizeFirstLetter(normalized);
31
+ }
32
+ return normalized;
33
+ })
34
+ .join('');
35
+ }
@@ -0,0 +1,4 @@
1
+ export type LanguageCode = 'es' | 'en';
2
+ export type TranslationVariables = Record<string, string | number>;
3
+ export type FlatMessages = Record<string, string>;
4
+ export type TranslationsByLanguage = Record<LanguageCode, FlatMessages>;
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./mergeRecords";
2
+ export * from "./storage";
3
+ export * from "./search";
4
+ export * from "./confirm";
5
+ export * from "./theme";
6
+ export * from "./observability";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Combina dos mapas por clave string: se conservan primero las entradas de `base`
3
+ * cuya clave no existe en `override`; luego se añaden (o reemplazan) todas las de `override`.
4
+ * El orden de iteración queda alineado con el merge histórico de catálogos estáticos + CRUD.
5
+ */
6
+ export function mergeRecordsPreferOverride<T>(
7
+ base: Record<string, T>,
8
+ override: Record<string, T>,
9
+ ): Record<string, T> {
10
+ const out: Record<string, T> = {};
11
+ for (const key of Object.keys(base)) {
12
+ if (!Object.prototype.hasOwnProperty.call(override, key)) {
13
+ out[key] = base[key] as T;
14
+ }
15
+ }
16
+ for (const key of Object.keys(override)) {
17
+ out[key] = override[key] as T;
18
+ }
19
+ return out;
20
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Interfaz genérica para error tracking (Sentry, Datadog, LogRocket, etc.).
3
+ * El producto inyecta su implementación concreta.
4
+ */
5
+ export type ErrorReporter = {
6
+ init: () => void;
7
+ captureException: (error: unknown, context?: Record<string, string>) => void;
8
+ };
9
+
10
+ let reporter: ErrorReporter | null = null;
11
+
12
+ /**
13
+ * Registra un error reporter. Llamar una sola vez al inicio de la app.
14
+ */
15
+ export function registerErrorReporter(r: ErrorReporter): void {
16
+ reporter = r;
17
+ reporter.init();
18
+ }
19
+
20
+ /**
21
+ * Captura un error. No-op si no hay reporter registrado.
22
+ */
23
+ export function captureError(error: unknown, context?: Record<string, string>): void {
24
+ reporter?.captureException(error, context);
25
+ }
26
+
27
+ /**
28
+ * Crea un error reporter para Sentry a partir de un DSN.
29
+ * Retorna null si el DSN está vacío (desarrollo local).
30
+ * El caller debe pasar el módulo @sentry/* para evitar la dependencia en core.
31
+ */
32
+ export function createSentryReporter(
33
+ dsn: string | undefined,
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- interfaz genérica compatible con cualquier SDK de error tracking
35
+ sentry: { init: (...args: any[]) => any; captureException: (...args: any[]) => any },
36
+ environment: string,
37
+ ): ErrorReporter | null {
38
+ if (!dsn) return null;
39
+ return {
40
+ init: () =>
41
+ sentry.init({
42
+ dsn,
43
+ environment,
44
+ tracesSampleRate: 0,
45
+ beforeSend: (event: unknown) => {
46
+ const e = event as { exception?: { values?: Array<{ value?: string }> } };
47
+ const message = e?.exception?.values?.[0]?.value ?? '';
48
+ if (message.includes('401') || message.includes('NetworkError')) return null;
49
+ return event;
50
+ },
51
+ }),
52
+ captureException: (error, context) =>
53
+ sentry.captureException(error, context ? { tags: context } : undefined),
54
+ };
55
+ }
@@ -0,0 +1,78 @@
1
+ /* eslint-disable react-refresh/only-export-components -- hooks acoplados al Context del mismo archivo */
2
+ import { createContext, useCallback, useContext, useEffect, useRef, useState, type PropsWithChildren } from 'react';
3
+
4
+ type PageSearchContextValue = {
5
+ query: string;
6
+ setQuery: (value: string) => void;
7
+ register: () => () => void;
8
+ visible: boolean;
9
+ placeholder: string;
10
+ };
11
+
12
+ const PageSearchContext = createContext<PageSearchContextValue>({
13
+ query: '',
14
+ setQuery: () => {},
15
+ register: () => () => {},
16
+ visible: false,
17
+ placeholder: 'Search...',
18
+ });
19
+
20
+ const PageSearchShellContext = createContext(false);
21
+
22
+ /**
23
+ * Hook que registra la página como consumidora del search y devuelve el query.
24
+ * Al desmontar, des-registra y el input desaparece.
25
+ */
26
+ export function usePageSearch(): string {
27
+ const { query, register } = useContext(PageSearchContext);
28
+ useEffect(() => register(), [register]);
29
+ return query;
30
+ }
31
+
32
+ /**
33
+ * Hook para el Shell: controla visibilidad y query del search input.
34
+ */
35
+ export function usePageSearchShellControl() {
36
+ const { query, setQuery, visible, placeholder } = useContext(PageSearchContext);
37
+ return {
38
+ query,
39
+ visible,
40
+ placeholder,
41
+ setQuery,
42
+ clear: () => setQuery(''),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Provider del buscador de página. Se monta una vez en el Shell.
48
+ * Las páginas se registran con usePageSearch() — cuando hay al menos una registrada, el input es visible.
49
+ */
50
+ export function PageSearchProvider({
51
+ children,
52
+ placeholder = 'Search...',
53
+ }: PropsWithChildren<{ placeholder?: string }>) {
54
+ const [query, setQuery] = useState('');
55
+ const countRef = useRef(0);
56
+ const [visible, setVisible] = useState(false);
57
+
58
+ const register = useCallback(() => {
59
+ countRef.current += 1;
60
+ setVisible(true);
61
+ return () => {
62
+ countRef.current -= 1;
63
+ if (countRef.current <= 0) {
64
+ countRef.current = 0;
65
+ setVisible(false);
66
+ setQuery('');
67
+ }
68
+ };
69
+ }, []);
70
+
71
+ return (
72
+ <PageSearchShellContext.Provider value>
73
+ <PageSearchContext.Provider value={{ query, setQuery, register, visible, placeholder }}>
74
+ {children}
75
+ </PageSearchContext.Provider>
76
+ </PageSearchShellContext.Provider>
77
+ );
78
+ }
@@ -0,0 +1,55 @@
1
+ import type { ChangeEventHandler } from "react";
2
+
3
+ export type SearchInputProps = {
4
+ value: string;
5
+ onChange: (value: string) => void;
6
+ placeholder: string;
7
+ ariaLabel?: string;
8
+ autoComplete?: string;
9
+ rootClassName?: string;
10
+ inputClassName?: string;
11
+ clearLabel?: string;
12
+ };
13
+
14
+ /**
15
+ * Input canónico de búsqueda para consola.
16
+ * La app host aporta el CSS del tema sobre las clases `page-search*`.
17
+ */
18
+ export function SearchInput({
19
+ value,
20
+ onChange,
21
+ placeholder,
22
+ ariaLabel,
23
+ autoComplete = "off",
24
+ rootClassName,
25
+ inputClassName,
26
+ clearLabel = "Limpiar búsqueda",
27
+ }: SearchInputProps) {
28
+ const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
29
+ onChange(event.target.value);
30
+ };
31
+
32
+ return (
33
+ <div className={["page-search", rootClassName].filter(Boolean).join(" ").trim()}>
34
+ <input
35
+ type="search"
36
+ className={["page-search__input", inputClassName].filter(Boolean).join(" ").trim()}
37
+ placeholder={placeholder}
38
+ autoComplete={autoComplete}
39
+ value={value}
40
+ onChange={handleChange}
41
+ aria-label={ariaLabel ?? placeholder}
42
+ />
43
+ {value.length > 0 ? (
44
+ <button
45
+ className="page-search__clear"
46
+ onClick={() => onChange("")}
47
+ aria-label={clearLabel}
48
+ type="button"
49
+ >
50
+ ×
51
+ </button>
52
+ ) : null}
53
+ </div>
54
+ );
55
+ }
@@ -0,0 +1,5 @@
1
+ export { normalize } from "./normalize";
2
+ export { trigrams, similarity } from "./trigram";
3
+ export { search, type SearchEntry, type SearchResult, type SearchOptions } from "./search";
4
+ export { SearchInput, type SearchInputProps } from "./SearchInput";
5
+ export { PageSearchProvider, usePageSearch, usePageSearchShellControl } from "./PageSearchProvider";
@@ -0,0 +1,8 @@
1
+ /** Normaliza texto: minúsculas, sin acentos, trim. */
2
+ export function normalize(text: string): string {
3
+ return text
4
+ .normalize("NFD")
5
+ .replace(/[\u0300-\u036f]/g, "")
6
+ .toLowerCase()
7
+ .trim();
8
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Función principal de búsqueda fuzzy client-side.
3
+ *
4
+ * Combina word-level prefix/substring match + trigram similarity
5
+ * para dar resultados relevantes con tolerancia a typos y acentos.
6
+ */
7
+
8
+ import { normalize } from "./normalize";
9
+ import { similarity } from "./trigram";
10
+
11
+ export type SearchEntry<T> = {
12
+ item: T;
13
+ text: string;
14
+ };
15
+
16
+ export type SearchResult<T> = {
17
+ item: T;
18
+ score: number;
19
+ };
20
+
21
+ export type SearchOptions = {
22
+ /** Umbral mínimo para resultados trigram-only (sin prefix/substring). Default: 0.55 */
23
+ trigramThreshold?: number;
24
+ /** Máximo resultados devueltos. Default: sin límite. */
25
+ limit?: number;
26
+ };
27
+
28
+ const DEFAULT_TRIGRAM_THRESHOLD = 0.55;
29
+ const PREFIX_BOOST = 0.5;
30
+ const SUBSTRING_BOOST = 0.35;
31
+
32
+ /**
33
+ * Busca entries por similitud con el query.
34
+ *
35
+ * Estrategia de scoring (por entry):
36
+ * 1. Si alguna palabra del texto empieza con el query → baseSim + 0.5
37
+ * 2. Si el query es substring del texto → baseSim + 0.35
38
+ * 3. Solo trigramas → requiere similitud ≥ trigramThreshold (default 0.55)
39
+ *
40
+ * baseSim = max(similitud vs texto completo, mejor similitud vs palabra individual)
41
+ *
42
+ * Resultados ordenados por score descendente.
43
+ */
44
+ export function search<T>(
45
+ query: string,
46
+ entries: SearchEntry<T>[],
47
+ options?: SearchOptions,
48
+ ): SearchResult<T>[] {
49
+ const q = normalize(query);
50
+ if (q.length === 0) return [];
51
+
52
+ const threshold = options?.trigramThreshold ?? DEFAULT_TRIGRAM_THRESHOLD;
53
+ const results: SearchResult<T>[] = [];
54
+
55
+ for (const entry of entries) {
56
+ const norm = normalize(entry.text);
57
+ const words = norm.split(/\s+/).filter(Boolean);
58
+
59
+ const hasSubstring = norm.includes(q);
60
+ const hasWordPrefix = words.some((w) => w.startsWith(q));
61
+
62
+ let bestWordSim = 0;
63
+ for (const w of words) {
64
+ const s = similarity(q, w);
65
+ if (s > bestWordSim) bestWordSim = s;
66
+ }
67
+
68
+ const fullSim = similarity(query, entry.text);
69
+ const baseSim = Math.max(fullSim, bestWordSim);
70
+
71
+ if (hasWordPrefix || hasSubstring) {
72
+ const boost = hasWordPrefix ? PREFIX_BOOST : SUBSTRING_BOOST;
73
+ results.push({ item: entry.item, score: Math.min(1, baseSim + boost) });
74
+ } else if (baseSim >= threshold) {
75
+ results.push({ item: entry.item, score: baseSim });
76
+ }
77
+ }
78
+
79
+ results.sort((a, b) => b.score - a.score);
80
+
81
+ if (options?.limit && results.length > options.limit) {
82
+ return results.slice(0, options.limit);
83
+ }
84
+ return results;
85
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Generación de trigramas y similitud (Dice coefficient).
3
+ *
4
+ * Funciones puras — no tienen conocimiento de UI ni de búsqueda.
5
+ */
6
+
7
+ import { normalize } from "./normalize";
8
+
9
+ /** Extrae trigramas de un string (normalizado internamente). */
10
+ export function trigrams(text: string): Set<string> {
11
+ const norm = normalize(text);
12
+ const padded = ` ${norm} `;
13
+ const result = new Set<string>();
14
+ for (let i = 0; i <= padded.length - 3; i++) {
15
+ result.add(padded.slice(i, i + 3));
16
+ }
17
+ return result;
18
+ }
19
+
20
+ /** Coeficiente de Dice: 2 * |A ∩ B| / (|A| + |B|). Rango [0, 1]. */
21
+ export function similarity(a: string, b: string): number {
22
+ const ta = trigrams(a);
23
+ const tb = trigrams(b);
24
+ if (ta.size === 0 && tb.size === 0) return 1;
25
+ if (ta.size === 0 || tb.size === 0) return 0;
26
+ let intersection = 0;
27
+ for (const t of ta) {
28
+ if (tb.has(t)) intersection++;
29
+ }
30
+ return (2 * intersection) / (ta.size + tb.size);
31
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,110 @@
1
+ export interface BrowserStorageNamespaceOptions {
2
+ namespace: string;
3
+ storage?: Storage;
4
+ hostAware?: boolean;
5
+ legacyKeys?: string[];
6
+ }
7
+
8
+ export interface BrowserStorageNamespace {
9
+ key(name: string): string;
10
+ getString(name: string): string | null;
11
+ setString(name: string, value: string): void;
12
+ remove(name: string): void;
13
+ getJSON<T>(name: string): T | null;
14
+ setJSON(name: string, value: unknown | null | undefined): void;
15
+ clear(): void;
16
+ }
17
+
18
+ function resolveStorage(custom?: Storage): Storage | null {
19
+ if (custom) {
20
+ return custom;
21
+ }
22
+ if (typeof window === "undefined") {
23
+ return null;
24
+ }
25
+ return window.localStorage;
26
+ }
27
+
28
+ function resolvePrefix(namespace: string, hostAware: boolean): string {
29
+ const cleanNamespace = namespace.trim();
30
+ if (!hostAware || typeof window === "undefined") {
31
+ return `${cleanNamespace}:`;
32
+ }
33
+ return `${cleanNamespace}:${window.location.host}:`;
34
+ }
35
+
36
+ export function createBrowserStorageNamespace(
37
+ options: BrowserStorageNamespaceOptions,
38
+ ): BrowserStorageNamespace {
39
+ const storage = resolveStorage(options.storage);
40
+ const hostAware = options.hostAware ?? true;
41
+ const legacyKeys = options.legacyKeys ?? [];
42
+
43
+ function prefix(): string {
44
+ return resolvePrefix(options.namespace, hostAware);
45
+ }
46
+
47
+ function key(name: string): string {
48
+ return `${prefix()}${name}`;
49
+ }
50
+
51
+ function getString(name: string): string | null {
52
+ return storage?.getItem(key(name)) ?? null;
53
+ }
54
+
55
+ function setString(name: string, value: string): void {
56
+ storage?.setItem(key(name), value);
57
+ }
58
+
59
+ function remove(name: string): void {
60
+ storage?.removeItem(key(name));
61
+ }
62
+
63
+ function getJSON<T>(name: string): T | null {
64
+ const raw = getString(name);
65
+ if (!raw) {
66
+ return null;
67
+ }
68
+ try {
69
+ return JSON.parse(raw) as T;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function setJSON(name: string, value: unknown | null | undefined): void {
76
+ if (value === null || value === undefined) {
77
+ remove(name);
78
+ return;
79
+ }
80
+ setString(name, JSON.stringify(value));
81
+ }
82
+
83
+ function clear(): void {
84
+ if (!storage) {
85
+ return;
86
+ }
87
+
88
+ legacyKeys.forEach((legacyKey) => storage.removeItem(legacyKey));
89
+
90
+ const prefixValue = prefix();
91
+ const toRemove: string[] = [];
92
+ for (let index = 0; index < storage.length; index += 1) {
93
+ const itemKey = storage.key(index);
94
+ if (itemKey && itemKey.startsWith(prefixValue)) {
95
+ toRemove.push(itemKey);
96
+ }
97
+ }
98
+ toRemove.forEach((itemKey) => storage.removeItem(itemKey));
99
+ }
100
+
101
+ return {
102
+ key,
103
+ getString,
104
+ setString,
105
+ remove,
106
+ getJSON,
107
+ setJSON,
108
+ clear,
109
+ };
110
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { createBrowserStorageNamespace } from './storage';
2
+
3
+ export type Theme = 'light' | 'dark';
4
+
5
+ export type ThemeConfig = {
6
+ /** Namespace para el storage (e.g. 'pymes-ui') */
7
+ namespace: string;
8
+ /** Key dentro del namespace (e.g. 'app:theme') */
9
+ storageKey?: string;
10
+ /** Atributo HTML donde se aplica el tema (default: 'data-theme') */
11
+ attribute?: string;
12
+ };
13
+
14
+ export type ThemeManager = {
15
+ get: () => Theme;
16
+ toggle: () => Theme;
17
+ apply: (theme?: Theme) => void;
18
+ };
19
+
20
+ /**
21
+ * Crea un theme manager con storage persistente y detección de preferencia del sistema.
22
+ */
23
+ export function createThemeManager(config: ThemeConfig): ThemeManager {
24
+ const storageKey = config.storageKey ?? 'theme';
25
+ const attribute = config.attribute ?? 'data-theme';
26
+ const storage = createBrowserStorageNamespace({ namespace: config.namespace, hostAware: false });
27
+
28
+ function get(): Theme {
29
+ const stored = storage.getString(storageKey);
30
+ if (stored === 'dark' || stored === 'light') return stored;
31
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
32
+ ? 'dark'
33
+ : 'light';
34
+ }
35
+
36
+ function apply(theme?: Theme): void {
37
+ const t = theme ?? get();
38
+ if (typeof document !== 'undefined') {
39
+ document.documentElement.setAttribute(attribute, t);
40
+ }
41
+ }
42
+
43
+ function toggle(): Theme {
44
+ const next = get() === 'dark' ? 'light' : 'dark';
45
+ storage.setString(storageKey, next);
46
+ apply(next);
47
+ return next;
48
+ }
49
+
50
+ return { get, toggle, apply };
51
+ }