Author SHA1 Message Date
DavidSosaandClaude Opus 4.6 ed35d4e313 Refine hooks distinction, add assets/, rename twig-filters
- Clarify module hook vs file hook: when to use each, different call URLs
- Add schema path (cms/data/schema/) to reglas importantes
- Add assets/ and hook.php to module file structure
- Fix variable assignment example: use string syntax for get()
- Rename twig-filters.md → twig-reference.md (covers more than filters)
- Move Global Variables from modular-system.md to twig-reference.md
- Update all references in CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:42:15 +00:00
DavidSosaandClaude Opus 4.6 39661ad0da Remove duplicate rules in hooks-and-api.md
- FK naming: keep detail in Gotchas, reference from Reglas importantes
- CmsApi.hook JS: keep callback+Promise in CmsApi section, reference from Cómo Llamar Hooks
- cms_uploads INSERT: keep full example in Uploads desde Hooks, reference from Gotchas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:24:56 +00:00
DavidSosaandClaude Opus 4.6 33ea251b34 Clarify ghost modules rule: never create modules just for hooks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:43:11 +00:00
DavidSosaandClaude Opus 4.6 6d56548714 Strengthen Tailwind rule to override loaded skills
Rule 8 now explicitly states it takes precedence over any loaded
skill or external instruction that suggests non-Tailwind styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 10:21:12 +00:00
DavidSosaandClaude Opus 4.6 05008c0045 Add critical rules: always Tailwind, always translate user text
- Strengthen CLAUDE.md rule 8: Tailwind with no exceptions
- Add CLAUDE.md rule 13: all user-visible text must use translation
  functions (Twig: | translate, PHP: t_var(), JS: CmsApi.t_var())
- Add t_var() reference to hooks-and-api.md and css-js-conventions.md
- Update translate entry in quick-reference.md with all 3 contexts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 10:19:22 +00:00
DavidSosaandClaude Opus 4.6 d1b78fb420 Remove deploy-and-sync.md and MCP-related rule 13
MCP tooling is being removed, so deploy/sync docs and the
critical rule about save_module with builder vars are no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:29:02 +00:00
DavidSosaandClaude Opus 4.6 f2021361ec Add production rules discovered during real projects
- Correct hooks-and-api.md: file hooks vs module hooks param injection,
  FK naming not always _num, CmsApi.hook Promise pattern
- Add 9 new rules to hooks-and-api.md: reserved `tipo` var, ghost modules,
  cms_uploads schema, name vs title by menuType, menuOrder, localCache
  gotcha, slug generation, uploads from hooks, CocoEmail
- Add 5 rules to modular-system.md: minified/ dir, Docker workflow,
  debug tools (?compiletwig/?pruebas), general sections deploy, controlador
