First commit

master
dev 8 months ago
commit 43b894b603

1
.gitignore vendored

@ -0,0 +1 @@
/node_modules/

8
.idea/.gitignore vendored

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>hspot</w>
</words>
</dictionary>
</component>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/cpq-engine.iml" filepath="$PROJECT_DIR$/.idea/cpq-engine.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1724
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,25 @@
{
"name": "visual-configuration-engine",
"version": "2.0.0",
"description": "Sistema de configuración visual dinámica",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"dev": "tsc --watch"
},
"keywords": ["configuration", "visual", "typescript"],
"author": "ACTIVE THING",
"license": "EULA",
"devDependencies": {
"@types/node": "^25.2.3",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}

@ -0,0 +1,5 @@
/**
* Constants - Public API
*/
export * from './locales';

@ -0,0 +1,87 @@
/**
* ============================================================================
* LOCALE CONSTANTS
* ============================================================================
*/
import type { SupportedLocale, LocaleInfo } from '@/types';
/**
* Locale por defecto del sistema
*/
export const DEFAULT_LOCALE: SupportedLocale = 'es';
/**
* Lista de todos los locales soportados
*/
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
'es',
'en',
'de',
'fr',
'it',
'pt',
'ca',
'eu',
'gl'
] as const;
/**
* Metadata de locales soportados
*/
export const LOCALE_INFO: Record<SupportedLocale, LocaleInfo> = {
es: {
code: 'es',
name: 'Spanish',
nativeName: 'Español',
flag: '🇪🇸'
},
en: {
code: 'en',
name: 'English',
nativeName: 'English',
flag: '🇬🇧'
},
de: {
code: 'de',
name: 'German',
nativeName: 'Deutsch',
flag: '🇩🇪'
},
fr: {
code: 'fr',
name: 'French',
nativeName: 'Français',
flag: '🇫🇷'
},
it: {
code: 'it',
name: 'Italian',
nativeName: 'Italiano',
flag: '🇮🇹'
},
pt: {
code: 'pt',
name: 'Portuguese',
nativeName: 'Português',
flag: '🇵🇹'
},
ca: {
code: 'ca',
name: 'Catalan',
nativeName: 'Català',
flag: '🏴'
},
eu: {
code: 'eu',
name: 'Basque',
nativeName: 'Euskara',
flag: '🏴'
},
gl: {
code: 'gl',
name: 'Galician',
nativeName: 'Galego',
flag: '🏴'
}
};

@ -0,0 +1,13 @@
/**
* ============================================================================
* ENGINE - Public API
* ============================================================================
*
* Business logic del Visual Configuration Engine
*/
// JSON Logic Engine
export * from './json-logic';
// Visual Configuration Engine
export * from './visual-config';

@ -0,0 +1,130 @@
/**
* ============================================================================
* JSON LOGIC EVALUATOR
* ============================================================================
*/
import type { JsonLogic } from '@/types';
/**
* Evaluador de expresiones JsonLogic
*/
export class JsonLogicEvaluator {
/**
* Evalúa una expresión JsonLogic
*/
evaluate(expression: JsonLogic, data: Record<string, any>): any {
// Primitivos
if (expression === null) return null;
if (typeof expression === 'boolean') return expression;
if (typeof expression === 'number') return expression;
if (typeof expression === 'string') return expression;
if (Array.isArray(expression)) {
return expression.map(item => this.evaluate(item, data));
}
if (typeof expression !== 'object') return expression;
// Operadores
const expr = expression as Record<string, any>;
const operator = Object.keys(expr)[0];
const args = expr[operator];
switch (operator) {
// Variables
case 'var':
return this.getVar(args, data);
// Lógicos
case '==':
case '===':
return this.evaluateAll(args, data).every((v, i, arr) =>
i === 0 || v === arr[0]
);
case '!=':
case '!==':
return this.evaluate(args[0], data) !== this.evaluate(args[1], data);
case '!':
return !this.evaluate(args, data);
case 'and':
return this.evaluateAll(args, data).every(Boolean);
case 'or':
return this.evaluateAll(args, data).some(Boolean);
// Comparación
case '>':
return this.evaluate(args[0], data) > this.evaluate(args[1], data);
case '>=':
return this.evaluate(args[0], data) >= this.evaluate(args[1], data);
case '<':
return this.evaluate(args[0], data) < this.evaluate(args[1], data);
case '<=':
return this.evaluate(args[0], data) <= this.evaluate(args[1], data);
// Condicionales
case 'if':
const [condition, thenBranch, elseBranch] = args;
return this.evaluate(condition, data)
? this.evaluate(thenBranch, data)
: this.evaluate(elseBranch !== undefined ? elseBranch : null, data);
// Arrays
case 'in':
const [item, array] = args;
const evaluatedItem = this.evaluate(item, data);
const evaluatedArray = this.evaluate(array, data);
return Array.isArray(evaluatedArray) && evaluatedArray.includes(evaluatedItem);
// Matemáticos
case '+':
return this.evaluateAll(args, data).reduce((a, b) => a + b, 0);
case '-':
const nums = this.evaluateAll(args, data);
return nums.length === 1 ? -nums[0] : nums[0] - nums[1];
case '*':
return this.evaluateAll(args, data).reduce((a, b) => a * b, 1);
case '/':
const [dividend, divisor] = this.evaluateAll(args, data);
return divisor !== 0 ? dividend / divisor : null;
case '%':
return this.evaluate(args[0], data) % this.evaluate(args[1], data);
default:
console.warn(`[JsonLogic] Unknown operator: ${operator}`);
return null;
}
}
private evaluateAll(args: any[], data: Record<string, any>): any[] {
return (Array.isArray(args) ? args : [args]).map(arg =>
this.evaluate(arg, data)
);
}
private getVar(path: string | any, data: Record<string, any>): any {
if (typeof path !== 'string') {
path = this.evaluate(path, data);
}
const parts = String(path).split('.');
let current: any = data;
for (const part of parts) {
if (current === undefined || current === null) return undefined;
current = current[part];
}
return current;
}
}

@ -0,0 +1,5 @@
/**
* JSON Logic Engine - Public API
*/
export * from './evaluator';

@ -0,0 +1,56 @@
/**
* ============================================================================
* ASYNC IMAGE GENERATOR
* ============================================================================
*/
import type { Section, AttrID, Value } from '@/types';
import { generateSectionImageUrl } from '@/engine';
/**
* Generación async de imagen via API
*/
export async function generateSectionImageAsync(
section: Section,
globalAttributes: Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): Promise<string> {
const config = section.visualConfig;
const fallback = config.fallbackImage || '/images/fallback.jpg';
if (config.strategy !== 'api_generated' || !config.apiConfig) {
return generateSectionImageUrl(section, globalAttributes, sectionAttributes);
}
try {
const params: Record<string, Value> = {};
config.apiConfig.params?.forEach(attrId => {
const value = globalAttributes[attrId] ?? sectionAttributes[attrId];
if (value !== undefined) params[attrId] = value;
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.apiConfig.timeout || 5000);
const response = await fetch(config.apiConfig.endpoint, {
method: config.apiConfig.method || 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sectionId: section.id, attributes: params }),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
console.error(`[Render] API ${config.apiConfig.endpoint} returned ${response.status}`);
return fallback;
}
const data = await response.json();
return data.imageUrl || fallback;
} catch (error) {
console.error(`[Render] Network or Timeout error for ${section.id}:`, error);
return fallback;
}
}

@ -0,0 +1,54 @@
/**
* ============================================================================
* ATTRIBUTE CACHE
* ============================================================================
*/
import type { Attribute, ID } from '@/types';
/**
* Cache para utilities del engine
*/
export class AttributeCache {
private uiVisibleCache = new Map<ID<'attr'>, boolean>();
private visualAttributesCache: Attribute[] | null = null;
private priceAttributesCache: Attribute[] | null = null;
invalidate(): void {
this.uiVisibleCache.clear();
this.visualAttributesCache = null;
this.priceAttributesCache = null;
}
getUIVisible(
attrId: ID<'attr'>,
compute: () => boolean
): boolean {
if (this.uiVisibleCache.has(attrId)) {
return this.uiVisibleCache.get(attrId)!;
}
const result = compute();
this.uiVisibleCache.set(attrId, result);
return result;
}
getVisualAttributes(
compute: () => Attribute[]
): Attribute[] {
if (this.visualAttributesCache) {
return this.visualAttributesCache;
}
this.visualAttributesCache = compute();
return this.visualAttributesCache;
}
getPriceAttributes(
compute: () => Attribute[]
): Attribute[] {
if (this.priceAttributesCache) {
return this.priceAttributesCache;
}
this.priceAttributesCache = compute();
return this.priceAttributesCache;
}
}

@ -0,0 +1,34 @@
/**
* ============================================================================
* DISPLAY FLAG EVALUATOR
* ============================================================================
*/
import type {JsonLogic, ID, Value, AttrID} from '@/types';
import type { JsonLogicEvaluator } from '@/engine';
import { extractIdPart } from '@/utils';
/**
* Evalúa display flags con soporte para condicionales
*/
export function evaluateDisplayFlag(
flag: boolean | JsonLogic | undefined,
defaultValue: boolean,
attributeValues: Record<AttrID, Value>,
evaluator: JsonLogicEvaluator
): boolean {
if (flag === undefined) return defaultValue;
if (typeof flag === 'boolean') return flag;
// Es JsonLogic: evaluar
const data = {
attributes: Object.fromEntries(
(Object.entries(attributeValues) as [AttrID, Value][]).map(([id, value]) => [
extractIdPart(id, 'at'),
value
])
)
};
return evaluator.evaluate(flag, data);
}

@ -0,0 +1,38 @@
/**
* ============================================================================
* IMAGE GENERATOR
* ============================================================================
*/
import type { Section, AttrID, Value } from '@/types';
import { buildSectionImageUrl } from '@/utils';
/**
* Genera URL de imagen para una sección
* Utiliza el URL builder centralizado
*/
export function generateSectionImageUrl(
section: Section,
globalAttributes: Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): string {
const config = section.visualConfig;
if (!config.imageUrlTemplate) {
console.warn(`[Render] No imageUrlTemplate for section ${section.id}`);
return config.fallbackImage || '/images/fallback.jpg';
}
return buildSectionImageUrl(
config.imageUrlTemplate,
section.id,
{
basePath: config.basePath,
fallbackImage: config.fallbackImage,
globalAttributeDependencies: config.globalAttributeDependencies,
sectionAttributeDependencies: config.sectionAttributeDependencies
},
globalAttributes,
sectionAttributes
);
}

@ -0,0 +1,9 @@
/**
* Visual Configuration Engine - Public API
*/
export * from './display-evaluator';
export * from './image-generator';
export * from './async-generator';
export * from './rerender-detector';
export * from './cache';

@ -0,0 +1,31 @@
/**
* ============================================================================
* RERENDER DETECTOR
* ============================================================================
*/
import type { Section, AttrID , Value } from '@/types';
import { deepEqual } from '@/utils';
/**
* Determina si una sección debe re-renderizarse
*/
export function shouldRerenderSection(
section: Section,
changedAttributeId: AttrID,
oldValue: Value,
newValue: Value,
isGlobalAttribute: boolean
): boolean {
const config = section.visualConfig;
// Verificar si el atributo afecta a esta sección
const affectsThisSection = isGlobalAttribute
? config.globalAttributeDependencies?.includes(changedAttributeId)
: config.sectionAttributeDependencies?.includes(changedAttributeId);
if (!affectsThisSection) return false;
// Deep equality para valores complejos
return !deepEqual(oldValue, newValue);
}

@ -0,0 +1,21 @@
/**
* ============================================================================
* VISUAL CONFIGURATION ENGINE - Public API
* ============================================================================
*
* Sistema completo de configuración visual dinámica
*
* @packageDocumentation
*/
// Types (re-export all types)
export * from './types';
// Constants
export * from './constants';
// Utilities
export * from './utils';
// Engine (Business Logic)
export * from './engine';

@ -0,0 +1,6 @@
// index.ts
export * from './messages';
export * from './logger';

@ -0,0 +1,233 @@
/**
* ============================================================================
* LOGGING SYSTEM - Structured Logging
* ============================================================================
*/
import type { I18nString, SupportedLocale } from '@/types';
import { translate } from '@/utils';
import type { MessageCategory } from './messages';
// ============================================================================
// LOG LEVELS
// ============================================================================
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
NONE = 4
}
// ============================================================================
// LOG ENTRY
// ============================================================================
export interface LogEntry {
timestamp : Date;
level : LogLevel;
category : MessageCategory;
message : I18nString;
context? : Record<string, any>;
locale? : SupportedLocale;
}
// ============================================================================
// LOGGER
// ============================================================================
export class Logger {
private static instance: Logger;
private logs : LogEntry[] = [];
private level : LogLevel = LogLevel.WARN;
private locale : SupportedLocale = 'es';
private maxLogs : number = 1000;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
/**
* Configura el nivel de logging
*/
setLevel(level: LogLevel): void {
this.level = level;
}
/**
* Configura el locale para los mensajes
*/
setLocale(locale: SupportedLocale): void {
this.locale = locale;
}
/**
* Configura el número máximo de logs a retener
*/
setMaxLogs(max: number): void {
this.maxLogs = max;
}
/**
* Log nivel DEBUG
*/
debug(category: MessageCategory, message: I18nString, context?: Record<string, any>): void {
this.log(LogLevel.DEBUG, category, message, context);
}
/**
* Log nivel INFO
*/
info(category: MessageCategory, message: I18nString, context?: Record<string, any>): void {
this.log(LogLevel.INFO, category, message, context);
}
/**
* Log nivel WARN
*/
warn(category: MessageCategory, message: I18nString, context?: Record<string, any>): void {
this.log(LogLevel.WARN, category, message, context);
}
/**
* Log nivel ERROR
*/
error(category: MessageCategory, message: I18nString, context?: Record<string, any>): void {
this.log(LogLevel.ERROR, category, message, context);
}
/**
* Log interno
*/
private log(
level : LogLevel,
category: MessageCategory,
message : I18nString,
context?: Record<string, any>
): void {
// Skip si el nivel es menor al configurado
if (level < this.level) return;
const entry: LogEntry = {
timestamp: new Date(),
level,
category,
message,
context,
locale: this.locale
};
// Añadir al historial
this.logs.push(entry);
// Limitar tamaño del historial
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
// Output a consola
this.outputToConsole(entry);
}
/**
* Output formateado a consola
*/
private outputToConsole(entry: LogEntry): void {
const translatedMessage = translate(entry.message, entry.locale || this.locale);
const prefix = `[${entry.category}]`;
const timestamp = entry.timestamp.toISOString();
switch (entry.level) {
case LogLevel.DEBUG:
console.debug(timestamp, prefix, translatedMessage, entry.context || '');
break;
case LogLevel.INFO:
console.info(timestamp, prefix, translatedMessage, entry.context || '');
break;
case LogLevel.WARN:
console.warn(timestamp, prefix, translatedMessage, entry.context || '');
break;
case LogLevel.ERROR:
console.error(timestamp, prefix, translatedMessage, entry.context || '');
break;
}
}
/**
* Obtiene los logs filtrados
*/
getLogs(filters?: {
level?: LogLevel;
category?: MessageCategory;
since?: Date;
}): LogEntry[] {
let filtered = this.logs;
if (filters?.level !== undefined) {
filtered = filtered.filter(log => log.level === filters.level);
}
if (filters?.category) {
filtered = filtered.filter(log => log.category === filters.category);
}
if (filters?.since) {
filtered = filtered.filter(log => log.timestamp >= filters.since!);
}
return filtered;
}
/**
* Limpia todos los logs
*/
clear(): void {
this.logs = [];
}
/**
* Exporta logs como JSON
*/
export(): string {
return JSON.stringify(this.logs, null, 2);
}
}
// ============================================================================
// INSTANCIA SINGLETON EXPORTADA
// ============================================================================
export const logger = Logger.getInstance();
// ============================================================================
// HELPERS
// ============================================================================
/**
* Configuración rápida para desarrollo
*/
export function setupDevelopmentLogging(): void {
logger.setLevel(LogLevel.DEBUG);
logger.setMaxLogs(5000);
}
/**
* Configuración para producción
*/
export function setupProductionLogging(): void {
logger.setLevel(LogLevel.ERROR);
logger.setMaxLogs(500);
}
/**
* Configuración para tests
*/
export function setupTestLogging(): void {
logger.setLevel(LogLevel.NONE);
}

