You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
330 lines
11 KiB
330 lines
11 KiB
|
|
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';
|
|
}
|
|
|
|
|