First commit

master
dev 8 months ago
parent 43b894b603
commit e81ecc8e14

@ -0,0 +1,29 @@
/**
* ============================================================================
* DEFAULT VALUES
* ============================================================================
*/
import type {SupportedLocale } from "@/types";
export const DEFAULTS = {
/** Imagen de fallback por defecto */
FALLBACK_IMAGE: '/images/fallback.jpg',
/** Timeout por defecto para API calls (ms) */
API_TIMEOUT: 5000,
/** Número máximo de reintentos */
MAX_RETRIES: 0,
/** Delay entre reintentos (ms) */
RETRY_DELAY: 1000,
/** Tamaño máximo de cache */
CACHE_MAX_SIZE: 200,
/** Tiempo de vida del cache (ms) */
CACHE_MAX_AGE: 5 * 60 * 1000, // 5 minutos
LOCALE: 'es' as SupportedLocale,
} as const;

@ -3,3 +3,4 @@
*/
export * from './locales';
export * from './defaults';

@ -6,10 +6,6 @@
import type { SupportedLocale, LocaleInfo } from '@/types';
/**
* Locale por defecto del sistema
*/
export const DEFAULT_LOCALE: SupportedLocale = 'es';
/**
* Lista de todos los locales soportados

@ -1,56 +0,0 @@
/**
* ============================================================================
* 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;
}
}

@ -12,10 +12,10 @@ 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
flag : boolean | JsonLogic | undefined,
defaultValue : boolean,
attributeValues : Record<AttrID, Value>,
evaluator : JsonLogicEvaluator
): boolean {
if (flag === undefined) return defaultValue;
if (typeof flag === 'boolean') return flag;

@ -1,38 +1,448 @@
/**
* ============================================================================
* IMAGE GENERATOR
* IMAGE GENERATOR - Consolidated & Improved
* ============================================================================
*
* Generación de URLs de imágenes para secciones
* Soporta: static, dynamic, y API-generated
*/
import type { Section, AttrID, Value } from '@/types';
import { buildSectionImageUrl } from '@/utils';
import type {Section, AttrID, Value, APIMethod} from '@/types';
import {type BuildImageConfig, buildSectionImageUrl} from '@/utils';
import { logger , ERRORS, INFO, MESSAGE_CATEGORIES } from '@/messages';
import {DEFAULTS} from "@/constants";
// ============================================================================
// TYPES
// ============================================================================
export interface ImageGenerationOptions {
/** Timeout para API calls (default: 5000ms) */
timeout? : number;
/** Reintentos en caso de fallo (default: 0) */
retries? : number;
/** Delay entre reintentos en ms (default: 1000ms) */
retryDelay? : number;
}
export interface ImageGenerationResult {
url : string;
source : 'static' | 'api' | 'fallback';
cached? : boolean;
duration?: number;
}
// ============================================================================
// CACHE
// ============================================================================
class ImageUrlCache {
private cache = new Map<string, { url: string; timestamp: number }>();
private maxAge = 5 * 60 * 1000; // 5 minutos
private maxSize = 200;
get(key: string): string | undefined {
const entry = this.cache.get(key);
if (!entry) return undefined;
// Check si expiró
if (Date.now() - entry.timestamp > this.maxAge) {
this.cache.delete(key);
return undefined;
}
return entry.url;
}
set(key: string, url: string): void {
if (this.cache.size >= this.maxSize) {
// Remove oldest
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, {
url,
timestamp: Date.now()
});
}
clear(): void {
this.cache.clear();
}
}
const imageCache = new ImageUrlCache();
// ============================================================================
// MAIN GENERATOR
// ============================================================================
/**
* Genera URL de imagen para una sección
* Utiliza el URL builder centralizado
*
* @param section - Sección a renderizar
* @param globalAttrs - Atributos globales
* @param sectionAttr - Atributos de la sección
* @param options - Opciones de generación
* @returns Promise con la URL generada
*
* @example
* const url = await generateImageUrl(section, globalAttrs, sectionAttrs);
*
* @example
* // Con opciones
* const url = await generateImageUrl(section, globalAttrs, sectionAttrs, {
* timeout: 10000,
* retries: 2
* });
*/
export function generateSectionImageUrl(
section: Section,
globalAttributes: Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): string {
export async function generateImageUrl(
section : Section,
globalAttrs : Record<AttrID, Value>,
sectionAttr : Record<AttrID, Value>,
options? : ImageGenerationOptions
): Promise<ImageGenerationResult> {
const startTime = performance.now();
const config = section.visualConfig;
// Check cache
const cacheKey = generateCacheKey(section, globalAttrs, sectionAttr);
const cached = imageCache.get(cacheKey);
if (cached) {
logger.debug(MESSAGE_CATEGORIES.CACHE, INFO.CACHE_HIT(cacheKey));
return {
url: cached,
source: 'static',
cached: true,
duration: performance.now() - startTime
};
}
logger.debug(MESSAGE_CATEGORIES.CACHE, INFO.CACHE_MISS(cacheKey));
// Determinar estrategia
let result: ImageGenerationResult;
switch (config.strategy) {
case 'api_generated':
result = await generateViaAPI(
section,
globalAttrs,
sectionAttr,
options
);
break;
case 'static_image':
case 'dynamic_image':
default:
result = generateStaticUrl(
section,
globalAttrs,
sectionAttr
);
break;
}
// Cache result si fue exitoso
if (result.source !== 'fallback') {
imageCache.set(cacheKey, result.url);
}
result.duration = performance.now() - startTime;
return result;
}
// ============================================================================
// STATIC URL GENERATION
// ============================================================================
/**
* Genera URL estática usando template
*/
function generateStaticUrl(
section : Section,
globalAttrs : Record<AttrID, Value>,
sectionAttrs: Record<AttrID, Value>
): ImageGenerationResult {
const config = section.visualConfig;
// Validar que existe template
if (!config.imageUrlTemplate) {
console.warn(`[Render] No imageUrlTemplate for section ${section.id}`);
return config.fallbackImage || '/images/fallback.jpg';
logger.warn(
MESSAGE_CATEGORIES.RENDER,
ERRORS.NO_IMAGE_TEMPLATE(section.id),
{ sectionId: section.id }
);
return {
url: config.fallbackImage || DEFAULTS.FALLBACK_IMAGE,
source: 'fallback'
};
}
return buildSectionImageUrl(
// Generar URL
const url = buildSectionImageUrl(
config.imageUrlTemplate,
section.id,
{
basePath: config.basePath,
fallbackImage: config.fallbackImage,
globalAttributeDependencies: config.globalAttributeDependencies,
sectionAttributeDependencies: config.sectionAttributeDependencies
},
globalAttributes,
sectionAttributes
basePath : config.basePath,
fallbackImage : config.fallbackImage,
globalAttrDependencies : config.globalAttrDependencies,
sectionAttrDependencies : config.sectionAttrDependencies
} as BuildImageConfig,
globalAttrs,
sectionAttrs
);
return {
url,
source: 'static'
};
}
// ============================================================================
// API GENERATION
// ============================================================================
/**
* Genera URL via API call
*/
async function generateViaAPI(
section : Section,
globalAttrs : Record<AttrID, Value>,
sectionAttrs: Record<AttrID, Value>,
options? : ImageGenerationOptions
): Promise<ImageGenerationResult> {
const config = section.visualConfig;
const fallback = config.fallbackImage || DEFAULTS.FALLBACK_IMAGE;
// Validar configuración API
if (!config.apiConfig) {
logger.error(
MESSAGE_CATEGORIES.API,
ERRORS.API_MISSING_CONFIG(section.id), // ✅ MENSAJE CENTRALIZADO
{ sectionId: section.id }
);
return { url: fallback, source: 'fallback' };
}
// Preparar parámetros
const params: Record<string, Value> = {};
config.apiConfig.params?.forEach((attrId) => {
const value = globalAttrs[attrId as AttrID] ?? sectionAttrs[attrId as AttrID];
if (value !== undefined) {
params[attrId] = value;
}
});
// Intentar con reintentos
const maxRetries = options?.retries ?? 0;
const retryDelay = options?.retryDelay ?? 1000;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const url = await fetchImageFromAPI(
config.apiConfig.endpoint,
config.apiConfig.method || 'POST',
{
sectionId: section.id,
attributes: params
},
options?.timeout || config.apiConfig.timeout || 5000
);
if (url) {
logger.info(
MESSAGE_CATEGORIES.API,
INFO.IMAGE_GENERATED_SUCCESS(section.id), // ✅ MENSAJE CENTRALIZADO
{ sectionId: section.id, attempt: attempt + 1 }
);
return { url, source: 'api' };
}
} catch (error) {
const isLastAttempt = attempt === maxRetries;
if (error instanceof Error && error.name === 'AbortError') {
logger.error(
MESSAGE_CATEGORIES.API,
ERRORS.API_TIMEOUT(
config.apiConfig.endpoint,
options?.timeout || config.apiConfig.timeout || 5000
),
{
sectionId: section.id,
attempt: attempt + 1,
maxRetries: maxRetries + 1
}
);
} else {
logger.error(
MESSAGE_CATEGORIES.API,
ERRORS.NETWORK_ERROR(section.id),
{
sectionId: section.id,
attempt: attempt + 1,
maxRetries: maxRetries + 1,
error: error instanceof Error ? error.message : String(error)
}
);
}
// Si no es el último intento, esperar antes de reintentar
if (!isLastAttempt) {
await sleep(retryDelay);
logger.info(
MESSAGE_CATEGORIES.API,
INFO.RETRYING_IMAGE_GENERATION(attempt + 2, maxRetries + 1) // ✅ MENSAJE CENTRALIZADO
);
}
}
}
// Todos los intentos fallaron
logger.warn(
MESSAGE_CATEGORIES.RENDER,
INFO.USING_FALLBACK_IMAGE(section.id),
{ sectionId: section.id }
);
return { url: fallback, source: 'fallback' };
}
/**
* Realiza el fetch a la API
*/
async function fetchImageFromAPI(
endpoint: string,
method : APIMethod,
body : any,
timeout : number
): Promise<string | null> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(endpoint, {
method,
headers : { 'Content-Type': 'application/json' },
body : JSON.stringify(body),
signal : controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
logger.error(
MESSAGE_CATEGORIES.API,
ERRORS.API_REQUEST_FAILED(endpoint, response.status),
{ endpoint, status: response.status }
);
return null;
}
const data = await response.json();
return data.imageUrl || null;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
// ============================================================================
// HELPERS
// ============================================================================
/**
* Genera clave de cache única para una combinación de sección/atributos
*/
function generateCacheKey(
section : Section,
globalAttributes : Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): string {
const config = section.visualConfig;
// Incluir solo atributos relevantes
const relevantGlobal = config.globalAttrDependencies?.reduce(
(acc, id) => {
acc[id] = globalAttributes[id];
return acc;
},
{} as Record<AttrID, Value>
) || {};
const relevantSection = config.sectionAttrDependencies?.reduce(
(acc, id) => {
acc[id] = sectionAttributes[id];
return acc;
},
{} as Record<AttrID, Value>
) || {};
return JSON.stringify({
sectionId: section.id,
template : config.imageUrlTemplate,
global : relevantGlobal,
section : relevantSection
});
}
/**
* Sleep helper para reintentos
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
// ============================================================================
// EXPORTS
// ============================================================================
/**
* Alias para compatibilidad con código existente
*/
export const generateSectionImageUrl = generateStaticUrl;
/**
* Alias para compatibilidad
*/
export async function generateSectionImageAsync(
section: Section,
globalAttributes: Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
): Promise<string> {
const result = await generateImageUrl(section, globalAttributes, sectionAttributes);
return result.url;
}
/**
* Limpia la cache de imágenes
*/
export function clearImageCache(): void {
imageCache.clear();
logger.info(MESSAGE_CATEGORIES.CACHE, INFO.CACHE_CLEARED);
}
// ============================================================================
// DEFAULT EXPORT
// ============================================================================
export default {
generateImageUrl,
generateStaticUrl,
generateViaAPI,
generateSectionImageUrl,
generateSectionImageAsync,
clearImageCache
};

@ -11,18 +11,18 @@ 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
section : Section,
changedAttrId : AttrID,
oldValue : Value,
newValue : Value,
isGlobalAttr : 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);
const affectsThisSection = isGlobalAttr
? config.globalAttrDependencies?.includes(changedAttrId)
: config.sectionAttrDependencies?.includes(changedAttrId);
if (!affectsThisSection) return false;