@ -0,0 +1,197 @@
// messages.ts
/**
* ============================================================================
* MESSAGES SYSTEM - Error & Warning Messages
* ============================================================================
*
* Sistema centralizado de mensajes con soporte i18n
*/
import type { I18nString, AttrID, SectionID } from '@/types';
// ============================================================================
// ERROR MESSAGES
// ============================================================================
export const ERRORS = {
// ID Errors
INVALID_ID_FORMAT: (id: string): I18nString => ({
es: `Formato de ID inválido: "${id}". Formato esperado: "prefijo:valor"`,
en: `Invalid ID format: "${id}". Expected format: "prefix:value"`
}),
ID_PREFIX_MISMATCH: (id: string, expected: string, got: string): I18nString => ({
es: `Error de prefijo: se esperaba "${expected}:", se obtuvo "${got}:" en ID "${id}"`,
en: `Prefix mismatch: expected "${expected}:", got "${got}:" in ID "${id}"`
}),
EMPTY_ID_VALUE: (id: string): I18nString => ({
es: `Parte del ID vacía en: "${id}"`,
en: `Empty ID part in: "${id}"`
}),
INVALID_ID_VALUE: (value: string): I18nString => ({
es: `Valor de ID inválido: "${value}". No debe estar vacío o contener ":"`,
en: `Invalid ID value: "${value}". Must not be empty or contain ":"`
}),
// I18n Errors
INVALID_I18N_STRING: (locale: string): I18nString => ({
es: `I18nString inválido. Debe ser un string o un objeto con al menos la clave '${locale}'`,
en: `Invalid I18nString. Must be a string or object with at least '${locale}' key`
}),
MISSING_DEFAULT_TRANSLATION: (locale: string): I18nString => ({
es: `Falta la traducción obligatoria en '${locale}'`,
en: `Missing required translation for '${locale}'`
}),
// Attribute Errors
COMPUTED_NOT_READONLY: (attrId: AttrID): I18nString => ({
es: `Atributo calculado ${attrId} debe ser de solo lectura`,
en: `Computed attribute ${attrId} should be readonly`
}),
COMPUTED_NO_DEPENDENCIES: (attrId: AttrID): I18nString => ({
es: `Atributo calculado ${attrId} no tiene dependencias`,
en: `Computed attribute ${attrId} has no dependencies`
}),
// Section Errors
NO_IMAGE_TEMPLATE: (sectionId: SectionID): I18nString => ({
es: `No hay imageUrlTemplate para la sección ${sectionId}`,
en: `No imageUrlTemplate for section ${sectionId}`
}),
UNRESOLVED_PLACEHOLDERS: (placeholders: string[], template: string): I18nString => ({
es: `Placeholders sin resolver: ${placeholders.join(', ')} en template: ${template}`,
en: `Unresolved placeholders: ${placeholders.join(', ')} in template: ${template}`
}),
PLACEHOLDER_NOT_IN_DEPENDENCIES: (placeholder: string, sectionId: SectionID): I18nString => ({
es: `Placeholder ${placeholder} no está en las dependencias de ${sectionId}`,
en: `Placeholder ${placeholder} not in dependencies for ${sectionId}`
}),
// API Errors
API_REQUEST_FAILED: (endpoint: string, status: number): I18nString => ({
es: `La API ${endpoint} retornó código ${status}`,
en: `API ${endpoint} returned status ${status}`
}),
API_TIMEOUT: (endpoint: string, timeout: number): I18nString => ({
es: `Timeout de ${timeout}ms alcanzado para ${endpoint}`,
en: `Timeout of ${timeout}ms reached for ${endpoint}`
}),
NETWORK_ERROR: (sectionId: SectionID): I18nString => ({
es: `Error de red o timeout para la sección ${sectionId}`,
en: `Network or timeout error for section ${sectionId}`
}),
// JsonLogic Errors
UNKNOWN_OPERATOR: (operator: string): I18nString => ({
es: `Operador desconocido: ${operator}`,
en: `Unknown operator: ${operator}`
})
} as const;
// ============================================================================
// WARNING MESSAGES
// ============================================================================
export const WARNINGS = {
// Attribute Warnings
FIXED_AFFECTS_PRICE: (attrId: AttrID): I18nString => ({
es: `Atributo fijo ${attrId} no debería afectar al precio`,
en: `Fixed attribute ${attrId} should not affect price`
}),
// Rendering Warnings
MISSING_GLOBAL_ATTRIBUTE: (attrId: AttrID): I18nString => ({
es: `Falta atributo global: ${attrId}, usando 'default'`,
en: `Missing global attribute: ${attrId}, using 'default'`
}),
MISSING_SECTION_ATTRIBUTE: (attrId: AttrID): I18nString => ({
es: `Falta atributo de sección: ${attrId}, usando 'default'`,
en: `Missing section attribute: ${attrId}, using 'default'`
}),
INVALID_SECTION_ID_FORMAT: (sectionId: SectionID): I18nString => ({
es: `Formato de ID de sección inválido: ${sectionId}`,
en: `Invalid section ID format: ${sectionId}`
}),
INVALID_VIEW_ID_FORMAT: (viewId: string): I18nString => ({
es: `Formato de ID de vista inválido: ${viewId}`,
en: `Invalid view ID format: ${viewId}`
})
} as const;
// ============================================================================
// VALIDATION MESSAGES
// ============================================================================
export const VALIDATION = {
REQUIRED_FIELD: (fieldName: I18nString): I18nString => ({
es: `El campo ${typeof fieldName === 'string' ? fieldName : fieldName.es} es obligatorio`,
en: `Field ${typeof fieldName === 'string' ? fieldName : fieldName.en} is required`
}),
INVALID_VALUE: (fieldName: I18nString, value: any): I18nString => ({
es: `Valor inválido para ${typeof fieldName === 'string' ? fieldName : fieldName.es}: ${value}`,
en: `Invalid value for ${typeof fieldName === 'string' ? fieldName : fieldName.en}: ${value}`
}),
OUT_OF_RANGE: (min: number, max: number): I18nString => ({
es: `El valor debe estar entre ${min} y ${max}`,
en: `Value must be between ${min} and ${max}`
}),
INVALID_FORMAT: (format: string): I18nString => ({
es: `Formato inválido. Se esperaba: ${format}`,
en: `Invalid format. Expected: ${format}`
})
} as const;
// ============================================================================
// INFO MESSAGES
// ============================================================================
export const INFO = {
USING_FALLBACK_IMAGE: (sectionId: SectionID): I18nString => ({
es: `Usando imagen de fallback para ${sectionId}`,
en: `Using fallback image for ${sectionId}`
}),
CACHE_HIT: (key: string): I18nString => ({
es: `Cache hit para: ${key}`,
en: `Cache hit for: ${key}`
}),
CACHE_MISS: (key: string): I18nString => ({
es: `Cache miss para: ${key}`,
en: `Cache miss for: ${key}`
})
} as const;
// ============================================================================
// MESSAGE CATEGORIES (para logging estructurado)
// ============================================================================
export const MESSAGE_CATEGORIES = {
ID : 'ID',
I18N : 'I18N',
ATTRIBUTE : 'ATTRIBUTE',
SECTION : 'SECTION',
RENDER : 'RENDER',
API : 'API',
JSON_LOGIC : 'JSON_LOGIC',
VALIDATION : 'VALIDATION',
CACHE : 'CACHE'
} as const;
export type MessageCategory = typeof MESSAGE_CATEGORIES[keyof typeof MESSAGE_CATEGORIES];

@ -0,0 +1,19 @@
// api.types.ts
import type {AttrID} from "@/types";
export type APIMethod =
'GET' |
'POST'
export interface APIConfig {
endpoint : string;
method? : APIMethod;
params? : string;
timeout? : number;
};

@ -0,0 +1,15 @@
/**
* Core Types - Public API
*/
export * from './primitives.types';
export * from './metadata.types';
export * from './pricing.types.ts';
export * from './media.types.ts';
export * from './severity.types.ts';
export * from './api.types.ts';
// Re-export specific branded ID types for convenience
export type { ID, Value, DataType } from './primitives.types';
export type { Metadata } from './metadata.types.ts';

@ -0,0 +1,10 @@
// media.types.ts
export interface Media {
thumbnail? : string;
image? : string;
icon? : string;
gallery? : string[];
}

@ -0,0 +1,12 @@
/**
* ============================================================================
* METADATA - Extended Information
* ============================================================================
*/
/**
* Metadata flexible para extender cualquier entidad
*/
export type Metadata = Record<string, unknown>;

@ -0,0 +1,17 @@
// pricing.types.ts
/**
* ============================================================================
* PRICING TYPES
* ============================================================================
*/
import type { JsonLogic } from '@/types';
/**
* Información de precio
*/
export interface Pricing {
baseAmount : number | 'consultation';
currency? : string;
dynamicExpression?: JsonLogic;
}

@ -0,0 +1,40 @@
/**
* ============================================================================
* CORE PRIMITIVES - Base Types
* ============================================================================
*
* Tipos fundamentales del sistema
*/
/**
* ID con tipo branded para type-safety
*
* @example
* const attrId: ID<'at'> = "at:suelo"
* const sectionId: ID<'sc'> = "sc:salon"
*/
export type ID<T extends string = string> = `${T}:${string}`;
/**
* Valores que pueden tomar los atributos
*/
export type Value =
| string
| number
| boolean
| Date
| ID
| Value[];
/**
* Tipos de datos soportados
*/
export type DataType =
| 'string'
| 'number'
| 'boolean'
| 'date'
| 'currency'
| 'dimension'
| 'reference'
| 'list';

@ -0,0 +1,8 @@
// severity.types.ts
export type Severity =
'error' |
'warning' |
'info';

@ -0,0 +1,60 @@
/**
* ============================================================================
* INTERNATIONALIZATION TYPES
* ============================================================================
*/
/**
* Locales soportados por el sistema
*/
export type SupportedLocale =
| 'es' // Español
| 'en' // English
| 'de' // Deutsch
| 'fr' // Français
| 'it' // Italiano
| 'pt' // Português
| 'ca' // Català
| 'eu' // Euskara
| 'gl'; // Galego
/**
* Objeto con traducciones tipadas
* Garantiza que al menos existe el locale por defecto (español)
*/
export type I18nTranslations = {
[K in SupportedLocale]?: string;
} & {
es: string; // Español es obligatorio
};
/**
* String internacionalizable
*
* Puede ser:
* - `string`: Texto simple (mismo en todos los idiomas)
* - `I18nTranslations`: Objeto con traducciones por locale
*
* @example
* // Simple (mismo en todos los idiomas)
* const label: I18nString = "OK";
*
* // Con traducciones
* const greeting: I18nString = {
* es: "Hola",
* en: "Hello",
* de: "Hallo"
* };
*/
export type I18nString = string | I18nTranslations;
/**
* Información sobre un locale
*/
export interface LocaleInfo {
code : SupportedLocale;
name : string;
nativeName : string;
flag : string;
rtl? : boolean;
}

@ -0,0 +1,5 @@
/**
* i18n Types - Public API
*/
export * from './i18n.types';

@ -0,0 +1,22 @@
/**
* ============================================================================
* TYPES - Public API
* ============================================================================
*
* Sistema de tipos completo del Visual Configuration Engine
*/
// Core Types
export * from './core';
// i18n Types
export * from './i18n';
// Logic Types
export * from './logic';
// Model Types
export * from './model';
// View Types
export * from './view';

@ -0,0 +1,5 @@
/**
* Logic Types - Public API
*/
export * from './json-logic.types';

@ -0,0 +1,17 @@
/**
* ============================================================================
* JSON LOGIC TYPES
* ============================================================================
*/
/**
* Expresión JsonLogic
* Permite lógica condicional declarativa
*/
export type JsonLogic =
| { var: string }
| { [op: string]: any }
| string
| number
| boolean
| null;

