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