@ -0,0 +1,119 @@
// ============================================================================
// ERROR MESSAGES
// ============================================================================
import type {AttrID, I18nString, SectionID} from "@/types";
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}`
}),
API_MISSING_CONFIG: (sectionId: SectionID): I18nString => ({
es: `Estrategia 'api_generated' pero falta apiConfig en sección ${sectionId}`,
en: `Strategy 'api_generated' but missing apiConfig in section ${sectionId}`
}),
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}`
}),
URL_BUILD_ERROR: (template: string, error: string): I18nString => ({
es: `Error construyendo URL: ${error} para template: ${template}`,
en: `Error building URL: ${error} for template: ${template}`
}),
MISSING_REQUIRED_ATTRIBUTE: (scope: string, attrId: AttrID, placeholder: string): I18nString => ({
es: `Falta atributo ${scope} requerido: ${attrId} para placeholder ${placeholder}`,
en: `Missing required ${scope} attribute: ${attrId} for placeholder ${placeholder}`
}),
NO_TEMPLATE_PROVIDED: (sectionId: SectionID): I18nString => ({
es: `No se proporcionó template para la sección ${sectionId}`,
en: `No template provided for section ${sectionId}`
}),
INVALID_ID_PREFIX: (expectedPrefix: string, gotPrefix: string): I18nString => ({
es: `Prefijo esperado "${expectedPrefix}", pero se obtuvo "${gotPrefix}"`,
en: `Expected prefix "${expectedPrefix}", but got "${gotPrefix}"`
}),
} as const;