@ -0,0 +1,164 @@
/**
* ============================================================================
* ATTRIBUTE TYPES
* ============================================================================
*/
import type { I18nString } from '../i18n';
import type { Metadata, Value, DataType } from '../core';
import type { AttrID, OptionID, SectionID, JsonLogic } from '@/types';
// ============================================================================
// ATTRIBUTE DISPLAY
// ============================================================================
/**
* Configuración de visualización de un atributo
* Los flags pueden ser booleanos o expresiones condicionales
*/
export interface AttributeDisplay {
/**
* Si se muestra en UI
* - boolean: true/false estático
* - JsonLogic: Evaluado dinámicamente
*/
uiVisible?: boolean | JsonLogic;
/**
* Si afecta a la visualización
*/
affectsVisual?: boolean | JsonLogic;
/**
* Si afecta al precio
*/
affectsPrice?: boolean | JsonLogic;
/**
* Si es de solo lectura
*/
readonly?: boolean;
/**
* Orden de visualización
*/
order?: number;
/**
* Icono
*/
icon?: string;
/**
* Texto de ayuda
*/
helpText?: I18nString;
}
// ============================================================================
// ATTRIBUTE TYPES
// ============================================================================
export type AttributeType =
'fixed' |
'dynamic' |
'quantifiable' |
'computed';
export type AttributeCategory =
'physical' |
'aesthetic' |
'functional' |
'structural' |
'technical' |
'financial';
// ============================================================================
// BASE ATTRIBUTE
// ============================================================================
export interface BaseAttribute {
id : AttrID;
name : I18nString;
description : I18nString;
type : AttributeType;
category? : AttributeCategory;
display : AttributeDisplay;
metadata? : Metadata;
}
// ============================================================================
// FIXED ATTRIBUTE
// ============================================================================
export interface FixedAttribute extends BaseAttribute {
type : 'fixed';
dataType: DataType;
value : Value;
unit? : I18nString;
}
// ============================================================================
// DYNAMIC ATTRIBUTE
// ============================================================================
export interface DynamicAttribute extends BaseAttribute {
type : 'dynamic';
dataType : 'reference';
defaultValue : OptionID;
options : AttributeOption[];
required? : boolean;
isController? : boolean;
controls? : SectionID[];
filterExpression?: JsonLogic;
}
export interface AttributeOption {
optionId : OptionID;
priority? : number;
pricingOverride? : number;
metadata? : Metadata;
}
// ============================================================================
// QUANTIFIABLE ATTRIBUTE
// ============================================================================
export interface QuantifiableAttribute extends BaseAttribute {
type : 'quantifiable';
dataType : 'number';
quantity : QuantityDefinition;
unit? : I18nString;
userConfigurable?: boolean;
pricePerUnit? : number;
min? : number;
max? : number;
}
export type QuantityDefinition =
| number
| { min: number; max: number; step?: number; default: number }
| { values: number[]; default: number };
// ============================================================================
// COMPUTED ATTRIBUTE
// ============================================================================
export interface ComputedAttribute extends BaseAttribute {
type : 'computed';
dataType : DataType;
unit? : I18nString;
expression : JsonLogic;
dependencies: AttrID[];
}
// ============================================================================
// UNION TYPE
// ============================================================================
export type Attribute =
| FixedAttribute
| DynamicAttribute
| QuantifiableAttribute
| ComputedAttribute;

@ -0,0 +1,28 @@
/**
* ============================================================================
* CONFIGURATION CATALOG TYPES
* ============================================================================
*/
import type { I18nString } from '../i18n';
import type {
OptionID,
ObjectID,
RuleID,
OptionDefinition,
ConfigurableObject,
ValidationRule
} from '@/types';
export interface ConfigurationCatalog {
name : I18nString;
description? : I18nString;
supportedLocales: string[];
defaultLocale : string;
options : Record<OptionID, OptionDefinition>;
objects : Record<ObjectID, ConfigurableObject>;
rules? : Record<RuleID, ValidationRule>;
}

@ -0,0 +1,18 @@
/**
* ============================================================================
* MODEL - Branded ID Types
* ============================================================================
*/
import type { ID } from '../core';
/**
* ID específicos con prefijos
*/
export type AttrID = ID<'at'>;
export type SectionID = ID<'sc'>;
export type OptionID = ID<'op'>;
export type ObjectID = ID<'ob'>;
export type RuleID = ID<'rl'>;
export type ViewID = ID<'vw'>;
export type HotspotID = ID<'hs'>;

@ -0,0 +1,25 @@
/**
* Model Types - Public API
*/
// IDs
export * from './ids.types';
// Attributes
export * from './attribute.types';
// Sections
export * from './section.types';
// Objects
export * from './object.types';
// Options
export * from './option.types';
// Rules
export * from './rule.types';
// Catalog
export * from './catalog.types';

@ -0,0 +1,22 @@
/**
* ============================================================================
* CONFIGURABLE OBJECT TYPES
* ============================================================================
*/
import type { I18nString } from '../i18n';
import type { Metadata } from '../core';
import type { ObjectID, SectionID, Attribute, Section } from '@/types';
export interface ConfigurableObject {
id : ObjectID;
name : I18nString;
description : I18nString;
attributes : Attribute[];
sections : Record<SectionID, Section>;
sectionOrder?: SectionID[];
category? : string;
metadata? : Metadata;
}

@ -0,0 +1,22 @@
/**
* ============================================================================
* OPTION DEFINITION TYPES
* ============================================================================
*/
import type {Pricing,Metadata,Media} from "@/types/core";
import type { OptionID } from '@/types';
import type { I18nString } from '../i18n';
export interface OptionDefinition {
id : OptionID;
name : I18nString;
description : I18nString;
media? : Media;
pricing? : Pricing;
tags? : string[];
style? : string;
metadata? : Metadata;
}

@ -0,0 +1,31 @@
/**
* ============================================================================
* VALIDATION RULE TYPES
* ============================================================================
*/
import type { RuleID, AttrID ,JsonLogic } from '@/types';
import type { Value } from '../core';
import type { I18nString } from '../i18n';
import type {Severity} from "@/types/core";
export interface ValidationRule {
id : RuleID;
name : I18nString;
condition : JsonLogic;
//TODO sacar como tipos
action: {
type: 'allow' | 'forbid' | 'require' | 'suggest';
targetAttribute: AttrID;
values: Value[];
};
priority : number;
bidirectional: boolean;
affects : AttrID[];
severity : Severity;
message : I18nString;
}

@ -0,0 +1,94 @@
/**
* ============================================================================
* SECTION TYPES
* ============================================================================
*/
import type { I18nString } from '../i18n';
import type {APIConfig, Metadata} from '../core';
import type {
AttrID,
SectionID,
Attribute,
JsonLogic } from '@/types';
// ============================================================================
// RENDERING STRATEGY
// ============================================================================
export type RenderingStrategy =
| 'static_image'
| 'dynamic_image'
| 'api_generated'
| 'three_d'
| 'composite_layers'
| 'none';
// ============================================================================
// VISUAL CONFIG
// ============================================================================
export interface SectionVisualConfig {
strategy : RenderingStrategy;
/** Template para URL estática */
imageUrlTemplate? : string;
/** Atributos globales que afectan la imagen */
globalAttributeDependencies? : AttrID[];
/** Atributos de sección que afectan la imagen */
sectionAttributeDependencies?: AttrID[];
/** Ruta base */
basePath? : string;
/** Config para generación async via API */
//TODO sacar como tipo
apiConfig? : APIConfig;
/** Config 3D */
threeDConfig?: {
modelUrl : string;
textureAttributes: Array<{
attributeId: AttrID;
scope: 'global' | 'section';
}>;
};
/** Imagen de fallback si falla generación */
fallbackImage?: string;
}
// ============================================================================
// SECTION AVAILABILITY
// ============================================================================
export interface SectionAvailability {
mode : 'required' | 'optional' | 'conditional';
condition? : JsonLogic;
dependsOn? : AttrID[];
//TODO sacar como tipos
onDeactivate?: {
action : 'clear' | 'preserve' | 'reset';
confirmWithUser?: boolean;
confirmMessage? : I18nString;
};
}
// ============================================================================
// SECTION
// ============================================================================
export interface Section {
id : SectionID;
name : I18nString;
description : I18nString;
attributes : Attribute[];
visualConfig: SectionVisualConfig;
availability: SectionAvailability;
order? : number;
icon? : string;
metadata? : Metadata;
}

@ -0,0 +1,5 @@
/**
* View Types - Public API
*/
export * from './view.types';

@ -0,0 +1,150 @@
/**
* ============================================================================
* VIEW TYPES - Visual Representation
* ============================================================================
*/
import type { I18nString } from '../i18n';
import type { ViewID, HotspotID, AttrID , RenderingStrategy, APIConfig, JsonLogic} from "@/types";
// ============================================================================
// VIEW TYPES
// ============================================================================
/**
* Tipos de vista predefinidos
*/
export type ViewType =
| 'front' // Vista frontal
| 'side' // Vista lateral
| 'rear' // Vista posterior
| 'top' // Vista desde arriba
| 'isometric' // Vista isométrica/3D
| 'panoramic' // Vista 360° panorámica
| 'detail' // Vista de detalle/zoom
| 'blueprint' // Plano/blueprint
| 'custom'; // Tipo personalizado
// ============================================================================
// HOTSPOT
// ============================================================================
/**
* Punto interactivo en una vista
*/
export interface Hotspot {
/** ID único del hotspot */
id: HotspotID;
/** Nombre descriptivo */
name: I18nString;
/** Posición X en porcentaje (0-100) */
x: number;
/** Posición Y en porcentaje (0-100) */
y: number;
/** Radio del hotspot en píxeles */
radius?: number;
/** Color del hotspot */
color?: string;
/** Icono del hotspot */
icon?: string;
/** Tipo de scope al que referencia */
scopeType: 'global' | 'section';
/** ID del atributo que este hotspot controla */
targetAttributeId: AttrID;
/** Si es visible (puede ser condicional) */
visible?: boolean | JsonLogic;
/** Tooltip al hacer hover */
tooltip?: I18nString;
/** Orden z-index para superposición */
zIndex?: number;
}
// ============================================================================
// VIEW VISUAL CONFIG
// ============================================================================
/**
* Configuración visual específica de una vista
*/
export interface ViewVisualConfig {
/** Estrategia de renderizado */
strategy: RenderingStrategy;
/** Template para URL de imagen */
imageUrlTemplate?: string;
/** Ruta base de imágenes */
basePath?: string;
/** Atributos globales que afectan a esta vista */
globalAttributeDependencies?: AttrID[];
/** Atributos de sección que afectan a esta vista */
sectionAttributeDependencies?: AttrID[];
/** Imagen de fallback */
fallbackImage?: string;
/** Config para API */
apiConfig?: APIConfig ;
/** Config 3D */
threeDConfig?: {
modelUrl: string;
cameraPosition?: { x: number; y: number; z: number };
textureAttributes?: Array<{
attributeId: AttrID;
scope: 'global' | 'section';
}>;
};
}
// ============================================================================
// SECTION VIEW
// ============================================================================
/**
* Vista visual de una sección
*/
export interface SectionView {
/** ID único de la vista */
id: ViewID;
/** Nombre de la vista */
name: I18nString;
/** Descripción */
description?: I18nString;
/** Tipo de vista */
type: ViewType;
/** Configuración visual de esta vista */
visualConfig: ViewVisualConfig;
/** Hotspots interactivos (OPCIONAL) */
hotspots?: Hotspot[];
/** Orden de esta vista en la navegación */
order?: number;
/** Si es la vista por defecto para esta sección */
isDefault?: boolean;
/** Icono para el selector de vistas */
icon?: string;
/** Si está visible (puede ser condicional) */
visible?: boolean | JsonLogic;
}

@ -0,0 +1,34 @@
/**
* ============================================================================
* DEEP EQUALITY UTILITY
* ============================================================================
*/
import type { Value } from '../../types/core';
/**
* Deep equality check para valores
*/
export function deepEqual(a: Value, b: Value): boolean {
// 1. Chequeo de identidad simple (cubre primitivos iguales)
if (a === b) return true;
// 2. Si alguno de los dos NO es un objeto o es null, devuelve false
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false;
}
// 3. Dates
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// 4. Arrays
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, idx) => deepEqual(val, b[idx]));
}
// 5. Otros tipos
return false;
}

@ -0,0 +1,5 @@
/**
* Equality Utilities - Public API
*/
export * from './deep-equal';

@ -0,0 +1,67 @@
/**
* ============================================================================
* I18N BUILDER UTILITIES
* ============================================================================
*/
import type { I18nString, I18nTranslations, SupportedLocale } from '@/types';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@/constants';
import { translate } from '@/utils';
/**
* Crear I18nString desde objeto parcial
* Garantiza que al menos existe el locale por defecto
*/
export function createI18nString(
translations: Partial<Record<SupportedLocale, string>>
): I18nString {
if (!translations[DEFAULT_LOCALE]) {
throw new Error(`I18nString must have at least '${DEFAULT_LOCALE}' translation`);
}
return translations as I18nTranslations;
}
/**
* Crear I18nString simple (mismo texto en todos los idiomas)
*/
export function createSimpleI18nString(text: string): I18nString {
return text;
}
/**
* Convertir string simple a objeto de traducciones
* (Útil para migración)
*/
export function expandToTranslations(
text: I18nString,
defaultLocale: SupportedLocale = DEFAULT_LOCALE
): I18nTranslations {
if (typeof text === 'string') {
return { [defaultLocale]: text } as I18nTranslations;
}
return text;
}
/**
* Combinar múltiples I18nStrings
* (Útil para concatenar textos)
*/
export function combineI18nStrings(
strings: I18nString[],
separator: string = ' '
): I18nString {
// Si todos son strings simples, concatenar directamente
if (strings.every(s => typeof s === 'string')) {
return strings.join(separator);
}
// Crear objeto con todas las traducciones
const combined: Partial<Record<SupportedLocale, string>> = {};
for (const locale of SUPPORTED_LOCALES) {
const parts = strings.map(s => translate(s, locale));
combined[locale] = parts.join(separator);
}
return combined as I18nTranslations;
}

@ -0,0 +1,8 @@
/**
* i18n Utilities - Public API
*/
export * from './translation.utils';
export * from './locale.utils';
export * from './builder.utils';
export * from './validation.utils';

@ -0,0 +1,40 @@
/**
* ============================================================================
* LOCALE UTILITIES
* ============================================================================
*/
import type { SupportedLocale, LocaleInfo } from '@/types';
import { SUPPORTED_LOCALES, LOCALE_INFO, DEFAULT_LOCALE } from '@/constants';
/**
* Verificar si un string es un locale soportado
*/
export function isSupportedLocale(locale: string): locale is SupportedLocale {
return SUPPORTED_LOCALES.includes(locale as SupportedLocale);
}
/**
* Obtener locale válido con fallback
*/
export function getValidLocale(
locale: string | undefined,
fallback: SupportedLocale = DEFAULT_LOCALE
): SupportedLocale {
if (!locale) return fallback;
return isSupportedLocale(locale) ? locale : fallback;
}
/**
* Extraer código de idioma de un locale (ej: "es-ES" → "es")
*/
export function extractLanguageCode(locale: string): string {
return locale.split('-')[0].toLowerCase();
}
/**
* Obtener información de un locale
*/
export function getLocaleInfo(locale: SupportedLocale): LocaleInfo {
return LOCALE_INFO[locale];
}

