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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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,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…
Reference in new issue