@ -2,5 +2,9 @@
export * from './messages';
export * from './errors';
export * from './warnings';
export * from './logger';
export * from './info';
export * from './validation';

@ -0,0 +1,37 @@
// ============================================================================
// INFO MESSAGES
// ============================================================================
import type {I18nString, SectionID} from "@/types";
export const INFO = {
USING_FALLBACK_IMAGE: (sectionId: SectionID): I18nString => ({
es: `Usando imagen de fallback para ${sectionId}`,
en: `Using fallback image for ${sectionId}`
}),
IMAGE_GENERATED_SUCCESS: (sectionId: SectionID): I18nString => ({
es: `Imagen generada exitosamente para ${sectionId}`,
en: `Image generated successfully for ${sectionId}`
}),
RETRYING_IMAGE_GENERATION: (attempt: number, total: number): I18nString => ({
es: `Reintentando generación de imagen (${attempt}/${total})`,
en: `Retrying image generation (${attempt}/${total})`
}),
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}`
}),
CACHE_CLEARED: {
es: 'Cache de imágenes limpiada',
en: 'Image cache cleared'
} as I18nString
} as const;

@ -1,5 +1,3 @@
// messages.ts
/**
* ============================================================================
* MESSAGES SYSTEM - Error & Warning Messages
@ -8,190 +6,28 @@
* 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}'`
}),
import type { I18nString , AttrID, SectionID } from '@/types';
// 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'
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,28 @@
// ============================================================================
// VALIDATION MESSAGES
// ============================================================================
import type {I18nString} from "@/types";
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;

