commit
43b894b603
@ -0,0 +1 @@
|
|||||||
|
/node_modules/
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<component name="ProjectDictionaryState">
|
||||||
|
<dictionary name="project">
|
||||||
|
<words>
|
||||||
|
<w>hspot</w>
|
||||||
|
</words>
|
||||||
|
</dictionary>
|
||||||
|
</component>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/cpq-engine.iml" filepath="$PROJECT_DIR$/.idea/cpq-engine.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "visual-configuration-engine",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Sistema de configuración visual dinámica",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "tsc --watch"
|
||||||
|
},
|
||||||
|
"keywords": ["configuration", "visual", "typescript"],
|
||||||
|
"author": "ACTIVE THING",
|
||||||
|
"license": "EULA",
|
||||||
|
|
||||||
|
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Constants - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './locales';
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* LOCALE CONSTANTS
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SupportedLocale, LocaleInfo } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale por defecto del sistema
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LOCALE: SupportedLocale = 'es';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista de todos los locales soportados
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
|
||||||
|
'es',
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
'it',
|
||||||
|
'pt',
|
||||||
|
'ca',
|
||||||
|
'eu',
|
||||||
|
'gl'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata de locales soportados
|
||||||
|
*/
|
||||||
|
export const LOCALE_INFO: Record<SupportedLocale, LocaleInfo> = {
|
||||||
|
es: {
|
||||||
|
code: 'es',
|
||||||
|
name: 'Spanish',
|
||||||
|
nativeName: 'Español',
|
||||||
|
flag: '🇪🇸'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
code: 'en',
|
||||||
|
name: 'English',
|
||||||
|
nativeName: 'English',
|
||||||
|
flag: '🇬🇧'
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
code: 'de',
|
||||||
|
name: 'German',
|
||||||
|
nativeName: 'Deutsch',
|
||||||
|
flag: '🇩🇪'
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
code: 'fr',
|
||||||
|
name: 'French',
|
||||||
|
nativeName: 'Français',
|
||||||
|
flag: '🇫🇷'
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
code: 'it',
|
||||||
|
name: 'Italian',
|
||||||
|
nativeName: 'Italiano',
|
||||||
|
flag: '🇮🇹'
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
code: 'pt',
|
||||||
|
name: 'Portuguese',
|
||||||
|
nativeName: 'Português',
|
||||||
|
flag: '🇵🇹'
|
||||||
|
},
|
||||||
|
ca: {
|
||||||
|
code: 'ca',
|
||||||
|
name: 'Catalan',
|
||||||
|
nativeName: 'Català',
|
||||||
|
flag: '🏴'
|
||||||
|
},
|
||||||
|
eu: {
|
||||||
|
code: 'eu',
|
||||||
|
name: 'Basque',
|
||||||
|
nativeName: 'Euskara',
|
||||||
|
flag: '🏴'
|
||||||
|
},
|
||||||
|
gl: {
|
||||||
|
code: 'gl',
|
||||||
|
name: 'Galician',
|
||||||
|
nativeName: 'Galego',
|
||||||
|
flag: '🏴'
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* ENGINE - Public API
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Business logic del Visual Configuration Engine
|
||||||
|
*/
|
||||||
|
|
||||||
|
// JSON Logic Engine
|
||||||
|
export * from './json-logic';
|
||||||
|
|
||||||
|
// Visual Configuration Engine
|
||||||
|
export * from './visual-config';
|
||||||
@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* JSON LOGIC EVALUATOR
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JsonLogic } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluador de expresiones JsonLogic
|
||||||
|
*/
|
||||||
|
export class JsonLogicEvaluator {
|
||||||
|
/**
|
||||||
|
* Evalúa una expresión JsonLogic
|
||||||
|
*/
|
||||||
|
evaluate(expression: JsonLogic, data: Record<string, any>): any {
|
||||||
|
// Primitivos
|
||||||
|
if (expression === null) return null;
|
||||||
|
if (typeof expression === 'boolean') return expression;
|
||||||
|
if (typeof expression === 'number') return expression;
|
||||||
|
if (typeof expression === 'string') return expression;
|
||||||
|
if (Array.isArray(expression)) {
|
||||||
|
return expression.map(item => this.evaluate(item, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof expression !== 'object') return expression;
|
||||||
|
|
||||||
|
// Operadores
|
||||||
|
const expr = expression as Record<string, any>;
|
||||||
|
const operator = Object.keys(expr)[0];
|
||||||
|
const args = expr[operator];
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
// Variables
|
||||||
|
case 'var':
|
||||||
|
return this.getVar(args, data);
|
||||||
|
|
||||||
|
// Lógicos
|
||||||
|
case '==':
|
||||||
|
case '===':
|
||||||
|
return this.evaluateAll(args, data).every((v, i, arr) =>
|
||||||
|
i === 0 || v === arr[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
case '!=':
|
||||||
|
case '!==':
|
||||||
|
return this.evaluate(args[0], data) !== this.evaluate(args[1], data);
|
||||||
|
|
||||||
|
case '!':
|
||||||
|
return !this.evaluate(args, data);
|
||||||
|
|
||||||
|
case 'and':
|
||||||
|
return this.evaluateAll(args, data).every(Boolean);
|
||||||
|
|
||||||
|
case 'or':
|
||||||
|
return this.evaluateAll(args, data).some(Boolean);
|
||||||
|
|
||||||
|
// Comparación
|
||||||
|
case '>':
|
||||||
|
return this.evaluate(args[0], data) > this.evaluate(args[1], data);
|
||||||
|
|
||||||
|
case '>=':
|
||||||
|
return this.evaluate(args[0], data) >= this.evaluate(args[1], data);
|
||||||
|
|
||||||
|
case '<':
|
||||||
|
return this.evaluate(args[0], data) < this.evaluate(args[1], data);
|
||||||
|
|
||||||
|
case '<=':
|
||||||
|
return this.evaluate(args[0], data) <= this.evaluate(args[1], data);
|
||||||
|
|
||||||
|
// Condicionales
|
||||||
|
case 'if':
|
||||||
|
const [condition, thenBranch, elseBranch] = args;
|
||||||
|
return this.evaluate(condition, data)
|
||||||
|
? this.evaluate(thenBranch, data)
|
||||||
|
: this.evaluate(elseBranch !== undefined ? elseBranch : null, data);
|
||||||
|
|
||||||
|
// Arrays
|
||||||
|
case 'in':
|
||||||
|
const [item, array] = args;
|
||||||
|
const evaluatedItem = this.evaluate(item, data);
|
||||||
|
const evaluatedArray = this.evaluate(array, data);
|
||||||
|
return Array.isArray(evaluatedArray) && evaluatedArray.includes(evaluatedItem);
|
||||||
|
|
||||||
|
// Matemáticos
|
||||||
|
case '+':
|
||||||
|
return this.evaluateAll(args, data).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
case '-':
|
||||||
|
const nums = this.evaluateAll(args, data);
|
||||||
|
return nums.length === 1 ? -nums[0] : nums[0] - nums[1];
|
||||||
|
|
||||||
|
case '*':
|
||||||
|
return this.evaluateAll(args, data).reduce((a, b) => a * b, 1);
|
||||||
|
|
||||||
|
case '/':
|
||||||
|
const [dividend, divisor] = this.evaluateAll(args, data);
|
||||||
|
return divisor !== 0 ? dividend / divisor : null;
|
||||||
|
|
||||||
|
case '%':
|
||||||
|
return this.evaluate(args[0], data) % this.evaluate(args[1], data);
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`[JsonLogic] Unknown operator: ${operator}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private evaluateAll(args: any[], data: Record<string, any>): any[] {
|
||||||
|
return (Array.isArray(args) ? args : [args]).map(arg =>
|
||||||
|
this.evaluate(arg, data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVar(path: string | any, data: Record<string, any>): any {
|
||||||
|
if (typeof path !== 'string') {
|
||||||
|
path = this.evaluate(path, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = String(path).split('.');
|
||||||
|
let current: any = data;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current === undefined || current === null) return undefined;
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* JSON Logic Engine - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './evaluator';
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* ASYNC IMAGE GENERATOR
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Section, AttrID, Value } from '@/types';
|
||||||
|
import { generateSectionImageUrl } from '@/engine';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generación async de imagen via API
|
||||||
|
*/
|
||||||
|
export async function generateSectionImageAsync(
|
||||||
|
section: Section,
|
||||||
|
globalAttributes: Record<AttrID, Value>,
|
||||||
|
sectionAttributes: Record<AttrID, Value>
|
||||||
|
): Promise<string> {
|
||||||
|
const config = section.visualConfig;
|
||||||
|
const fallback = config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
|
||||||
|
if (config.strategy !== 'api_generated' || !config.apiConfig) {
|
||||||
|
return generateSectionImageUrl(section, globalAttributes, sectionAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: Record<string, Value> = {};
|
||||||
|
config.apiConfig.params?.forEach(attrId => {
|
||||||
|
const value = globalAttributes[attrId] ?? sectionAttributes[attrId];
|
||||||
|
if (value !== undefined) params[attrId] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), config.apiConfig.timeout || 5000);
|
||||||
|
|
||||||
|
const response = await fetch(config.apiConfig.endpoint, {
|
||||||
|
method: config.apiConfig.method || 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sectionId: section.id, attributes: params }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[Render] API ${config.apiConfig.endpoint} returned ${response.status}`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.imageUrl || fallback;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Render] Network or Timeout error for ${section.id}:`, error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* ATTRIBUTE CACHE
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
import type { Attribute, ID } from '@/types';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache para utilities del engine
|
||||||
|
*/
|
||||||
|
export class AttributeCache {
|
||||||
|
private uiVisibleCache = new Map<ID<'attr'>, boolean>();
|
||||||
|
private visualAttributesCache: Attribute[] | null = null;
|
||||||
|
private priceAttributesCache: Attribute[] | null = null;
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
this.uiVisibleCache.clear();
|
||||||
|
this.visualAttributesCache = null;
|
||||||
|
this.priceAttributesCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUIVisible(
|
||||||
|
attrId: ID<'attr'>,
|
||||||
|
compute: () => boolean
|
||||||
|
): boolean {
|
||||||
|
if (this.uiVisibleCache.has(attrId)) {
|
||||||
|
return this.uiVisibleCache.get(attrId)!;
|
||||||
|
}
|
||||||
|
const result = compute();
|
||||||
|
this.uiVisibleCache.set(attrId, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVisualAttributes(
|
||||||
|
compute: () => Attribute[]
|
||||||
|
): Attribute[] {
|
||||||
|
if (this.visualAttributesCache) {
|
||||||
|
return this.visualAttributesCache;
|
||||||
|
}
|
||||||
|
this.visualAttributesCache = compute();
|
||||||
|
return this.visualAttributesCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPriceAttributes(
|
||||||
|
compute: () => Attribute[]
|
||||||
|
): Attribute[] {
|
||||||
|
if (this.priceAttributesCache) {
|
||||||
|
return this.priceAttributesCache;
|
||||||
|
}
|
||||||
|
this.priceAttributesCache = compute();
|
||||||
|
return this.priceAttributesCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* DISPLAY FLAG EVALUATOR
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {JsonLogic, ID, Value, AttrID} from '@/types';
|
||||||
|
import type { JsonLogicEvaluator } from '@/engine';
|
||||||
|
import { extractIdPart } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evalúa display flags con soporte para condicionales
|
||||||
|
*/
|
||||||
|
export function evaluateDisplayFlag(
|
||||||
|
flag: boolean | JsonLogic | undefined,
|
||||||
|
defaultValue: boolean,
|
||||||
|
attributeValues: Record<AttrID, Value>,
|
||||||
|
evaluator: JsonLogicEvaluator
|
||||||
|
): boolean {
|
||||||
|
if (flag === undefined) return defaultValue;
|
||||||
|
if (typeof flag === 'boolean') return flag;
|
||||||
|
|
||||||
|
// Es JsonLogic: evaluar
|
||||||
|
const data = {
|
||||||
|
attributes: Object.fromEntries(
|
||||||
|
(Object.entries(attributeValues) as [AttrID, Value][]).map(([id, value]) => [
|
||||||
|
extractIdPart(id, 'at'),
|
||||||
|
value
|
||||||
|
])
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
return evaluator.evaluate(flag, data);
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* IMAGE GENERATOR
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Section, AttrID, Value } from '@/types';
|
||||||
|
import { buildSectionImageUrl } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera URL de imagen para una sección
|
||||||
|
* Utiliza el URL builder centralizado
|
||||||
|
*/
|
||||||
|
export function generateSectionImageUrl(
|
||||||
|
section: Section,
|
||||||
|
globalAttributes: Record<AttrID, Value>,
|
||||||
|
sectionAttributes: Record<AttrID, Value>
|
||||||
|
): string {
|
||||||
|
const config = section.visualConfig;
|
||||||
|
|
||||||
|
if (!config.imageUrlTemplate) {
|
||||||
|
console.warn(`[Render] No imageUrlTemplate for section ${section.id}`);
|
||||||
|
return config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildSectionImageUrl(
|
||||||
|
config.imageUrlTemplate,
|
||||||
|
section.id,
|
||||||
|
{
|
||||||
|
basePath: config.basePath,
|
||||||
|
fallbackImage: config.fallbackImage,
|
||||||
|
globalAttributeDependencies: config.globalAttributeDependencies,
|
||||||
|
sectionAttributeDependencies: config.sectionAttributeDependencies
|
||||||
|
},
|
||||||
|
globalAttributes,
|
||||||
|
sectionAttributes
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Visual Configuration Engine - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './display-evaluator';
|
||||||
|
export * from './image-generator';
|
||||||
|
export * from './async-generator';
|
||||||
|
export * from './rerender-detector';
|
||||||
|
export * from './cache';
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* RERENDER DETECTOR
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Section, AttrID , Value } from '@/types';
|
||||||
|
import { deepEqual } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determina si una sección debe re-renderizarse
|
||||||
|
*/
|
||||||
|
export function shouldRerenderSection(
|
||||||
|
section: Section,
|
||||||
|
changedAttributeId: AttrID,
|
||||||
|
oldValue: Value,
|
||||||
|
newValue: Value,
|
||||||
|
isGlobalAttribute: boolean
|
||||||
|
): boolean {
|
||||||
|
const config = section.visualConfig;
|
||||||
|
|
||||||
|
// Verificar si el atributo afecta a esta sección
|
||||||
|
const affectsThisSection = isGlobalAttribute
|
||||||
|
? config.globalAttributeDependencies?.includes(changedAttributeId)
|
||||||
|
: config.sectionAttributeDependencies?.includes(changedAttributeId);
|
||||||
|
|
||||||
|
if (!affectsThisSection) return false;
|
||||||
|
|
||||||
|
// Deep equality para valores complejos
|
||||||
|
return !deepEqual(oldValue, newValue);
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VISUAL CONFIGURATION ENGINE - Public API
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Sistema completo de configuración visual dinámica
|
||||||
|
*
|
||||||
|
* @packageDocumentation
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types (re-export all types)
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
export * from './constants';
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
export * from './utils';
|
||||||
|
|
||||||
|
// Engine (Business Logic)
|
||||||
|
export * from './engine';
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
// index.ts
|
||||||
|
|
||||||
|
|
||||||
|
export * from './messages';
|
||||||
|
export * from './logger';
|
||||||
|
|
||||||
@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* LOGGING SYSTEM - Structured Logging
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString, SupportedLocale } from '@/types';
|
||||||
|
import { translate } from '@/utils';
|
||||||
|
import type { MessageCategory } from './messages';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LOG LEVELS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export enum LogLevel {
|
||||||
|
DEBUG = 0,
|
||||||
|
INFO = 1,
|
||||||
|
WARN = 2,
|
||||||
|
ERROR = 3,
|
||||||
|
NONE = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LOG ENTRY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp : Date;
|
||||||
|
level : LogLevel;
|
||||||
|
category : MessageCategory;
|
||||||
|
message : I18nString;
|
||||||
|
context? : Record<string, any>;
|
||||||
|
locale? : SupportedLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LOGGER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
private static instance: Logger;
|
||||||
|
private logs : LogEntry[] = [];
|
||||||
|
private level : LogLevel = LogLevel.WARN;
|
||||||
|
private locale : SupportedLocale = 'es';
|
||||||
|
private maxLogs : number = 1000;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): Logger {
|
||||||
|
if (!Logger.instance) {
|
||||||
|
Logger.instance = new Logger();
|
||||||
|
}
|
||||||
|
return Logger.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configura el nivel de logging
|
||||||
|
*/
|
||||||
|
setLevel(level: LogLevel): void {
|
||||||
|
this.level = level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configura el locale para los mensajes
|
||||||
|
*/
|
||||||
|
setLocale(locale: SupportedLocale): void {
|
||||||
|
this.locale = locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configura el número máximo de logs a retener
|
||||||
|
*/
|
||||||
|
setMaxLogs(max: number): void {
|
||||||
|
this.maxLogs = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log nivel DEBUG
|
||||||
|
*/
|
||||||
|
debug(category: MessageCategory, message: I18nString, context?: Record<string, any>): void {
|
||||||
|
this.log(LogLevel.DEBUG, category, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log nivel INFO
|
||||||
|
*/
|
||||||
|
info(category: MessageCategory, message: I18nString, context?: Record<string, any>): void {
|
||||||
|
this.log(LogLevel.INFO, category, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log nivel WARN
|
||||||
|
*/
|
||||||
|
warn(category: MessageCategory, message: I18nString, context?: Record<string, any>): void {
|
||||||
|
this.log(LogLevel.WARN, category, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log nivel ERROR
|
||||||
|
*/
|
||||||
|
error(category: MessageCategory, message: I18nString, context?: Record<string, any>): void {
|
||||||
|
this.log(LogLevel.ERROR, category, message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log interno
|
||||||
|
*/
|
||||||
|
private log(
|
||||||
|
level : LogLevel,
|
||||||
|
category: MessageCategory,
|
||||||
|
message : I18nString,
|
||||||
|
context?: Record<string, any>
|
||||||
|
): void {
|
||||||
|
// Skip si el nivel es menor al configurado
|
||||||
|
if (level < this.level) return;
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
level,
|
||||||
|
category,
|
||||||
|
message,
|
||||||
|
context,
|
||||||
|
locale: this.locale
|
||||||
|
};
|
||||||
|
|
||||||
|
// Añadir al historial
|
||||||
|
this.logs.push(entry);
|
||||||
|
|
||||||
|
// Limitar tamaño del historial
|
||||||
|
if (this.logs.length > this.maxLogs) {
|
||||||
|
this.logs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output a consola
|
||||||
|
this.outputToConsole(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Output formateado a consola
|
||||||
|
*/
|
||||||
|
private outputToConsole(entry: LogEntry): void {
|
||||||
|
const translatedMessage = translate(entry.message, entry.locale || this.locale);
|
||||||
|
const prefix = `[${entry.category}]`;
|
||||||
|
const timestamp = entry.timestamp.toISOString();
|
||||||
|
|
||||||
|
switch (entry.level) {
|
||||||
|
case LogLevel.DEBUG:
|
||||||
|
console.debug(timestamp, prefix, translatedMessage, entry.context || '');
|
||||||
|
break;
|
||||||
|
case LogLevel.INFO:
|
||||||
|
console.info(timestamp, prefix, translatedMessage, entry.context || '');
|
||||||
|
break;
|
||||||
|
case LogLevel.WARN:
|
||||||
|
console.warn(timestamp, prefix, translatedMessage, entry.context || '');
|
||||||
|
break;
|
||||||
|
case LogLevel.ERROR:
|
||||||
|
console.error(timestamp, prefix, translatedMessage, entry.context || '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los logs filtrados
|
||||||
|
*/
|
||||||
|
getLogs(filters?: {
|
||||||
|
level?: LogLevel;
|
||||||
|
category?: MessageCategory;
|
||||||
|
since?: Date;
|
||||||
|
}): LogEntry[] {
|
||||||
|
let filtered = this.logs;
|
||||||
|
|
||||||
|
if (filters?.level !== undefined) {
|
||||||
|
filtered = filtered.filter(log => log.level === filters.level);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.category) {
|
||||||
|
filtered = filtered.filter(log => log.category === filters.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.since) {
|
||||||
|
filtered = filtered.filter(log => log.timestamp >= filters.since!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia todos los logs
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.logs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta logs como JSON
|
||||||
|
*/
|
||||||
|
export(): string {
|
||||||
|
return JSON.stringify(this.logs, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INSTANCIA SINGLETON EXPORTADA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const logger = Logger.getInstance();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración rápida para desarrollo
|
||||||
|
*/
|
||||||
|
export function setupDevelopmentLogging(): void {
|
||||||
|
logger.setLevel(LogLevel.DEBUG);
|
||||||
|
logger.setMaxLogs(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración para producción
|
||||||
|
*/
|
||||||
|
export function setupProductionLogging(): void {
|
||||||
|
logger.setLevel(LogLevel.ERROR);
|
||||||
|
logger.setMaxLogs(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración para tests
|
||||||
|
*/
|
||||||
|
export function setupTestLogging(): void {
|
||||||
|
logger.setLevel(LogLevel.NONE);
|
||||||
|
}
|
||||||
@ -0,0 +1,197 @@
|
|||||||
|
// messages.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MESSAGES SYSTEM - Error & Warning Messages
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Sistema centralizado de mensajes con soporte i18n
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString, AttrID, SectionID } from '@/types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ERROR MESSAGES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ERRORS = {
|
||||||
|
// ID Errors
|
||||||
|
INVALID_ID_FORMAT: (id: string): I18nString => ({
|
||||||
|
es: `Formato de ID inválido: "${id}". Formato esperado: "prefijo:valor"`,
|
||||||
|
en: `Invalid ID format: "${id}". Expected format: "prefix:value"`
|
||||||
|
}),
|
||||||
|
|
||||||
|
ID_PREFIX_MISMATCH: (id: string, expected: string, got: string): I18nString => ({
|
||||||
|
es: `Error de prefijo: se esperaba "${expected}:", se obtuvo "${got}:" en ID "${id}"`,
|
||||||
|
en: `Prefix mismatch: expected "${expected}:", got "${got}:" in ID "${id}"`
|
||||||
|
}),
|
||||||
|
|
||||||
|
EMPTY_ID_VALUE: (id: string): I18nString => ({
|
||||||
|
es: `Parte del ID vacía en: "${id}"`,
|
||||||
|
en: `Empty ID part in: "${id}"`
|
||||||
|
}),
|
||||||
|
|
||||||
|
INVALID_ID_VALUE: (value: string): I18nString => ({
|
||||||
|
es: `Valor de ID inválido: "${value}". No debe estar vacío o contener ":"`,
|
||||||
|
en: `Invalid ID value: "${value}". Must not be empty or contain ":"`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// I18n Errors
|
||||||
|
INVALID_I18N_STRING: (locale: string): I18nString => ({
|
||||||
|
es: `I18nString inválido. Debe ser un string o un objeto con al menos la clave '${locale}'`,
|
||||||
|
en: `Invalid I18nString. Must be a string or object with at least '${locale}' key`
|
||||||
|
}),
|
||||||
|
|
||||||
|
MISSING_DEFAULT_TRANSLATION: (locale: string): I18nString => ({
|
||||||
|
es: `Falta la traducción obligatoria en '${locale}'`,
|
||||||
|
en: `Missing required translation for '${locale}'`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Attribute Errors
|
||||||
|
COMPUTED_NOT_READONLY: (attrId: AttrID): I18nString => ({
|
||||||
|
es: `Atributo calculado ${attrId} debe ser de solo lectura`,
|
||||||
|
en: `Computed attribute ${attrId} should be readonly`
|
||||||
|
}),
|
||||||
|
|
||||||
|
COMPUTED_NO_DEPENDENCIES: (attrId: AttrID): I18nString => ({
|
||||||
|
es: `Atributo calculado ${attrId} no tiene dependencias`,
|
||||||
|
en: `Computed attribute ${attrId} has no dependencies`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Section Errors
|
||||||
|
NO_IMAGE_TEMPLATE: (sectionId: SectionID): I18nString => ({
|
||||||
|
es: `No hay imageUrlTemplate para la sección ${sectionId}`,
|
||||||
|
en: `No imageUrlTemplate for section ${sectionId}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
UNRESOLVED_PLACEHOLDERS: (placeholders: string[], template: string): I18nString => ({
|
||||||
|
es: `Placeholders sin resolver: ${placeholders.join(', ')} en template: ${template}`,
|
||||||
|
en: `Unresolved placeholders: ${placeholders.join(', ')} in template: ${template}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
PLACEHOLDER_NOT_IN_DEPENDENCIES: (placeholder: string, sectionId: SectionID): I18nString => ({
|
||||||
|
es: `Placeholder ${placeholder} no está en las dependencias de ${sectionId}`,
|
||||||
|
en: `Placeholder ${placeholder} not in dependencies for ${sectionId}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// API Errors
|
||||||
|
API_REQUEST_FAILED: (endpoint: string, status: number): I18nString => ({
|
||||||
|
es: `La API ${endpoint} retornó código ${status}`,
|
||||||
|
en: `API ${endpoint} returned status ${status}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
API_TIMEOUT: (endpoint: string, timeout: number): I18nString => ({
|
||||||
|
es: `Timeout de ${timeout}ms alcanzado para ${endpoint}`,
|
||||||
|
en: `Timeout of ${timeout}ms reached for ${endpoint}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
NETWORK_ERROR: (sectionId: SectionID): I18nString => ({
|
||||||
|
es: `Error de red o timeout para la sección ${sectionId}`,
|
||||||
|
en: `Network or timeout error for section ${sectionId}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// JsonLogic Errors
|
||||||
|
UNKNOWN_OPERATOR: (operator: string): I18nString => ({
|
||||||
|
es: `Operador desconocido: ${operator}`,
|
||||||
|
en: `Unknown operator: ${operator}`
|
||||||
|
})
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WARNING MESSAGES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const WARNINGS = {
|
||||||
|
// Attribute Warnings
|
||||||
|
FIXED_AFFECTS_PRICE: (attrId: AttrID): I18nString => ({
|
||||||
|
es: `Atributo fijo ${attrId} no debería afectar al precio`,
|
||||||
|
en: `Fixed attribute ${attrId} should not affect price`
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Rendering Warnings
|
||||||
|
MISSING_GLOBAL_ATTRIBUTE: (attrId: AttrID): I18nString => ({
|
||||||
|
es: `Falta atributo global: ${attrId}, usando 'default'`,
|
||||||
|
en: `Missing global attribute: ${attrId}, using 'default'`
|
||||||
|
}),
|
||||||
|
|
||||||
|
MISSING_SECTION_ATTRIBUTE: (attrId: AttrID): I18nString => ({
|
||||||
|
es: `Falta atributo de sección: ${attrId}, usando 'default'`,
|
||||||
|
en: `Missing section attribute: ${attrId}, using 'default'`
|
||||||
|
}),
|
||||||
|
|
||||||
|
INVALID_SECTION_ID_FORMAT: (sectionId: SectionID): I18nString => ({
|
||||||
|
es: `Formato de ID de sección inválido: ${sectionId}`,
|
||||||
|
en: `Invalid section ID format: ${sectionId}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
INVALID_VIEW_ID_FORMAT: (viewId: string): I18nString => ({
|
||||||
|
es: `Formato de ID de vista inválido: ${viewId}`,
|
||||||
|
en: `Invalid view ID format: ${viewId}`
|
||||||
|
})
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION MESSAGES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const VALIDATION = {
|
||||||
|
REQUIRED_FIELD: (fieldName: I18nString): I18nString => ({
|
||||||
|
es: `El campo ${typeof fieldName === 'string' ? fieldName : fieldName.es} es obligatorio`,
|
||||||
|
en: `Field ${typeof fieldName === 'string' ? fieldName : fieldName.en} is required`
|
||||||
|
}),
|
||||||
|
|
||||||
|
INVALID_VALUE: (fieldName: I18nString, value: any): I18nString => ({
|
||||||
|
es: `Valor inválido para ${typeof fieldName === 'string' ? fieldName : fieldName.es}: ${value}`,
|
||||||
|
en: `Invalid value for ${typeof fieldName === 'string' ? fieldName : fieldName.en}: ${value}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
OUT_OF_RANGE: (min: number, max: number): I18nString => ({
|
||||||
|
es: `El valor debe estar entre ${min} y ${max}`,
|
||||||
|
en: `Value must be between ${min} and ${max}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
INVALID_FORMAT: (format: string): I18nString => ({
|
||||||
|
es: `Formato inválido. Se esperaba: ${format}`,
|
||||||
|
en: `Invalid format. Expected: ${format}`
|
||||||
|
})
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INFO MESSAGES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const INFO = {
|
||||||
|
USING_FALLBACK_IMAGE: (sectionId: SectionID): I18nString => ({
|
||||||
|
es: `Usando imagen de fallback para ${sectionId}`,
|
||||||
|
en: `Using fallback image for ${sectionId}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
CACHE_HIT: (key: string): I18nString => ({
|
||||||
|
es: `Cache hit para: ${key}`,
|
||||||
|
en: `Cache hit for: ${key}`
|
||||||
|
}),
|
||||||
|
|
||||||
|
CACHE_MISS: (key: string): I18nString => ({
|
||||||
|
es: `Cache miss para: ${key}`,
|
||||||
|
en: `Cache miss for: ${key}`
|
||||||
|
})
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MESSAGE CATEGORIES (para logging estructurado)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const MESSAGE_CATEGORIES = {
|
||||||
|
ID : 'ID',
|
||||||
|
I18N : 'I18N',
|
||||||
|
ATTRIBUTE : 'ATTRIBUTE',
|
||||||
|
SECTION : 'SECTION',
|
||||||
|
RENDER : 'RENDER',
|
||||||
|
API : 'API',
|
||||||
|
JSON_LOGIC : 'JSON_LOGIC',
|
||||||
|
VALIDATION : 'VALIDATION',
|
||||||
|
CACHE : 'CACHE'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
|
export type MessageCategory = typeof MESSAGE_CATEGORIES[keyof typeof MESSAGE_CATEGORIES];
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
// api.types.ts
|
||||||
|
import type {AttrID} from "@/types";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type APIMethod =
|
||||||
|
'GET' |
|
||||||
|
'POST'
|
||||||
|
|
||||||
|
|
||||||
|
export interface APIConfig {
|
||||||
|
endpoint : string;
|
||||||
|
method? : APIMethod;
|
||||||
|
params? : string;
|
||||||
|
timeout? : number;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Core Types - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './primitives.types';
|
||||||
|
export * from './metadata.types';
|
||||||
|
export * from './pricing.types.ts';
|
||||||
|
export * from './media.types.ts';
|
||||||
|
export * from './severity.types.ts';
|
||||||
|
export * from './api.types.ts';
|
||||||
|
|
||||||
|
|
||||||
|
// Re-export specific branded ID types for convenience
|
||||||
|
export type { ID, Value, DataType } from './primitives.types';
|
||||||
|
export type { Metadata } from './metadata.types.ts';
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
// media.types.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface Media {
|
||||||
|
thumbnail? : string;
|
||||||
|
image? : string;
|
||||||
|
icon? : string;
|
||||||
|
gallery? : string[];
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* METADATA - Extended Information
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata flexible para extender cualquier entidad
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
export type Metadata = Record<string, unknown>;
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
// pricing.types.ts
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* PRICING TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JsonLogic } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información de precio
|
||||||
|
*/
|
||||||
|
export interface Pricing {
|
||||||
|
baseAmount : number | 'consultation';
|
||||||
|
currency? : string;
|
||||||
|
dynamicExpression?: JsonLogic;
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* CORE PRIMITIVES - Base Types
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Tipos fundamentales del sistema
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID con tipo branded para type-safety
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const attrId: ID<'at'> = "at:suelo"
|
||||||
|
* const sectionId: ID<'sc'> = "sc:salon"
|
||||||
|
*/
|
||||||
|
export type ID<T extends string = string> = `${T}:${string}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valores que pueden tomar los atributos
|
||||||
|
*/
|
||||||
|
export type Value =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| Date
|
||||||
|
| ID
|
||||||
|
| Value[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de datos soportados
|
||||||
|
*/
|
||||||
|
export type DataType =
|
||||||
|
| 'string'
|
||||||
|
| 'number'
|
||||||
|
| 'boolean'
|
||||||
|
| 'date'
|
||||||
|
| 'currency'
|
||||||
|
| 'dimension'
|
||||||
|
| 'reference'
|
||||||
|
| 'list';
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
// severity.types.ts
|
||||||
|
|
||||||
|
|
||||||
|
export type Severity =
|
||||||
|
'error' |
|
||||||
|
'warning' |
|
||||||
|
'info';
|
||||||
|
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* INTERNATIONALIZATION TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locales soportados por el sistema
|
||||||
|
*/
|
||||||
|
export type SupportedLocale =
|
||||||
|
| 'es' // Español
|
||||||
|
| 'en' // English
|
||||||
|
| 'de' // Deutsch
|
||||||
|
| 'fr' // Français
|
||||||
|
| 'it' // Italiano
|
||||||
|
| 'pt' // Português
|
||||||
|
| 'ca' // Català
|
||||||
|
| 'eu' // Euskara
|
||||||
|
| 'gl'; // Galego
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objeto con traducciones tipadas
|
||||||
|
* Garantiza que al menos existe el locale por defecto (español)
|
||||||
|
*/
|
||||||
|
export type I18nTranslations = {
|
||||||
|
[K in SupportedLocale]?: string;
|
||||||
|
} & {
|
||||||
|
es: string; // Español es obligatorio
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String internacionalizable
|
||||||
|
*
|
||||||
|
* Puede ser:
|
||||||
|
* - `string`: Texto simple (mismo en todos los idiomas)
|
||||||
|
* - `I18nTranslations`: Objeto con traducciones por locale
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Simple (mismo en todos los idiomas)
|
||||||
|
* const label: I18nString = "OK";
|
||||||
|
*
|
||||||
|
* // Con traducciones
|
||||||
|
* const greeting: I18nString = {
|
||||||
|
* es: "Hola",
|
||||||
|
* en: "Hello",
|
||||||
|
* de: "Hallo"
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export type I18nString = string | I18nTranslations;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información sobre un locale
|
||||||
|
*/
|
||||||
|
export interface LocaleInfo {
|
||||||
|
code : SupportedLocale;
|
||||||
|
name : string;
|
||||||
|
nativeName : string;
|
||||||
|
flag : string;
|
||||||
|
rtl? : boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* i18n Types - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './i18n.types';
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* TYPES - Public API
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Sistema de tipos completo del Visual Configuration Engine
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Core Types
|
||||||
|
export * from './core';
|
||||||
|
|
||||||
|
// i18n Types
|
||||||
|
export * from './i18n';
|
||||||
|
|
||||||
|
// Logic Types
|
||||||
|
export * from './logic';
|
||||||
|
|
||||||
|
// Model Types
|
||||||
|
export * from './model';
|
||||||
|
|
||||||
|
// View Types
|
||||||
|
export * from './view';
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Logic Types - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './json-logic.types';
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* JSON LOGIC TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expresión JsonLogic
|
||||||
|
* Permite lógica condicional declarativa
|
||||||
|
*/
|
||||||
|
export type JsonLogic =
|
||||||
|
| { var: string }
|
||||||
|
| { [op: string]: any }
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* ATTRIBUTE TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
import type { I18nString } from '../i18n';
|
||||||
|
import type { Metadata, Value, DataType } from '../core';
|
||||||
|
import type { AttrID, OptionID, SectionID, JsonLogic } from '@/types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ATTRIBUTE DISPLAY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración de visualización de un atributo
|
||||||
|
* Los flags pueden ser booleanos o expresiones condicionales
|
||||||
|
*/
|
||||||
|
export interface AttributeDisplay {
|
||||||
|
/**
|
||||||
|
* Si se muestra en UI
|
||||||
|
* - boolean: true/false estático
|
||||||
|
* - JsonLogic: Evaluado dinámicamente
|
||||||
|
*/
|
||||||
|
uiVisible?: boolean | JsonLogic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si afecta a la visualización
|
||||||
|
*/
|
||||||
|
affectsVisual?: boolean | JsonLogic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si afecta al precio
|
||||||
|
*/
|
||||||
|
affectsPrice?: boolean | JsonLogic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si es de solo lectura
|
||||||
|
*/
|
||||||
|
readonly?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orden de visualización
|
||||||
|
*/
|
||||||
|
order?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icono
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texto de ayuda
|
||||||
|
*/
|
||||||
|
helpText?: I18nString;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ATTRIBUTE TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttributeType =
|
||||||
|
'fixed' |
|
||||||
|
'dynamic' |
|
||||||
|
'quantifiable' |
|
||||||
|
'computed';
|
||||||
|
|
||||||
|
|
||||||
|
export type AttributeCategory =
|
||||||
|
'physical' |
|
||||||
|
'aesthetic' |
|
||||||
|
'functional' |
|
||||||
|
'structural' |
|
||||||
|
'technical' |
|
||||||
|
'financial';
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BASE ATTRIBUTE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface BaseAttribute {
|
||||||
|
id : AttrID;
|
||||||
|
name : I18nString;
|
||||||
|
description : I18nString;
|
||||||
|
type : AttributeType;
|
||||||
|
category? : AttributeCategory;
|
||||||
|
display : AttributeDisplay;
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FIXED ATTRIBUTE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FixedAttribute extends BaseAttribute {
|
||||||
|
type : 'fixed';
|
||||||
|
dataType: DataType;
|
||||||
|
value : Value;
|
||||||
|
unit? : I18nString;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DYNAMIC ATTRIBUTE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface DynamicAttribute extends BaseAttribute {
|
||||||
|
type : 'dynamic';
|
||||||
|
dataType : 'reference';
|
||||||
|
defaultValue : OptionID;
|
||||||
|
options : AttributeOption[];
|
||||||
|
required? : boolean;
|
||||||
|
isController? : boolean;
|
||||||
|
controls? : SectionID[];
|
||||||
|
filterExpression?: JsonLogic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttributeOption {
|
||||||
|
optionId : OptionID;
|
||||||
|
priority? : number;
|
||||||
|
pricingOverride? : number;
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QUANTIFIABLE ATTRIBUTE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface QuantifiableAttribute extends BaseAttribute {
|
||||||
|
type : 'quantifiable';
|
||||||
|
dataType : 'number';
|
||||||
|
quantity : QuantityDefinition;
|
||||||
|
unit? : I18nString;
|
||||||
|
userConfigurable?: boolean;
|
||||||
|
pricePerUnit? : number;
|
||||||
|
min? : number;
|
||||||
|
max? : number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuantityDefinition =
|
||||||
|
| number
|
||||||
|
| { min: number; max: number; step?: number; default: number }
|
||||||
|
| { values: number[]; default: number };
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COMPUTED ATTRIBUTE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ComputedAttribute extends BaseAttribute {
|
||||||
|
type : 'computed';
|
||||||
|
dataType : DataType;
|
||||||
|
unit? : I18nString;
|
||||||
|
expression : JsonLogic;
|
||||||
|
dependencies: AttrID[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UNION TYPE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type Attribute =
|
||||||
|
| FixedAttribute
|
||||||
|
| DynamicAttribute
|
||||||
|
| QuantifiableAttribute
|
||||||
|
| ComputedAttribute;
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* CONFIGURATION CATALOG TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString } from '../i18n';
|
||||||
|
import type {
|
||||||
|
OptionID,
|
||||||
|
ObjectID,
|
||||||
|
RuleID,
|
||||||
|
OptionDefinition,
|
||||||
|
ConfigurableObject,
|
||||||
|
ValidationRule
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface ConfigurationCatalog {
|
||||||
|
name : I18nString;
|
||||||
|
description? : I18nString;
|
||||||
|
supportedLocales: string[];
|
||||||
|
defaultLocale : string;
|
||||||
|
|
||||||
|
options : Record<OptionID, OptionDefinition>;
|
||||||
|
objects : Record<ObjectID, ConfigurableObject>;
|
||||||
|
rules? : Record<RuleID, ValidationRule>;
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* MODEL - Branded ID Types
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ID } from '../core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID específicos con prefijos
|
||||||
|
*/
|
||||||
|
export type AttrID = ID<'at'>;
|
||||||
|
export type SectionID = ID<'sc'>;
|
||||||
|
export type OptionID = ID<'op'>;
|
||||||
|
export type ObjectID = ID<'ob'>;
|
||||||
|
export type RuleID = ID<'rl'>;
|
||||||
|
export type ViewID = ID<'vw'>;
|
||||||
|
export type HotspotID = ID<'hs'>;
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Model Types - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
// IDs
|
||||||
|
export * from './ids.types';
|
||||||
|
|
||||||
|
// Attributes
|
||||||
|
export * from './attribute.types';
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
export * from './section.types';
|
||||||
|
|
||||||
|
// Objects
|
||||||
|
export * from './object.types';
|
||||||
|
|
||||||
|
// Options
|
||||||
|
export * from './option.types';
|
||||||
|
|
||||||
|
// Rules
|
||||||
|
export * from './rule.types';
|
||||||
|
|
||||||
|
// Catalog
|
||||||
|
export * from './catalog.types';
|
||||||
|
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* CONFIGURABLE OBJECT TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString } from '../i18n';
|
||||||
|
import type { Metadata } from '../core';
|
||||||
|
import type { ObjectID, SectionID, Attribute, Section } from '@/types';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface ConfigurableObject {
|
||||||
|
id : ObjectID;
|
||||||
|
name : I18nString;
|
||||||
|
description : I18nString;
|
||||||
|
attributes : Attribute[];
|
||||||
|
sections : Record<SectionID, Section>;
|
||||||
|
sectionOrder?: SectionID[];
|
||||||
|
category? : string;
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* OPTION DEFINITION TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {Pricing,Metadata,Media} from "@/types/core";
|
||||||
|
import type { OptionID } from '@/types';
|
||||||
|
import type { I18nString } from '../i18n';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface OptionDefinition {
|
||||||
|
id : OptionID;
|
||||||
|
name : I18nString;
|
||||||
|
description : I18nString;
|
||||||
|
media? : Media;
|
||||||
|
pricing? : Pricing;
|
||||||
|
tags? : string[];
|
||||||
|
style? : string;
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VALIDATION RULE TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import type { RuleID, AttrID ,JsonLogic } from '@/types';
|
||||||
|
import type { Value } from '../core';
|
||||||
|
import type { I18nString } from '../i18n';
|
||||||
|
import type {Severity} from "@/types/core";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface ValidationRule {
|
||||||
|
id : RuleID;
|
||||||
|
name : I18nString;
|
||||||
|
condition : JsonLogic;
|
||||||
|
//TODO sacar como tipos
|
||||||
|
action: {
|
||||||
|
type: 'allow' | 'forbid' | 'require' | 'suggest';
|
||||||
|
targetAttribute: AttrID;
|
||||||
|
values: Value[];
|
||||||
|
};
|
||||||
|
priority : number;
|
||||||
|
bidirectional: boolean;
|
||||||
|
affects : AttrID[];
|
||||||
|
severity : Severity;
|
||||||
|
message : I18nString;
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* SECTION TYPES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
import type { I18nString } from '../i18n';
|
||||||
|
import type {APIConfig, Metadata} from '../core';
|
||||||
|
import type {
|
||||||
|
AttrID,
|
||||||
|
SectionID,
|
||||||
|
Attribute,
|
||||||
|
JsonLogic } from '@/types';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RENDERING STRATEGY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type RenderingStrategy =
|
||||||
|
| 'static_image'
|
||||||
|
| 'dynamic_image'
|
||||||
|
| 'api_generated'
|
||||||
|
| 'three_d'
|
||||||
|
| 'composite_layers'
|
||||||
|
| 'none';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VISUAL CONFIG
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export interface SectionVisualConfig {
|
||||||
|
strategy : RenderingStrategy;
|
||||||
|
|
||||||
|
/** Template para URL estática */
|
||||||
|
imageUrlTemplate? : string;
|
||||||
|
|
||||||
|
/** Atributos globales que afectan la imagen */
|
||||||
|
globalAttributeDependencies? : AttrID[];
|
||||||
|
|
||||||
|
/** Atributos de sección que afectan la imagen */
|
||||||
|
sectionAttributeDependencies?: AttrID[];
|
||||||
|
|
||||||
|
/** Ruta base */
|
||||||
|
basePath? : string;
|
||||||
|
|
||||||
|
/** Config para generación async via API */
|
||||||
|
//TODO sacar como tipo
|
||||||
|
apiConfig? : APIConfig;
|
||||||
|
|
||||||
|
/** Config 3D */
|
||||||
|
threeDConfig?: {
|
||||||
|
modelUrl : string;
|
||||||
|
textureAttributes: Array<{
|
||||||
|
attributeId: AttrID;
|
||||||
|
scope: 'global' | 'section';
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Imagen de fallback si falla generación */
|
||||||
|
fallbackImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SECTION AVAILABILITY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SectionAvailability {
|
||||||
|
mode : 'required' | 'optional' | 'conditional';
|
||||||
|
condition? : JsonLogic;
|
||||||
|
dependsOn? : AttrID[];
|
||||||
|
//TODO sacar como tipos
|
||||||
|
onDeactivate?: {
|
||||||
|
action : 'clear' | 'preserve' | 'reset';
|
||||||
|
confirmWithUser?: boolean;
|
||||||
|
confirmMessage? : I18nString;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
id : SectionID;
|
||||||
|
name : I18nString;
|
||||||
|
description : I18nString;
|
||||||
|
attributes : Attribute[];
|
||||||
|
visualConfig: SectionVisualConfig;
|
||||||
|
availability: SectionAvailability;
|
||||||
|
order? : number;
|
||||||
|
icon? : string;
|
||||||
|
metadata? : Metadata;
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* View Types - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './view.types';
|
||||||
@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VIEW TYPES - Visual Representation
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString } from '../i18n';
|
||||||
|
import type { ViewID, HotspotID, AttrID , RenderingStrategy, APIConfig, JsonLogic} from "@/types";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VIEW TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de vista predefinidos
|
||||||
|
*/
|
||||||
|
export type ViewType =
|
||||||
|
| 'front' // Vista frontal
|
||||||
|
| 'side' // Vista lateral
|
||||||
|
| 'rear' // Vista posterior
|
||||||
|
| 'top' // Vista desde arriba
|
||||||
|
| 'isometric' // Vista isométrica/3D
|
||||||
|
| 'panoramic' // Vista 360° panorámica
|
||||||
|
| 'detail' // Vista de detalle/zoom
|
||||||
|
| 'blueprint' // Plano/blueprint
|
||||||
|
| 'custom'; // Tipo personalizado
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HOTSPOT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punto interactivo en una vista
|
||||||
|
*/
|
||||||
|
export interface Hotspot {
|
||||||
|
/** ID único del hotspot */
|
||||||
|
id: HotspotID;
|
||||||
|
|
||||||
|
/** Nombre descriptivo */
|
||||||
|
name: I18nString;
|
||||||
|
|
||||||
|
/** Posición X en porcentaje (0-100) */
|
||||||
|
x: number;
|
||||||
|
|
||||||
|
/** Posición Y en porcentaje (0-100) */
|
||||||
|
y: number;
|
||||||
|
|
||||||
|
/** Radio del hotspot en píxeles */
|
||||||
|
radius?: number;
|
||||||
|
|
||||||
|
/** Color del hotspot */
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
/** Icono del hotspot */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** Tipo de scope al que referencia */
|
||||||
|
scopeType: 'global' | 'section';
|
||||||
|
|
||||||
|
/** ID del atributo que este hotspot controla */
|
||||||
|
targetAttributeId: AttrID;
|
||||||
|
|
||||||
|
/** Si es visible (puede ser condicional) */
|
||||||
|
visible?: boolean | JsonLogic;
|
||||||
|
|
||||||
|
/** Tooltip al hacer hover */
|
||||||
|
tooltip?: I18nString;
|
||||||
|
|
||||||
|
/** Orden z-index para superposición */
|
||||||
|
zIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VIEW VISUAL CONFIG
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración visual específica de una vista
|
||||||
|
*/
|
||||||
|
export interface ViewVisualConfig {
|
||||||
|
/** Estrategia de renderizado */
|
||||||
|
strategy: RenderingStrategy;
|
||||||
|
|
||||||
|
/** Template para URL de imagen */
|
||||||
|
imageUrlTemplate?: string;
|
||||||
|
|
||||||
|
/** Ruta base de imágenes */
|
||||||
|
basePath?: string;
|
||||||
|
|
||||||
|
/** Atributos globales que afectan a esta vista */
|
||||||
|
globalAttributeDependencies?: AttrID[];
|
||||||
|
|
||||||
|
/** Atributos de sección que afectan a esta vista */
|
||||||
|
sectionAttributeDependencies?: AttrID[];
|
||||||
|
|
||||||
|
/** Imagen de fallback */
|
||||||
|
fallbackImage?: string;
|
||||||
|
|
||||||
|
/** Config para API */
|
||||||
|
apiConfig?: APIConfig ;
|
||||||
|
|
||||||
|
/** Config 3D */
|
||||||
|
threeDConfig?: {
|
||||||
|
modelUrl: string;
|
||||||
|
cameraPosition?: { x: number; y: number; z: number };
|
||||||
|
textureAttributes?: Array<{
|
||||||
|
attributeId: AttrID;
|
||||||
|
scope: 'global' | 'section';
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SECTION VIEW
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vista visual de una sección
|
||||||
|
*/
|
||||||
|
export interface SectionView {
|
||||||
|
/** ID único de la vista */
|
||||||
|
id: ViewID;
|
||||||
|
|
||||||
|
/** Nombre de la vista */
|
||||||
|
name: I18nString;
|
||||||
|
|
||||||
|
/** Descripción */
|
||||||
|
description?: I18nString;
|
||||||
|
|
||||||
|
/** Tipo de vista */
|
||||||
|
type: ViewType;
|
||||||
|
|
||||||
|
/** Configuración visual de esta vista */
|
||||||
|
visualConfig: ViewVisualConfig;
|
||||||
|
|
||||||
|
/** Hotspots interactivos (OPCIONAL) */
|
||||||
|
hotspots?: Hotspot[];
|
||||||
|
|
||||||
|
/** Orden de esta vista en la navegación */
|
||||||
|
order?: number;
|
||||||
|
|
||||||
|
/** Si es la vista por defecto para esta sección */
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
/** Icono para el selector de vistas */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** Si está visible (puede ser condicional) */
|
||||||
|
visible?: boolean | JsonLogic;
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* DEEP EQUALITY UTILITY
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Value } from '../../types/core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep equality check para valores
|
||||||
|
*/
|
||||||
|
export function deepEqual(a: Value, b: Value): boolean {
|
||||||
|
// 1. Chequeo de identidad simple (cubre primitivos iguales)
|
||||||
|
if (a === b) return true;
|
||||||
|
|
||||||
|
// 2. Si alguno de los dos NO es un objeto o es null, devuelve false
|
||||||
|
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Dates
|
||||||
|
if (a instanceof Date && b instanceof Date) {
|
||||||
|
return a.getTime() === b.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Arrays
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((val, idx) => deepEqual(val, b[idx]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Otros tipos
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Equality Utilities - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './deep-equal';
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* I18N BUILDER UTILITIES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString, I18nTranslations, SupportedLocale } from '@/types';
|
||||||
|
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@/constants';
|
||||||
|
import { translate } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear I18nString desde objeto parcial
|
||||||
|
* Garantiza que al menos existe el locale por defecto
|
||||||
|
*/
|
||||||
|
export function createI18nString(
|
||||||
|
translations: Partial<Record<SupportedLocale, string>>
|
||||||
|
): I18nString {
|
||||||
|
if (!translations[DEFAULT_LOCALE]) {
|
||||||
|
throw new Error(`I18nString must have at least '${DEFAULT_LOCALE}' translation`);
|
||||||
|
}
|
||||||
|
return translations as I18nTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear I18nString simple (mismo texto en todos los idiomas)
|
||||||
|
*/
|
||||||
|
export function createSimpleI18nString(text: string): I18nString {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir string simple a objeto de traducciones
|
||||||
|
* (Útil para migración)
|
||||||
|
*/
|
||||||
|
export function expandToTranslations(
|
||||||
|
text: I18nString,
|
||||||
|
defaultLocale: SupportedLocale = DEFAULT_LOCALE
|
||||||
|
): I18nTranslations {
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
return { [defaultLocale]: text } as I18nTranslations;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combinar múltiples I18nStrings
|
||||||
|
* (Útil para concatenar textos)
|
||||||
|
*/
|
||||||
|
export function combineI18nStrings(
|
||||||
|
strings: I18nString[],
|
||||||
|
separator: string = ' '
|
||||||
|
): I18nString {
|
||||||
|
// Si todos son strings simples, concatenar directamente
|
||||||
|
if (strings.every(s => typeof s === 'string')) {
|
||||||
|
return strings.join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear objeto con todas las traducciones
|
||||||
|
const combined: Partial<Record<SupportedLocale, string>> = {};
|
||||||
|
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const parts = strings.map(s => translate(s, locale));
|
||||||
|
combined[locale] = parts.join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined as I18nTranslations;
|
||||||
|
}
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* i18n Utilities - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './translation.utils';
|
||||||
|
export * from './locale.utils';
|
||||||
|
export * from './builder.utils';
|
||||||
|
export * from './validation.utils';
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* LOCALE UTILITIES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SupportedLocale, LocaleInfo } from '@/types';
|
||||||
|
import { SUPPORTED_LOCALES, LOCALE_INFO, DEFAULT_LOCALE } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si un string es un locale soportado
|
||||||
|
*/
|
||||||
|
export function isSupportedLocale(locale: string): locale is SupportedLocale {
|
||||||
|
return SUPPORTED_LOCALES.includes(locale as SupportedLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener locale válido con fallback
|
||||||
|
*/
|
||||||
|
export function getValidLocale(
|
||||||
|
locale: string | undefined,
|
||||||
|
fallback: SupportedLocale = DEFAULT_LOCALE
|
||||||
|
): SupportedLocale {
|
||||||
|
if (!locale) return fallback;
|
||||||
|
return isSupportedLocale(locale) ? locale : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extraer código de idioma de un locale (ej: "es-ES" → "es")
|
||||||
|
*/
|
||||||
|
export function extractLanguageCode(locale: string): string {
|
||||||
|
return locale.split('-')[0].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener información de un locale
|
||||||
|
*/
|
||||||
|
export function getLocaleInfo(locale: SupportedLocale): LocaleInfo {
|
||||||
|
return LOCALE_INFO[locale];
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* TRANSLATION UTILITIES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString, SupportedLocale } from '../../types/i18n';
|
||||||
|
import { DEFAULT_LOCALE } from '../../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener texto traducido
|
||||||
|
*
|
||||||
|
* @param text - String i18n (simple o con traducciones)
|
||||||
|
* @param locale - Locale objetivo
|
||||||
|
* @param fallback - Locale de respaldo si no existe la traducción
|
||||||
|
* @returns Texto en el idioma solicitado
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const text: I18nString = { es: "Hola", en: "Hello" };
|
||||||
|
* translate(text, 'es') // → "Hola"
|
||||||
|
* translate(text, 'fr') // → "Hola" (fallback a español)
|
||||||
|
* translate("OK", 'es') // → "OK" (string simple)
|
||||||
|
*/
|
||||||
|
export function translate(
|
||||||
|
text: I18nString,
|
||||||
|
locale: SupportedLocale,
|
||||||
|
fallback: SupportedLocale = DEFAULT_LOCALE
|
||||||
|
): string {
|
||||||
|
// Si es string simple, devolverlo directamente
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar con el locale solicitado
|
||||||
|
if (text[locale]) {
|
||||||
|
return text[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback al locale de respaldo
|
||||||
|
if (text[fallback]) {
|
||||||
|
return text[fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Último recurso: primer valor disponible
|
||||||
|
const firstAvailable = Object.values(text)[0];
|
||||||
|
return firstAvailable || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias corto de translate
|
||||||
|
*/
|
||||||
|
export const t = translate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si un I18nString tiene traducción para un locale
|
||||||
|
*/
|
||||||
|
export function hasTranslation(
|
||||||
|
text: I18nString,
|
||||||
|
locale: SupportedLocale
|
||||||
|
): boolean {
|
||||||
|
if (typeof text === 'string') return true;
|
||||||
|
return text[locale] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todos los locales disponibles en un I18nString
|
||||||
|
*/
|
||||||
|
export function getAvailableLocales(text: I18nString): SupportedLocale[] {
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
// String simple está disponible en todos los locales
|
||||||
|
return [...SUPPORTED_LOCALES];
|
||||||
|
}
|
||||||
|
return Object.keys(text) as SupportedLocale[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si un I18nString está completo (tiene todas las traducciones)
|
||||||
|
*/
|
||||||
|
export function isComplete(text: I18nString): boolean {
|
||||||
|
if (typeof text === 'string') return true;
|
||||||
|
return SUPPORTED_LOCALES.every(locale => text[locale] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener locales faltantes en un I18nString
|
||||||
|
*/
|
||||||
|
export function getMissingLocales(text: I18nString): SupportedLocale[] {
|
||||||
|
if (typeof text === 'string') return [];
|
||||||
|
return SUPPORTED_LOCALES.filter(locale => text[locale] === undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export SUPPORTED_LOCALES for convenience
|
||||||
|
import { SUPPORTED_LOCALES } from '@/constants';
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* I18N VALIDATION UTILITIES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { I18nString } from '@/types';
|
||||||
|
import { DEFAULT_LOCALE } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar que un I18nString es correcto
|
||||||
|
*/
|
||||||
|
export function validateI18nString(text: unknown): text is I18nString {
|
||||||
|
if (typeof text === 'string') return true;
|
||||||
|
|
||||||
|
if (typeof text !== 'object' || text === null) return false;
|
||||||
|
|
||||||
|
const obj = text as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Debe tener al menos el locale por defecto
|
||||||
|
if (typeof obj[DEFAULT_LOCALE] !== 'string') return false;
|
||||||
|
|
||||||
|
// Todos los valores deben ser strings
|
||||||
|
return Object.values(obj).every(v => typeof v === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error para I18nString inválido
|
||||||
|
*/
|
||||||
|
export class I18nStringError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`[I18n] ${message}`);
|
||||||
|
this.name = 'I18nStringError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar y lanzar error si es inválido
|
||||||
|
*/
|
||||||
|
export function assertValidI18nString(text: unknown): asserts text is I18nString {
|
||||||
|
if (!validateI18nString(text)) {
|
||||||
|
throw new I18nStringError(
|
||||||
|
`Invalid I18nString. Must be a string or object with at least '${DEFAULT_LOCALE}' key.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,362 @@
|
|||||||
|
// utils/ids-improved.ts
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* ID UTILITIES - VERSIÓN MEJORADA
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Sistema robusto y type-safe para manejar IDs con prefijos
|
||||||
|
*
|
||||||
|
* Mejoras sobre versión original:
|
||||||
|
* - Cache para performance
|
||||||
|
* - Validación centralizada
|
||||||
|
* - Error handling robusto
|
||||||
|
* - Type-safe parsing
|
||||||
|
* - Fácil de testear
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {AttrID, ObjectID, OptionID, RuleID, SectionID, HotspotID, ViewID, ID} from "@/types";
|
||||||
|
import {ERRORS, logger, MESSAGE_CATEGORIES} from "@/messages";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type IDPrefix = 'at' | 'op' | 'sc' | 'ob' | 'rl' | 'vw' | 'hs';
|
||||||
|
|
||||||
|
export interface ParsedID {
|
||||||
|
prefix: string;
|
||||||
|
value: string;
|
||||||
|
fullId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CACHE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache LRU simple para parsing de IDs
|
||||||
|
* Evita splits repetitivos en hot paths
|
||||||
|
*/
|
||||||
|
class IDCache {
|
||||||
|
private cache = new Map<string, string>();
|
||||||
|
private maxSize = 500;
|
||||||
|
|
||||||
|
get(id: string): string | undefined {
|
||||||
|
return this.cache.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(id: string, value: string): void {
|
||||||
|
if (this.cache.size >= this.maxSize) {
|
||||||
|
const firstKey = this.cache.keys().next().value;
|
||||||
|
if (firstKey !== undefined) {
|
||||||
|
this.cache.delete(firstKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cache.set(id, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idPartCache = new IDCache();
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida que un string tenga formato de ID válido
|
||||||
|
*/
|
||||||
|
export function isValidId(id: string): boolean {
|
||||||
|
if (!id) return false;
|
||||||
|
|
||||||
|
const parts = id.split(':');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
|
||||||
|
const [prefix, value] = parts;
|
||||||
|
return !!prefix && !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida que un ID tenga el prefijo esperado
|
||||||
|
*/
|
||||||
|
export function hasPrefix<T extends string>(id: string, expectedPrefix: T): boolean {
|
||||||
|
const parts = id.split(':');
|
||||||
|
return parts[0] === expectedPrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PARSING
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsea un ID en sus componentes
|
||||||
|
*
|
||||||
|
* @throws {Error} Si el ID tiene formato inválido
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseId("at:suelo") // → { prefix: "at", value: "suelo", fullId: "at:suelo" }
|
||||||
|
*/
|
||||||
|
export function parseId(id: string): ParsedID {
|
||||||
|
if (!isValidId(id)) {
|
||||||
|
const errorMsg = ERRORS.INVALID_ID_FORMAT(id);
|
||||||
|
logger.error(MESSAGE_CATEGORIES.ID, errorMsg, { id });
|
||||||
|
throw new Error(JSON.stringify(errorMsg));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [prefix, value] = id.split(':');
|
||||||
|
|
||||||
|
return {
|
||||||
|
prefix,
|
||||||
|
value,
|
||||||
|
fullId: id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae solo la parte del valor del ID (después del prefijo)
|
||||||
|
* Con cache para performance
|
||||||
|
*
|
||||||
|
* @param id - ID completo (ej: "at:suelo")
|
||||||
|
* @param expectedPrefix - Prefijo esperado (opcional, para validación)
|
||||||
|
* @returns La parte del valor (ej: "suelo")
|
||||||
|
*
|
||||||
|
* @throws {Error} Si el ID es inválido o no coincide el prefijo
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* extractIdPart("at:suelo") // → "suelo"
|
||||||
|
* extractIdPart("at:suelo", "at") // → "suelo" ✅
|
||||||
|
* extractIdPart("at:suelo", "sc") // → throws Error ❌
|
||||||
|
*/
|
||||||
|
export function extractIdPart<T extends string>(
|
||||||
|
id: ID<T>,
|
||||||
|
expectedPrefix?: T
|
||||||
|
): string {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = expectedPrefix ? `${id}:${expectedPrefix}` : id;
|
||||||
|
const cached = idPartCache.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate
|
||||||
|
const parsed = parseId(id);
|
||||||
|
|
||||||
|
// Validate prefix if provided
|
||||||
|
if (expectedPrefix && parsed.prefix !== expectedPrefix) {
|
||||||
|
throw new Error(
|
||||||
|
`ID prefix mismatch: expected "${expectedPrefix}:", got "${parsed.prefix}:" in ID "${id}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache and return
|
||||||
|
idPartCache.set(cacheKey, parsed.value);
|
||||||
|
return parsed.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versión safe de extractIdPart que retorna undefined en lugar de throw
|
||||||
|
*/
|
||||||
|
export function tryExtractIdPart<T extends string>(
|
||||||
|
id: ID<T>,
|
||||||
|
expectedPrefix?: T
|
||||||
|
): string | undefined {
|
||||||
|
try {
|
||||||
|
return extractIdPart(id, expectedPrefix);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BUILDERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea IDs type-safe con validación
|
||||||
|
* Mejora sobre versión original que no valida
|
||||||
|
*/
|
||||||
|
export const IDS = {
|
||||||
|
/**
|
||||||
|
* Crea un AttrID
|
||||||
|
* @throws {Error} Si el valor es inválido
|
||||||
|
*/
|
||||||
|
attr: (id: string): AttrID => {
|
||||||
|
if (!id || id.includes(':')) {
|
||||||
|
throw new Error(`Invalid attr ID value: "${id}". Must not be empty or contain ":"`);
|
||||||
|
}
|
||||||
|
return `at:${id}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
opt: (id: string): OptionID => {
|
||||||
|
if (!id || id.includes(':')) {
|
||||||
|
throw new Error(`Invalid option ID value: "${id}"`);
|
||||||
|
}
|
||||||
|
return `op:${id}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
sec: (id: string): SectionID => {
|
||||||
|
if (!id || id.includes(':')) {
|
||||||
|
throw new Error(`Invalid section ID value: "${id}"`);
|
||||||
|
}
|
||||||
|
return `sc:${id}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
obj: (id: string): ObjectID => {
|
||||||
|
if (!id || id.includes(':')) {
|
||||||
|
throw new Error(`Invalid object ID value: "${id}"`);
|
||||||
|
}
|
||||||
|
return `ob:${id}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
rule: (id: string): RuleID => {
|
||||||
|
if (!id || id.includes(':')) {
|
||||||
|
throw new Error(`Invalid rule ID value: "${id}"`);
|
||||||
|
}
|
||||||
|
return `rl:${id}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
view: (id: string): ViewID => {
|
||||||
|
if (!id || id.includes(':')) {
|
||||||
|
throw new Error(`Invalid view ID value: "${id}"`);
|
||||||
|
}
|
||||||
|
return `vw:${id}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
hspot: (id: string): HotspotID => {
|
||||||
|
if (!id || id.includes(':')) {
|
||||||
|
throw new Error(`Invalid hotspot ID value: "${id}"`);
|
||||||
|
}
|
||||||
|
return `hs:${id}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versión safe de IDS que retorna undefined en lugar de throw
|
||||||
|
*/
|
||||||
|
export const SafeIDS = {
|
||||||
|
attr: (id: string): AttrID | undefined => {
|
||||||
|
try { return IDS.attr(id); } catch { return undefined; }
|
||||||
|
},
|
||||||
|
opt: (id: string): OptionID | undefined => {
|
||||||
|
try { return IDS.opt(id); } catch { return undefined; }
|
||||||
|
},
|
||||||
|
sec: (id: string): SectionID | undefined => {
|
||||||
|
try { return IDS.sec(id); } catch { return undefined; }
|
||||||
|
},
|
||||||
|
obj: (id: string): ObjectID | undefined => {
|
||||||
|
try { return IDS.obj(id); } catch { return undefined; }
|
||||||
|
},
|
||||||
|
rule: (id: string): RuleID | undefined => {
|
||||||
|
try { return IDS.rule(id); } catch { return undefined; }
|
||||||
|
},
|
||||||
|
view: (id: string): ViewID | undefined => {
|
||||||
|
try { return IDS.view(id); } catch { return undefined; }
|
||||||
|
},
|
||||||
|
hspot: (id: string): HotspotID | undefined => {
|
||||||
|
try { return IDS.hspot(id); } catch { return undefined; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// COLLECTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae todas las partes de valor de una colección de IDs
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* extractAllIdParts(["at:suelo", "at:pared"])
|
||||||
|
* // → ["suelo", "pared"]
|
||||||
|
*/
|
||||||
|
export function extractAllIdParts<T extends string>(
|
||||||
|
ids: ID<T>[],
|
||||||
|
expectedPrefix?: T
|
||||||
|
): string[] {
|
||||||
|
return ids.map(id => extractIdPart(id, expectedPrefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte un array de IDs a un objeto Record con las partes como keys
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* idsToRecord(["at:suelo", "at:pared"], (id) => attributes[id])
|
||||||
|
* // → { suelo: Value1, pared: Value2 }
|
||||||
|
*/
|
||||||
|
export function idsToRecord<T extends string, V>(
|
||||||
|
ids: ID<T>[],
|
||||||
|
getValue: (id: ID<T>) => V,
|
||||||
|
expectedPrefix?: T
|
||||||
|
): Record<string, V> {
|
||||||
|
const record: Record<string, V> = {};
|
||||||
|
|
||||||
|
for (const id of ids) {
|
||||||
|
const part = extractIdPart(id, expectedPrefix);
|
||||||
|
record[part] = getValue(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia el cache de IDs
|
||||||
|
* Útil para tests o cuando se sospecha corrupción
|
||||||
|
*/
|
||||||
|
export function clearIdCache(): void {
|
||||||
|
idPartCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza un ID (asegura formato consistente)
|
||||||
|
* Útil para IDs que vienen de input de usuario
|
||||||
|
*/
|
||||||
|
export function normalizeId(id: string, expectedPrefix?: IDPrefix): string {
|
||||||
|
const parsed = parseId(id);
|
||||||
|
|
||||||
|
if (expectedPrefix && parsed.prefix !== expectedPrefix) {
|
||||||
|
throw new Error(`Expected prefix "${expectedPrefix}", got "${parsed.prefix}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizar el value: lowercase, trim, remove espacios extra
|
||||||
|
const normalizedValue = parsed.value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '_');
|
||||||
|
|
||||||
|
return `${parsed.prefix}:${normalizedValue}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Validation
|
||||||
|
isValidId,
|
||||||
|
hasPrefix,
|
||||||
|
|
||||||
|
// Parsing
|
||||||
|
parseId,
|
||||||
|
extractIdPart,
|
||||||
|
tryExtractIdPart,
|
||||||
|
|
||||||
|
// Builders
|
||||||
|
IDS,
|
||||||
|
SafeIDS,
|
||||||
|
|
||||||
|
// Collections
|
||||||
|
extractAllIdParts,
|
||||||
|
idsToRecord,
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
clearIdCache,
|
||||||
|
normalizeId
|
||||||
|
};
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* ID Utilities - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './id-parser';
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* UTILITIES - Public API
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// i18n Utilities
|
||||||
|
export * from './i18n';
|
||||||
|
|
||||||
|
// ID Utilities
|
||||||
|
export * from './ids';
|
||||||
|
|
||||||
|
// URL Utilities
|
||||||
|
export * from './url';
|
||||||
|
|
||||||
|
// View Utilities
|
||||||
|
export * from './view';
|
||||||
|
|
||||||
|
// Validation Utilities
|
||||||
|
export * from './validation';
|
||||||
|
|
||||||
|
// Equality Utilities
|
||||||
|
export * from './equality';
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* URL Utilities - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './url-builder';
|
||||||
@ -0,0 +1,387 @@
|
|||||||
|
// utils/url-builder.ts
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* URL TEMPLATE BUILDER - VERSIÓN MEJORADA
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Centraliza toda la lógica de generación de URLs con templates
|
||||||
|
* Elimina duplicación entre vce.ts y view.ts
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AttrID, SectionID, ViewID } from "../../types/model";
|
||||||
|
import type { Value } from "../../types/core";
|
||||||
|
import { extractIdPart } from "../ids";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UrlTemplateContext {
|
||||||
|
/** Ruta base opcional */
|
||||||
|
basePath?: string;
|
||||||
|
|
||||||
|
/** ID de sección */
|
||||||
|
sectionId?: SectionID;
|
||||||
|
|
||||||
|
/** ID de vista */
|
||||||
|
viewId?: ViewID;
|
||||||
|
|
||||||
|
/** Atributos globales disponibles */
|
||||||
|
globalAttributes?: Record<AttrID, Value>;
|
||||||
|
|
||||||
|
/** Atributos de sección disponibles */
|
||||||
|
sectionAttributes?: Record<AttrID, Value>;
|
||||||
|
|
||||||
|
/** IDs de atributos globales que el template necesita */
|
||||||
|
globalDependencies?: AttrID[];
|
||||||
|
|
||||||
|
/** IDs de atributos de sección que el template necesita */
|
||||||
|
sectionDependencies?: AttrID[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrlBuildResult {
|
||||||
|
/** URL generada (o fallback si hubo errores) */
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/** Placeholders que no se pudieron resolver */
|
||||||
|
unresolvedPlaceholders: string[];
|
||||||
|
|
||||||
|
/** Advertencias durante la generación */
|
||||||
|
warnings: string[];
|
||||||
|
|
||||||
|
/** Si se usó la URL de fallback */
|
||||||
|
usedFallback: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrlBuilderOptions {
|
||||||
|
/** URL de fallback si falla la generación */
|
||||||
|
fallbackUrl?: string;
|
||||||
|
|
||||||
|
/** Modo de logging: 'none' | 'console' | 'collect' */
|
||||||
|
logMode?: 'none' | 'console' | 'collect';
|
||||||
|
|
||||||
|
/** Estrategia cuando falta un atributo: 'default' | 'skip' | 'error' */
|
||||||
|
onMissingAttribute?: 'default' | 'skip' | 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// URL TEMPLATE BUILDER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class UrlTemplateBuilder {
|
||||||
|
private warnings: string[] = [];
|
||||||
|
private readonly fallbackUrl: string;
|
||||||
|
private readonly options: Required<UrlBuilderOptions>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private template: string,
|
||||||
|
options: UrlBuilderOptions = {}
|
||||||
|
) {
|
||||||
|
this.fallbackUrl = options.fallbackUrl || '/images/fallback.jpg';
|
||||||
|
this.options = {
|
||||||
|
fallbackUrl: this.fallbackUrl,
|
||||||
|
logMode: options.logMode || 'console',
|
||||||
|
onMissingAttribute: options.onMissingAttribute || 'default'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construye la URL reemplazando todos los placeholders
|
||||||
|
*/
|
||||||
|
build(context: UrlTemplateContext): UrlBuildResult {
|
||||||
|
this.warnings = [];
|
||||||
|
let url = this.template;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Reemplazar basePath
|
||||||
|
if (context.basePath) {
|
||||||
|
url = this.replaceSimpleToken(url, 'basePath', context.basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Reemplazar sectionId
|
||||||
|
if (context.sectionId) {
|
||||||
|
const sectionIdPart = extractIdPart(context.sectionId, 'sc');
|
||||||
|
url = this.replaceSimpleToken(url, 'sectionId', sectionIdPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reemplazar viewId
|
||||||
|
if (context.viewId) {
|
||||||
|
const viewIdPart = extractIdPart(context.viewId, 'vw');
|
||||||
|
url = this.replaceSimpleToken(url, 'viewId', viewIdPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Reemplazar atributos globales
|
||||||
|
if (context.globalDependencies && context.globalAttributes) {
|
||||||
|
url = this.replaceAttributes(
|
||||||
|
url,
|
||||||
|
'global',
|
||||||
|
context.globalDependencies,
|
||||||
|
context.globalAttributes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Reemplazar atributos de sección
|
||||||
|
if (context.sectionDependencies && context.sectionAttributes) {
|
||||||
|
url = this.replaceAttributes(
|
||||||
|
url,
|
||||||
|
'section',
|
||||||
|
context.sectionDependencies,
|
||||||
|
context.sectionAttributes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Verificar placeholders sin resolver
|
||||||
|
const unresolved = this.findUnresolvedPlaceholders(url);
|
||||||
|
|
||||||
|
if (unresolved.length > 0) {
|
||||||
|
this.addWarning(
|
||||||
|
`Unresolved placeholders: ${unresolved.join(', ')}`,
|
||||||
|
{ template: this.template, partialUrl: url }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: this.fallbackUrl,
|
||||||
|
unresolvedPlaceholders: unresolved,
|
||||||
|
warnings: this.warnings,
|
||||||
|
usedFallback: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
unresolvedPlaceholders: [],
|
||||||
|
warnings: this.warnings,
|
||||||
|
usedFallback: false
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.addWarning(
|
||||||
|
`Error building URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
{ template: this.template, context }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: this.fallbackUrl,
|
||||||
|
unresolvedPlaceholders: [],
|
||||||
|
warnings: this.warnings,
|
||||||
|
usedFallback: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reemplaza un token simple {key} → value
|
||||||
|
*/
|
||||||
|
private replaceSimpleToken(url: string, key: string, value: string): string {
|
||||||
|
const placeholder = `{${key}}`;
|
||||||
|
return url.replace(placeholder, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reemplaza placeholders de atributos: {scope.attrName}
|
||||||
|
*/
|
||||||
|
private replaceAttributes(
|
||||||
|
url: string,
|
||||||
|
scope: 'global' | 'section',
|
||||||
|
dependencies: AttrID[],
|
||||||
|
attributes: Record<AttrID, Value>
|
||||||
|
): string {
|
||||||
|
for (const attrId of dependencies) {
|
||||||
|
const attrName = extractIdPart(attrId, 'at');
|
||||||
|
const placeholder = `{${scope}.${attrName}}`;
|
||||||
|
|
||||||
|
// Skip si el placeholder no existe en el template
|
||||||
|
if (!url.includes(placeholder)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = attributes[attrId];
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
const normalized = this.normalizeValueForUrl(value);
|
||||||
|
url = url.replace(placeholder, normalized);
|
||||||
|
} else {
|
||||||
|
// Manejar atributo faltante según estrategia
|
||||||
|
url = this.handleMissingAttribute(
|
||||||
|
url,
|
||||||
|
placeholder,
|
||||||
|
scope,
|
||||||
|
attrId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza un valor para usar en URL
|
||||||
|
* - Lowercase
|
||||||
|
* - Espacios → underscores
|
||||||
|
* - Solo caracteres alfanuméricos y guiones
|
||||||
|
*/
|
||||||
|
private normalizeValueForUrl(value: Value): string {
|
||||||
|
// Arrays: join with dash
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(v => this.normalizeValueForUrl(v)).join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dates: ISO format
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
}
|
||||||
|
|
||||||
|
// Todo lo demás: string
|
||||||
|
return String(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[^a-z0-9_-]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maneja el caso de atributo faltante según configuración
|
||||||
|
*/
|
||||||
|
private handleMissingAttribute(
|
||||||
|
url: string,
|
||||||
|
placeholder: string,
|
||||||
|
scope: string,
|
||||||
|
attrId: AttrID
|
||||||
|
): string {
|
||||||
|
const strategy = this.options.onMissingAttribute;
|
||||||
|
|
||||||
|
switch (strategy) {
|
||||||
|
case 'default':
|
||||||
|
this.addWarning(
|
||||||
|
`Missing ${scope} attribute: ${attrId}, using 'default'`,
|
||||||
|
{ placeholder }
|
||||||
|
);
|
||||||
|
return url.replace(placeholder, 'default');
|
||||||
|
|
||||||
|
case 'skip':
|
||||||
|
this.addWarning(
|
||||||
|
`Missing ${scope} attribute: ${attrId}, skipping replacement`,
|
||||||
|
{ placeholder }
|
||||||
|
);
|
||||||
|
return url; // No reemplazar
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
throw new Error(
|
||||||
|
`Missing required ${scope} attribute: ${attrId} for placeholder ${placeholder}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encuentra placeholders que no fueron reemplazados
|
||||||
|
*/
|
||||||
|
private findUnresolvedPlaceholders(url: string): string[] {
|
||||||
|
const matches = url.match(/\{[^}]+}/g);
|
||||||
|
return matches || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Añade una advertencia al log
|
||||||
|
*/
|
||||||
|
private addWarning(message: string, context?: Record<string, any>): void {
|
||||||
|
this.warnings.push(message);
|
||||||
|
|
||||||
|
if (this.options.logMode === 'console') {
|
||||||
|
console.warn('[UrlBuilder]', message, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HELPER FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera URL para una sección
|
||||||
|
* Wrapper conveniente sobre UrlTemplateBuilder
|
||||||
|
*/
|
||||||
|
export function buildSectionImageUrl(
|
||||||
|
template: string,
|
||||||
|
sectionId: SectionID,
|
||||||
|
config: {
|
||||||
|
basePath?: string;
|
||||||
|
fallbackImage?: string;
|
||||||
|
globalAttributeDependencies?: AttrID[];
|
||||||
|
sectionAttributeDependencies?: AttrID[];
|
||||||
|
},
|
||||||
|
globalAttributes: Record<AttrID, Value>,
|
||||||
|
sectionAttributes: Record<AttrID, Value>
|
||||||
|
): string {
|
||||||
|
if (!template) {
|
||||||
|
console.warn(`[UrlBuilder] No template provided for section ${sectionId}`);
|
||||||
|
return config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new UrlTemplateBuilder(template, {
|
||||||
|
fallbackUrl: config.fallbackImage,
|
||||||
|
logMode: 'console',
|
||||||
|
onMissingAttribute: 'default'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = builder.build({
|
||||||
|
basePath: config.basePath,
|
||||||
|
sectionId,
|
||||||
|
globalAttributes,
|
||||||
|
sectionAttributes,
|
||||||
|
globalDependencies: config.globalAttributeDependencies,
|
||||||
|
sectionDependencies: config.sectionAttributeDependencies
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera URL para una vista específica
|
||||||
|
*/
|
||||||
|
export function buildViewImageUrl(
|
||||||
|
template: string,
|
||||||
|
sectionId: SectionID,
|
||||||
|
viewId: ViewID,
|
||||||
|
config: {
|
||||||
|
basePath?: string;
|
||||||
|
fallbackImage?: string;
|
||||||
|
globalAttributeDependencies?: AttrID[];
|
||||||
|
sectionAttributeDependencies?: AttrID[];
|
||||||
|
},
|
||||||
|
globalAttributes: Record<AttrID, Value>,
|
||||||
|
sectionAttributes: Record<AttrID, Value>
|
||||||
|
): string {
|
||||||
|
if (!template) {
|
||||||
|
return config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const builder = new UrlTemplateBuilder(template, {
|
||||||
|
fallbackUrl: config.fallbackImage,
|
||||||
|
logMode: 'console',
|
||||||
|
onMissingAttribute: 'default'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = builder.build({
|
||||||
|
basePath: config.basePath,
|
||||||
|
sectionId,
|
||||||
|
viewId,
|
||||||
|
globalAttributes,
|
||||||
|
sectionAttributes,
|
||||||
|
globalDependencies: config.globalAttributeDependencies,
|
||||||
|
sectionDependencies: config.sectionAttributeDependencies
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default {
|
||||||
|
UrlTemplateBuilder,
|
||||||
|
buildSectionImageUrl,
|
||||||
|
buildViewImageUrl
|
||||||
|
};
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* ATTRIBUTE VALIDATOR
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
import type {Attribute, AttrID, SectionID} from '@/types';
|
||||||
|
|
||||||
|
|
||||||
|
export type ValidationSeverity =
|
||||||
|
'error' |
|
||||||
|
'warning' ;
|
||||||
|
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
attrId? : AttrID;
|
||||||
|
sectionId? : SectionID;
|
||||||
|
message : string;
|
||||||
|
severity : ValidationSeverity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar atributo en runtime
|
||||||
|
*/
|
||||||
|
export function validateAttribute(attr: Attribute): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
// Computed debe ser readonly
|
||||||
|
if (attr.type === 'computed' && !attr.display.readonly) {
|
||||||
|
errors.push({
|
||||||
|
attrId: attr.id,
|
||||||
|
message: 'Computed attributes should be readonly',
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed no debería afectar precio
|
||||||
|
if (attr.type === 'fixed' && attr.display.affectsPrice) {
|
||||||
|
errors.push({
|
||||||
|
attrId: attr.id,
|
||||||
|
message: 'Fixed attributes should not affect price',
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que dependencies existen
|
||||||
|
if (attr.type === 'computed') {
|
||||||
|
if (!attr.dependencies || attr.dependencies.length === 0) {
|
||||||
|
errors.push({
|
||||||
|
attrId: attr.id,
|
||||||
|
message: 'Computed attribute has no dependencies',
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Validation Utilities - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './attribute.validator';
|
||||||
|
export * from './section.validator';
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* SECTION VALIDATOR
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Section, AttrID } from '@/types';
|
||||||
|
import type { ValidationError } from '@/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar sección en runtime
|
||||||
|
*/
|
||||||
|
export function validateSection(section: Section): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
// Validar que imageUrlTemplate tiene placeholders válidos
|
||||||
|
if (section.visualConfig.imageUrlTemplate) {
|
||||||
|
const template = section.visualConfig.imageUrlTemplate;
|
||||||
|
const placeholders = template.match(/\{[^}]+}/g) || [];
|
||||||
|
|
||||||
|
placeholders.forEach(placeholder => {
|
||||||
|
const cleanName = placeholder.slice(1, -1); // Remove { }
|
||||||
|
|
||||||
|
if (cleanName.startsWith('global.')) {
|
||||||
|
const attrName = cleanName.replace('global.', '');
|
||||||
|
const attrId = `at:${attrName}` as AttrID;
|
||||||
|
|
||||||
|
if (!section.visualConfig.globalAttributeDependencies?.includes(attrId)) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
message: `Placeholder ${placeholder} not in globalAttributeDependencies`,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* HOTSPOT FILTER UTILITIES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Hotspot, SectionView , AttrID } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener hotspots visibles de una vista
|
||||||
|
*/
|
||||||
|
export function getVisibleHotspots(
|
||||||
|
view : SectionView,
|
||||||
|
attributeValues : Record<AttrID, any>,
|
||||||
|
evaluator : any
|
||||||
|
): Hotspot[] {
|
||||||
|
if (!view.hotspots) return [];
|
||||||
|
|
||||||
|
return view.hotspots.filter(hotspot => {
|
||||||
|
if (hotspot.visible === undefined || hotspot.visible === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (hotspot.visible === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Es JsonLogic, evaluar
|
||||||
|
const data = { attributes: attributeValues };
|
||||||
|
return evaluator.evaluate(hotspot.visible, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* View Utilities - Public API
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './view-selector.utils';
|
||||||
|
export * from './hotspot-filter.utils';
|
||||||
|
export * from './view-image.utils';
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VIEW IMAGE UTILITIES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SectionView } from '../../types/view';
|
||||||
|
import type { SectionID, AttrID } from '../../types/model';
|
||||||
|
import type { Value } from '../../types/core';
|
||||||
|
import { extractIdPart } from '../ids';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar URL de imagen para una vista específica
|
||||||
|
*/
|
||||||
|
export function generateViewImageUrl(
|
||||||
|
view : SectionView,
|
||||||
|
sectionId : SectionID,
|
||||||
|
globalAttributes : Record<AttrID, Value>,
|
||||||
|
sectionAttributes: Record<AttrID, Value>
|
||||||
|
): string {
|
||||||
|
const config = view.visualConfig;
|
||||||
|
|
||||||
|
if (!config.imageUrlTemplate) {
|
||||||
|
return config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = config.imageUrlTemplate;
|
||||||
|
|
||||||
|
// Reemplazar {basePath}
|
||||||
|
if (config.basePath) {
|
||||||
|
url = url.replace('{basePath}', config.basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reemplazar {sectionId}
|
||||||
|
const sectionIdPart = extractIdPart(sectionId, 'sc');
|
||||||
|
url = url.replace('{sectionId}', sectionIdPart);
|
||||||
|
|
||||||
|
// Reemplazar {viewId}
|
||||||
|
const viewIdPart = extractIdPart(view.id, 'vw');
|
||||||
|
url = url.replace('{viewId}', viewIdPart);
|
||||||
|
|
||||||
|
// Reemplazar atributos globales
|
||||||
|
config.globalAttributeDependencies?.forEach(attrId => {
|
||||||
|
const attrName = extractIdPart(attrId, 'at');
|
||||||
|
const value = globalAttributes[attrId];
|
||||||
|
if (value !== undefined) {
|
||||||
|
url = url.replace(`{global.${attrName}}`, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reemplazar atributos de sección
|
||||||
|
config.sectionAttributeDependencies?.forEach(attrId => {
|
||||||
|
const attrName = extractIdPart(attrId, 'at');
|
||||||
|
const value = sectionAttributes[attrId];
|
||||||
|
if (value !== undefined) {
|
||||||
|
url = url.replace(`{section.${attrName}}`, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* VIEW SELECTOR UTILITIES
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import type { SectionView, AttrID } from '@/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener la vista por defecto de una lista de vistas
|
||||||
|
*/
|
||||||
|
export function getDefaultView(views: SectionView[]): SectionView | null {
|
||||||
|
const defaultView = views.find(v => v.isDefault === true);
|
||||||
|
return defaultView || views[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtrar vistas visibles según condiciones
|
||||||
|
*/
|
||||||
|
export function getVisibleViews(
|
||||||
|
views: SectionView[],
|
||||||
|
attributeValues: Record<AttrID, any>,
|
||||||
|
evaluator: any
|
||||||
|
): SectionView[] {
|
||||||
|
return views.filter(view => {
|
||||||
|
if (view.visible === undefined || view.visible === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (view.visible === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Es JsonLogic, evaluar
|
||||||
|
const data = { attributes: attributeValues };
|
||||||
|
return evaluator.evaluate(view.visible, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* INTERNATIONALIZATION (i18n) - Utilities
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Sistema de internacionalización para textos multiidioma
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
import type {SupportedLocale, I18nString, I18nTranslations, LocaleInfo} from "../types/i18n.ts";
|
||||||
|
import { DEFAULT_LOCALE } from "../types/i18n.ts";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SUPPORTED LOCALES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista de todos los locales soportados
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
|
||||||
|
'es',
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
'it',
|
||||||
|
'pt',
|
||||||
|
'ca',
|
||||||
|
'eu',
|
||||||
|
'gl'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LOCALE UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si un string es un locale soportado
|
||||||
|
*/
|
||||||
|
export function isSupportedLocale(locale: string): locale is SupportedLocale {
|
||||||
|
return SUPPORTED_LOCALES.includes(locale as SupportedLocale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener locale válido con fallback
|
||||||
|
*/
|
||||||
|
export function getValidLocale(
|
||||||
|
locale: string | undefined,
|
||||||
|
fallback: SupportedLocale = DEFAULT_LOCALE
|
||||||
|
): SupportedLocale {
|
||||||
|
if (!locale) return fallback;
|
||||||
|
return isSupportedLocale(locale) ? locale : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extraer código de idioma de un locale (ej: "es-ES" → "es")
|
||||||
|
*/
|
||||||
|
export function extractLanguageCode(locale: string): string {
|
||||||
|
return locale.split('-')[0].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TRANSLATION UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener texto traducido
|
||||||
|
*
|
||||||
|
* @param text - String i18n (simple o con traducciones)
|
||||||
|
* @param locale - Locale objetivo
|
||||||
|
* @param fallback - Locale de respaldo si no existe la traducción
|
||||||
|
* @returns Texto en el idioma solicitado
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const text: I18nString = { es: "Hola", en: "Hello" };
|
||||||
|
* t(text, 'es') // → "Hola"
|
||||||
|
* t(text, 'fr') // → "Hola" (fallback a español)
|
||||||
|
* t("OK", 'es') // → "OK" (string simple)
|
||||||
|
*/
|
||||||
|
export function t(
|
||||||
|
text: I18nString,
|
||||||
|
locale: SupportedLocale,
|
||||||
|
fallback: SupportedLocale = DEFAULT_LOCALE
|
||||||
|
): string {
|
||||||
|
// Si es string simple, devolverlo directamente
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar con el locale solicitado
|
||||||
|
if (text[locale]) {
|
||||||
|
return text[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback al locale de respaldo
|
||||||
|
if (text[fallback]) {
|
||||||
|
return text[fallback];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Último recurso: primer valor disponible
|
||||||
|
const firstAvailable = Object.values(text)[0];
|
||||||
|
return firstAvailable || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias de `t` para compatibilidad con librerías i18n
|
||||||
|
*/
|
||||||
|
export const translate = t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si un I18nString tiene traducción para un locale
|
||||||
|
*/
|
||||||
|
export function hasTranslation(
|
||||||
|
text: I18nString,
|
||||||
|
locale: SupportedLocale
|
||||||
|
): boolean {
|
||||||
|
if (typeof text === 'string') return true;
|
||||||
|
return text[locale] !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener todos los locales disponibles en un I18nString
|
||||||
|
*/
|
||||||
|
export function getAvailableLocales(text: I18nString): SupportedLocale[] {
|
||||||
|
if (typeof text === 'string') return [...SUPPORTED_LOCALES];
|
||||||
|
return Object.keys(text) as SupportedLocale[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar si un I18nString está completo (tiene todas las traducciones)
|
||||||
|
*/
|
||||||
|
export function isComplete(text: I18nString): boolean {
|
||||||
|
if (typeof text === 'string') return true;
|
||||||
|
return SUPPORTED_LOCALES.every(locale => text[locale] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener locales faltantes en un I18nString
|
||||||
|
*/
|
||||||
|
export function getMissingLocales(text: I18nString): SupportedLocale[] {
|
||||||
|
if (typeof text === 'string') return [];
|
||||||
|
return SUPPORTED_LOCALES.filter(locale => text[locale] === undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// I18N BUILDERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear I18nString desde objeto parcial
|
||||||
|
* Garantiza que al menos existe el locale por defecto
|
||||||
|
*/
|
||||||
|
export function createI18nString(
|
||||||
|
translations: Partial<Record<SupportedLocale, string>>
|
||||||
|
): I18nString {
|
||||||
|
if (!translations[DEFAULT_LOCALE]) {
|
||||||
|
throw new Error(`I18nString must have at least '${DEFAULT_LOCALE}' translation`);
|
||||||
|
}
|
||||||
|
return translations as I18nTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crear I18nString simple (mismo texto en todos los idiomas)
|
||||||
|
*/
|
||||||
|
export function createSimpleI18nString(text: string): I18nString {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertir string simple a objeto de traducciones
|
||||||
|
* (útil para migración)
|
||||||
|
*/
|
||||||
|
export function expandToTranslations(
|
||||||
|
text: I18nString,
|
||||||
|
defaultLocale: SupportedLocale = DEFAULT_LOCALE
|
||||||
|
): I18nTranslations {
|
||||||
|
if (typeof text === 'string') {
|
||||||
|
return { [defaultLocale]: text } as I18nTranslations;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combinar múltiples I18nStrings
|
||||||
|
* (útil para concatenar textos)
|
||||||
|
*/
|
||||||
|
export function combineI18nStrings(
|
||||||
|
strings: I18nString[],
|
||||||
|
separator: string = ' '
|
||||||
|
): I18nString {
|
||||||
|
// Si todos son strings simples, concatenar directamente
|
||||||
|
if (strings.every(s => typeof s === 'string')) {
|
||||||
|
return strings.join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear objeto con todas las traducciones
|
||||||
|
const combined: Partial<Record<SupportedLocale, string>> = {};
|
||||||
|
|
||||||
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
|
const parts = strings.map(s => t(s, locale));
|
||||||
|
combined[locale] = parts.join(separator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined as I18nTranslations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LOCALE METADATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata de locales soportados
|
||||||
|
*/
|
||||||
|
export const LOCALE_INFO: Record<SupportedLocale, LocaleInfo> = {
|
||||||
|
es: {
|
||||||
|
code: 'es',
|
||||||
|
name: 'Spanish',
|
||||||
|
nativeName: 'Español',
|
||||||
|
flag: '🇪🇸'
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
code: 'en',
|
||||||
|
name: 'English',
|
||||||
|
nativeName: 'English',
|
||||||
|
flag: '🇬🇧'
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
code: 'de',
|
||||||
|
name: 'German',
|
||||||
|
nativeName: 'Deutsch',
|
||||||
|
flag: '🇩🇪'
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
code: 'fr',
|
||||||
|
name: 'French',
|
||||||
|
nativeName: 'Français',
|
||||||
|
flag: '🇫🇷'
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
code: 'it',
|
||||||
|
name: 'Italian',
|
||||||
|
nativeName: 'Italiano',
|
||||||
|
flag: '🇮🇹'
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
code: 'pt',
|
||||||
|
name: 'Portuguese',
|
||||||
|
nativeName: 'Português',
|
||||||
|
flag: '🇵🇹'
|
||||||
|
},
|
||||||
|
ca: {
|
||||||
|
code: 'ca',
|
||||||
|
name: 'Catalan',
|
||||||
|
nativeName: 'Català',
|
||||||
|
flag: '🏴'
|
||||||
|
},
|
||||||
|
eu: {
|
||||||
|
code: 'eu',
|
||||||
|
name: 'Basque',
|
||||||
|
nativeName: 'Euskara',
|
||||||
|
flag: '🏴'
|
||||||
|
},
|
||||||
|
gl: {
|
||||||
|
code: 'gl',
|
||||||
|
name: 'Galician',
|
||||||
|
nativeName: 'Galego',
|
||||||
|
flag: '🏴'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener información de un locale
|
||||||
|
*/
|
||||||
|
export function getLocaleInfo(locale: SupportedLocale): LocaleInfo {
|
||||||
|
return LOCALE_INFO[locale];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar que un I18nString es correcto
|
||||||
|
*/
|
||||||
|
export function validateI18nString(text: unknown): text is I18nString {
|
||||||
|
if (typeof text === 'string') return true;
|
||||||
|
|
||||||
|
if (typeof text !== 'object' || text === null) return false;
|
||||||
|
|
||||||
|
const obj = text as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Debe tener al menos el locale por defecto
|
||||||
|
if (typeof obj[DEFAULT_LOCALE] !== 'string') return false;
|
||||||
|
|
||||||
|
// Todos los valores deben ser strings
|
||||||
|
return Object.values(obj).every(v => typeof v === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error para I18nString inválido
|
||||||
|
*/
|
||||||
|
export class I18nStringError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`[I18n] ${message}`);
|
||||||
|
this.name = 'I18nStringError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validar y lanzar error si es inválido
|
||||||
|
*/
|
||||||
|
export function assertValidI18nString(text: unknown): asserts text is I18nString {
|
||||||
|
if (!validateI18nString(text)) {
|
||||||
|
throw new I18nStringError(
|
||||||
|
`Invalid I18nString. Must be a string or object with at least '${DEFAULT_LOCALE}' key.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Constantes y Metadata
|
||||||
|
SUPPORTED_LOCALES,
|
||||||
|
|
||||||
|
// Utilities de Locale
|
||||||
|
isSupportedLocale,
|
||||||
|
getValidLocale,
|
||||||
|
extractLanguageCode,
|
||||||
|
getLocaleInfo,
|
||||||
|
|
||||||
|
// Traducción
|
||||||
|
t,
|
||||||
|
translate,
|
||||||
|
hasTranslation,
|
||||||
|
getAvailableLocales,
|
||||||
|
isComplete,
|
||||||
|
getMissingLocales,
|
||||||
|
|
||||||
|
// Builders
|
||||||
|
createI18nString,
|
||||||
|
createSimpleI18nString,
|
||||||
|
expandToTranslations,
|
||||||
|
combineI18nStrings,
|
||||||
|
|
||||||
|
// Validación y Errores
|
||||||
|
validateI18nString,
|
||||||
|
assertValidI18nString,
|
||||||
|
I18nStringError
|
||||||
|
};
|
||||||
|
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
// ids.ts
|
||||||
|
import type {AttrID, ObjectID, OptionID, RuleID, SectionID, HotspotID, ViewID} from "../types/all.ts";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const IDS = {
|
||||||
|
attr : (id: string): AttrID => `at:${id}`,
|
||||||
|
opt : (id: string): OptionID => `op:${id}`,
|
||||||
|
sec : (id: string): SectionID=> `sc:${id}`,
|
||||||
|
obj : (id: string): ObjectID => `ob:${id}`,
|
||||||
|
rule : (id: string): RuleID => `rl:${id}`,
|
||||||
|
view : (id: string): ViewID => `vw:${id}`,
|
||||||
|
hspot: (id: string): HotspotID=> `hs:${id}`,
|
||||||
|
};
|
||||||
|
|
||||||
@ -0,0 +1,329 @@
|
|||||||
|
|
||||||
|
import type {
|
||||||
|
ID,
|
||||||
|
Section,
|
||||||
|
Attribute,
|
||||||
|
JsonLogicEvaluator,
|
||||||
|
JsonLogic,
|
||||||
|
Value, AttrID, OptionID, SectionID, ObjectID, RuleID, ViewID, HotspotID
|
||||||
|
} from '../types/all.ts';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITIES (Con Mejoras)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ MEJORADO: Evalúa display flags con soporte para condicionales
|
||||||
|
*/
|
||||||
|
export function evaluateDisplayFlag(
|
||||||
|
flag: boolean | JsonLogic | undefined,
|
||||||
|
defaultValue: boolean,
|
||||||
|
attributeValues: Record<ID<'attr'>, Value>,
|
||||||
|
evaluator: JsonLogicEvaluator
|
||||||
|
): boolean {
|
||||||
|
if (flag === undefined) return defaultValue;
|
||||||
|
if (typeof flag === 'boolean') return flag;
|
||||||
|
|
||||||
|
// Es JsonLogic: evaluar
|
||||||
|
const data = {
|
||||||
|
attributes: Object.fromEntries(
|
||||||
|
Object.entries(attributeValues).map(([id, value]) => [
|
||||||
|
id.split(':')[1], // "attr:suelo" → "suelo"
|
||||||
|
value
|
||||||
|
])
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
return evaluator.evaluate(flag, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ MEJORADO: Generación de URL con validación y fallbacks
|
||||||
|
*/
|
||||||
|
export function generateSectionImageUrl(
|
||||||
|
section: Section,
|
||||||
|
globalAttributes : Record<AttrID, Value>,
|
||||||
|
sectionAttributes: Record<AttrID, Value>
|
||||||
|
): string {
|
||||||
|
const config = section.visualConfig;
|
||||||
|
|
||||||
|
if (!config.imageUrlTemplate) {
|
||||||
|
console.warn(`[Render] No imageUrlTemplate for section ${section.id}`);
|
||||||
|
return config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = config.imageUrlTemplate;
|
||||||
|
|
||||||
|
// Reemplazar {basePath}
|
||||||
|
if (config.basePath) {
|
||||||
|
url = url.replace('{basePath}', config.basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reemplazar {sectionId}
|
||||||
|
const sectionId = section.id.split(':')[1];
|
||||||
|
if (sectionId) {
|
||||||
|
url = url.replace('{sectionId}', sectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No overload matches this call.
|
||||||
|
/The last overload gave the following error.
|
||||||
|
Argument of type 'string | undefined' is not assignable to parameter of type '(substring: string, ...args: any[]) => string'.
|
||||||
|
Type 'undefined' is not assignable to type '(substring: string, ...args: any[]) => string'.ts(2769)
|
||||||
|
lib.es5.d.ts(470, 5): The last overload is declared here.
|
||||||
|
url = url.replace('{sectionId}', sectionId);
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Reemplazar {global.attributeId}
|
||||||
|
config.globalAttributeDependencies?.forEach(attrId => {
|
||||||
|
const attrName = attrId.split(':')[1];
|
||||||
|
const placeholder = `{global.${attrName}}`;
|
||||||
|
const value = globalAttributes[attrId];
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
// Normalizar valor para URL
|
||||||
|
const normalizedValue = String(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[^a-z0-9_]/g, '');
|
||||||
|
url = url.replace(placeholder, normalizedValue);
|
||||||
|
} else {
|
||||||
|
// Atributo faltante: usar 'default'
|
||||||
|
console.warn(`[Render] Missing global attribute ${attrId}, using 'default'`);
|
||||||
|
url = url.replace(placeholder, 'default');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reemplazar {section.attributeId}
|
||||||
|
config.sectionAttributeDependencies?.forEach(attrId => {
|
||||||
|
const attrName = attrId.split(':')[1];
|
||||||
|
const placeholder = `{section.${attrName}}`;
|
||||||
|
const value = sectionAttributes[attrId];
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
const normalizedValue = String(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/[^a-z0-9_]/g, '');
|
||||||
|
url = url.replace(placeholder, normalizedValue);
|
||||||
|
} else {
|
||||||
|
console.warn(`[Render] Missing section attribute ${attrId}, using 'default'`);
|
||||||
|
url = url.replace(placeholder, 'default');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ NUEVO: Validar que no queden placeholders sin reemplazar
|
||||||
|
const unresolvedPlaceholders = url.match(/\{[^}]+}/g);
|
||||||
|
if (unresolvedPlaceholders) {
|
||||||
|
console.error(
|
||||||
|
`[Render] Unresolved placeholders in URL: ${unresolvedPlaceholders.join(', ')}`,
|
||||||
|
`Original template: ${config.imageUrlTemplate}`,
|
||||||
|
`Partial URL: ${url}`
|
||||||
|
);
|
||||||
|
return config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ NUEVO: Generación async de imagen via API
|
||||||
|
*/
|
||||||
|
export async function generateSectionImageAsync(
|
||||||
|
section: Section,
|
||||||
|
globalAttributes : Record<AttrID, Value>,
|
||||||
|
sectionAttributes: Record<AttrID, Value>
|
||||||
|
): Promise<string> {
|
||||||
|
const config = section.visualConfig;
|
||||||
|
const fallback = config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
|
||||||
|
if (config.strategy !== 'api_generated' || !config.apiConfig) {
|
||||||
|
return generateSectionImageUrl(section, globalAttributes, sectionAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: Record<string, Value> = {};
|
||||||
|
config.apiConfig.params?.forEach(attrId => {
|
||||||
|
const value = globalAttributes[attrId] ?? sectionAttributes[attrId];
|
||||||
|
if (value !== undefined) params[attrId] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), config.apiConfig.timeout || 5000);
|
||||||
|
|
||||||
|
const response = await fetch(config.apiConfig.endpoint, {
|
||||||
|
method: config.apiConfig.method || 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sectionId: section.id, attributes: params }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// ✅ SOLUCIÓN: En lugar de 'throw', manejamos el error y retornamos el fallback
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[Render] API ${config.apiConfig.endpoint} returned ${response.status}`);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.imageUrl || fallback;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// El catch ahora solo se encarga de errores reales:
|
||||||
|
// fallos de red, JSON malformado o el Timeout (AbortError)
|
||||||
|
console.error(`[Render] Network or Timeout error for ${section.id}:`, error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ MEJORADO: Deep equality para valores complejos
|
||||||
|
*/
|
||||||
|
export function shouldRerenderSection(
|
||||||
|
section: Section,
|
||||||
|
changedAttributeId: AttrID,
|
||||||
|
oldValue: Value,
|
||||||
|
newValue: Value,
|
||||||
|
isGlobalAttribute: boolean
|
||||||
|
): boolean {
|
||||||
|
const config = section.visualConfig;
|
||||||
|
|
||||||
|
// Verificar si el atributo afecta a esta sección
|
||||||
|
const affectsThisSection = isGlobalAttribute
|
||||||
|
? config.globalAttributeDependencies?.includes(changedAttributeId)
|
||||||
|
: config.sectionAttributeDependencies?.includes(changedAttributeId);
|
||||||
|
|
||||||
|
if (!affectsThisSection) return false;
|
||||||
|
|
||||||
|
// Deep equality para valores complejos
|
||||||
|
return !deepEqual(oldValue, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep equality check
|
||||||
|
*/
|
||||||
|
export function deepEqual(a: Value, b: Value): boolean {
|
||||||
|
// 1. Chequeo de identidad simple (cubre primitivos iguales)
|
||||||
|
if (a === b) return true;
|
||||||
|
|
||||||
|
// 2. Si alguno de los dos NO es un objeto o es null, devuelve false
|
||||||
|
// (Esto descarta string, number, boolean, ID y null de un golpe)
|
||||||
|
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// AHORA TypeScript SABE que 'a' y 'b' son OBJETOS (Date o Array)
|
||||||
|
// =========================================================
|
||||||
|
|
||||||
|
// 3. Ahora es seguro preguntar por Date
|
||||||
|
if (a instanceof Date && b instanceof Date) {
|
||||||
|
return a.getTime() === b.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Chequeo de Arrays
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((val, idx) => deepEqual(val, b[idx]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Si son objetos pero no Dates ni Arrays, comparamos sus claves (opcional, pero recomendado)
|
||||||
|
// Esto es útil si tu 'Value' llega a incluir objetos planos en el futuro.
|
||||||
|
/*
|
||||||
|
const keysA = Object.keys(a);
|
||||||
|
const keysB = Object.keys(b);
|
||||||
|
if (keysA.length !== keysB.length) return false;
|
||||||
|
return keysA.every(key => deepEqual((a as any)[key], (b as any)[key]));
|
||||||
|
*/
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION (Runtime)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ NUEVO: Validar modelo en runtime
|
||||||
|
*/
|
||||||
|
export function validateAttribute(attr: Attribute): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
// Computed debe ser readonly
|
||||||
|
if (attr.type === 'computed' && !attr.display.readonly) {
|
||||||
|
errors.push({
|
||||||
|
attributeId: attr.id,
|
||||||
|
message: 'Computed attributes should be readonly',
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed no debería afectar precio
|
||||||
|
if (attr.type === 'fixed' && attr.display.affectsPrice) {
|
||||||
|
errors.push({
|
||||||
|
attributeId: attr.id,
|
||||||
|
message: 'Fixed attributes should not affect price',
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que dependencies existen
|
||||||
|
if (attr.type === 'computed') {
|
||||||
|
if (!attr.dependencies || attr.dependencies.length === 0) {
|
||||||
|
errors.push({
|
||||||
|
attributeId: attr.id,
|
||||||
|
message: 'Computed attribute has no dependencies',
|
||||||
|
severity: 'warning'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSection(section: Section): ValidationError[] {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
// Validar que imageUrlTemplate tiene placeholders válidos
|
||||||
|
if (section.visualConfig.imageUrlTemplate) {
|
||||||
|
const template = section.visualConfig.imageUrlTemplate;
|
||||||
|
const placeholders = template.match(/\{[^}]+}/g) || [];
|
||||||
|
|
||||||
|
placeholders.forEach(placeholder => {
|
||||||
|
const cleanName = placeholder.slice(1, -1); // Remove { }
|
||||||
|
|
||||||
|
if (cleanName.startsWith('global.')) {
|
||||||
|
const attrName = cleanName.replace('global.', '');
|
||||||
|
const attrId = `at:${attrName}` as AttrID;
|
||||||
|
|
||||||
|
if (!section.visualConfig.globalAttributeDependencies?.includes(attrId)) {
|
||||||
|
errors.push({
|
||||||
|
sectionId: section.id,
|
||||||
|
message: `Placeholder ${placeholder} not in globalAttributeDependencies`,
|
||||||
|
severity: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
attributeId?: ID<'at'>;
|
||||||
|
sectionId?: ID<'sc'>;
|
||||||
|
message: string;
|
||||||
|
severity: 'error' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
// view.ts
|
||||||
|
|
||||||
|
import type { Hotspot, SectionView, AttrID, SectionID} from "../types/all.ts";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener la vista por defecto de una lista de vistas
|
||||||
|
*/
|
||||||
|
export function getDefaultView(views: SectionView[]): SectionView | null {
|
||||||
|
const defaultView = views.find(v => v.isDefault === true);
|
||||||
|
return defaultView || views[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtrar vistas visibles según condiciones
|
||||||
|
*/
|
||||||
|
export function getVisibleViews(
|
||||||
|
views: SectionView[],
|
||||||
|
attributeValues: Record<AttrID, any>,
|
||||||
|
evaluator: any
|
||||||
|
): SectionView[] {
|
||||||
|
return views.filter(view => {
|
||||||
|
if (view.visible === undefined || view.visible === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (view.visible === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Es JsonLogic, evaluar
|
||||||
|
const data = { attributes: attributeValues };
|
||||||
|
return evaluator.evaluate(view.visible, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener hotspots visibles de una vista
|
||||||
|
*/
|
||||||
|
export function getVisibleHotspots(
|
||||||
|
view: SectionView,
|
||||||
|
attributeValues: Record<AttrID, any>,
|
||||||
|
evaluator: any
|
||||||
|
): Hotspot[] {
|
||||||
|
if (!view.hotspots) return [];
|
||||||
|
|
||||||
|
return view.hotspots.filter(hotspot => {
|
||||||
|
if (hotspot.visible === undefined || hotspot.visible === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (hotspot.visible === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Es JsonLogic, evaluar
|
||||||
|
const data = { attributes: attributeValues };
|
||||||
|
return evaluator.evaluate(hotspot.visible, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar URL de imagen para una vista específica
|
||||||
|
*/
|
||||||
|
export function generateViewImageUrl(
|
||||||
|
view: SectionView,
|
||||||
|
sectionId: SectionID,
|
||||||
|
globalAttributes : Record<AttrID, any>,
|
||||||
|
sectionAttributes: Record<AttrID, any>
|
||||||
|
): string {
|
||||||
|
const config = view.visualConfig;
|
||||||
|
|
||||||
|
if (!config.imageUrlTemplate) {
|
||||||
|
return config.fallbackImage || '/images/fallback.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = config.imageUrlTemplate;
|
||||||
|
|
||||||
|
// Reemplazar {basePath}
|
||||||
|
if (config.basePath) {
|
||||||
|
url = url.replace('{basePath}', config.basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reemplazar {sectionId}
|
||||||
|
url = url.replace('{sectionId}', sectionId.split(':')[1]);
|
||||||
|
|
||||||
|
// Reemplazar {viewId}
|
||||||
|
url = url.replace('{viewId}', view.id.split(':')[1]);
|
||||||
|
|
||||||
|
// Reemplazar atributos globales
|
||||||
|
config.globalAttributeDependencies?.forEach(attrId => {
|
||||||
|
const attrName = attrId.split(':')[1];
|
||||||
|
const value = globalAttributes[attrId];
|
||||||
|
if (value !== undefined) {
|
||||||
|
url = url.replace(`{global.${attrName}}`, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reemplazar atributos de sección
|
||||||
|
config.sectionAttributeDependencies?.forEach(attrId => {
|
||||||
|
const attrName = attrId.split(':')[1];
|
||||||
|
const value = sectionAttributes[attrId];
|
||||||
|
if (value !== undefined) {
|
||||||
|
url = url.replace(`{section.${attrName}}`, String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,217 @@
|
|||||||
|
// view_examples.ts
|
||||||
|
import type {AttrID, HotspotID, SectionView, ViewID} from '../types/all.ts';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EJEMPLOS DE USO
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EJEMPLO 1: Salón con 3 vistas
|
||||||
|
*/
|
||||||
|
export const ejemploSalonVistas: SectionView[] = [
|
||||||
|
// Vista 1: Frontal (por defecto)
|
||||||
|
{
|
||||||
|
id: "vw:salon_frontal" as ViewID,
|
||||||
|
name: { es: "Vista Frontal", en: "Front View" },
|
||||||
|
description: { es: "Vista principal del salón" },
|
||||||
|
type: "front",
|
||||||
|
isDefault: true,
|
||||||
|
order: 1,
|
||||||
|
|
||||||
|
visualConfig: {
|
||||||
|
strategy: "dynamic_image",
|
||||||
|
basePath: "/images/sections/salon",
|
||||||
|
imageUrlTemplate: "{basePath}/frontal_{global.suelo}_{global.zocalo}_{section.mobiliario}.jpg",
|
||||||
|
globalAttributeDependencies: [
|
||||||
|
"at:suelo" as AttrID,
|
||||||
|
"at:zocalo" as AttrID
|
||||||
|
],
|
||||||
|
sectionAttributeDependencies: [
|
||||||
|
"at:mobiliario_salon" as AttrID,
|
||||||
|
"at:iluminacion_salon" as AttrID
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hotspots en vista frontal
|
||||||
|
hotspots: [
|
||||||
|
{
|
||||||
|
id: "hs:salon_suelo" as HotspotID,
|
||||||
|
name: { es: "Suelo" },
|
||||||
|
x: 50,
|
||||||
|
y: 85,
|
||||||
|
radius: 30,
|
||||||
|
color: "#FFD700",
|
||||||
|
icon: "floor",
|
||||||
|
scopeType: "global",
|
||||||
|
targetAttributeId: "at:suelo" as AttrID,
|
||||||
|
tooltip: { es: "Configurar tipo de suelo" },
|
||||||
|
zIndex: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hs:salon_muebles" as HotspotID,
|
||||||
|
name: { es: "Mobiliario" },
|
||||||
|
x: 40,
|
||||||
|
y: 50,
|
||||||
|
radius: 40,
|
||||||
|
color: "#4CAF50",
|
||||||
|
icon: "sofa",
|
||||||
|
scopeType: "section",
|
||||||
|
targetAttributeId: "at:mobiliario_salon" as AttrID,
|
||||||
|
tooltip: { es: "Elegir mobiliario del salón" },
|
||||||
|
zIndex: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hs:salon_lampara" as HotspotID,
|
||||||
|
name: { es: "Iluminación" },
|
||||||
|
x: 50,
|
||||||
|
y: 15,
|
||||||
|
radius: 25,
|
||||||
|
color: "#FFC107",
|
||||||
|
icon: "lightbulb",
|
||||||
|
scopeType: "section",
|
||||||
|
targetAttributeId: "at:iluminacion_salon" as AttrID,
|
||||||
|
tooltip: { es: "Configurar iluminación" },
|
||||||
|
zIndex: 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vista 2: Lateral
|
||||||
|
{
|
||||||
|
id: "vw:salon_lateral" as ViewID,
|
||||||
|
name: { es: "Vista Lateral", en: "Side View" },
|
||||||
|
type: "side",
|
||||||
|
order: 2,
|
||||||
|
|
||||||
|
visualConfig: {
|
||||||
|
strategy: "dynamic_image",
|
||||||
|
basePath: "/images/sections/salon",
|
||||||
|
imageUrlTemplate: "{basePath}/lateral_{global.suelo}_{section.mobiliario}.jpg",
|
||||||
|
globalAttributeDependencies: ["at:suelo" as AttrID],
|
||||||
|
sectionAttributeDependencies: ["at:mobiliario_salon" as AttrID]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Esta vista tiene menos hotspots
|
||||||
|
hotspots: [
|
||||||
|
{
|
||||||
|
id: "hs:salon_lateral_suelo" as HotspotID,
|
||||||
|
name: { es: "Suelo" },
|
||||||
|
x: 50,
|
||||||
|
y: 90,
|
||||||
|
radius: 30,
|
||||||
|
scopeType: "global",
|
||||||
|
targetAttributeId: "at:suelo" as AttrID
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vista 3: Cenital (sin hotspots)
|
||||||
|
{
|
||||||
|
id: "vw:salon_cenital" as ViewID,
|
||||||
|
name: { es: "Plano", en: "Floor Plan" },
|
||||||
|
type: "top",
|
||||||
|
order: 3,
|
||||||
|
|
||||||
|
visualConfig: {
|
||||||
|
strategy: "static_image",
|
||||||
|
basePath: "/images/sections/salon",
|
||||||
|
imageUrlTemplate: "{basePath}/plano.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sin hotspots - solo visualización
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EJEMPLO 2: Coche con vista 360°
|
||||||
|
*/
|
||||||
|
export const ejemploCocheVista360: SectionView = {
|
||||||
|
id: "vw:coche_360" as ViewID,
|
||||||
|
name: { es: "Vista 360°" },
|
||||||
|
type: "panoramic",
|
||||||
|
isDefault: true,
|
||||||
|
|
||||||
|
visualConfig: {
|
||||||
|
strategy: "three_d",
|
||||||
|
threeDConfig: {
|
||||||
|
modelUrl: "/models/coche.glb",
|
||||||
|
cameraPosition: { x: 5, y: 2, z: 5 },
|
||||||
|
textureAttributes: [
|
||||||
|
{
|
||||||
|
attributeId: "at:color" as AttrID,
|
||||||
|
scope: "global"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributeId: "at:llantas" as AttrID,
|
||||||
|
scope: "section"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hotspots en modelo 3D (coordenadas 3D)
|
||||||
|
hotspots: [
|
||||||
|
{
|
||||||
|
id: "hs:puerta" as HotspotID,
|
||||||
|
name: { es: "Puerta" },
|
||||||
|
x: 30, // Coordenadas relativas al canvas
|
||||||
|
y: 50,
|
||||||
|
scopeType: "section",
|
||||||
|
targetAttributeId: "at:tipo_puerta" as AttrID
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EJEMPLO 3: Vista condicional (solo visible con cierta opción)
|
||||||
|
*/
|
||||||
|
export const ejemploVistaCondicional: SectionView = {
|
||||||
|
id: "vw:terraza_exterior" as ViewID,
|
||||||
|
name: { es: "Vista Exterior" },
|
||||||
|
type: "front",
|
||||||
|
|
||||||
|
// Solo visible si tiene_terraza = true
|
||||||
|
visible: {
|
||||||
|
"==": [
|
||||||
|
{ "var": "attributes.tiene_terraza" },
|
||||||
|
"opt:si"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
visualConfig: {
|
||||||
|
strategy: "dynamic_image",
|
||||||
|
imageUrlTemplate: "/images/terraza_exterior_{section.suelo_terraza}.jpg",
|
||||||
|
sectionAttributeDependencies: ["at:suelo_terraza" as AttrID]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EJEMPLO 4: Vista de detalle con zoom
|
||||||
|
*/
|
||||||
|
export const ejemploVistaDetalle: SectionView = {
|
||||||
|
id: "vw:cocina_encimera_detalle" as ViewID,
|
||||||
|
name: { es: "Detalle Encimera" },
|
||||||
|
type: "detail",
|
||||||
|
|
||||||
|
visualConfig: {
|
||||||
|
strategy: "dynamic_image",
|
||||||
|
imageUrlTemplate: "/images/cocina/detalle_encimera_{section.encimera}.jpg",
|
||||||
|
sectionAttributeDependencies: ["at:encimera" as AttrID]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Hotspot específico de la encimera
|
||||||
|
hotspots: [
|
||||||
|
{
|
||||||
|
id: "hs:encimera_material" as HotspotID,
|
||||||
|
name: { es: "Material Encimera" },
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
radius: 50,
|
||||||
|
scopeType: "section",
|
||||||
|
targetAttributeId: "at:encimera" as AttrID,
|
||||||
|
tooltip: { es: "Cambiar material de la encimera" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
// types.ts
|
||||||
|
|
||||||
|
export * from './primitives.ts';
|
||||||
|
export * from './i18n.ts';
|
||||||
|
export * from './json_logic.ts';
|
||||||
|
export * from './view.ts';
|
||||||
|
export * from './vce_model.ts';
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
// ui18.ts
|
||||||
|
|
||||||
|
export type SupportedLocale =
|
||||||
|
| 'es' // Español
|
||||||
|
| 'en' // English
|
||||||
|
| 'de' // Deutsch
|
||||||
|
| 'fr' // Français
|
||||||
|
| 'it' // Italiano
|
||||||
|
| 'pt' // Português
|
||||||
|
| 'ca' // Català
|
||||||
|
| 'eu' // Euskara
|
||||||
|
| 'gl'; // Galego
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale por defecto del sistema
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LOCALE: SupportedLocale = 'es';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista de todos los locales soportados
|
||||||
|
*/
|
||||||
|
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = [
|
||||||
|
'es',
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
'it',
|
||||||
|
'pt',
|
||||||
|
'ca',
|
||||||
|
'eu',
|
||||||
|
'gl'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// I18N STRING TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Objeto con traducciones tipadas
|
||||||
|
* Garantiza que al menos existe el locale por defecto
|
||||||
|
*/
|
||||||
|
export type I18nTranslations = {
|
||||||
|
[K in SupportedLocale]?: string;
|
||||||
|
} & {
|
||||||
|
[DEFAULT_LOCALE]: string; // Español es obligatorio
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String internacionalizable
|
||||||
|
*
|
||||||
|
* Puede ser:
|
||||||
|
* - `string`: Texto simple (mismo en todos los idiomas)
|
||||||
|
* - `I18nTranslations`: Objeto con traducciones por locale
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Simple (mismo en todos los idiomas)
|
||||||
|
* const label: I18nString = "OK";
|
||||||
|
*
|
||||||
|
* // Con traducciones
|
||||||
|
* const greeting: I18nString = {
|
||||||
|
* es: "Hola",
|
||||||
|
* en: "Hello",
|
||||||
|
* de: "Hallo"
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* // Solo español (obligatorio) + algún otro
|
||||||
|
* const name: I18nString = {
|
||||||
|
* es: "Configuración",
|
||||||
|
* en: "Configuration"
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export type I18nString = string | I18nTranslations;
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LOCALE METADATA
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Información sobre un locale
|
||||||
|
*/
|
||||||
|
export interface LocaleInfo {
|
||||||
|
code: SupportedLocale;
|
||||||
|
name: string;
|
||||||
|
nativeName: string;
|
||||||
|
flag: string;
|
||||||
|
rtl?: boolean;
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
// json_logic.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JSON LOGIC
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type JsonLogic =
|
||||||
|
| { var: string }
|
||||||
|
| { [op: string]: any }
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// JSONLOGIC EVALUATOR (Simplificado)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export class JsonLogicEvaluator {
|
||||||
|
evaluate(expression: JsonLogic, data: Record<string, any>): any {
|
||||||
|
// Implementación completa en archivo separado
|
||||||
|
// Por ahora: placeholder
|
||||||
|
if (typeof expression === 'boolean') return expression;
|
||||||
|
if (typeof expression === 'number') return expression;
|
||||||
|
if (typeof expression === 'string') return expression;
|
||||||
|
|
||||||
|
if (typeof expression === 'object' && expression !== null) {
|
||||||
|
if ('var' in expression) {
|
||||||
|
return this.getVar(expression.var, data);
|
||||||
|
}
|
||||||
|
if ('==' in expression) {
|
||||||
|
const [a, b] = (expression as any)['=='];
|
||||||
|
return this.evaluate(a, data) === this.evaluate(b, data);
|
||||||
|
}
|
||||||
|
if ('if' in expression) {
|
||||||
|
const [cond, then_, else_] = (expression as any)['if'];
|
||||||
|
return this.evaluate(cond, data)
|
||||||
|
? this.evaluate(then_, data)
|
||||||
|
: this.evaluate(else_, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expression;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVar(path: string, data: Record<string, any>): any {
|
||||||
|
const parts = path.split('.');
|
||||||
|
let current = data;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current === undefined || current === null) return undefined;
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
// metadata.ts
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type Metadata = Record<string, unknown>;
|
||||||
|
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
// primitives.ts
|
||||||
|
|
||||||
|
|
||||||
|
export type ID<T extends string = string> = `${T}:${string}`;
|
||||||
|
|
||||||
|
|
||||||
|
export type Value =
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| Date
|
||||||
|
| ID
|
||||||
|
| Value[];
|
||||||
|
|
||||||
|
export type DataType =
|
||||||
|
| 'string'
|
||||||
|
| 'number'
|
||||||
|
| 'boolean'
|
||||||
|
| 'date'
|
||||||
|
| 'currency'
|
||||||
|
| 'dimension'
|
||||||
|
| 'reference'
|
||||||
|
| 'list';
|
||||||
@ -0,0 +1,362 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* CONFIGURATION MODEL - FINAL VERSION
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* Mejoras implementadas:
|
||||||
|
*
|
||||||
|
* ✅ NIVEL 1 (Incrementales):
|
||||||
|
* 1. Condicionales en display flags (uiVisible, affectsVisual, affectsPrice)
|
||||||
|
* 2. Validación y fallbacks en generateImageUrl
|
||||||
|
* 3. Caching en utilities
|
||||||
|
* 4. Validación de modelo en runtime
|
||||||
|
*
|
||||||
|
* ✅ NIVEL 2 (Intermedias):
|
||||||
|
* 5. Soporte async rendering (API-generated)
|
||||||
|
* 6. Deep equality para shouldRerender
|
||||||
|
* 7. Logging de visual changes
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
import type { JsonLogic } from "./json_logic.ts";
|
||||||
|
import type { I18nString } from "./i18n.ts";
|
||||||
|
import type { Metadata } from "./metadata.ts";
|
||||||
|
import type { RenderingStrategy } from "./view.ts"
|
||||||
|
import type { ID, Value, DataType } from "./primitives.ts";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type AttrID = ID<'at'>;
|
||||||
|
export type SectionID = ID<'sc'>;
|
||||||
|
export type OptionID = ID<'op'>;
|
||||||
|
export type ObjectID = ID<'ob'>;
|
||||||
|
export type RuleID = ID<'rl'>;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ATTRIBUTE DISPLAY (Con Condicionales)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ MEJORADO: Display flags pueden ser boolean O expresión condicional
|
||||||
|
*/
|
||||||
|
export interface AttributeDisplay {
|
||||||
|
/**
|
||||||
|
* Si se muestra en UI
|
||||||
|
* - boolean: true/false estático
|
||||||
|
* - JsonLogic: Evaluado dinámicamente
|
||||||
|
*
|
||||||
|
* Ejemplo condicional:
|
||||||
|
* {
|
||||||
|
* "if": [
|
||||||
|
* { "==": [{ "var": "attributes.usuario_tipo" }, "avanzado"] },
|
||||||
|
* true,
|
||||||
|
* false
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
uiVisible?: boolean | JsonLogic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si afecta a la visualización
|
||||||
|
* - boolean: true/false estático
|
||||||
|
* - JsonLogic: Condicional
|
||||||
|
*
|
||||||
|
* Ejemplo: Solo afecta visual si plan es premium
|
||||||
|
* {
|
||||||
|
* "==": [{ "var": "attributes.plan" }, "premium"]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
affectsVisual?: boolean | JsonLogic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si afecta al precio
|
||||||
|
*/
|
||||||
|
affectsPrice?: boolean | JsonLogic;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Si es de solo lectura
|
||||||
|
*/
|
||||||
|
readonly?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orden de visualización
|
||||||
|
*/
|
||||||
|
order?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icono
|
||||||
|
*/
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Texto de ayuda
|
||||||
|
*/
|
||||||
|
helpText?: I18nString;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BASE ATTRIBUTE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface BaseAttribute {
|
||||||
|
id: AttrID;
|
||||||
|
name: I18nString;
|
||||||
|
description: I18nString;
|
||||||
|
type: AttributeType;
|
||||||
|
category?: AttributeCategory;
|
||||||
|
display: AttributeDisplay;
|
||||||
|
metadata?: Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttributeType = 'fixed' | 'dynamic' | 'quantifiable' | 'computed';
|
||||||
|
export type AttributeCategory = 'physical' | 'aesthetic' | 'functional' | 'structural' | 'technical' | 'financial';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ATTRIBUTE TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FixedAttribute extends BaseAttribute {
|
||||||
|
type: 'fixed';
|
||||||
|
dataType: DataType;
|
||||||
|
value: Value;
|
||||||
|
unit?: I18nString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DynamicAttribute extends BaseAttribute {
|
||||||
|
type: 'dynamic';
|
||||||
|
dataType: 'reference';
|
||||||
|
defaultValue: ID<'op'>;
|
||||||
|
options: AttributeOption[];
|
||||||
|
required?: boolean;
|
||||||
|
isController?: boolean;
|
||||||
|
controls?: ID<'sc'>[];
|
||||||
|
filterExpression?: JsonLogic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuantifiableAttribute extends BaseAttribute {
|
||||||
|
type: 'quantifiable';
|
||||||
|
dataType: 'number';
|
||||||
|
quantity: QuantityDefinition;
|
||||||
|
unit?: I18nString;
|
||||||
|
userConfigurable?: boolean;
|
||||||
|
pricePerUnit?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComputedAttribute extends BaseAttribute {
|
||||||
|
type: 'computed';
|
||||||
|
dataType: DataType;
|
||||||
|
unit?: I18nString;
|
||||||
|
expression: JsonLogic;
|
||||||
|
dependencies: ID<'at'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Attribute =
|
||||||
|
| FixedAttribute
|
||||||
|
| DynamicAttribute
|
||||||
|
| QuantifiableAttribute
|
||||||
|
| ComputedAttribute;
|
||||||
|
|
||||||
|
export interface AttributeOption {
|
||||||
|
optionId: OptionID;
|
||||||
|
priority?: number;
|
||||||
|
pricingOverride?: number;
|
||||||
|
metadata?: Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuantityDefinition =
|
||||||
|
| number
|
||||||
|
| { min: number; max: number; step?: number; default: number }
|
||||||
|
| { values: number[]; default: number };
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VISUAL RENDERING (Con Async Support)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SectionVisualConfig {
|
||||||
|
strategy: RenderingStrategy;
|
||||||
|
|
||||||
|
/** Template para URL estática */
|
||||||
|
imageUrlTemplate?: string;
|
||||||
|
|
||||||
|
/** Atributos globales que afectan la imagen */
|
||||||
|
globalAttributeDependencies?: AttrID[];
|
||||||
|
|
||||||
|
/** Atributos de sección que afectan la imagen */
|
||||||
|
sectionAttributeDependencies?: AttrID[];
|
||||||
|
|
||||||
|
/** Ruta base */
|
||||||
|
basePath?: string;
|
||||||
|
|
||||||
|
/** ✅ NUEVO: Config para generación async via API */
|
||||||
|
apiConfig?: {
|
||||||
|
endpoint: string;
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
params?: AttrID[];
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Config 3D */
|
||||||
|
threeDConfig?: {
|
||||||
|
modelUrl: string;
|
||||||
|
textureAttributes: Array<{
|
||||||
|
attributeId: AttrID;
|
||||||
|
scope: 'global' | 'section';
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ✅ NUEVO: Imagen de fallback si falla generación */
|
||||||
|
fallbackImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SECTION & OBJECT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
id: SectionID;
|
||||||
|
name: I18nString;
|
||||||
|
description: I18nString;
|
||||||
|
attributes: Attribute[];
|
||||||
|
visualConfig: SectionVisualConfig;
|
||||||
|
availability: SectionAvailability;
|
||||||
|
order?: number;
|
||||||
|
icon?: string;
|
||||||
|
metadata?: Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionAvailability {
|
||||||
|
mode: 'required' | 'optional' | 'conditional';
|
||||||
|
condition?: JsonLogic;
|
||||||
|
dependsOn?: ID<'attr'>[];
|
||||||
|
onDeactivate?: {
|
||||||
|
action: 'clear' | 'preserve' | 'reset';
|
||||||
|
confirmWithUser?: boolean;
|
||||||
|
confirmMessage?: I18nString;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigurableObject {
|
||||||
|
id: ObjectID;
|
||||||
|
name: I18nString;
|
||||||
|
description: I18nString;
|
||||||
|
attributes: Attribute[];
|
||||||
|
sections: Record<SectionID, Section>;
|
||||||
|
sectionOrder?: ID<'sec'>[];
|
||||||
|
category?: string;
|
||||||
|
metadata?: Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// OPTION DEFINITION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface OptionDefinition {
|
||||||
|
id: OptionID;
|
||||||
|
name: I18nString;
|
||||||
|
description: I18nString;
|
||||||
|
media?: {
|
||||||
|
thumbnail?: string;
|
||||||
|
image?: string;
|
||||||
|
icon?: string;
|
||||||
|
gallery?: string[];
|
||||||
|
};
|
||||||
|
pricing?: {
|
||||||
|
baseAmount: number | 'consultation';
|
||||||
|
currency?: string;
|
||||||
|
dynamicExpression?: JsonLogic;
|
||||||
|
};
|
||||||
|
tags?: string[];
|
||||||
|
style?: string;
|
||||||
|
metadata?: Metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CATALOG
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ConfigurationCatalog {
|
||||||
|
name: I18nString;
|
||||||
|
description?: I18nString;
|
||||||
|
supportedLocales: string[];
|
||||||
|
defaultLocale: string;
|
||||||
|
options: Record<OptionID, OptionDefinition>;
|
||||||
|
objects: Record<ObjectID, ConfigurableObject>;
|
||||||
|
rules? : Record<RuleID , ValidationRule>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationRule {
|
||||||
|
id: RuleID;
|
||||||
|
name: I18nString;
|
||||||
|
condition: JsonLogic;
|
||||||
|
action: {
|
||||||
|
type: 'allow' | 'forbid' | 'require' | 'suggest';
|
||||||
|
targetAttribute: ID<'attr'>;
|
||||||
|
values: Value[];
|
||||||
|
};
|
||||||
|
priority: number;
|
||||||
|
bidirectional: boolean;
|
||||||
|
affects: AttrID[];
|
||||||
|
severity: 'error' | 'warning' | 'info';
|
||||||
|
message: I18nString;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CACHING UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ NUEVO: Cache para utilities
|
||||||
|
*/
|
||||||
|
export class AttributeCache {
|
||||||
|
private uiVisibleCache = new Map<ID<'attr'>, boolean>();
|
||||||
|
private visualAttributesCache: Attribute[] | null = null;
|
||||||
|
private priceAttributesCache: Attribute[] | null = null;
|
||||||
|
|
||||||
|
invalidate(): void {
|
||||||
|
this.uiVisibleCache.clear();
|
||||||
|
this.visualAttributesCache = null;
|
||||||
|
this.priceAttributesCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getUIVisible(
|
||||||
|
attrId: ID<'attr'>,
|
||||||
|
compute: () => boolean
|
||||||
|
): boolean {
|
||||||
|
if (this.uiVisibleCache.has(attrId)) {
|
||||||
|
return this.uiVisibleCache.get(attrId)!;
|
||||||
|
}
|
||||||
|
const result = compute();
|
||||||
|
this.uiVisibleCache.set(attrId, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getVisualAttributes(
|
||||||
|
compute: () => Attribute[]
|
||||||
|
): Attribute[] {
|
||||||
|
if (this.visualAttributesCache) {
|
||||||
|
return this.visualAttributesCache;
|
||||||
|
}
|
||||||
|
this.visualAttributesCache = compute();
|
||||||
|
return this.visualAttributesCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPriceAttributes(
|
||||||
|
compute: () => Attribute[]
|
||||||
|
): Attribute[] {
|
||||||
|
if (this.priceAttributesCache) {
|
||||||
|
return this.priceAttributesCache;
|
||||||
|
}
|
||||||
|
this.priceAttributesCache = compute();
|
||||||
|
return this.priceAttributesCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,189 @@
|
|||||||
|
// view.ts
|
||||||
|
/**
|
||||||
|
* ============================================================================
|
||||||
|
* CONFIGURATION VIEWS - Visual Representation
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* CONCEPTO:
|
||||||
|
*
|
||||||
|
* Una SECCIÓN puede tener MÚLTIPLES VISTAS
|
||||||
|
*
|
||||||
|
* Ejemplo: Sección "Salón"
|
||||||
|
* - Vista "Frontal": Muestra pared frontal con ventanas y muebles
|
||||||
|
* - Vista "Lateral": Muestra pared lateral con puerta
|
||||||
|
* - Vista "Cenital": Vista desde arriba, muestra distribución
|
||||||
|
* - Vista "360": Vista panorámica
|
||||||
|
*
|
||||||
|
* Cada vista tiene:
|
||||||
|
* - Su propia imagen base
|
||||||
|
* - Sus propios hotspots (puntos interactivos)
|
||||||
|
* - Configuración de cómo se genera la URL de imagen
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ID } from './primitives.ts';
|
||||||
|
import type { I18nString } from './i18n';
|
||||||
|
import type { JsonLogic } from "./json_logic.ts";
|
||||||
|
import type {Attribute, AttrID} from "./vce_model.ts";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VIEW TYPES (Tipos de Vista)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
export type ViewID = ID<'vw'>;
|
||||||
|
export type HotspotID = ID<'hs'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tipos de vista predefinidos
|
||||||
|
*/
|
||||||
|
type ViewType =
|
||||||
|
| 'front' // Vista frontal
|
||||||
|
| 'side' // Vista lateral
|
||||||
|
| 'rear' // Vista posterior
|
||||||
|
| 'top' // Vista desde arriba
|
||||||
|
| 'isometric' // Vista isométrica/3D
|
||||||
|
| 'panoramic' // Vista 360° panorámica
|
||||||
|
| 'detail' // Vista de detalle/zoom
|
||||||
|
| 'blueprint' // Plano/blueprint
|
||||||
|
| 'custom'; // Tipo personalizado
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HOTSPOT (Punto Interactivo)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punto interactivo en una vista
|
||||||
|
* Al hacer click, permite configurar un atributo específico
|
||||||
|
*/
|
||||||
|
export interface Hotspot {
|
||||||
|
/** ID único del hotspot */
|
||||||
|
id: HotspotID;
|
||||||
|
|
||||||
|
/** Nombre descriptivo */
|
||||||
|
name: I18nString;
|
||||||
|
|
||||||
|
/** Posición X en porcentaje (0-100) */
|
||||||
|
x: number;
|
||||||
|
|
||||||
|
/** Posición Y en porcentaje (0-100) */
|
||||||
|
y: number;
|
||||||
|
|
||||||
|
/** Radio del hotspot en píxeles */
|
||||||
|
radius?: number;
|
||||||
|
|
||||||
|
/** Color del hotspot (hex, rgb, etc.) */
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
/** Icono del hotspot */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** Tipo de scope al que referencia */
|
||||||
|
scopeType: 'global' | 'section';
|
||||||
|
|
||||||
|
/** ID del atributo que este hotspot controla */
|
||||||
|
targetAttributeId: AttrID;
|
||||||
|
|
||||||
|
/** Si es visible (puede ser condicional) */
|
||||||
|
visible?: boolean | JsonLogic;
|
||||||
|
|
||||||
|
/** Tooltip al hacer hover */
|
||||||
|
tooltip?: I18nString;
|
||||||
|
|
||||||
|
/** Orden z-index para superposición */
|
||||||
|
zIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VIEW (Vista Visual de una Sección)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vista visual de una sección
|
||||||
|
* Una sección puede tener múltiples vistas
|
||||||
|
*/
|
||||||
|
export interface SectionView {
|
||||||
|
/** ID único de la vista */
|
||||||
|
id: ViewID;
|
||||||
|
|
||||||
|
/** Nombre de la vista */
|
||||||
|
name: I18nString;
|
||||||
|
|
||||||
|
/** Descripción */
|
||||||
|
description?: I18nString;
|
||||||
|
|
||||||
|
/** Tipo de vista */
|
||||||
|
type: ViewType;
|
||||||
|
|
||||||
|
/** Configuración visual de esta vista */
|
||||||
|
visualConfig: ViewVisualConfig;
|
||||||
|
|
||||||
|
/** Hotspots interactivos (OPCIONAL) */
|
||||||
|
hotspots?: Hotspot[];
|
||||||
|
|
||||||
|
/** Orden de esta vista en la navegación */
|
||||||
|
order?: number;
|
||||||
|
|
||||||
|
/** Si es la vista por defecto para esta sección */
|
||||||
|
isDefault?: boolean;
|
||||||
|
|
||||||
|
/** Icono para el selector de vistas */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** Si está visible (puede ser condicional) */
|
||||||
|
visible?: boolean | JsonLogic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración visual específica de una vista
|
||||||
|
*/
|
||||||
|
export interface ViewVisualConfig {
|
||||||
|
/** Estrategia de renderizado */
|
||||||
|
strategy: RenderingStrategy;
|
||||||
|
|
||||||
|
/** Template para URL de imagen */
|
||||||
|
imageUrlTemplate?: string;
|
||||||
|
|
||||||
|
/** Ruta base de imágenes */
|
||||||
|
basePath?: string;
|
||||||
|
|
||||||
|
/** Atributos globales que afectan a esta vista */
|
||||||
|
globalAttributeDependencies?: AttrID[];
|
||||||
|
|
||||||
|
/** Atributos de sección que afectan a esta vista */
|
||||||
|
sectionAttributeDependencies?: AttrID[];
|
||||||
|
|
||||||
|
/** Imagen de fallback */
|
||||||
|
fallbackImage?: string;
|
||||||
|
|
||||||
|
/** Config para API */
|
||||||
|
apiConfig?: {
|
||||||
|
endpoint: string;
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
params?: AttrID[];
|
||||||
|
timeout?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Config 3D */
|
||||||
|
threeDConfig?: {
|
||||||
|
modelUrl: string;
|
||||||
|
cameraPosition?: { x: number; y: number; z: number };
|
||||||
|
textureAttributes?: Array<{
|
||||||
|
attributeId: AttrID;
|
||||||
|
scope: 'global' | 'section';
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderingStrategy =
|
||||||
|
| 'static_image'
|
||||||
|
| 'dynamic_image'
|
||||||
|
| 'api_generated'
|
||||||
|
| 'three_d'
|
||||||
|
| 'composite_layers'
|
||||||
|
| 'none';
|
||||||
|
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/types/*": ["types/*"],
|
||||||
|
"@/utils/*": ["utils/*"],
|
||||||
|
"@/engine/*": ["engine/*"],
|
||||||
|
"@/constants/*": ["constants/*"],
|
||||||
|
"@/*": ["*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// ✅ Opciones CRÍTICAS para usar .ts en imports:
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true, // Obligatorio con la opción anterior
|
||||||
|
"module": "esnext", // Cambiado de nodenext a esnext
|
||||||
|
"moduleResolution": "bundler", // Permite resoluciones modernas tipo Vite/Bun
|
||||||
|
|
||||||
|
"target": "esnext",
|
||||||
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Reference in new issue