14 KiB
Patrones de Producción
Este documento recoge patrones reales usados en módulos y secciones generales de proyectos Acai en producción. Cada patrón incluye el HTML/Twig listo para reutilizar y notas sobre cuándo aplicarlo. Cubre: cabecera de sección con colores configurables, layout zigzag (imagen + texto alternado), acordeón FAQ, formulario de contacto completo con c-form, compartir en redes sociales, sección general de detalle de producto, galería con carousel modo gallery. Léelo cuando vayas a crear un módulo y quieras evitar reinventar patrones que ya tienen una versión canónica testeada en producció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 como inicio.
<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>
2. Layout Zigzag (imagen + texto alternado)
Usa loop.index is odd/even para alternar la dirección.
<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>
3. Acordeón FAQ
<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>
script.js:
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".faq-page").forEach((faq) => {
faq.addEventListener("click", () => {
const body = faq.nextElementSibling;
const isActive = faq.classList.toggle("active");
body.classList.toggle("hidden", !isActive);
if (typeof AOS !== "undefined") AOS.refresh();
});
});
});
4. Formulario de contacto completo
<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>
5. Compartir en redes sociales
<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>
6. Sección general — Detalle de producto
Patrón completo para template/estandar/modulos/custom-productos/index-base.tpl:
<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 }} €</div>
<div class="text-2xl font-semibold ml-4">{{ thisrecord.precio_descuento }} €</div>
</div>
<div c-else class="text-2xl font-semibold mt-4">{{ thisrecord.precio }} €</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>
7. Galería con carousel — modo Gallery
<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>
8. Listado con filtros y paginación
<set :categorias="'categorias' | get('visible=1', 'orden ASC')"></set>
{% set perPage = 12 %}
{% set page = pagina | default(1) %}
{% set offset = (page - 1) * perPage %}
<div class="container mx-auto max-w-7xl px-6 py-10">
<!-- Filtros -->
<nav class="flex flex-wrap gap-2 mb-8">
<a href="?cat=" class="px-4 py-2 rounded-full bg-gray-200">{{ 'Todas' | translate }}</a>
{% for cat in categorias %}
<a href="?cat={{ cat.num }}" class="px-4 py-2 rounded-full bg-gray-200">{{ cat.nombre }}</a>
{% endfor %}
</nav>
<!-- Listado -->
{% set where = 'visible=1' %}
{% if cat_filter %}
{% set where = where ~ ' AND categoria_num=' ~ cat_filter %}
{% endif %}
{% set productos = 'productos' | get(where, 'orden ASC', perPage) %}
<ul class="grid grid-cols-1 md:grid-cols-3 gap-6">
{% for prod in productos %}
<li class="bg-white rounded-xl shadow p-4">
<a href="{{ prod.enlace }}">
<img src="{{ prod.imagen[0].urlPath | imagec(400) }}" alt="{{ prod.nombre }}" class="w-full aspect-video object-cover rounded">
<h3 class="text-lg font-semibold mt-3">{{ prod.nombre }}</h3>
<p class="text-gray-600">{{ prod.descripcion | truncate(100) }}</p>
</a>
</li>
{% endfor %}
</ul>
</div>
Reglas de aplicación
- Estos patrones son referencia — adáptalos al estilo del proyecto (ver
docs/project-styles.mdsi existe). - Reutiliza clases utilitarias de Acai (
bg-main-color,transition3s,lazyload,glightbox,c-tns-wrapper) antes de inventar. - Para campos editables siempre añade
data-field-label(ver01-builder-fields.md). - Para
c-form, prefiere usarlo antes de construir POST/hook custom. - Para detalle de registro usa siempre la convención
custom-{tableName}/.