@ -0,0 +1,43 @@
// ============================================================================
// WARNING MESSAGES
// ============================================================================
import type {AttrID, I18nString, SectionID} from "@/types";
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}`
}),
MISSING_ATTRIBUTE_USING_DEFAULT: (scope: string, attrId: AttrID): I18nString => ({
es: `Falta atributo ${scope}: ${attrId}, usando 'default'`,
en: `Missing ${scope} attribute: ${attrId}, using 'default'`
}),
MISSING_ATTRIBUTE_SKIPPING: (scope: string, attrId: AttrID): I18nString => ({
es: `Falta atributo ${scope}: ${attrId}, omitiendo reemplazo`,
en: `Missing ${scope} attribute: ${attrId}, skipping replacement`
}),
} as const;

@ -12,7 +12,7 @@ export type APIMethod =
export interface APIConfig {
endpoint : string;
method? : APIMethod;
params? : string;
params? : string[];
timeout? : number;
};

@ -11,18 +11,24 @@ import type { I18nString } from '../i18n';
import type {Severity} from "@/types/core";
export type ValidationRuleActionType =
'allow' |
'forbid' |
'require' |
'suggest';
export interface ValidationRuleAction {
type : ValidationRuleActionType;
targetAttr : AttrID;
values : Value[];
}
export interface ValidationRule {
id : RuleID;
name : I18nString;
condition : JsonLogic;
//TODO sacar como tipos
action: {
type: 'allow' | 'forbid' | 'require' | 'suggest';
targetAttribute: AttrID;
values: Value[];
};
id : RuleID;
name : I18nString;
condition : JsonLogic;
action : ValidationRuleAction;
priority : number;
bidirectional: boolean;
affects : AttrID[];

@ -36,10 +36,10 @@ export interface SectionVisualConfig {
imageUrlTemplate? : string;
/** Atributos globales que afectan la imagen */
globalAttributeDependencies? : AttrID[];
globalAttrDependencies? : AttrID[];
/** Atributos de sección que afectan la imagen */
sectionAttributeDependencies?: AttrID[];
sectionAttrDependencies?: AttrID[];
/** Ruta base */
basePath? : string;
@ -50,10 +50,10 @@ export interface SectionVisualConfig {
/** Config 3D */
threeDConfig?: {
modelUrl : string;
textureAttributes: Array<{
attributeId: AttrID;
scope: 'global' | 'section';
modelUrl : string;
textureAttrs: Array<{
attrId : AttrID;
scope : 'global' | 'section';
}>;
};
@ -85,7 +85,7 @@ export interface Section {
id : SectionID;
name : I18nString;
description : I18nString;
attributes : Attribute[];
attrs : Attribute[];
visualConfig: SectionVisualConfig;
availability: SectionAvailability;
order? : number;

@ -88,10 +88,10 @@ export interface ViewVisualConfig {
basePath?: string;
/** Atributos globales que afectan a esta vista */
globalAttributeDependencies?: AttrID[];
globalAttrDependencies?: AttrID[];
/** Atributos de sección que afectan a esta vista */
sectionAttributeDependencies?: AttrID[];
sectionAttrDependencies?: AttrID[];
/** Imagen de fallback */
fallbackImage?: string;
@ -101,9 +101,13 @@ export interface ViewVisualConfig {
/** Config 3D */
threeDConfig?: {
modelUrl: string;
cameraPosition?: { x: number; y: number; z: number };
textureAttributes?: Array<{
modelUrl : string;
cameraPosition? : {
x: number;
y: number;
z: number
};
textureAttributes? : Array<{
attributeId: AttrID;
scope: 'global' | 'section';
}>;

@ -4,7 +4,7 @@
* ============================================================================
*/
import type { Value } from '../../types/core';
import type { Value } from '@/types';
/**
* Deep equality check para valores

@ -5,7 +5,7 @@
*/
import type { I18nString, I18nTranslations, SupportedLocale } from '@/types';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@/constants';
import { DEFAULTS, SUPPORTED_LOCALES } from '@/constants';
import { translate } from '@/utils';
/**
@ -15,8 +15,8 @@ import { translate } from '@/utils';
export function createI18nString(
translations: Partial<Record<SupportedLocale, string>>
): I18nString {
if (!translations[DEFAULT_LOCALE]) {
throw new Error(`I18nString must have at least '${DEFAULT_LOCALE}' translation`);
if (!translations[DEFAULTS.LOCALE]) {
throw new Error(`I18nString must have at least '${DEFAULTS.LOCALE}' translation`);
}
return translations as I18nTranslations;
}
@ -33,9 +33,10 @@ export function createSimpleI18nString(text: string): I18nString {
* (Útil para migración)
*/
export function expandToTranslations(
text: I18nString,
defaultLocale: SupportedLocale = DEFAULT_LOCALE
text : I18nString,
defaultLocale: SupportedLocale = DEFAULTS.LOCALE
): I18nTranslations {
if (typeof text === 'string') {
return { [defaultLocale]: text } as I18nTranslations;
}
@ -47,8 +48,8 @@ export function expandToTranslations(
* (Útil para concatenar textos)
*/
export function combineI18nStrings(
strings: I18nString[],
separator: string = ' '
strings : I18nString[],
separator : string = ' '
): I18nString {
// Si todos son strings simples, concatenar directamente
if (strings.every(s => typeof s === 'string')) {

@ -5,7 +5,7 @@
*/
import type { SupportedLocale, LocaleInfo } from '@/types';
import { SUPPORTED_LOCALES, LOCALE_INFO, DEFAULT_LOCALE } from '@/constants';
import { SUPPORTED_LOCALES, LOCALE_INFO, DEFAULTS } from '@/constants';
/**
* Verificar si un string es un locale soportado
@ -18,8 +18,8 @@ export function isSupportedLocale(locale: string): locale is SupportedLocale {
* Obtener locale válido con fallback
*/
export function getValidLocale(
locale: string | undefined,
fallback: SupportedLocale = DEFAULT_LOCALE
locale : string | undefined,
fallback: SupportedLocale = DEFAULTS.LOCALE
): SupportedLocale {
if (!locale) return fallback;
return isSupportedLocale(locale) ? locale : fallback;

@ -4,8 +4,8 @@
* ============================================================================
*/
import type { I18nString, SupportedLocale } from '../../types/i18n';
import { DEFAULT_LOCALE } from '../../constants';
import type { I18nString, SupportedLocale } from '@/types';
import { DEFAULTS } from '@/constants';
/**
* Obtener texto traducido
@ -24,7 +24,7 @@ import { DEFAULT_LOCALE } from '../../constants';
export function translate(
text: I18nString,
locale: SupportedLocale,
fallback: SupportedLocale = DEFAULT_LOCALE
fallback: SupportedLocale = DEFAULTS.LOCALE
): string {
// Si es string simple, devolverlo directamente
if (typeof text === 'string') {
@ -55,8 +55,8 @@ export const t = translate;
* Verificar si un I18nString tiene traducción para un locale
*/
export function hasTranslation(
text: I18nString,
locale: SupportedLocale
text : I18nString,
locale : SupportedLocale
): boolean {
if (typeof text === 'string') return true;
return text[locale] !== undefined;

@ -5,7 +5,7 @@
*/
import type { I18nString } from '@/types';
import { DEFAULT_LOCALE } from '@/constants';
import { DEFAULTS } from '@/constants';
/**
* Validar que un I18nString es correcto
@ -18,7 +18,7 @@ export function validateI18nString(text: unknown): text is I18nString {
const obj = text as Record<string, unknown>;
// Debe tener al menos el locale por defecto
if (typeof obj[DEFAULT_LOCALE] !== 'string') return false;
if (typeof obj[DEFAULTS.LOCALE] !== 'string') return false;
// Todos los valores deben ser strings
return Object.values(obj).every(v => typeof v === 'string');
@ -40,7 +40,7 @@ export class I18nStringError extends Error {
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.`
`Invalid I18nString. Must be a string or object with at least '${DEFAULTS.LOCALE}' key.`
);
}
}

