diff --git a/index.html b/index.html
new file mode 100644
index 0000000..4e2e969
--- /dev/null
+++ b/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+ VICE — Catalog Editor
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/package.json b/package.json
index 85898fe..da7c5c1 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,8 @@
"description": "",
"main": "dist/index.js",
"scripts": {
- "build": "tsc"
+ "build": "tsc",
+ "dev": "vite dev"
},
"keywords": [
"configuration",
@@ -13,18 +14,27 @@
],
"author": "ACTIVE THING",
"license": "EULA",
+
"dependencies": {
- "svelte": "^5.53.0"
+ "svelte": "^5.53.0",
+ "@tailwindcss/vite": "^4.1.18"
},
+
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4",
+ "@tailwindcss/postcss": "^4.1.18",
+ "@tsconfig/svelte": "^5.0.6",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@types/node": "^25.2.3",
"jsdom": "^28.1.0",
"ts-node": "^10.9.2",
+ "autoprefixer": "^10.4.23",
+ "postcss": "^8.5.6",
+ "tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
+ "vite-plugin-singlefile": "^2.3.0",
"vitest": "^4.0.18"
},
"private": true
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..516702f
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ '@tailwindcss/postcss': {},
+ autoprefixer: {},
+ },
+}
\ No newline at end of file
diff --git a/src/editor/App.svelte b/src/editor/App.svelte
new file mode 100644
index 0000000..cf19eb1
--- /dev/null
+++ b/src/editor/App.svelte
@@ -0,0 +1,25 @@
+
+
+
+
+
+
diff --git a/src/editor/app.css b/src/editor/app.css
new file mode 100644
index 0000000..7ebf2a8
--- /dev/null
+++ b/src/editor/app.css
@@ -0,0 +1,7 @@
+@import "tailwindcss";
+
+
+/* Smooth scroll para navegación */
+html {
+ scroll-behavior: smooth;
+}
\ No newline at end of file
diff --git a/src/editor/components/layout/EditorLayout.svelte b/src/editor/components/layout/EditorLayout.svelte
new file mode 100644
index 0000000..729180a
--- /dev/null
+++ b/src/editor/components/layout/EditorLayout.svelte
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/editor/components/layout/Sidebar.svelte b/src/editor/components/layout/Sidebar.svelte
new file mode 100644
index 0000000..febcead
--- /dev/null
+++ b/src/editor/components/layout/Sidebar.svelte
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ VICE
+ |
+
+ {editor.catalog.name.es || 'Nuevo catálogo'}
+
+ {#if editor.isDirty}
+ ● Sin guardar
+ {/if}
+
+
+
+
+
+
+ {#if !editor.validation.valid}
+
+
+ {editor.validation.errors.length} error{editor.validation.errors.length !== 1 ? 'es' : ''}
+
+ {:else}
+
+
+ Válido
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/editor/components/layout/TopBar.svelte b/src/editor/components/layout/TopBar.svelte
new file mode 100644
index 0000000..febcead
--- /dev/null
+++ b/src/editor/components/layout/TopBar.svelte
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+ VICE
+ |
+
+ {editor.catalog.name.es || 'Nuevo catálogo'}
+
+ {#if editor.isDirty}
+ ● Sin guardar
+ {/if}
+
+
+
+
+
+
+ {#if !editor.validation.valid}
+
+
+ {editor.validation.errors.length} error{editor.validation.errors.length !== 1 ? 'es' : ''}
+
+ {:else}
+
+
+ Válido
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/editor/engines/id-generator.ts b/src/editor/engines/id-generator.ts
new file mode 100644
index 0000000..a146bd2
--- /dev/null
+++ b/src/editor/engines/id-generator.ts
@@ -0,0 +1,28 @@
+/**
+ * ============================================================================
+ * ID GENERATOR
+ * ============================================================================
+ *
+ * Genera IDs únicos con el prefijo correcto para cada entidad.
+ * Formato: prefix:timestamp_random
+ * Ejemplo: op:1709123456_a3f2
+ */
+
+export function generateId(prefix: 'op' | 'at' | 'sc' | 'ob' | 'rl' | 'ct' | 'vw'): string {
+ const timestamp = Date.now().toString(36);
+ const random = Math.random().toString(36).slice(2, 6);
+ return `${prefix}:${timestamp}_${random}`;
+}
+
+/**
+ * Genera un code limpio a partir de un nombre.
+ * 'Suelo Parquet' → 'suelo_parquet'
+ */
+export function generateCode(name: string): string {
+ return name
+ .toLowerCase()
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '') // eliminar acentos
+ .replace(/[^a-z0-9]+/g, '_')
+ .replace(/^_|_$/g, '');
+}
\ No newline at end of file
diff --git a/src/editor/engines/validator.ts b/src/editor/engines/validator.ts
new file mode 100644
index 0000000..423f639
--- /dev/null
+++ b/src/editor/engines/validator.ts
@@ -0,0 +1,209 @@
+/**
+ * ============================================================================
+ * CATALOG VALIDATOR
+ * ============================================================================
+ *
+ * Valida un EditorCatalog y devuelve errores y warnings con path.
+ */
+
+import type {
+ EditorCatalog,
+ EditorOption,
+ EditorObject,
+ EditorSection,
+ EditorAttribute,
+ EditorRule,
+ ValidationResult,
+ ValidationError,
+} from '../types/editor';
+
+export function validateCatalog(catalog: EditorCatalog): ValidationResult {
+ const errors : ValidationError[] = [];
+ const warnings: ValidationError[] = [];
+
+ const push = (severity: 'error' | 'warning', path: string, message: string) => {
+ (severity === 'error' ? errors : warnings).push({ path, message, severity });
+ };
+
+ // -------------------------------------------------------------------------
+ // Catálogo
+ // -------------------------------------------------------------------------
+ if (!catalog.code) push('error', 'catalog.code', 'El catálogo necesita un código');
+ if (!catalog.name.es) push('error', 'catalog.name.es', 'El catálogo necesita nombre en español');
+ if (!catalog.basePath) push('warning', 'catalog.basePath', 'Sin basePath — las imágenes no se resolverán');
+ if (catalog.objects.length === 0) push('warning', 'catalog.objects', 'El catálogo no tiene objetos');
+ if (catalog.options.length === 0) push('warning', 'catalog.options', 'El catálogo no tiene opciones');
+
+ // -------------------------------------------------------------------------
+ // Opciones
+ // -------------------------------------------------------------------------
+ const optionIds = new Set(catalog.options.map(o => o.id));
+ const optionCodes = new Set();
+
+ for (const opt of catalog.options) {
+ const p = `options.${opt.id}`;
+ if (!opt.code) push('error', `${p}.code`, `Opción "${opt.id}" sin código`);
+ if (!opt.name.es) push('error', `${p}.name.es`, `Opción "${opt.code || opt.id}" sin nombre`);
+
+ if (opt.code && optionCodes.has(opt.code)) {
+ push('error', `${p}.code`, `Código de opción duplicado: "${opt.code}"`);
+ }
+ if (opt.code) optionCodes.add(opt.code);
+
+ if (opt.pricing.type === 'fixed' && opt.pricing.baseAmount < 0) {
+ push('warning', `${p}.pricing`, `Precio negativo en opción "${opt.code}"`);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Objetos
+ // -------------------------------------------------------------------------
+ const objectCodes = new Set();
+
+ for (const obj of catalog.objects) {
+ const p = `objects.${obj.id}`;
+ if (!obj.code) push('error', `${p}.code`, `Objeto "${obj.id}" sin código`);
+ if (!obj.name.es) push('error', `${p}.name.es`, `Objeto "${obj.code || obj.id}" sin nombre`);
+
+ if (obj.code && objectCodes.has(obj.code)) {
+ push('error', `${p}.code`, `Código de objeto duplicado: "${obj.code}"`);
+ }
+ if (obj.code) objectCodes.add(obj.code);
+
+ if (obj.sections.length === 0) {
+ push('warning', `${p}.sections`, `Objeto "${obj.code}" sin secciones`);
+ }
+
+ // Secciones
+ for (const sec of obj.sections) {
+ validateSection(sec, `${p}.sections.${sec.id}`, optionIds, push);
+ }
+
+ // Atributos globales
+ for (const attr of obj.attributes) {
+ validateAttribute(attr, `${p}.attributes.${attr.id}`, optionIds, push);
+ }
+
+ // sectionOrder coherente
+ for (const sid of obj.sectionOrder) {
+ if (!obj.sections.find(s => s.id === sid)) {
+ push('error', `${p}.sectionOrder`, `sectionOrder referencia sección inexistente: "${sid}"`);
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Reglas
+ // -------------------------------------------------------------------------
+ const allAttrIds = collectAllAttrIds(catalog);
+
+ for (const rule of catalog.rules) {
+ const p = `rules.${rule.id}`;
+ if (!rule.name.es) push('warning', `${p}.name`, `Regla "${rule.id}" sin nombre`);
+ if (!rule.message.es) push('warning', `${p}.message`, `Regla "${rule.id}" sin mensaje`);
+
+ // Validar que los atributos referenciados existen
+ const condAttrId = extractConditionAttrId(rule.condition);
+ if (condAttrId && !allAttrIds.has(condAttrId)) {
+ push('error', `${p}.condition`, `Condición referencia atributo inexistente: "${condAttrId}"`);
+ }
+ if (!allAttrIds.has(rule.action.targetAttrId)) {
+ push('error', `${p}.action.targetAttr`, `Acción referencia atributo inexistente: "${rule.action.targetAttrId}"`);
+ }
+
+ // Validar que las opciones referenciadas existen
+ for (const optId of rule.action.values) {
+ if (!optionIds.has(optId)) {
+ push('error', `${p}.action.values`, `Acción referencia opción inexistente: "${optId}"`);
+ }
+ }
+ }
+
+ return {
+ valid: errors.length === 0,
+ errors,
+ warnings,
+ };
+}
+
+// ============================================================================
+// HELPERS
+// ============================================================================
+
+function validateSection(
+ sec : EditorSection,
+ path: string,
+ optionIds: Set,
+ push: (severity: 'error' | 'warning', path: string, message: string) => void,
+): void {
+ if (!sec.code) push('error', `${path}.code`, `Sección "${sec.id}" sin código`);
+ if (!sec.name.es) push('error', `${path}.name.es`, `Sección "${sec.code || sec.id}" sin nombre`);
+
+ if (sec.kind === 'visual') {
+ if (sec.views.length === 0) {
+ push('warning', `${path}.views`, `Sección visual "${sec.code}" sin vistas`);
+ }
+ if (!sec.defaultView) {
+ push('warning', `${path}.defaultView`, `Sección "${sec.code}" sin defaultView`);
+ }
+ for (const view of sec.views) {
+ if (!view.template) {
+ push('warning', `${path}.views.${view.id}.template`, `Vista "${view.code}" sin template`);
+ }
+ }
+ }
+
+ if (sec.attributes.length === 0) {
+ push('warning', `${path}.attributes`, `Sección "${sec.code}" sin atributos`);
+ }
+
+ for (const attr of sec.attributes) {
+ validateAttribute(attr, `${path}.attributes.${attr.id}`, optionIds, push);
+ }
+}
+
+function validateAttribute(
+ attr : EditorAttribute,
+ path : string,
+ optionIds: Set,
+ push : (severity: 'error' | 'warning', path: string, message: string) => void,
+): void {
+ if (!attr.code) push('error', `${path}.code`, `Atributo "${attr.id}" sin código`);
+ if (!attr.name.es) push('error', `${path}.name.es`, `Atributo "${attr.code || attr.id}" sin nombre`);
+
+ if (attr.type === 'dynamic') {
+ if (attr.options.length === 0) {
+ push('warning', `${path}.options`, `Atributo "${attr.code}" sin opciones`);
+ }
+ for (const attrOpt of attr.options) {
+ if (!optionIds.has(attrOpt.optionId)) {
+ push('error', `${path}.options`, `Atributo "${attr.code}" referencia opción inexistente: "${attrOpt.optionId}"`);
+ }
+ }
+ if (attr.defaultValue && !attr.options.some(o => o.optionId === attr.defaultValue)) {
+ push('error', `${path}.defaultValue`, `Atributo "${attr.code}": defaultValue no está en las opciones`);
+ }
+ }
+}
+
+function collectAllAttrIds(catalog: EditorCatalog): Set {
+ const ids = new Set();
+ for (const obj of catalog.objects) {
+ for (const attr of obj.attributes) ids.add(attr.id);
+ for (const sec of obj.sections) {
+ for (const attr of sec.attributes) ids.add(attr.id);
+ }
+ }
+ return ids;
+}
+
+function extractConditionAttrId(condition: EditorRule['condition']): string | null {
+ if (condition.type === 'attribute_equals' || condition.type === 'attribute_in') {
+ return condition.attrId;
+ }
+ if (condition.type === 'and' || condition.type === 'or') {
+ return extractConditionAttrId(condition.conditions[0]);
+ }
+ return null;
+}
+
diff --git a/src/editor/main.ts b/src/editor/main.ts
new file mode 100644
index 0000000..266660e
--- /dev/null
+++ b/src/editor/main.ts
@@ -0,0 +1,17 @@
+/**
+ * ============================================================================
+ * VICE Catalog Editor — Entry Point
+ * ============================================================================
+ */
+
+import { mount } from 'svelte';
+import App from './App.svelte';
+
+const app = mount(App, {
+ target: document.getElementById('app')!
+});
+
+
+
+export default app;
+
diff --git a/src/editor/stores/editor.svelte.ts b/src/editor/stores/editor.svelte.ts
new file mode 100644
index 0000000..8a21c3d
--- /dev/null
+++ b/src/editor/stores/editor.svelte.ts
@@ -0,0 +1,587 @@
+/**
+ * ============================================================================
+ * EDITOR STORE — Svelte 5 (Runas)
+ * ============================================================================
+ *
+ * Estado central del editor de catálogos.
+ * Gestiona la navegación entre secciones del editor, el catálogo en edición,
+ * validación en tiempo real y exportación a JSON.
+ */
+
+import { generateId } from '../engines/id-generator.ts';
+import { validateCatalog } from '../engines/validator.ts';
+import type {
+ EditorCatalog,
+ EditorObject,
+ EditorSection,
+ EditorAttribute,
+ EditorOption,
+ EditorRule,
+ ValidationResult,
+ EditorPanel,
+} from '../types/editor';
+
+// ============================================================================
+// EDITOR STORE
+// ============================================================================
+
+export class EditorStore {
+
+ // -------------------------------------------------------------------------
+ // Navegación — panel activo
+ // -------------------------------------------------------------------------
+
+ /** Panel activo en el editor */
+ activePanel = $state('catalog');
+
+ /** ID del objeto activo en el editor de objetos */
+ activeObjectId = $state(null);
+
+ /** ID de la sección activa en el editor de secciones */
+ activeSectionId = $state(null);
+
+ /** ID del atributo activo */
+ activeAttributeId = $state(null);
+
+ /** ID de la opción activa en la librería */
+ activeOptionId = $state(null);
+
+ /** ID de la regla activa */
+ activeRuleId = $state(null);
+
+ // -------------------------------------------------------------------------
+ // Catálogo en edición
+ // -------------------------------------------------------------------------
+
+ catalog = $state(this.#emptyCatalog());
+
+ // -------------------------------------------------------------------------
+ // Estado del editor
+ // -------------------------------------------------------------------------
+
+ /** ¿Hay cambios sin guardar? */
+ isDirty = $state(false);
+
+ /** Resultado de la última validación */
+ validation = $state({ valid: true, errors: [], warnings: [] });
+
+ /** ¿Está el panel de preview abierto? */
+ previewOpen = $state(false);
+
+ /** ¿Está el panel de export abierto? */
+ exportOpen = $state(false);
+
+ // -------------------------------------------------------------------------
+ // Derived — navegación
+ // -------------------------------------------------------------------------
+
+ activeObject = $derived(
+ this.activeObjectId
+ ? this.catalog.objects.find(o => o.id === this.activeObjectId) ?? null
+ : null
+ );
+
+ activeSection = $derived(
+ this.activeObject && this.activeSectionId
+ ? this.activeObject.sections.find(s => s.id === this.activeSectionId) ?? null
+ : null
+ );
+
+ activeAttribute = $derived(
+ this.activeSection && this.activeAttributeId
+ ? this.activeSection.attributes.find(a => a.id === this.activeAttributeId) ?? null
+ : null
+ );
+
+ activeOption = $derived(
+ this.activeOptionId
+ ? this.catalog.options.find(o => o.id === this.activeOptionId) ?? null
+ : null
+ );
+
+ activeRule = $derived(
+ this.activeRuleId
+ ? this.catalog.rules.find(r => r.id === this.activeRuleId) ?? null
+ : null
+ );
+
+ /** Opciones disponibles para usar en atributos — toda la librería */
+ optionLibrary = $derived(this.catalog.options);
+
+ /** ¿El catálogo tiene suficiente información para previsualizarse? */
+ canPreview = $derived(
+ this.catalog.objects.length > 0 &&
+ this.catalog.options.length > 0
+ );
+
+ /** JSON exportable del catálogo */
+ exportJson = $derived(
+ this.exportOpen ? this.#buildExportJson() : null
+ );
+
+ // -------------------------------------------------------------------------
+ // Navegación — API pública
+ // -------------------------------------------------------------------------
+
+ setPanel(panel: EditorPanel): void {
+ this.activePanel = panel;
+ this.activeSectionId = null;
+ this.activeAttributeId = null;
+ }
+
+ setActiveObject(id: string | null): void {
+ this.activeObjectId = id;
+ this.activeSectionId = null;
+ this.activeAttributeId = null;
+ }
+
+ setActiveSection(id: string | null): void {
+ this.activeSectionId = id;
+ this.activeAttributeId = null;
+ }
+
+ setActiveAttribute(id: string | null): void {
+ this.activeAttributeId = id;
+ }
+
+ setActiveOption(id: string | null): void {
+ this.activeOptionId = id;
+ }
+
+ setActiveRule(id: string | null): void {
+ this.activeRuleId = id;
+ }
+
+ togglePreview(): void { this.previewOpen = !this.previewOpen; }
+ toggleExport(): void {
+ this.exportOpen = !this.exportOpen;
+ if (this.exportOpen) this.validate();
+ }
+
+ // -------------------------------------------------------------------------
+ // Catálogo — metadatos
+ // -------------------------------------------------------------------------
+
+ updateCatalogMeta(patch: Partial>): void {
+ Object.assign(this.catalog, patch);
+ this.#markDirty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Opciones — CRUD
+ // -------------------------------------------------------------------------
+
+ addOption(): EditorOption {
+ const option: EditorOption = {
+ id : generateId('op'),
+ code : '',
+ name : { es: '', en: '' },
+ description: { es: '', en: '' },
+ pricing : { type: 'fixed', baseAmount: 0 },
+ tags : [],
+ };
+ this.catalog.options.push(option);
+ this.activeOptionId = option.id;
+ this.#markDirty();
+ return option;
+ }
+
+ updateOption(id: string, patch: Partial): void {
+ const idx = this.catalog.options.findIndex(o => o.id === id);
+ if (idx === -1) return;
+ Object.assign(this.catalog.options[idx], patch);
+ this.#markDirty();
+ }
+
+ deleteOption(id: string): void {
+ this.catalog.options = this.catalog.options.filter(o => o.id !== id);
+ if (this.activeOptionId === id) this.activeOptionId = null;
+ this.#markDirty();
+ }
+
+ duplicateOption(id: string): void {
+ const src = this.catalog.options.find(o => o.id === id);
+ if (!src) return;
+ const copy: EditorOption = {
+ ...structuredClone(src),
+ id : generateId('op'),
+ code: src.code + '_copy',
+ name: { es: src.name.es + ' (copia)', en: src.name.en + ' (copy)' },
+ };
+ this.catalog.options.push(copy);
+ this.activeOptionId = copy.id;
+ this.#markDirty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Objetos — CRUD
+ // -------------------------------------------------------------------------
+
+ addObject(): EditorObject {
+ const object: EditorObject = {
+ id : generateId('ob'),
+ code : '',
+ name : { es: '', en: '' },
+ description : { es: '', en: '' },
+ category : '',
+ basePath : '',
+ sections : [],
+ sectionOrder: [],
+ attributes : [],
+ };
+ this.catalog.objects.push(object);
+ this.activeObjectId = object.id;
+ this.activeSectionId = null;
+ this.#markDirty();
+ return object;
+ }
+
+ updateObject(id: string, patch: Partial): void {
+ const obj = this.catalog.objects.find(o => o.id === id);
+ if (!obj) return;
+ Object.assign(obj, patch);
+ this.#markDirty();
+ }
+
+ deleteObject(id: string): void {
+ this.catalog.objects = this.catalog.objects.filter(o => o.id !== id);
+ if (this.activeObjectId === id) {
+ this.activeObjectId = null;
+ this.activeSectionId = null;
+ }
+ this.#markDirty();
+ }
+
+ reorderSections(objectId: string, order: string[]): void {
+ const obj = this.catalog.objects.find(o => o.id === objectId);
+ if (!obj) return;
+ obj.sectionOrder = order;
+ obj.sections = order
+ .map(id => obj.sections.find(s => s.id === id))
+ .filter((s): s is EditorSection => s !== undefined);
+ this.#markDirty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Secciones — CRUD
+ // -------------------------------------------------------------------------
+
+ addSection(objectId: string): EditorSection {
+ const obj = this.catalog.objects.find(o => o.id === objectId);
+ if (!obj) throw new Error(`Object ${objectId} not found`);
+
+ const section: EditorSection = {
+ id : generateId('sc'),
+ code : '',
+ kind : 'visual',
+ name : { es: '', en: '' },
+ description: { es: '', en: '' },
+ attributes : [],
+ views : [],
+ defaultView: null,
+ availability: { mode: 'required' },
+ };
+
+ obj.sections.push(section);
+ obj.sectionOrder.push(section.id);
+ this.activeSectionId = section.id;
+ this.#markDirty();
+ return section;
+ }
+
+ updateSection(objectId: string, sectionId: string, patch: Partial): void {
+ const section = this.#findSection(objectId, sectionId);
+ if (!section) return;
+ Object.assign(section, patch);
+ this.#markDirty();
+ }
+
+ deleteSection(objectId: string, sectionId: string): void {
+ const obj = this.catalog.objects.find(o => o.id === objectId);
+ if (!obj) return;
+ obj.sections = obj.sections.filter(s => s.id !== sectionId);
+ obj.sectionOrder = obj.sectionOrder.filter(id => id !== sectionId);
+ if (this.activeSectionId === sectionId) this.activeSectionId = null;
+ this.#markDirty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Atributos — CRUD
+ // -------------------------------------------------------------------------
+
+ addAttribute(objectId: string, sectionId: string): EditorAttribute {
+ const section = this.#findSection(objectId, sectionId);
+ if (!section) throw new Error(`Section ${sectionId} not found`);
+
+ const attr: EditorAttribute = {
+ id : generateId('at'),
+ code : '',
+ name : { es: '', en: '' },
+ description : { es: '', en: '' },
+ type : 'dynamic',
+ dataType : 'reference',
+ defaultValue: null,
+ options : [],
+ display : {
+ uiVisible : true,
+ affectsVisual: true,
+ affectsPrice : true,
+ },
+ };
+
+ section.attributes.push(attr);
+ this.activeAttributeId = attr.id;
+ this.#markDirty();
+ return attr;
+ }
+
+ updateAttribute(objectId: string, sectionId: string, attrId: string, patch: Partial): void {
+ const section = this.#findSection(objectId, sectionId);
+ const attr = section?.attributes.find(a => a.id === attrId);
+ if (!attr) return;
+ Object.assign(attr, patch);
+ this.#markDirty();
+ }
+
+ deleteAttribute(objectId: string, sectionId: string, attrId: string): void {
+ const section = this.#findSection(objectId, sectionId);
+ if (!section) return;
+ section.attributes = section.attributes.filter(a => a.id !== attrId);
+ if (this.activeAttributeId === attrId) this.activeAttributeId = null;
+ this.#markDirty();
+ }
+
+ /** Añade una opción de la librería a un atributo */
+ addOptionToAttribute(objectId: string, sectionId: string, attrId: string, optionId: string): void {
+ const section = this.#findSection(objectId, sectionId);
+ const attr = section?.attributes.find(a => a.id === attrId);
+ if (!attr) return;
+ if (attr.options.some(o => o.optionId === optionId)) return; // ya existe
+ attr.options.push({
+ optionId,
+ priority : attr.options.length + 1,
+ quantity : null,
+ dynamicExpression: null,
+ });
+ this.#markDirty();
+ }
+
+ removeOptionFromAttribute(objectId: string, sectionId: string, attrId: string, optionId: string): void {
+ const section = this.#findSection(objectId, sectionId);
+ const attr = section?.attributes.find(a => a.id === attrId);
+ if (!attr) return;
+ attr.options = attr.options.filter(o => o.optionId !== optionId);
+ this.#markDirty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Reglas — CRUD
+ // -------------------------------------------------------------------------
+
+ addRule(): EditorRule {
+ const rule: EditorRule = {
+ id : generateId('rl'),
+ name : { es: '', en: '' },
+ condition: { type: 'attribute_equals', attrId: '', value: '' },
+ action : { type: 'forbid', targetAttrId: '', values: [] },
+ severity : 'error',
+ priority : this.catalog.rules.length + 1,
+ message : { es: '', en: '' },
+ };
+ this.catalog.rules.push(rule);
+ this.activeRuleId = rule.id;
+ this.#markDirty();
+ return rule;
+ }
+
+ updateRule(id: string, patch: Partial): void {
+ const rule = this.catalog.rules.find(r => r.id === id);
+ if (!rule) return;
+ Object.assign(rule, patch);
+ this.#markDirty();
+ }
+
+ deleteRule(id: string): void {
+ this.catalog.rules = this.catalog.rules.filter(r => r.id !== id);
+ if (this.activeRuleId === id) this.activeRuleId = null;
+ this.#markDirty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Validación y exportación
+ // -------------------------------------------------------------------------
+
+ validate(): ValidationResult {
+ this.validation = validateCatalog(this.catalog);
+ return this.validation;
+ }
+
+ exportToJson(): string {
+ this.validate();
+ return JSON.stringify(this.#buildExportJson(), null, 2);
+ }
+
+ /** Importa un catálogo desde JSON */
+ importFromJson(json: string): void {
+ try {
+ const parsed = JSON.parse(json);
+ this.catalog = parsed as EditorCatalog;
+ this.validate();
+ this.isDirty = false;
+ } catch (e) {
+ this.validation = {
+ valid : false,
+ errors : [{ severity: 'error', path: 'root', message: 'JSON inválido' }],
+ warnings: [],
+ };
+ }
+ }
+
+ /** Resetea el editor a un catálogo vacío */
+ reset(): void {
+ this.catalog = this.#emptyCatalog();
+ this.activeObjectId = null;
+ this.activeSectionId = null;
+ this.activeAttributeId = null;
+ this.activeOptionId = null;
+ this.activeRuleId = null;
+ this.activePanel = 'catalog';
+ this.isDirty = false;
+ this.validation = { valid: true, errors: [], warnings: [] };
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers privados
+ // -------------------------------------------------------------------------
+
+ #findSection(objectId: string, sectionId: string): EditorSection | undefined {
+ return this.catalog.objects
+ .find(o => o.id === objectId)
+ ?.sections.find(s => s.id === sectionId);
+ }
+
+ #markDirty(): void {
+ this.isDirty = true;
+ // Validación incremental en background
+ this.validation = validateCatalog(this.catalog);
+ }
+
+ #emptyCatalog(): EditorCatalog {
+ return {
+ id : generateId('ct'),
+ code : '',
+ name : { es: '', en: '' },
+ description: { es: '', en: '' },
+ basePath : '/renders/',
+ options : [],
+ objects : [],
+ rules : [],
+ tax : null,
+ };
+ }
+
+ #buildExportJson(): object {
+ // Transforma el EditorCatalog al formato ConfigurationCatalog
+ const options: Record = {};
+ for (const opt of this.catalog.options) {
+ options[opt.id] = {
+ id : opt.id,
+ code : opt.code,
+ name : opt.name,
+ description: opt.description,
+ pricing : opt.pricing.type === 'fixed'
+ ? { baseAmount: opt.pricing.baseAmount }
+ : { baseAmount: 0, dynamicExpression: opt.pricing.expression },
+ tags : opt.tags,
+ };
+ }
+
+ const objects: Record = {};
+ for (const obj of this.catalog.objects) {
+ const sections: Record = {};
+ for (const sec of obj.sections) {
+ sections[sec.id] = {
+ kind : sec.kind,
+ id : sec.id,
+ code : sec.code,
+ name : sec.name,
+ description : sec.description,
+ attributes : sec.attributes.map(a => ({
+ id : a.id,
+ code : a.code,
+ name : a.name,
+ description : a.description,
+ type : a.type,
+ dataType : a.dataType,
+ defaultValue: a.defaultValue,
+ options : a.options,
+ display : a.display,
+ })),
+ views : Object.fromEntries(
+ sec.views.map(v => [v.id, v])
+ ),
+ defaultView : sec.defaultView,
+ availability: sec.availability,
+ };
+ }
+
+ objects[obj.id] = {
+ id : obj.id,
+ code : obj.code,
+ name : obj.name,
+ description : obj.description,
+ category : obj.category,
+ basePath : obj.basePath || this.catalog.basePath,
+ attributes : obj.attributes,
+ sections,
+ sectionOrder: obj.sectionOrder,
+ };
+ }
+
+ const rules: Record = {};
+ for (const rule of this.catalog.rules) {
+ rules[rule.id] = {
+ id : rule.id,
+ name : rule.name,
+ condition: this.#ruleConditionToJsonLogic(rule.condition),
+ action : {
+ type : rule.action.type,
+ targetAttr: rule.action.targetAttrId,
+ values : rule.action.values,
+ },
+ priority : rule.priority,
+ severity : rule.severity,
+ //affects : [rule.condition.attrId, rule.action.targetAttrId],
+ message : rule.message,
+ };
+ }
+
+ return {
+ id : this.catalog.id,
+ code : this.catalog.code,
+ name : this.catalog.name,
+ description: this.catalog.description,
+ basePath : this.catalog.basePath,
+ options,
+ objects,
+ rules,
+ ...(this.catalog.tax ? { tax: this.catalog.tax } : {}),
+ };
+ }
+
+ #ruleConditionToJsonLogic(condition: EditorRule['condition']): object {
+ switch (condition.type) {
+ case 'attribute_equals':
+ return { '==': [{ var: `attributes.${condition.attrId.replace(':', '_')}` }, condition.value] };
+ case 'attribute_in':
+ return { 'in': [{ var: `attributes.${condition.attrId.replace(':', '_')}` }, condition.values] };
+ case 'and':
+ return { 'and': condition.conditions.map(c => this.#ruleConditionToJsonLogic(c)) };
+ case 'or':
+ return { 'or': condition.conditions.map(c => this.#ruleConditionToJsonLogic(c)) };
+ default:
+ return {};
+ }
+ }
+}
+
diff --git a/src/editor/types/editor.ts b/src/editor/types/editor.ts
new file mode 100644
index 0000000..86dd244
--- /dev/null
+++ b/src/editor/types/editor.ts
@@ -0,0 +1,208 @@
+/**
+ * ============================================================================
+ * EDITOR TYPES
+ * ============================================================================
+ *
+ * Tipos del editor — versión "plana" del catálogo optimizada para edición.
+ * Arrays en lugar de Records para facilitar reordenación y CRUD.
+ * Se transforma a ConfigurationCatalog al exportar.
+ */
+
+export type EditorPanel = 'catalog' | 'options' | 'objects' | 'rules' | 'preview' | 'export';
+
+// ============================================================================
+// I18N
+// ============================================================================
+
+export interface I18nField {
+ es: string;
+ en: string;
+}
+
+// ============================================================================
+// PRICING
+// ============================================================================
+
+export type EditorPricing =
+ | { type: 'fixed'; baseAmount: number }
+ | { type: 'dynamic'; baseAmount: number; expression: object };
+
+// ============================================================================
+// OPTIONS
+// ============================================================================
+
+export interface EditorOption {
+ id : string;
+ code : string;
+ name : I18nField;
+ description: I18nField;
+ pricing : EditorPricing;
+ tags : string[];
+}
+
+// ============================================================================
+// ATTRIBUTE OPTION — referencia a opción en un atributo
+// ============================================================================
+
+export interface EditorAttributeOption {
+ optionId : string;
+ priority : number;
+ quantity : EditorQuantityConfig | null;
+ dynamicExpression: object | null;
+}
+
+export interface EditorQuantityConfig {
+ default: number;
+ min : number;
+ max : number;
+ step : number;
+ unit : I18nField;
+}
+
+// ============================================================================
+// ATTRIBUTES
+// ============================================================================
+
+export interface EditorAttribute {
+ id : string;
+ code : string;
+ name : I18nField;
+ description : I18nField;
+ type : 'dynamic' | 'fixed' | 'computed';
+ dataType : 'reference' | 'number' | 'string' | 'boolean';
+ defaultValue: string | number | null;
+ options : EditorAttributeOption[]; // solo si type === 'dynamic'
+ display : EditorDisplay;
+}
+
+export interface EditorDisplay {
+ uiVisible : boolean;
+ affectsVisual: boolean;
+ affectsPrice : boolean;
+ readonly? : boolean;
+}
+
+// ============================================================================
+// VIEWS
+// ============================================================================
+
+export type EditorVisualStrategy = 'static_image' | 'composite_layers' | 'api_generated' | 'three_d';
+
+export interface EditorView {
+ id : string;
+ code : string;
+ name : I18nField;
+ type : 'front' | 'panoramic' | 'detail';
+ strategy: EditorVisualStrategy;
+ template: string; // para static_image
+ basePath: string;
+ fallback: string;
+}
+
+// ============================================================================
+// SECTIONS
+// ============================================================================
+
+export interface EditorSection {
+ id : string;
+ code : string;
+ kind : 'visual' | 'config';
+ name : I18nField;
+ description : I18nField;
+ attributes : EditorAttribute[];
+ views : EditorView[];
+ defaultView : string | null;
+ availability: { mode: 'required' | 'optional' | 'conditional' };
+}
+
+// ============================================================================
+// OBJECTS
+// ============================================================================
+
+export interface EditorObject {
+ id : string;
+ code : string;
+ name : I18nField;
+ description : I18nField;
+ category : string;
+ basePath : string;
+ attributes : EditorAttribute[]; // atributos globales
+ sections : EditorSection[];
+ sectionOrder: string[];
+}
+
+// ============================================================================
+// RULES
+// ============================================================================
+
+export type EditorRuleActionType = 'forbid' | 'allow' | 'require' | 'suggest';
+export type EditorSeverity = 'error' | 'warning' | 'info';
+
+/**
+ * Condición del editor — árbol de condiciones simples.
+ * Se convierte a JsonLogic al exportar.
+ */
+export type EditorCondition =
+ | { type: 'attribute_equals'; attrId: string; value: string }
+ | { type: 'attribute_in'; attrId: string; values: string[] }
+ | { type: 'and'; conditions: EditorCondition[] }
+ | { type: 'or'; conditions: EditorCondition[] };
+
+export interface EditorRuleAction {
+ type : EditorRuleActionType;
+ targetAttrId: string;
+ values : string[];
+}
+
+export interface EditorRule {
+ id : string;
+ name : I18nField;
+ condition: EditorCondition;
+ action : EditorRuleAction;
+ severity : EditorSeverity;
+ priority : number;
+ message : I18nField;
+}
+
+// ============================================================================
+// TAX
+// ============================================================================
+
+export interface EditorTax {
+ rate : number;
+ included: boolean;
+ label : I18nField;
+}
+
+// ============================================================================
+// CATALOG
+// ============================================================================
+
+export interface EditorCatalog {
+ id : string;
+ code : string;
+ name : I18nField;
+ description: I18nField;
+ basePath : string;
+ options : EditorOption[];
+ objects : EditorObject[];
+ rules : EditorRule[];
+ tax : EditorTax | null;
+}
+
+// ============================================================================
+// VALIDATION
+// ============================================================================
+
+export interface ValidationError {
+ path : string; // ej: 'objects.ob_1.sections.sc_2.attributes.at_3'
+ message: string;
+ severity: 'error' | 'warning';
+}
+
+export interface ValidationResult {
+ valid : boolean;
+ errors : ValidationError[];
+ warnings: ValidationError[];
+}
+
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index 04bf26e..0000000
--- a/src/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-console.log('Happy developing ✨')
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..96b3455
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,8 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
+
+/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */
+export default {
+ // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
+ // for more information about preprocessors
+ preprocess: vitePreprocess(),
+}
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..31c18cf
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,21 @@
+{
+ "extends": "@tsconfig/svelte/tsconfig.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "types": ["svelte", "vite/client"],
+ "noEmit": true,
+ /**
+ * Typecheck JS in `.svelte` and `.js` files by default.
+ * Disable checkJs if you'd like to use dynamic types in JS.
+ * Note that setting allowJs false does not prevent the use
+ * of JS in `.svelte` files.
+ */
+ "allowJs": true,
+ "checkJs": true,
+ "moduleDetection": "force"
+ },
+ "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 1b00c10..5176913 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,6 +11,7 @@
"@/constants/*" : ["constants/*"],
"@/adapters/*" : ["adapters/*"],
"@/svelte/*" : ["adapters/svelte/*"],
+ "@/editor/*" : ["editor/*"],
"@/*" : ["*"]
},
diff --git a/vitest.config.ts b/vite.config.ts
similarity index 87%
rename from vitest.config.ts
rename to vite.config.ts
index f38c275..ce70c2a 100644
--- a/vitest.config.ts
+++ b/vite.config.ts
@@ -1,9 +1,11 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
import { svelte } from '@sveltejs/vite-plugin-svelte';
+import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
- plugins: [svelte()],
+ base: './',
+ plugins: [svelte(),tailwindcss()],
test: {
globals: true,
environment: 'node',