@ -0,0 +1,93 @@
/**
* ============================================================================
* TRANSLATION UTILITIES
* ============================================================================
*/
import type { I18nString, SupportedLocale } from '../../types/i18n';
import { DEFAULT_LOCALE } from '../../constants';
/**
* Obtener texto traducido
*
* @param text - String i18n (simple o con traducciones)
* @param locale - Locale objetivo
* @param fallback - Locale de respaldo si no existe la traducción
* @returns Texto en el idioma solicitado
*
* @example
* const text: I18nString = { es: "Hola", en: "Hello" };
* translate(text, 'es') // → "Hola"
* translate(text, 'fr') // → "Hola" (fallback a español)
* translate("OK", 'es') // → "OK" (string simple)
*/
export function translate(
text: I18nString,
locale: SupportedLocale,
fallback: SupportedLocale = DEFAULT_LOCALE
): string {
// Si es string simple, devolverlo directamente
if (typeof text === 'string') {
return text;
}
// Intentar con el locale solicitado
if (text[locale]) {
return text[locale];
}
// Fallback al locale de respaldo
if (text[fallback]) {
return text[fallback];
}
// Último recurso: primer valor disponible
const firstAvailable = Object.values(text)[0];
return firstAvailable || '';
}
/**
* Alias corto de translate
*/
export const t = translate;
/**
* Verificar si un I18nString tiene traducción para un locale
*/
export function hasTranslation(
text: I18nString,
locale: SupportedLocale
): boolean {
if (typeof text === 'string') return true;
return text[locale] !== undefined;
}
/**
* Obtener todos los locales disponibles en un I18nString
*/
export function getAvailableLocales(text: I18nString): SupportedLocale[] {
if (typeof text === 'string') {
// String simple está disponible en todos los locales
return [...SUPPORTED_LOCALES];
}
return Object.keys(text) as SupportedLocale[];
}
/**
* Verificar si un I18nString está completo (tiene todas las traducciones)
*/
export function isComplete(text: I18nString): boolean {
if (typeof text === 'string') return true;
return SUPPORTED_LOCALES.every(locale => text[locale] !== undefined);
}
/**
* Obtener locales faltantes en un I18nString
*/
export function getMissingLocales(text: I18nString): SupportedLocale[] {
if (typeof text === 'string') return [];
return SUPPORTED_LOCALES.filter(locale => text[locale] === undefined);
}
// Re-export SUPPORTED_LOCALES for convenience
import { SUPPORTED_LOCALES } from '@/constants';

@ -0,0 +1,46 @@
/**
* ============================================================================
* I18N VALIDATION UTILITIES
* ============================================================================
*/
import type { I18nString } from '@/types';
import { DEFAULT_LOCALE } from '@/constants';
/**
* Validar que un I18nString es correcto
*/
export function validateI18nString(text: unknown): text is I18nString {
if (typeof text === 'string') return true;
if (typeof text !== 'object' || text === null) return false;
const obj = text as Record<string, unknown>;
// Debe tener al menos el locale por defecto
if (typeof obj[DEFAULT_LOCALE] !== 'string') return false;
// Todos los valores deben ser strings
return Object.values(obj).every(v => typeof v === 'string');
}
/**
* Error para I18nString inválido
*/
export class I18nStringError extends Error {
constructor(message: string) {
super(`[I18n] ${message}`);
this.name = 'I18nStringError';
}
}
/**
* Validar y lanzar error si es inválido
*/
export function assertValidI18nString(text: unknown): asserts text is I18nString {
if (!validateI18nString(text)) {
throw new I18nStringError(
`Invalid I18nString. Must be a string or object with at least '${DEFAULT_LOCALE}' key.`
);
}
}