@ -148,9 +148,13 @@ export function extractIdPart<T extends string>(
// Validate prefix if provided
if (expectedPrefix && parsed.prefix !== expectedPrefix) {
throw new Error(
`ID prefix mismatch: expected "${expectedPrefix}:", got "${parsed.prefix}:" in ID "${id}"`
);
const errorMsg = ERRORS.ID_PREFIX_MISMATCH(id, expectedPrefix, parsed.prefix);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, {
id,
expectedPrefix,
gotPrefix: parsed.prefix
});
throw new Error(JSON.stringify(errorMsg));
}
// Cache and return
@ -181,55 +185,65 @@ export function tryExtractIdPart<T extends string>(
* 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 ":"`);
const errorMsg = ERRORS.INVALID_ID_VALUE(id);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id, type: 'attr' });
throw new Error(JSON.stringify(errorMsg));
}
return `at:${id}`;
},
opt: (id: string): OptionID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid option ID value: "${id}"`);
const errorMsg = ERRORS.INVALID_ID_VALUE(id);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id, type: 'option' });
throw new Error(JSON.stringify(errorMsg));
}
return `op:${id}`;
},
sec: (id: string): SectionID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid section ID value: "${id}"`);
const errorMsg = ERRORS.INVALID_ID_VALUE(id);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id, type: 'section' });
throw new Error(JSON.stringify(errorMsg));
}
return `sc:${id}`;
},
obj: (id: string): ObjectID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid object ID value: "${id}"`);
const errorMsg = ERRORS.INVALID_ID_VALUE(id);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id, type: 'object' });
throw new Error(JSON.stringify(errorMsg));
}
return `ob:${id}`;
},
rule: (id: string): RuleID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid rule ID value: "${id}"`);
const errorMsg = ERRORS.INVALID_ID_VALUE(id);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id, type: 'rule' });
throw new Error(JSON.stringify(errorMsg));
}
return `rl:${id}`;
},
view: (id: string): ViewID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid view ID value: "${id}"`);
const errorMsg = ERRORS.INVALID_ID_VALUE(id);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id, type: 'view' });
throw new Error(JSON.stringify(errorMsg));
}
return `vw:${id}`;
},
hspot: (id: string): HotspotID => {
if (!id || id.includes(':')) {
throw new Error(`Invalid hotspot ID value: "${id}"`);
const errorMsg = ERRORS.INVALID_ID_VALUE(id);
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id, type: 'hotspot' });
throw new Error(JSON.stringify(errorMsg));
}
return `hs:${id}`;
}
@ -320,9 +334,15 @@ export function clearIdCache(): void {
*/
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}"`);
const errorMsg = ERRORS.INVALID_ID_PREFIX(expectedPrefix, parsed.prefix); // ✅ CAMBIO
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, {
id,
expectedPrefix,
gotPrefix: parsed.prefix
});
throw new Error(JSON.stringify(errorMsg));
}
// Normalizar el value: lowercase, trim, remove espacios extra

