Files
agenticSystem/docs/10-production-patterns.md
Jordan Diaz 6881d64a08 ajustes
2026-04-25 10:27:51 +00:00

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 }} &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>
<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.md si 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 (ver 01-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}/.