@ -0,0 +1,362 @@
// utils/ids-improved.ts
/**
* ============================================================================
* ID UTILITIES - VERSIÓN MEJORADA
* ============================================================================
*
* Sistema robusto y type-safe para manejar IDs con prefijos
*
* Mejoras sobre versión original:
* - Cache para performance
* - Validación centralizada
* - Error handling robusto
* - Type-safe parsing
* - Fácil de testear
*
* ============================================================================
*/
import type {AttrID, ObjectID, OptionID, RuleID, SectionID, HotspotID, ViewID, ID} from "@/types";
import {ERRORS, logger, MESSAGE_CATEGORIES} from "@/messages";
// ============================================================================
// TYPES
// ============================================================================
export type IDPrefix = 'at' | 'op' | 'sc' | 'ob' | 'rl' | 'vw' | 'hs';
export interface ParsedID {
prefix: string;
value: string;
fullId: string;
}
// ============================================================================
// CACHE
// ============================================================================
/**
* Cache LRU simple para parsing de IDs
* Evita splits repetitivos en hot paths
*/
class IDCache {
private cache = new Map<string, string>();
private maxSize = 500;
get(id: string): string | undefined {
return this.cache.get(id);
}
set(id: string, value: string): void {
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(id, value);
}
clear(): void {
this.cache.clear();
}
}
const idPartCache = new IDCache();
// ============================================================================
// VALIDATION
// ============================================================================
/**
* Valida que un string tenga formato de ID válido
*/
export function isValidId(id: string): boolean {
if (!id) return false;
const parts = id.split(':');
if (parts.length !== 2) return false;
const [prefix, value] = parts;
return !!prefix && !!value;
}
/**
* Valida que un ID tenga el prefijo esperado
*/
export function hasPrefix<T extends string>(id: string, expectedPrefix: T): boolean {
const parts = id.split(':');
return parts[0] === expectedPrefix;
}
// ============================================================================
// PARSING
// ============================================================================
/**
* Parsea un ID en sus componentes
*
* @throws {Error} Si el ID tiene formato inválido
*
* @example
* parseId("at:suelo") // → { prefix: "at", value: "suelo", fullId: "at:suelo" }
*/
export function parseId(id: string): ParsedID {
if (!isValidId(id)) {
const errorMsg = ERRORS.INVALID_ID_FORMAT(id);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id });
throw new Error(JSON.stringify(errorMsg));
}
const [prefix, value] = id.split(':');
return {
prefix,
value,
fullId: id
};
}
/**
* Extrae solo la parte del valor del ID (después del prefijo)
* Con cache para performance
*
* @param id - ID completo (ej: "at:suelo")
* @param expectedPrefix - Prefijo esperado (opcional, para validación)
* @returns La parte del valor (ej: "suelo")
*
* @throws {Error} Si el ID es inválido o no coincide el prefijo
*
* @example
* extractIdPart("at:suelo") // → "suelo"
* extractIdPart("at:suelo", "at") // → "suelo" ✅
* extractIdPart("at:suelo", "sc") // → throws Error ❌
*/
export function extractIdPart<T extends string>(
id: ID<T>,
expectedPrefix?: T
): string {
// Check cache first
const cacheKey = expectedPrefix ? `${id}:${expectedPrefix}` : id;
const cached = idPartCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
// Parse and validate
const parsed = parseId(id);
// Validate prefix if provided
if (expectedPrefix && parsed.prefix !== expectedPrefix) {
throw new Error(
`ID prefix mismatch: expected "${expectedPrefix}:", got "${parsed.prefix}:" in ID "${id}"`
);
}
// Cache and return
idPartCache.set(cacheKey, parsed.value);
return parsed.value;
}
/**
* Versión safe de extractIdPart que retorna undefined en lugar de throw
*/
export function tryExtractIdPart<T extends string>(
id: ID<T>,
expectedPrefix?: T
): string | undefined {
try {
return extractIdPart(id, expectedPrefix);
} catch {
return undefined;
}
}
// ============================================================================
// BUILDERS
// ============================================================================
/**
* Crea IDs type-safe con validación
* Mejora sobre versión original que no valida
*/
export const IDS = {
/**
* Crea un AttrID
* @throws {Error} Si el valor es inválido
*/
attr: (id: string): AttrID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid attr ID value: "${id}". Must not be empty or contain ":"`);
}
return `at:${id}`;
},
opt: (id: string): OptionID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid option ID value: "${id}"`);
}
return `op:${id}`;
},
sec: (id: string): SectionID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid section ID value: "${id}"`);
}
return `sc:${id}`;
},
obj: (id: string): ObjectID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid object ID value: "${id}"`);
}
return `ob:${id}`;
},
rule: (id: string): RuleID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid rule ID value: "${id}"`);
}
return `rl:${id}`;
},
view: (id: string): ViewID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid view ID value: "${id}"`);
}
return `vw:${id}`;
},
hspot: (id: string): HotspotID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid hotspot ID value: "${id}"`);
}
return `hs:${id}`;
}
};
/**
* Versión safe de IDS que retorna undefined en lugar de throw
*/
export const SafeIDS = {
attr: (id: string): AttrID | undefined => {
try { return IDS.attr(id); } catch { return undefined; }
},
opt: (id: string): OptionID | undefined => {
try { return IDS.opt(id); } catch { return undefined; }
},
sec: (id: string): SectionID | undefined => {
try { return IDS.sec(id); } catch { return undefined; }
},
obj: (id: string): ObjectID | undefined => {
try { return IDS.obj(id); } catch { return undefined; }
},
rule: (id: string): RuleID | undefined => {
try { return IDS.rule(id); } catch { return undefined; }
},
view: (id: string): ViewID | undefined => {
try { return IDS.view(id); } catch { return undefined; }
},
hspot: (id: string): HotspotID | undefined => {
try { return IDS.hspot(id); } catch { return undefined; }
}
};
// ============================================================================
// COLLECTIONS
// ============================================================================
/**
* Extrae todas las partes de valor de una colección de IDs
*
* @example
* extractAllIdParts(["at:suelo", "at:pared"])
* // → ["suelo", "pared"]
*/
export function extractAllIdParts<T extends string>(
ids: ID<T>[],
expectedPrefix?: T
): string[] {
return ids.map(id => extractIdPart(id, expectedPrefix));
}
/**
* Convierte un array de IDs a un objeto Record con las partes como keys
*
* @example
* idsToRecord(["at:suelo", "at:pared"], (id) => attributes[id])
* // → { suelo: Value1, pared: Value2 }
*/
export function idsToRecord<T extends string, V>(
ids: ID<T>[],
getValue: (id: ID<T>) => V,
expectedPrefix?: T
): Record<string, V> {
const record: Record<string, V> = {};
for (const id of ids) {
const part = extractIdPart(id, expectedPrefix);
record[part] = getValue(id);
}
return record;
}
// ============================================================================
// UTILITIES
// ============================================================================
/**
* Limpia el cache de IDs
* Útil para tests o cuando se sospecha corrupción
*/
export function clearIdCache(): void {
idPartCache.clear();
}
/**
* Normaliza un ID (asegura formato consistente)
* Útil para IDs que vienen de input de usuario
*/
export function normalizeId(id: string, expectedPrefix?: IDPrefix): string {
const parsed = parseId(id);
if (expectedPrefix && parsed.prefix !== expectedPrefix) {
throw new Error(`Expected prefix "${expectedPrefix}", got "${parsed.prefix}"`);
}
// Normalizar el value: lowercase, trim, remove espacios extra
const normalizedValue = parsed.value
.toLowerCase()
.trim()
.replace(/\s+/g, '_');
return `${parsed.prefix}:${normalizedValue}`;
}
// ============================================================================
// EXPORTS
// ============================================================================
export default {
// Validation
isValidId,
hasPrefix,
// Parsing
parseId,
extractIdPart,
tryExtractIdPart,
// Builders
IDS,
SafeIDS,
// Collections
extractAllIdParts,
idsToRecord,
// Utilities
clearIdCache,
normalizeId
};

@ -0,0 +1,5 @@
/**
* ID Utilities - Public API
*/
export * from './id-parser';

@ -0,0 +1,23 @@
/**
* ============================================================================
* UTILITIES - Public API
* ============================================================================
*/
// i18n Utilities
export * from './i18n';
// ID Utilities
export * from './ids';
// URL Utilities
export * from './url';
// View Utilities
export * from './view';
// Validation Utilities
export * from './validation';
// Equality Utilities
export * from './equality';

@ -0,0 +1,5 @@
/**
* URL Utilities - Public API
*/
export * from './url-builder';

@ -0,0 +1,387 @@
// utils/url-builder.ts
/**
* ============================================================================
* URL TEMPLATE BUILDER - VERSIÓN MEJORADA
* ============================================================================
*
* Centraliza toda la lógica de generación de URLs con templates
* Elimina duplicación entre vce.ts y view.ts
*
* ============================================================================
*/
import type { AttrID, SectionID, ViewID } from "../../types/model";
import type { Value } from "../../types/core";
import { extractIdPart } from "../ids";
// ============================================================================
// TYPES
// ============================================================================
export interface UrlTemplateContext {
/** Ruta base opcional */
basePath?: string;
/** ID de sección */
sectionId?: SectionID;
/** ID de vista */
viewId?: ViewID;
/** Atributos globales disponibles */
globalAttributes?: Record<AttrID, Value>;
/** Atributos de sección disponibles */
sectionAttributes?: Record<AttrID, Value>;
/** IDs de atributos globales que el template necesita */
globalDependencies?: AttrID[];
/** IDs de atributos de sección que el template necesita */
sectionDependencies?: AttrID[];
}
export interface UrlBuildResult {
/** URL generada (o fallback si hubo errores) */
url: string;
/** Placeholders que no se pudieron resolver */
unresolvedPlaceholders: string[];
/** Advertencias durante la generación */
warnings: string[];
/** Si se usó la URL de fallback */
usedFallback: boolean;
}
export interface UrlBuilderOptions {
/** URL de fallback si falla la generación */
fallbackUrl?: string;
/** Modo de logging: 'none' | 'console' | 'collect' */
logMode?: 'none' | 'console' | 'collect';
/** Estrategia cuando falta un atributo: 'default' | 'skip' | 'error' */
onMissingAttribute?: 'default' | 'skip' | 'error';
}
// ============================================================================
// URL TEMPLATE BUILDER
// ============================================================================
export class UrlTemplateBuilder {
private warnings: string[] = [];
private readonly fallbackUrl: string;
private readonly options: Required<UrlBuilderOptions>;
constructor(
private template: string,
options: UrlBuilderOptions = {}
) {
this.fallbackUrl = options.fallbackUrl || '/images/fallback.jpg';
this.options = {
fallbackUrl: this.fallbackUrl,
logMode: options.logMode || 'console',
onMissingAttribute: options.onMissingAttribute || 'default'
};
}
/**
* Construye la URL reemplazando todos los placeholders
*/
build(context: UrlTemplateContext): UrlBuildResult {
this.warnings = [];
let url = this.template;
try {
// 1. Reemplazar basePath
if (context.basePath) {
url = this.replaceSimpleToken(url, 'basePath', context.basePath);
}
// 2. Reemplazar sectionId
if (context.sectionId) {
const sectionIdPart = extractIdPart(context.sectionId, 'sc');
url = this.replaceSimpleToken(url, 'sectionId', sectionIdPart);
}
// 3. Reemplazar viewId
if (context.viewId) {
const viewIdPart = extractIdPart(context.viewId, 'vw');
url = this.replaceSimpleToken(url, 'viewId', viewIdPart);
}
// 4. Reemplazar atributos globales
if (context.globalDependencies && context.globalAttributes) {
url = this.replaceAttributes(
url,
'global',
context.globalDependencies,
context.globalAttributes
);
}
// 5. Reemplazar atributos de sección
if (context.sectionDependencies && context.sectionAttributes) {
url = this.replaceAttributes(
url,
'section',
context.sectionDependencies,
context.sectionAttributes
);
}
// 6. Verificar placeholders sin resolver
const unresolved = this.findUnresolvedPlaceholders(url);
if (unresolved.length > 0) {
this.addWarning(
`Unresolved placeholders: ${unresolved.join(', ')}`,
{ template: this.template, partialUrl: url }
);
return {
url: this.fallbackUrl,
unresolvedPlaceholders: unresolved,
warnings: this.warnings,
usedFallback: true
};
}
return {
url,
unresolvedPlaceholders: [],
warnings: this.warnings,
usedFallback: false
};
} catch (error) {
this.addWarning(
`Error building URL: ${error instanceof Error ? error.message : String(error)}`,
{ template: this.template, context }
);
return {
url: this.fallbackUrl,
unresolvedPlaceholders: [],
warnings: this.warnings,
usedFallback: true
};
}
}
/**
* Reemplaza un token simple {key} → value
*/
private replaceSimpleToken(url: string, key: string, value: string): string {
const placeholder = `{${key}}`;
return url.replace(placeholder, value);
}
/**
* Reemplaza placeholders de atributos: {scope.attrName}
*/
private replaceAttributes(
url: string,
scope: 'global' | 'section',
dependencies: AttrID[],
attributes: Record<AttrID, Value>
): string {
for (const attrId of dependencies) {
const attrName = extractIdPart(attrId, 'at');
const placeholder = `{${scope}.${attrName}}`;
// Skip si el placeholder no existe en el template
if (!url.includes(placeholder)) {
continue;
}
const value = attributes[attrId];
if (value !== undefined) {
const normalized = this.normalizeValueForUrl(value);
url = url.replace(placeholder, normalized);
} else {
// Manejar atributo faltante según estrategia
url = this.handleMissingAttribute(
url,
placeholder,
scope,
attrId
);
}
}
return url;
}
/**
* Normaliza un valor para usar en URL
* - Lowercase
* - Espacios → underscores
* - Solo caracteres alfanuméricos y guiones
*/
private normalizeValueForUrl(value: Value): string {
// Arrays: join with dash
if (Array.isArray(value)) {
return value.map(v => this.normalizeValueForUrl(v)).join('-');
}
// Dates: ISO format
if (value instanceof Date) {
return value.toISOString().split('T')[0]; // YYYY-MM-DD
}
// Todo lo demás: string
return String(value)
.toLowerCase()
.trim()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_-]/g, '');
}
/**
* Maneja el caso de atributo faltante según configuración
*/
private handleMissingAttribute(
url: string,
placeholder: string,
scope: string,
attrId: AttrID
): string {
const strategy = this.options.onMissingAttribute;
switch (strategy) {
case 'default':
this.addWarning(
`Missing ${scope} attribute: ${attrId}, using 'default'`,
{ placeholder }
);
return url.replace(placeholder, 'default');
case 'skip':
this.addWarning(
`Missing ${scope} attribute: ${attrId}, skipping replacement`,
{ placeholder }
);
return url; // No reemplazar
case 'error':
throw new Error(
`Missing required ${scope} attribute: ${attrId} for placeholder ${placeholder}`
);
}
}
/**
* Encuentra placeholders que no fueron reemplazados
*/
private findUnresolvedPlaceholders(url: string): string[] {
const matches = url.match(/\{[^}]+}/g);
return matches || [];
}
/**
* Añade una advertencia al log
*/
private addWarning(message: string, context?: Record<string, any>): void {
this.warnings.push(message);
if (this.options.logMode === 'console') {
console.warn('[UrlBuilder]', message, context);
}
}
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Genera URL para una sección
* Wrapper conveniente sobre UrlTemplateBuilder
*/
export function buildSectionImageUrl(
template: string,
sectionId: SectionID,
config: {
basePath?: string;
fallbackImage?: string;
globalAttributeDependencies?: AttrID[];
sectionAttributeDependencies?: AttrID[];
},
globalAttributes: Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): string {
if (!template) {
console.warn(`[UrlBuilder] No template provided for section ${sectionId}`);
return config.fallbackImage || '/images/fallback.jpg';
}
const builder = new UrlTemplateBuilder(template, {
fallbackUrl: config.fallbackImage,
logMode: 'console',
onMissingAttribute: 'default'
});
const result = builder.build({
basePath: config.basePath,
sectionId,
globalAttributes,
sectionAttributes,
globalDependencies: config.globalAttributeDependencies,
sectionDependencies: config.sectionAttributeDependencies
});
return result.url;
}
/**
* Genera URL para una vista específica
*/
export function buildViewImageUrl(
template: string,
sectionId: SectionID,
viewId: ViewID,
config: {
basePath?: string;
fallbackImage?: string;
globalAttributeDependencies?: AttrID[];
sectionAttributeDependencies?: AttrID[];
},
globalAttributes: Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): string {
if (!template) {
return config.fallbackImage || '/images/fallback.jpg';
}
const builder = new UrlTemplateBuilder(template, {
fallbackUrl: config.fallbackImage,
logMode: 'console',
onMissingAttribute: 'default'
});
const result = builder.build({
basePath: config.basePath,
sectionId,
viewId,
globalAttributes,
sectionAttributes,
globalDependencies: config.globalAttributeDependencies,
sectionDependencies: config.sectionAttributeDependencies
});
return result.url;
}
// ============================================================================
// EXPORTS
// ============================================================================
export default {
UrlTemplateBuilder,
buildSectionImageUrl,
buildViewImageUrl
};

@ -0,0 +1,57 @@
/**
* ============================================================================
* ATTRIBUTE VALIDATOR
* ============================================================================
*/
import type {Attribute, AttrID, SectionID} from '@/types';
export type ValidationSeverity =
'error' |
'warning' ;
export interface ValidationError {
attrId? : AttrID;
sectionId? : SectionID;
message : string;
severity : ValidationSeverity;
}
/**
* Validar atributo en runtime
*/
export function validateAttribute(attr: Attribute): ValidationError[] {
const errors: ValidationError[] = [];
// Computed debe ser readonly
if (attr.type === 'computed' && !attr.display.readonly) {
errors.push({
attrId: attr.id,
message: 'Computed attributes should be readonly',
severity: 'error'
});
}
// Fixed no debería afectar precio
if (attr.type === 'fixed' && attr.display.affectsPrice) {
errors.push({
attrId: attr.id,
message: 'Fixed attributes should not affect price',
severity: 'warning'
});
}
// Verificar que dependencies existen
if (attr.type === 'computed') {
if (!attr.dependencies || attr.dependencies.length === 0) {
errors.push({
attrId: attr.id,
message: 'Computed attribute has no dependencies',
severity: 'warning'
});
}
}
return errors;
}

@ -0,0 +1,6 @@
/**
* Validation Utilities - Public API
*/
export * from './attribute.validator';
export * from './section.validator';

@ -0,0 +1,40 @@
/**
* ============================================================================
* SECTION VALIDATOR
* ============================================================================
*/
import type { Section, AttrID } from '@/types';
import type { ValidationError } from '@/utils';
/**
* Validar sección en runtime
*/
export function validateSection(section: Section): ValidationError[] {
const errors: ValidationError[] = [];
// Validar que imageUrlTemplate tiene placeholders válidos
if (section.visualConfig.imageUrlTemplate) {
const template = section.visualConfig.imageUrlTemplate;
const placeholders = template.match(/\{[^}]+}/g) || [];
placeholders.forEach(placeholder => {
const cleanName = placeholder.slice(1, -1); // Remove { }
if (cleanName.startsWith('global.')) {
const attrName = cleanName.replace('global.', '');
const attrId = `at:${attrName}` as AttrID;
if (!section.visualConfig.globalAttributeDependencies?.includes(attrId)) {
errors.push({
sectionId: section.id,
message: `Placeholder ${placeholder} not in globalAttributeDependencies`,
severity: 'error'
});
}
}
});
}
return errors;
}

@ -0,0 +1,30 @@
/**
* ============================================================================
* HOTSPOT FILTER UTILITIES
* ============================================================================
*/
import type { Hotspot, SectionView , AttrID } from '@/types';
/**
* Obtener hotspots visibles de una vista
*/
export function getVisibleHotspots(
view : SectionView,
attributeValues : Record<AttrID, any>,
evaluator : any
): Hotspot[] {
if (!view.hotspots) return [];
return view.hotspots.filter(hotspot => {
if (hotspot.visible === undefined || hotspot.visible === true) {
return true;
}
if (hotspot.visible === false) {
return false;
}
// Es JsonLogic, evaluar
const data = { attributes: attributeValues };
return evaluator.evaluate(hotspot.visible, data);
});
}

@ -0,0 +1,7 @@
/**
* View Utilities - Public API
*/
export * from './view-selector.utils';
export * from './hotspot-filter.utils';
export * from './view-image.utils';

@ -0,0 +1,61 @@
/**
* ============================================================================
* VIEW IMAGE UTILITIES
* ============================================================================
*/
import type { SectionView } from '../../types/view';
import type { SectionID, AttrID } from '../../types/model';
import type { Value } from '../../types/core';
import { extractIdPart } from '../ids';
/**
* Generar URL de imagen para una vista específica
*/
export function generateViewImageUrl(
view : SectionView,
sectionId : SectionID,
globalAttributes : Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): string {
const config = view.visualConfig;
if (!config.imageUrlTemplate) {
return config.fallbackImage || '/images/fallback.jpg';
}
let url = config.imageUrlTemplate;
// Reemplazar {basePath}
if (config.basePath) {
url = url.replace('{basePath}', config.basePath);
}
// Reemplazar {sectionId}
const sectionIdPart = extractIdPart(sectionId, 'sc');
url = url.replace('{sectionId}', sectionIdPart);
// Reemplazar {viewId}
const viewIdPart = extractIdPart(view.id, 'vw');
url = url.replace('{viewId}', viewIdPart);
// Reemplazar atributos globales
config.globalAttributeDependencies?.forEach(attrId => {
const attrName = extractIdPart(attrId, 'at');
const value = globalAttributes[attrId];
if (value !== undefined) {
url = url.replace(`{global.${attrName}}`, String(value));
}
});
// Reemplazar atributos de sección
config.sectionAttributeDependencies?.forEach(attrId => {
const attrName = extractIdPart(attrId, 'at');
const value = sectionAttributes[attrId];
if (value !== undefined) {
url = url.replace(`{section.${attrName}}`, String(value));
}
});
return url;
}

@ -0,0 +1,37 @@
/**
* ============================================================================
* VIEW SELECTOR UTILITIES
* ============================================================================
*/
import type { SectionView, AttrID } from '@/types';
/**
* Obtener la vista por defecto de una lista de vistas
*/
export function getDefaultView(views: SectionView[]): SectionView | null {
const defaultView = views.find(v => v.isDefault === true);
return defaultView || views[0] || null;
}
/**
* Filtrar vistas visibles según condiciones
*/
export function getVisibleViews(
views: SectionView[],
attributeValues: Record<AttrID, any>,
evaluator: any
): SectionView[] {
return views.filter(view => {
if (view.visible === undefined || view.visible === true) {
return true;
}
if (view.visible === false) {
return false;
}
// Es JsonLogic, evaluar
const data = { attributes: attributeValues };
return evaluator.evaluate(view.visible, data);
});
}

@ -0,0 +1,353 @@
/**
* ============================================================================
* INTERNATIONALIZATION (i18n) - Utilities
* ============================================================================
*
* Sistema de internacionalización para textos multiidioma
*
* ============================================================================
*/
import type {SupportedLocale, I18nString, I18nTranslations, LocaleInfo} from "../types/i18n.ts";
import { DEFAULT_LOCALE } from "../types/i18n.ts";
// ============================================================================
// SUPPORTED LOCALES
// ============================================================================
/**
* Lista de todos los locales soportados
*/
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
'es',
'en',
'de',
'fr',
'it',
'pt',
'ca',
'eu',
'gl'
] as const;
// ============================================================================
// LOCALE UTILITIES
// ============================================================================
/**
* Verificar si un string es un locale soportado
*/
export function isSupportedLocale(locale: string): locale is SupportedLocale {
return SUPPORTED_LOCALES.includes(locale as SupportedLocale);
}
/**
* Obtener locale válido con fallback
*/
export function getValidLocale(
locale: string | undefined,
fallback: SupportedLocale = DEFAULT_LOCALE
): SupportedLocale {
if (!locale) return fallback;
return isSupportedLocale(locale) ? locale : fallback;
}
/**
* Extraer código de idioma de un locale (ej: "es-ES" → "es")
*/
export function extractLanguageCode(locale: string): string {
return locale.split('-')[0].toLowerCase();
}
// ============================================================================
// TRANSLATION UTILITIES
// ============================================================================
/**
* Obtener texto traducido
*
* @param text - String i18n (simple o con traducciones)
* @param locale - Locale objetivo
* @param fallback - Locale de respaldo si no existe la traducción
* @returns Texto en el idioma solicitado
*
* @example
* const text: I18nString = { es: "Hola", en: "Hello" };
* t(text, 'es') // → "Hola"
* t(text, 'fr') // → "Hola" (fallback a español)
* t("OK", 'es') // → "OK" (string simple)
*/
export function t(
text: I18nString,
locale: SupportedLocale,
fallback: SupportedLocale = DEFAULT_LOCALE
): string {
// Si es string simple, devolverlo directamente
if (typeof text === 'string') {
return text;
}
// Intentar con el locale solicitado
if (text[locale]) {
return text[locale];
}
// Fallback al locale de respaldo
if (text[fallback]) {
return text[fallback];
}
// Último recurso: primer valor disponible
const firstAvailable = Object.values(text)[0];
return firstAvailable || '';
}
/**
* Alias de `t` para compatibilidad con librerías i18n
*/
export const translate = t;
/**
* Verificar si un I18nString tiene traducción para un locale
*/
export function hasTranslation(
text: I18nString,
locale: SupportedLocale
): boolean {
if (typeof text === 'string') return true;
return text[locale] !== undefined;
}
/**
* Obtener todos los locales disponibles en un I18nString
*/
export function getAvailableLocales(text: I18nString): SupportedLocale[] {
if (typeof text === 'string') return [...SUPPORTED_LOCALES];
return Object.keys(text) as SupportedLocale[];
}
/**
* Verificar si un I18nString está completo (tiene todas las traducciones)
*/
export function isComplete(text: I18nString): boolean {
if (typeof text === 'string') return true;
return SUPPORTED_LOCALES.every(locale => text[locale] !== undefined);
}
/**
* Obtener locales faltantes en un I18nString
*/
export function getMissingLocales(text: I18nString): SupportedLocale[] {
if (typeof text === 'string') return [];
return SUPPORTED_LOCALES.filter(locale => text[locale] === undefined);
}
// ============================================================================
// I18N BUILDERS
// ============================================================================
/**
* Crear I18nString desde objeto parcial
* Garantiza que al menos existe el locale por defecto
*/
export function createI18nString(
translations: Partial<Record<SupportedLocale, string>>
): I18nString {
if (!translations[DEFAULT_LOCALE]) {
throw new Error(`I18nString must have at least '${DEFAULT_LOCALE}' translation`);
}
return translations as I18nTranslations;
}
/**
* Crear I18nString simple (mismo texto en todos los idiomas)
*/
export function createSimpleI18nString(text: string): I18nString {
return text;
}
/**
* Convertir string simple a objeto de traducciones
* (útil para migración)
*/
export function expandToTranslations(
text: I18nString,
defaultLocale: SupportedLocale = DEFAULT_LOCALE
): I18nTranslations {
if (typeof text === 'string') {
return { [defaultLocale]: text } as I18nTranslations;
}
return text;
}
/**
* Combinar múltiples I18nStrings
* (útil para concatenar textos)
*/
export function combineI18nStrings(
strings: I18nString[],
separator: string = ' '
): I18nString {
// Si todos son strings simples, concatenar directamente
if (strings.every(s => typeof s === 'string')) {
return strings.join(separator);
}
// Crear objeto con todas las traducciones
const combined: Partial<Record<SupportedLocale, string>> = {};
for (const locale of SUPPORTED_LOCALES) {
const parts = strings.map(s => t(s, locale));
combined[locale] = parts.join(separator);
}
return combined as I18nTranslations;
}
// ============================================================================
// LOCALE METADATA
// ============================================================================
/**
* Metadata de locales soportados
*/
export const LOCALE_INFO: Record<SupportedLocale, LocaleInfo> = {
es: {
code: 'es',
name: 'Spanish',
nativeName: 'Español',
flag: '🇪🇸'
},
en: {
code: 'en',
name: 'English',
nativeName: 'English',
flag: '🇬🇧'
},
de: {
code: 'de',
name: 'German',
nativeName: 'Deutsch',
flag: '🇩🇪'
},
fr: {
code: 'fr',
name: 'French',
nativeName: 'Français',
flag: '🇫🇷'
},
it: {
code: 'it',
name: 'Italian',
nativeName: 'Italiano',
flag: '🇮🇹'
},
pt: {
code: 'pt',
name: 'Portuguese',
nativeName: 'Português',
flag: '🇵🇹'
},
ca: {
code: 'ca',
name: 'Catalan',
nativeName: 'Català',
flag: '🏴'
},
eu: {
code: 'eu',
name: 'Basque',
nativeName: 'Euskara',
flag: '🏴'
},
gl: {
code: 'gl',
name: 'Galician',
nativeName: 'Galego',
flag: '🏴'
}
};
/**
* Obtener información de un locale
*/
export function getLocaleInfo(locale: SupportedLocale): LocaleInfo {
return LOCALE_INFO[locale];
}
// ============================================================================
// VALIDATION
// ============================================================================
/**
* Validar que un I18nString es correcto
*/
export function validateI18nString(text: unknown): text is I18nString {
if (typeof text === 'string') return true;
if (typeof text !== 'object' || text === null) return false;
const obj = text as Record<string, unknown>;
// Debe tener al menos el locale por defecto
if (typeof obj[DEFAULT_LOCALE] !== 'string') return false;
// Todos los valores deben ser strings
return Object.values(obj).every(v => typeof v === 'string');
}
/**
* Error para I18nString inválido
*/
export class I18nStringError extends Error {
constructor(message: string) {
super(`[I18n] ${message}`);
this.name = 'I18nStringError';
}
}
/**
* Validar y lanzar error si es inválido
*/
export function assertValidI18nString(text: unknown): asserts text is I18nString {
if (!validateI18nString(text)) {
throw new I18nStringError(
`Invalid I18nString. Must be a string or object with at least '${DEFAULT_LOCALE}' key.`
);
}
}
// ============================================================================
// EXPORTS
// ============================================================================
export default {
// Constantes y Metadata
SUPPORTED_LOCALES,
// Utilities de Locale
isSupportedLocale,
getValidLocale,
extractLanguageCode,
getLocaleInfo,
// Traducción
t,
translate,
hasTranslation,
getAvailableLocales,
isComplete,
getMissingLocales,
// Builders
createI18nString,
createSimpleI18nString,
expandToTranslations,
combineI18nStrings,
// Validación y Errores
validateI18nString,
assertValidI18nString,
I18nStringError
};

@ -0,0 +1,19 @@
// ids.ts
import type {AttrID, ObjectID, OptionID, RuleID, SectionID, HotspotID, ViewID} from "../types/all.ts";
// ============================================================================
// EXPORTS
// ============================================================================
export const IDS = {
attr : (id: string): AttrID => `at:${id}`,
opt : (id: string): OptionID => `op:${id}`,
sec : (id: string): SectionID=> `sc:${id}`,
obj : (id: string): ObjectID => `ob:${id}`,
rule : (id: string): RuleID => `rl:${id}`,
view : (id: string): ViewID => `vw:${id}`,
hspot: (id: string): HotspotID=> `hs:${id}`,
};

@ -0,0 +1,329 @@
import type {
ID,
Section,
Attribute,
JsonLogicEvaluator,
JsonLogic,
Value, AttrID, OptionID, SectionID, ObjectID, RuleID, ViewID, HotspotID
} from '../types/all.ts';
// ============================================================================
// UTILITIES (Con Mejoras)
// ============================================================================
/**
* ✅ MEJORADO: Evalúa display flags con soporte para condicionales
*/
export function evaluateDisplayFlag(
flag: boolean | JsonLogic | undefined,
defaultValue: boolean,
attributeValues: Record<ID<'attr'>, Value>,
evaluator: JsonLogicEvaluator
): boolean {
if (flag === undefined) return defaultValue;
if (typeof flag === 'boolean') return flag;
// Es JsonLogic: evaluar
const data = {
attributes: Object.fromEntries(
Object.entries(attributeValues).map(([id, value]) => [
id.split(':')[1], // "attr:suelo" → "suelo"
value
])
)
};
return evaluator.evaluate(flag, data);
}
/**
* ✅ MEJORADO: Generación de URL con validación y fallbacks
*/
export function generateSectionImageUrl(
section: Section,
globalAttributes : Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): string {
const config = section.visualConfig;
if (!config.imageUrlTemplate) {
console.warn(`[Render] No imageUrlTemplate for section ${section.id}`);
return config.fallbackImage || '/images/fallback.jpg';
}
let url = config.imageUrlTemplate;
// Reemplazar {basePath}
if (config.basePath) {
url = url.replace('{basePath}', config.basePath);
}
// Reemplazar {sectionId}
const sectionId = section.id.split(':')[1];
if (sectionId) {
url = url.replace('{sectionId}', sectionId);
}
/* No overload matches this call.
/The last overload gave the following error.
Argument of type 'string | undefined' is not assignable to parameter of type '(substring: string, ...args: any[]) => string'.
Type 'undefined' is not assignable to type '(substring: string, ...args: any[]) => string'.ts(2769)
lib.es5.d.ts(470, 5): The last overload is declared here.
url = url.replace('{sectionId}', sectionId);
*/
// Reemplazar {global.attributeId}
config.globalAttributeDependencies?.forEach(attrId => {
const attrName = attrId.split(':')[1];
const placeholder = `{global.${attrName}}`;
const value = globalAttributes[attrId];
if (value !== undefined) {
// Normalizar valor para URL
const normalizedValue = String(value)
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_]/g, '');
url = url.replace(placeholder, normalizedValue);
} else {
// Atributo faltante: usar 'default'
console.warn(`[Render] Missing global attribute ${attrId}, using 'default'`);
url = url.replace(placeholder, 'default');
}
});
// Reemplazar {section.attributeId}
config.sectionAttributeDependencies?.forEach(attrId => {
const attrName = attrId.split(':')[1];
const placeholder = `{section.${attrName}}`;
const value = sectionAttributes[attrId];
if (value !== undefined) {
const normalizedValue = String(value)
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_]/g, '');
url = url.replace(placeholder, normalizedValue);
} else {
console.warn(`[Render] Missing section attribute ${attrId}, using 'default'`);
url = url.replace(placeholder, 'default');
}
});
// ✅ NUEVO: Validar que no queden placeholders sin reemplazar
const unresolvedPlaceholders = url.match(/\{[^}]+}/g);
if (unresolvedPlaceholders) {
console.error(
`[Render] Unresolved placeholders in URL: ${unresolvedPlaceholders.join(', ')}`,
`Original template: ${config.imageUrlTemplate}`,
`Partial URL: ${url}`
);
return config.fallbackImage || '/images/fallback.jpg';
}
return url;
}
/**
* ✅ NUEVO: Generación async de imagen via API
*/
export async function generateSectionImageAsync(
section: Section,
globalAttributes : Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): Promise<string> {
const config = section.visualConfig;
const fallback = config.fallbackImage || '/images/fallback.jpg';
if (config.strategy !== 'api_generated' || !config.apiConfig) {
return generateSectionImageUrl(section, globalAttributes, sectionAttributes);
}
try {
const params: Record<string, Value> = {};
config.apiConfig.params?.forEach(attrId => {
const value = globalAttributes[attrId] ?? sectionAttributes[attrId];
if (value !== undefined) params[attrId] = value;
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.apiConfig.timeout || 5000);
const response = await fetch(config.apiConfig.endpoint, {
method: config.apiConfig.method || 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sectionId: section.id, attributes: params }),
signal: controller.signal
});
clearTimeout(timeoutId);
// ✅ SOLUCIÓN: En lugar de 'throw', manejamos el error y retornamos el fallback
if (!response.ok) {
console.error(`[Render] API ${config.apiConfig.endpoint} returned ${response.status}`);
return fallback;
}
const data = await response.json();
return data.imageUrl || fallback;
} catch (error) {
// El catch ahora solo se encarga de errores reales:
// fallos de red, JSON malformado o el Timeout (AbortError)
console.error(`[Render] Network or Timeout error for ${section.id}:`, error);
return fallback;
}
}
/**
* ✅ MEJORADO: Deep equality para valores complejos
*/
export function shouldRerenderSection(
section: Section,
changedAttributeId: AttrID,
oldValue: Value,
newValue: Value,
isGlobalAttribute: boolean
): boolean {
const config = section.visualConfig;
// Verificar si el atributo afecta a esta sección
const affectsThisSection = isGlobalAttribute
? config.globalAttributeDependencies?.includes(changedAttributeId)
: config.sectionAttributeDependencies?.includes(changedAttributeId);
if (!affectsThisSection) return false;
// Deep equality para valores complejos
return !deepEqual(oldValue, newValue);
}
/**
* Deep equality check
*/
export function deepEqual(a: Value, b: Value): boolean {
// 1. Chequeo de identidad simple (cubre primitivos iguales)
if (a === b) return true;
// 2. Si alguno de los dos NO es un objeto o es null, devuelve false
// (Esto descarta string, number, boolean, ID y null de un golpe)
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
return false;
}
// =========================================================
// AHORA TypeScript SABE que 'a' y 'b' son OBJETOS (Date o Array)
// =========================================================
// 3. Ahora es seguro preguntar por Date
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
// 4. Chequeo de Arrays
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, idx) => deepEqual(val, b[idx]));
}
// 5. Si son objetos pero no Dates ni Arrays, comparamos sus claves (opcional, pero recomendado)
// Esto es útil si tu 'Value' llega a incluir objetos planos en el futuro.
/*
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every(key => deepEqual((a as any)[key], (b as any)[key]));
*/
return false;
}
// ============================================================================
// VALIDATION (Runtime)
// ============================================================================
/**
* ✅ NUEVO: Validar modelo en runtime
*/
export function validateAttribute(attr: Attribute): ValidationError[] {
const errors: ValidationError[] = [];
// Computed debe ser readonly
if (attr.type === 'computed' && !attr.display.readonly) {
errors.push({
attributeId: attr.id,
message: 'Computed attributes should be readonly',
severity: 'error'
});
}
// Fixed no debería afectar precio
if (attr.type === 'fixed' && attr.display.affectsPrice) {
errors.push({
attributeId: attr.id,
message: 'Fixed attributes should not affect price',
severity: 'warning'
});
}
// Verificar que dependencies existen
if (attr.type === 'computed') {
if (!attr.dependencies || attr.dependencies.length === 0) {
errors.push({
attributeId: attr.id,
message: 'Computed attribute has no dependencies',
severity: 'warning'
});
}
}
return errors;
}
export function validateSection(section: Section): ValidationError[] {
const errors: ValidationError[] = [];
// Validar que imageUrlTemplate tiene placeholders válidos
if (section.visualConfig.imageUrlTemplate) {
const template = section.visualConfig.imageUrlTemplate;
const placeholders = template.match(/\{[^}]+}/g) || [];
placeholders.forEach(placeholder => {
const cleanName = placeholder.slice(1, -1); // Remove { }
if (cleanName.startsWith('global.')) {
const attrName = cleanName.replace('global.', '');
const attrId = `at:${attrName}` as AttrID;
if (!section.visualConfig.globalAttributeDependencies?.includes(attrId)) {
errors.push({
sectionId: section.id,
message: `Placeholder ${placeholder} not in globalAttributeDependencies`,
severity: 'error'
});
}
}
});
}
return errors;
}
export interface ValidationError {
attributeId?: ID<'at'>;
sectionId?: ID<'sc'>;
message: string;
severity: 'error' | 'warning';
}

@ -0,0 +1,109 @@
// view.ts
import type { Hotspot, SectionView, AttrID, SectionID} from "../types/all.ts";
// ============================================================================
// UTILITIES
// ============================================================================
/**
* Obtener la vista por defecto de una lista de vistas
*/
export function getDefaultView(views: SectionView[]): SectionView | null {
const defaultView = views.find(v => v.isDefault === true);
return defaultView || views[0] || null;
}
/**
* Filtrar vistas visibles según condiciones
*/
export function getVisibleViews(
views: SectionView[],
attributeValues: Record<AttrID, any>,
evaluator: any
): SectionView[] {
return views.filter(view => {
if (view.visible === undefined || view.visible === true) {
return true;
}
if (view.visible === false) {
return false;
}
// Es JsonLogic, evaluar
const data = { attributes: attributeValues };
return evaluator.evaluate(view.visible, data);
});
}
/**
* Obtener hotspots visibles de una vista
*/
export function getVisibleHotspots(
view: SectionView,
attributeValues: Record<AttrID, any>,
evaluator: any
): Hotspot[] {
if (!view.hotspots) return [];
return view.hotspots.filter(hotspot => {
if (hotspot.visible === undefined || hotspot.visible === true) {
return true;
}
if (hotspot.visible === false) {
return false;
}
// Es JsonLogic, evaluar
const data = { attributes: attributeValues };
return evaluator.evaluate(hotspot.visible, data);
});
}
/**
* Generar URL de imagen para una vista específica
*/
export function generateViewImageUrl(
view: SectionView,
sectionId: SectionID,
globalAttributes : Record<AttrID, any>,
sectionAttributes: Record<AttrID, any>
): string {
const config = view.visualConfig;
if (!config.imageUrlTemplate) {
return config.fallbackImage || '/images/fallback.jpg';
}
let url = config.imageUrlTemplate;
// Reemplazar {basePath}
if (config.basePath) {
url = url.replace('{basePath}', config.basePath);
}
// Reemplazar {sectionId}
url = url.replace('{sectionId}', sectionId.split(':')[1]);
// Reemplazar {viewId}
url = url.replace('{viewId}', view.id.split(':')[1]);
// Reemplazar atributos globales
config.globalAttributeDependencies?.forEach(attrId => {
const attrName = attrId.split(':')[1];
const value = globalAttributes[attrId];
if (value !== undefined) {
url = url.replace(`{global.${attrName}}`, String(value));
}
});
// Reemplazar atributos de sección
config.sectionAttributeDependencies?.forEach(attrId => {
const attrName = attrId.split(':')[1];
const value = sectionAttributes[attrId];
if (value !== undefined) {
url = url.replace(`{section.${attrName}}`, String(value));
}
});
return url;
}

@ -0,0 +1,107 @@
import type { ConfigurableObject, Section, DynamicAttribute, Attribute } from './types/model.ts';
import { IDS, JsonLogicEvaluator } from './types/model.ts';
import { evaluateDisplayFlag, generateSectionImageUrl } from './code/vce.ts';
// -------------------------------
// 1️⃣ Crear catálogo de opciones
// -------------------------------
const catalogOptions = {
[IDS.opt('rojo')]: { id: IDS.opt('rojo'), name: 'Rojo', description: 'Color Rojo' },
[IDS.opt('blanco')]: { id: IDS.opt('blanco'), name: 'Blanco', description: 'Color Blanco' },
[IDS.opt('beige')]: { id: IDS.opt('beige'), name: 'Beige', description: 'Interior Beige' }
};
// -------------------------------
// 2️⃣ Crear atributos
// -------------------------------
const colorAttr: DynamicAttribute = {
id: IDS.attr('color'),
name: 'Color Exterior',
description: 'Color de la carrocería',
type: 'dynamic',
dataType: 'reference',
defaultValue: IDS.opt('blanco'),
options: [
{ optionId: IDS.opt('rojo') },
{ optionId: IDS.opt('blanco') }
],
display: {
uiVisible: true,
affectsVisual: true,
affectsPrice: true
}
};
const interiorAttr: DynamicAttribute = {
id: IDS.attr('interior'),
name: 'Interior',
description: 'Color del interior',
type: 'dynamic',
dataType: 'reference',
defaultValue: IDS.opt('beige'),
options: [
{ optionId: IDS.opt('beige') },
{ optionId: IDS.opt('blanco') }
],
display: {
uiVisible: true,
affectsVisual: true,
affectsPrice: true
}
};
// -------------------------------
// 3️⃣ Crear sección
// -------------------------------
const salonSection: Section = {
id: IDS.sec('salon'),
name: 'Salón',
description: 'Sección principal',
attributes: [interiorAttr],
availability: { mode: 'required' },
visualConfig: {
strategy: 'dynamic_image',
imageUrlTemplate: '/images/{sectionId}_{global.color}_{section.interior}.jpg',
basePath: '/images',
globalAttributeDependencies: [colorAttr.id],
sectionAttributeDependencies: [interiorAttr.id],
fallbackImage: '/images/fallback.jpg'
}
};
// -------------------------------
// 4️⃣ Crear objeto configurable
// -------------------------------
const coche: ConfigurableObject = {
id: IDS.obj('tesla_model3'),
name: 'Tesla Model 3',
description: 'Ejemplo de coche configurador',
attributes: [colorAttr],
sections: {
[salonSection.id]: salonSection
}
};
// -------------------------------
// 5️⃣ Estado del usuario
// -------------------------------
const userGlobalAttrs = {
[colorAttr.id]: IDS.opt('rojo')
};
const userSectionAttrs = {
[interiorAttr.id]: IDS.opt('beige')
};
// -------------------------------
// 6️⃣ Evaluar display flags
// -------------------------------
const evaluator = new JsonLogicEvaluator();
const isColorVisible = evaluateDisplayFlag(colorAttr.display.uiVisible, true, userGlobalAttrs, evaluator);
console.log('Color visible?', isColorVisible);
// -------------------------------
// 7️⃣ Generar URL de imagen de sección
// -------------------------------
const imageUrl = generateSectionImageUrl(salonSection, userGlobalAttrs, userSectionAttrs);
console.log('Imagen generada:', imageUrl);

@ -0,0 +1,217 @@
// view_examples.ts
import type {AttrID, HotspotID, SectionView, ViewID} from '../types/all.ts';
// ============================================================================
// EJEMPLOS DE USO
// ============================================================================
/**
* EJEMPLO 1: Salón con 3 vistas
*/
export const ejemploSalonVistas: SectionView[] = [
// Vista 1: Frontal (por defecto)
{
id: "vw:salon_frontal" as ViewID,
name: { es: "Vista Frontal", en: "Front View" },
description: { es: "Vista principal del salón" },
type: "front",
isDefault: true,
order: 1,
visualConfig: {
strategy: "dynamic_image",
basePath: "/images/sections/salon",
imageUrlTemplate: "{basePath}/frontal_{global.suelo}_{global.zocalo}_{section.mobiliario}.jpg",
globalAttributeDependencies: [
"at:suelo" as AttrID,
"at:zocalo" as AttrID
],
sectionAttributeDependencies: [
"at:mobiliario_salon" as AttrID,
"at:iluminacion_salon" as AttrID
]
},
// Hotspots en vista frontal
hotspots: [
{
id: "hs:salon_suelo" as HotspotID,
name: { es: "Suelo" },
x: 50,
y: 85,
radius: 30,
color: "#FFD700",
icon: "floor",
scopeType: "global",
targetAttributeId: "at:suelo" as AttrID,
tooltip: { es: "Configurar tipo de suelo" },
zIndex: 1
},
{
id: "hs:salon_muebles" as HotspotID,
name: { es: "Mobiliario" },
x: 40,
y: 50,
radius: 40,
color: "#4CAF50",
icon: "sofa",
scopeType: "section",
targetAttributeId: "at:mobiliario_salon" as AttrID,
tooltip: { es: "Elegir mobiliario del salón" },
zIndex: 2
},
{
id: "hs:salon_lampara" as HotspotID,
name: { es: "Iluminación" },
x: 50,
y: 15,
radius: 25,
color: "#FFC107",
icon: "lightbulb",
scopeType: "section",
targetAttributeId: "at:iluminacion_salon" as AttrID,
tooltip: { es: "Configurar iluminación" },
zIndex: 3
}
]
},
// Vista 2: Lateral
{
id: "vw:salon_lateral" as ViewID,
name: { es: "Vista Lateral", en: "Side View" },
type: "side",
order: 2,
visualConfig: {
strategy: "dynamic_image",
basePath: "/images/sections/salon",
imageUrlTemplate: "{basePath}/lateral_{global.suelo}_{section.mobiliario}.jpg",
globalAttributeDependencies: ["at:suelo" as AttrID],
sectionAttributeDependencies: ["at:mobiliario_salon" as AttrID]
},
// Esta vista tiene menos hotspots
hotspots: [
{
id: "hs:salon_lateral_suelo" as HotspotID,
name: { es: "Suelo" },
x: 50,
y: 90,
radius: 30,
scopeType: "global",
targetAttributeId: "at:suelo" as AttrID
}
]
},
// Vista 3: Cenital (sin hotspots)
{
id: "vw:salon_cenital" as ViewID,
name: { es: "Plano", en: "Floor Plan" },
type: "top",
order: 3,
visualConfig: {
strategy: "static_image",
basePath: "/images/sections/salon",
imageUrlTemplate: "{basePath}/plano.jpg"
}
// Sin hotspots - solo visualización
}
];
/**
* EJEMPLO 2: Coche con vista 360°
*/
export const ejemploCocheVista360: SectionView = {
id: "vw:coche_360" as ViewID,
name: { es: "Vista 360°" },
type: "panoramic",
isDefault: true,
visualConfig: {
strategy: "three_d",
threeDConfig: {
modelUrl: "/models/coche.glb",
cameraPosition: { x: 5, y: 2, z: 5 },
textureAttributes: [
{
attributeId: "at:color" as AttrID,
scope: "global"
},
{
attributeId: "at:llantas" as AttrID,
scope: "section"
}
]
}
},
// Hotspots en modelo 3D (coordenadas 3D)
hotspots: [
{
id: "hs:puerta" as HotspotID,
name: { es: "Puerta" },
x: 30, // Coordenadas relativas al canvas
y: 50,
scopeType: "section",
targetAttributeId: "at:tipo_puerta" as AttrID
}
]
};
/**
* EJEMPLO 3: Vista condicional (solo visible con cierta opción)
*/
export const ejemploVistaCondicional: SectionView = {
id: "vw:terraza_exterior" as ViewID,
name: { es: "Vista Exterior" },
type: "front",
// Solo visible si tiene_terraza = true
visible: {
"==": [
{ "var": "attributes.tiene_terraza" },
"opt:si"
]
},
visualConfig: {
strategy: "dynamic_image",
imageUrlTemplate: "/images/terraza_exterior_{section.suelo_terraza}.jpg",
sectionAttributeDependencies: ["at:suelo_terraza" as AttrID]
}
};
/**
* EJEMPLO 4: Vista de detalle con zoom
*/
export const ejemploVistaDetalle: SectionView = {
id: "vw:cocina_encimera_detalle" as ViewID,
name: { es: "Detalle Encimera" },
type: "detail",
visualConfig: {
strategy: "dynamic_image",
imageUrlTemplate: "/images/cocina/detalle_encimera_{section.encimera}.jpg",
sectionAttributeDependencies: ["at:encimera" as AttrID]
},
// Hotspot específico de la encimera
hotspots: [
{
id: "hs:encimera_material" as HotspotID,
name: { es: "Material Encimera" },
x: 50,
y: 50,
radius: 50,
scopeType: "section",
targetAttributeId: "at:encimera" as AttrID,
tooltip: { es: "Cambiar material de la encimera" }
}
]
};

@ -0,0 +1,7 @@
// types.ts
export * from './primitives.ts';
export * from './i18n.ts';
export * from './json_logic.ts';
export * from './view.ts';
export * from './vce_model.ts';

@ -0,0 +1,93 @@
// ui18.ts
export type SupportedLocale =
| 'es' // Español
| 'en' // English
| 'de' // Deutsch
| 'fr' // Français
| 'it' // Italiano
| 'pt' // Português
| 'ca' // Català
| 'eu' // Euskara
| 'gl'; // Galego
/**
* Locale por defecto del sistema
*/
export const DEFAULT_LOCALE: SupportedLocale = 'es';
/**
* Lista de todos los locales soportados
*/
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
'es',
'en',
'de',
'fr',
'it',
'pt',
'ca',
'eu',
'gl'
] as const;
// ============================================================================
// I18N STRING TYPES
// ============================================================================
/**
* Objeto con traducciones tipadas
* Garantiza que al menos existe el locale por defecto
*/
export type I18nTranslations = {
[K in SupportedLocale]?: string;
} & {
[DEFAULT_LOCALE]: string; // Español es obligatorio
};
/**
* String internacionalizable
*
* Puede ser:
* - `string`: Texto simple (mismo en todos los idiomas)
* - `I18nTranslations`: Objeto con traducciones por locale
*
* @example
* // Simple (mismo en todos los idiomas)
* const label: I18nString = "OK";
*
* // Con traducciones
* const greeting: I18nString = {
* es: "Hola",
* en: "Hello",
* de: "Hallo"
* };
*
* // Solo español (obligatorio) + algún otro
* const name: I18nString = {
* es: "Configuración",
* en: "Configuration"
* };
*/
export type I18nString = string | I18nTranslations;
// ============================================================================
// LOCALE METADATA
// ============================================================================
/**
* Información sobre un locale
*/
export interface LocaleInfo {
code: SupportedLocale;
name: string;
nativeName: string;
flag: string;
rtl?: boolean;
}

@ -0,0 +1,59 @@
// json_logic.ts
// ============================================================================
// JSON LOGIC
// ============================================================================
export type JsonLogic =
| { var: string }
| { [op: string]: any }
| string
| number
| boolean
| null;
// ============================================================================
// JSONLOGIC EVALUATOR (Simplificado)
// ============================================================================
export class JsonLogicEvaluator {
evaluate(expression: JsonLogic, data: Record<string, any>): any {
// Implementación completa en archivo separado
// Por ahora: placeholder
if (typeof expression === 'boolean') return expression;
if (typeof expression === 'number') return expression;
if (typeof expression === 'string') return expression;
if (typeof expression === 'object' && expression !== null) {
if ('var' in expression) {
return this.getVar(expression.var, data);
}
if ('==' in expression) {
const [a, b] = (expression as any)['=='];
return this.evaluate(a, data) === this.evaluate(b, data);
}
if ('if' in expression) {
const [cond, then_, else_] = (expression as any)['if'];
return this.evaluate(cond, data)
? this.evaluate(then_, data)
: this.evaluate(else_, data);
}
}
return expression;
}
private getVar(path: string, data: Record<string, any>): any {
const parts = path.split('.');
let current = data;
for (const part of parts) {
if (current === undefined || current === null) return undefined;
current = current[part];
}
return current;
}
}

@ -0,0 +1,6 @@
// metadata.ts
export type Metadata = Record<string, unknown>;

@ -0,0 +1,23 @@
// primitives.ts
export type ID<T extends string = string> = `${T}:${string}`;
export type Value =
| string
| number
| boolean
| Date
| ID
| Value[];
export type DataType =
| 'string'
| 'number'
| 'boolean'
| 'date'
| 'currency'
| 'dimension'
| 'reference'
| 'list';

@ -0,0 +1,362 @@
/**
* ============================================================================
* CONFIGURATION MODEL - FINAL VERSION
* ============================================================================
*
* Mejoras implementadas:
*
* ✅ NIVEL 1 (Incrementales):
* 1. Condicionales en display flags (uiVisible, affectsVisual, affectsPrice)
* 2. Validación y fallbacks en generateImageUrl
* 3. Caching en utilities
* 4. Validación de modelo en runtime
*
* ✅ NIVEL 2 (Intermedias):
* 5. Soporte async rendering (API-generated)
* 6. Deep equality para shouldRerender
* 7. Logging de visual changes
*
* ============================================================================
*/
import type { JsonLogic } from "./json_logic.ts";
import type { I18nString } from "./i18n.ts";
import type { Metadata } from "./metadata.ts";
import type { RenderingStrategy } from "./view.ts"
import type { ID, Value, DataType } from "./primitives.ts";
export type AttrID = ID<'at'>;
export type SectionID = ID<'sc'>;
export type OptionID = ID<'op'>;
export type ObjectID = ID<'ob'>;
export type RuleID = ID<'rl'>;
// ============================================================================
// ATTRIBUTE DISPLAY (Con Condicionales)
// ============================================================================
/**
* ✅ MEJORADO: Display flags pueden ser boolean O expresión condicional
*/
export interface AttributeDisplay {
/**
* Si se muestra en UI
* - boolean: true/false estático
* - JsonLogic: Evaluado dinámicamente
*
* Ejemplo condicional:
* {
* "if": [
* { "==": [{ "var": "attributes.usuario_tipo" }, "avanzado"] },
* true,
* false
* ]
* }
*/
uiVisible?: boolean | JsonLogic;
/**
* Si afecta a la visualización
* - boolean: true/false estático
* - JsonLogic: Condicional
*
* Ejemplo: Solo afecta visual si plan es premium
* {
* "==": [{ "var": "attributes.plan" }, "premium"]
* }
*/
affectsVisual?: boolean | JsonLogic;
/**
* Si afecta al precio
*/
affectsPrice?: boolean | JsonLogic;
/**
* Si es de solo lectura
*/
readonly?: boolean;
/**
* Orden de visualización
*/
order?: number;
/**
* Icono
*/
icon?: string;
/**
* Texto de ayuda
*/
helpText?: I18nString;
}
// ============================================================================
// BASE ATTRIBUTE
// ============================================================================
export interface BaseAttribute {
id: AttrID;
name: I18nString;
description: I18nString;
type: AttributeType;
category?: AttributeCategory;
display: AttributeDisplay;
metadata?: Metadata;
}
export type AttributeType = 'fixed' | 'dynamic' | 'quantifiable' | 'computed';
export type AttributeCategory = 'physical' | 'aesthetic' | 'functional' | 'structural' | 'technical' | 'financial';
// ============================================================================
// ATTRIBUTE TYPES
// ============================================================================
export interface FixedAttribute extends BaseAttribute {
type: 'fixed';
dataType: DataType;
value: Value;
unit?: I18nString;
}
export interface DynamicAttribute extends BaseAttribute {
type: 'dynamic';
dataType: 'reference';
defaultValue: ID<'op'>;
options: AttributeOption[];
required?: boolean;
isController?: boolean;
controls?: ID<'sc'>[];
filterExpression?: JsonLogic;
}
export interface QuantifiableAttribute extends BaseAttribute {
type: 'quantifiable';
dataType: 'number';
quantity: QuantityDefinition;
unit?: I18nString;
userConfigurable?: boolean;
pricePerUnit?: number;
min?: number;
max?: number;
}
export interface ComputedAttribute extends BaseAttribute {
type: 'computed';
dataType: DataType;
unit?: I18nString;
expression: JsonLogic;
dependencies: ID<'at'>[];
}
export type Attribute =
| FixedAttribute
| DynamicAttribute
| QuantifiableAttribute
| ComputedAttribute;
export interface AttributeOption {
optionId: OptionID;
priority?: number;
pricingOverride?: number;
metadata?: Metadata;
}
export type QuantityDefinition =
| number
| { min: number; max: number; step?: number; default: number }
| { values: number[]; default: number };
// ============================================================================
// VISUAL RENDERING (Con Async Support)
// ============================================================================
export interface SectionVisualConfig {
strategy: RenderingStrategy;
/** Template para URL estática */
imageUrlTemplate?: string;
/** Atributos globales que afectan la imagen */
globalAttributeDependencies?: AttrID[];
/** Atributos de sección que afectan la imagen */
sectionAttributeDependencies?: AttrID[];
/** Ruta base */
basePath?: string;
/** ✅ NUEVO: Config para generación async via API */
apiConfig?: {
endpoint: string;
method?: 'GET' | 'POST';
params?: AttrID[];
timeout?: number;
};
/** Config 3D */
threeDConfig?: {
modelUrl: string;
textureAttributes: Array<{
attributeId: AttrID;
scope: 'global' | 'section';
}>;
};
/** ✅ NUEVO: Imagen de fallback si falla generación */
fallbackImage?: string;
}
// ============================================================================
// SECTION & OBJECT
// ============================================================================
export interface Section {
id: SectionID;
name: I18nString;
description: I18nString;
attributes: Attribute[];
visualConfig: SectionVisualConfig;
availability: SectionAvailability;
order?: number;
icon?: string;
metadata?: Metadata;
}
export interface SectionAvailability {
mode: 'required' | 'optional' | 'conditional';
condition?: JsonLogic;
dependsOn?: ID<'attr'>[];
onDeactivate?: {
action: 'clear' | 'preserve' | 'reset';
confirmWithUser?: boolean;
confirmMessage?: I18nString;
};
}
export interface ConfigurableObject {
id: ObjectID;
name: I18nString;
description: I18nString;
attributes: Attribute[];
sections: Record<SectionID, Section>;
sectionOrder?: ID<'sec'>[];
category?: string;
metadata?: Metadata;
}
// ============================================================================
// OPTION DEFINITION
// ============================================================================
export interface OptionDefinition {
id: OptionID;
name: I18nString;
description: I18nString;
media?: {
thumbnail?: string;
image?: string;
icon?: string;
gallery?: string[];
};
pricing?: {
baseAmount: number | 'consultation';
currency?: string;
dynamicExpression?: JsonLogic;
};
tags?: string[];
style?: string;
metadata?: Metadata;
}
// ============================================================================
// CATALOG
// ============================================================================
export interface ConfigurationCatalog {
name: I18nString;
description?: I18nString;
supportedLocales: string[];
defaultLocale: string;
options: Record<OptionID, OptionDefinition>;
objects: Record<ObjectID, ConfigurableObject>;
rules? : Record<RuleID , ValidationRule>;
}
export interface ValidationRule {
id: RuleID;
name: I18nString;
condition: JsonLogic;
action: {
type: 'allow' | 'forbid' | 'require' | 'suggest';
targetAttribute: ID<'attr'>;
values: Value[];
};
priority: number;
bidirectional: boolean;
affects: AttrID[];
severity: 'error' | 'warning' | 'info';
message: I18nString;
}
// ============================================================================
// CACHING UTILITIES
// ============================================================================
/**
* ✅ NUEVO: Cache para utilities
*/
export class AttributeCache {
private uiVisibleCache = new Map<ID<'attr'>, boolean>();
private visualAttributesCache: Attribute[] | null = null;
private priceAttributesCache: Attribute[] | null = null;
invalidate(): void {
this.uiVisibleCache.clear();
this.visualAttributesCache = null;
this.priceAttributesCache = null;
}
getUIVisible(
attrId: ID<'attr'>,
compute: () => boolean
): boolean {
if (this.uiVisibleCache.has(attrId)) {
return this.uiVisibleCache.get(attrId)!;
}
const result = compute();
this.uiVisibleCache.set(attrId, result);
return result;
}
getVisualAttributes(
compute: () => Attribute[]
): Attribute[] {
if (this.visualAttributesCache) {
return this.visualAttributesCache;
}
this.visualAttributesCache = compute();
return this.visualAttributesCache;
}
getPriceAttributes(
compute: () => Attribute[]
): Attribute[] {
if (this.priceAttributesCache) {
return this.priceAttributesCache;
}
this.priceAttributesCache = compute();
return this.priceAttributesCache;
}
}

@ -0,0 +1,189 @@
// view.ts
/**
* ============================================================================
* CONFIGURATION VIEWS - Visual Representation
* ============================================================================
*
* CONCEPTO:
*
* Una SECCIÓN puede tener MÚLTIPLES VISTAS
*
* Ejemplo: Sección "Salón"
* - Vista "Frontal": Muestra pared frontal con ventanas y muebles
* - Vista "Lateral": Muestra pared lateral con puerta
* - Vista "Cenital": Vista desde arriba, muestra distribución
* - Vista "360": Vista panorámica
*
* Cada vista tiene:
* - Su propia imagen base
* - Sus propios hotspots (puntos interactivos)
* - Configuración de cómo se genera la URL de imagen
*
* ============================================================================
*/
import type { ID } from './primitives.ts';
import type { I18nString } from './i18n';
import type { JsonLogic } from "./json_logic.ts";
import type {Attribute, AttrID} from "./vce_model.ts";
// ============================================================================
// VIEW TYPES (Tipos de Vista)
// ============================================================================
export type ViewID = ID<'vw'>;
export type HotspotID = ID<'hs'>;
/**
* Tipos de vista predefinidos
*/
type ViewType =
| 'front' // Vista frontal
| 'side' // Vista lateral
| 'rear' // Vista posterior
| 'top' // Vista desde arriba
| 'isometric' // Vista isométrica/3D
| 'panoramic' // Vista 360° panorámica
| 'detail' // Vista de detalle/zoom
| 'blueprint' // Plano/blueprint
| 'custom'; // Tipo personalizado
// ============================================================================
// HOTSPOT (Punto Interactivo)
// ============================================================================
/**
* Punto interactivo en una vista
* Al hacer click, permite configurar un atributo específico
*/
export interface Hotspot {
/** ID único del hotspot */
id: HotspotID;
/** Nombre descriptivo */
name: I18nString;
/** Posición X en porcentaje (0-100) */
x: number;
/** Posición Y en porcentaje (0-100) */
y: number;
/** Radio del hotspot en píxeles */
radius?: number;
/** Color del hotspot (hex, rgb, etc.) */
color?: string;
/** Icono del hotspot */
icon?: string;
/** Tipo de scope al que referencia */
scopeType: 'global' | 'section';
/** ID del atributo que este hotspot controla */
targetAttributeId: AttrID;
/** Si es visible (puede ser condicional) */
visible?: boolean | JsonLogic;
/** Tooltip al hacer hover */
tooltip?: I18nString;
/** Orden z-index para superposición */
zIndex?: number;
}
// ============================================================================
// VIEW (Vista Visual de una Sección)
// ============================================================================
/**
* Vista visual de una sección
* Una sección puede tener múltiples vistas
*/
export interface SectionView {
/** ID único de la vista */
id: ViewID;
/** Nombre de la vista */
name: I18nString;
/** Descripción */
description?: I18nString;
/** Tipo de vista */
type: ViewType;
/** Configuración visual de esta vista */
visualConfig: ViewVisualConfig;
/** Hotspots interactivos (OPCIONAL) */
hotspots?: Hotspot[];
/** Orden de esta vista en la navegación */
order?: number;
/** Si es la vista por defecto para esta sección */
isDefault?: boolean;
/** Icono para el selector de vistas */
icon?: string;
/** Si está visible (puede ser condicional) */
visible?: boolean | JsonLogic;
}
/**
* Configuración visual específica de una vista
*/
export interface ViewVisualConfig {
/** Estrategia de renderizado */
strategy: RenderingStrategy;
/** Template para URL de imagen */
imageUrlTemplate?: string;
/** Ruta base de imágenes */
basePath?: string;
/** Atributos globales que afectan a esta vista */
globalAttributeDependencies?: AttrID[];
/** Atributos de sección que afectan a esta vista */
sectionAttributeDependencies?: AttrID[];
/** Imagen de fallback */
fallbackImage?: string;
/** Config para API */
apiConfig?: {
endpoint: string;
method?: 'GET' | 'POST';
params?: AttrID[];
timeout?: number;
};
/** Config 3D */
threeDConfig?: {
modelUrl: string;
cameraPosition?: { x: number; y: number; z: number };
textureAttributes?: Array<{
attributeId: AttrID;
scope: 'global' | 'section';
}>;
};
}
export type RenderingStrategy =
| 'static_image'
| 'dynamic_image'
| 'api_generated'
| 'three_d'
| 'composite_layers'
| 'none';

@ -0,0 +1,28 @@
{
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"baseUrl": "./src",
"paths": {
"@/types/*": ["types/*"],
"@/utils/*": ["utils/*"],
"@/engine/*": ["engine/*"],
"@/constants/*": ["constants/*"],
"@/*": ["*"]
},
// ✅ Opciones CRÍTICAS para usar .ts en imports:
"allowImportingTsExtensions": true,
"noEmit": true, // Obligatorio con la opción anterior
"module": "esnext", // Cambiado de nodenext a esnext
"moduleResolution": "bundler", // Permite resoluciones modernas tipo Vite/Bun
"target": "esnext",
"strict": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Loading…
Cancel
Save

Powered by TurnKey Linux.