@ -10,9 +10,10 @@
* ============================================================================
*/
import type { AttrID, SectionID, ViewID } from "../../types/model";
import type { Value } from "../../types/core";
import type {AttrID, SectionID, ViewID, Value, I18nString} from "@/types";
import { DEFAULTS } from '@/constants';
import { extractIdPart } from "../ids";
import {ERRORS, logger, MESSAGE_CATEGORIES, WARNINGS} from "@/messages";
// ============================================================================
// TYPES
@ -29,10 +30,10 @@ export interface UrlTemplateContext {
viewId?: ViewID;
/** Atributos globales disponibles */
globalAttributes?: Record<AttrID, Value>;
globalAttrs?: Record<AttrID, Value>;
/** Atributos de sección disponibles */
sectionAttributes?: Record<AttrID, Value>;
sectionAttrs?: Record<AttrID, Value>;
/** IDs de atributos globales que el template necesita */
globalDependencies?: AttrID[];
@ -49,21 +50,31 @@ export interface UrlBuildResult {
unresolvedPlaceholders: string[];
/** Advertencias durante la generación */
warnings: string[];
warnings: I18nString[];
/** Si se usó la URL de fallback */
usedFallback: boolean;
}
/** Modo de logging: 'none' | 'console' | 'collect' */
export type UrlBuilderLogMode =
'none' |
'console' |
'collect'
/** Estrategia cuando falta un atributo: 'default' | 'skip' | 'error' */
export type UrlBuilderOnMissingStrategy =
'default' |
'skip' |
'error';
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';
fallbackUrl? : string;
logMode? : UrlBuilderLogMode;
onMissingAttr? : UrlBuilderOnMissingStrategy
}
// ============================================================================
@ -71,19 +82,19 @@ export interface UrlBuilderOptions {
// ============================================================================
export class UrlTemplateBuilder {
private warnings: string[] = [];
private warnings : I18nString[] = [];
private readonly fallbackUrl: string;
private readonly options: Required<UrlBuilderOptions>;
private readonly options : Required<UrlBuilderOptions>;
constructor(
private template: string,
options: UrlBuilderOptions = {}
) {
this.fallbackUrl = options.fallbackUrl || '/images/fallback.jpg';
this.fallbackUrl = options.fallbackUrl || DEFAULTS.FALLBACK_IMAGE;
this.options = {
fallbackUrl: this.fallbackUrl,
logMode: options.logMode || 'console',
onMissingAttribute: options.onMissingAttribute || 'default'
fallbackUrl : this.fallbackUrl,
logMode : options.logMode || 'console',
onMissingAttr: options.onMissingAttr || 'default'
};
}
@ -113,22 +124,22 @@ export class UrlTemplateBuilder {
}
// 4. Reemplazar atributos globales
if (context.globalDependencies && context.globalAttributes) {
if (context.globalDependencies && context.globalAttrs) {
url = this.replaceAttributes(
url,
'global',
context.globalDependencies,
context.globalAttributes
context.globalAttrs
);
}
// 5. Reemplazar atributos de sección
if (context.sectionDependencies && context.sectionAttributes) {
if (context.sectionDependencies && context.sectionAttrs) {
url = this.replaceAttributes(
url,
'section',
context.sectionDependencies,
context.sectionAttributes
context.sectionAttrs
);
}
@ -137,36 +148,36 @@ export class UrlTemplateBuilder {
if (unresolved.length > 0) {
this.addWarning(
`Unresolved placeholders: ${unresolved.join(', ')}`,
ERRORS.UNRESOLVED_PLACEHOLDERS(unresolved,this.template),
{ template: this.template, partialUrl: url }
);
return {
url: this.fallbackUrl,
unresolvedPlaceholders: unresolved,
warnings: this.warnings,
usedFallback: true
url : this.fallbackUrl,
unresolvedPlaceholders : unresolved,
warnings : this.warnings,
usedFallback : true
};
}
return {
url,
unresolvedPlaceholders: [],
warnings: this.warnings,
usedFallback: false
warnings : this.warnings,
usedFallback : false
};
} catch (error) {
this.addWarning(
`Error building URL: ${error instanceof Error ? error.message : String(error)}`,
ERRORS.URL_BUILD_ERROR(this.template,error instanceof Error ? error.message : String(error)),
{ template: this.template, context }
);
return {
url: this.fallbackUrl,
url : this.fallbackUrl,
unresolvedPlaceholders: [],
warnings: this.warnings,
usedFallback: true
warnings : this.warnings,
usedFallback : true
};
}
}
@ -183,10 +194,10 @@ export class UrlTemplateBuilder {
* Reemplaza placeholders de atributos: {scope.attrName}
*/
private replaceAttributes(
url: string,
scope: 'global' | 'section',
url : string,
scope : 'global' | 'section',
dependencies: AttrID[],
attributes: Record<AttrID, Value>
attrs : Record<AttrID, Value>
): string {
for (const attrId of dependencies) {
const attrName = extractIdPart(attrId, 'at');
@ -197,7 +208,7 @@ export class UrlTemplateBuilder {
continue;
}
const value = attributes[attrId];
const value = attrs[attrId];
if (value !== undefined) {
const normalized = this.normalizeValueForUrl(value);
@ -250,27 +261,27 @@ export class UrlTemplateBuilder {
scope: string,
attrId: AttrID
): string {
const strategy = this.options.onMissingAttribute;
const strategy = this.options.onMissingAttr;
switch (strategy) {
case 'default':
this.addWarning(
`Missing ${scope} attribute: ${attrId}, using 'default'`,
WARNINGS.MISSING_ATTRIBUTE_USING_DEFAULT(scope, attrId),
{ placeholder }
);
return url.replace(placeholder, 'default');
case 'skip':
this.addWarning(
`Missing ${scope} attribute: ${attrId}, skipping replacement`,
WARNINGS.MISSING_ATTRIBUTE_SKIPPING(scope, attrId),
{ placeholder }
);
return url; // No reemplazar
return url;
case 'error':
throw new Error(
`Missing required ${scope} attribute: ${attrId} for placeholder ${placeholder}`
);
const errorMsg = ERRORS.MISSING_REQUIRED_ATTRIBUTE(scope, attrId, placeholder);
logger.error(MESSAGE_CATEGORIES.RENDER, errorMsg, { scope, attrId, placeholder });
throw new Error(JSON.stringify(errorMsg));
}
}
@ -285,11 +296,11 @@ export class UrlTemplateBuilder {
/**
* Añade una advertencia al log
*/
private addWarning(message: string, context?: Record<string, any>): void {
private addWarning(message: I18nString, context?: Record<string, any>): void {
this.warnings.push(message);
if (this.options.logMode === 'console') {
console.warn('[UrlBuilder]', message, context);
logger.warn(MESSAGE_CATEGORIES.RENDER, message, context);
}
}
}
@ -298,40 +309,48 @@ export class UrlTemplateBuilder {
// HELPER FUNCTIONS
// ============================================================================
export interface BuildImageConfig {
basePath? : string;
fallbackImage? : string;
globalAttrDependencies? : AttrID[];
sectionAttrDependencies?: AttrID[];
}
/**
* 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>
template : string,
sectionId : SectionID,
config : BuildImageConfig,
globalAttrs : Record<AttrID, Value>,
sectionAttrs: Record<AttrID, Value>
): string {
if (!template) {
console.warn(`[UrlBuilder] No template provided for section ${sectionId}`);
return config.fallbackImage || '/images/fallback.jpg';
logger.warn(
MESSAGE_CATEGORIES.RENDER,
ERRORS.NO_TEMPLATE_PROVIDED(sectionId),
{ sectionId }
);
return config.fallbackImage || DEFAULTS.FALLBACK_IMAGE;;
}
const builder = new UrlTemplateBuilder(template, {
fallbackUrl: config.fallbackImage,
logMode: 'console',
onMissingAttribute: 'default'
fallbackUrl : config.fallbackImage,
logMode : 'console',
onMissingAttr: 'default'
});
const result = builder.build({
basePath: config.basePath,
basePath : config.basePath,
sectionId,
globalAttributes,
sectionAttributes,
globalDependencies: config.globalAttributeDependencies,
sectionDependencies: config.sectionAttributeDependencies
globalAttrs : globalAttrs,
sectionAttrs : sectionAttrs,
globalDependencies : config.globalAttrDependencies,
sectionDependencies : config.sectionAttrDependencies
});
return result.url;
@ -341,36 +360,32 @@ export function buildSectionImageUrl(
* 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>
template : string,
sectionId : SectionID,
viewId : ViewID,
config : BuildImageConfig,
globalAttrs : Record<AttrID, Value>,
sectionAttrs: Record<AttrID, Value>
): string {
if (!template) {
return config.fallbackImage || '/images/fallback.jpg';
return config.fallbackImage || DEFAULTS.FALLBACK_IMAGE;
}
const builder = new UrlTemplateBuilder(template, {
fallbackUrl: config.fallbackImage,
logMode: 'console',
onMissingAttribute: 'default'
fallbackUrl : config.fallbackImage,
logMode : 'console',
onMissingAttr: 'default'
});
const result = builder.build({
basePath: config.basePath,
basePath : config.basePath,
sectionId,
viewId,
globalAttributes,
sectionAttributes,
globalDependencies: config.globalAttributeDependencies,
sectionDependencies: config.sectionAttributeDependencies
globalAttrs : globalAttrs,
sectionAttrs: sectionAttrs,
globalDependencies : config.globalAttrDependencies,
sectionDependencies: config.sectionAttrDependencies
});
return result.url;

@ -3,7 +3,7 @@
* ATTRIBUTE VALIDATOR
* ============================================================================
*/
import type {Attribute, AttrID, SectionID} from '@/types';
import type {Attribute, AttrID, I18nString, SectionID} from '@/types';
export type ValidationSeverity =
@ -14,7 +14,7 @@ export type ValidationSeverity =
export interface ValidationError {
attrId? : AttrID;
sectionId? : SectionID;
message : string;
message : I18nString;
severity : ValidationSeverity;
}
@ -27,8 +27,8 @@ export function validateAttribute(attr: Attribute): ValidationError[] {
// Computed debe ser readonly
if (attr.type === 'computed' && !attr.display.readonly) {
errors.push({
attrId: attr.id,
message: 'Computed attributes should be readonly',
attrId : attr.id,
message : 'Computed attributes should be readonly',
severity: 'error'
});
}
@ -36,8 +36,8 @@ export function validateAttribute(attr: Attribute): ValidationError[] {
// 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',
attrId : attr.id,
message : 'Fixed attributes should not affect price',
severity: 'warning'
});
}
@ -46,8 +46,8 @@ export function validateAttribute(attr: Attribute): ValidationError[] {
if (attr.type === 'computed') {
if (!attr.dependencies || attr.dependencies.length === 0) {
errors.push({
attrId: attr.id,
message: 'Computed attribute has no dependencies',
attrId : attr.id,
message : 'Computed attribute has no dependencies',
severity: 'warning'
});
}

@ -6,6 +6,7 @@
import type { Section, AttrID } from '@/types';
import type { ValidationError } from '@/utils';
import {ERRORS} from "@/messages";
/**
* Validar sección en runtime
@ -25,10 +26,10 @@ export function validateSection(section: Section): ValidationError[] {
const attrName = cleanName.replace('global.', '');
const attrId = `at:${attrName}` as AttrID;
if (!section.visualConfig.globalAttributeDependencies?.includes(attrId)) {
if (!section.visualConfig.globalAttrDependencies?.includes(attrId)) {
errors.push({
sectionId: section.id,
message: `Placeholder ${placeholder} not in globalAttributeDependencies`,
message: ERRORS.PLACEHOLDER_NOT_IN_DEPENDENCIES(placeholder, section.id),
severity: 'error'
});
}

@ -10,9 +10,9 @@ import type { Hotspot, SectionView , AttrID } from '@/types';
* Obtener hotspots visibles de una vista
*/
export function getVisibleHotspots(
view : SectionView,
attributeValues : Record<AttrID, any>,
evaluator : any
view : SectionView,
attrValues : Record<AttrID, any>,
evaluator : any
): Hotspot[] {
if (!view.hotspots) return [];
@ -24,7 +24,7 @@ export function getVisibleHotspots(
return false;
}
// Es JsonLogic, evaluar
const data = { attributes: attributeValues };
const data = { attributes: attrValues };
return evaluator.evaluate(hotspot.visible, data);
});
}

@ -4,24 +4,24 @@
* ============================================================================
*/
import type { SectionView } from '../../types/view';
import type { SectionID, AttrID } from '../../types/model';
import type { Value } from '../../types/core';
import type { SectionView, SectionID, AttrID, Value } from '@/types';
import { extractIdPart } from '../ids';
import {DEFAULTS} from "@/constants";
/**
* Generar URL de imagen para una vista específica
*/
export function generateViewImageUrl(
view : SectionView,
sectionId : SectionID,
globalAttributes : Record<AttrID, Value>,
sectionAttributes: Record<AttrID, Value>
view : SectionView,
sectionId : SectionID,
globalAttrs : Record<AttrID, Value>,
sectionAttrs: Record<AttrID, Value>
): string {
const config = view.visualConfig;
if (!config.imageUrlTemplate) {
return config.fallbackImage || '/images/fallback.jpg';
return config.fallbackImage || DEFAULTS.FALLBACK_IMAGE;
}
let url = config.imageUrlTemplate;
@ -40,18 +40,18 @@ export function generateViewImageUrl(
url = url.replace('{viewId}', viewIdPart);
// Reemplazar atributos globales
config.globalAttributeDependencies?.forEach(attrId => {
config.globalAttrDependencies?.forEach(attrId => {
const attrName = extractIdPart(attrId, 'at');
const value = globalAttributes[attrId];
const value = globalAttrs[attrId];
if (value !== undefined) {
url = url.replace(`{global.${attrName}}`, String(value));
}
});
// Reemplazar atributos de sección
config.sectionAttributeDependencies?.forEach(attrId => {
config.sectionAttrDependencies?.forEach(attrId => {
const attrName = extractIdPart(attrId, 'at');
const value = sectionAttributes[attrId];
const value = sectionAttrs[attrId];
if (value !== undefined) {
url = url.replace(`{section.${attrName}}`, String(value));
}

@ -3,8 +3,6 @@
* VIEW SELECTOR UTILITIES
* ============================================================================
*/
import type { SectionView, AttrID } from '@/types';
/**
@ -19,10 +17,11 @@ export function getDefaultView(views: SectionView[]): SectionView | null {
* Filtrar vistas visibles según condiciones
*/
export function getVisibleViews(
views: SectionView[],
attributeValues: Record<AttrID, any>,
evaluator: any
views : SectionView[],
attrValues : Record<AttrID, any>,
evaluator : any
): SectionView[] {
return views.filter(view => {
if (view.visible === undefined || view.visible === true) {
return true;
@ -31,7 +30,7 @@ export function getVisibleViews(
return false;
}
// Es JsonLogic, evaluar
const data = { attributes: attributeValues };
const data = { attributes: attrValues };
return evaluator.evaluate(view.visible, data);
});
}

@ -1,353 +0,0 @@
/**
* ============================================================================
* 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
};

@ -1,19 +0,0 @@
// 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}`,
};

@ -1,329 +0,0 @@
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';
}

@ -1,109 +0,0 @@
// 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;
}

@ -1,107 +0,0 @@
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);

@ -1,217 +0,0 @@
// 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" }
}
]
};

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

@ -1,93 +0,0 @@
// 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;
}

@ -1,59 +0,0 @@
// 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;
}
}

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

@ -1,23 +0,0 @@
// 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';

@ -1,362 +0,0 @@
/**
* ============================================================================
* 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;
}
}

@ -1,189 +0,0 @@
// 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';
Loading…
Cancel
Save

Powered by TurnKey Linux.