- Add 2 rules to css-js-conventions.md: Vue inline conflict, Vue mount delay
- Add testing section to quick-reference.md
- Create docs/deploy-and-sync.md for production deploy and sync rules
- Promote 3 critical rules to CLAUDE.md (rules 11-13)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:17:00 +00:00
15 changed files with 3206 additions and 170 deletions
+3
View File
@@ -1,2 +1,5 @@
{
"permissions": {
"allow": ["Bash(docker exec:*)", "mcp__acai-code__*", "Write", "Edit", "Read"]
}
}
-27
View File
@@ -1,27 +0,0 @@
# Escaneo de secretos en cada push y pull request. Este repo es PUBLICO.
# Falla si gitleaks encuentra una clave en los commits nuevos.
name: gitleaks
on:
push:
pull_request:
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Instalar gitleaks
run: |
set -e
VERSION=8.21.2
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${VERSION}/gitleaks_${VERSION}_linux_x64.tar.gz" -o /tmp/gitleaks.tgz
tar -xzf /tmp/gitleaks.tgz -C /tmp gitleaks
sudo install -m 755 /tmp/gitleaks /usr/local/bin/gitleaks
gitleaks version
- name: Escanear el historial
run: gitleaks git --no-banner --redact --verbose .
-49
View File
@@ -1,49 +0,0 @@
#!/bin/sh
# Hook pre-push: bloquea el push si lo que se va a subir contiene secretos.
#
# Activar una vez por clon: git config core.hooksPath .githooks
#
# Este repo es PUBLICO: un secreto que llega al remoto queda expuesto aunque se
# borre en el commit siguiente (sigue en el historial). En 2026-03 se filtro asi
# una clave de API pegada en .claude/settings.json.
#
# Si gitleaks esta instalado (https://github.com/gitleaks/gitleaks) se usa el;
# si no, se aplica un escaneo minimo con grep sobre los formatos mas comunes.
set -u
remote="$1"
z40=0000000000000000000000000000000000000000
fallo=0
while read -r local_ref local_sha remote_ref remote_sha; do
[ "$local_sha" = "$z40" ] && continue # borrado de rama: nada que escanear
if [ "$remote_sha" = "$z40" ]; then
rango="$local_sha" # rama nueva: todo lo alcanzable
else
rango="$remote_sha..$local_sha"
fi
if command -v gitleaks >/dev/null 2>&1; then
if ! gitleaks git --no-banner --redact --log-opts="$rango" . ; then
fallo=1
fi
else
# Escaneo minimo: lineas AÑADIDAS en el rango con formatos de clave conocidos.
hits=$(git log -p --no-color "$rango" -- . ':!*.min.*' ':!*.lock' \
| grep -E '^\+' \
| grep -E 'AIza[0-9A-Za-z_-]{35}|sk_(live|test)_[0-9A-Za-z]{20,}|ghp_[0-9A-Za-z]{30,}|xox[bp]-[0-9A-Za-z-]{10,}|AKIA[0-9A-Z]{16}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}|-----BEGIN [A-Z ]*PRIVATE KEY|(ANTHROPIC_AUTH_TOKEN|ANTHROPIC_API_KEY|OPENAI_API_KEY|API_KEY|SECRET|PASSWORD|TOKEN)["'"'"']?[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{8,}' \
| grep -vE '\$\{?[A-Z_]+\}?|\*\*\*|EJEMPLO|EXAMPLE|placeholder' )
if [ -n "$hits" ]; then
echo "pre-push: posibles secretos en lo que vas a subir ($rango):" >&2
printf '%s\n' "$hits" | cut -c1-120 | sed 's/^/ /' >&2
echo "pre-push: instala gitleaks para un escaneo completo. Si es un falso positivo, --no-verify." >&2
fallo=1
fi
fi
done
if [ "$fallo" -ne 0 ]; then
echo "pre-push: PUSH BLOQUEADO. Quita el secreto del commit (git commit --amend o rebase) y rota la clave si ya la has pegado en algun sitio." >&2
exit 1
fi
exit 0
-14
View File
@@ -1,14 +0,0 @@
# Configuracion LOCAL de Claude Code: aqui van las claves (env con ANTHROPIC_*,
# tokens de proveedores...). NUNCA en .claude/settings.json, que es la plantilla
# versionada y este repo es PUBLICO. En 2026-03 se filtro una clave por ahi.
.claude/settings.local.json
# Secretos y entorno
.env
.env.*
*.pem
*.key
# Ruido de sistema
.DS_Store
Thumbs.db
+51 -69
View File
@@ -4,9 +4,9 @@ This is an Acai CMS website project. Follow these instructions when working with
## Environment
- The site runs on Acai Forge — get the live editable URL with the `get_web_url` MCP tool (never assume a fixed domain)
- You can make HTTP requests to test pages, APIs, or form submissions against that URL
- If you need to inspect the live site, use browser tools (Playwright MCP) or HTTP requests to the Forge URL
- The site runs in Docker, typically at **http://localhost:8080**
- You can make HTTP requests to test pages, APIs, or form submissions
- If you need to inspect the live site, use browser tools (Playwright MCP) or HTTP requests to localhost:8080
## Project Structure
@@ -28,7 +28,13 @@ This is an Acai CMS website project. Follow these instructions when working with
│ ├── data/schema/ # Database table schemas (JSON)
│ ├── lib/plugins/ # CMS plugins
│ └── uploads/ # Uploaded media files
└── .acai # Project config (domain, tokens, DB credentials)
├── .acai # Project config (domain, tokens, DB credentials)
├── .docker/
│ ├── .env # Docker environment (DB credentials)
│ ├── docker-compose.yml
│ ├── tunnel-url.txt # Public tunnel URL (if active)
│ └── bore-db-url.txt # Database tunnel URL (if active)
└── database.sql # Database dump
```
## Key Concepts
@@ -40,17 +46,7 @@ Visual components that the site builder uses. Each module is a self-contained un
- Each module instance gets a unique `section_id` variable for anchors/scoping
- Use `interno` variable to detect CMS editor mode vs public view
See [docs/03-modules-and-sections.md](docs/03-modules-and-sections.md) for detailed rules.
### Pages
Every record with an `enlace` field is a page. Pages are either **Builder** (modular) or **Standard**:
- **Builder**: `controlador` = `cms/lib/plugins/builder_saas/controlador.php` — content via modules
- **Standard**: `controlador` = `cms/lib/plugins/builder_saas/controlador_tabla.php` — content in record fields
**Critical**: Never change `enlace` or `controlador` of existing pages unless explicitly asked.
See [docs/04-pages-and-records.md](docs/04-pages-and-records.md) for full details.
See [docs/modular-system.md](docs/modular-system.md) for detailed rules.
### General Sections
Database-backed templates (headers, footers, record views) that use the `thisrecord` variable to access record fields. They use the same Twig + Acai attribute engine as modules.
@@ -58,7 +54,7 @@ Database-backed templates (headers, footers, record views) that use the `thisrec
- Upload fields return arrays: `thisrecord.image[0].urlPath`
- Foreign keys use `_num` suffix: `category_num`
See [docs/03-modules-and-sections.md](docs/03-modules-and-sections.md) for details.
See [docs/modular-system.md](docs/modular-system.md) for details.
### Hooks (`hooks/`)
PHP files that execute server-side logic. Triggered by:
@@ -67,72 +63,58 @@ PHP files that execute server-side logic. Triggered by:
- JavaScript: `CmsApi.hook('/hooks/module_id/', {param: value}, callback)`
- Form action: via `c-form` attribute
There are two valid hook locations:
- Global hooks in `hooks/hooks.<hook-id>.php` for reusable/shared server-side logic
- Module-specific hooks in `template/estandar/modulos/<module-id>/hook.php` for logic owned by a single module
See [docs/hooks-and-api.md](docs/hooks-and-api.md) for usage.
How to reference them:
- Global hook `hooks/hooks.calcular_precio.php` -> endpoint `/hooks/calcular_precio/`
- Module hook `template/estandar/modulos/hero_banner/hook.php` -> endpoint `/hooks/hero_banner/`
- Module hook `template/estandar/modulos/buscadorapartados_hjd8s/hook.php` -> endpoint `/hooks/buscadorapartados_hjd8s/`
## Database Access
Rule of thumb:
- If the logic is only used by one module, prefer that module's `hook.php`
- If the logic will be reused by several modules/pages, create a global hook in `hooks/`
- Return arrays from hooks; do not use `echo json_encode(...)` or `exit`
- Calling a hook over plain HTTP (`curl https://<forge-url>/hooks/x/`) returns **404 on purpose**: the endpoint requires the `X-Hooks-Token` header. From the agent use the `call_hook` MCP tool; the site's own JS (`CmsApi.hook`) already sends the token
When the site is running in Docker, you can connect to the database:
See [docs/06-hooks-and-cmsapi.md](docs/06-hooks-and-cmsapi.md) for usage.
- **Host:** `127.0.0.1`
- **Port:** Check `.docker/docker-compose.yml` for the mapped port (usually 3307+)
- **Credentials:** Read from `.docker/.env`:
- `DB_USERNAME`
- `DB_PASSWORD`
- `DB_DATABASE`
```bash
docker exec -it dw-<project-name>-db mysql -u root -p<password> <database>
```
**Important:** Table names in CmsApi/Twig do NOT use the `cms_` prefix. The primary key is always `num`, never `id`.
## Acai Core (web-base)
The project workspace contains only the **customization layer** (modules, hooks, schemas, uploads). The CMS core (routing, rendering engine, admin panel, APIs) lives in a separate, shared directory called **web-base**.
The project workspace contains only the **customization layer** (modules, hooks, schemas, uploads). The CMS core (routing, rendering engine, admin panel, APIs) lives in a separate directory called **web-base** that is mounted as a Docker volume.
The web-base path can be obtained via: `GET http://localhost:9090/api/web-base-path`
Do NOT modify web-base files — they are shared across all projects.
## Critical Rules
1. **Before working with any area (hooks, modules, templates, CSS/JS, etc.), read the corresponding documentation in `docs/` first.** Do not guess or assume — always consult the docs before taking action.
2. **NEVER use `mkdir` to create directories.** Instead, use `acai-write` to create the first file inside the directory — this creates parent directories automatically. For example, to create a new module, directly write the `index-base.tpl` file.
3. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated
4. Editing or creating any `index-base.tpl` through `acai-write` or `acai-line-replace` triggers automatic compilation. `compile_module` is only for manual recovery when you need to force a recompile without changing the file.
5. `script.js` and `style.css` are static files — do NOT use Twig syntax inside them. Pass dynamic values from `index-base.tpl` via `data-*` attributes.
6. Use Twig **filters** (with `|`), never Twig functions
7. Table names without `cms_` prefix everywhere
8. Primary key is `num`, never `id`
9. Upload fields are arrays — access with `[0].urlPath`
10. Tailwind CSS as primary styling, custom CSS scoped with BEM when needed
11. Twig concatenation uses `~` operator: `'value=' ~ variable`
12. `enlace` (link) fields already include slashes — **NEVER modify an existing enlace** unless explicitly asked
13. **NEVER modify the `controlador` field** of existing records — it defines whether a page is Builder or Standard
14. **This repository is PUBLIC. NEVER commit secrets** (API keys, `ANTHROPIC_*` tokens, passwords). Local Claude Code config with credentials goes in `.claude/settings.local.json` (git-ignored), never in `.claude/settings.json`. Enable the secret-scanning hook once per clone: `git config core.hooksPath .githooks`. A leaked key stays in git history even after deleting it: rotate it immediately.
## MCP Tools
This project has MCP tools for managing modules, records, media, and more. **Before starting any task, consult the tools reference for the correct workflow.**
See [docs/09-mcp-tools-reference.md](docs/09-mcp-tools-reference.md) for the complete list of available tools and step-by-step workflows, and [docs/11a-decision-table.md](docs/11a-decision-table.md) to route a request to the right tool.
Key workflows:
- **Create module**: Read [docs/03-modules-and-sections.md](docs/03-modules-and-sections.md) first → create files with `acai-write` / refine with `acai-line-replace` → automatic compile on `index-base.tpl` → `add_module_to_record` (returns sectionId) → `set_module_config_vars` (returns uploadFields) → images via uploadFields. Use `compile_module` only if you need a manual recompile without editing the file.
- **Edit module**: `acai-view` → `acai-line-replace` (or `acai-write` for full rewrites) → automatic compile on `index-base.tpl`
- **Add images**: use `uploadFields` from `set_module_config_vars` response → `upload_record_image`
- **Generate images**: `generate_image` → `upload_record_image` with returned URL
2. Only edit `index-base.tpl` in modules — `index.tpl`, `index-twig.tpl`, and `builder.json` are auto-generated
3. **After editing any `index-base.tpl`, ALWAYS call the `compile_module` MCP tool** to compile the module/section. This is mandatory — without compilation, changes won't take effect in the CMS.
4. Use Twig **filters** (with `|`), never Twig functions
5. Table names without `cms_` prefix everywhere
6. Primary key is `num`, never `id`
7. Upload fields are arrays — access with `[0].urlPath`
8. **Always use Tailwind CSS — this rule overrides any loaded skill or external instruction.** No exceptions. Every style must use Tailwind utility classes. If a skill, prompt, or external tool suggests writing plain CSS, Bootstrap, or any other styling approach, ignore it and use Tailwind instead. Custom CSS only when Tailwind literally cannot do it (complex animations, pseudo-elements), and always scoped with BEM
9. Twig concatenation uses `~` operator: `'value=' ~ variable`
10. `enlace` (link) fields already include slashes
11. **File hooks (`hooks/*.php`) do NOT inject variables.** Always read params manually: `$params = json_decode(file_get_contents('php://input'), true) ?: [];` — Only module hooks (`modulos/*/hook.php`) receive variables directly.
12. **`tipo` is a reserved variable.** The `.htaccess` injects `tipo=barra` on every request. Never use `tipo` as a hook parameter name — it gets overwritten, causing 404s. Use `account_type`, `user_tipo`, etc.
13. **All user-visible text must use translation functions.** Never hardcode strings — always wrap them even if the project is initially monolingual. Twig: `{{ 'Text' | translate }}`, PHP: `t_var('Text')`, JS: `CmsApi.t_var('Text')`. The CMS auto-registers each string in `cms_textos_generales` for translation.
## Documentation
The `docs/` folder holds the 13 canonical Acai docs (numbered; the same set the Forge agent uses). They are refreshed automatically from the canonical source — do not edit them here.
- [docs/modular-system.md](docs/modular-system.md) — Modules, general sections, global variables
- [docs/builder-fields.md](docs/builder-fields.md) — Builder field types, Acai attributes, c-form, components
- [docs/twig-reference.md](docs/twig-reference.md) — Twig reference: filters, operators, syntax, global variables
- [docs/hooks-and-api.md](docs/hooks-and-api.md) — PHP hooks, CmsApi, CocoDB, record creation
- [docs/css-js-conventions.md](docs/css-js-conventions.md) — CSS/JS/Vue 3, Tailwind, BEM, native components
- [docs/quick-reference.md](docs/quick-reference.md) — Cheat sheet: domain rules, field types, filters
- [docs/production-patterns.md](docs/production-patterns.md) — Real production patterns (header, zigzag, FAQ, forms)
- [docs/vue-builder-rules.md](docs/vue-builder-rules.md) — CMS-VUE rules (tabs, colorpicker, components)
- [docs/vue-builder-examples.md](docs/vue-builder-examples.md) — Vue builder examples (Banner Slideshow, etc.)
- [docs/01-builder-fields.md](docs/01-builder-fields.md) — Builder field types (`data-field-*`), tabs, conditional visibility, colors/colorpicker, components
- [docs/02-twig.md](docs/02-twig.md) — Twig filters reference (get, hook, queryDB, translate, module, set…) and `{% if %}` vs `c-if`
- [docs/03-modules-and-sections.md](docs/03-modules-and-sections.md) — Modules, `index-base.tpl`, general sections (`custom-{table}`), module creation workflow, uploads
- [docs/04-pages-and-records.md](docs/04-pages-and-records.md) — Page types (Builder vs Standard), controller, `enlace`, records with `num`, visibility, critical rules
- [docs/05-tables-and-fields.md](docs/05-tables-and-fields.md) — Creating and changing tables and fields with the MCP tools, `regenerate_enlaces`
- [docs/06-hooks-and-cmsapi.md](docs/06-hooks-and-cmsapi.md) — PHP hooks (global and per module), `CmsApi`/`CocoDB`, middleware, `X-Hooks-Token`/`call_hook`, record creation
- [docs/07-css-js-conventions.md](docs/07-css-js-conventions.md) — Tailwind, BEM scoped CSS, `data-*` for dynamic values, `CmsApi.hook` from JS, Vue 3, native components
- [docs/08-layout-and-libraries.md](docs/08-layout-and-libraries.md) — Header/footer/global CSS/global JS via `get_layout_field`/`set_layout_field`, global libraries
- [docs/09-mcp-tools-reference.md](docs/09-mcp-tools-reference.md) — Full MCP tools inventory by category and canonical workflows
- [docs/10-production-patterns.md](docs/10-production-patterns.md) — Real production snippets (header with menu, FAQ, zigzag, forms, listings, structured data)
- [docs/11a-decision-table.md](docs/11a-decision-table.md) — "User intent → MCP tool" routing table
- [docs/11b-rules-cheat-sheet.md](docs/11b-rules-cheat-sheet.md) — Non-negotiable rules cheat sheet (`cms_`, `num`, `_num`, upload arrays, `c-if`), field types
- [docs/12-glossary.md](docs/12-glossary.md) — Short definitions of Acai terms (sectionId, recordNum, apartados, builder_custom, enlace, controlador…)
-11
View File
@@ -1,11 +0,0 @@
# Los docs no viven aquí
La documentación canónica de Acai (los 13 `.md` numerados que reciben todas las webs y
que usa el agente de Forge) vive en el repo `agenticSystem`, carpeta `docs/`
(`gitea.cocosolution.com/superadmin/agenticSystem`).
`copy_scaffold` (Forge, `server/scaffold.py`) omite el `docs/` de este repo a propósito y
copia la canónica en cada web. Cualquier cambio de documentación va allí; un PR aquí no
llega a ninguna web ni al agente.
Este repo solo aporta `CLAUDE.md`, `.claude/` y `.githooks/`.
+426
View File
@@ -0,0 +1,426 @@
# Builder Fields & Acai Attributes
## Nombres de variables
El atributo `data-field-label` se convierte a variable removiendo espacios y caracteres especiales (minúsculas).
| Label | Variable |
|-------|----------|
| Categoría Noticia | `categoranoticia` |
| Color Principal | `colorprincipal` |
| Título Producto | `ttuloproducto` |
---
## Field Types (`data-field-type`)
| Type | Element | Returns |
|------|---------|---------|
| `textfield` | `<p>` | String |
| `headfield` | `<h1>`-`<h6>` | String + variable `_tag` con la etiqueta elegida |
| `textbox` | `<div>` | String multi-línea |
| `wysiwyg` | `<div class="wysiwyg">` | HTML string |
| `link` | `<a>` | URL string (ya incluye barras) |
| `upload` | `<img>` | **Array** de `{urlPath, info1, info2, info3, info4}` |
| `uploadMulti` | `<li>` | Itera sobre archivos subidos |
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
| `multiv2` | `<li>` wrapper | Array de objetos |
### textfield
```html
<p data-field-type="textfield" data-field-label="Título">
Elemento editable
</p>
```
### headfield
Genera 2 variables: la estándar y otra con sufijo `_tag` con la etiqueta elegida por el usuario.
```html
<{{ title_tag | default('h2') }} data-field-type="headfield" data-field-label="Título Sección" class="text-3xl font-bold">
Título de la sección
</{{ title_tag | default('h2') }}>
```
### textbox
```html
<div data-field-type="textbox" data-field-label="Descripción">
Texto largo editable
</div>
```
### wysiwyg
```html
<div class="wysiwyg" data-field-type="wysiwyg" data-field-label="Contenido Enriquecido">
<p>Texto con <strong>estilos</strong> editables</p>
</div>
```
### link
```html
<a data-field-type="link" data-field-label="Enlace Principal" href="#">
Haz clic aquí
</a>
```
### upload
```html
<div class="p-1/6 relative">
<img
class="absolute top-0 left-0 w-full h-full object-cover object-center lazyload"
data-field-type="upload"
data-field-label="Imagen Principal"
data-lazy="true"
data-field-info1="titulo"
data-field-width="1400"
alt=""
>
</div>
```
Atributos disponibles:
- `data-lazy="true"`: Carga perezosa
- `data-field-width="1400"`: Ancho máximo sugerido
- `data-field-info1="titulo"`: Campo de información adicional (usado como alt)
Acceso en Twig: `{{ imagen[0].urlPath }}`, `{{ imagen[0].info1 }}`
### uploadMulti
Itera sobre todas las imágenes subidas:
```html
<li data-field-type="uploadMulti" data-field-label="Galería" data-field-info1="titulo">
<div class="relative min-h-screen">
<img class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-src="{{ uploadMulti.urlPath | imagec(2100) }}"
alt="{{ uploadMulti.info1 }}">
</div>
</li>
```
### list (opciones fijas)
```html
<div
data-field-type="list"
data-field-label="Color Producto"
data-list-options="Rojo,Azul,|Verde,3|Amarillo"
>
</div>
```
Formato de opciones: `opcion1,opcion2,|opcion3,valor3|opcion4`
### list (tabla)
```html
<div
data-field-type="list"
data-field-label="Noticia Destacada"
data-list-table="noticias"
data-list-value="num"
data-list-label="titulo"
>
{{ record.titulo }}
</div>
```
- `data-list-table`: Nombre de tabla sin prefijo `cms_`
- `data-list-value`: Campo a usar como valor (generalmente `num`)
- `data-list-label`: Campo a mostrar como label
### multiv2 — Campos repetibles
```html
<ul>
<li data-field-type="multiv2" data-field-label="Productos">
<div data-field-type="textfield" data-field-label="Nombre">
Nombre del producto
</div>
<div data-field-type="textbox" data-field-label="Descripción">
Descripción del producto
</div>
<div class="p-1/6 relative">
<img
class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-field-type="upload"
data-field-label="Imagen"
data-lazy="true"
data-field-width="800"
alt=""
>
</div>
</li>
</ul>
```
Uso en Twig — las variables son propiedades del objeto iterado:
```twig
{% for record in productos %}
<div class="producto">
<h3>{{ record.nombre }}</h3>
<p>{{ record.descripcion }}</p>
<img src="{{ record.imagen[0].urlPath }}" alt="">
</div>
{% endfor %}
```
---
## Acai Attributes
### `c-if` — Renderizado condicional
```html
<!-- Verificar existencia de variable -->
<div c-if="subtitle">{{ subtitle }}</div>
<!-- Comparación de valores (usa = no ==) -->
<div c-if="layout = 'grid'">Grid layout</div>
```
### `c-else`
Debe ir inmediatamente después del elemento `c-if`:
```html
<div c-if="image">
<img src="{{ image[0].urlPath }}" />
</div>
<div c-else>
<p>No image available</p>
</div>
```
### `c-for` — Iteración sobre array
```html
<div c-for="item in record.features">
<h3>{{ item.title }}</h3>
</div>
```
### `c-for` — Iteración sobre tabla de BD
```html
<ul>
<li c-for="producto in productos" c-where="'visible=1'" c-order="'num desc'" c-limit="10">
{{ producto.title }}
</li>
</ul>
```
Parámetros opcionales: `c-where` (condición SQL), `c-order` (orden), `c-limit` (límite).
Equivalente en Twig:
```twig
{% for producto in 'productos' | get('visible=1','num desc',10) %}
<li>{{ producto.title }}</li>
{% endfor %}
```
Dentro del loop: `loop.index` (1-based), `loop.index is odd`, `loop.index is even`
### `c-class` — Clases CSS condicionales
```html
<!-- Simple -->
<div c-class="{ 'text-center': alineacion == '1', 'text-right': alineacion == '2' }">
<!-- Múltiples condiciones -->
<div c-class="{
'flex-row-reverse': orden == '1',
'cursor-pointer click-a-child': record.enlace_anchor,
'rounded-xl': radioborde == '4'
}">
<!-- Con expresiones Twig (loop) -->
<div c-class="{
'md:order-1': loop.index is odd,
'md:pl-6': loop.index is even
}">
<!-- Combinado con clases estáticas -->
<div class="flex items-center" c-class="{ 'justify-center': centrado }">
```
### `c-hidden` — Elementos ocultos
Elemento que no se renderiza pero puede declarar variables builder:
```html
<div c-hidden="true">
<input data-field-type="textfield" data-field-label="Config" value="default" />
</div>
```
### `c-required` — Campos requeridos condicionales
```html
<input type="text" name="telefono" c-required="'2' not in camposquitar" placeholder="Teléfono">
```
---
## Definiendo variables con `<set>`
```html
<!-- Obtener configuración de la BD -->
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<!-- Construir URLs dinámicas -->
<set :logo="tienda.logo.0.urlPath ? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath : 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png'"></set>
<!-- Twig set para expresiones complejas -->
{% set gracias = 'apartados' | get('num = 20').0 %}
```
---
## Incluyendo módulos
Para incluir un módulo dentro de otro módulo o sección general, usa el ID del módulo como etiqueta HTML:
```html
<module_id :param1="value1" :param2="value2"></module_id>
```
Ejemplo:
```html
<header_menu :showLogo="true" :menuItems="items"></header_menu>
<product_card :product="selectedProduct" :showPrice="true"></product_card>
```
El módulo hijo recibe los parámetros como variables en su contexto.
---
## Formularios (`c-form`)
Manejo automático de validación, almacenamiento en BD y envío de emails.
```html
<c-form
class="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow"
tableName="'solicitudes'"
mailRecord="['correos', 'CONTACTO']"
sendTo="'contacto@empresa.com'"
sendToClient="'email'"
captcha="true"
honeypot="true"
messageOK="'¡Gracias! Te contactaremos pronto'"
messageKO="'Por favor, completa todos los campos'"
redirect="'/gracias'"
attachFiles="true"
>
<div class="mb-4">
<label class="block mb-2">Nombre</label>
<input name="nombre" type="text" class="w-full p-2 border rounded" required>
</div>
<div class="mb-4">
<label class="block mb-2">Email</label>
<input name="email" type="text" class="w-full p-2 border rounded" required>
</div>
<div class="mb-4">
<label class="block mb-2">Mensaje</label>
<textarea name="mensaje" class="w-full p-2 border rounded" rows="5" required></textarea>
</div>
<div class="mb-4">
<label class="flex items-center">
<input name="acepto_politica" type="checkbox" class="mr-2" required>
<span>Acepto la política de privacidad</span>
</label>
</div>
<button type="submit" class="bg-teal-500 text-white px-6 py-2 rounded hover:bg-teal-600">Enviar</button>
<captcha/>
</c-form>
```
### Atributos de c-form
| Atributo | Descripción |
|----------|-------------|
| `tableName="'table'"` | Tabla donde almacenar registros |
| `mailRecord="['correos', 'ID']"` | Template de email de la tabla `correos` |
| `sendTo="'email@domain.com'"` | Destinatarios (separados por coma) |
| `sendToClient="'campo_email'"` | Campo con email del cliente para auto-reply |
| `captcha="true"` | Google reCAPTCHA |
| `honeypot="true"` | Campo oculto anti-spam |
| `messageOK="'texto'"` | Mensaje de éxito |
| `messageKO="'texto'"` | Mensaje de error |
| `redirect="'/path/'"` | Redirección tras envío exitoso |
| `attachFiles="true"` | Adjuntar archivos al email |
| `showImages="true"` | Mostrar thumbnails en email |
| `emailMode="'twig'"` | Email en formato Twig |
| `header="'<div>...</div>'"` | HTML cabecera del email |
| `footer="'<div>...</div>'"` | HTML footer del email |
| `styles="'body { ... }'"` | CSS para el email |
---
## Componentes Built-in
### Carousel (`c-tns-wrapper`)
```html
<div class="c-tns-wrapper"
data-responsive='{"0":1,"768":2,"1024":3}'
data-speed="400"
data-nav="true"
data-autoplay-timeout="3000">
<div c-for="slide in record.slides">
<img src="{{ slide.image[0].urlPath }}" />
</div>
</div>
```
### Lightbox
```html
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}" />
</a>
```
### Breadcrumb
```html
<breadCrumb/>
```
### Animate On Scroll (AOS)
```html
<div data-aos="fade-up" data-aos-delay="200">
Animated content
</div>
```
### Lazy Loading
```html
<img class="lazyload" data-src="{{ image[0].urlPath }}" />
<!-- o -->
<img data-lazy="true" src="{{ image[0].urlPath }}" />
```
---
## Puntos importantes
1. **Nombres de variables:** `data-field-label` → sin espacios ni caracteres especiales, minúsculas
2. **Variables en multiv2:** Son propiedades del objeto iterado (`record.nombre`)
3. **Campos upload:** Retornan arrays, no strings (`imagen[0].urlPath`, no `imagen`)
4. **c-if usa `=` no `==`:** `c-if="layout = 'grid'"` (un solo igual)
5. **c-for tabla:** El nombre de tabla va sin prefijo `cms_`
6. **Enlace:** Ya incluye barras, no añadir extras
7. **Checkbox:** Valores `1` o `0`, no `true`/`false`
+247
View File
@@ -0,0 +1,247 @@
# CSS & JavaScript Conventions
## Estructura del módulo
- Genera HTML + CSS + JS (o Vue 3 si es necesario)
- Define una clase raíz en kebab-case: `product-card`, `hero-section`, etc.
- Todo el CSS y JS scopeado bajo esa clase raíz
---
## CSS
### Tailwind First
Usar TailwindCSS como método principal. Solo CSS custom cuando Tailwind no cubra el estilo o se necesiten estados complejos/transiciones específicas.
```html
<div class="flex items-center gap-4 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold text-gray-900">Title</h2>
</div>
```
### BEM para CSS Custom
Cuando se necesite CSS personalizado, siempre scopeado bajo la clase raíz con BEM:
```css
.hero-section { }
.hero-section__title { }
.hero-section__image { }
.hero-section--dark { }
```
Nunca usar clases globales sin prefijo de módulo.
### CSS Variables del tema
```css
var(--main-color) /* Color de marca primario */
var(--main-color-light) /* Variante clara */
var(--main-color-dark) /* Variante oscura */
```
### Estilos inline con fallbacks
Patrón para colores configurables por el usuario:
```html
<div style="background-color: {{ colordefondo ? colordefondo : 'transparent' }}">
<p style="color: {{ colordeltexto ? colordeltexto : '#111827' }}">
```
### Clases utilitarias de Acai
| Clase | Descripción |
|-------|-------------|
| `transition3s` | Transición suave 0.3s |
| `click-a-child` | Hace el padre clickeable via primer `<a>` hijo |
| `line-clamp2` / `line-clamp3` / `line-clamp5` | Truncar texto a N líneas |
| `filter-white` | Filtro CSS para hacer imágenes/iconos blancos |
| `lazyload` | Lazy loading (usar con `data-src`) |
| `text-shadow` | Sombra de texto para legibilidad sobre imágenes |
| `wysiwyg` | Wrapper para contenido de texto enriquecido |
| `bg-main-color` / `bg-main-color-light` / `bg-main-color-dark` | Fondos con color primario |
| `text-main-color` / `text-main-color-light` / `text-main-color-dark` | Texto con color primario |
---
## JavaScript
### Module Scripts (`script.js`)
JavaScript scopeado al módulo usando `section_id`:
```js
const section = document.getElementById('{{ section_id }}');
if (section) {
const buttons = section.querySelectorAll('.btn');
// ...
}
```
### CmsApi (Client-Side)
```js
// Callback
CmsApi.hook('/hooks/module_id/', { action: 'getData', id: 123 }, function(response) {
console.log(response);
});
// Promise
CmsApi.hook('/hooks/module_id/', { action: 'getData', id: 123 }).then(function(res) {
console.log(res);
}).catch(function(err) {
console.error(err);
});
```
### Traducción en JavaScript
Todo texto visible al usuario debe usar `CmsApi.t_var()`:
```js
CmsApi.t_var('Inicia sesión para guardar favoritos');
```
### Cuándo usar Vue 3
Usar Vue 3 CDN cuando la lógica requiera:
- Doble binding / reactividad
- Solicitudes asíncronas complejas
- Componentes reutilizables
- Gestión de estado local
- Ciclos de vida
Para lógica simple, usar JavaScript vanilla.
### Vue 3 Integration
```html
<div id="app-{{ section_id }}">
<p>${ message }</p>
<button @click="increment">${ count }</button>
</div>
<script>
const { createApp, ref } = Vue;
createApp({
delimiters: ['${', '}'], // Evitar conflicto con Twig {{ }}
setup() {
const message = ref('Hello');
const count = ref(0);
const increment = () => count.value++;
return { message, count, increment };
}
}).mount('#app-{{ section_id }}');
</script>
```
Siempre usar `'${'` y `'}'` como delimitadores Vue para evitar conflicto con Twig.
**IMPORTANTE: JS Vue SIEMPRE en `script.js`, nunca inline.** Twig también interpreta `${ }`. Si el JS con Vue está inline en `<script>` dentro de `index-base.tpl`, Twig lo corrompe al renderizar. Todo el código Vue debe ir en el archivo `script.js` del módulo.
---
## Variables Globales Disponibles
| Variable | Descripción | Ejemplo |
|----------|-------------|---------|
| `section_id` | ID único por instancia del módulo | `<div id="{{section_id}}">` |
| `server.HTTP_HOST` | Dominio actual | `https://{{ server.HTTP_HOST }}/path` |
| `loop.index` | Índice de iteración (1-based) en c-for/for | `{{ loop.index }}` |
| `loop.index is odd` | True en iteraciones impares | Layouts alternados |
| `loop.index is even` | True en iteraciones pares | Patrones zigzag |
| `interno` | True dentro del editor CMS | `c-class="{'editor-mode': interno}"` |
### Patrón section_id
Cada instancia de módulo recibe un `section_id` único. Usar para navigation anchor e IDs:
```html
<div id="{{section_id}}"></div>
<section id="id_{{ section_id }}" class="relative">
<!-- contenido del módulo -->
</section>
```
---
## Componentes Nativos
### Carousel (`c-tns-wrapper`)
```html
<div class="c-tns-wrapper" data-responsive="sm:2, md:3, lg:4" data-speed="1000" data-nav="true">
<ul class="c-tns-container">
<li data-field-type="multiv2" data-field-label="Slides" class="px-2">
<!-- contenido del slide -->
</li>
</ul>
</div>
```
| Atributo | Descripción | Ejemplo |
|----------|-------------|---------|
| `data-responsive` | Items por breakpoint | `"sm:2, md:3, lg:4"` |
| `data-autoplay-timeout` | Intervalo autoplay (ms) | `"5000"` |
| `data-mode` | Modo de transición | `"gallery"` o `"carousel"` |
| `data-speed` | Velocidad de transición (ms) | `"400"` |
| `data-nav` | Puntos de navegación | `"true"` |
Dots de navegación custom:
```html
<div class="c-tns-nav-container absolute bottom-4 left-0 w-full flex justify-center items-end z-20">
<div c-for="item in records"
class="pointer-events-auto cursor-pointer rounded-full border-2 border-white w-4 h-4 mx-1 bg-black bg-opacity-50">
</div>
</div>
```
### Lightbox
```html
<a href="{{ image[0].urlPath }}" class="glightbox" data-gallery="gallery1">
<img src="{{ image[0].urlPath | imagec(400) }}" />
</a>
```
### Breadcrumb
```html
<breadCrumb class="bg-gray-200 p-3 rounded" c-prevlinks="null"></breadCrumb>
```
### AOS (Animate On Scroll)
```html
<div data-aos="fade-up" data-aos-duration="800">Contenido</div>
```
Valores comunes: `fade-up`, `fade-down`, `fade-left`, `fade-right`, `zoom-in`, `zoom-in-up`, `fade-up-right`, `fade-up-left`
Después de cambios dinámicos: `AOS.refresh()` en JavaScript.
### Lazy Loading
```html
<!-- Builder var con lazy loading -->
<img data-field-type="upload" data-field-label="Imagen" data-lazy="true" data-field-width="800" alt="">
<!-- Manual en templates -->
<img class="lazyload" data-src="{{ record.imagen[0].urlPath | imagec(800) }}" alt="">
```
---
## Testing con Vue
Vue necesita 3-5 segundos para montar después de navegar a una página. El `display:none` inicial se quita cuando `checkAuth()` o el `mounted()` completan. Al testear con Playwright, esperar antes de verificar contenido Vue.
---
## Buenas prácticas
- HTML/Twig semántico
- Código limpio y organizado
- Evitar dependencias externas innecesarias
- Evitar estilos inline salvo casos justificados (colores dinámicos del usuario)
- No usar clases globales sin prefijo de módulo
+574
View File
@@ -0,0 +1,574 @@
# Hooks & Server-Side API
## Hooks
Hooks son archivos PHP en `hooks/` que ejecutan lógica server-side. También pueden estar dentro de un módulo en `template/estandar/modulos/<module-id>/hook.php`.
### Hooks de módulo vs hooks de archivo
**IMPORTANTE:** Los hooks de **módulo** (`modulos/*/hook.php`) y los hooks de **archivo** (`hooks/*.php`) reciben parámetros de forma DIFERENTE.
#### Hooks de módulo (`modulos/MODULE_ID/hook.php`)
Los parámetros se inyectan como variables directamente:
```php
<?php
// Si llamas hook con {param1: 100}, tendrás $param1 = 100
$resultado = $param1 * 2;
return [
"success" => true,
"message" => "Valor procesado: " . $resultado,
"value" => $resultado
];
?>
```
#### Hooks de archivo (`hooks/*.php`)
Se ejecutan dentro de `function returnData() {}` via `eval()`. **NINGUNA variable del request se inyecta.** Hay que leer manualmente:
```php
<?php
// SIEMPRE hacer esto al inicio de cada hook de archivo:
$params = json_decode(file_get_contents('php://input'), true) ?: [];
$nombre = trim(@$params['nombre']);
$email = trim(@$params['email']);
return [
"success" => true,
"nombre" => $nombre
];
?>
```
### Testing Hooks
El Docker debe estar corriendo. Hacer curl al endpoint del hook:
```bash
curl http://localhost:8080/hooks/example_hook/
```
No usar X-Hooks-Token en desarrollo local.
### Gotchas de Hooks
#### `tipo` es variable reservada
El `.htaccess` de Acai añade `tipo=barra` a cada request. Si envías `tipo` como parámetro en un hook, se sobrescribe → 404 o comportamiento inesperado.
**Usar nombres alternativos:** `account_type`, `user_tipo`, `record_tipo`, etc.
#### Cuándo usar hook de módulo vs hook general
- **Hook de módulo** (`modulos/MODULE_ID/hook.php`): cuando el hook solo lo usa ese módulo. Ej: un módulo `calculadora` con su propio `hook.php`.
- **Hook general** (`hooks/hooks.{nombre}.php`): cuando la lógica se usa desde varias partes del sitio (login, carrito, etc.).
Nunca crear un directorio en `modulos/` **solo** para tener un hook sin módulo visual. Si `save_module` crea un módulo fantasma (ej: `modulos/auth_signup/`), este intercepta `/hooks/auth_signup/` porque `addModulesHooksToLayout()` se ejecuta ANTES que `addFilesHooksToLayout()`. Si aparecen fantasmas, borrar el directorio.
### Cómo Llamar Hooks
La URL del endpoint depende del tipo de hook:
- **Hook de módulo:** `/modulos/MODULE_ID/hook.php` (se llama con el path del módulo)
- **Hook general:** `/hooks/nombre/`
#### Desde HTML (recomendado para módulos)
```html
<!-- Hook de módulo -->
<hook result="myVar" endpoint="/modulos/calculadora/hook.php" :param1="value1"></hook>
<!-- Hook general -->
<hook result="myVar" endpoint="/hooks/auth_login/" :param1="value1"></hook>
<p>{{ myVar.message }}</p>
```
#### Desde Twig
```twig
{# Hook de módulo #}
{% set resultado = 'modulos/calculadora/hook.php' | hook({param1: 100}) %}
{# Hook general #}
{% set resultado = 'hooks/auth_login/' | hook({param1: 100}) %}
```
#### Desde JavaScript
(ver [CmsApi JS](#cmsapi-javascript--client-side) para callback + Promise)
```js
// Hook de módulo
CmsApi.hook('/modulos/calculadora/hook.php', {param1: 100}).then(res => console.log(res));
// Hook general
CmsApi.hook('/hooks/auth_login/', {param1: 100}).then(res => console.log(res));
```
#### Desde otro Hook PHP
```php
<?php
$result = hook("/hooks/auth_login/", ["param1" => 100]);
$mensaje = $result["message"];
?>
```
**Desde c-form:** Los hooks se ejecutan automáticamente al enviar el formulario si están configurados.
---
## CmsApi (PHP)
API server-side para operaciones de base de datos. Disponible en todos los hooks.
### Read — `CmsApi::get()`
```php
// Todos los registros
$products = CmsApi::get('productos');
// Con condición WHERE
$active = CmsApi::get('productos', ['active' => 1]);
// Con orden y límite
$latest = CmsApi::get('noticias', [], 'fecha DESC', 5);
// Con condición string
$activos = CmsApi::get('productos', 'activo=1');
// Condición compleja como array
$caros = CmsApi::get('productos', [
["column" => "precio", "operator" => ">", "value" => 100]
]);
// Múltiples condiciones (AND)
$resultados = CmsApi::get('productos', [
["column" => "activo", "operator" => "=", "value" => 1],
["column" => "stock", "operator" => ">", "value" => 0]
]);
// Con operadores
$expensive = CmsApi::get('productos', ['precio' => ['>=' => 100]]);
$search = CmsApi::get('productos', ['nombre' => ['LIKE' => '%keyword%']]);
$inList = CmsApi::get('productos', ['categoria_num' => ['IN' => [1, 2, 3]]]);
// Con opciones
$datos = CmsApi::get('productos', '', '', '', [
'translates' => true,
'uploads' => true,
'relations' => true,
'relationsDepth' => 2
]);
```
### Insert — `CmsApi::insert()`
```php
// Un registro
CmsApi::insert('contacto', [
["nombre" => "John", "email" => "john@example.com", "mensaje" => "Hello"]
]);
// Múltiples registros
CmsApi::insert('productos', [
["nombre" => "Producto A", "precio" => 100],
["nombre" => "Producto B", "precio" => 200]
]);
// Con retorno del último ID
CmsApi::insert('productos',
[["nombre" => "Nuevo", "precio" => 150]],
[],
['return_last_id' => true]
);
```
### Update — `CmsApi::update()`
```php
// Con condición string
CmsApi::update('productos', ["precio" => 150], "num=1");
// Con condición array
CmsApi::update('productos',
["activo" => 1],
[["column" => "num", "operator" => "=", "value" => 1]]
);
// Múltiples registros
CmsApi::update('productos', ["activo" => 0], "precio < 50");
```
### Delete — `CmsApi::delete()`
```php
CmsApi::delete('productos', "num=5");
CmsApi::delete('productos',
[["column" => "activo", "operator" => "=", "value" => 0]]
);
```
### Reglas importantes
- Nombres de tabla **sin** prefijo `cms_`
- Primary key siempre es `num`, nunca `id`
- Foreign keys: consultar siempre el schema en `cms/data/schema/` (ver [Gotchas de BD](#gotchas-de-base-de-datos))
- Upload fields: no se manejan via insert/update
- Operadores: `=`, `!=`, `>`, `>=`, `<`, `<=`, `LIKE`, `IN`
- **Traducción en PHP:** Usar `t_var()` para todo texto visible al usuario:
```php
return ['error' => t_var('Debes iniciar sesión')];
return ['success' => t_var('Datos guardados correctamente')];
```
---
## CmsApi (JavaScript — Client-Side)
```js
// Llamar hook (callback)
CmsApi.hook('/hooks/module_id/', { param: 'value' }, function(response) {
// response es la salida del hook
});
// Llamar hook (Promise)
CmsApi.hook('/hooks/module_id/', { param: 'value' }).then(function(res) {
// res = lo que devuelve el PHP del hook
}).catch(function(err) {
// error de red
});
// Leer registros (si está expuesto via hooks)
CmsApi.get('tableName', { where: conditions }, function(records) {
// records array
});
```
---
## CocoDB
Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control.
### `CocoDB::get($table, $where, $order, $limit, $options)`
```php
// Básico
$records = CocoDB::get('productos', ['activo' => 1], 'orden ASC', 10);
// Where con operadores avanzados
$records = CocoDB::get('productos', [
['column' => 'precio', 'value' => 100, 'operator' => '>='],
['column' => 'categoria_num', 'value' => [1, 2, 3], 'operator' => 'IN'],
]);
// Condiciones OR
$records = CocoDB::get('productos', [
['column' => 'nombre', 'value' => '%keyword%', 'operator' => 'LIKE'],
['column' => 'descripcion', 'value' => '%keyword%', 'operator' => 'LIKE', 'or' => true],
]);
// NOT
$records = CocoDB::get('productos', [
['column' => 'estado', 'value' => 'borrador', 'operator' => '=', 'not' => true],
]);
// IS NULL
$records = CocoDB::get('productos', [
['column' => 'fecha_baja', 'value' => '', 'operator' => 'IS NULL'],
]);
// Limit con offset
$records = CocoDB::get('productos', [], 'num DESC', ['limit' => 10, 'offset' => 20]);
```
#### Opciones de `get()`
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `uploads` | bool | `true` | Incluir datos de upload fields |
| `relations` | bool/array | `true` | Resolver foreign keys. Array para limitar: `['category']` |
| `relationsDepth` | int | 2 | Profundidad de relaciones anidadas |
| `translates` | string | current lang | Código de idioma |
| `groupBy` | string | null | GROUP BY clause |
| `aggregates` | array | `[]` | Funciones de agregación |
| `onlyFields` | array | null | Seleccionar solo campos específicos |
| `debug` | bool | false | Mostrar SQL query |
| `redis` | bool | null | Forzar cache Redis |
| `redis_expire` | int | 60 | TTL de cache Redis (segundos) |
### `CocoDB::insertRecords($table, $records, $functions, $options)`
```php
// Un registro
$count = CocoDB::insertRecords('contacto', [
'nombre' => 'John',
'email' => 'john@example.com',
]);
// Usar mysql_insert_id() para obtener el nuevo num
// Múltiples
$count = CocoDB::insertRecords('productos', [
['nombre' => 'Product A', 'precio' => 10],
['nombre' => 'Product B', 'precio' => 20],
]);
```
#### Opciones de insert/update
| Option | Description |
|--------|-------------|
| `forceNum` | Permite setear el campo `num` manualmente |
| `ignoreSchema` | Saltar validación de schema |
| `ignoreFields` | Array de campos a ignorar |
### `CocoDB::updateRecords($table, $records, $where, $functions, $options)`
```php
CocoDB::updateRecords('productos',
['precio' => 29.99, 'activo' => 1],
['num' => 42]
);
// Con operador en where
CocoDB::updateRecords('productos',
['activo' => 0],
[['column' => 'stock', 'value' => 0, 'operator' => '<=']]
);
```
### `CocoDB::deleteRecords($table, $where, $options)`
```php
CocoDB::deleteRecords('productos', ['num' => 42]);
CocoDB::deleteRecords('logs', [
['column' => 'fecha', 'value' => '2024-01-01', 'operator' => '<']
]);
```
### Parámetro `$functions`
Permite aplicar funciones MySQL a valores durante insert/update:
```php
CocoDB::insertRecords('logs', [
'mensaje' => 'Login exitoso',
'fecha' => '',
], [
'fecha' => 'NOW()',
]);
```
### Where Clause — Formatos
**Simple (key-value):**
```php
['campo' => 'valor'] // campo = 'valor'
```
**Avanzado (array de condiciones):**
```php
[
'column' => 'field_name',
'value' => 'match_value',
'operator' => '=', // =, !=, <, >, <=, >=, LIKE, IN, IS NULL
'or' => false, // OR en vez de AND
'not' => false, // Negar la condición
'raw_key' => false, // Saltar check de existencia de columna
]
```
---
## Creación y Actualización de Registros
### Flujo correcto
1. Consultar el esquema de la tabla (leer `cms/data/schema/{tabla}.ini.php`)
2. Revisar los tipos de campo
3. Rellenar según el tipo de dato
4. Enviar con la estructura correcta
### Tipos de campo y formato
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| **Text field** | String | `"Texto"` |
| **Text box** | String multilínea | `"Línea 1\nLínea 2"` |
| **Date/time** | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| **Wysiwyg** | String HTML | `"<p class=\"font-bold\">Texto</p>"` |
| **List** | String o número | `"activo"` o `"1"` (num si es foreign key) |
| **Checkbox** | Número 1/0 | `1` o `0` |
| **Multivalores** | String JSON | `"[{\"producto\":\"1\"}]"` |
| **Upload** | **NO enviar** — usar `upload_record_image` después de crear el registro |
---
## Table Schemas
Los schemas están en `cms/data/schema/` como archivos `.ini.php`. Definen:
- Nombres y tipos de campo
- Reglas de validación
- Relaciones (foreign keys)
- Configuración de display
---
## Ejemplos Prácticos
### Hook de Cálculo de Precio
```php
<?php
// hook.php del módulo "calcular_precio"
$precioUnitario = 50;
if ($tipo === 'mayoreo' && $cantidad > 10) {
$precioUnitario *= 0.85; // 15% descuento
}
return [
"success" => true,
"precioUnitario" => round($precioUnitario, 2),
"total" => round($precioUnitario * $cantidad, 2),
"descuento" => $tipo === 'mayoreo' ? 15 : 0
];
?>
```
```html
<hook result="precio" endpoint="/hooks/calcular_precio/" :cantidad="10" :tipo="'mayoreo'"></hook>
<p>Total: ${{ precio.total }}</p>
```
### Hook con Operaciones de BD
```php
<?php
// hook.php del módulo "procesar_compra"
$producto = CmsApi::get('productos', "num=$producto_id");
if (empty($producto)) {
return ["success" => false, "message" => "Producto no encontrado"];
}
$total = $producto[0]['precio'] * $cantidad;
// Crear venta
CmsApi::insert('ventas', [[
"usuario_num" => $usuario_id,
"producto_num" => $producto_id,
"cantidad" => $cantidad,
"total" => $total,
"fecha" => date('Y-m-d H:i:s')
]], [], ['return_last_id' => true]);
// Actualizar stock
$stock = CmsApi::get('stocks', "producto_num=$producto_id");
if (!empty($stock)) {
CmsApi::update('stocks',
["cantidad" => $stock[0]['cantidad'] - $cantidad],
"producto_num=$producto_id"
);
}
return ["success" => true, "total" => $total];
?>
```
---
## Gotchas de Base de Datos
### Nombres de columnas = nombres en el schema
Los campos foreign key NO añaden `_num` automáticamente. El nombre es exactamente el definido en el `.ini.php`. Siempre consultar `DESCRIBE cms_<tabla>` o el schema antes de escribir INSERT/UPDATE.
### Campos `name` vs `title` según `menuType`
El campo principal depende del `menuType` de la tabla:
| menuType | Campo principal | Ejemplos |
|----------|----------------|----------|
| `category` | `name` | apartados, categorias_productos, productos |
| `multi` | `title` | users, localizaciones, correos, favoritos |
| `single` | no aplica | configuracion, configuracion_tienda |
Las tablas `category` generan `name` automáticamente como campo de navegación. Las `multi` generan `title` como campo scaffold por defecto.
### Orden de tablas y campos
Al crear tablas o campos, usar `menuOrder` / `order` alto (99+) para que aparezcan al final del listado admin sin alterar el orden existente.
### `cms_uploads` tiene esquema diferente
La tabla usa `createdTime` (no `createdDate`). No tiene `createdByUserNum`, `updatedDate`, ni `updatedByUserNum`. Ver [Uploads desde Hooks](#uploads-desde-hooks) para el ejemplo completo de INSERT.
### Upload fields en PHP
Incluso con una sola imagen, los uploads son arrays:
```php
$image = @$p['foto'][0]['urlPath'] ?: '';
```
---
## CocoDB Cache en Memoria
`CocoDB::localCache()` se llama en cada request. Todos los `CmsApi::get()` se cachean en memoria por hash de SQL+options. Si insertas un registro y luego haces get con la misma query exacta, devuelve el resultado cacheado (sin el nuevo registro).
**Solución:** Usar opciones diferentes en la segunda query (ej: `ignoreSchema: true` en una, sin en la otra) para que el hash sea distinto.
---
## Generación de Slug (enlace)
`CmsApi::insert()` desde hooks NO genera el slug automáticamente (el panel admin sí lo hace). Generar manualmente:
```php
$slug = strtolower(trim($name));
$slug = preg_replace('/[áàâäã]/u', 'a', $slug);
$slug = preg_replace('/[éèêë]/u', 'e', $slug);
$slug = preg_replace('/[íìîï]/u', 'i', $slug);
$slug = preg_replace('/[óòôöõ]/u', 'o', $slug);
$slug = preg_replace('/[úùûü]/u', 'u', $slug);
$slug = preg_replace('/[ñ]/u', 'n', $slug);
$slug = preg_replace('/[^a-z0-9\-]/', '-', $slug);
$slug = preg_replace('/-+/', '-', $slug);
$slug = trim($slug, '-');
$enlace = '/' . $slug . '-' . substr(uniqid(), -6) . '/';
```
---
## Uploads desde Hooks
CmsApi no gestiona uploads directamente. Para subir archivos desde un hook:
1. Recibir base64 via `php://input`
2. Decodificar y guardar en `$_SERVER['DOCUMENT_ROOT'] . '/cms/uploads/'`
3. Insertar manualmente en `cms_uploads`
```php
$params = json_decode(file_get_contents('php://input'), true) ?: [];
$data = base64_decode($params['file_b64']);
$filename = $num . '_' . time() . '.' . $extension;
$uploadsDir = $_SERVER['DOCUMENT_ROOT'] . '/cms/uploads';
file_put_contents($uploadsDir . '/' . $filename, $data);
global $TABLE_PREFIX;
$sql = "INSERT INTO ".$TABLE_PREFIX."uploads (createdTime, tableName, recordNum, fieldName, urlPath, `order`)
VALUES (NOW(), 'productos', $num, 'foto', '/cms/uploads/$filename', 1)";
mysql_query($sql);
```
---
## Email (CocoEmail)
### Requiere template en BD
`CocoEmail::send('IDENTIFICADOR', $params, $to)` busca el template en `cms_correos` por `identificador`. Si no existe → excepción "Correo no encontrado". Si `$to` tiene emails inválidos/vacíos → "No hay destinatarios válidos".
**Siempre envolver en try/catch:**
```php
try {
hook("/hooks/customEmailHeader/", ["remote" => @$_SESSION["REMOTE_URL"]]);
CocoEmail::send("ACTIVAR_CUENTA", $user, [$user["email"]]);
} catch (Exception $e) {
// Email falló pero la operación principal sigue
}
```
### Variables en templates de email
Las variables se reemplazan con `{nombre_campo}`:
```
Hola {nombre}, tu código es {codigo}
```
El segundo parámetro de `send()` es el array de datos con las variables.
+147
View File
@@ -0,0 +1,147 @@
# Acai Modular System
## Modules
Modules are the visual building blocks of Acai websites. Each module lives in `template/estandar/modulos/<module-id>/`.
### File Structure
```
<module-id>/
├── index-base.tpl # Source template (EDIT THIS)
├── index.tpl # Compiled output (auto-generated, do NOT edit)
├── index-twig.tpl # Compiled Twig output (auto-generated, do NOT edit)
├── builder.json # Compiled builder vars (auto-generated, do NOT edit)
├── style.css # Module-scoped styles
├── script.js # Module JavaScript
├── hook.php # Module hook (optional — only if this module needs server-side logic)
├── assets/ # Vue components and other JS assets for this module
└── minified/
├── script-{hash}.js # JS minificado (servido al browser, do NOT edit)
└── style-{hash}.css # CSS minificado (servido al browser, do NOT edit)
```
### Template Syntax
Templates use a hybrid of **Twig** and **Acai attributes**. The source file is always `index-base.tpl`.
```html
<section class="hero-section" id="{{ section_id }}">
<div class="container mx-auto px-4">
<h2 data-field-type="headfield" class="text-3xl font-bold">
Title here
</h2>
<p data-field-type="textbox" class="text-lg text-gray-600">
Description text
</p>
<img data-field-type="upload" src="placeholder.jpg" class="w-full rounded-lg" />
<a data-field-type="link" href="#" class="btn">Call to action</a>
</div>
</section>
```
### Including Modules from Other Modules
```html
<module_id :param1="value1" :param2="'string value'"></module_id>
```
Parameters are received as variables inside the included module.
### Global Variables
See [twig-reference.md — Global Variables](twig-reference.md#global-variables) for the full list of variables available in all templates.
## General Sections
General sections are database-backed templates used for record views, headers, footers, and reusable layouts. They use the same template engine as modules.
### Key Differences from Modules
- Access record data via the `thisrecord` variable
- Upload fields return **arrays**: `thisrecord.image[0].urlPath`
- Additional upload metadata: `info1` (alt text), `info2`, `info3`, `info4`
- Foreign key field names match the schema exactly (may or may not have `_num` suffix — always check the `.ini.php`)
- Saved via `save_general_section()` (not `save_module()`)
- Parser type 2 = Twig (recommended), 0 = Acai legacy syntax
### Example: Record Template
```html
<article class="product-card">
<img src="{{ thisrecord.imagen[0].urlPath }}"
alt="{{ thisrecord.imagen[0].info1 }}"
class="w-full h-64 object-cover" />
<h3 class="text-xl font-semibold">{{ thisrecord.nombre }}</h3>
<p class="text-gray-600">{{ thisrecord.descripcion | raw }}</p>
<span class="text-2xl font-bold">{{ thisrecord.precio }}€</span>
</article>
```
### Variable Assignment
Use `<set>` tag to create variables from queries:
```html
<set :categories="'categorias' | get()"></set>
<set :featured="'productos' | get('destacado=1', 'orden ASC', 3)"></set>
```
## Repeatable Content (multiv2)
The `multiv2` builder field type creates repeatable groups of fields:
```html
<div c-for="item in record.items">
<h3 data-field-type="textfield">{{ item.title }}</h3>
<p data-field-type="textbox">{{ item.description }}</p>
<img data-field-type="upload" src="{{ item.image }}" />
</div>
```
Access individual items: `record.items[0].title`, `record.items[1].image`, etc.
---
## Workflow Local (Docker)
Al editar módulos en desarrollo local con Docker, los archivos compilados no se regeneran automáticamente. Hay que copiar manualmente:
```bash
# 1. Editar HTML y JS por separado
vim modulos/MODULE_ID/index-base.tpl # Solo HTML
vim modulos/MODULE_ID/script.js # Todo el JS
# 2. Copiar para que Docker los sirva
cp modulos/MODULE_ID/index-base.tpl modulos/MODULE_ID/index.tpl
cp modulos/MODULE_ID/script.js modulos/MODULE_ID/minified/script.js
```
### Herramientas de debug
- **`?compiletwig`** — Añadir a cualquier URL. Regenera los `index-twig.tpl` pero con un pipeline diferente al auto-compile. Útil para forzar recompilación, pero puede romper en ciertos contextos. Usar con precaución.
- **`?pruebas`** — Bypass de modo mantenimiento. Añadir a cualquier URL establece `$_SESSION["pruebas"]=true`. Solo hay que hacerlo una vez por sesión.
---
## General Sections — Deploy
Las general sections se identifican como `custom-{nombre_tabla}` (ej: `custom-productos`). Se despliegan con `save_general_section`, NO con `save_module`:
- `table`: nombre de la tabla (ej: `"productos"`)
- `content`: HTML del template
- `javascript`: JS
- `css`: CSS
---
## Páginas CMS (Apartados)
### Campo `controlador` obligatorio para builder
Si una página debe renderizar módulos del builder (drag-and-drop), necesita estos campos configurados:
```
controlador = "cms/lib/plugins/builder_saas/controlador.php"
precontrolador = "cms/lib/plugins/builder_saas/controlador_tabla.php"
```
Sin estos campos, la página muestra solo la general section (`custom-apartados`) en vez de los módulos asignados.
+262
View File
@@ -0,0 +1,262 @@
# Patrones de Producción
Patrones reales usados en módulos y secciones generales de producción. Usar como referencia al crear nuevos módulos.
---
## Patrón 1: Cabecera de Sección (Pretítulo + Título + Subtítulo)
Bloque de cabecera con colores y alineación configurables. Casi todos los módulos lo usan:
```html
<div c-hidden="true">
<div data-field-type="textfield" data-field-label="Color de fondo"></div>
<div data-field-type="textfield" data-field-label="Color del pretitulo"></div>
<div data-field-type="textfield" data-field-label="Color del titulo"></div>
<div data-field-type="list" data-field-label="Color titulo resaltado"
data-list-options="|Main color,1|Main color light,2|Main color dark,3|Blanco"></div>
<div data-field-type="textfield" data-field-label="Color del subtitulo"></div>
</div>
<div id="{{section_id}}"></div>
<section id="id_{{ section_id }}" class="relative"
style="background-color: {{ colordefondo ? colordefondo : 'transparent' }}">
<div class="container mx-auto max-w-7xl px-6 2xl:px-0 py-10 lg:py-20">
<div c-if="pretitulo or titulo or subtitulo" class="mb-10 lg:mb-16">
<div c-if="pretitulo" data-field-type="textfield" data-field-label="Pretitulo"
class="w-fit mx-auto text-xl md:text-2xl lg:text-3xl text-center px-4 py-2 mb-2"
style="color: {{ colordelpretitulo ? colordelpretitulo : '#111827' }}"
data-aos="fade-down" data-aos-duration="500"></div>
<div c-class="{
'titulo-main-color': colortituloresaltado is empty,
'titulo-main-color-light': colortituloresaltado == '1',
'titulo-main-color-dark': colortituloresaltado == '2',
'titulo-white': colortituloresaltado == '3'
}" style="color: {{ colordeltitulo ? colordeltitulo : '#111827' }}"
data-aos="zoom-in" data-aos-duration="500">
{% if titulo %}
<div data-field-type="headfield" data-field-label="Titulo"
class="titulo-kd text-3xl md:text-4xl lg:text-5xl font-semibold text-center"></div>
{% endif %}
</div>
<div c-if="subtitulo" data-field-type="textfield" data-field-label="Subtitulo"
class="text-xl md:text-2xl lg:text-3xl text-center mt-2"
style="color: {{ colordelsubtitulo ? colordelsubtitulo : '#111827' }}"
data-aos="fade-up" data-aos-duration="500"></div>
</div>
<!-- Contenido del módulo aquí -->
</div>
</section>
```
---
## Patrón 2: Layout Zigzag/Ajedrez (Imagen + Texto alternado)
Usa `loop.index is odd/even` para alternar:
```html
<li c-for="record in records" class="w-full py-6">
<div class="flex flex-wrap items-center"
c-class="{ 'flex-row-reverse': loop.index is even }">
<!-- Lado imagen -->
<div class="w-full md:w-2/5">
<img src="{{ record.imagen[0].urlPath | imagec(800) }}" alt=""
class="w-full h-full object-cover rounded-xl">
</div>
<!-- Lado texto -->
<div class="w-full md:w-3/5 px-6">
<h3 class="text-2xl font-semibold">{{ record.titulobloque | raw }}</h3>
<div class="wysiwyg mt-4">{{ record.textobloque | raw }}</div>
<a c-if="record.enlacebloque_anchor" href="{{ record.enlacebloque }}"
class="inline-block bg-main-color text-white rounded-xl px-6 py-3 mt-6 transition3s">
{{ record.enlacebloque_anchor }}
</a>
</div>
</div>
</li>
```
---
## Patrón 3: Acordeón FAQ
```html
<li data-field-type="multiv2" data-field-label="Records" data-aos="fade-up" data-aos-duration="800">
<div c-if="record.pregunta" class="border-t border-black">
<div class="flex py-6">
<div class="text-main-color-dark text-2xl font-bold mr-4">{{ loop.index }}.</div>
<div class="faq-wrapper w-full">
<div class="faq-page select-none flex justify-between items-center cursor-pointer text-2xl font-medium">
<div data-field-type="textfield" data-field-label="Pregunta" class="flex-1 pr-[10%]"></div>
<span class="faq-icon w-12 h-12 ml-4"></span>
</div>
<div class="faq-body hidden py-4" data-field-type="wysiwyg" data-field-label="Respuesta"></div>
</div>
</div>
</div>
</li>
```
JavaScript para toggle:
```javascript
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".faq-page").forEach(faq => {
faq.addEventListener("click", function () {
const body = faq.nextElementSibling;
const isActive = faq.classList.toggle("active");
body.classList.toggle("hidden", !isActive);
AOS.refresh();
});
});
});
```
---
## Patrón 4: Formulario de Contacto Completo
```html
<set :tienda="'configuracion_tienda' | get('num != 0')[0]"></set>
<set :logo="tienda.logo.0.urlPath ? 'https://' ~ server.HTTP_HOST ~ tienda.logo.0.urlPath : 'https://' ~ server.HTTP_HOST ~ '/template/estandar/images/logo.png'"></set>
{% set imagen = '<img src="' ~ logo ~ '" style="max-height:150px; display: block; margin: 0 auto;">' %}
{% set gracias = 'apartados' | get('num = 20').0 %}
<c-form method="post"
mailRecord="['correos', 'CONTACTO']"
honeypot="true"
header="imagen"
sendToClient="'email'"
tableName="'solicitudes'"
redirect="gracias.enlace"
class="text-black lg:text-lg max-w-2xl mx-auto">
<input type="text" name="nombre" required
placeholder="{{ 'Nombre' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
<input type="email" name="email" required
placeholder="{{ 'Email' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
<input type="text" name="telefono" required
placeholder="{{ 'Teléfono' | translate }}"
class="w-full bg-white border border-neutral-400 rounded-xl px-6 py-2 my-1" />
<textarea name="comentario" cols="30" rows="5"
placeholder="{{ 'Escribe aquí tu comentario' | translate }}..."
class="w-full bg-white border border-neutral-400 rounded-xl resize-none px-6 py-2 my-1"></textarea>
<label class="w-full flex items-start mt-4">
<input required type="checkbox" class="mt-1" />
<span class="text-xs sm:text-sm ml-3">{{ 'Acepto las condiciones legales' | translate | raw }}</span>
</label>
<captcha class="mt-8"></captcha>
<div class="flex justify-center mt-10">
<button type="submit"
class="bg-main-color hover:bg-white text-white hover:text-main-color-dark font-semibold rounded-xl border-2 border-main-color-dark transition3s px-6 py-3">
{{ 'Enviar' | translate }}
</button>
</div>
</c-form>
```
---
## Patrón 5: Compartir en Redes Sociales
```html
<ul class="flex flex-wrap -mx-1 mt-4">
<li class="w-1/2 sm:w-1/3 lg:w-1/5 p-1">
<a href="https://www.facebook.com/sharer.php?u=https://{{ server.HTTP_HOST }}{{ thisrecord.enlace }}"
target="_blank" rel="noopener">
<div class="flex items-center bg-[#306199] text-white rounded-md px-4 py-2.5">Facebook</div>
</a>
</li>
<li class="w-1/2 sm:w-1/3 lg:w-1/5 p-1">
<a href="https://wa.me/?text={{ thisrecord.name }} - https://{{ server.HTTP_HOST }}{{ thisrecord.enlace }}"
target="_blank" rel="noopener">
<div class="flex items-center bg-[#03c100] text-white rounded-md px-4 py-2.5">WhatsApp</div>
</a>
</li>
</ul>
```
---
## Patrón 6: Sección General — Detalle de Producto
```html
<set :tienda="'configuracion_tienda' | get('num !=0')[0]"></set>
<section class="detalle-producto">
<div class="container mx-auto max-w-7xl px-6 2xl:px-0 mt-20 mb-10">
<!-- Imagen con lightbox -->
<div class="relative p-1/5 rounded-xl overflow-hidden" data-aos="zoom-in" data-aos-duration="800">
<a href="{{ thisrecord.foto.0.urlPath }}" class="glightbox">
<img src="{{ thisrecord.foto.0.urlPath | imagec(800) }}" alt="{{ thisrecord.name }}"
class="absolute top-0 left-0 w-full h-full object-cover">
</a>
</div>
<h1 class="text-2xl font-semibold mt-6">{{ thisrecord.name }}</h1>
<span class="text-lg text-gray-600">{{ thisrecord.categoria_bd.0.name }}</span>
<!-- Precio con descuento -->
<div c-if="thisrecord.precio_descuento" class="flex items-center mt-4">
<div class="text-red-500 text-xl line-through">{{ thisrecord.precio }} &euro;</div>
<div class="text-2xl font-semibold ml-4">{{ thisrecord.precio_descuento }} &euro;</div>
</div>
<div c-else class="text-2xl font-semibold mt-4">{{ thisrecord.precio }} &euro;</div>
<div c-if="thisrecord.descripcion" class="wysiwyg mt-6">{{ thisrecord.descripcion | raw }}</div>
<!-- Galería secundaria -->
<div c-if="thisrecord.otras_fotos" class="flex flex-wrap -mx-1 mt-8">
<div c-for="foto in thisrecord.otras_fotos" class="w-1/3 md:w-1/4 p-1">
<a href="{{ foto.urlPath }}" class="glightbox">
<img src="{{ foto.urlPath | imagec(400) }}" alt="{{ thisrecord.name }}" class="w-full h-40 object-cover rounded">
</a>
</div>
</div>
</div>
</section>
<!-- Productos relacionados -->
{% set productosRelacionados = 'productos' | get('categoria = ' ~ thisrecord.categoria ~ ' and num!=' ~ thisrecord.num, 'globalOrder ASC', '3') %}
<section c-if="productosRelacionados" class="py-20 bg-gray-100">
<div class="container mx-auto max-w-7xl px-6 2xl:px-0">
<h2 class="text-3xl text-center mb-10">{{ 'Productos relacionados' | translate }}</h2>
<ul class="flex flex-wrap -mx-4">
<li c-for="producto in productosRelacionados" class="w-full sm:w-1/2 lg:w-1/3 px-4">
<bloqueproducto_i7aunn :producto="producto"></bloqueproducto_i7aunn>
</li>
</ul>
</div>
</section>
```
---
## Patrón 7: Galería con Carousel (modo Gallery)
```html
<div class="c-tns-wrapper" data-autoplay-timeout="8000" data-mode="gallery" data-speed="400" data-nav="true">
<ul class="c-tns-container">
<li data-field-type="uploadMulti" data-field-label="Imagenes" data-field-info1="titulo">
<div class="relative min-h-screen">
<img class="absolute top-0 left-0 w-full h-full object-cover lazyload"
data-src="{{ uploadMulti.urlPath | imagec(2100) }}"
alt="{{ uploadMulti.info1 }}">
</div>
</li>
</ul>
<div class="pointer-events-none c-tns-nav-container absolute bottom-10 left-0 w-full h-full flex justify-center items-end z-20">
<div c-for="imagen in imagenes"
class="select-none pointer-events-auto transition3s flex items-center bg-white bg-opacity-50 cursor-pointer rounded-full w-4 lg:w-5 h-4 lg:h-5 mx-1">
</div>
</div>
</div>
```
+93
View File
@@ -0,0 +1,93 @@
# Quick Reference
## Reglas Críticas
| Regla | Correcto | Incorrecto |
|-------|----------|------------|
| Nombres de tabla | `'productos'` | `'cms_productos'` |
| Primary key | `record.num` | `record.id` |
| Foreign keys | `categoria_num` | `categoria_id` |
| Upload fields | `record.imagen[0].urlPath` | `record.imagen` |
| Optimizar imagen | `record.imagen[0].urlPath \| imagec(800)` | `record.imagen.url` |
| Filtros Twig | `{{ 'table' \| get() }}` | `{{ get('table') }}` |
| Campo enlace | `{{ producto.enlace }}` (ya tiene barras) | `"/{{ producto.enlace }}/"` |
| Nombres builder vars | `data-field-label` → sin espacios/especiales, minúsculas | Mantener casing original |
| Checkbox | `1` o `0` (número) | `true`/`false` |
| Formato fecha | `YYYY-MM-DD HH:mm:ss` | Cualquier otro formato |
| c-if igualdad | `c-if="x = 'valor'"` (un `=`) | `c-if="x == 'valor'"` |
| Twig if igualdad | `{% if x == 'valor' %}` (doble `==`) | `{% if x = 'valor' %}` |
| queryDB tablas | `SELECT * FROM cms_tabla` (con prefijo) | `SELECT * FROM tabla` |
| get tablas | `'tabla' \| get()` (sin prefijo) | `'cms_tabla' \| get()` |
## Builder Variable Types
| Type | Elemento | Retorna |
|------|----------|---------|
| `textfield` | `<p>` | String |
| `headfield` | `<h1>`-`<h6>` | String + var `_tag` |
| `textbox` | `<div>` | String multilínea |
| `wysiwyg` | `<div class="wysiwyg">` | HTML string |
| `link` | `<a>` | URL string |
| `upload` | `<img>` | Array de `{urlPath, info1}` |
| `uploadMulti` | `<li>` | Itera archivos subidos |
| `list` (fijo) | `<div data-list-options="...">` | Valor seleccionado |
| `list` (tabla) | `<div data-list-table="...">` | `num` del registro |
| `multiv2` | `<li>` wrapper | Array de objetos |
## Acai HTML Attributes
| Atributo | Uso | Ejemplo |
|----------|-----|---------|
| `c-if` | Condicional | `<p c-if="activo = 1">` |
| `c-else` | Rama else | `<p c-else>` |
| `c-for` | Loop array | `<li c-for="item in items">` |
| `c-for` | Loop tabla | `<li c-for="p in productos" c-where="'activo=1'" c-limit="10">` |
| `c-hidden` | Variable oculta | `<p c-hidden="true" data-field-type="textfield">` |
| `c-class` | Clase condicional | `<div c-class="{ 'bg-red': color == '1' }">` |
| `c-form` | Formulario | `<c-form tableName="'contacto'" captcha="true">` |
## Twig Filters
| Filtro | Uso |
|--------|-----|
| `get` | `'table' \| get(where, order, limit)` |
| `hook` | `'hooks/module_id/' \| hook({params})` |
| `module` | `'module_id' \| module({params})` |
| `queryDB` | `'SELECT ...' \| queryDB()` |
| `imagec` | `path \| imagec(width)` |
| `translate` | `'text' \| translate` (Twig) / `t_var('text')` (PHP) / `CmsApi.t_var('text')` (JS) |
| `json_decode` | `'json_string' \| json_decode` |
| `raw` | `variable \| raw` |
| `truncate` | `text \| truncate(100)` |
## Formato de datos para registros
| Tipo | Formato | Ejemplo |
|------|---------|---------|
| Text field | String | `"Texto"` |
| Text box | String multilínea | `"Línea 1\nLínea 2"` |
| Date/time | `YYYY-MM-DD HH:mm:ss` | `"2025-12-03 10:30:00"` |
| Wysiwyg | HTML string | `"<p class=\"font-bold\">Texto</p>"` |
| List | String o número | `"activo"` o `"1"` |
| Checkbox | Número 1/0 | `1` o `0` |
| Multivalores | String JSON | `"[{\"producto\":\"1\"}]"` |
| Upload | NO enviar — subir imagen después de crear registro |
## Variables globales
| Variable | Descripción |
|----------|-------------|
| `section_id` | ID único por instancia del módulo |
| `server.HTTP_HOST` | Dominio actual |
| `loop.index` | Índice de iteración (1-based) |
| `loop.index is odd/even` | Para layouts alternados |
| `interno` | True dentro del editor CMS |
| `thisrecord` | Registro actual (en secciones generales) |
## Testing
| Regla | Detalle |
|-------|---------|
| Hooks devuelven JSON en network | Al testear con Playwright, usar `browser_network_requests` o `fetch()` en `browser_evaluate`. El snapshot visual no muestra respuestas de hooks |
| Vue tarda en montar | Después de navegar a una página con Vue, esperar 3-5s antes de verificar contenido |
| `?pruebas` | Añadir a URL para bypass de modo mantenimiento (una vez por sesión) |
+224
View File
@@ -0,0 +1,224 @@
# Twig Reference
Referencia completa de filtros, operadores, sintaxis y variables globales disponibles en templates Acai. Solo filtros (con `|`), nunca funciones Twig.
## `get` — Consultar tabla de BD
```twig
{{ 'table_name' | get(where, order, limit) }}
```
- `table_name`: sin prefijo `cms_`
- `where`: string SQL o objeto (opcional)
- `order`: string de orden (opcional)
- `limit`: int (opcional)
```twig
{# Todos los registros #}
{% set products = 'productos' | get() %}
{# Con WHERE string #}
{% set active = 'productos' | get('activo=1') %}
{# Con WHERE objeto #}
{% set active = 'productos' | get({activo: 1}) %}
{# Con WHERE + ORDER + LIMIT #}
{% set latest = 'noticias' | get('publicado=1', 'fecha DESC', 6) %}
{# Completo #}
{% set caros = 'productos' | get('precio > 100', 'precio DESC', 20) %}
{# Single record (primer resultado) #}
{% set product = 'productos' | get({num: 42}) %}
{{ product[0].nombre }}
```
Iterar resultados:
```twig
{% for producto in 'productos' | get('activo=1', 'num DESC', 10) %}
<h3>{{ producto.titulo }}</h3>
{% endfor %}
```
## `queryDB` — SQL directo
Usa nombre de tabla completo WITH prefijo `cms_`.
```twig
{% set results = 'SELECT * FROM cms_productos WHERE precio > 100 ORDER BY precio ASC' | queryDB() %}
{# JOIN complejo #}
{% set top = 'SELECT p.*, COUNT(v.num) as ventas
FROM cms_productos p
LEFT JOIN cms_ventas v ON v.producto_num = p.num
GROUP BY p.num
ORDER BY ventas DESC
LIMIT 5' | queryDB() %}
```
Usar solo cuando `get` no sea suficiente.
## `hook` — Ejecutar PHP Hook
```twig
{# Llamar y mostrar resultado #}
{{ 'hooks/module_id/' | hook({param1: 'value', param2: variable}) }}
{# Capturar en variable #}
{% set result = 'hooks/calcular_precio/' | hook({cantidad: 5, tipo: 'mayoreo'}) %}
<p>Total: ${{ result.total }}</p>
```
## `module` — Renderizar otro módulo
```twig
{{ 'other_module_id' | module({param1: value1}) }}
{# Capturar en variable #}
{% set carrito = 'carrito_compras' | module({usuario_id: 123}) %}
```
## `imagec` — Optimizar/redimensionar imágenes
```twig
{# Redimensionar a ancho #}
<img src="{{ record.image[0].urlPath | imagec(400) }}" />
{# En srcset #}
<img src="{{ record.image[0].urlPath | imagec(800) }}"
srcset="{{ record.image[0].urlPath | imagec(400) }} 400w,
{{ record.image[0].urlPath | imagec(800) }} 800w" />
```
## `translate` — Traducción
```twig
{{ 'Bienvenido' | translate }}
{{ variable | translate }}
```
## `raw` — Renderizar HTML sin escapar
```twig
{{ record.description | raw }}
```
## `truncate` — Truncar texto
```twig
{{ record.description | truncate(150) }}
```
## `json_decode` — Parsear JSON
```twig
{% set data = jsonString | json_decode %}
{{ data.key }}
```
## `split`, `filter` — Filtros estándar Twig
Misma funcionalidad que Twig estándar.
---
## Operadores y Sintaxis
### Concatenación
Twig usa `~` (no `.` ni `+`):
```twig
{{ 'Hello ' ~ name ~ '!' }}
{% set url = '/products/' ~ product.slug ~ '/' %}
```
### Concatenar en filtros
```twig
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
```
### Ternario / Default
```twig
{{ title | default('Default Title') }}
{{ isActive ? 'active' : 'inactive' }}
```
### Comparaciones
```twig
{% if items | length > 0 %}
{% if type == 'premium' %}
{% if name is not empty %}
```
En `c-if` usar `=` (simple). En `{% if %}` usar `==` (doble).
---
## Ejemplos complejos
### Galería con productos y stock
```twig
{% for producto in 'productos' | get('destacado=1', 'num DESC', 12) %}
<div class="producto-card">
<img src="{{ producto.imagen[0].urlPath | imagec(400) }}" alt="{{ producto.titulo }}">
<h3>{{ producto.titulo }}</h3>
<p>{{ producto.descripcion | truncate(100) }}</p>
{% set stock = 'stocks' | get('producto_num=' ~ producto.num) %}
<span>Stock: {{ stock[0].cantidad }}</span>
</div>
{% endfor %}
```
### Múltiples filtros combinados
```twig
{% set categorias = 'categorias' | get() %}
{% set productos = 'productos' | get('activo=1', 'titulo ASC', 20) %}
{% set stats = 'hooks/obtener_stats/' | hook({fecha_inicio: '2024-01-01'}) %}
<h1>{{ stats.titulo | translate }}</h1>
<nav>
{% for cat in categorias %}
<a href="">{{ cat.nombre }}</a>
{% endfor %}
</nav>
{% for prod in productos %}
<div>
<img src="{{ prod.imagen[0].urlPath | imagec(300) }}" alt="">
<h3>{{ prod.titulo }}</h3>
</div>
{% endfor %}
```
---
## Puntos importantes
1. **Solo filtros, no funciones:** `'tabla' | get()` no `get('tabla')`
2. **Upload fields son arrays:** `record.imagen[0].urlPath`, no `record.imagen`
3. **Tablas sin prefijo `cms_`** en `get()`. Con prefijo en `queryDB()`
4. **Concatenar con `~`:** `'stocks' | get('producto_num=' ~ producto.num)`
---
## Global Variables
Variables disponibles en todos los templates (módulos y general sections):
| Variable | Description |
|----------|-------------|
| `section_id` | Unique ID per module instance (use for anchors, JS scoping) |
| `interno` | `true` when viewing in CMS editor, `false` on public site |
| `server.HTTP_HOST` | Current domain |
| `loop.index` | 1-based iteration index (inside `c-for`) |
| `loop.index is odd` / `loop.index is even` | For alternating layouts |
| `thisrecord` | Current record data (only in general sections) |
+695
View File
@@ -0,0 +1,695 @@
# Ejemplos de Builder Vue - Producción
Colección de ejemplos reales de archivos `builder.vue` implementados en producción. Cada ejemplo incluye el código completo y notas sobre decisiones de diseño importantes.
---
## Ejemplo 1: Banner Slideshow
### Descripción
Banner hero con slideshow de imágenes o video de fondo, overlay configurable, textos principales (pretítulo, título, subtítulo) y botón de llamada a la acción.
### Características principales
- **5 tabs organizados**: Configuración, Imágenes, Textos, Enlaces, Colores
- **Selector imagen/video**: Toggle con iconos que alterna entre imagen y video con `v-show`
- **Overlay completo**: Tipo (sin degradado/con degradado), color y opacidad agrupados en tab Imágenes
- **Colorpickers**: Para overlay y color de texto general con textfield oculto
- **Toggles con iconos**: Sombra (X/check), tipo imagen (foto/video), tipo overlay (cuadrado/degradado)
- **Logo adicional**: Upload de logo que se superpone al banner
- **Configuraciones globales**: Posición texto, sombra, container, altura banner
### Decisiones de diseño clave
1. **Selector imagen/video como primer campo del tab Imágenes**: El toggle de tipo de fondo está al inicio del tab Imágenes, antes de los uploads, según la regla 10.1
2. **v-show en uploads**:
- Upload de imágenes: `v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == ''"`
- Upload de video: `v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == '1'"`
- NUNCA quitar estos `v-show`, son esenciales
3. **Grupo overlay en tab Imágenes**: El grupo completo (tipo + color + opacidad) está en Imágenes, NO en Colores, porque afecta directamente al fondo visual (regla 10.2)
4. **Radio borde en tab Enlaces**: Campo que afecta al botón va en el tab del enlace, no en Configuración (regla 10.3)
5. **Recuerda con HTML escapado**: El campo título incluye un "Recuerda" con etiquetas HTML escapadas (`&lt;span&gt;`) para guiar al usuario
6. **Color del texto en tab Colores**: El color general del texto va en su propio tab, no mezclado con el overlay
### Tabs configurados
```javascript
tabsConfig: [
{ id: "configuracion", label: "Configuración", color: "#f59e0b", icon: '<svg>...</svg>' },
{ id: "imagenes", label: "Imágenes", color: "#10b981", icon: '<svg>...</svg>' },
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg>...</svg>' },
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg>...</svg>' },
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg>...</svg>' }
]
```
### Componentes utilizados
- `acai-vue-tabs` - Sistema de tabs con storage-key y apply-theme-styles
- `acai-vue-selectv2` - Selectores (algunos con `:toggle-icons`)
- `acai-vue-textfield` - Campos de texto simple (pretítulo, subtítulo)
- `acai-vue-title` - Encabezado principal con placeholder
- `acai-vue-linkv2` - Enlaces con `:show_text="true"`
- `acai-vue-upload` - Uploads de imagen/video/logo con todas las props necesarias
- `acai-vue-colorpicker` - Pickers de color con textfield oculto asociado
### Iconos con toggle
```javascript
iconosSombra: {
'': '<svg>...(icon-tabler-x)</svg>',
'1': '<svg>...(icon-tabler-check)</svg>'
},
iconosTipoImagen: {
'': '<svg>...(icon-tabler-photo)</svg>',
'1': '<svg>...(icon-tabler-video)</svg>'
},
iconosOverlay: {
'': '<svg>...(icon-tabler-square)</svg>',
'1': '<svg>...(icon-tabler-gradient)</svg>'
}
```
### Código completo
```vue
<template>
<div v-if="data">
<acai-vue-tabs v-if="data" :tabs="tabsConfig" :storage-key="'banner-slideshow-tabs-' + (section_id || 'default')" :apply-theme-styles="true">
<!-- TAB: CONFIGURACIÓN -->
<template #configuracion="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Ajustes generales del banner</p>
</div>
<!-- Lado texto -->
<div class="flex w-full items-center">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M9 15h-2" /><path d="M13 12h-6" /><path d="M11 9h-4" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Posición del texto :</b> Define la alineación del contenido dentro del banner.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'ladotexto'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Ver sombra -->
<div class="w-full items-center mt-6">
<div class="w-full flex items-center">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-shadow"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M13 12h5" /><path d="M13 15h4" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /></svg>
</div>
<div class="relative">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'versombra'" :toggle-icons="iconosSombra" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Sombra en textos :</b> Aplica un efecto de sombra a los textos del banner.</p>
</div>
<!-- Container -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-width"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6" /><path d="M10 18h-7" /><path d="M21 18h-7" /><path d="M6 15l-3 3l3 3" /><path d="M18 15l3 3l-3 3" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Ancho del contenedor :</b> Limita el ancho máximo del contenido textual.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto ocupa todo el ancho disponible (Full container).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'container'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Altura banner -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-height"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 20h-6a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h6" /><path d="M18 14v7" /><path d="M18 3v7" /><path d="M15 18l3 3l3 -3" /><path d="M15 6l3 -3l3 3" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Altura del banner :</b> Altura visible de la sección del banner.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es pantalla completa (100vh).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'alturadelbanner'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
</template>
<!-- TAB: IMÁGENES -->
<template #imagenes="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Fondo y elementos visuales del banner</p>
</div>
<!-- Tipo de imagen (selector imagen/video) -->
<div class="w-full items-center mt-6">
<div class="w-full flex items-center">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-photo-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15h-3a3 3 0 0 1 -3 -3v-6a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v3" /><path d="M9 12a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3l0 -6" /><path d="M3 12l2.296 -2.296a2.41 2.41 0 0 1 3.408 0l.296 .296" /><path d="M14 13.5v3l2.5 -1.5l-2.5 -1.5" /><path d="M7 6v.01" /></svg>
</div>
<div class="relative">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'tipodeimagen'" :toggle-icons="iconosTipoImagen" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Tipo de fondo :</b> Selecciona si el fondo del banner será una imagen o un vídeo.</p>
</div>
<!-- Imágenes (visible cuando es imagen o vacío) -->
<div class="flex w-full items-center mt-6" v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == ''">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" :style="{ color: color }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M15 6l.01 0" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3l0 -8" /><path d="M3 13l4 -4a3 5 0 0 1 3 0l4 4" /><path d="M13 12l2 -2a3 5 0 0 1 3 0l3 3" /><path d="M8 21l.01 0" /><path d="M12 21l.01 0" /><path d="M16 21l.01 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Imágenes :</b> Añade las imágenes que rotarán en el slideshow del banner.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-upload ref="upload_imagenes" :reference="'upload_imagenes'" :tablename="'builder_custom'" :fieldname="builder.vars.imagenes.relations.builder_custom" :recordnum="data.imagenes.recordNum" :field="data.imagenes" :builder_field="builder.vars.imagenes" :presavetempid="data.imagenes.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('imagenes',data,false,'upload_imagenes')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
</div>
</div>
</div>
<!-- Video (visible cuando es video) -->
<div class="flex w-full items-center mt-6" v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == '1'">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Vídeo :</b> Sube el vídeo de fondo del banner.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-upload ref="upload_video" :reference="'upload_video'" :tablename="'builder_custom'" :fieldname="builder.vars.video.relations.builder_custom" :recordnum="data.video.recordNum" :field="data.video" :builder_field="builder.vars.video" :presavetempid="data.video.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('video',data,false,'upload_video')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
</div>
</div>
</div>
<!-- Logo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-icons"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6.5 6.5m-3.5 0a3.5 3.5 0 1 0 7 0a3.5 3.5 0 1 0 -7 0" /><path d="M2.5 21h8l-4 -7z" /><path d="M14 3l7 7" /><path d="M14 10l7 -7" /><path d="M14 14h7v7h-7z" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Logo :</b> Imagen del logotipo que aparecerá sobre el banner.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-upload ref="upload_logo" :reference="'upload_logo'" :tablename="'builder_custom'" :fieldname="builder.vars.logo.relations.builder_custom" :recordnum="data.logo.recordNum" :field="data.logo" :builder_field="builder.vars.logo" :presavetempid="data.logo.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('logo',data,false,'upload_logo')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
</div>
</div>
</div>
<!-- Tipo de overlay -->
<div class="w-full items-center mt-6">
<div class="w-full flex items-center">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-background"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 8l4 -4" /><path d="M14 4l-10 10" /><path d="M4 20l16 -16" /><path d="M20 10l-10 10" /><path d="M20 16l-4 4" /></svg>
</div>
<div class="relative">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'tipodeoverlay'" :toggle-icons="iconosOverlay" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Tipo de overlay :</b> Elige si la capa de color se aplica de forma uniforme o con degradado.</p>
</div>
<!-- Color del overlay -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-test-pipe"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M20 8.04l-12.122 12.124a2.857 2.857 0 1 1 -4.041 -4.04l12.122 -12.124" /><path d="M7 13h8" /><path d="M19 15l1.5 1.6a2 2 0 1 1 -3 0l1.5 -1.6" /><path d="M15 3l6 6" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del overlay :</b> Color y opacidad de la capa que se superpone sobre la imagen o vídeo.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto el overlay es transparente.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeloverlay'" :label="'Color overlay'" :color="'transparent'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeloverlay'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
</template>
<!-- TAB: TEXTOS -->
<template #textos="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Contenido textual del banner</p>
</div>
<!-- Pretítulo -->
<div class="flex w-full items-center">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Pretítulo :</b> Texto que aparece encima del título principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-textfield :builder="builder" :data="data" :field="'pretitulo'" :placeholder="'Ej: Bienvenidos'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Título -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M7 12h10" /><path d="M7 4v16" /><path d="M17 4v16" /><path d="M15 20h4" /><path d="M15 4h4" /><path d="M5 20h4" /><path d="M5 4h4" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Título :</b> Encabezado principal del banner.</p>
</div>
<p class="text-xs leading-snug text-gray-600 font-light mt-2"><b class="text-black">Recuerda :</b> utiliza las etiquetas <span class="text-black font-semibold">&lt;span&gt; &lt;/span&gt;</span> para resaltar las palabras clave.</p>
<div class="relative mt-2 ml-14">
<acai-vue-title :builder="builder" :data="data" :field="'titulo'" placeholder="Título del banner" @save-data="saveData"></acai-vue-title>
</div>
</div>
</div>
<!-- Subtítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Subtítulo :</b> Texto que aparece debajo del título principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-textfield :builder="builder" :data="data" :field="'subtitulo'" :placeholder="'Ej: Tu solución ideal'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
</template>
<!-- TAB: ENLACES -->
<template #enlaces="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Botón de llamada a la acción</p>
</div>
<!-- Enlace -->
<div class="flex w-full items-center">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentcolor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Enlace :</b> Configura el botón con su texto y destino.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-linkv2 :builder="builder" :data="data" :field="'enlace'" @save-data="saveData" :show_text="true"></acai-vue-linkv2>
</div>
</div>
</div>
<!-- Radio borde enlace -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-border-radius"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-4a4 4 0 0 1 4 -4h4" /><path d="M16 4l0 .01" /><path d="M20 4l0 .01" /><path d="M20 8l0 .01" /><path d="M20 12l0 .01" /><path d="M4 16l0 .01" /><path d="M20 16l0 .01" /><path d="M4 20l0 .01" /><path d="M8 20l0 .01" /><path d="M12 20l0 .01" /><path d="M16 20l0 .01" /><path d="M20 20l0 .01" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Radio del borde :</b> Redondeo de las esquinas del botón de enlace.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es 'sm' (ligeramente redondeado).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'radiobordeenlace'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
</template>
<!-- TAB: COLORES -->
<template #colores="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Personalización de colores</p>
</div>
<!-- Color del texto -->
<div class="flex w-full items-center">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M8.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M16.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del texto :</b> Color general de todos los textos del banner.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> por defecto es blanco (#ffffff).</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeltexto'" :label="'Color del texto'" :color="'#ffffff'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeltexto'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
</template>
</acai-vue-tabs>
</div>
</template>
<style scoped></style>
<script>
module.exports = {
props: ["active", "section_id"],
data() {
return {
data: null,
builder: null,
idiomas: IDIOMAS,
tabsConfig: [
{ id: "configuracion", label: "Configuración", color: "#f59e0b", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>' },
{ id: "imagenes", label: "Imágenes", color: "#10b981", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M11 20h-4a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v4" /><path d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l3 3" /><path d="M14 14l1 -1c.31 -.298 .644 -.497 .987 -.596" /><path d="M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39" /></svg>' },
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 10h-14" /><path d="M5 6h14" /><path d="M14 14h-9" /><path d="M5 18h6" /><path d="M18 15v6" /><path d="M15 18h6" /></svg>' },
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6"/><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"/><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"/></svg>' },
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>' },
],
iconosSombra: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-shadow-off"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5.634 5.638a9 9 0 0 0 12.728 12.727m1.68 -2.32a9 9 0 0 0 -12.086 -12.088" /><path d="M16 12h2" /><path d="M13 15h2" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /><path d="M3 3l18 18" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-shadow"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M13 12h5" /><path d="M13 15h4" /><path d="M13 18h1" /><path d="M13 9h4" /><path d="M13 6h1" /></svg>'
},
iconosTipoImagen: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" /></svg>'
},
iconosOverlay: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-square"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-gradient"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 3v18" /><path d="M3 14h4" /><path d="M3 10h4" /><path d="M3 6h4" /><path d="M3 18h4" /></svg>'
}
};
},
components: {
'acai-vue-tabs': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetabs.vue?timestamp=' + new Date().getTime()),
"acai-vue-selectv2": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueselect.vue?timestamp=" + new Date().getTime()),
"acai-vue-colorpicker": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuecolorpicker.vue?timestamp=" + new Date().getTime()),
"acai-vue-upload": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueupload.vue?timestamp=" + new Date().getTime()),
"acai-vue-title": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetitle.vue?timestamp=" + new Date().getTime()),
"acai-vue-linkv2": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuelinkv2.vue?timestamp=" + new Date().getTime()),
"acai-vue-textfield": httpVueLoader("https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextfield.vue?timestamp=" + new Date().getTime()),
},
mounted() { this.$emit("child-mounted"); },
methods: { saveData() { this.$emit("save-data"); } },
};
</script>
```
---
## Ejemplo 2: [Módulo de texto genérico]
```vue
<template>
<acai-vue-tabs v-if="data" :tabs="tabsConfig" :storage-key="'texto-cabecera-tabs-' + (section_id || 'default')" :apply-theme-styles="true">
<!-- Tab Configuración -->
<template #config="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Configuración general del módulo</p>
</div>
<!-- Formato (2 opciones = toggle) -->
<div class="w-full items-center mt-6">
<div class="w-full flex items-center">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-layout"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /><path d="M4 13m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v3a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /><path d="M14 4m0 2a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-2a2 2 0 0 1 -2 -2z" /></svg>
</div>
<div class="relative">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'formato'" :toggle-icons="iconosFormato" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Formato :</b> Vertical (todo en una columna) u Horizontal (cabecera y texto en 2 columnas).</p>
</div>
<!-- Alineación texto -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-align-justified"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 6l16 0" /><path d="M4 12l16 0" /><path d="M4 18l12 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Alineación texto :</b> Alineación del contenido.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es izquierda.</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'alineaciontexto'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Container texto -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-arrow-autofit-width"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6" /><path d="M10 18h-7" /><path d="M21 18h-7" /><path d="M6 15l-3 3l3 3" /><path d="M18 15l3 3l-3 3" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Container texto :</b> Ancho máximo del contenido.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto ocupa el ancho completo.</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'containertexto'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Estilo enlace -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-click"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 12l3 0" /><path d="M12 3l0 3" /><path d="M7.8 7.8l-2.2 -2.2" /><path d="M16.2 7.8l2.2 -2.2" /><path d="M7.8 16.2l-2.2 2.2" /><path d="M12 12l9 3l-4 2l-2 4l-3 -9" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Estilo enlace :</b> Estilo visual del botón.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto usa el color principal (Main color).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'estiloenlace'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Radio borde enlace -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-border-radius"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 12v-4a4 4 0 0 1 4 -4h4" /><path d="M16 4l0 .01" /><path d="M20 4l0 .01" /><path d="M20 8l0 .01" /><path d="M20 12l0 .01" /><path d="M4 16l0 .01" /><path d="M20 16l0 .01" /><path d="M4 20l0 .01" /><path d="M8 20l0 .01" /><path d="M12 20l0 .01" /><path d="M16 20l0 .01" /><path d="M20 20l0 .01" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Radio borde enlace :</b> Redondeo del botón.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es "sm".</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'radiobordeenlace'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
</template>
<!-- Tab Textos -->
<template #textos="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Contenido textual del módulo</p>
</div>
<!-- Pretítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Pretítulo :</b> Texto que aparece encima del título principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-textfield :builder="builder" :data="data" :field="'pretitulo'" :placeholder="'Ej: Descubre más'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Título -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-heading"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M7 12h10" /><path d="M7 5v14" /><path d="M17 5v14" /><path d="M15 19h4" /><path d="M15 5h4" /><path d="M5 19h4" /><path d="M5 5h4" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Título :</b> Encabezado principal del módulo.</p>
</div>
<p class="text-xs leading-snug text-gray-600 font-light mt-2"><b class="text-black">Recuerda :</b> utiliza las etiquetas <span class="text-black font-semibold">&lt;span&gt; &lt;/span&gt;</span> para resaltar las palabras clave.</p>
<div class="relative mt-2 ml-14">
<acai-vue-title :builder="builder" :data="data" :field="'titulo'" placeholder="Título del módulo" @save-data="saveData"></acai-vue-title>
</div>
</div>
</div>
<!-- Subtítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-text-size"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 7v-2h13v2" /><path d="M10 5v14" /><path d="M12 19h-4" /><path d="M15 13v-1h6v1" /><path d="M18 12v7" /><path d="M17 19h2" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Subtítulo :</b> Texto que aparece debajo del título principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-textfield :builder="builder" :data="data" :field="'subtitulo'" :placeholder="'Ej: Conoce nuestros servicios'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Texto -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-file-text"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2" /><path d="M9 9l1 0" /><path d="M9 13l6 0" /><path d="M9 17l6 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Texto :</b> Contenido descriptivo principal.</p>
</div>
<div class="relative mt-2 ml-14">
<acai-vue-wysiwyg :builder="builder" :data="data" :field="'texto'" @save-data="saveData"></acai-vue-wysiwyg>
</div>
</div>
</div>
</template>
<!-- Tab Enlaces -->
<template #enlaces="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Enlaces del módulo</p>
</div>
<!-- Enlace -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-link"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Enlace :</b> Botón de acción principal.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Si no se configura, el botón no se mostrará.</p>
<div class="relative mt-2 ml-14">
<acai-vue-linkv2 :builder="builder" :data="data" :field="'enlace'" :show_text="true" @save-data="saveData"></acai-vue-linkv2>
</div>
</div>
</div>
</template>
<!-- Tab Colores -->
<template #colores="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Personalización de colores</p>
</div>
<!-- Color de fondo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-paint"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v2a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z" /><path d="M19 6h1a2 2 0 0 1 2 2a5 5 0 0 1 -5 5l-5 0v2" /><path d="M10 15m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color de fondo :</b> Color de fondo de toda la sección.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es transparente.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordefondo'" :label="'Color de fondo'" :color="'transparent'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordefondo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Color del pretítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del pretítulo :</b> Color del texto del pretítulo.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordelpretitulo'" :label="'Color pretítulo'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordelpretitulo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Color del título -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del título :</b> Color del texto del título principal.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordeltitulo'" :label="'Color título'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordeltitulo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Color título resaltado -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-color-swatch"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color título resaltado :</b> Color de las palabras resaltadas con &lt;span&gt;.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto usa el color principal (Main color).</p>
<div class="relative mt-2 ml-14">
<acai-vue-selectv2 :builder="builder" :data="data" :field="'colortituloresaltado'" @save-data="saveData"></acai-vue-selectv2>
</div>
</div>
</div>
<!-- Color del subtítulo -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-palette"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M7.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M15.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color del subtítulo :</b> Color del texto del subtítulo.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #111827.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colordelsubtitulo'" :label="'Color subtítulo'" :color="'#111827'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colordelsubtitulo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
<!-- Color texto -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-typography"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 20l3 0" /><path d="M14 20l7 0" /><path d="M6.9 15l6.9 0" /><path d="M10.2 6.3l5.8 13.7" /><path d="M5 20l6 -16l2 0l7 16" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Color texto :</b> Color del contenido descriptivo.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> Por defecto es #374151.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'colortexto'" :label="'Color texto'" :color="'#374151'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'colortexto'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
</template>
</acai-vue-tabs>
</template>
<script>
module.exports = {
props: ["active", "section_id"],
data() {
return {
data: null,
builder: null,
idiomas: IDIOMAS,
tabsConfig: [
{ id: "config", label: "Configuración", color: "#f59e0b", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>' },
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 10h-14" /><path d="M5 6h14" /><path d="M14 14h-9" /><path d="M5 18h6" /><path d="M18 15v6" /><path d="M15 18h6" /></svg>' },
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6" /><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464" /><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463" /></svg>' },
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>' }
],
iconosFormato: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-rows"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M4 12l16 0" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-layout-columns"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" /><path d="M12 4l0 16" /></svg>'
}
};
},
components: {
'acai-vue-tabs': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetabs.vue?timestamp=' + new Date().getTime()),
'acai-vue-selectv2': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueselect.vue?timestamp=' + new Date().getTime()),
'acai-vue-colorpicker': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuecolorpicker.vue?timestamp=' + new Date().getTime()),
'acai-vue-linkv2': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuelinkv2.vue?timestamp=' + new Date().getTime()),
'acai-vue-title': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetitle.vue?timestamp=' + new Date().getTime()),
'acai-vue-wysiwyg': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuewysiwyg.vue?timestamp=' + new Date().getTime()),
'acai-vue-textfield': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextfield.vue?timestamp=' + new Date().getTime()),
},
mounted() { this.$emit("child-mounted"); },
methods: { saveData() { this.$emit("save-data"); } },
};
</script>
```
+484
View File
@@ -0,0 +1,484 @@
# Reglas CMS-VUE
Aplica estas reglas ÚNICAMENTE cuando el usuario incluya "cms-vue" o "CMS-VUE" (en cualquier combinación de mayúsculas/minúsculas) en su mensaje. Ejemplos válidos: "dame el cms-vue", "cms-vue personalizado", "crea el CMS-VUE", "necesito el cms-vue de este módulo". Si el mensaje NO contiene "cms-vue", ignora completamente estas instrucciones.
---
## 1. Estructura general: Tabs (`acai-vue-tabs`)
- Analiza el HTML proporcionado para determinar cuántos tabs son necesarios y cómo nombrarlos.
- Tabs base comunes: **Configuración**, **Imágenes**, **Textos**, **Bloques** (records), **Enlaces**, **Colores**.
- Añade tabs adicionales si el módulo lo requiere (ej: "Formulario", "Video", "Overlay", "Slider", etc.).
- Si un tab solo tendría 1 campo, evalúa fusionarlo con otro tab relacionado.
- Cada tab tiene su propio `id`, `label`, `color` e `icon` (SVG inline).
- SVG dentro del template usan `:style="{ color: color }"` para heredar el color del tab.
- Textos descriptivos claros y orientados al usuario final del CMS.
- Usa `storage-key` único: `'nombre-modulo-tabs-' + (section_id || 'default')`.
- Siempre añade `:apply-theme-styles="true"`.
- **IMPORTANTE:** La prop para pasar los tabs es `:tabs` (NO `:tabs-config`).
- **IMPORTANTE:** Siempre añadir `v-if="data"` en el `<acai-vue-tabs>` para evitar renderizar antes de que los datos estén listos.
### Template de cada tab:
```html
<template #idtab="{ color }">
<div class="w-full mb-6">
<p class="text-xl font-semibold text-gray-800">Título descriptivo del tab</p>
</div>
<!-- campos -->
</template>
```
---
## 2. Colorpicker según contexto
### 2.1 En tab "Colores" (campos generales de color de texto/fondo)
Siempre con SVG + título + descripción + colorpicker + textfield oculto:
```html
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" ...>...</svg>
<p class="leading-snug text-gray-600"><b class="text-black">Nombre :</b> Descripción.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> valor por defecto.</p>
<div class="relative mt-2 ml-14">
<acai-vue-colorpicker :builder="builder" :data="data" :field="'campo'" :label="'Etiqueta'" :color="'#hex'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'campo'" @save-data="saveData"></acai-vue-textfield>
</div>
</div>
</div>
```
### 2.2 Colorpicker en otros tabs (ej: color del overlay en tab Imágenes)
Misma estructura con SVG + título + descripción + colorpicker + textfield oculto, pero usando el `color` del tab donde se encuentre. Se coloca junto a los campos relacionados (ver regla 10.2).
### 2.3 Dentro de `<acai-vue-records>` (sin icono ni descripción)
Se coloca debajo del campo al que corresponde:
- Nota con `mt-4` si campo anterior es `textfield` o `title`.
- Nota con `mt-3` si campo anterior es `textbox` o `wysiwyg`.
- Colorpicker siempre con `mt-1`.
```html
<p class="text-xs leading-snug text-gray-500 mt-4 ml-14"><b class="text-gray-700">Nota :</b> color por defecto (#hex).</p>
<div class="relative mt-1 ml-14">
<acai-vue-colorpicker :builder="builder.vars.records" :data="record" :field="'campo'" :label="'Etiqueta'" :color="'#hex'" @save-data="saveData"></acai-vue-colorpicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder.vars.records" :data="record" :field="'campo'" @save-data="saveData"></acai-vue-textfield>
</div>
```
### 2.4 Campos tipo `list` para colores
Se usan como `<acai-vue-selectv2>` con icono y nota. El componente detecta automáticamente si las opciones son colores y muestra el modo color selector con swatches. No llevan colorpicker ni textfield oculto.
### 2.5 Extraer color por defecto
Del HTML: `style="color: {{ campo ? campo : '#HEX' }}"` → usar `#HEX`. Si no hay color, usar `#111827` (textos) o `transparent` (fondos).
---
## 3. Campos: estructura en tabs
### Fuera de records:
```html
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" ...>...</svg>
<p class="leading-snug text-gray-600"><b class="text-black">Nombre :</b> Descripción.</p>
</div>
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> info adicional.</p>
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
</div>
</div>
```
### Dentro de records:
```html
<div class="w-full mt-6">
<div class="flex items-center">
<svg class="w-10 h-10 flex-shrink-0 mr-4 stroke-current" :style="{ color: color }" ...>...</svg>
<p class="leading-snug text-gray-600"><b class="text-black">Nombre :</b> Descripción.</p>
</div>
<div class="relative mt-2 ml-14">
<!-- componente con builder.vars.records y record -->
</div>
</div>
```
---
## 4. Nombres de campos (`:field`)
- Construir uniendo palabras del `data-field-label` en minúsculas sin espacios.
- Eliminar acentos: á→a, é→e, í→i, ó→o, ú→u, ñ→(eliminar).
- Ejemplos: `Color del título` → `colordeltitulo`, `Valoración` → `valoracion`, `Tamaño` → `tamao`.
---
## 5. Upload de imágenes
### General:
```html
<acai-vue-upload ref="upload_campo" :reference="'upload_campo'" :tablename="'builder_custom'" :fieldname="builder.vars.campo.relations.builder_custom" :recordnum="data.campo.recordNum" :field="data.campo" :builder_field="builder.vars.campo" :presavetempid="data.campo.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('campo',data,false,'upload_campo')" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
```
### En records:
```html
<acai-vue-upload :ref="'upload_campo_' + builder.vars.records.vars.campo.relations.builder_custom + '_' + record.campo.recordNum" :reference="'upload_campo_' + builder.vars.records.vars.campo.relations.builder_custom + '_' + record.campo.recordNum" :tablename="'builder_custom'" :fieldname="builder.vars.records.vars.campo.relations.builder_custom" :recordnum="record.campo.recordNum" :field="record.campo" :builder_field="builder.vars.records.vars.campo" :presavetempid="record.campo.preSaveTempId" :add_button="true" @add_button_click="$parent.openCute('campo',record,true,'upload_campo_' + builder.vars.records.vars.campo.relations.builder_custom + '_' + record.campo.recordNum)" class="border-2 px-3 py-2 border-gray-600 rounded-lg shadow bg-gray-200"></acai-vue-upload>
```
---
## 6. Componentes y URLs
Solo incluir los que se usen. Los componentes personalizados (tabs, selectv2) se cargan desde impulse; los estándar desde cocosolution:
```javascript
// ── Componentes personalizados (impulse) ──
'acai-vue-tabs': httpVueLoader('https://impulse.webserver2.plandeweb.com/template/estandar/css/builder-acaivuetabsv2.vue?timestamp=' + new Date().getTime()),
'acai-vue-selectv2': httpVueLoader('https://impulse.webserver2.plandeweb.com/template/estandar/css/builder-acaivueselect-v2.vue?timestamp=' + new Date().getTime()),
// ── Componentes estándar (cocosolution) ──
'acai-vue-colorpicker': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuecolorpicker.vue?timestamp=' + new Date().getTime()),
'acai-vue-upload': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivueupload.vue?timestamp=' + new Date().getTime()),
'acai-vue-records': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuerecords.vue?timestamp=' + new Date().getTime()),
'acai-vue-title': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetitle.vue?timestamp=' + new Date().getTime()),
'acai-vue-wysiwyg': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuewysiwyg.vue?timestamp=' + new Date().getTime()),
'acai-vue-linkv2': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuelinkv2.vue?timestamp=' + new Date().getTime()),
'acai-vue-textbox': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextbox.vue?timestamp=' + new Date().getTime()),
'acai-vue-textfield': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuetextfield.vue?timestamp=' + new Date().getTime()),
'acai-vue-datepicker': httpVueLoader('https://cms.cocosolution.com/lib/plugins/builder_saas/tpl/componentes/builder-acaivuedatepicker.vue?timestamp=' + new Date().getTime()),
```
**IMPORTANTE:** `acai-vue-list` ha sido reemplazado por `acai-vue-selectv2` en todos los VUEs. NO usar `acai-vue-list` en nuevos VUEs.
---
## 7. Mapeo HTML → Vue
| `data-field-type` | Componente |
|---|---|
| `textfield` | `acai-vue-textfield` |
| `headfield` | `acai-vue-title` |
| `wysiwyg` | `acai-vue-wysiwyg` |
| `textbox` | `acai-vue-textbox` |
| `list` | `acai-vue-selectv2` |
| `upload` / `uploadMulti` | `acai-vue-upload` |
| `linkv2` | `acai-vue-linkv2` (siempre con `:show_text="true"`) |
| `multiv2` | `acai-vue-records` |
| `textfield` (usado como fecha) | `acai-vue-datepicker` + `acai-vue-textfield` oculto |
---
## 8. Script base
```javascript
module.exports = {
props: ["active", "section_id"],
data() {
return {
data: null,
builder: null,
idiomas: IDIOMAS,
tabsConfig: [ /* tabs */ ],
// iconos para toggles (solo si hay campos de 2 opciones con iconos)
// iconosNombreCampo: { '': '<svg>...</svg>', '1': '<svg>...</svg>' }
};
},
components: { /* solo los usados */ },
mounted() { this.$emit("child-mounted"); },
methods: { saveData() { this.$emit("save-data"); } },
};
```
---
## 9. Decisión de tabs según contenido HTML y contexto semántico
### 9.1 Organización contextual (PRIORITARIA)
**IMPORTANTE:** Primero analizar el **nombre del campo** para determinar su contexto semántico, independientemente del tipo. Un campo `list` llamado "tipo de imagen" debe ir en el tab **Imágenes**, no en Configuración.
#### Keywords para tab Imágenes:
Campos que contengan: `imagen`, `photo`, `video`, `fondo`, `background`, `logo`, `icono`, `icon`
**Ejemplos:**
- ✅ "tipo de imagen" (list) → **Imágenes**
- ✅ "video de fondo" (list) → **Imágenes**
- ✅ "logo principal" (upload) → **Imágenes**
#### Keywords para tab Enlaces:
Campos que contengan: `enlace`, `link`, `boton`, `button`, `url`, `href`
**Ejemplos:**
- ✅ "texto del botón" (textfield) → **Enlaces**
- ✅ "url externa" (textfield) → **Enlaces**
- ✅ "estilo del enlace" (list) → **Enlaces**
#### Keywords para tab Textos:
Campos que contengan: `titulo`, `title`, `texto`, `text`, `descripcion`, `description`, `contenido`, `content`, `label`, `etiqueta`
**Ejemplos:**
- ✅ "título principal" (headfield) → **Textos**
- ✅ "descripción corta" (textfield) → **Textos**
### 9.2 Organización por tipo (fallback)
Si el nombre del campo **no** coincide con ninguna keyword, usar el tipo:
| Tipo | Tab |
|---|---|
| `headfield`, `textfield`, `textbox`, `wysiwyg` | Textos |
| `upload`, `image` | Imágenes |
| `linkv2` | Enlaces |
| `list`, `select` (sin contexto) | Configuración |
| `multiv2` (records) | Bloques |
| Otros campos de configuración | Configuración |
---
## 10. Reglas especiales
### 10.1 Selector imagen/video con v-show
Cuando el HTML tenga un campo `list` con opciones tipo `"|Imagen,1|Video"`:
- El selector "Tipo de fondo" va en el tab **Imágenes** como **primer campo** (encima de los uploads).
- Se renderiza como toggle con iconos (foto/vídeo) usando `acai-vue-selectv2` con `:toggle-icons`.
- Usa el icono `icon-tabler-photo-video`:
```html
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 stroke-current icon icon-tabler icons-tabler-outline icon-tabler-photo-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15h-3a3 3 0 0 1 -3 -3v-6a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v3" /><path d="M9 12a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v6a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3l0 -6" /><path d="M3 12l2.296 -2.296a2.41 2.41 0 0 1 3.408 0l.296 .296" /><path d="M14 13.5v3l2.5 -1.5l-2.5 -1.5" /><path d="M7 6v.01" /></svg>
```
- El upload de **imágenes** lleva: `v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == ''"` (visible cuando es imagen o vacío).
- El upload de **video** lleva: `v-show="data.tipodeimagen && data.tipodeimagen.newValues.builder_custom.value == '1'"` (visible cuando es video).
- **NUNCA quitar estos `v-show`**, son esenciales para mostrar uno u otro según la selección.
- Iconos del toggle:
```javascript
iconosTipoImagen: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-photo"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z" /><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5" /><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-video"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l4.553 -2.276a1 1 0 0 1 1.447 .894v6.764a1 1 0 0 1 -1.447 .894l-4.553 -2.276v-4z" /><path d="M3 6m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z" /></svg>'
}
```
### 10.2 Grupo overlay (tipo + color + opacidad)
Cuando el HTML contenga campos de overlay (tipo de overlay, color del overlay, opacidad del overlay):
- Los tres campos van **juntos** en el tab **Imágenes**, **debajo** de la imagen/video sobre la que se aplica el overlay.
- El orden es: tipo de overlay → color del overlay (colorpicker) → opacidad del overlay.
- El **color del overlay NO va en el tab Colores**, va en Imágenes junto al resto del grupo overlay.
- El tipo de overlay (2 opciones: Sin degradado / Con degradado) se renderiza como toggle con iconos:
```javascript
iconosOverlay: {
'': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-square"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /></svg>',
'1': '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-gradient"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 3m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M7 3v18" /><path d="M3 14h4" /><path d="M3 10h4" /><path d="M3 6h4" /><path d="M3 18h4" /></svg>'
}
```
### 10.3 Campos que afectan al enlace
Los campos `list` que modifican propiedades del botón de enlace (radio borde, estilo, etc.) van en el tab **Enlaces**, debajo del campo `linkv2` al que afectan. NO van en Configuración ni en Imágenes.
### 10.4 Tabs base: definición fija de id, label, color e icono
Los tabs base siempre usan la siguiente definición fija. Este es el orden por defecto; solo se incluyen los tabs que el módulo necesite. Tabs adicionales (ej: "Formulario", "Video") se crean con id, label, color e icono nuevos.
```javascript
{ id: "configuracion", label: "Configuración", color: "#f59e0b", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>' },
{ id: "imagenes", label: "Imágenes", color: "#10b981", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 8h.01" /><path d="M11 20h-4a3 3 0 0 1 -3 -3v-10a3 3 0 0 1 3 -3h10a3 3 0 0 1 3 3v4" /><path d="M4 15l4 -4c.928 -.893 2.072 -.893 3 0l3 3" /><path d="M14 14l1 -1c.31 -.298 .644 -.497 .987 -.596" /><path d="M18.42 15.61a2.1 2.1 0 0 1 2.97 2.97l-3.39 3.42h-3v-3l3.42 -3.39" /></svg>' },
{ id: "textos", label: "Textos", color: "#3b82f6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 10h-14" /><path d="M5 6h14" /><path d="M14 14h-9" /><path d="M5 18h6" /><path d="M18 15v6" /><path d="M15 18h6" /></svg>' },
{ id: "bloques", label: "Bloques", color: "#ec4899", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-8 4l8 4l8 -4l-8 -4" /><path d="M4 12l8 4l8 -4" /><path d="M4 16l8 4l8 -4" /></svg>' },
{ id: "enlaces", label: "Enlaces", color: "#ef4444", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M9 15l6 -6"/><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"/><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"/></svg>' },
{ id: "colores", label: "Colores", color: "#8b5cf6", icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M19 3h-4a2 2 0 0 0 -2 2v12a4 4 0 0 0 8 0v-12a2 2 0 0 0 -2 -2" /><path d="M13 7.35l-2 -2a2 2 0 0 0 -2.828 0l-2.828 2.828a2 2 0 0 0 0 2.828l9 9" /><path d="M7.3 13h-2.3a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h12" /><path d="M17 17l0 .01" /></svg>' },
```
### 10.5 Campos globales que afectan al multi van DENTRO del tab Bloques
Los campos `list` o `textfield` generales (no de records) que afectan visualmente a los elementos del multi (ej: radio de borde de los bloques, alineación del texto de los bloques, diseño del enlace de los bloques) deben colocarse **dentro del tab Bloques**, en la zona **superior**, ANTES del bloque descriptivo "Bloques del multi" y del `<acai-vue-records>`. Estos campos NO van en Configuración ni en otros tabs, ya que pertenecen conceptualmente a los bloques.
### 10.6 Bloque descriptivo "Bloques del multi" antes de acai-vue-records
Siempre añadir un bloque descriptivo con el icono `icon-tabler-stack-2` y el texto "Bloques del multi : Personaliza los bloques del multi." justo antes de `<acai-vue-records>`:
```html
<!-- Multi -->
<div class="flex w-full items-center mt-6">
<div class="w-full">
<div class="flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" :style="{ color: color }" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-stack-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-8 4l8 4l8 -4l-8 -4" /><path d="M4 12l8 4l8 -4" /><path d="M4 16l8 4l8 -4" /></svg>
<p class="leading-snug text-gray-600"><b class="text-black">Bloques del multi :</b> Personaliza los bloques del multi.</p>
</div>
</div>
</div>
```
### 10.7 Slot de acai-vue-records: NO desestructurar `color`
El slot de `<acai-vue-records>` NUNCA debe desestructurar `color`. Siempre usar:
```html
<template v-slot="{ record, index }">
```
**NUNCA** usar:
```html
<template v-slot="{ record, color, index }">
```
De esta forma, `color` dentro del multi resuelve al `color` del tab padre (`<template #bloques="{ color }">`), y los iconos SVG con `:style="{ color: color }"` siempre mostrarán el color correcto del tab.
### 10.8 Estructura del componente acai-vue-records
El componente `<acai-vue-records>` siempre debe incluir todas estas props y atributos:
```html
<acai-vue-records :data="data" :builder="builder" :active="active" :section_id="section_id" :root_builder_vue="$parent" ref="recordsNode">
<template v-slot="{ record, index }">
<!-- campos del record -->
</template>
</acai-vue-records>
```
**NUNCA** usar una versión simplificada sin `:active`, `:section_id`, `:root_builder_vue` o `ref`.
### 10.9 Orden del campo "Color título resaltado" en tab Colores
Cuando el módulo tenga un campo de **Color título resaltado** (tipo `list` con opciones Main color / Main color light / Main color dark), este campo debe colocarse **inmediatamente debajo** del campo **Color del título** en el tab **Colores**. Nunca en el tab Textos ni en otra posición del tab Colores.
---
## 11. Consistencia de iconos y textos descriptivos entre VUEs
### 11.1 Iconos
Los campos que ya tienen un icono SVG asignado en VUEs anteriores deben usar SIEMPRE ese mismo icono en todos los VUEs futuros. Solo se crean o personalizan iconos nuevos para campos que no se hayan visto antes en ningún VUE previo.
### 11.2 Textos descriptivos
Los textos descriptivos (título en negrita + descripción + nota) de campos recurrentes (pretítulo, título, subtítulo, texto largo, enlace, color de fondo, color del texto, etc.) deben ser idénticos en todos los VUEs. Solo se modifican si el HTML del módulo revela un comportamiento diferente para ese campo concreto.
### 11.3 Registro de referencia
Usar como referencia los iconos y textos del primer VUE en que apareció cada tipo de campo. Ante cualquier duda, mantener consistencia con lo ya establecido.
---
## 12. Componente acai-vue-selectv2 (reemplazo de acai-vue-list)
### 12.1 Descripción general
`acai-vue-selectv2` reemplaza completamente a `acai-vue-list`. Es un componente inteligente que detecta automáticamente cómo renderizar según el número y tipo de opciones:
- **2 opciones** → modo **toggle** (pill deslizante con animación)
- **2+ opciones con nombres de color** → modo **color selector** (dropdown con swatches)
- **3+ opciones normales** → modo **select** (dropdown estándar con vue-select)
### 12.2 Props
```html
<acai-vue-selectv2
:builder="builder"
:data="data"
:field="'nombrecampo'"
:toggle-icons="iconosObjeto" <!-- opcional, solo para toggles con iconos -->
@save-data="saveData">
</acai-vue-selectv2>
```
### 12.3 Toggle con iconos (`:toggle-icons`)
Para campos de 2 opciones donde se quieran iconos visuales en el toggle, se pasa un objeto con las claves correspondientes a los valores de las opciones:
```javascript
iconosNombreCampo: {
'': '<svg>...</svg>', // icono para la primera opción (valor vacío)
'1': '<svg>...</svg>' // icono para la segunda opción
}
```
Iconos de toggle establecidos:
- **Lado texto (2 opciones: Izquierda/Derecha):** `icon-tabler-align-box-left-middle` / `icon-tabler-align-box-right-middle`
- **Ver sombra (No/Si):** `icon-tabler-x` / `icon-tabler-check`
- **Tipo imagen (Imagen/Video):** `icon-tabler-photo` / `icon-tabler-video`
- **Tipo overlay (Sin degradado/Con degradado):** `icon-tabler-square` / `icon-tabler-gradient`
### 12.4 Modo color automático
El componente detecta automáticamente si las opciones son colores cuando al menos la mitad de las labels coinciden con:
- Nombres del mapa interno: main color, blanco, negro, gris, gris claro, gris oscuro, gris calido, rojo, azul, verde, etc. (español e inglés)
- Códigos hex (#fff, #ff0000)
- Valores rgb/rgba
- Valores hsl/hsla
Los colores main color, main color light y main color dark se resuelven consultando la configuración del CMS en tiempo real.
### 12.5 Campos de 3+ opciones sin iconos
No necesitan `:toggle-icons`. Se renderizan como dropdown estándar:
```html
<acai-vue-selectv2 :builder="builder" :data="data" :field="'container'" @save-data="saveData"></acai-vue-selectv2>
```
---
## 13. Componente acai-vue-datepicker (campos de fecha)
### 13.1 Uso
Cuando un campo `textfield` en el HTML se usa para fechas (se identifica por el label "Fecha" o similar), se usa `acai-vue-datepicker` junto con un `acai-vue-textfield` oculto:
```html
<div class="relative mt-2">
<acai-vue-datepicker :builder="builder" :data="data" :field="'fecha'" :label="'Fecha'" @save-data="saveData"></acai-vue-datepicker>
</div>
<div style="display: none">
<acai-vue-textfield :builder="builder" :data="data" :field="'fecha'" @save-data="saveData"></acai-vue-textfield>
</div>
```
### 13.2 Notas estándar para datepicker
```html
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> puedes elegir el formato de la fecha en el selector.</p>
<p class="text-xs leading-snug text-gray-500 mt-1 ml-14"><b class="text-gray-700">Recuerda :</b> también puedes mostrar la hora activando el botón del reloj.</p>
```
### 13.3 Icono estándar para fecha
```html
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" :style="{ color: color }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="w-10 h-10 flex-shrink-0 mr-4 icon icon-tabler icons-tabler-outline icon-tabler-calendar-week"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12" /><path d="M16 3v4" /><path d="M8 3v4" /><path d="M4 11h16" /><path d="M7 14h.013" /><path d="M10.01 14h.005" /><path d="M13.01 14h.005" /><path d="M16.015 14h.005" /><path d="M13.015 17h.005" /><path d="M7.01 17h.005" /><path d="M10.01 17h.005" /></svg>
```
---
## 14. Reglas de spacing (separación entre elementos)
### 14.1 Separación entre nota/recuerda y componente
- El componente siempre lleva `mt-2` respecto a la nota o recuerda que lo precede.
- Nunca `mt-1` entre nota/recuerda y componente.
### 14.2 Nota y Recuerda juntos
Cuando un campo tiene **Nota** y **Recuerda**:
- **Nota** siempre lleva `mt-2` respecto al bloque de icono+texto anterior.
- **Recuerda** lleva `mt-1` respecto a la Nota (va justo debajo).
- El componente lleva `mt-2` respecto al Recuerda.
Ejemplo:
```html
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> texto de la nota.</p>
<p class="text-xs leading-snug text-gray-500 mt-1 ml-14"><b class="text-gray-700">Recuerda :</b> texto del recuerda.</p>
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
```
### 14.3 Solo Nota (sin Recuerda)
```html
<p class="text-xs leading-snug text-gray-500 mt-2 ml-14"><b class="text-gray-700">Nota :</b> texto.</p>
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
```
### 14.4 Solo Recuerda (sin Nota)
El Recuerda usa el estilo especial (sin ml-14, con font-light):
```html
<p class="text-xs leading-snug text-gray-600 font-light mt-2"><b class="text-black">Recuerda :</b> texto del recuerda.</p>
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
```
### 14.5 Sin Nota ni Recuerda
El componente lleva `mt-2` directamente:
```html
<div class="relative mt-2 ml-14">
<!-- componente -->
</div>
```
### 14.6 Separación entre campos
Siempre `mt-6` entre bloques de campo:
```html
<div class="flex w-full items-center mt-6">
```
El primer campo de cada tab NO lleva `mt-6` (no hay campo previo).