Compare commits
29 Commits
pre-fix-cu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555541b2a0 | ||
|
|
fae4def974 | ||
|
|
8bbbf484bd | ||
|
|
50c411408e | ||
|
|
d7c9c2a801 | ||
|
|
30068ca01e | ||
|
|
959d76fd92 | ||
|
|
04387d46bb | ||
|
|
4f1e85fe88 | ||
|
|
2cb7363cbb | ||
|
|
18bf3d191c | ||
|
|
09d87835b8 | ||
|
|
2896e2d006 | ||
|
|
c2fff49961 | ||
|
|
85f3387fd2 | ||
|
|
ff5ba25505 | ||
|
|
eab974d14c | ||
|
|
b509b1a2b4 | ||
|
|
83d113d669 | ||
|
|
0c1908e7d1 | ||
|
|
5333531be4 | ||
|
|
fb68f2023c | ||
|
|
79e91f59ee | ||
|
|
c23dc22d76 | ||
|
|
b79569c5e7 | ||
|
|
6be292e085 | ||
|
|
885276aad1 | ||
|
|
1e6a076904 | ||
|
|
a33c43a104 |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(mkdir:*)",
|
||||
"mcp__serena__activate_project",
|
||||
"mcp__serena__find_symbol",
|
||||
"Bash(ssh:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,27 @@ final class AdsensePlacementFieldMapper implements FieldMapperInterface
|
||||
'adsense-placementExcludeCategoriesAdv' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'adsense-placementExcludePostIdsAdv' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'adsense-placementExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// INCONTENT ADVANCED (In-Content Ads Avanzado)
|
||||
'adsense-placementIncontentMode' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_mode'],
|
||||
'adsense-placementIncontentAfterH2Enabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_h2_enabled'],
|
||||
'adsense-placementIncontentAfterH2Probability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_h2_probability'],
|
||||
'adsense-placementIncontentAfterH3Enabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_h3_enabled'],
|
||||
'adsense-placementIncontentAfterH3Probability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_h3_probability'],
|
||||
'adsense-placementIncontentAfterParagraphsEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_paragraphs_enabled'],
|
||||
'adsense-placementIncontentAfterParagraphsProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_paragraphs_probability'],
|
||||
'adsense-placementIncontentAfterImagesEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_images_enabled'],
|
||||
'adsense-placementIncontentAfterImagesProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_images_probability'],
|
||||
'adsense-placementIncontentAfterListsEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_lists_enabled'],
|
||||
'adsense-placementIncontentAfterListsProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_lists_probability'],
|
||||
'adsense-placementIncontentAfterBlockquotesEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_blockquotes_enabled'],
|
||||
'adsense-placementIncontentAfterBlockquotesProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_blockquotes_probability'],
|
||||
'adsense-placementIncontentAfterTablesEnabled' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_tables_enabled'],
|
||||
'adsense-placementIncontentAfterTablesProbability' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_after_tables_probability'],
|
||||
'adsense-placementIncontentMaxTotalAds' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_max_total_ads'],
|
||||
'adsense-placementIncontentMinSpacing' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_min_spacing'],
|
||||
'adsense-placementIncontentFormat' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_format'],
|
||||
'adsense-placementIncontentPriorityMode' => ['group' => 'incontent_advanced', 'attribute' => 'incontent_priority_mode'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ final class AdsensePlacementFormBuilder
|
||||
$html .= $this->buildVisibilityGroup($componentId);
|
||||
$html .= $this->buildDiagramSection();
|
||||
$html .= $this->buildPostLocationsGroup($componentId);
|
||||
$html .= $this->buildInContentAdvancedGroup($componentId);
|
||||
$html .= $this->buildInContentAdsGroup($componentId);
|
||||
$html .= $this->buildExclusionsGroup($componentId);
|
||||
$html .= ' </div>';
|
||||
@@ -342,6 +343,291 @@ final class AdsensePlacementFormBuilder
|
||||
return $html;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Seccion avanzada para In-Content Ads con multiples tipos de ubicacion
|
||||
* Incluye: modo de densidad, ubicaciones por elemento, limites y espaciado
|
||||
*/
|
||||
private function buildInContentAdvancedGroup(string $cid): string
|
||||
{
|
||||
// Obtener valores actuales del grupo incontent_advanced
|
||||
$mode = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_mode', 'paragraphs_only');
|
||||
$mode = is_string($mode) ? $mode : 'paragraphs_only';
|
||||
|
||||
// Ubicaciones
|
||||
$h2Enabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h2_enabled', true);
|
||||
$h2Prob = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h2_probability', '100');
|
||||
$h3Enabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h3_enabled', true);
|
||||
$h3Prob = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_h3_probability', '50');
|
||||
$paragraphsEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_paragraphs_enabled', true);
|
||||
$paragraphsProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_paragraphs_probability', '75');
|
||||
$imagesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_images_enabled', true);
|
||||
$imagesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_images_probability', '75');
|
||||
$listsEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_lists_enabled', false);
|
||||
$listsProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_lists_probability', '50');
|
||||
$blockquotesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_blockquotes_enabled', false);
|
||||
$blockquotesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_blockquotes_probability', '50');
|
||||
$tablesEnabled = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_tables_enabled', false);
|
||||
$tablesProb = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_after_tables_probability', '50');
|
||||
|
||||
// Limites
|
||||
$maxAds = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_max_total_ads', '8');
|
||||
$minSpacing = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_min_spacing', '3');
|
||||
$format = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_format', 'in-article');
|
||||
$priorityMode = $this->renderer->getFieldValue($cid, 'incontent_advanced', 'incontent_priority_mode', 'position');
|
||||
|
||||
// Cast to string where needed
|
||||
$h2Prob = is_string($h2Prob) ? $h2Prob : '100';
|
||||
$h3Prob = is_string($h3Prob) ? $h3Prob : '50';
|
||||
$paragraphsProb = is_string($paragraphsProb) ? $paragraphsProb : '75';
|
||||
$imagesProb = is_string($imagesProb) ? $imagesProb : '75';
|
||||
$listsProb = is_string($listsProb) ? $listsProb : '50';
|
||||
$blockquotesProb = is_string($blockquotesProb) ? $blockquotesProb : '50';
|
||||
$tablesProb = is_string($tablesProb) ? $tablesProb : '50';
|
||||
$maxAds = is_string($maxAds) ? $maxAds : '8';
|
||||
$minSpacing = is_string($minSpacing) ? $minSpacing : '3';
|
||||
$format = is_string($format) ? $format : 'in-article';
|
||||
$priorityMode = is_string($priorityMode) ? $priorityMode : 'position';
|
||||
|
||||
$isParagraphsOnly = $mode === 'paragraphs_only';
|
||||
$disabledAttr = $isParagraphsOnly ? 'disabled' : '';
|
||||
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #198754;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-body-text me-2" style="color: #198754;"></i>';
|
||||
$html .= ' In-Content Ads Avanzado';
|
||||
$html .= ' <span class="badge bg-success ms-2">Nuevo</span>';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Indicador de densidad
|
||||
$html .= ' <div id="roiIncontentDensityIndicator" class="alert alert-info small mb-3">';
|
||||
$html .= ' <i class="bi bi-speedometer2 me-1"></i>';
|
||||
$html .= ' Densidad estimada: <strong id="roiDensityLevel">Calculando...</strong>';
|
||||
$html .= ' <span id="roiDensityBadge" class="badge bg-secondary ms-1">~? ads</span>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Banner informativo para modo Solo parrafos
|
||||
$html .= ' <div id="roiParagraphsOnlyBanner" class="alert alert-light border small mb-3' . ($isParagraphsOnly ? '' : ' d-none') . '">';
|
||||
$html .= ' <i class="bi bi-info-circle me-1 text-primary"></i>';
|
||||
$html .= ' <strong>Solo parrafos:</strong> Los anuncios se insertan unicamente despues de parrafos, ';
|
||||
$html .= ' usando la configuracion de la seccion "Post Content". Cambia a otro modo para elegir ubicaciones adicionales.';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Selector de modo con descripciones
|
||||
$html .= ' <div class="mb-4">';
|
||||
$html .= ' <label for="' . esc_attr($cid) . 'IncontentMode" class="form-label fw-semibold">';
|
||||
$html .= ' <i class="bi bi-sliders me-1" style="color: #FF8600;"></i>Estrategia de insercion';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <p class="text-muted small mb-2">Define donde y con que frecuencia se insertaran anuncios dentro del contenido.</p>';
|
||||
$html .= ' <select class="form-select mb-3" id="' . esc_attr($cid) . 'IncontentMode">';
|
||||
$modeOptions = [
|
||||
'paragraphs_only' => 'Solo parrafos (clasico)',
|
||||
'conservative' => 'Conservador - H2 y parrafos',
|
||||
'balanced' => 'Balanceado - Multiples elementos',
|
||||
'aggressive' => 'Intensivo - Todos los elementos',
|
||||
'custom' => 'Personalizado'
|
||||
];
|
||||
foreach ($modeOptions as $value => $label) {
|
||||
$selected = selected($mode, $value, false);
|
||||
$html .= '<option value="' . esc_attr($value) . '" ' . $selected . '>' . esc_html($label) . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
|
||||
// Descripciones de cada modo
|
||||
$html .= ' <div id="roiModeDescriptions" class="small">';
|
||||
|
||||
// Solo parrafos
|
||||
$html .= ' <div id="roiModeDescParagraphsOnly" class="alert alert-light border py-2 px-3' . ($mode !== 'paragraphs_only' ? ' d-none' : '') . '">';
|
||||
$html .= ' <strong class="text-primary"><i class="bi bi-text-paragraph me-1"></i>Solo parrafos</strong>';
|
||||
$html .= ' <p class="mb-1 mt-1">Inserta anuncios unicamente despues de parrafos. Usa la configuracion de la seccion "Post Content" (numero de anuncios, parrafos entre ads, etc).</p>';
|
||||
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Tu contenido tiene pocos encabezados o prefieres la configuracion tradicional.</span>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Conservador
|
||||
$html .= ' <div id="roiModeDescConservative" class="alert alert-light border py-2 px-3' . ($mode !== 'conservative' ? ' d-none' : '') . '">';
|
||||
$html .= ' <strong class="text-success"><i class="bi bi-shield-check me-1"></i>Conservador</strong>';
|
||||
$html .= ' <p class="mb-1 mt-1">Maximo 5 anuncios con espaciado amplio (5 elementos). Solo inserta despues de titulos H2 y parrafos.</p>';
|
||||
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Priorizas la experiencia del usuario sobre los ingresos. Articulos cortos o medianos.</span>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Balanceado
|
||||
$html .= ' <div id="roiModeDescBalanced" class="alert alert-light border py-2 px-3' . ($mode !== 'balanced' ? ' d-none' : '') . '">';
|
||||
$html .= ' <strong class="text-primary"><i class="bi bi-balance-scale me-1"></i>Balanceado</strong>';
|
||||
$html .= ' <p class="mb-1 mt-1">Hasta 8 anuncios con espaciado moderado (3 elementos). Usa H2, H3, parrafos e imagenes.</p>';
|
||||
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Buscas equilibrio entre ingresos y experiencia. Articulos medianos a largos.</span>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Intensivo
|
||||
$html .= ' <div id="roiModeDescAggressive" class="alert alert-light border py-2 px-3' . ($mode !== 'aggressive' ? ' d-none' : '') . '">';
|
||||
$html .= ' <strong class="text-warning"><i class="bi bi-lightning-charge me-1"></i>Intensivo</strong>';
|
||||
$html .= ' <p class="mb-1 mt-1">Hasta 15 anuncios con espaciado minimo (2 elementos). Usa todos los tipos de elementos disponibles.</p>';
|
||||
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Priorizas maximizar ingresos. Solo para articulos muy largos (+3000 palabras).</span>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Personalizado
|
||||
$html .= ' <div id="roiModeDescCustom" class="alert alert-light border py-2 px-3' . ($mode !== 'custom' ? ' d-none' : '') . '">';
|
||||
$html .= ' <strong class="text-secondary"><i class="bi bi-gear me-1"></i>Personalizado</strong>';
|
||||
$html .= ' <p class="mb-1 mt-1">Tu configuras manualmente cada ubicacion, probabilidad y limites.</p>';
|
||||
$html .= ' <span class="text-muted"><i class="bi bi-lightbulb me-1"></i>Ideal si: Quieres control total sobre donde aparecen los anuncios.</span>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Subseccion: Ubicaciones por elemento
|
||||
$html .= ' <details class="mb-3 border rounded" id="roiLocationsDetails"' . ($isParagraphsOnly ? '' : ' open') . '>';
|
||||
$html .= ' <summary class="p-3 bg-light fw-bold" style="cursor: pointer;">';
|
||||
$html .= ' <i class="bi bi-geo-alt me-1"></i>';
|
||||
$html .= ' Ubicaciones por Elemento';
|
||||
$html .= ' </summary>';
|
||||
$html .= ' <div class="p-3">';
|
||||
|
||||
// Grid de ubicaciones
|
||||
$locations = [
|
||||
['id' => 'H2', 'label' => 'Despues de H2 (titulos)', 'enabled' => $h2Enabled, 'prob' => $h2Prob, 'icon' => 'bi-type-h2'],
|
||||
['id' => 'H3', 'label' => 'Despues de H3 (subtitulos)', 'enabled' => $h3Enabled, 'prob' => $h3Prob, 'icon' => 'bi-type-h3'],
|
||||
['id' => 'Paragraphs', 'label' => 'Despues de parrafos', 'enabled' => $paragraphsEnabled, 'prob' => $paragraphsProb, 'icon' => 'bi-text-paragraph'],
|
||||
['id' => 'Images', 'label' => 'Despues de imagenes', 'enabled' => $imagesEnabled, 'prob' => $imagesProb, 'icon' => 'bi-image'],
|
||||
['id' => 'Lists', 'label' => 'Despues de listas', 'enabled' => $listsEnabled, 'prob' => $listsProb, 'icon' => 'bi-list-ul'],
|
||||
['id' => 'Blockquotes', 'label' => 'Despues de citas', 'enabled' => $blockquotesEnabled, 'prob' => $blockquotesProb, 'icon' => 'bi-quote'],
|
||||
['id' => 'Tables', 'label' => 'Despues de tablas', 'enabled' => $tablesEnabled, 'prob' => $tablesProb, 'icon' => 'bi-table'],
|
||||
];
|
||||
|
||||
$probOptions = [
|
||||
'100' => '100%',
|
||||
'75' => '75%',
|
||||
'50' => '50%',
|
||||
'25' => '25%'
|
||||
];
|
||||
|
||||
foreach ($locations as $loc) {
|
||||
$enabledId = $cid . 'IncontentAfter' . $loc['id'] . 'Enabled';
|
||||
$probId = $cid . 'IncontentAfter' . $loc['id'] . 'Probability';
|
||||
$checked = checked($loc['enabled'], true, false);
|
||||
|
||||
$html .= ' <div class="row g-2 mb-2 align-items-center">';
|
||||
$html .= ' <div class="col-7">';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= ' <input type="checkbox" class="form-check-input roi-incontent-location" ';
|
||||
$html .= ' id="' . esc_attr($enabledId) . '" ' . $checked . ' ' . $disabledAttr . '>';
|
||||
$html .= ' <label class="form-check-label small" for="' . esc_attr($enabledId) . '">';
|
||||
$html .= ' <i class="bi ' . esc_attr($loc['icon']) . ' me-1" style="color: #0d6efd;"></i>';
|
||||
$html .= ' ' . esc_html($loc['label']);
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-5">';
|
||||
$html .= ' <select class="form-select form-select-sm roi-incontent-prob" ';
|
||||
$html .= ' id="' . esc_attr($probId) . '" ' . $disabledAttr . '>';
|
||||
foreach ($probOptions as $pValue => $pLabel) {
|
||||
$pSelected = selected($loc['prob'], $pValue, false);
|
||||
$html .= '<option value="' . esc_attr($pValue) . '" ' . $pSelected . '>' . esc_html($pLabel) . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
}
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= ' </details>';
|
||||
|
||||
// Subseccion: Limites y espaciado
|
||||
$html .= ' <details class="mb-3 border rounded" id="roiLimitsDetails"' . ($isParagraphsOnly ? '' : ' open') . '>';
|
||||
$html .= ' <summary class="p-3 bg-light fw-bold" style="cursor: pointer;">';
|
||||
$html .= ' <i class="bi bi-sliders me-1"></i>';
|
||||
$html .= ' Limites y Espaciado';
|
||||
$html .= ' </summary>';
|
||||
$html .= ' <div class="p-3">';
|
||||
$html .= ' <div class="row g-3">';
|
||||
|
||||
// Max total ads
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$html .= ' <label for="' . esc_attr($cid) . 'IncontentMaxTotalAds" class="form-label small fw-semibold">';
|
||||
$html .= ' Maximo total de ads';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($cid) . 'IncontentMaxTotalAds" ' . $disabledAttr . '>';
|
||||
for ($i = 1; $i <= 25; $i++) {
|
||||
$iStr = (string)$i;
|
||||
$adSelected = selected($maxAds, $iStr, false);
|
||||
$label = $i === 1 ? '1 anuncio' : $i . ' anuncios';
|
||||
$html .= '<option value="' . esc_attr($iStr) . '" ' . $adSelected . '>' . esc_html($label) . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Min spacing
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$html .= ' <label for="' . esc_attr($cid) . 'IncontentMinSpacing" class="form-label small fw-semibold">';
|
||||
$html .= ' Espaciado minimo (elementos)';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($cid) . 'IncontentMinSpacing" ' . $disabledAttr . '>';
|
||||
$spacingOptions = [
|
||||
'1' => '1 elemento',
|
||||
'2' => '2 elementos',
|
||||
'3' => '3 elementos',
|
||||
'4' => '4 elementos',
|
||||
'5' => '5 elementos',
|
||||
'6' => '6 elementos'
|
||||
];
|
||||
foreach ($spacingOptions as $sValue => $sLabel) {
|
||||
$sSelected = selected($minSpacing, $sValue, false);
|
||||
$html .= '<option value="' . esc_attr($sValue) . '" ' . $sSelected . '>' . esc_html($sLabel) . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Formato de ads
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$html .= ' <label for="' . esc_attr($cid) . 'IncontentFormat" class="form-label small fw-semibold">';
|
||||
$html .= ' Formato de ads';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($cid) . 'IncontentFormat" ' . $disabledAttr . '>';
|
||||
$formatOptions = [
|
||||
'in-article' => 'In-Article (fluid)',
|
||||
'auto' => 'Auto (responsive)'
|
||||
];
|
||||
foreach ($formatOptions as $fValue => $fLabel) {
|
||||
$fSelected = selected($format, $fValue, false);
|
||||
$html .= '<option value="' . esc_attr($fValue) . '" ' . $fSelected . '>' . esc_html($fLabel) . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Priority mode
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$html .= ' <label for="' . esc_attr($cid) . 'IncontentPriorityMode" class="form-label small fw-semibold">';
|
||||
$html .= ' Estrategia de seleccion';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select class="form-select form-select-sm" id="' . esc_attr($cid) . 'IncontentPriorityMode" ' . $disabledAttr . '>';
|
||||
$priorityOptions = [
|
||||
'position' => 'Por posicion (distribucion uniforme)',
|
||||
'priority' => 'Por prioridad (maximizar H2/H3)'
|
||||
];
|
||||
foreach ($priorityOptions as $pmValue => $pmLabel) {
|
||||
$pmSelected = selected($priorityMode, $pmValue, false);
|
||||
$html .= '<option value="' . esc_attr($pmValue) . '" ' . $pmSelected . '>' . esc_html($pmLabel) . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' <small class="text-muted">Como resolver conflictos cuando dos ubicaciones estan muy cerca</small>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </details>';
|
||||
|
||||
// Warning para densidad alta
|
||||
$html .= ' <div id="roiHighDensityWarning" class="alert alert-warning small d-none">';
|
||||
$html .= ' <i class="bi bi-exclamation-triangle me-1"></i>';
|
||||
$html .= ' <strong>Atencion:</strong> Densidad alta (>10 ads) puede afectar UX y violar politicas de AdSense.';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seccion especial para in-content ads con configuracion de 1-8 random
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\ArchiveHeader\Infrastructure\FieldMapping;
|
||||
|
||||
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
|
||||
|
||||
/**
|
||||
* Field Mapper para Archive Header
|
||||
*
|
||||
* RESPONSABILIDAD:
|
||||
* - Mapear field IDs del formulario a atributos de BD
|
||||
* - Solo conoce sus propios campos (modularidad)
|
||||
*/
|
||||
final class ArchiveHeaderFieldMapper implements FieldMapperInterface
|
||||
{
|
||||
public function getComponentName(): string
|
||||
{
|
||||
return 'archive-header';
|
||||
}
|
||||
|
||||
public function getFieldMapping(): array
|
||||
{
|
||||
return [
|
||||
// Visibility
|
||||
'archiveHeaderEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'archiveHeaderShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'archiveHeaderShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'archiveHeaderVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'archiveHeaderVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'archiveHeaderVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'archiveHeaderVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'archiveHeaderVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions)
|
||||
'archiveHeaderExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'archiveHeaderExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'archiveHeaderExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'archiveHeaderExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'archiveHeaderBlogTitle' => ['group' => 'content', 'attribute' => 'blog_title'],
|
||||
'archiveHeaderShowPostCount' => ['group' => 'content', 'attribute' => 'show_post_count'],
|
||||
'archiveHeaderShowDescription' => ['group' => 'content', 'attribute' => 'show_description'],
|
||||
'archiveHeaderCategoryPrefix' => ['group' => 'content', 'attribute' => 'category_prefix'],
|
||||
'archiveHeaderTagPrefix' => ['group' => 'content', 'attribute' => 'tag_prefix'],
|
||||
'archiveHeaderAuthorPrefix' => ['group' => 'content', 'attribute' => 'author_prefix'],
|
||||
'archiveHeaderDatePrefix' => ['group' => 'content', 'attribute' => 'date_prefix'],
|
||||
'archiveHeaderSearchPrefix' => ['group' => 'content', 'attribute' => 'search_prefix'],
|
||||
'archiveHeaderCountSingular' => ['group' => 'content', 'attribute' => 'posts_count_singular'],
|
||||
'archiveHeaderCountPlural' => ['group' => 'content', 'attribute' => 'posts_count_plural'],
|
||||
|
||||
// Typography
|
||||
'archiveHeaderHeadingLevel' => ['group' => 'typography', 'attribute' => 'heading_level'],
|
||||
'archiveHeaderTitleSize' => ['group' => 'typography', 'attribute' => 'title_size'],
|
||||
'archiveHeaderTitleWeight' => ['group' => 'typography', 'attribute' => 'title_weight'],
|
||||
'archiveHeaderDescriptionSize' => ['group' => 'typography', 'attribute' => 'description_size'],
|
||||
'archiveHeaderCountSize' => ['group' => 'typography', 'attribute' => 'count_size'],
|
||||
|
||||
// Colors
|
||||
'archiveHeaderTitleColor' => ['group' => 'colors', 'attribute' => 'title_color'],
|
||||
'archiveHeaderPrefixColor' => ['group' => 'colors', 'attribute' => 'prefix_color'],
|
||||
'archiveHeaderDescriptionColor' => ['group' => 'colors', 'attribute' => 'description_color'],
|
||||
'archiveHeaderCountBgColor' => ['group' => 'colors', 'attribute' => 'count_bg_color'],
|
||||
'archiveHeaderCountTextColor' => ['group' => 'colors', 'attribute' => 'count_text_color'],
|
||||
|
||||
// Spacing
|
||||
'archiveHeaderMarginTop' => ['group' => 'spacing', 'attribute' => 'margin_top'],
|
||||
'archiveHeaderMarginBottom' => ['group' => 'spacing', 'attribute' => 'margin_bottom'],
|
||||
'archiveHeaderPadding' => ['group' => 'spacing', 'attribute' => 'padding'],
|
||||
'archiveHeaderTitleMarginBottom' => ['group' => 'spacing', 'attribute' => 'title_margin_bottom'],
|
||||
'archiveHeaderCountPadding' => ['group' => 'spacing', 'attribute' => 'count_padding'],
|
||||
|
||||
// Behavior
|
||||
'archiveHeaderIsSticky' => ['group' => 'behavior', 'attribute' => 'is_sticky'],
|
||||
'archiveHeaderStickyOffset' => ['group' => 'behavior', 'attribute' => 'sticky_offset'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\ArchiveHeader\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para Archive Header
|
||||
*
|
||||
* @package ROITheme\Admin\ArchiveHeader\Infrastructure\Ui
|
||||
*/
|
||||
final class ArchiveHeaderFormBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private AdminDashboardRenderer $renderer
|
||||
) {}
|
||||
|
||||
public function buildForm(string $componentId): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= $this->buildHeader($componentId);
|
||||
|
||||
$html .= '<div class="row g-3">';
|
||||
|
||||
// Columna izquierda
|
||||
$html .= '<div class="col-lg-6">';
|
||||
$html .= $this->buildVisibilityGroup($componentId);
|
||||
$html .= $this->buildContentGroup($componentId);
|
||||
$html .= $this->buildBehaviorGroup($componentId);
|
||||
$html .= '</div>';
|
||||
|
||||
// Columna derecha
|
||||
$html .= '<div class="col-lg-6">';
|
||||
$html .= $this->buildTypographyGroup($componentId);
|
||||
$html .= $this->buildColorsGroup($componentId);
|
||||
$html .= $this->buildSpacingGroup($componentId);
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildHeader(string $componentId): string
|
||||
{
|
||||
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
|
||||
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
|
||||
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
|
||||
$html .= ' <div>';
|
||||
$html .= ' <h3 class="h4 mb-1 fw-bold">';
|
||||
$html .= ' <i class="bi bi-layout-text-window me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Configuracion de Cabecera de Archivo';
|
||||
$html .= ' </h3>';
|
||||
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
|
||||
$html .= ' Cabecera dinamica para paginas de listados (blog, categorias, tags, autor, fecha, busqueda)';
|
||||
$html .= ' </p>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="archive-header">';
|
||||
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
|
||||
$html .= ' Restaurar valores por defecto';
|
||||
$html .= ' </button>';
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildVisibilityGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Visibilidad';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
|
||||
$html .= $this->buildSwitch('archiveHeaderEnabled', 'Activar componente', 'bi-power', $enabled);
|
||||
|
||||
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
|
||||
$html .= $this->buildSwitch('archiveHeaderShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
|
||||
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
|
||||
$html .= $this->buildSwitch('archiveHeaderShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
// Page visibility checkboxes
|
||||
$html .= ' <hr class="my-3">';
|
||||
$html .= ' <p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en tipos de pagina';
|
||||
$html .= ' </p>';
|
||||
|
||||
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
|
||||
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', false);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', false);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('archiveHeaderVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Exclusions
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'archiveHeader');
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildContentGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Contenido';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Blog Title
|
||||
$blogTitle = $this->renderer->getFieldValue($componentId, 'content', 'blog_title', 'Blog');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="archiveHeaderBlogTitle" class="form-label small mb-1 fw-semibold">Titulo del blog</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderBlogTitle" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($blogTitle) . '">';
|
||||
$html .= ' <small class="text-muted">Mostrado en la pagina principal del blog</small>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Switches
|
||||
$showPostCount = $this->renderer->getFieldValue($componentId, 'content', 'show_post_count', true);
|
||||
$html .= $this->buildSwitch('archiveHeaderShowPostCount', 'Mostrar contador de posts', 'bi-hash', $showPostCount);
|
||||
|
||||
$showDescription = $this->renderer->getFieldValue($componentId, 'content', 'show_description', true);
|
||||
$html .= $this->buildSwitch('archiveHeaderShowDescription', 'Mostrar descripcion', 'bi-text-paragraph', $showDescription);
|
||||
|
||||
// Prefixes section
|
||||
$html .= ' <hr class="my-3">';
|
||||
$html .= ' <p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-tag me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Prefijos de titulo';
|
||||
$html .= ' </p>';
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
|
||||
$categoryPrefix = $this->renderer->getFieldValue($componentId, 'content', 'category_prefix', 'Categoria:');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderCategoryPrefix" class="form-label small mb-1">Categoria</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderCategoryPrefix" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($categoryPrefix) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$tagPrefix = $this->renderer->getFieldValue($componentId, 'content', 'tag_prefix', 'Etiqueta:');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderTagPrefix" class="form-label small mb-1">Etiqueta</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderTagPrefix" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($tagPrefix) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$authorPrefix = $this->renderer->getFieldValue($componentId, 'content', 'author_prefix', 'Articulos de:');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderAuthorPrefix" class="form-label small mb-1">Autor</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderAuthorPrefix" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($authorPrefix) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$datePrefix = $this->renderer->getFieldValue($componentId, 'content', 'date_prefix', 'Archivo:');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderDatePrefix" class="form-label small mb-1">Fecha</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderDatePrefix" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($datePrefix) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$searchPrefix = $this->renderer->getFieldValue($componentId, 'content', 'search_prefix', 'Resultados para:');
|
||||
$html .= ' <div class="col-12">';
|
||||
$html .= ' <label for="archiveHeaderSearchPrefix" class="form-label small mb-1">Busqueda</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderSearchPrefix" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($searchPrefix) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
// Post count texts
|
||||
$html .= ' <hr class="my-3">';
|
||||
$html .= ' <p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-123 me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Textos del contador';
|
||||
$html .= ' </p>';
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
|
||||
$countSingular = $this->renderer->getFieldValue($componentId, 'content', 'posts_count_singular', 'publicacion');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderCountSingular" class="form-label small mb-1">Singular</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderCountSingular" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($countSingular) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$countPlural = $this->renderer->getFieldValue($componentId, 'content', 'posts_count_plural', 'publicaciones');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderCountPlural" class="form-label small mb-1">Plural</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderCountPlural" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($countPlural) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildBehaviorGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-gear me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Comportamiento';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$isSticky = $this->renderer->getFieldValue($componentId, 'behavior', 'is_sticky', false);
|
||||
$html .= $this->buildSwitch('archiveHeaderIsSticky', 'Header fijo al hacer scroll', 'bi-pin-angle', $isSticky);
|
||||
|
||||
$stickyOffset = $this->renderer->getFieldValue($componentId, 'behavior', 'sticky_offset', '0');
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <label for="archiveHeaderStickyOffset" class="form-label small mb-1 fw-semibold">Offset sticky</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderStickyOffset" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($stickyOffset) . '">';
|
||||
$html .= ' <small class="text-muted">Distancia desde el top cuando es sticky (ej: 60px)</small>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildTypographyGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Tipografia';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Heading level
|
||||
$headingLevel = $this->renderer->getFieldValue($componentId, 'typography', 'heading_level', 'h1');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="archiveHeaderHeadingLevel" class="form-label small mb-1 fw-semibold">Nivel de encabezado</label>';
|
||||
$html .= ' <select id="archiveHeaderHeadingLevel" class="form-select form-select-sm">';
|
||||
foreach (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as $level) {
|
||||
$selected = $headingLevel === $level ? ' selected' : '';
|
||||
$html .= sprintf(' <option value="%s"%s>%s</option>', $level, $selected, strtoupper($level));
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' <small class="text-muted">Importante para SEO y accesibilidad</small>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$titleSize = $this->renderer->getFieldValue($componentId, 'typography', 'title_size', '2rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderTitleSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($titleSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$titleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'title_weight', '700');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderTitleWeight" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($titleWeight) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-0">';
|
||||
|
||||
$descriptionSize = $this->renderer->getFieldValue($componentId, 'typography', 'description_size', '1rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderDescriptionSize" class="form-label small mb-1 fw-semibold">Tamano descripcion</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderDescriptionSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($descriptionSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$countSize = $this->renderer->getFieldValue($componentId, 'typography', 'count_size', '0.875rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderCountSize" class="form-label small mb-1 fw-semibold">Tamano contador</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderCountSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($countSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildColorsGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Colores';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$titleColor = $this->renderer->getFieldValue($componentId, 'colors', 'title_color', '#0E2337');
|
||||
$html .= $this->buildColorPicker('archiveHeaderTitleColor', 'Titulo', $titleColor);
|
||||
|
||||
$prefixColor = $this->renderer->getFieldValue($componentId, 'colors', 'prefix_color', '#6b7280');
|
||||
$html .= $this->buildColorPicker('archiveHeaderPrefixColor', 'Prefijo', $prefixColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$descriptionColor = $this->renderer->getFieldValue($componentId, 'colors', 'description_color', '#6b7280');
|
||||
$html .= $this->buildColorPicker('archiveHeaderDescriptionColor', 'Descripcion', $descriptionColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <p class="small fw-semibold mb-2">Contador de posts</p>';
|
||||
$html .= ' <div class="row g-2 mb-0">';
|
||||
|
||||
$countBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'count_bg_color', '#FF8600');
|
||||
$html .= $this->buildColorPicker('archiveHeaderCountBgColor', 'Fondo', $countBgColor);
|
||||
|
||||
$countTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'count_text_color', '#ffffff');
|
||||
$html .= $this->buildColorPicker('archiveHeaderCountTextColor', 'Texto', $countTextColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildSpacingGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Espaciado';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$marginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_top', '2rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderMarginTop" class="form-label small mb-1 fw-semibold">Margen superior</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderMarginTop" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($marginTop) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$marginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'margin_bottom', '2rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderMarginBottom" class="form-label small mb-1 fw-semibold">Margen inferior</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderMarginBottom" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($marginBottom) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$padding = $this->renderer->getFieldValue($componentId, 'spacing', 'padding', '1.5rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderPadding" class="form-label small mb-1 fw-semibold">Padding</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderPadding" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($padding) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$titleMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'title_margin_bottom', '0.5rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="archiveHeaderTitleMarginBottom" class="form-label small mb-1 fw-semibold">Margen titulo</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderTitleMarginBottom" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($titleMarginBottom) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="mb-0">';
|
||||
$countPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'count_padding', '0.25rem 0.75rem');
|
||||
$html .= ' <label for="archiveHeaderCountPadding" class="form-label small mb-1 fw-semibold">Padding contador</label>';
|
||||
$html .= ' <input type="text" id="archiveHeaderCountPadding" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($countPadding) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
|
||||
{
|
||||
$checked = $checked === true || $checked === '1' || $checked === 1;
|
||||
|
||||
$html = ' <div class="mb-2">';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= sprintf(
|
||||
' <input class="form-check-input" type="checkbox" id="%s" %s>',
|
||||
esc_attr($id),
|
||||
$checked ? 'checked' : ''
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <label class="form-check-label small" for="%s">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
|
||||
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildColorPicker(string $id, string $label, string $value): string
|
||||
{
|
||||
$html = ' <div class="col-6">';
|
||||
$html .= sprintf(
|
||||
' <label class="form-label small fw-semibold">%s</label>',
|
||||
esc_html($label)
|
||||
);
|
||||
$html .= ' <div class="input-group input-group-sm">';
|
||||
$html .= sprintf(
|
||||
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
|
||||
esc_attr($id),
|
||||
esc_attr($value)
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <span class="input-group-text" id="%sValue">%s</span>',
|
||||
esc_attr($id),
|
||||
esc_html(strtoupper($value))
|
||||
);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
|
||||
{
|
||||
$checked = $checked === true || $checked === '1' || $checked === 1;
|
||||
|
||||
$html = ' <div class="form-check form-check-checkbox mb-2">';
|
||||
$html .= sprintf(
|
||||
' <input class="form-check-input" type="checkbox" id="%s" %s>',
|
||||
esc_attr($id),
|
||||
$checked ? 'checked' : ''
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <label class="form-check-label small" for="%s">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
|
||||
$html .= sprintf(' %s', esc_html($label));
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ final class CtaBoxSidebarFormBuilder
|
||||
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
|
||||
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
// Grid 3 columnas según Design System
|
||||
|
||||
@@ -8,9 +8,10 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
|
||||
/**
|
||||
* Value Object para ID único de snippet CSS
|
||||
*
|
||||
* Soporta dos formatos:
|
||||
* Soporta tres formatos:
|
||||
* 1. Generado: css_[timestamp]_[random] (ej: "css_1701432000_a1b2c3")
|
||||
* 2. Legacy/Migración: kebab-case (ej: "cls-tables-apu", "generic-tables")
|
||||
* 2. Legacy con prefijo: css_[descriptive]_[number] (ej: "css_tablas_apu_1764624826")
|
||||
* 3. Legacy kebab-case: (ej: "cls-tables-apu", "generic-tables")
|
||||
*
|
||||
* Esto permite migrar snippets existentes sin romper IDs.
|
||||
*/
|
||||
@@ -18,6 +19,7 @@ final class SnippetId
|
||||
{
|
||||
private const PREFIX = 'css_';
|
||||
private const PATTERN_GENERATED = '/^css_[0-9]+_[a-z0-9]{6}$/';
|
||||
private const PATTERN_LEGACY_PREFIX = '/^css_[a-z0-9_]+$/';
|
||||
private const PATTERN_LEGACY = '/^[a-z0-9]+(-[a-z0-9]+)*$/';
|
||||
|
||||
private function __construct(
|
||||
@@ -47,7 +49,8 @@ final class SnippetId
|
||||
|
||||
// Validar formato generado (css_*)
|
||||
if (str_starts_with($id, self::PREFIX)) {
|
||||
if (!preg_match(self::PATTERN_GENERATED, $id)) {
|
||||
// Acepta formato nuevo (css_timestamp_random) o legacy (css_descriptivo_numero)
|
||||
if (!preg_match(self::PATTERN_GENERATED, $id) && !preg_match(self::PATTERN_LEGACY_PREFIX, $id)) {
|
||||
throw new ValidationException(
|
||||
sprintf('Formato de ID generado inválido: %s. Esperado: css_[timestamp]_[random]', $id)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Bootstrap;
|
||||
|
||||
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\UseCases\SaveSnippetUseCase;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\UseCases\DeleteSnippetUseCase;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
|
||||
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
|
||||
use ROITheme\Shared\Domain\Exceptions\ValidationException;
|
||||
|
||||
/**
|
||||
* Bootstrap para CustomCSSManager
|
||||
*
|
||||
* Registra el handler de formulario POST en admin_init
|
||||
* ANTES de que se envíen headers HTTP
|
||||
*/
|
||||
final class CustomCSSManagerBootstrap
|
||||
{
|
||||
private const NONCE_ACTION = 'roi_custom_css_manager';
|
||||
|
||||
public static function init(): void
|
||||
{
|
||||
add_action('admin_init', [self::class, 'handleFormSubmission']);
|
||||
}
|
||||
|
||||
public static function handleFormSubmission(): void
|
||||
{
|
||||
if (!isset($_POST['roi_css_action'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar que estamos en la página correcta
|
||||
$page = $_GET['page'] ?? '';
|
||||
$component = $_GET['component'] ?? '';
|
||||
if ($page !== 'roi-theme-admin' || $component !== 'custom-css-manager') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar nonce
|
||||
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', self::NONCE_ACTION)) {
|
||||
wp_die('Nonce verification failed');
|
||||
}
|
||||
|
||||
// Verificar permisos
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_die('Insufficient permissions');
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$repository = new WordPressSnippetRepository($wpdb);
|
||||
$saveUseCase = new SaveSnippetUseCase($repository);
|
||||
$deleteUseCase = new DeleteSnippetUseCase($repository);
|
||||
|
||||
$action = sanitize_text_field($_POST['roi_css_action']);
|
||||
|
||||
try {
|
||||
match ($action) {
|
||||
'save' => self::processSave($_POST, $saveUseCase),
|
||||
'delete' => self::processDelete($_POST, $deleteUseCase),
|
||||
default => null,
|
||||
};
|
||||
|
||||
// Redirect con mensaje de éxito
|
||||
$redirect_url = admin_url('admin.php?page=roi-theme-admin&component=custom-css-manager&roi_message=success');
|
||||
wp_redirect($redirect_url);
|
||||
exit;
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
$redirect_url = admin_url('admin.php?page=roi-theme-admin&component=custom-css-manager&roi_message=error&roi_error=' . urlencode($e->getMessage()));
|
||||
wp_redirect($redirect_url);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
private static function processSave(array $data, SaveSnippetUseCase $useCase): void
|
||||
{
|
||||
$id = sanitize_text_field($data['snippet_id'] ?? '');
|
||||
if (empty($id)) {
|
||||
$id = SnippetId::generate()->value();
|
||||
}
|
||||
|
||||
$request = SaveSnippetRequest::fromArray([
|
||||
'id' => $id,
|
||||
'name' => sanitize_text_field($data['snippet_name'] ?? ''),
|
||||
'description' => sanitize_textarea_field($data['snippet_description'] ?? ''),
|
||||
'css' => wp_strip_all_tags($data['snippet_css'] ?? ''),
|
||||
'type' => sanitize_text_field($data['snippet_type'] ?? 'deferred'),
|
||||
'pages' => array_map('sanitize_text_field', $data['snippet_pages'] ?? ['all']),
|
||||
'enabled' => isset($data['snippet_enabled']),
|
||||
'order' => absint($data['snippet_order'] ?? 100),
|
||||
]);
|
||||
|
||||
$useCase->execute($request);
|
||||
}
|
||||
|
||||
private static function processDelete(array $data, DeleteSnippetUseCase $useCase): void
|
||||
{
|
||||
$id = sanitize_text_field($data['snippet_id'] ?? '');
|
||||
if (empty($id)) {
|
||||
throw new ValidationException('ID de snippet requerido para eliminar');
|
||||
}
|
||||
$useCase->execute($id);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,7 @@ namespace ROITheme\Admin\CustomCSSManager\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\CustomCSSManager\Infrastructure\Persistence\WordPressSnippetRepository;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\UseCases\SaveSnippetUseCase;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\UseCases\DeleteSnippetUseCase;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\UseCases\GetAllSnippetsUseCase;
|
||||
use ROITheme\Admin\CustomCSSManager\Application\DTOs\SaveSnippetRequest;
|
||||
use ROITheme\Admin\CustomCSSManager\Domain\ValueObjects\SnippetId;
|
||||
use ROITheme\Shared\Domain\Exceptions\ValidationException;
|
||||
|
||||
/**
|
||||
* FormBuilder para gestión de CSS snippets en Admin Panel
|
||||
@@ -19,6 +14,9 @@ use ROITheme\Shared\Domain\Exceptions\ValidationException;
|
||||
* - Constructor recibe AdminDashboardRenderer
|
||||
* - Método buildForm() genera el HTML del formulario
|
||||
*
|
||||
* NOTA: El handler de formulario POST está en CustomCSSManagerBootstrap
|
||||
* para que se ejecute en admin_init ANTES de que se envíen headers HTTP.
|
||||
*
|
||||
* Design System: Gradiente navy #0E2337 → #1e3a5f, accent #FF8600
|
||||
*/
|
||||
final class CustomCSSManagerFormBuilder
|
||||
@@ -28,120 +26,15 @@ final class CustomCSSManagerFormBuilder
|
||||
|
||||
private WordPressSnippetRepository $repository;
|
||||
private GetAllSnippetsUseCase $getAllUseCase;
|
||||
private SaveSnippetUseCase $saveUseCase;
|
||||
private DeleteSnippetUseCase $deleteUseCase;
|
||||
|
||||
public function __construct(
|
||||
private readonly AdminDashboardRenderer $renderer
|
||||
) {
|
||||
// Crear repositorio y Use Cases internamente
|
||||
// Crear repositorio y Use Case para listar snippets
|
||||
global $wpdb;
|
||||
$this->repository = new WordPressSnippetRepository($wpdb);
|
||||
$this->getAllUseCase = new GetAllSnippetsUseCase($this->repository);
|
||||
$this->saveUseCase = new SaveSnippetUseCase($this->repository);
|
||||
$this->deleteUseCase = new DeleteSnippetUseCase($this->repository);
|
||||
|
||||
// Registrar handler de formulario POST
|
||||
$this->registerFormHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra handler para procesar formularios POST
|
||||
*/
|
||||
private function registerFormHandler(): void
|
||||
{
|
||||
// Solo registrar una vez
|
||||
static $registered = false;
|
||||
if ($registered) {
|
||||
return;
|
||||
}
|
||||
$registered = true;
|
||||
|
||||
add_action('admin_init', function() {
|
||||
$this->handleFormSubmission();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa envío de formulario
|
||||
*/
|
||||
public function handleFormSubmission(): void
|
||||
{
|
||||
if (!isset($_POST['roi_css_action'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar nonce
|
||||
if (!wp_verify_nonce($_POST['_wpnonce'] ?? '', self::NONCE_ACTION)) {
|
||||
wp_die('Nonce verification failed');
|
||||
}
|
||||
|
||||
// Verificar permisos
|
||||
if (!current_user_can('manage_options')) {
|
||||
wp_die('Insufficient permissions');
|
||||
}
|
||||
|
||||
$action = sanitize_text_field($_POST['roi_css_action']);
|
||||
|
||||
try {
|
||||
match ($action) {
|
||||
'save' => $this->processSave($_POST),
|
||||
'delete' => $this->processDelete($_POST),
|
||||
default => null,
|
||||
};
|
||||
|
||||
// Redirect con mensaje de éxito
|
||||
wp_redirect(add_query_arg('roi_message', 'success', wp_get_referer()));
|
||||
exit;
|
||||
|
||||
} catch (ValidationException $e) {
|
||||
// Redirect con mensaje de error
|
||||
wp_redirect(add_query_arg([
|
||||
'roi_message' => 'error',
|
||||
'roi_error' => urlencode($e->getMessage())
|
||||
], wp_get_referer()));
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa guardado de snippet
|
||||
*/
|
||||
private function processSave(array $data): void
|
||||
{
|
||||
$id = sanitize_text_field($data['snippet_id'] ?? '');
|
||||
|
||||
// Generar ID si es nuevo
|
||||
if (empty($id)) {
|
||||
$id = SnippetId::generate()->value();
|
||||
}
|
||||
|
||||
$request = SaveSnippetRequest::fromArray([
|
||||
'id' => $id,
|
||||
'name' => sanitize_text_field($data['snippet_name'] ?? ''),
|
||||
'description' => sanitize_textarea_field($data['snippet_description'] ?? ''),
|
||||
'css' => wp_strip_all_tags($data['snippet_css'] ?? ''),
|
||||
'type' => sanitize_text_field($data['snippet_type'] ?? 'deferred'),
|
||||
'pages' => array_map('sanitize_text_field', $data['snippet_pages'] ?? ['all']),
|
||||
'enabled' => isset($data['snippet_enabled']),
|
||||
'order' => absint($data['snippet_order'] ?? 100),
|
||||
]);
|
||||
|
||||
$this->saveUseCase->execute($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa eliminación de snippet
|
||||
*/
|
||||
private function processDelete(array $data): void
|
||||
{
|
||||
$id = sanitize_text_field($data['snippet_id'] ?? '');
|
||||
|
||||
if (empty($id)) {
|
||||
throw new ValidationException('ID de snippet requerido para eliminar');
|
||||
}
|
||||
|
||||
$this->deleteUseCase->execute($id);
|
||||
// NOTA: El handler POST está en CustomCSSManagerBootstrap (admin_init)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,13 +53,9 @@ final class CustomCSSManagerFormBuilder
|
||||
// Header
|
||||
$html .= $this->buildHeader($componentId, count($snippets));
|
||||
|
||||
// Mensajes flash
|
||||
// Toast para mensajes (usa el sistema existente de admin-dashboard.js)
|
||||
if ($message) {
|
||||
$html .= sprintf(
|
||||
'<div class="alert alert-%s m-3">%s</div>',
|
||||
esc_attr($message['type']),
|
||||
esc_html($message['text'])
|
||||
);
|
||||
$html .= $this->buildToastTrigger($message);
|
||||
}
|
||||
|
||||
// Lista de snippets existentes
|
||||
@@ -367,7 +256,7 @@ final class CustomCSSManagerFormBuilder
|
||||
// Botones
|
||||
$html .= ' <div class="col-12">';
|
||||
$html .= ' <button type="submit" class="btn text-white" style="background-color: #FF8600;">';
|
||||
$html .= ' <i class="bi bi-save me-1"></i> Guardar Snippet';
|
||||
$html .= ' <i class="bi bi-check-circle me-1"></i> Guardar Cambios';
|
||||
$html .= ' </button>';
|
||||
$html .= ' <button type="button" class="btn btn-secondary" onclick="resetCssForm()">';
|
||||
$html .= ' <i class="bi bi-x-circle me-1"></i> Cancelar';
|
||||
@@ -449,14 +338,84 @@ final class CustomCSSManagerFormBuilder
|
||||
$message = $_GET['roi_message'] ?? null;
|
||||
|
||||
if ($message === 'success') {
|
||||
return ['type' => 'success', 'text' => 'Snippet guardado correctamente'];
|
||||
return ['type' => 'success', 'text' => 'Cambios guardados correctamente'];
|
||||
}
|
||||
|
||||
if ($message === 'error') {
|
||||
$error = urldecode($_GET['roi_error'] ?? 'Error desconocido');
|
||||
return ['type' => 'danger', 'text' => $error];
|
||||
return ['type' => 'error', 'text' => $error];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera script para mostrar Toast
|
||||
*/
|
||||
private function buildToastTrigger(array $message): string
|
||||
{
|
||||
$type = esc_js($message['type']);
|
||||
$text = esc_js($message['text']);
|
||||
|
||||
// Mapear tipo a configuración de Bootstrap
|
||||
$typeMap = [
|
||||
'success' => ['bg' => 'success', 'icon' => 'bi-check-circle-fill'],
|
||||
'error' => ['bg' => 'danger', 'icon' => 'bi-x-circle-fill'],
|
||||
];
|
||||
$config = $typeMap[$type] ?? $typeMap['success'];
|
||||
$bg = $config['bg'];
|
||||
$icon = $config['icon'];
|
||||
|
||||
return <<<HTML
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Crear container de toasts si no existe
|
||||
let toastContainer = document.getElementById('roiToastContainer');
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.id = 'roiToastContainer';
|
||||
toastContainer.className = 'toast-container position-fixed start-50 translate-middle-x';
|
||||
toastContainer.style.top = '60px';
|
||||
toastContainer.style.zIndex = '999999';
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
|
||||
// Crear toast
|
||||
const toastId = 'toast-' + Date.now();
|
||||
const toastHTML = `
|
||||
<div id="\${toastId}" class="toast align-items-center text-white bg-{$bg} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi {$icon} me-2"></i>
|
||||
<strong>{$text}</strong>
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
|
||||
|
||||
// Mostrar toast
|
||||
const toastElement = document.getElementById(toastId);
|
||||
const toast = new bootstrap.Toast(toastElement, {
|
||||
autohide: true,
|
||||
delay: 5000
|
||||
});
|
||||
toast.show();
|
||||
|
||||
// Eliminar del DOM después de ocultarse
|
||||
toastElement.addEventListener('hidden.bs.toast', function() {
|
||||
toastElement.remove();
|
||||
});
|
||||
|
||||
// Limpiar parámetros de URL sin recargar
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('roi_message');
|
||||
url.searchParams.delete('roi_error');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
});
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,16 @@ final class AdminDashboardRenderer implements DashboardRendererInterface
|
||||
'label' => 'Related Posts',
|
||||
'icon' => 'bi-grid-3x3-gap',
|
||||
],
|
||||
'archive-header' => [
|
||||
'id' => 'archive-header',
|
||||
'label' => 'Archive Header',
|
||||
'icon' => 'bi-layout-text-window',
|
||||
],
|
||||
'post-grid' => [
|
||||
'id' => 'post-grid',
|
||||
'label' => 'Post Grid',
|
||||
'icon' => 'bi-grid-3x3',
|
||||
],
|
||||
'contact-form' => [
|
||||
'id' => 'contact-form',
|
||||
'label' => 'Contact Form',
|
||||
|
||||
@@ -518,4 +518,402 @@
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// IN-CONTENT ADS AVANZADO - JavaScript
|
||||
// =========================================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeInContentAdvanced();
|
||||
});
|
||||
|
||||
/**
|
||||
* Inicializa la funcionalidad de In-Content Ads Avanzado
|
||||
*/
|
||||
function initializeInContentAdvanced() {
|
||||
// Buscar el selector de modo (puede tener prefijo dinámico)
|
||||
const modeSelect = document.querySelector('[id$="IncontentMode"]');
|
||||
if (!modeSelect) {
|
||||
return; // No estamos en la página de AdSense
|
||||
}
|
||||
|
||||
// Obtener prefijo del componente desde el ID
|
||||
const componentPrefix = modeSelect.id.replace('IncontentMode', '');
|
||||
|
||||
// Definir presets de modos
|
||||
const modePresets = {
|
||||
'paragraphs_only': null, // Solo inserta despues de parrafos (config basica)
|
||||
'conservative': {
|
||||
maxAds: '5',
|
||||
minSpacing: '5',
|
||||
h2: { enabled: true, prob: '75' },
|
||||
h3: { enabled: false, prob: '50' },
|
||||
paragraphs: { enabled: true, prob: '50' },
|
||||
images: { enabled: false, prob: '50' },
|
||||
lists: { enabled: false, prob: '50' },
|
||||
blockquotes: { enabled: false, prob: '50' },
|
||||
tables: { enabled: false, prob: '50' }
|
||||
},
|
||||
'balanced': {
|
||||
maxAds: '8',
|
||||
minSpacing: '3',
|
||||
h2: { enabled: true, prob: '100' },
|
||||
h3: { enabled: true, prob: '50' },
|
||||
paragraphs: { enabled: true, prob: '75' },
|
||||
images: { enabled: true, prob: '75' },
|
||||
lists: { enabled: false, prob: '50' },
|
||||
blockquotes: { enabled: false, prob: '50' },
|
||||
tables: { enabled: false, prob: '50' }
|
||||
},
|
||||
'aggressive': {
|
||||
maxAds: '15',
|
||||
minSpacing: '2',
|
||||
h2: { enabled: true, prob: '100' },
|
||||
h3: { enabled: true, prob: '100' },
|
||||
paragraphs: { enabled: true, prob: '100' },
|
||||
images: { enabled: true, prob: '100' },
|
||||
lists: { enabled: true, prob: '75' },
|
||||
blockquotes: { enabled: true, prob: '75' },
|
||||
tables: { enabled: true, prob: '75' }
|
||||
},
|
||||
'custom': null // Configuración manual
|
||||
};
|
||||
|
||||
// Elementos del DOM
|
||||
const elements = {
|
||||
mode: modeSelect,
|
||||
paragraphsOnlyBanner: document.getElementById('roiParagraphsOnlyBanner'),
|
||||
densityIndicator: document.getElementById('roiIncontentDensityIndicator'),
|
||||
densityLevel: document.getElementById('roiDensityLevel'),
|
||||
densityBadge: document.getElementById('roiDensityBadge'),
|
||||
highDensityWarning: document.getElementById('roiHighDensityWarning'),
|
||||
locationsDetails: document.getElementById('roiLocationsDetails'),
|
||||
limitsDetails: document.getElementById('roiLimitsDetails'),
|
||||
maxAds: document.querySelector('[id$="IncontentMaxTotalAds"]'),
|
||||
minSpacing: document.querySelector('[id$="IncontentMinSpacing"]'),
|
||||
// Descripciones de modos
|
||||
modeDescriptions: {
|
||||
paragraphs_only: document.getElementById('roiModeDescParagraphsOnly'),
|
||||
conservative: document.getElementById('roiModeDescConservative'),
|
||||
balanced: document.getElementById('roiModeDescBalanced'),
|
||||
aggressive: document.getElementById('roiModeDescAggressive'),
|
||||
custom: document.getElementById('roiModeDescCustom')
|
||||
},
|
||||
locations: [
|
||||
{ key: 'H2', el: document.querySelector('[id$="IncontentAfterH2Enabled"]'), prob: document.querySelector('[id$="IncontentAfterH2Probability"]') },
|
||||
{ key: 'H3', el: document.querySelector('[id$="IncontentAfterH3Enabled"]'), prob: document.querySelector('[id$="IncontentAfterH3Probability"]') },
|
||||
{ key: 'Paragraphs', el: document.querySelector('[id$="IncontentAfterParagraphsEnabled"]'), prob: document.querySelector('[id$="IncontentAfterParagraphsProbability"]') },
|
||||
{ key: 'Images', el: document.querySelector('[id$="IncontentAfterImagesEnabled"]'), prob: document.querySelector('[id$="IncontentAfterImagesProbability"]') },
|
||||
{ key: 'Lists', el: document.querySelector('[id$="IncontentAfterListsEnabled"]'), prob: document.querySelector('[id$="IncontentAfterListsProbability"]') },
|
||||
{ key: 'Blockquotes', el: document.querySelector('[id$="IncontentAfterBlockquotesEnabled"]'), prob: document.querySelector('[id$="IncontentAfterBlockquotesProbability"]') },
|
||||
{ key: 'Tables', el: document.querySelector('[id$="IncontentAfterTablesEnabled"]'), prob: document.querySelector('[id$="IncontentAfterTablesProbability"]') }
|
||||
]
|
||||
};
|
||||
|
||||
// Estado para detectar cambios manuales
|
||||
let isApplyingPreset = false;
|
||||
|
||||
/**
|
||||
* Actualiza el indicador de densidad
|
||||
*/
|
||||
function updateDensityIndicator() {
|
||||
const mode = elements.mode.value;
|
||||
|
||||
if (mode === 'paragraphs_only') {
|
||||
elements.densityLevel.textContent = 'Solo parrafos';
|
||||
elements.densityBadge.textContent = 'clasico';
|
||||
elements.densityBadge.className = 'badge bg-secondary ms-1';
|
||||
elements.densityIndicator.className = 'alert alert-light border small mb-3';
|
||||
elements.highDensityWarning.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
// Calcular densidad estimada
|
||||
const maxAds = parseInt(elements.maxAds.value) || 8;
|
||||
let totalWeight = 0;
|
||||
let enabledCount = 0;
|
||||
|
||||
elements.locations.forEach(loc => {
|
||||
if (loc.el && loc.el.checked) {
|
||||
const prob = parseInt(loc.prob.value) || 100;
|
||||
totalWeight += prob;
|
||||
enabledCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const avgProb = enabledCount > 0 ? totalWeight / enabledCount : 0;
|
||||
const estimatedAds = Math.round((maxAds * avgProb) / 100);
|
||||
|
||||
// Determinar nivel
|
||||
let level, badgeClass, alertClass;
|
||||
if (estimatedAds <= 3) {
|
||||
level = 'Baja';
|
||||
badgeClass = 'bg-success';
|
||||
alertClass = 'alert-success';
|
||||
} else if (estimatedAds <= 6) {
|
||||
level = 'Media';
|
||||
badgeClass = 'bg-info';
|
||||
alertClass = 'alert-info';
|
||||
} else if (estimatedAds <= 10) {
|
||||
level = 'Alta';
|
||||
badgeClass = 'bg-warning';
|
||||
alertClass = 'alert-warning';
|
||||
} else {
|
||||
level = 'Muy Alta';
|
||||
badgeClass = 'bg-danger';
|
||||
alertClass = 'alert-danger';
|
||||
}
|
||||
|
||||
elements.densityLevel.textContent = level;
|
||||
elements.densityBadge.textContent = '~' + estimatedAds + ' ads';
|
||||
elements.densityBadge.className = 'badge ' + badgeClass + ' ms-1';
|
||||
elements.densityIndicator.className = 'alert ' + alertClass + ' small mb-3';
|
||||
|
||||
// Mostrar/ocultar warning de densidad alta
|
||||
if (estimatedAds > 10) {
|
||||
elements.highDensityWarning.classList.remove('d-none');
|
||||
} else {
|
||||
elements.highDensityWarning.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica un preset de modo
|
||||
*/
|
||||
function applyPreset(presetName) {
|
||||
const preset = modePresets[presetName];
|
||||
if (!preset) return;
|
||||
|
||||
isApplyingPreset = true;
|
||||
|
||||
// Aplicar max ads y spacing
|
||||
if (elements.maxAds) elements.maxAds.value = preset.maxAds;
|
||||
if (elements.minSpacing) elements.minSpacing.value = preset.minSpacing;
|
||||
|
||||
// Aplicar ubicaciones
|
||||
const locationKeys = ['h2', 'h3', 'paragraphs', 'images', 'lists', 'blockquotes', 'tables'];
|
||||
locationKeys.forEach((key, index) => {
|
||||
const loc = elements.locations[index];
|
||||
const presetLoc = preset[key];
|
||||
if (loc.el && presetLoc) {
|
||||
loc.el.checked = presetLoc.enabled;
|
||||
if (loc.prob) loc.prob.value = presetLoc.prob;
|
||||
}
|
||||
});
|
||||
|
||||
isApplyingPreset = false;
|
||||
updateDensityIndicator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Habilita/deshabilita campos según modo
|
||||
*/
|
||||
function toggleFieldsState() {
|
||||
const currentMode = elements.mode.value;
|
||||
const isParagraphsOnly = currentMode === 'paragraphs_only';
|
||||
|
||||
// Toggle details sections
|
||||
if (elements.locationsDetails) {
|
||||
if (isParagraphsOnly) {
|
||||
elements.locationsDetails.removeAttribute('open');
|
||||
} else {
|
||||
elements.locationsDetails.setAttribute('open', '');
|
||||
}
|
||||
}
|
||||
if (elements.limitsDetails) {
|
||||
if (isParagraphsOnly) {
|
||||
elements.limitsDetails.removeAttribute('open');
|
||||
} else {
|
||||
elements.limitsDetails.setAttribute('open', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle campos
|
||||
if (elements.maxAds) elements.maxAds.disabled = isParagraphsOnly;
|
||||
if (elements.minSpacing) elements.minSpacing.disabled = isParagraphsOnly;
|
||||
|
||||
elements.locations.forEach(loc => {
|
||||
if (loc.el) loc.el.disabled = isParagraphsOnly;
|
||||
if (loc.prob) loc.prob.disabled = isParagraphsOnly;
|
||||
});
|
||||
|
||||
// Toggle banner informativo
|
||||
if (elements.paragraphsOnlyBanner) {
|
||||
if (isParagraphsOnly) {
|
||||
elements.paragraphsOnlyBanner.classList.remove('d-none');
|
||||
} else {
|
||||
elements.paragraphsOnlyBanner.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle descripciones de modo (mostrar solo la activa)
|
||||
if (elements.modeDescriptions) {
|
||||
Object.keys(elements.modeDescriptions).forEach(mode => {
|
||||
const descEl = elements.modeDescriptions[mode];
|
||||
if (descEl) {
|
||||
if (mode === currentMode) {
|
||||
descEl.classList.remove('d-none');
|
||||
} else {
|
||||
descEl.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Actualizar indicador
|
||||
updateDensityIndicator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja cambio de modo
|
||||
*/
|
||||
function handleModeChange(e) {
|
||||
const newMode = e.target.value;
|
||||
const currentMode = e.target.dataset.previousValue || 'paragraphs_only';
|
||||
|
||||
// Si cambia de custom a preset, mostrar confirmación
|
||||
if (currentMode === 'custom' && newMode !== 'custom' && modePresets[newMode]) {
|
||||
showConfirmModal(
|
||||
'Cambiar modo',
|
||||
'Al cambiar a un modo preconfigurado se perderán tus ajustes personalizados. ¿Continuar?',
|
||||
function() {
|
||||
applyPreset(newMode);
|
||||
toggleFieldsState();
|
||||
e.target.dataset.previousValue = newMode;
|
||||
},
|
||||
function() {
|
||||
// Cancelar: restaurar valor anterior
|
||||
e.target.value = currentMode;
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Aplicar preset si corresponde
|
||||
if (modePresets[newMode]) {
|
||||
applyPreset(newMode);
|
||||
}
|
||||
|
||||
toggleFieldsState();
|
||||
e.target.dataset.previousValue = newMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja cambios en campos (auto-switch a custom)
|
||||
*/
|
||||
function handleFieldChange() {
|
||||
if (isApplyingPreset) return;
|
||||
|
||||
const currentMode = elements.mode.value;
|
||||
if (currentMode !== 'custom' && currentMode !== 'paragraphs_only') {
|
||||
elements.mode.value = 'custom';
|
||||
elements.mode.dataset.previousValue = 'custom';
|
||||
showNotice('info', 'Modo cambiado a "Personalizado" por tus ajustes manuales.');
|
||||
updateDensityIndicator();
|
||||
} else {
|
||||
updateDensityIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
// Inicializar estado
|
||||
elements.mode.dataset.previousValue = elements.mode.value;
|
||||
toggleFieldsState();
|
||||
updateDensityIndicator();
|
||||
|
||||
// Event listeners
|
||||
elements.mode.addEventListener('change', handleModeChange);
|
||||
|
||||
if (elements.maxAds) {
|
||||
elements.maxAds.addEventListener('change', handleFieldChange);
|
||||
}
|
||||
if (elements.minSpacing) {
|
||||
elements.minSpacing.addEventListener('change', handleFieldChange);
|
||||
}
|
||||
|
||||
elements.locations.forEach(loc => {
|
||||
if (loc.el) {
|
||||
loc.el.addEventListener('change', handleFieldChange);
|
||||
}
|
||||
if (loc.prob) {
|
||||
loc.prob.addEventListener('change', handleFieldChange);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Muestra un modal de confirmación con callback de cancelación
|
||||
*/
|
||||
function showConfirmModal(title, message, onConfirm, onCancel) {
|
||||
// Crear modal si no existe
|
||||
let modal = document.getElementById('roiConfirmModal');
|
||||
if (!modal) {
|
||||
const modalHTML = `
|
||||
<div class="modal fade" id="roiConfirmModal" tabindex="-1" aria-labelledby="roiConfirmModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header" style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-bottom: none;">
|
||||
<h5 class="modal-title text-white" id="roiConfirmModalLabel">
|
||||
<i class="bi bi-question-circle me-2" style="color: #FF8600;"></i>
|
||||
<span id="roiConfirmModalTitle">Confirmar</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="roiConfirmModalBody" style="padding: 2rem;">
|
||||
Mensaje de confirmación
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid #dee2e6; padding: 1rem 1.5rem;">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle me-1"></i>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="button" class="btn text-white" id="roiConfirmModalConfirm" style="background-color: #FF8600; border-color: #FF8600;">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
Confirmar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
modal = document.getElementById('roiConfirmModal');
|
||||
}
|
||||
|
||||
// Actualizar contenido
|
||||
document.getElementById('roiConfirmModalTitle').textContent = title;
|
||||
document.getElementById('roiConfirmModalBody').textContent = message;
|
||||
|
||||
// Configurar callback de confirmación
|
||||
const confirmButton = document.getElementById('roiConfirmModalConfirm');
|
||||
const newConfirmButton = confirmButton.cloneNode(true);
|
||||
confirmButton.parentNode.replaceChild(newConfirmButton, confirmButton);
|
||||
|
||||
newConfirmButton.addEventListener('click', function() {
|
||||
const bsModal = bootstrap.Modal.getInstance(modal);
|
||||
bsModal.hide();
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm();
|
||||
}
|
||||
});
|
||||
|
||||
// Configurar callback de cancelación
|
||||
if (typeof onCancel === 'function') {
|
||||
modal.addEventListener('hidden.bs.modal', function handler() {
|
||||
modal.removeEventListener('hidden.bs.modal', handler);
|
||||
// Solo llamar onCancel si no fue por confirmación
|
||||
if (!modal.dataset.confirmed) {
|
||||
onCancel();
|
||||
}
|
||||
delete modal.dataset.confirmed;
|
||||
});
|
||||
|
||||
newConfirmButton.addEventListener('click', function() {
|
||||
modal.dataset.confirmed = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
// Mostrar modal
|
||||
const bsModal = new bootstrap.Modal(modal);
|
||||
bsModal.show();
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
@@ -37,7 +37,7 @@ final class ComponentGroupRegistry
|
||||
'label' => __('Contenido Principal', 'roi-theme'),
|
||||
'icon' => 'bi-file-richtext',
|
||||
'description' => __('Secciones principales de páginas y posts', 'roi-theme'),
|
||||
'components' => ['hero', 'featured-image', 'table-of-contents', 'related-post']
|
||||
'components' => ['hero', 'featured-image', 'table-of-contents', 'related-post', 'archive-header', 'post-grid']
|
||||
],
|
||||
'ctas-conversion' => [
|
||||
'label' => __('CTAs & Conversión', 'roi-theme'),
|
||||
|
||||
@@ -60,6 +60,12 @@ $group = $groupId && isset($groups[$groupId]) ? $groups[$groupId] : null;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Componentes con sistema de guardado propio (CRUD de entidades)
|
||||
$componentsWithOwnSaveSystem = ['custom-css-manager'];
|
||||
|
||||
if (!in_array($activeComponent, $componentsWithOwnSaveSystem, true)):
|
||||
?>
|
||||
<!-- Botones Globales Save/Cancel -->
|
||||
<div class="d-flex justify-content-end gap-2 p-3 rounded border mt-4" style="background-color: #f8f9fa; border-color: #e9ecef !important;">
|
||||
<button type="button" class="btn btn-outline-secondary" id="cancelChanges">
|
||||
@@ -71,4 +77,5 @@ $group = $groupId && isset($groups[$groupId]) ? $groups[$groupId] : null;
|
||||
<?php echo esc_html__('Guardar Cambios', 'roi-theme'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\PostGrid\Infrastructure\FieldMapping;
|
||||
|
||||
use ROITheme\Admin\Shared\Domain\Contracts\FieldMapperInterface;
|
||||
|
||||
/**
|
||||
* Field Mapper para Post Grid
|
||||
*
|
||||
* RESPONSABILIDAD:
|
||||
* - Mapear field IDs del formulario a atributos de BD
|
||||
* - Solo conoce sus propios campos (modularidad)
|
||||
*/
|
||||
final class PostGridFieldMapper implements FieldMapperInterface
|
||||
{
|
||||
public function getComponentName(): string
|
||||
{
|
||||
return 'post-grid';
|
||||
}
|
||||
|
||||
public function getFieldMapping(): array
|
||||
{
|
||||
return [
|
||||
// Visibility
|
||||
'postGridEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'postGridShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'postGridShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'postGridVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'postGridVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'postGridVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'postGridVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'postGridVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions)
|
||||
'postGridExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'postGridExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'postGridExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'postGridExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'postGridShowThumbnail' => ['group' => 'content', 'attribute' => 'show_thumbnail'],
|
||||
'postGridShowExcerpt' => ['group' => 'content', 'attribute' => 'show_excerpt'],
|
||||
'postGridShowMeta' => ['group' => 'content', 'attribute' => 'show_meta'],
|
||||
'postGridShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
|
||||
'postGridExcerptLength' => ['group' => 'content', 'attribute' => 'excerpt_length'],
|
||||
'postGridReadMoreText' => ['group' => 'content', 'attribute' => 'read_more_text'],
|
||||
'postGridNoPostsMessage' => ['group' => 'content', 'attribute' => 'no_posts_message'],
|
||||
|
||||
// Layout
|
||||
'postGridColumnsDesktop' => ['group' => 'layout', 'attribute' => 'columns_desktop'],
|
||||
'postGridColumnsTablet' => ['group' => 'layout', 'attribute' => 'columns_tablet'],
|
||||
'postGridColumnsMobile' => ['group' => 'layout', 'attribute' => 'columns_mobile'],
|
||||
'postGridImagePosition' => ['group' => 'layout', 'attribute' => 'image_position'],
|
||||
|
||||
// Media
|
||||
'postGridFallbackImage' => ['group' => 'media', 'attribute' => 'fallback_image'],
|
||||
'postGridFallbackImageAlt' => ['group' => 'media', 'attribute' => 'fallback_image_alt'],
|
||||
|
||||
// Typography
|
||||
'postGridHeadingLevel' => ['group' => 'typography', 'attribute' => 'heading_level'],
|
||||
'postGridCardTitleSize' => ['group' => 'typography', 'attribute' => 'card_title_size'],
|
||||
'postGridCardTitleWeight' => ['group' => 'typography', 'attribute' => 'card_title_weight'],
|
||||
'postGridExcerptSize' => ['group' => 'typography', 'attribute' => 'excerpt_size'],
|
||||
'postGridMetaSize' => ['group' => 'typography', 'attribute' => 'meta_size'],
|
||||
|
||||
// Colors
|
||||
'postGridCardBgColor' => ['group' => 'colors', 'attribute' => 'card_bg_color'],
|
||||
'postGridCardTitleColor' => ['group' => 'colors', 'attribute' => 'card_title_color'],
|
||||
'postGridCardHoverBgColor' => ['group' => 'colors', 'attribute' => 'card_hover_bg_color'],
|
||||
'postGridCardBorderColor' => ['group' => 'colors', 'attribute' => 'card_border_color'],
|
||||
'postGridCardHoverBorderColor' => ['group' => 'colors', 'attribute' => 'card_hover_border_color'],
|
||||
'postGridExcerptColor' => ['group' => 'colors', 'attribute' => 'excerpt_color'],
|
||||
'postGridMetaColor' => ['group' => 'colors', 'attribute' => 'meta_color'],
|
||||
'postGridCategoryBgColor' => ['group' => 'colors', 'attribute' => 'category_bg_color'],
|
||||
'postGridCategoryTextColor' => ['group' => 'colors', 'attribute' => 'category_text_color'],
|
||||
'postGridPaginationColor' => ['group' => 'colors', 'attribute' => 'pagination_color'],
|
||||
'postGridPaginationActiveBg' => ['group' => 'colors', 'attribute' => 'pagination_active_bg'],
|
||||
'postGridPaginationActiveColor' => ['group' => 'colors', 'attribute' => 'pagination_active_color'],
|
||||
|
||||
// Spacing
|
||||
'postGridGapHorizontal' => ['group' => 'spacing', 'attribute' => 'gap_horizontal'],
|
||||
'postGridGapVertical' => ['group' => 'spacing', 'attribute' => 'gap_vertical'],
|
||||
'postGridCardPadding' => ['group' => 'spacing', 'attribute' => 'card_padding'],
|
||||
'postGridSectionMarginTop' => ['group' => 'spacing', 'attribute' => 'section_margin_top'],
|
||||
'postGridSectionMarginBottom' => ['group' => 'spacing', 'attribute' => 'section_margin_bottom'],
|
||||
|
||||
// Visual Effects
|
||||
'postGridCardBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'card_border_radius'],
|
||||
'postGridCardShadow' => ['group' => 'visual_effects', 'attribute' => 'card_shadow'],
|
||||
'postGridCardHoverShadow' => ['group' => 'visual_effects', 'attribute' => 'card_hover_shadow'],
|
||||
'postGridCardTransition' => ['group' => 'visual_effects', 'attribute' => 'card_transition'],
|
||||
'postGridImageBorderRadius' => ['group' => 'visual_effects', 'attribute' => 'image_border_radius'],
|
||||
];
|
||||
}
|
||||
}
|
||||
781
Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php
Normal file
781
Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php
Normal file
@@ -0,0 +1,781 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\PostGrid\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* PostGridFormBuilder - Genera formulario admin para Post Grid
|
||||
*
|
||||
* Sigue el mismo patron visual que RelatedPostFormBuilder:
|
||||
* - Header con gradiente navy
|
||||
* - Layout de 2 columnas
|
||||
* - Cards con borde izquierdo
|
||||
* - Inputs compactos (form-control-sm)
|
||||
*
|
||||
* @package ROITheme\Admin\PostGrid\Infrastructure\Ui
|
||||
*/
|
||||
final class PostGridFormBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private AdminDashboardRenderer $renderer
|
||||
) {}
|
||||
|
||||
public function buildForm(string $componentId): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= $this->buildHeader();
|
||||
|
||||
$html .= '<div class="row g-3">';
|
||||
|
||||
// Columna izquierda
|
||||
$html .= '<div class="col-lg-6">';
|
||||
$html .= $this->buildShortcodeGuide();
|
||||
$html .= $this->buildVisibilityGroup($componentId);
|
||||
$html .= $this->buildContentGroup($componentId);
|
||||
$html .= $this->buildMediaGroup($componentId);
|
||||
$html .= '</div>';
|
||||
|
||||
// Columna derecha
|
||||
$html .= '<div class="col-lg-6">';
|
||||
$html .= $this->buildLayoutGroup($componentId);
|
||||
$html .= $this->buildTypographyGroup($componentId);
|
||||
$html .= $this->buildColorsGroup($componentId);
|
||||
$html .= $this->buildSpacingGroup($componentId);
|
||||
$html .= $this->buildEffectsGroup($componentId);
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildHeader(): string
|
||||
{
|
||||
$html = '<div class="rounded p-4 mb-4 shadow text-white" ';
|
||||
$html .= 'style="background: linear-gradient(135deg, #0E2337 0%, #1e3a5f 100%); border-left: 4px solid #FF8600;">';
|
||||
$html .= ' <div class="d-flex align-items-center justify-content-between flex-wrap gap-3">';
|
||||
$html .= ' <div>';
|
||||
$html .= ' <h3 class="h4 mb-1 fw-bold">';
|
||||
$html .= ' <i class="bi bi-grid-3x3-gap me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Configuracion de Post Grid';
|
||||
$html .= ' </h3>';
|
||||
$html .= ' <p class="mb-0 small" style="opacity: 0.85;">';
|
||||
$html .= ' Grid de posts para listados, archivos y resultados de busqueda';
|
||||
$html .= ' </p>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <button type="button" class="btn btn-sm btn-outline-light btn-reset-defaults" data-component="post-grid">';
|
||||
$html .= ' <i class="bi bi-arrow-counterclockwise me-1"></i>';
|
||||
$html .= ' Restaurar valores por defecto';
|
||||
$html .= ' </button>';
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildVisibilityGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-toggle-on me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Visibilidad';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$enabled = $this->renderer->getFieldValue($componentId, 'visibility', 'is_enabled', true);
|
||||
$html .= $this->buildSwitch('postGridEnabled', 'Activar componente', 'bi-power', $enabled);
|
||||
|
||||
$showOnDesktop = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_desktop', true);
|
||||
$html .= $this->buildSwitch('postGridShowOnDesktop', 'Mostrar en escritorio', 'bi-display', $showOnDesktop);
|
||||
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
|
||||
$html .= $this->buildSwitch('postGridShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
$html .= ' <hr class="my-3">';
|
||||
$html .= ' <p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-eye me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en tipos de pagina';
|
||||
$html .= ' </p>';
|
||||
|
||||
$showOnHome = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_home', true);
|
||||
$showOnPosts = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_posts', false);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', false);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', true);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', true);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('postGridVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Reglas de exclusion
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'postGrid');
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildContentGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-card-text me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Contenido';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Switches de contenido
|
||||
$showThumbnail = $this->renderer->getFieldValue($componentId, 'content', 'show_thumbnail', true);
|
||||
$html .= $this->buildSwitch('postGridShowThumbnail', 'Mostrar imagen destacada', 'bi-image', $showThumbnail);
|
||||
|
||||
$showExcerpt = $this->renderer->getFieldValue($componentId, 'content', 'show_excerpt', true);
|
||||
$html .= $this->buildSwitch('postGridShowExcerpt', 'Mostrar extracto', 'bi-text-paragraph', $showExcerpt);
|
||||
|
||||
$showMeta = $this->renderer->getFieldValue($componentId, 'content', 'show_meta', true);
|
||||
$html .= $this->buildSwitch('postGridShowMeta', 'Mostrar metadatos', 'bi-info-circle', $showMeta);
|
||||
|
||||
$showCategories = $this->renderer->getFieldValue($componentId, 'content', 'show_categories', true);
|
||||
$html .= $this->buildSwitch('postGridShowCategories', 'Mostrar categorias', 'bi-folder', $showCategories);
|
||||
|
||||
$html .= ' <hr class="my-3">';
|
||||
|
||||
// Excerpt length
|
||||
$excerptLength = $this->renderer->getFieldValue($componentId, 'content', 'excerpt_length', '20');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridExcerptLength" class="form-label small mb-1 fw-semibold">Longitud del extracto</label>';
|
||||
$html .= ' <select id="postGridExcerptLength" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="10"' . ($excerptLength === '10' ? ' selected' : '') . '>10 palabras</option>';
|
||||
$html .= ' <option value="15"' . ($excerptLength === '15' ? ' selected' : '') . '>15 palabras</option>';
|
||||
$html .= ' <option value="20"' . ($excerptLength === '20' ? ' selected' : '') . '>20 palabras</option>';
|
||||
$html .= ' <option value="25"' . ($excerptLength === '25' ? ' selected' : '') . '>25 palabras</option>';
|
||||
$html .= ' <option value="30"' . ($excerptLength === '30' ? ' selected' : '') . '>30 palabras</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Read more text
|
||||
$readMoreText = $this->renderer->getFieldValue($componentId, 'content', 'read_more_text', 'Leer mas');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridReadMoreText" class="form-label small mb-1 fw-semibold">Texto de leer mas</label>';
|
||||
$html .= ' <input type="text" id="postGridReadMoreText" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($readMoreText) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
// No posts message
|
||||
$noPostsMessage = $this->renderer->getFieldValue($componentId, 'content', 'no_posts_message', 'No se encontraron publicaciones');
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <label for="postGridNoPostsMessage" class="form-label small mb-1 fw-semibold">Mensaje sin posts</label>';
|
||||
$html .= ' <input type="text" id="postGridNoPostsMessage" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($noPostsMessage) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildLayoutGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-grid me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Disposicion';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Columns desktop
|
||||
$colsDesktop = $this->renderer->getFieldValue($componentId, 'layout', 'columns_desktop', '3');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridColumnsDesktop" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-display me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Columnas escritorio';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridColumnsDesktop" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="2"' . ($colsDesktop === '2' ? ' selected' : '') . '>2 columnas</option>';
|
||||
$html .= ' <option value="3"' . ($colsDesktop === '3' ? ' selected' : '') . '>3 columnas</option>';
|
||||
$html .= ' <option value="4"' . ($colsDesktop === '4' ? ' selected' : '') . '>4 columnas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Columns tablet
|
||||
$colsTablet = $this->renderer->getFieldValue($componentId, 'layout', 'columns_tablet', '2');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridColumnsTablet" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-tablet me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Columnas tablet';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridColumnsTablet" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="1"' . ($colsTablet === '1' ? ' selected' : '') . '>1 columna</option>';
|
||||
$html .= ' <option value="2"' . ($colsTablet === '2' ? ' selected' : '') . '>2 columnas</option>';
|
||||
$html .= ' <option value="3"' . ($colsTablet === '3' ? ' selected' : '') . '>3 columnas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Columns mobile
|
||||
$colsMobile = $this->renderer->getFieldValue($componentId, 'layout', 'columns_mobile', '1');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridColumnsMobile" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-phone me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Columnas movil';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridColumnsMobile" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="1"' . ($colsMobile === '1' ? ' selected' : '') . '>1 columna</option>';
|
||||
$html .= ' <option value="2"' . ($colsMobile === '2' ? ' selected' : '') . '>2 columnas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Image position
|
||||
$imagePosition = $this->renderer->getFieldValue($componentId, 'layout', 'image_position', 'top');
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <label for="postGridImagePosition" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-aspect-ratio me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Posicion de imagen';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridImagePosition" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="top"' . ($imagePosition === 'top' ? ' selected' : '') . '>Arriba</option>';
|
||||
$html .= ' <option value="left"' . ($imagePosition === 'left' ? ' selected' : '') . '>Izquierda</option>';
|
||||
$html .= ' <option value="none"' . ($imagePosition === 'none' ? ' selected' : '') . '>Sin imagen</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildMediaGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-image me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Medios';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Fallback image
|
||||
$fallbackImage = $this->renderer->getFieldValue($componentId, 'media', 'fallback_image', '');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridFallbackImage" class="form-label small mb-1 fw-semibold">URL imagen por defecto</label>';
|
||||
$html .= ' <input type="url" id="postGridFallbackImage" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_url($fallbackImage) . '" placeholder="https://...">';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Fallback image alt
|
||||
$fallbackImageAlt = $this->renderer->getFieldValue($componentId, 'media', 'fallback_image_alt', 'Imagen por defecto');
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <label for="postGridFallbackImageAlt" class="form-label small mb-1 fw-semibold">Texto alternativo</label>';
|
||||
$html .= ' <input type="text" id="postGridFallbackImageAlt" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($fallbackImageAlt) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildTypographyGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-fonts me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Tipografia';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Heading level
|
||||
$headingLevel = $this->renderer->getFieldValue($componentId, 'typography', 'heading_level', 'h3');
|
||||
$html .= ' <div class="mb-3">';
|
||||
$html .= ' <label for="postGridHeadingLevel" class="form-label small mb-1 fw-semibold">Nivel de encabezado</label>';
|
||||
$html .= ' <select id="postGridHeadingLevel" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="h2"' . ($headingLevel === 'h2' ? ' selected' : '') . '>H2</option>';
|
||||
$html .= ' <option value="h3"' . ($headingLevel === 'h3' ? ' selected' : '') . '>H3</option>';
|
||||
$html .= ' <option value="h4"' . ($headingLevel === 'h4' ? ' selected' : '') . '>H4</option>';
|
||||
$html .= ' <option value="h5"' . ($headingLevel === 'h5' ? ' selected' : '') . '>H5</option>';
|
||||
$html .= ' <option value="h6"' . ($headingLevel === 'h6' ? ' selected' : '') . '>H6</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardTitleSize = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_size', '1.1rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridCardTitleSize" class="form-label small mb-1 fw-semibold">Tamano titulo</label>';
|
||||
$html .= ' <input type="text" id="postGridCardTitleSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardTitleSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$cardTitleWeight = $this->renderer->getFieldValue($componentId, 'typography', 'card_title_weight', '600');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridCardTitleWeight" class="form-label small mb-1 fw-semibold">Peso titulo</label>';
|
||||
$html .= ' <input type="text" id="postGridCardTitleWeight" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardTitleWeight) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-0">';
|
||||
|
||||
$excerptSize = $this->renderer->getFieldValue($componentId, 'typography', 'excerpt_size', '0.9rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridExcerptSize" class="form-label small mb-1 fw-semibold">Tamano extracto</label>';
|
||||
$html .= ' <input type="text" id="postGridExcerptSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($excerptSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$metaSize = $this->renderer->getFieldValue($componentId, 'typography', 'meta_size', '0.8rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridMetaSize" class="form-label small mb-1 fw-semibold">Tamano metadatos</label>';
|
||||
$html .= ' <input type="text" id="postGridMetaSize" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($metaSize) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildColorsGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-palette me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Colores';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Cards
|
||||
$html .= ' <p class="small fw-semibold mb-2">Cards</p>';
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_bg_color', '#ffffff');
|
||||
$html .= $this->buildColorPicker('postGridCardBgColor', 'Fondo', $cardBgColor);
|
||||
|
||||
$cardTitleColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_title_color', '#0E2337');
|
||||
$html .= $this->buildColorPicker('postGridCardTitleColor', 'Titulo', $cardTitleColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardHoverBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_bg_color', '#f9fafb');
|
||||
$html .= $this->buildColorPicker('postGridCardHoverBgColor', 'Fondo hover', $cardHoverBgColor);
|
||||
|
||||
$cardBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_border_color', '#e5e7eb');
|
||||
$html .= $this->buildColorPicker('postGridCardBorderColor', 'Borde', $cardBorderColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardHoverBorderColor = $this->renderer->getFieldValue($componentId, 'colors', 'card_hover_border_color', '#FF8600');
|
||||
$html .= $this->buildColorPicker('postGridCardHoverBorderColor', 'Borde hover', $cardHoverBorderColor);
|
||||
|
||||
$excerptColor = $this->renderer->getFieldValue($componentId, 'colors', 'excerpt_color', '#6b7280');
|
||||
$html .= $this->buildColorPicker('postGridExcerptColor', 'Extracto', $excerptColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$metaColor = $this->renderer->getFieldValue($componentId, 'colors', 'meta_color', '#9ca3af');
|
||||
$html .= $this->buildColorPicker('postGridMetaColor', 'Metadatos', $metaColor);
|
||||
|
||||
$categoryBgColor = $this->renderer->getFieldValue($componentId, 'colors', 'category_bg_color', '#FFF5EB');
|
||||
$html .= $this->buildColorPicker('postGridCategoryBgColor', 'Fondo cat.', $categoryBgColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$categoryTextColor = $this->renderer->getFieldValue($componentId, 'colors', 'category_text_color', '#FF8600');
|
||||
$html .= $this->buildColorPicker('postGridCategoryTextColor', 'Texto cat.', $categoryTextColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
// Paginacion
|
||||
$html .= ' <p class="small fw-semibold mb-2">Paginacion</p>';
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$paginationColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_color', '#0E2337');
|
||||
$html .= $this->buildColorPicker('postGridPaginationColor', 'Color', $paginationColor);
|
||||
|
||||
$paginationActiveBg = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_bg', '#FF8600');
|
||||
$html .= $this->buildColorPicker('postGridPaginationActiveBg', 'Activo fondo', $paginationActiveBg);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-0">';
|
||||
|
||||
$paginationActiveColor = $this->renderer->getFieldValue($componentId, 'colors', 'pagination_active_color', '#ffffff');
|
||||
$html .= $this->buildColorPicker('postGridPaginationActiveColor', 'Activo texto', $paginationActiveColor);
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildSpacingGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-arrows-move me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Espaciado';
|
||||
$html .= ' </h5>';
|
||||
|
||||
// Separación entre cards
|
||||
$html .= ' <p class="small text-muted mb-2">Separacion entre cards:</p>';
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
// Gap horizontal (entre columnas)
|
||||
$gapHorizontal = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_horizontal', '24px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridGapHorizontal" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-arrows-expand me-1" style="color: #FF8600;"></i>Horizontal';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridGapHorizontal" class="form-select form-select-sm">';
|
||||
$gapOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px', '40px', '48px'];
|
||||
foreach ($gapOptions as $opt) {
|
||||
$selected = ($gapHorizontal === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Gap vertical (entre filas)
|
||||
$gapVertical = $this->renderer->getFieldValue($componentId, 'spacing', 'gap_vertical', '24px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridGapVertical" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-arrows-collapse me-1" style="color: #FF8600;"></i>Vertical';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridGapVertical" class="form-select form-select-sm">';
|
||||
foreach ($gapOptions as $opt) {
|
||||
$selected = ($gapVertical === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
// Padding interno de cada card
|
||||
$html .= ' <p class="small text-muted mb-2">Padding interno de card:</p>';
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardPadding = $this->renderer->getFieldValue($componentId, 'spacing', 'card_padding', '20px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridCardPadding" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-box me-1" style="color: #FF8600;"></i>Padding';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridCardPadding" class="form-select form-select-sm">';
|
||||
$paddingOptions = ['0px', '8px', '12px', '16px', '20px', '24px', '32px'];
|
||||
foreach ($paddingOptions as $opt) {
|
||||
$selected = ($cardPadding === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-6"></div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
// Margenes de la seccion
|
||||
$html .= ' <p class="small text-muted mb-2">Margenes de la seccion:</p>';
|
||||
$html .= ' <div class="row g-2 mb-0">';
|
||||
|
||||
$sectionMarginTop = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_top', '0px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridSectionMarginTop" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-arrow-up me-1" style="color: #FF8600;"></i>Arriba';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridSectionMarginTop" class="form-select form-select-sm">';
|
||||
$marginOptions = ['0px', '8px', '16px', '24px', '32px', '48px', '64px'];
|
||||
foreach ($marginOptions as $opt) {
|
||||
$selected = ($sectionMarginTop === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$sectionMarginBottom = $this->renderer->getFieldValue($componentId, 'spacing', 'section_margin_bottom', '32px');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridSectionMarginBottom" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-arrow-down me-1" style="color: #FF8600;"></i>Abajo';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="postGridSectionMarginBottom" class="form-select form-select-sm">';
|
||||
foreach ($marginOptions as $opt) {
|
||||
$selected = ($sectionMarginBottom === $opt) ? ' selected' : '';
|
||||
$html .= ' <option value="' . $opt . '"' . $selected . '>' . $opt . '</option>';
|
||||
}
|
||||
$html .= ' </select>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildEffectsGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-magic me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Efectos Visuales';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$html .= ' <div class="row g-2 mb-3">';
|
||||
|
||||
$cardBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_border_radius', '0.5rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridCardBorderRadius" class="form-label small mb-1 fw-semibold">Radio borde</label>';
|
||||
$html .= ' <input type="text" id="postGridCardBorderRadius" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardBorderRadius) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$imageBorderRadius = $this->renderer->getFieldValue($componentId, 'visual_effects', 'image_border_radius', '0.375rem');
|
||||
$html .= ' <div class="col-6">';
|
||||
$html .= ' <label for="postGridImageBorderRadius" class="form-label small mb-1 fw-semibold">Radio imagen</label>';
|
||||
$html .= ' <input type="text" id="postGridImageBorderRadius" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($imageBorderRadius) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="mb-3">';
|
||||
$cardTransition = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_transition', 'all 0.3s ease');
|
||||
$html .= ' <label for="postGridCardTransition" class="form-label small mb-1 fw-semibold">Transicion</label>';
|
||||
$html .= ' <input type="text" id="postGridCardTransition" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardTransition) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="mb-3">';
|
||||
$cardShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_shadow', '0 1px 3px rgba(0,0,0,0.1)');
|
||||
$html .= ' <label for="postGridCardShadow" class="form-label small mb-1 fw-semibold">Sombra normal</label>';
|
||||
$html .= ' <input type="text" id="postGridCardShadow" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardShadow) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' <div class="mb-0">';
|
||||
$cardHoverShadow = $this->renderer->getFieldValue($componentId, 'visual_effects', 'card_hover_shadow', '0 4px 12px rgba(0,0,0,0.15)');
|
||||
$html .= ' <label for="postGridCardHoverShadow" class="form-label small mb-1 fw-semibold">Sombra hover</label>';
|
||||
$html .= ' <input type="text" id="postGridCardHoverShadow" class="form-control form-control-sm" ';
|
||||
$html .= ' value="' . esc_attr($cardHoverShadow) . '">';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildSwitch(string $id, string $label, string $icon, mixed $checked): string
|
||||
{
|
||||
$checked = $checked === true || $checked === '1' || $checked === 1;
|
||||
|
||||
$html = ' <div class="mb-2">';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= sprintf(
|
||||
' <input class="form-check-input" type="checkbox" id="%s" %s>',
|
||||
esc_attr($id),
|
||||
$checked ? 'checked' : ''
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <label class="form-check-label small" for="%s">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
|
||||
$html .= sprintf(' <strong>%s</strong>', esc_html($label));
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildColorPicker(string $id, string $label, string $value): string
|
||||
{
|
||||
$html = ' <div class="col-6">';
|
||||
$html .= sprintf(
|
||||
' <label class="form-label small fw-semibold">%s</label>',
|
||||
esc_html($label)
|
||||
);
|
||||
$html .= ' <div class="input-group input-group-sm">';
|
||||
$html .= sprintf(
|
||||
' <input type="color" class="form-control form-control-color" id="%s" value="%s">',
|
||||
esc_attr($id),
|
||||
esc_attr($value)
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <span class="input-group-text" id="%sValue">%s</span>',
|
||||
esc_attr($id),
|
||||
esc_html(strtoupper($value))
|
||||
);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, mixed $checked): string
|
||||
{
|
||||
$checked = $checked === true || $checked === '1' || $checked === 1;
|
||||
|
||||
$html = ' <div class="form-check form-check-checkbox mb-2">';
|
||||
$html .= sprintf(
|
||||
' <input class="form-check-input" type="checkbox" id="%s" %s>',
|
||||
esc_attr($id),
|
||||
$checked ? 'checked' : ''
|
||||
);
|
||||
$html .= sprintf(
|
||||
' <label class="form-check-label small" for="%s">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= sprintf(' <i class="bi %s me-1" style="color: #FF8600;"></i>', esc_attr($icon));
|
||||
$html .= sprintf(' %s', esc_html($label));
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildShortcodeGuide(): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #FF8600;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-code-square me-2" style="color: #FF8600;"></i>';
|
||||
$html .= ' Shortcode [roi_post_grid]';
|
||||
$html .= ' </h5>';
|
||||
|
||||
$html .= ' <p class="small text-muted mb-3">';
|
||||
$html .= ' Usa este shortcode para insertar grids de posts en cualquier pagina o entrada. ';
|
||||
$html .= ' Los estilos se heredan de la configuracion de este componente.';
|
||||
$html .= ' </p>';
|
||||
|
||||
// Uso basico
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-1-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Uso basico (9 posts, 3 columnas)';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Por categoria
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-2-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Filtrar por categoria';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid category="precios-unitarios"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Personalizar cantidad y columnas
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-3-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' 6 posts en 2 columnas';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="6" columns="2"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Con paginacion
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-4-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Con paginacion';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid posts_per_page="12" show_pagination="true"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Filtrar por tag
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-5-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Filtrar por etiqueta';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.8rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid tag="tutorial"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Ejemplo completo
|
||||
$html .= ' <p class="small fw-semibold mb-1">';
|
||||
$html .= ' <i class="bi bi-6-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Ejemplo completo';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="bg-dark text-light rounded p-2 mb-3" style="font-family: monospace; font-size: 0.75rem;">';
|
||||
$html .= ' <code class="text-warning">[roi_post_grid category="cursos" posts_per_page="6" columns="3" show_meta="false" show_categories="true"]</code>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Tabla de atributos
|
||||
$html .= ' <hr class="my-3">';
|
||||
$html .= ' <p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-list-check me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Atributos disponibles';
|
||||
$html .= ' </p>';
|
||||
$html .= ' <div class="table-responsive">';
|
||||
$html .= ' <table class="table table-sm table-bordered small mb-0">';
|
||||
$html .= ' <thead class="table-light">';
|
||||
$html .= ' <tr><th>Atributo</th><th>Default</th><th>Descripcion</th></tr>';
|
||||
$html .= ' </thead>';
|
||||
$html .= ' <tbody>';
|
||||
$html .= ' <tr><td><code>posts_per_page</code></td><td>9</td><td>Cantidad de posts</td></tr>';
|
||||
$html .= ' <tr><td><code>columns</code></td><td>3</td><td>Columnas (1-4)</td></tr>';
|
||||
$html .= ' <tr><td><code>category</code></td><td>-</td><td>Slug de categoria</td></tr>';
|
||||
$html .= ' <tr><td><code>exclude_category</code></td><td>-</td><td>Excluir categoria</td></tr>';
|
||||
$html .= ' <tr><td><code>tag</code></td><td>-</td><td>Slug de etiqueta</td></tr>';
|
||||
$html .= ' <tr><td><code>author</code></td><td>-</td><td>ID o username</td></tr>';
|
||||
$html .= ' <tr><td><code>orderby</code></td><td>date</td><td>date, title, rand</td></tr>';
|
||||
$html .= ' <tr><td><code>order</code></td><td>DESC</td><td>DESC o ASC</td></tr>';
|
||||
$html .= ' <tr><td><code>show_pagination</code></td><td>false</td><td>Mostrar paginacion</td></tr>';
|
||||
$html .= ' <tr><td><code>show_thumbnail</code></td><td>true</td><td>Mostrar imagen</td></tr>';
|
||||
$html .= ' <tr><td><code>show_excerpt</code></td><td>true</td><td>Mostrar extracto</td></tr>';
|
||||
$html .= ' <tr><td><code>show_meta</code></td><td>true</td><td>Fecha y autor</td></tr>';
|
||||
$html .= ' <tr><td><code>show_categories</code></td><td>true</td><td>Badges categoria</td></tr>';
|
||||
$html .= ' <tr><td><code>excerpt_length</code></td><td>20</td><td>Palabras extracto</td></tr>';
|
||||
$html .= ' <tr><td><code>exclude_posts</code></td><td>-</td><td>IDs separados por coma</td></tr>';
|
||||
$html .= ' <tr><td><code>offset</code></td><td>0</td><td>Saltar N posts</td></tr>';
|
||||
$html .= ' <tr><td><code>id</code></td><td>-</td><td>ID unico (multiples grids)</td></tr>';
|
||||
$html .= ' <tr><td><code>class</code></td><td>-</td><td>Clase CSS adicional</td></tr>';
|
||||
$html .= ' </tbody>';
|
||||
$html .= ' </table>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ final class FieldMapperProvider
|
||||
'Footer',
|
||||
'ThemeSettings',
|
||||
'AdsensePlacement',
|
||||
'ArchiveHeader',
|
||||
'PostGrid',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
BASE STYLES - Todas las tablas genéricas
|
||||
======================================== */
|
||||
|
||||
.post-content table:not(.analisis table) {
|
||||
.post-content table:not(.analisis table):not(.desglose table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem auto;
|
||||
@@ -23,9 +23,9 @@
|
||||
}
|
||||
|
||||
/* Header styles - VERY OBVIOUS */
|
||||
.post-content table:not(.analisis table) thead tr:first-child th,
|
||||
.post-content table:not(.analisis table) tbody tr:first-child td,
|
||||
.post-content table:not(.analisis table) tr:first-child td {
|
||||
.post-content table:not(.analisis table):not(.desglose table) thead tr:first-child th,
|
||||
.post-content table:not(.analisis table):not(.desglose table) tbody tr:first-child td,
|
||||
.post-content table:not(.analisis table):not(.desglose table) tr:first-child td {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
padding: 1.25rem 1rem;
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
/* Body cells */
|
||||
.post-content table:not(.analisis table) tbody tr:not(:first-child) td {
|
||||
.post-content table:not(.analisis table):not(.desglose table) tbody tr:not(:first-child) td {
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--color-neutral-100);
|
||||
text-align: left;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
const CONFIG = {
|
||||
timeout: 5000, // Timeout de fallback en milisegundos
|
||||
loadedClass: 'adsense-loaded',
|
||||
debug: false // Cambiar a true para logs en consola
|
||||
debug: true // TEMPORAL: Habilitado para diagnóstico
|
||||
};
|
||||
|
||||
// Estado
|
||||
@@ -54,8 +54,10 @@
|
||||
// Remover event listeners para prevenir múltiples triggers
|
||||
removeEventListeners();
|
||||
|
||||
// Cargar etiquetas de script de AdSense
|
||||
loadAdSenseScripts();
|
||||
// Cargar etiquetas de script de AdSense y esperar a que cargue
|
||||
// IMPORTANTE: Debe esperar a que adsbygoogle.js cargue antes de ejecutar push
|
||||
loadAdSenseScripts(function() {
|
||||
debugLog('Biblioteca AdSense cargada, ejecutando push scripts...');
|
||||
|
||||
// Ejecutar scripts de push de AdSense
|
||||
executeAdSensePushScripts();
|
||||
@@ -64,21 +66,30 @@
|
||||
document.body.classList.add(CONFIG.loadedClass);
|
||||
|
||||
debugLog('Carga de AdSense completada');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Encuentra y carga todas las etiquetas de script de AdSense retrasadas
|
||||
* @param {Function} callback - Función a ejecutar cuando la biblioteca cargue
|
||||
*/
|
||||
function loadAdSenseScripts() {
|
||||
function loadAdSenseScripts(callback) {
|
||||
const delayedScripts = document.querySelectorAll('script[data-adsense-script]');
|
||||
|
||||
if (delayedScripts.length === 0) {
|
||||
debugLog('No se encontraron scripts retrasados de AdSense');
|
||||
// Ejecutar callback de todas formas (puede haber ads sin script principal)
|
||||
if (typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Se encontraron ' + delayedScripts.length + ' script(s) retrasado(s) de AdSense');
|
||||
|
||||
var scriptsLoaded = 0;
|
||||
var totalScripts = delayedScripts.length;
|
||||
|
||||
delayedScripts.forEach(function(oldScript) {
|
||||
const newScript = document.createElement('script');
|
||||
|
||||
@@ -95,6 +106,23 @@
|
||||
newScript.crossorigin = oldScript.getAttribute('crossorigin');
|
||||
}
|
||||
|
||||
// Esperar a que cargue antes de ejecutar callback
|
||||
newScript.onload = function() {
|
||||
scriptsLoaded++;
|
||||
debugLog('Script cargado (' + scriptsLoaded + '/' + totalScripts + '): ' + newScript.src.substring(0, 50) + '...');
|
||||
if (scriptsLoaded === totalScripts && typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
newScript.onerror = function() {
|
||||
scriptsLoaded++;
|
||||
debugLog('Error cargando script: ' + newScript.src);
|
||||
if (scriptsLoaded === totalScripts && typeof callback === 'function') {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
// Reemplazar script viejo con el nuevo
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
});
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Auto-detectar y agregar clases a filas especiales de tablas APU
|
||||
*
|
||||
* Este script detecta automáticamente filas especiales en tablas .desglose y .analisis
|
||||
* y les agrega las clases CSS correspondientes para que se apliquen los estilos correctos.
|
||||
*
|
||||
* Detecta:
|
||||
* - Section headers: Material, Mano de Obra, Herramienta, Equipo
|
||||
* - Subtotal rows: Filas que empiezan con "Suma de"
|
||||
* - Total row: Costo Directo
|
||||
*
|
||||
* @package Apus_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Agrega clases a filas especiales de tablas APU
|
||||
*/
|
||||
function applyApuTableClasses() {
|
||||
// Buscar todas las tablas con clase .desglose o .analisis
|
||||
const tables = document.querySelectorAll('.desglose table, .analisis table');
|
||||
|
||||
if (tables.length === 0) {
|
||||
return; // No hay tablas APU en esta página
|
||||
}
|
||||
|
||||
let classesAdded = 0;
|
||||
|
||||
tables.forEach(function(table) {
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
|
||||
rows.forEach(function(row) {
|
||||
// Evitar procesar filas que ya tienen clase
|
||||
if (row.classList.contains('section-header') ||
|
||||
row.classList.contains('subtotal-row') ||
|
||||
row.classList.contains('total-row')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secondCell = row.querySelector('td:nth-child(2)');
|
||||
if (!secondCell) {
|
||||
return; // Fila sin segunda celda
|
||||
}
|
||||
|
||||
const text = secondCell.textContent.trim();
|
||||
|
||||
// Detectar section headers
|
||||
if (text === 'Material' ||
|
||||
text === 'Mano de Obra' ||
|
||||
text === 'Herramienta' ||
|
||||
text === 'Equipo' ||
|
||||
text === 'MATERIAL' ||
|
||||
text === 'MANO DE OBRA' ||
|
||||
text === 'HERRAMIENTA' ||
|
||||
text === 'EQUIPO') {
|
||||
row.classList.add('section-header');
|
||||
classesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detectar subtotales (cualquier variación de "Suma de")
|
||||
if (text.toLowerCase().startsWith('suma de ') ||
|
||||
text.toLowerCase().startsWith('subtotal ')) {
|
||||
row.classList.add('subtotal-row');
|
||||
classesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Detectar total final
|
||||
if (text === 'Costo Directo' ||
|
||||
text === 'COSTO DIRECTO' ||
|
||||
text === 'Total' ||
|
||||
text === 'TOTAL' ||
|
||||
text === 'Costo directo') {
|
||||
row.classList.add('total-row');
|
||||
classesAdded++;
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Log para debugging (solo en desarrollo)
|
||||
if (classesAdded > 0 && window.console) {
|
||||
console.log('[APU Tables] Clases agregadas automáticamente: ' + classesAdded);
|
||||
}
|
||||
}
|
||||
|
||||
// Ejecutar cuando el DOM esté listo
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', applyApuTableClasses);
|
||||
} else {
|
||||
// DOM ya está listo
|
||||
applyApuTableClasses();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,342 +0,0 @@
|
||||
/**
|
||||
* Header Navigation JavaScript
|
||||
*
|
||||
* This file handles:
|
||||
* - Mobile hamburger menu toggle
|
||||
* - Sticky header behavior
|
||||
* - Smooth scroll to anchors (optional)
|
||||
* - Accessibility features (keyboard navigation, ARIA attributes)
|
||||
* - Body scroll locking when mobile menu is open
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Initialize on DOM ready
|
||||
*/
|
||||
function init() {
|
||||
setupMobileMenu();
|
||||
setupStickyHeader();
|
||||
setupSmoothScroll();
|
||||
setupKeyboardNavigation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile Menu Functionality
|
||||
*/
|
||||
function setupMobileMenu() {
|
||||
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
|
||||
const mobileMenuClose = document.getElementById('mobile-menu-close');
|
||||
|
||||
if (!mobileMenuToggle || !mobileMenu || !mobileMenuOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open mobile menu
|
||||
mobileMenuToggle.addEventListener('click', function() {
|
||||
openMobileMenu();
|
||||
});
|
||||
|
||||
// Close mobile menu via close button
|
||||
if (mobileMenuClose) {
|
||||
mobileMenuClose.addEventListener('click', function() {
|
||||
closeMobileMenu();
|
||||
});
|
||||
}
|
||||
|
||||
// Close mobile menu via overlay click
|
||||
mobileMenuOverlay.addEventListener('click', function() {
|
||||
closeMobileMenu();
|
||||
});
|
||||
|
||||
// Close mobile menu on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && mobileMenu.classList.contains('active')) {
|
||||
closeMobileMenu();
|
||||
mobileMenuToggle.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Close mobile menu when clicking a menu link
|
||||
const mobileMenuLinks = mobileMenu.querySelectorAll('a');
|
||||
mobileMenuLinks.forEach(function(link) {
|
||||
link.addEventListener('click', function() {
|
||||
closeMobileMenu();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle window resize - close mobile menu if switching to desktop
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', function() {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(function() {
|
||||
if (window.innerWidth >= 768 && mobileMenu.classList.contains('active')) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open mobile menu
|
||||
*/
|
||||
function openMobileMenu() {
|
||||
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
|
||||
|
||||
// Add active classes
|
||||
mobileMenu.classList.add('active');
|
||||
mobileMenuOverlay.classList.add('active');
|
||||
document.body.classList.add('mobile-menu-open');
|
||||
|
||||
// Update ARIA attributes
|
||||
mobileMenuToggle.setAttribute('aria-expanded', 'true');
|
||||
mobileMenu.setAttribute('aria-hidden', 'false');
|
||||
mobileMenuOverlay.setAttribute('aria-hidden', 'false');
|
||||
|
||||
// Focus trap - focus first menu item
|
||||
const firstMenuItem = mobileMenu.querySelector('a');
|
||||
if (firstMenuItem) {
|
||||
setTimeout(function() {
|
||||
firstMenuItem.focus();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close mobile menu
|
||||
*/
|
||||
function closeMobileMenu() {
|
||||
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileMenuOverlay = document.getElementById('mobile-menu-overlay');
|
||||
|
||||
// Remove active classes
|
||||
mobileMenu.classList.remove('active');
|
||||
mobileMenuOverlay.classList.remove('active');
|
||||
document.body.classList.remove('mobile-menu-open');
|
||||
|
||||
// Update ARIA attributes
|
||||
mobileMenuToggle.setAttribute('aria-expanded', 'false');
|
||||
mobileMenu.setAttribute('aria-hidden', 'true');
|
||||
mobileMenuOverlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky Header Behavior
|
||||
*/
|
||||
function setupStickyHeader() {
|
||||
const header = document.getElementById('masthead');
|
||||
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastScrollTop = 0;
|
||||
let scrollThreshold = 100;
|
||||
|
||||
window.addEventListener('scroll', function() {
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
// Add/remove scrolled class based on scroll position
|
||||
if (scrollTop > scrollThreshold) {
|
||||
header.classList.add('scrolled');
|
||||
} else {
|
||||
header.classList.remove('scrolled');
|
||||
}
|
||||
|
||||
lastScrollTop = scrollTop;
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth Scroll to Anchors (Optional)
|
||||
*/
|
||||
function setupSmoothScroll() {
|
||||
// Check if user prefers reduced motion
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (prefersReducedMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all anchor links
|
||||
const anchorLinks = document.querySelectorAll('a[href^="#"]');
|
||||
|
||||
anchorLinks.forEach(function(link) {
|
||||
link.addEventListener('click', function(e) {
|
||||
const href = this.getAttribute('href');
|
||||
|
||||
// Skip if href is just "#"
|
||||
if (href === '#') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = document.querySelector(href);
|
||||
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
|
||||
// Get header height for offset
|
||||
const header = document.getElementById('masthead');
|
||||
const headerHeight = header ? header.offsetHeight : 0;
|
||||
const targetPosition = target.getBoundingClientRect().top + window.pageYOffset - headerHeight - 20;
|
||||
|
||||
window.scrollTo({
|
||||
top: targetPosition,
|
||||
behavior: prefersReducedMotion ? 'auto' : 'smooth'
|
||||
});
|
||||
|
||||
// Update URL hash
|
||||
if (history.pushState) {
|
||||
history.pushState(null, null, href);
|
||||
}
|
||||
|
||||
// Focus target element for accessibility
|
||||
target.setAttribute('tabindex', '-1');
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard Navigation for Menus
|
||||
*/
|
||||
function setupKeyboardNavigation() {
|
||||
const menuItems = document.querySelectorAll('.primary-menu > li, .mobile-primary-menu > li');
|
||||
|
||||
menuItems.forEach(function(item) {
|
||||
const link = item.querySelector('a');
|
||||
const submenu = item.querySelector('.sub-menu');
|
||||
|
||||
if (!link || !submenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open submenu on Enter/Space
|
||||
link.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (submenu) {
|
||||
e.preventDefault();
|
||||
toggleSubmenu(item, submenu);
|
||||
}
|
||||
}
|
||||
|
||||
// Close submenu on Escape
|
||||
if (e.key === 'Escape') {
|
||||
closeSubmenu(item, submenu);
|
||||
link.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Close submenu when focus leaves
|
||||
const submenuLinks = submenu.querySelectorAll('a');
|
||||
if (submenuLinks.length > 0) {
|
||||
const lastSubmenuLink = submenuLinks[submenuLinks.length - 1];
|
||||
|
||||
lastSubmenuLink.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Tab' && !e.shiftKey) {
|
||||
closeSubmenu(item, submenu);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle submenu visibility
|
||||
*/
|
||||
function toggleSubmenu(item, submenu) {
|
||||
const isExpanded = item.classList.contains('submenu-open');
|
||||
|
||||
if (isExpanded) {
|
||||
closeSubmenu(item, submenu);
|
||||
} else {
|
||||
openSubmenu(item, submenu);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open submenu
|
||||
*/
|
||||
function openSubmenu(item, submenu) {
|
||||
item.classList.add('submenu-open');
|
||||
submenu.setAttribute('aria-hidden', 'false');
|
||||
|
||||
const firstLink = submenu.querySelector('a');
|
||||
if (firstLink) {
|
||||
firstLink.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close submenu
|
||||
*/
|
||||
function closeSubmenu(item, submenu) {
|
||||
item.classList.remove('submenu-open');
|
||||
submenu.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Trap focus within mobile menu when open
|
||||
*/
|
||||
function setupFocusTrap() {
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
if (!mobileMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (!mobileMenu.classList.contains('active')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
const focusableElements = mobileMenu.querySelectorAll(
|
||||
'a, button, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Shift + Tab
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
}
|
||||
} else {
|
||||
// Tab
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize focus trap
|
||||
*/
|
||||
setupFocusTrap();
|
||||
|
||||
/**
|
||||
* Initialize when DOM is ready
|
||||
*/
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -1,294 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Related Posts Functionality
|
||||
*
|
||||
* Provides configurable related posts functionality with Bootstrap grid support.
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
// Exit if accessed directly
|
||||
if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get related posts based on categories
|
||||
*
|
||||
* @param int $post_id The post ID to get related posts for
|
||||
* @return WP_Query|false Query object with related posts or false if none found
|
||||
*/
|
||||
function roi_get_related_posts($post_id) {
|
||||
// Get post categories
|
||||
$categories = wp_get_post_categories($post_id);
|
||||
|
||||
if (empty($categories)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get number of posts to display (default: 3)
|
||||
$posts_per_page = get_option('roi_related_posts_count', 3);
|
||||
|
||||
// Query arguments
|
||||
$args = array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $posts_per_page,
|
||||
'post__not_in' => array($post_id),
|
||||
'category__in' => $categories,
|
||||
'orderby' => 'rand',
|
||||
'no_found_rows' => true,
|
||||
'update_post_meta_cache' => false,
|
||||
'update_post_term_cache' => false,
|
||||
);
|
||||
|
||||
// Allow filtering of query args
|
||||
$args = apply_filters('roi_related_posts_args', $args, $post_id);
|
||||
|
||||
// Get related posts
|
||||
$related_query = new WP_Query($args);
|
||||
|
||||
return $related_query->have_posts() ? $related_query : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display related posts section
|
||||
*
|
||||
* @param int|null $post_id Optional. Post ID. Default is current post.
|
||||
* @return void
|
||||
*/
|
||||
function roi_display_related_posts($post_id = null) {
|
||||
// Get post ID
|
||||
if (!$post_id) {
|
||||
$post_id = get_the_ID();
|
||||
}
|
||||
|
||||
// Check if related posts are enabled
|
||||
$enabled = get_option('roi_related_posts_enabled', true);
|
||||
if (!$enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get related posts
|
||||
$related_query = roi_get_related_posts($post_id);
|
||||
|
||||
if (!$related_query) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get configuration options
|
||||
$title = get_option('roi_related_posts_title', __('Related Posts', 'roi-theme'));
|
||||
$columns = get_option('roi_related_posts_columns', 3);
|
||||
$show_excerpt = get_option('roi_related_posts_show_excerpt', true);
|
||||
$show_date = get_option('roi_related_posts_show_date', true);
|
||||
$show_category = get_option('roi_related_posts_show_category', true);
|
||||
$excerpt_length = get_option('roi_related_posts_excerpt_length', 20);
|
||||
$background_colors = get_option('roi_related_posts_bg_colors', array(
|
||||
'#1a73e8', // Blue
|
||||
'#e91e63', // Pink
|
||||
'#4caf50', // Green
|
||||
'#ff9800', // Orange
|
||||
'#9c27b0', // Purple
|
||||
'#00bcd4', // Cyan
|
||||
));
|
||||
|
||||
// Calculate Bootstrap column class
|
||||
$col_class = roi_get_column_class($columns);
|
||||
|
||||
// Start output
|
||||
?>
|
||||
<section class="related-posts-section">
|
||||
<div class="related-posts-container">
|
||||
|
||||
<?php if ($title) : ?>
|
||||
<h2 class="related-posts-title"><?php echo esc_html($title); ?></h2>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row g-4">
|
||||
<?php
|
||||
$color_index = 0;
|
||||
while ($related_query->have_posts()) :
|
||||
$related_query->the_post();
|
||||
$has_thumbnail = has_post_thumbnail();
|
||||
|
||||
// Get background color for posts without image
|
||||
$bg_color = $background_colors[$color_index % count($background_colors)];
|
||||
$color_index++;
|
||||
?>
|
||||
|
||||
<div class="<?php echo esc_attr($col_class); ?>">
|
||||
<article class="related-post-card <?php echo $has_thumbnail ? 'has-thumbnail' : 'no-thumbnail'; ?>">
|
||||
|
||||
<a href="<?php the_permalink(); ?>" class="related-post-link">
|
||||
|
||||
<?php if ($has_thumbnail) : ?>
|
||||
<!-- Card with Image -->
|
||||
<div class="related-post-thumbnail">
|
||||
<?php
|
||||
the_post_thumbnail('roi-thumbnail', array(
|
||||
'alt' => the_title_attribute(array('echo' => false)),
|
||||
'loading' => 'lazy',
|
||||
));
|
||||
?>
|
||||
|
||||
<?php if ($show_category) : ?>
|
||||
<?php
|
||||
$categories = get_the_category();
|
||||
if (!empty($categories)) :
|
||||
$category = $categories[0];
|
||||
?>
|
||||
<span class="related-post-category">
|
||||
<?php echo esc_html($category->name); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<!-- Card without Image - Color Background -->
|
||||
<div class="related-post-no-image" style="background-color: <?php echo esc_attr($bg_color); ?>;">
|
||||
<div class="related-post-no-image-content">
|
||||
<h3 class="related-post-no-image-title">
|
||||
<?php the_title(); ?>
|
||||
</h3>
|
||||
|
||||
<?php if ($show_category) : ?>
|
||||
<?php
|
||||
$categories = get_the_category();
|
||||
if (!empty($categories)) :
|
||||
$category = $categories[0];
|
||||
?>
|
||||
<span class="related-post-category no-image">
|
||||
<?php echo esc_html($category->name); ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="related-post-content">
|
||||
|
||||
<?php if ($has_thumbnail) : ?>
|
||||
<h3 class="related-post-title">
|
||||
<?php the_title(); ?>
|
||||
</h3>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($show_excerpt && $excerpt_length > 0) : ?>
|
||||
<div class="related-post-excerpt">
|
||||
<?php echo wp_trim_words(get_the_excerpt(), $excerpt_length, '...'); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($show_date) : ?>
|
||||
<div class="related-post-meta">
|
||||
<time class="related-post-date" datetime="<?php echo esc_attr(get_the_date('c')); ?>">
|
||||
<?php echo esc_html(get_the_date()); ?>
|
||||
</time>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
</a>
|
||||
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<?php endwhile; ?>
|
||||
</div><!-- .row -->
|
||||
|
||||
</div><!-- .related-posts-container -->
|
||||
</section><!-- .related-posts-section -->
|
||||
|
||||
<?php
|
||||
// Reset post data
|
||||
wp_reset_postdata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bootstrap column class based on number of columns
|
||||
*
|
||||
* @param int $columns Number of columns (1-4)
|
||||
* @return string Bootstrap column classes
|
||||
*/
|
||||
function roi_get_column_class($columns) {
|
||||
$columns = absint($columns);
|
||||
|
||||
switch ($columns) {
|
||||
case 1:
|
||||
return 'col-12';
|
||||
case 2:
|
||||
return 'col-12 col-md-6';
|
||||
case 3:
|
||||
return 'col-12 col-sm-6 col-lg-4';
|
||||
case 4:
|
||||
return 'col-12 col-sm-6 col-lg-3';
|
||||
default:
|
||||
return 'col-12 col-sm-6 col-lg-4'; // Default to 3 columns
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook related posts display after post content
|
||||
*/
|
||||
function roi_hook_related_posts() {
|
||||
if (is_single() && !is_attachment()) {
|
||||
roi_display_related_posts();
|
||||
}
|
||||
}
|
||||
add_action('roi_after_post_content', 'roi_hook_related_posts');
|
||||
|
||||
/**
|
||||
* Enqueue related posts styles
|
||||
*/
|
||||
function roi_enqueue_related_posts_styles() {
|
||||
if (is_single() && !is_attachment()) {
|
||||
$enabled = get_option('roi_related_posts_enabled', true);
|
||||
|
||||
if ($enabled) {
|
||||
wp_enqueue_style(
|
||||
'roirelated-posts',
|
||||
get_template_directory_uri() . '/Assets/Css/related-posts.css',
|
||||
array('roibootstrap'),
|
||||
ROI_VERSION,
|
||||
'all'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
add_action('wp_enqueue_scripts', 'roi_enqueue_related_posts_styles');
|
||||
|
||||
/**
|
||||
* Register related posts settings
|
||||
* These can be configured via theme options or customizer
|
||||
*/
|
||||
function roi_related_posts_default_options() {
|
||||
// Set default options if they don't exist
|
||||
$defaults = array(
|
||||
'roi_related_posts_enabled' => true,
|
||||
'roi_related_posts_title' => __('Related Posts', 'roi-theme'),
|
||||
'roi_related_posts_count' => 3,
|
||||
'roi_related_posts_columns' => 3,
|
||||
'roi_related_posts_show_excerpt' => true,
|
||||
'roi_related_posts_excerpt_length' => 20,
|
||||
'roi_related_posts_show_date' => true,
|
||||
'roi_related_posts_show_category' => true,
|
||||
'roi_related_posts_bg_colors' => array(
|
||||
'#1a73e8', // Blue
|
||||
'#e91e63', // Pink
|
||||
'#4caf50', // Green
|
||||
'#ff9800', // Orange
|
||||
'#9c27b0', // Purple
|
||||
'#00bcd4', // Cyan
|
||||
),
|
||||
);
|
||||
|
||||
foreach ($defaults as $option => $value) {
|
||||
if (get_option($option) === false) {
|
||||
add_option($option, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
add_action('after_setup_theme', 'roi_related_posts_default_options');
|
||||
@@ -9,13 +9,30 @@ use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
|
||||
* Inyecta anuncios dentro del contenido del post
|
||||
* via filtro the_content
|
||||
*
|
||||
* Soporta:
|
||||
* - Modo aleatorio (random) con posiciones variables
|
||||
* - Configuracion de 1-8 ads maximo
|
||||
* - Espacio minimo entre anuncios
|
||||
* Soporta dos modos:
|
||||
* - Solo parrafos: Logica clasica solo con parrafos (usa config de behavior)
|
||||
* - Avanzado: Multiples tipos de elementos (H2, H3, p, img, lists, blockquotes, tables)
|
||||
*
|
||||
* El modo se determina por incontent_mode:
|
||||
* - "paragraphs_only": usa config de behavior (insercion solo en parrafos)
|
||||
* - Otros: usa config de incontent_advanced
|
||||
*/
|
||||
final class ContentAdInjector
|
||||
{
|
||||
/**
|
||||
* Prioridades de elementos para seleccion
|
||||
* Mayor = mas importante
|
||||
*/
|
||||
private const ELEMENT_PRIORITIES = [
|
||||
'h2' => 10,
|
||||
'p' => 8,
|
||||
'h3' => 7,
|
||||
'image' => 6,
|
||||
'list' => 5,
|
||||
'blockquote' => 4,
|
||||
'table' => 3,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private array $settings,
|
||||
private AdsensePlacementRenderer $renderer
|
||||
@@ -25,18 +42,37 @@ final class ContentAdInjector
|
||||
* Filtra the_content para insertar anuncios
|
||||
*/
|
||||
public function inject(string $content): string
|
||||
{
|
||||
// DEBUG TEMPORAL
|
||||
$debug = '<!-- ROI_AD_DEBUG: inject() called, content length=' . strlen($content) . ' -->';
|
||||
|
||||
// PASO 0: Validar longitud minima (aplica a todos los modos)
|
||||
$minLength = (int)($this->settings['forms']['min_content_length'] ?? 500);
|
||||
if (strlen(strip_tags($content)) < $minLength) {
|
||||
return $debug . '<!-- SKIP: too short -->' . $content;
|
||||
}
|
||||
|
||||
// Determinar modo de operacion
|
||||
$mode = $this->settings['incontent_advanced']['incontent_mode'] ?? 'paragraphs_only';
|
||||
$debug .= '<!-- MODE=' . $mode . ' -->';
|
||||
|
||||
if ($mode === 'paragraphs_only') {
|
||||
return $debug . $this->injectParagraphsOnly($content);
|
||||
}
|
||||
|
||||
return $debug . $this->injectAdvanced($content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modo solo parrafos: logica clasica que inserta anuncios unicamente despues de parrafos
|
||||
*/
|
||||
private function injectParagraphsOnly(string $content): string
|
||||
{
|
||||
if (!($this->settings['behavior']['post_content_enabled'] ?? false)) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Verificar longitud minima
|
||||
$minLength = (int)($this->settings['forms']['min_content_length'] ?? 500);
|
||||
if (strlen(strip_tags($content)) < $minLength) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Obtener configuracion
|
||||
// Obtener configuracion de behavior (modo solo parrafos)
|
||||
$minAds = (int)($this->settings['behavior']['post_content_min_ads'] ?? 1);
|
||||
$maxAds = (int)($this->settings['behavior']['post_content_max_ads'] ?? 3);
|
||||
$afterParagraphs = (int)($this->settings['behavior']['post_content_after_paragraphs'] ?? 3);
|
||||
@@ -58,7 +94,7 @@ final class ContentAdInjector
|
||||
}
|
||||
|
||||
// Calcular posiciones de insercion
|
||||
$adPositions = $this->calculateAdPositions(
|
||||
$adPositions = $this->calculateParagraphsOnlyPositions(
|
||||
$totalParagraphs,
|
||||
$afterParagraphs,
|
||||
$minBetween,
|
||||
@@ -72,9 +108,452 @@ final class ContentAdInjector
|
||||
}
|
||||
|
||||
// Reconstruir contenido con anuncios insertados
|
||||
return $this->buildContentWithAds($paragraphs, $adPositions);
|
||||
return $this->buildParagraphsOnlyContent($paragraphs, $adPositions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modo avanzado: multiples tipos de elementos
|
||||
*/
|
||||
private function injectAdvanced(string $content): string
|
||||
{
|
||||
$config = $this->settings['incontent_advanced'] ?? [];
|
||||
$debug = '<!-- ADV: config keys=' . implode(',', array_keys($config)) . ' -->';
|
||||
|
||||
// Obtener configuracion
|
||||
$maxAds = (int)($config['incontent_max_total_ads'] ?? 8);
|
||||
$minSpacing = (int)($config['incontent_min_spacing'] ?? 3);
|
||||
$priorityMode = $config['incontent_priority_mode'] ?? 'position';
|
||||
$format = $config['incontent_format'] ?? 'in-article';
|
||||
$debug .= '<!-- ADV: maxAds=' . $maxAds . ' minSpacing=' . $minSpacing . ' -->';
|
||||
|
||||
// PASO 1: Escanear contenido para encontrar todas las ubicaciones
|
||||
$locations = $this->scanContent($content);
|
||||
$debug .= '<!-- ADV: scanContent found ' . count($locations) . ' locations -->';
|
||||
|
||||
if (empty($locations)) {
|
||||
return $debug . '<!-- ADV: NO locations found -->' . $content;
|
||||
}
|
||||
|
||||
// PASO 2: Filtrar por configuracion (enabled)
|
||||
$locations = $this->filterByEnabled($locations, $config);
|
||||
$debug .= '<!-- ADV: filterByEnabled left ' . count($locations) . ' -->';
|
||||
|
||||
if (empty($locations)) {
|
||||
return $debug . '<!-- ADV: EMPTY after filterByEnabled -->' . $content;
|
||||
}
|
||||
|
||||
// PASO 3: Aplicar probabilidad deterministica
|
||||
$postId = get_the_ID() ?: 0;
|
||||
$locations = $this->applyProbability($locations, $config, $postId);
|
||||
$debug .= '<!-- ADV: applyProbability left ' . count($locations) . ' -->';
|
||||
|
||||
if (empty($locations)) {
|
||||
return $debug . '<!-- ADV: EMPTY after applyProbability -->' . $content;
|
||||
}
|
||||
|
||||
// PASO 4-5: Filtrar por espaciado y limite (segun priority_mode)
|
||||
if ($priorityMode === 'priority') {
|
||||
$locations = $this->filterByPriorityFirst($locations, $minSpacing, $maxAds);
|
||||
} else {
|
||||
$locations = $this->filterByPositionFirst($locations, $minSpacing, $maxAds);
|
||||
}
|
||||
$debug .= '<!-- ADV: filterBySpacing left ' . count($locations) . ' -->';
|
||||
|
||||
if (empty($locations)) {
|
||||
return $debug . '<!-- ADV: EMPTY after filterBySpacing -->' . $content;
|
||||
}
|
||||
|
||||
// Ordenar por posicion para insercion correcta
|
||||
usort($locations, fn($a, $b) => $a['position'] <=> $b['position']);
|
||||
|
||||
$debug .= '<!-- ADV: INSERTING ' . count($locations) . ' ads -->';
|
||||
|
||||
// PASO 6: Insertar anuncios
|
||||
return $debug . $this->insertAds($content, $locations, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* PASO 1: Escanea el contenido para encontrar ubicaciones elegibles
|
||||
*
|
||||
* IMPORTANTE: No inserta anuncios:
|
||||
* - Dentro de tablas (<table>...</table>)
|
||||
* - Dentro de embeds/iframes (YouTube, etc.)
|
||||
* - Despues de tablas (tables excluidas completamente)
|
||||
*
|
||||
* @return array{position: int, type: string, tag: string, element_index: int}[]
|
||||
*/
|
||||
private function scanContent(string $content): array
|
||||
{
|
||||
$locations = [];
|
||||
$elementIndex = 0;
|
||||
|
||||
// Primero, mapear zonas prohibidas (tablas, iframes, embeds)
|
||||
$forbiddenZones = $this->mapForbiddenZones($content);
|
||||
|
||||
// Regex para encontrar tags de cierre de elementos de bloque
|
||||
// NOTA: Excluimos </table> - no queremos insertar despues de tablas
|
||||
$pattern = '/(<\/(?:p|h2|h3|figure|ul|ol|blockquote)>)/i';
|
||||
|
||||
// Encontrar todas las coincidencias con sus posiciones
|
||||
if (preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
$tag = strtolower($match[0]);
|
||||
$position = $match[1] + strlen($match[0]); // Posicion despues del tag
|
||||
|
||||
// Verificar que no este dentro de una zona prohibida
|
||||
if ($this->isInForbiddenZone($position, $forbiddenZones)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $this->getTypeFromTag($tag);
|
||||
if ($type) {
|
||||
$locations[] = [
|
||||
'position' => $position,
|
||||
'type' => $type,
|
||||
'tag' => $tag,
|
||||
'element_index' => $elementIndex++,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar imagenes standalone (no dentro de figure ni zonas prohibidas)
|
||||
$locations = array_merge($locations, $this->scanStandaloneImages($content, $elementIndex, $forbiddenZones));
|
||||
|
||||
// Validar listas (minimo 3 items)
|
||||
$locations = $this->validateLists($content, $locations);
|
||||
|
||||
// Ordenar por posicion
|
||||
usort($locations, fn($a, $b) => $a['position'] <=> $b['position']);
|
||||
|
||||
// Reasignar indices de elemento
|
||||
foreach ($locations as $i => &$loc) {
|
||||
$loc['element_index'] = $i;
|
||||
}
|
||||
|
||||
return $locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapea zonas donde NO se deben insertar anuncios
|
||||
* Incluye: tablas, iframes, embeds de video
|
||||
*
|
||||
* @return array{start: int, end: int}[]
|
||||
*/
|
||||
private function mapForbiddenZones(string $content): array
|
||||
{
|
||||
$zones = [];
|
||||
$contentLength = strlen($content);
|
||||
|
||||
// Tablas: buscar cada <table> y su </table> correspondiente
|
||||
$this->findMatchingTags($content, 'table', $zones);
|
||||
|
||||
// Iframes (YouTube, Vimeo, etc)
|
||||
$this->findMatchingTags($content, 'iframe', $zones);
|
||||
|
||||
// Figure con clase wp-block-embed (embeds de WordPress)
|
||||
if (preg_match_all('/<figure[^>]*class="[^"]*wp-block-embed[^"]*"[^>]*>/i', $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
$startPos = $match[1];
|
||||
$closePos = strpos($content, '</figure>', $startPos);
|
||||
if ($closePos !== false) {
|
||||
$zones[] = [
|
||||
'start' => $startPos,
|
||||
'end' => $closePos + strlen('</figure>'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encuentra tags de apertura y cierre correspondientes
|
||||
*/
|
||||
private function findMatchingTags(string $content, string $tagName, array &$zones): void
|
||||
{
|
||||
$openTag = '<' . $tagName;
|
||||
$closeTag = '</' . $tagName . '>';
|
||||
$offset = 0;
|
||||
|
||||
while (($startPos = stripos($content, $openTag, $offset)) !== false) {
|
||||
// Buscar el cierre correspondiente
|
||||
$closePos = stripos($content, $closeTag, $startPos);
|
||||
if ($closePos !== false) {
|
||||
$zones[] = [
|
||||
'start' => $startPos,
|
||||
'end' => $closePos + strlen($closeTag),
|
||||
];
|
||||
$offset = $closePos + strlen($closeTag);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si una posicion esta dentro de una zona prohibida
|
||||
*/
|
||||
private function isInForbiddenZone(int $position, array $forbiddenZones): bool
|
||||
{
|
||||
foreach ($forbiddenZones as $zone) {
|
||||
if ($position >= $zone['start'] && $position <= $zone['end']) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte tag de cierre a tipo de elemento
|
||||
* NOTA: </table> excluido - no insertamos ads despues de tablas
|
||||
*/
|
||||
private function getTypeFromTag(string $tag): ?string
|
||||
{
|
||||
return match ($tag) {
|
||||
'</p>' => 'p',
|
||||
'</h2>' => 'h2',
|
||||
'</h3>' => 'h3',
|
||||
'</figure>' => 'image',
|
||||
'</ul>', '</ol>' => 'list',
|
||||
'</blockquote>' => 'blockquote',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta imagenes que no estan dentro de figure ni zonas prohibidas
|
||||
*/
|
||||
private function scanStandaloneImages(string $content, int $startIndex, array $forbiddenZones = []): array
|
||||
{
|
||||
$locations = [];
|
||||
|
||||
// Encontrar todas las imagenes con sus posiciones
|
||||
if (!preg_match_all('/<img[^>]*>/i', $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
return $locations;
|
||||
}
|
||||
|
||||
foreach ($matches[0] as $match) {
|
||||
$imgTag = $match[0];
|
||||
$imgPosition = $match[1];
|
||||
|
||||
// Verificar que no este en zona prohibida
|
||||
if ($this->isInForbiddenZone($imgPosition, $forbiddenZones)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verificar si hay un <figure> abierto antes de esta imagen
|
||||
$contentBefore = substr($content, 0, $imgPosition);
|
||||
$lastFigureOpen = strrpos($contentBefore, '<figure');
|
||||
$lastFigureClose = strrpos($contentBefore, '</figure>');
|
||||
|
||||
// Si hay figure abierto sin cerrar, esta imagen esta dentro de figure
|
||||
if ($lastFigureOpen !== false && ($lastFigureClose === false || $lastFigureClose < $lastFigureOpen)) {
|
||||
continue; // Ignorar, se contara con </figure>
|
||||
}
|
||||
|
||||
// Imagen standalone - calcular posicion despues del tag
|
||||
$endPosition = $imgPosition + strlen($imgTag);
|
||||
|
||||
// Si la imagen esta seguida de </p> o similar, usar esa posicion
|
||||
$contentAfter = substr($content, $endPosition, 20);
|
||||
if (preg_match('/^\s*<\/p>/i', $contentAfter, $closeMatch)) {
|
||||
// La imagen esta dentro de un parrafo, no es standalone
|
||||
continue;
|
||||
}
|
||||
|
||||
$locations[] = [
|
||||
'position' => $endPosition,
|
||||
'type' => 'image',
|
||||
'tag' => $imgTag,
|
||||
'element_index' => $startIndex++,
|
||||
];
|
||||
}
|
||||
|
||||
return $locations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida que las listas tengan minimo 3 items
|
||||
*/
|
||||
private function validateLists(string $content, array $locations): array
|
||||
{
|
||||
return array_filter($locations, function ($loc) use ($content) {
|
||||
if ($loc['type'] !== 'list') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Encontrar el contenido de la lista
|
||||
$endPos = $loc['position'];
|
||||
$tag = $loc['tag'];
|
||||
$openTag = str_replace('/', '', $tag); // </ul> -> <ul>
|
||||
|
||||
// Buscar hacia atras el tag de apertura
|
||||
$contentBefore = substr($content, 0, $endPos);
|
||||
$lastOpen = strrpos($contentBefore, '<' . substr($openTag, 1)); // <ul o <ol
|
||||
|
||||
if ($lastOpen === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$listContent = substr($content, $lastOpen, $endPos - $lastOpen);
|
||||
|
||||
// Contar items (usando substr_count como indica el spec)
|
||||
$itemCount = substr_count(strtolower($listContent), '<li');
|
||||
|
||||
return $itemCount >= 3;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PASO 2: Filtra ubicaciones por campos enabled
|
||||
*/
|
||||
private function filterByEnabled(array $locations, array $config): array
|
||||
{
|
||||
$enabledTypes = [];
|
||||
|
||||
$typeMapping = [
|
||||
'h2' => 'incontent_after_h2_enabled',
|
||||
'h3' => 'incontent_after_h3_enabled',
|
||||
'p' => 'incontent_after_paragraphs_enabled',
|
||||
'image' => 'incontent_after_images_enabled',
|
||||
'list' => 'incontent_after_lists_enabled',
|
||||
'blockquote' => 'incontent_after_blockquotes_enabled',
|
||||
'table' => 'incontent_after_tables_enabled',
|
||||
];
|
||||
|
||||
foreach ($typeMapping as $type => $field) {
|
||||
if ($config[$field] ?? false) {
|
||||
$enabledTypes[] = $type;
|
||||
}
|
||||
}
|
||||
|
||||
return array_filter($locations, fn($loc) => in_array($loc['type'], $enabledTypes, true));
|
||||
}
|
||||
|
||||
/**
|
||||
* PASO 3: Aplica probabilidad deterministica usando seed del dia
|
||||
*/
|
||||
private function applyProbability(array $locations, array $config, int $postId): array
|
||||
{
|
||||
// Calcular seed deterministico
|
||||
$seed = crc32($postId . date('Y-m-d'));
|
||||
mt_srand($seed);
|
||||
|
||||
$probMapping = [
|
||||
'h2' => 'incontent_after_h2_probability',
|
||||
'h3' => 'incontent_after_h3_probability',
|
||||
'p' => 'incontent_after_paragraphs_probability',
|
||||
'image' => 'incontent_after_images_probability',
|
||||
'list' => 'incontent_after_lists_probability',
|
||||
'blockquote' => 'incontent_after_blockquotes_probability',
|
||||
'table' => 'incontent_after_tables_probability',
|
||||
];
|
||||
|
||||
return array_filter($locations, function ($loc) use ($config, $probMapping) {
|
||||
$field = $probMapping[$loc['type']] ?? null;
|
||||
if (!$field) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$probability = (int)($config[$field] ?? 100);
|
||||
$roll = mt_rand(1, 100);
|
||||
|
||||
return $roll <= $probability;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PASO 4-5 (modo position): Filtrar por espaciado primero, luego por prioridad
|
||||
*/
|
||||
private function filterByPositionFirst(array $locations, int $minSpacing, int $maxAds): array
|
||||
{
|
||||
// PASO 4: Ordenar por posicion y filtrar por espaciado
|
||||
usort($locations, fn($a, $b) => $a['element_index'] <=> $b['element_index']);
|
||||
|
||||
$filtered = [];
|
||||
$lastIndex = -999;
|
||||
|
||||
foreach ($locations as $loc) {
|
||||
if ($loc['element_index'] - $lastIndex >= $minSpacing) {
|
||||
$filtered[] = $loc;
|
||||
$lastIndex = $loc['element_index'];
|
||||
}
|
||||
}
|
||||
|
||||
// PASO 5: Si excede max, seleccionar por prioridad
|
||||
if (count($filtered) > $maxAds) {
|
||||
usort($filtered, fn($a, $b) =>
|
||||
(self::ELEMENT_PRIORITIES[$b['type']] ?? 0) <=> (self::ELEMENT_PRIORITIES[$a['type']] ?? 0)
|
||||
);
|
||||
$filtered = array_slice($filtered, 0, $maxAds);
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* PASO 4-5 (modo priority): Ordenar por prioridad primero, luego aplicar espaciado
|
||||
*/
|
||||
private function filterByPriorityFirst(array $locations, int $minSpacing, int $maxAds): array
|
||||
{
|
||||
// PASO 4: Ordenar por prioridad
|
||||
usort($locations, fn($a, $b) =>
|
||||
(self::ELEMENT_PRIORITIES[$b['type']] ?? 0) <=> (self::ELEMENT_PRIORITIES[$a['type']] ?? 0)
|
||||
);
|
||||
|
||||
// PASO 5: Filtrar por espaciado en orden de prioridad
|
||||
$selected = [];
|
||||
$usedIndices = [];
|
||||
|
||||
foreach ($locations as $loc) {
|
||||
if (count($selected) >= $maxAds) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Verificar espaciado con ubicaciones ya seleccionadas
|
||||
$violatesSpacing = false;
|
||||
foreach ($usedIndices as $usedIndex) {
|
||||
if (abs($loc['element_index'] - $usedIndex) < $minSpacing) {
|
||||
$violatesSpacing = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$violatesSpacing) {
|
||||
$selected[] = $loc;
|
||||
$usedIndices[] = $loc['element_index'];
|
||||
}
|
||||
}
|
||||
|
||||
return $selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* PASO 6: Inserta los anuncios en las posiciones calculadas
|
||||
*/
|
||||
private function insertAds(string $content, array $locations, string $format): string
|
||||
{
|
||||
// Insertar de atras hacia adelante para no afectar posiciones
|
||||
$locations = array_reverse($locations);
|
||||
|
||||
$adCount = count($locations);
|
||||
$currentAd = $adCount;
|
||||
|
||||
foreach ($locations as $loc) {
|
||||
$adHtml = $this->renderer->renderSlot($this->settings, 'post-content-adv-' . $currentAd);
|
||||
$content = substr_replace($content, $adHtml, $loc['position'], 0);
|
||||
$currentAd--;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// METODOS LEGACY (sin cambios para backward compatibility)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Divide el contenido en parrafos preservando el HTML
|
||||
*/
|
||||
@@ -103,11 +582,11 @@ final class ContentAdInjector
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula las posiciones donde insertar anuncios
|
||||
* Calcula las posiciones donde insertar anuncios (modo solo parrafos)
|
||||
*
|
||||
* @return int[] Indices de parrafos despues de los cuales insertar ads
|
||||
*/
|
||||
private function calculateAdPositions(
|
||||
private function calculateParagraphsOnlyPositions(
|
||||
int $totalParagraphs,
|
||||
int $afterFirst,
|
||||
int $minBetween,
|
||||
@@ -117,7 +596,6 @@ final class ContentAdInjector
|
||||
): array {
|
||||
// Calcular posiciones disponibles respetando el espacio minimo
|
||||
$availablePositions = [];
|
||||
$lastPosition = $afterFirst; // Primera posicion fija
|
||||
|
||||
// La primera posicion siempre es despues del parrafo indicado
|
||||
if ($afterFirst < $totalParagraphs) {
|
||||
@@ -178,9 +656,9 @@ final class ContentAdInjector
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruye el contenido insertando anuncios en las posiciones indicadas
|
||||
* Reconstruye el contenido insertando anuncios en las posiciones indicadas (modo solo parrafos)
|
||||
*/
|
||||
private function buildContentWithAds(array $paragraphs, array $adPositions): string
|
||||
private function buildParagraphsOnlyContent(array $paragraphs, array $adPositions): string
|
||||
{
|
||||
$newContent = '';
|
||||
$adsInserted = 0;
|
||||
|
||||
@@ -111,15 +111,24 @@ final class AdsensePlacementRenderer
|
||||
{
|
||||
$locationKey = str_replace('-', '_', $location);
|
||||
|
||||
// Manejar ubicaciones de in-content (post_content_1, post_content_2, etc.)
|
||||
// Manejar ubicaciones de in-content legacy (post_content_1, post_content_2, etc.)
|
||||
if (preg_match('/^post_content_(\d+)$/', $locationKey, $matches)) {
|
||||
// In-content ads heredan la configuracion de post_content
|
||||
// In-content ads heredan la configuracion de post_content (modo solo parrafos)
|
||||
return [
|
||||
'enabled' => $settings['behavior']['post_content_enabled'] ?? false,
|
||||
'format' => $settings['behavior']['post_content_format'] ?? 'in-article',
|
||||
];
|
||||
}
|
||||
|
||||
// Manejar ubicaciones de in-content avanzado (post_content_adv_1, post_content_adv_2, etc.)
|
||||
if (preg_match('/^post_content_adv_(\d+)$/', $locationKey, $matches)) {
|
||||
// In-content ads avanzados usan configuracion de incontent_advanced
|
||||
return [
|
||||
'enabled' => true, // Siempre enabled porque ya pasaron los filtros en ContentAdInjector
|
||||
'format' => $settings['incontent_advanced']['incontent_format'] ?? 'in-article',
|
||||
];
|
||||
}
|
||||
|
||||
// Mapeo de ubicaciones a grupos y campos
|
||||
$locationMap = [
|
||||
'post_top' => ['group' => 'behavior', 'enabled' => 'post_top_enabled', 'format' => 'post_top_format'],
|
||||
|
||||
314
Public/ArchiveHeader/Infrastructure/Ui/ArchiveHeaderRenderer.php
Normal file
314
Public/ArchiveHeader/Infrastructure/Ui/ArchiveHeaderRenderer.php
Normal file
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Public\ArchiveHeader\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\RendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
|
||||
/**
|
||||
* ArchiveHeaderRenderer - Renderiza cabecera dinamica para paginas de archivo
|
||||
*
|
||||
* RESPONSABILIDAD: Generar HTML y CSS del componente Archive Header
|
||||
*
|
||||
* CARACTERISTICAS:
|
||||
* - Deteccion automatica del tipo de archivo (categoria, tag, autor, fecha, busqueda)
|
||||
* - Titulo y descripcion dinamicos
|
||||
* - Contador de posts opcional
|
||||
* - Estilos 100% desde BD via CSSGenerator
|
||||
*
|
||||
* @package ROITheme\Public\ArchiveHeader\Infrastructure\Ui
|
||||
*/
|
||||
final class ArchiveHeaderRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'archive-header';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
|
||||
public function render(Component $component): string
|
||||
{
|
||||
$data = $component->getData();
|
||||
|
||||
if (!$this->isEnabled($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$visibilityClass = $this->getVisibilityClass($data);
|
||||
if ($visibilityClass === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$css = $this->generateCSS($data);
|
||||
$html = $this->buildHTML($data, $visibilityClass);
|
||||
|
||||
return sprintf("<style>%s</style>\n%s", $css, $html);
|
||||
}
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
{
|
||||
$value = $data['visibility']['is_enabled'] ?? false;
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function getVisibilityClass(array $data): ?string
|
||||
{
|
||||
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
|
||||
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
|
||||
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
|
||||
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
|
||||
|
||||
if (!$showDesktop && !$showMobile) {
|
||||
return null;
|
||||
}
|
||||
if (!$showDesktop && $showMobile) {
|
||||
return 'd-lg-none';
|
||||
}
|
||||
if ($showDesktop && !$showMobile) {
|
||||
return 'd-none d-lg-block';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function generateCSS(array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
$spacing = $data['spacing'] ?? [];
|
||||
$typography = $data['typography'] ?? [];
|
||||
$behavior = $data['behavior'] ?? [];
|
||||
|
||||
$cssRules = [];
|
||||
|
||||
// Container
|
||||
$marginTop = $spacing['margin_top'] ?? '2rem';
|
||||
$marginBottom = $spacing['margin_bottom'] ?? '2rem';
|
||||
$padding = $spacing['padding'] ?? '1.5rem';
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.archive-header', [
|
||||
'margin-top' => $marginTop,
|
||||
'margin-bottom' => $marginBottom,
|
||||
'padding' => $padding,
|
||||
]);
|
||||
|
||||
// Sticky behavior
|
||||
$isSticky = $behavior['is_sticky'] ?? false;
|
||||
$isSticky = $isSticky === true || $isSticky === '1' || $isSticky === 1;
|
||||
|
||||
if ($isSticky) {
|
||||
$stickyOffset = $behavior['sticky_offset'] ?? '0';
|
||||
$cssRules[] = $this->cssGenerator->generate('.archive-header', [
|
||||
'position' => 'sticky',
|
||||
'top' => $stickyOffset,
|
||||
'z-index' => '100',
|
||||
'background' => '#ffffff',
|
||||
]);
|
||||
}
|
||||
|
||||
// Title
|
||||
$titleColor = $colors['title_color'] ?? '#0E2337';
|
||||
$titleSize = $typography['title_size'] ?? '2rem';
|
||||
$titleWeight = $typography['title_weight'] ?? '700';
|
||||
$titleMarginBottom = $spacing['title_margin_bottom'] ?? '0.5rem';
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.archive-header__title', [
|
||||
'color' => $titleColor,
|
||||
'font-size' => $titleSize,
|
||||
'font-weight' => $titleWeight,
|
||||
'margin-bottom' => $titleMarginBottom,
|
||||
'line-height' => '1.2',
|
||||
]);
|
||||
|
||||
// Prefix
|
||||
$prefixColor = $colors['prefix_color'] ?? '#6b7280';
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.archive-header__prefix', [
|
||||
'color' => $prefixColor,
|
||||
'font-weight' => '400',
|
||||
]);
|
||||
|
||||
// Description
|
||||
$descColor = $colors['description_color'] ?? '#6b7280';
|
||||
$descSize = $typography['description_size'] ?? '1rem';
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.archive-header__description', [
|
||||
'color' => $descColor,
|
||||
'font-size' => $descSize,
|
||||
'margin-top' => '0.5rem',
|
||||
'line-height' => '1.6',
|
||||
]);
|
||||
|
||||
// Post count badge
|
||||
$countBgColor = $colors['count_bg_color'] ?? '#FF8600';
|
||||
$countTextColor = $colors['count_text_color'] ?? '#ffffff';
|
||||
$countSize = $typography['count_size'] ?? '0.875rem';
|
||||
$countPadding = $spacing['count_padding'] ?? '0.25rem 0.75rem';
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.archive-header__count', [
|
||||
'background-color' => $countBgColor,
|
||||
'color' => $countTextColor,
|
||||
'font-size' => $countSize,
|
||||
'padding' => $countPadding,
|
||||
'border-radius' => '9999px',
|
||||
'font-weight' => '500',
|
||||
'display' => 'inline-block',
|
||||
'margin-left' => '0.75rem',
|
||||
]);
|
||||
|
||||
return implode("\n", $cssRules);
|
||||
}
|
||||
|
||||
private function buildHTML(array $data, string $visibilityClass): string
|
||||
{
|
||||
$content = $data['content'] ?? [];
|
||||
$typography = $data['typography'] ?? [];
|
||||
|
||||
$headingLevel = $typography['heading_level'] ?? 'h1';
|
||||
$showPostCount = $content['show_post_count'] ?? true;
|
||||
$showPostCount = $showPostCount === true || $showPostCount === '1' || $showPostCount === 1;
|
||||
$showDescription = $content['show_description'] ?? true;
|
||||
$showDescription = $showDescription === true || $showDescription === '1' || $showDescription === 1;
|
||||
|
||||
// Get context-specific title and description
|
||||
$titleData = $this->getContextualTitle($content);
|
||||
$title = $titleData['title'];
|
||||
$prefix = $titleData['prefix'];
|
||||
$description = $showDescription ? $titleData['description'] : '';
|
||||
|
||||
// Get post count
|
||||
$postCount = $this->getPostCount();
|
||||
$countSingular = $content['posts_count_singular'] ?? 'publicacion';
|
||||
$countPlural = $content['posts_count_plural'] ?? 'publicaciones';
|
||||
$countText = $postCount === 1 ? $countSingular : $countPlural;
|
||||
|
||||
$containerClass = 'archive-header';
|
||||
if (!empty($visibilityClass)) {
|
||||
$containerClass .= ' ' . $visibilityClass;
|
||||
}
|
||||
|
||||
$html = sprintf('<div class="%s">', esc_attr($containerClass));
|
||||
|
||||
// Title with optional prefix
|
||||
$html .= sprintf('<%s class="archive-header__title">', esc_attr($headingLevel));
|
||||
|
||||
if (!empty($prefix)) {
|
||||
$html .= sprintf(
|
||||
'<span class="archive-header__prefix">%s</span> ',
|
||||
esc_html($prefix)
|
||||
);
|
||||
}
|
||||
|
||||
$html .= esc_html($title);
|
||||
|
||||
// Post count badge
|
||||
if ($showPostCount && $postCount > 0) {
|
||||
$html .= sprintf(
|
||||
'<span class="archive-header__count">%d %s</span>',
|
||||
$postCount,
|
||||
esc_html($countText)
|
||||
);
|
||||
}
|
||||
|
||||
$html .= sprintf('</%s>', esc_attr($headingLevel));
|
||||
|
||||
// Description
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf(
|
||||
'<p class="archive-header__description">%s</p>',
|
||||
esc_html($description)
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contextual title based on current page type
|
||||
*
|
||||
* @param array $content Content settings from schema
|
||||
* @return array{title: string, prefix: string, description: string}
|
||||
*/
|
||||
private function getContextualTitle(array $content): array
|
||||
{
|
||||
$title = '';
|
||||
$prefix = '';
|
||||
$description = '';
|
||||
|
||||
if (is_category()) {
|
||||
$prefix = $content['category_prefix'] ?? 'Categoria:';
|
||||
$title = single_cat_title('', false) ?: '';
|
||||
$description = category_description() ?: '';
|
||||
} elseif (is_tag()) {
|
||||
$prefix = $content['tag_prefix'] ?? 'Etiqueta:';
|
||||
$title = single_tag_title('', false) ?: '';
|
||||
$description = tag_description() ?: '';
|
||||
} elseif (is_author()) {
|
||||
$prefix = $content['author_prefix'] ?? 'Articulos de:';
|
||||
$title = get_the_author() ?: '';
|
||||
$description = get_the_author_meta('description') ?: '';
|
||||
} elseif (is_date()) {
|
||||
$prefix = $content['date_prefix'] ?? 'Archivo:';
|
||||
$title = $this->getDateArchiveTitle();
|
||||
$description = '';
|
||||
} elseif (is_search()) {
|
||||
$prefix = $content['search_prefix'] ?? 'Resultados para:';
|
||||
$title = get_search_query() ?: '';
|
||||
$description = '';
|
||||
} elseif (is_home()) {
|
||||
$prefix = '';
|
||||
$title = $content['blog_title'] ?? 'Blog';
|
||||
$description = '';
|
||||
} elseif (is_archive()) {
|
||||
$prefix = '';
|
||||
$title = get_the_archive_title() ?: 'Archivo';
|
||||
$description = get_the_archive_description() ?: '';
|
||||
} else {
|
||||
$prefix = '';
|
||||
$title = $content['blog_title'] ?? 'Blog';
|
||||
$description = '';
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'prefix' => $prefix,
|
||||
'description' => strip_tags($description),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted title for date archives
|
||||
*/
|
||||
private function getDateArchiveTitle(): string
|
||||
{
|
||||
if (is_day()) {
|
||||
return get_the_date();
|
||||
} elseif (is_month()) {
|
||||
return get_the_date('F Y');
|
||||
} elseif (is_year()) {
|
||||
return get_the_date('Y');
|
||||
}
|
||||
return get_the_archive_title() ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total post count for current query
|
||||
*/
|
||||
private function getPostCount(): int
|
||||
{
|
||||
global $wp_query;
|
||||
return $wp_query->found_posts ?? 0;
|
||||
}
|
||||
}
|
||||
651
Public/PostGrid/Infrastructure/Ui/PostGridRenderer.php
Normal file
651
Public/PostGrid/Infrastructure/Ui/PostGridRenderer.php
Normal file
@@ -0,0 +1,651 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Public\PostGrid\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\RendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
|
||||
/**
|
||||
* PostGridRenderer - Renderiza grid de posts del loop principal de WordPress
|
||||
*
|
||||
* RESPONSABILIDAD: Generar HTML y CSS del componente Post Grid
|
||||
*
|
||||
* DIFERENCIA CON RelatedPostRenderer:
|
||||
* - PostGrid usa global $wp_query (loop principal)
|
||||
* - RelatedPost crea su propio WP_Query
|
||||
*
|
||||
* CARACTERISTICAS:
|
||||
* - Grid responsive de cards con imagen, excerpt y meta
|
||||
* - Usa loop principal de WordPress (no crea queries propias)
|
||||
* - Paginacion nativa de WordPress
|
||||
* - Estilos 100% desde BD via CSSGenerator
|
||||
*
|
||||
* @package ROITheme\Public\PostGrid\Infrastructure\Ui
|
||||
*/
|
||||
final class PostGridRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'post-grid';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
|
||||
public function render(Component $component): string
|
||||
{
|
||||
$data = $component->getData();
|
||||
|
||||
if (!$this->isEnabled($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$visibilityClass = $this->getVisibilityClass($data);
|
||||
if ($visibilityClass === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
global $wp_query;
|
||||
|
||||
// Si no hay posts, mostrar mensaje
|
||||
if (!have_posts()) {
|
||||
$noPostsMessage = $data['content']['no_posts_message'] ?? 'No se encontraron publicaciones';
|
||||
return $this->renderNoPostsMessage($noPostsMessage, $visibilityClass, $data);
|
||||
}
|
||||
|
||||
$css = $this->generateCSS($data);
|
||||
$html = $this->buildHTML($data, $visibilityClass);
|
||||
|
||||
return sprintf("<style>%s</style>\n%s", $css, $html);
|
||||
}
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
{
|
||||
$value = $data['visibility']['is_enabled'] ?? false;
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function getVisibilityClass(array $data): ?string
|
||||
{
|
||||
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
|
||||
$showDesktop = $showDesktop === true || $showDesktop === '1' || $showDesktop === 1;
|
||||
$showMobile = $data['visibility']['show_on_mobile'] ?? true;
|
||||
$showMobile = $showMobile === true || $showMobile === '1' || $showMobile === 1;
|
||||
|
||||
if (!$showDesktop && !$showMobile) {
|
||||
return null;
|
||||
}
|
||||
if (!$showDesktop && $showMobile) {
|
||||
return 'd-lg-none';
|
||||
}
|
||||
if ($showDesktop && !$showMobile) {
|
||||
return 'd-none d-lg-block';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function renderNoPostsMessage(string $message, string $visibilityClass, array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
$spacing = $data['spacing'] ?? [];
|
||||
|
||||
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
|
||||
$textColor = $colors['excerpt_color'] ?? '#6b7280';
|
||||
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
|
||||
$padding = $spacing['card_padding'] ?? '1.25rem';
|
||||
|
||||
$css = $this->cssGenerator->generate('.post-grid-no-posts', [
|
||||
'background-color' => $bgColor,
|
||||
'color' => $textColor,
|
||||
'border' => "1px solid {$borderColor}",
|
||||
'border-radius' => '0.5rem',
|
||||
'padding' => '2rem',
|
||||
'text-align' => 'center',
|
||||
]);
|
||||
|
||||
$containerClass = 'post-grid-no-posts';
|
||||
if (!empty($visibilityClass)) {
|
||||
$containerClass .= ' ' . $visibilityClass;
|
||||
}
|
||||
|
||||
$html = sprintf(
|
||||
'<div class="%s"><p class="mb-0">%s</p></div>',
|
||||
esc_attr($containerClass),
|
||||
esc_html($message)
|
||||
);
|
||||
|
||||
return sprintf("<style>%s</style>\n%s", $css, $html);
|
||||
}
|
||||
|
||||
private function generateCSS(array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
$spacing = $data['spacing'] ?? [];
|
||||
$effects = $data['visual_effects'] ?? [];
|
||||
$typography = $data['typography'] ?? [];
|
||||
$layout = $data['layout'] ?? [];
|
||||
|
||||
$cssRules = [];
|
||||
|
||||
// Colores
|
||||
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
|
||||
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
|
||||
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
|
||||
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
|
||||
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
|
||||
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
|
||||
$metaColor = $colors['meta_color'] ?? '#9ca3af';
|
||||
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
|
||||
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
|
||||
$paginationColor = $colors['pagination_color'] ?? '#0E2337';
|
||||
$paginationActiveBg = $colors['pagination_active_bg'] ?? '#FF8600';
|
||||
$paginationActiveColor = $colors['pagination_active_color'] ?? '#ffffff';
|
||||
|
||||
// Spacing
|
||||
$gapHorizontal = $spacing['gap_horizontal'] ?? '24px';
|
||||
$gapVertical = $spacing['gap_vertical'] ?? '24px';
|
||||
$cardPadding = $spacing['card_padding'] ?? '20px';
|
||||
$sectionMarginTop = $spacing['section_margin_top'] ?? '0px';
|
||||
$sectionMarginBottom = $spacing['section_margin_bottom'] ?? '32px';
|
||||
|
||||
// Visual effects
|
||||
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
|
||||
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
|
||||
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
|
||||
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
|
||||
$imageBorderRadius = $effects['image_border_radius'] ?? '0.375rem';
|
||||
|
||||
// Typography
|
||||
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
|
||||
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
|
||||
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
|
||||
$metaSize = $typography['meta_size'] ?? '0.8rem';
|
||||
|
||||
// Container
|
||||
$cssRules[] = $this->cssGenerator->generate('.post-grid', [
|
||||
'margin-top' => $sectionMarginTop,
|
||||
'margin-bottom' => $sectionMarginBottom,
|
||||
]);
|
||||
|
||||
// Row: usar display flex con gap, quitar margins/paddings de Bootstrap
|
||||
$cssRules[] = ".post-grid .row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: {$gapHorizontal};
|
||||
row-gap: {$gapVertical};
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}";
|
||||
|
||||
// Columnas: quitar padding de Bootstrap y margin-bottom
|
||||
$cssRules[] = ".post-grid .post-card-col {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}";
|
||||
|
||||
// Card base - sin margin extra
|
||||
$cssRules[] = ".post-grid .card {
|
||||
background: {$cardBgColor};
|
||||
border: 1px solid {$cardBorderColor};
|
||||
border-radius: {$cardBorderRadius};
|
||||
box-shadow: {$cardShadow};
|
||||
transition: {$cardTransition};
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}";
|
||||
|
||||
// Card hover
|
||||
$cssRules[] = ".post-grid .card:hover {
|
||||
background: {$cardHoverBgColor};
|
||||
border-color: {$cardHoverBorderColor};
|
||||
box-shadow: {$cardHoverShadow};
|
||||
transform: translateY(-2px);
|
||||
}";
|
||||
|
||||
// Card body
|
||||
$cssRules[] = $this->cssGenerator->generate('.post-grid .card-body', [
|
||||
'padding' => $cardPadding,
|
||||
]);
|
||||
|
||||
// Card image
|
||||
$cssRules[] = ".post-grid .card-img-top {
|
||||
border-radius: {$imageBorderRadius} {$imageBorderRadius} 0 0;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}";
|
||||
|
||||
// Card title
|
||||
$cssRules[] = ".post-grid .card-title {
|
||||
color: {$cardTitleColor};
|
||||
font-size: {$cardTitleSize};
|
||||
font-weight: {$cardTitleWeight};
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.75rem;
|
||||
}";
|
||||
|
||||
// Card title hover
|
||||
$cssRules[] = ".post-grid a:hover .card-title {
|
||||
color: {$cardHoverBorderColor};
|
||||
}";
|
||||
|
||||
// Excerpt
|
||||
$cssRules[] = ".post-grid .card-text {
|
||||
color: {$excerptColor};
|
||||
font-size: {$excerptSize};
|
||||
line-height: 1.6;
|
||||
}";
|
||||
|
||||
// Meta
|
||||
$cssRules[] = ".post-grid .post-meta {
|
||||
color: {$metaColor};
|
||||
font-size: {$metaSize};
|
||||
}";
|
||||
|
||||
// Categories
|
||||
$cssRules[] = ".post-grid .post-category {
|
||||
background: {$categoryBgColor};
|
||||
color: {$categoryTextColor};
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .post-category:hover {
|
||||
background: {$categoryTextColor};
|
||||
color: #ffffff;
|
||||
}";
|
||||
|
||||
// Pagination
|
||||
$cssRules[] = ".post-grid .pagination {
|
||||
margin-top: 2rem;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .page-link {
|
||||
color: {$paginationColor};
|
||||
border: 1px solid {$cardBorderColor};
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0 0.25rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .page-link:hover {
|
||||
background-color: rgba(255, 133, 0, 0.1);
|
||||
border-color: {$paginationActiveBg};
|
||||
color: {$paginationActiveBg};
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .page-item.active .page-link,
|
||||
.post-grid .nav-links .current {
|
||||
background-color: {$paginationActiveBg};
|
||||
border-color: {$paginationActiveBg};
|
||||
color: {$paginationActiveColor};
|
||||
}";
|
||||
|
||||
// WordPress pagination classes
|
||||
$cssRules[] = ".post-grid .nav-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 2rem;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .nav-links a,
|
||||
.post-grid .nav-links span {
|
||||
color: {$paginationColor};
|
||||
border: 1px solid {$cardBorderColor};
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-decoration: none;
|
||||
}";
|
||||
|
||||
$cssRules[] = ".post-grid .nav-links a:hover {
|
||||
background-color: rgba(255, 133, 0, 0.1);
|
||||
border-color: {$paginationActiveBg};
|
||||
color: {$paginationActiveBg};
|
||||
}";
|
||||
|
||||
// Layout responsive columns
|
||||
$colsDesktop = $layout['columns_desktop'] ?? '3';
|
||||
$colsTablet = $layout['columns_tablet'] ?? '2';
|
||||
$colsMobile = $layout['columns_mobile'] ?? '1';
|
||||
|
||||
// Mobile (1 col = no gap needed)
|
||||
$mobileWidth = $this->getColumnWidth($colsMobile, $gapHorizontal);
|
||||
$cssRules[] = "@media (max-width: 575.98px) {
|
||||
.post-grid .post-card-col {
|
||||
flex: 0 0 {$mobileWidth};
|
||||
max-width: {$mobileWidth};
|
||||
}
|
||||
}";
|
||||
|
||||
// Tablet
|
||||
$tabletWidth = $this->getColumnWidth($colsTablet, $gapHorizontal);
|
||||
$cssRules[] = "@media (min-width: 576px) and (max-width: 991.98px) {
|
||||
.post-grid .post-card-col {
|
||||
flex: 0 0 {$tabletWidth};
|
||||
max-width: {$tabletWidth};
|
||||
}
|
||||
}";
|
||||
|
||||
// Desktop
|
||||
$desktopWidth = $this->getColumnWidth($colsDesktop, $gapHorizontal);
|
||||
$cssRules[] = "@media (min-width: 992px) {
|
||||
.post-grid .post-card-col {
|
||||
flex: 0 0 {$desktopWidth};
|
||||
max-width: {$desktopWidth};
|
||||
}
|
||||
}";
|
||||
|
||||
return implode("\n", $cssRules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el ancho de columna considerando el gap
|
||||
*
|
||||
* Con gap en flexbox, el ancho debe ser:
|
||||
* (100% - (n-1)*gap) / n
|
||||
*
|
||||
* @param string $cols Número de columnas
|
||||
* @param string $gap Valor del gap (ej: '1.5rem', '24px')
|
||||
* @return string Valor CSS con calc() si hay gap
|
||||
*/
|
||||
private function getColumnWidth(string $cols, string $gap): string
|
||||
{
|
||||
$colCount = (int)$cols;
|
||||
if ($colCount <= 0) {
|
||||
$colCount = 1;
|
||||
}
|
||||
|
||||
// Si es 1 columna, no hay gap entre columnas
|
||||
if ($colCount === 1) {
|
||||
return '100%';
|
||||
}
|
||||
|
||||
// Número de gaps = columnas - 1
|
||||
$gapCount = $colCount - 1;
|
||||
|
||||
// calc((100% - (n-1)*gap) / n)
|
||||
return sprintf('calc((100%% - %d * %s) / %d)', $gapCount, $gap, $colCount);
|
||||
}
|
||||
|
||||
private function buildHTML(array $data, string $visibilityClass): string
|
||||
{
|
||||
$content = $data['content'] ?? [];
|
||||
$typography = $data['typography'] ?? [];
|
||||
$media = $data['media'] ?? [];
|
||||
$layout = $data['layout'] ?? [];
|
||||
|
||||
$showThumbnail = $this->toBool($content['show_thumbnail'] ?? true);
|
||||
$showExcerpt = $this->toBool($content['show_excerpt'] ?? true);
|
||||
$showMeta = $this->toBool($content['show_meta'] ?? true);
|
||||
$showCategories = $this->toBool($content['show_categories'] ?? true);
|
||||
$excerptLength = (int)($content['excerpt_length'] ?? 20);
|
||||
$readMoreText = $content['read_more_text'] ?? 'Leer mas';
|
||||
$headingLevel = $typography['heading_level'] ?? 'h3';
|
||||
$fallbackImage = $media['fallback_image'] ?? '';
|
||||
$fallbackImageAlt = $media['fallback_image_alt'] ?? 'Imagen por defecto';
|
||||
$imagePosition = $layout['image_position'] ?? 'top';
|
||||
|
||||
$containerClass = 'post-grid';
|
||||
if (!empty($visibilityClass)) {
|
||||
$containerClass .= ' ' . $visibilityClass;
|
||||
}
|
||||
|
||||
$html = sprintf('<div class="%s">', esc_attr($containerClass));
|
||||
$html .= '<div class="row">';
|
||||
|
||||
while (have_posts()) {
|
||||
the_post();
|
||||
$html .= $this->buildCardHTML(
|
||||
$showThumbnail,
|
||||
$showExcerpt,
|
||||
$showMeta,
|
||||
$showCategories,
|
||||
$excerptLength,
|
||||
$readMoreText,
|
||||
$headingLevel,
|
||||
$fallbackImage,
|
||||
$fallbackImageAlt,
|
||||
$imagePosition
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
// Paginacion nativa de WordPress
|
||||
$html .= '<div class="pagination-wrapper">';
|
||||
$html .= $this->buildPaginationHTML();
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
wp_reset_postdata();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function buildCardHTML(
|
||||
bool $showThumbnail,
|
||||
bool $showExcerpt,
|
||||
bool $showMeta,
|
||||
bool $showCategories,
|
||||
int $excerptLength,
|
||||
string $readMoreText,
|
||||
string $headingLevel,
|
||||
string $fallbackImage,
|
||||
string $fallbackImageAlt,
|
||||
string $imagePosition
|
||||
): string {
|
||||
$permalink = get_permalink();
|
||||
$title = get_the_title();
|
||||
|
||||
$html = '<div class="post-card-col">';
|
||||
$html .= sprintf(
|
||||
'<a href="%s" class="text-decoration-none">',
|
||||
esc_url($permalink)
|
||||
);
|
||||
|
||||
$cardClass = 'card h-100';
|
||||
if ($imagePosition === 'left') {
|
||||
$cardClass .= ' flex-row';
|
||||
}
|
||||
|
||||
$html .= sprintf('<div class="%s">', esc_attr($cardClass));
|
||||
|
||||
// Imagen
|
||||
if ($showThumbnail && $imagePosition !== 'none') {
|
||||
$html .= $this->buildImageHTML($fallbackImage, $fallbackImageAlt, $imagePosition);
|
||||
}
|
||||
|
||||
$html .= '<div class="card-body">';
|
||||
|
||||
// Categorias
|
||||
if ($showCategories) {
|
||||
$html .= $this->buildCategoriesHTML();
|
||||
}
|
||||
|
||||
// Titulo
|
||||
$html .= sprintf(
|
||||
'<%s class="card-title">%s</%s>',
|
||||
esc_attr($headingLevel),
|
||||
esc_html($title),
|
||||
esc_attr($headingLevel)
|
||||
);
|
||||
|
||||
// Meta
|
||||
if ($showMeta) {
|
||||
$html .= $this->buildMetaHTML();
|
||||
}
|
||||
|
||||
// Excerpt
|
||||
if ($showExcerpt) {
|
||||
$html .= $this->buildExcerptHTML($excerptLength);
|
||||
}
|
||||
|
||||
$html .= '</div>'; // card-body
|
||||
$html .= '</div>'; // card
|
||||
$html .= '</a>';
|
||||
$html .= '</div>'; // col
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildImageHTML(string $fallbackImage, string $fallbackImageAlt, string $imagePosition): string
|
||||
{
|
||||
if (has_post_thumbnail()) {
|
||||
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
|
||||
return get_the_post_thumbnail(
|
||||
null,
|
||||
'medium_large',
|
||||
['class' => $imageClass, 'loading' => 'lazy']
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($fallbackImage)) {
|
||||
$imageClass = $imagePosition === 'left' ? 'card-img-left' : 'card-img-top';
|
||||
return sprintf(
|
||||
'<img src="%s" alt="%s" class="%s" loading="lazy">',
|
||||
esc_url($fallbackImage),
|
||||
esc_attr($fallbackImageAlt),
|
||||
esc_attr($imageClass)
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function buildCategoriesHTML(): string
|
||||
{
|
||||
$categories = get_the_category();
|
||||
if (empty($categories)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="post-categories mb-2">';
|
||||
foreach (array_slice($categories, 0, 2) as $category) {
|
||||
$html .= sprintf(
|
||||
'<span class="post-category">%s</span>',
|
||||
esc_html($category->name)
|
||||
);
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildMetaHTML(): string
|
||||
{
|
||||
$date = get_the_date();
|
||||
$author = get_the_author();
|
||||
|
||||
return sprintf(
|
||||
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
|
||||
esc_html($date),
|
||||
esc_html($author)
|
||||
);
|
||||
}
|
||||
|
||||
private function buildExcerptHTML(int $length): string
|
||||
{
|
||||
$excerpt = get_the_excerpt();
|
||||
|
||||
if (empty($excerpt)) {
|
||||
$excerpt = wp_trim_words(get_the_content(), $length, '...');
|
||||
} else {
|
||||
$excerpt = wp_trim_words($excerpt, $length, '...');
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<p class="card-text">%s</p>',
|
||||
esc_html($excerpt)
|
||||
);
|
||||
}
|
||||
|
||||
private function buildPaginationHTML(): string
|
||||
{
|
||||
global $wp_query;
|
||||
|
||||
$totalPages = $wp_query->max_num_pages;
|
||||
if ($totalPages <= 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$currentPage = max(1, get_query_var('paged', 1));
|
||||
$html = '<nav aria-label="Paginacion"><ul class="pagination justify-content-center">';
|
||||
|
||||
// Boton Inicio (siempre visible)
|
||||
$html .= sprintf(
|
||||
'<li class="page-item"><a class="page-link" href="%s">Inicio</a></li>',
|
||||
esc_url(get_pagenum_link(1))
|
||||
);
|
||||
|
||||
// Numeros de pagina - mostrar 5 paginas
|
||||
$visiblePages = 5;
|
||||
$start = max(1, $currentPage - 2);
|
||||
$end = min($totalPages, $start + $visiblePages - 1);
|
||||
|
||||
// Ajustar inicio si estamos cerca del final
|
||||
if ($end - $start < $visiblePages - 1) {
|
||||
$start = max(1, $end - $visiblePages + 1);
|
||||
}
|
||||
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i === $currentPage) {
|
||||
$html .= sprintf(
|
||||
'<li class="page-item active"><span class="page-link">%d</span></li>',
|
||||
$i
|
||||
);
|
||||
} else {
|
||||
$html .= sprintf(
|
||||
'<li class="page-item"><a class="page-link" href="%s">%d</a></li>',
|
||||
esc_url(get_pagenum_link($i)),
|
||||
$i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Ver mas (siguiente pagina)
|
||||
if ($currentPage < $totalPages) {
|
||||
$html .= sprintf(
|
||||
'<li class="page-item"><a class="page-link" href="%s">Ver mas</a></li>',
|
||||
esc_url(get_pagenum_link($currentPage + 1))
|
||||
);
|
||||
}
|
||||
|
||||
// Boton Fin (siempre visible)
|
||||
$html .= sprintf(
|
||||
'<li class="page-item"><a class="page-link" href="%s">Fin</a></li>',
|
||||
esc_url(get_pagenum_link($totalPages))
|
||||
);
|
||||
|
||||
$html .= '</ul></nav>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -111,8 +111,23 @@ final class TableOfContentsRenderer implements RendererInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
// Intentar primero con contenido filtrado (respeta shortcodes, etc.)
|
||||
$content = apply_filters('the_content', $post->post_content);
|
||||
|
||||
// Verificar si el contenido filtrado tiene headings
|
||||
$hasFilteredHeadings = preg_match('/<h[2-6][^>]*>/i', $content);
|
||||
|
||||
// FIX: Si el contenido filtrado no tiene headings pero el raw si,
|
||||
// usar el contenido raw. Esto ocurre cuando plugins como Thrive
|
||||
// transforman el contenido para usuarios no logueados.
|
||||
if (!$hasFilteredHeadings) {
|
||||
$hasRawHeadings = preg_match('/<h[2-6][^>]*>/i', $post->post_content);
|
||||
if ($hasRawHeadings) {
|
||||
// Usar wpautop para dar formato basico al contenido raw
|
||||
$content = wpautop($post->post_content);
|
||||
}
|
||||
}
|
||||
|
||||
$dom = new DOMDocument();
|
||||
libxml_use_internal_errors(true);
|
||||
$dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"component_name": "adsense-placement",
|
||||
"version": "1.3.0",
|
||||
"description": "Control de AdSense y Google Analytics - Con Anchor y Vignette Ads",
|
||||
"version": "1.4.0",
|
||||
"description": "Control de AdSense y Google Analytics - Con In-Content Ads Avanzado",
|
||||
"groups": {
|
||||
"visibility": {
|
||||
"label": "Activacion",
|
||||
@@ -113,6 +113,171 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"incontent_advanced": {
|
||||
"label": "In-Content Ads Avanzado",
|
||||
"priority": 69,
|
||||
"fields": {
|
||||
"incontent_mode": {
|
||||
"type": "select",
|
||||
"label": "Estrategia de insercion",
|
||||
"default": "paragraphs_only",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"paragraphs_only": "Solo parrafos (clasico)",
|
||||
"conservative": "Conservador - H2 y parrafos",
|
||||
"balanced": "Balanceado - Multiples elementos",
|
||||
"aggressive": "Intensivo - Todos los elementos",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"description": "Define donde se insertaran los anuncios dentro del contenido del post."
|
||||
},
|
||||
"incontent_after_h2_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de H2",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de encabezados H2"
|
||||
},
|
||||
"incontent_after_h2_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad H2",
|
||||
"default": "100",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"],
|
||||
"description": "Porcentaje de probabilidad de insercion"
|
||||
},
|
||||
"incontent_after_h3_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de H3",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de encabezados H3"
|
||||
},
|
||||
"incontent_after_h3_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad H3",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_paragraphs_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de parrafos",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de parrafos (ubicacion tradicional)"
|
||||
},
|
||||
"incontent_after_paragraphs_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad parrafos",
|
||||
"default": "75",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"],
|
||||
"description": "Porcentaje de probabilidad de insercion despues de parrafos"
|
||||
},
|
||||
"incontent_after_images_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de imagenes",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de figure o img standalone"
|
||||
},
|
||||
"incontent_after_images_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad imagenes",
|
||||
"default": "75",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_lists_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de listas",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de ul/ol (minimo 3 items)"
|
||||
},
|
||||
"incontent_after_lists_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad listas",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_blockquotes_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de blockquotes",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de citas en bloque"
|
||||
},
|
||||
"incontent_after_blockquotes_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad blockquotes",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_tables_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de tablas",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de tablas"
|
||||
},
|
||||
"incontent_after_tables_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad tablas",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_max_total_ads": {
|
||||
"type": "select",
|
||||
"label": "Maximo total de ads",
|
||||
"default": "8",
|
||||
"editable": true,
|
||||
"options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25"],
|
||||
"description": "Cantidad maxima de anuncios in-content por post"
|
||||
},
|
||||
"incontent_min_spacing": {
|
||||
"type": "select",
|
||||
"label": "Espaciado minimo",
|
||||
"default": "3",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"1": "1 elemento",
|
||||
"2": "2 elementos",
|
||||
"3": "3 elementos",
|
||||
"4": "4 elementos",
|
||||
"5": "5 elementos",
|
||||
"6": "6 elementos"
|
||||
},
|
||||
"description": "Minimo de elementos de bloque entre anuncios"
|
||||
},
|
||||
"incontent_format": {
|
||||
"type": "select",
|
||||
"label": "Formato de ads",
|
||||
"default": "in-article",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"in-article": "In-Article (fluid)",
|
||||
"auto": "Auto (responsive)"
|
||||
},
|
||||
"description": "Formato de anuncio para todas las ubicaciones in-content"
|
||||
},
|
||||
"incontent_priority_mode": {
|
||||
"type": "select",
|
||||
"label": "Estrategia de seleccion",
|
||||
"default": "position",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"position": "Por posicion (distribucion uniforme)",
|
||||
"priority": "Por prioridad (maximizar H2/H3)"
|
||||
},
|
||||
"description": "Como resolver conflictos cuando dos ubicaciones estan muy cerca"
|
||||
}
|
||||
}
|
||||
},
|
||||
"behavior": {
|
||||
"label": "Ubicaciones en Posts",
|
||||
"priority": 70,
|
||||
|
||||
233
Schemas/archive-header.json
Normal file
233
Schemas/archive-header.json
Normal file
@@ -0,0 +1,233 @@
|
||||
{
|
||||
"component_name": "archive-header",
|
||||
"version": "1.0.0",
|
||||
"description": "Cabecera dinamica para paginas de archivo con titulo y descripcion contextual",
|
||||
"groups": {
|
||||
"visibility": {
|
||||
"label": "Visibilidad",
|
||||
"priority": 10,
|
||||
"fields": {
|
||||
"is_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Activar componente",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"required": true
|
||||
},
|
||||
"show_on_desktop": {
|
||||
"type": "boolean",
|
||||
"label": "Mostrar en escritorio",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Muestra el componente en pantallas >= 992px"
|
||||
},
|
||||
"show_on_mobile": {
|
||||
"type": "boolean",
|
||||
"label": "Mostrar en movil",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Muestra el componente en pantallas < 992px"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"label": "Contenido",
|
||||
"priority": 20,
|
||||
"fields": {
|
||||
"blog_title": {
|
||||
"type": "text",
|
||||
"label": "Titulo del blog",
|
||||
"default": "Blog",
|
||||
"editable": true,
|
||||
"description": "Titulo mostrado en la pagina principal del blog"
|
||||
},
|
||||
"show_post_count": {
|
||||
"type": "boolean",
|
||||
"label": "Mostrar contador de posts",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Muestra el numero de posts encontrados"
|
||||
},
|
||||
"show_description": {
|
||||
"type": "boolean",
|
||||
"label": "Mostrar descripcion",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Muestra la descripcion de categoria/tag si existe"
|
||||
},
|
||||
"category_prefix": {
|
||||
"type": "text",
|
||||
"label": "Prefijo categoria",
|
||||
"default": "Categoria:",
|
||||
"editable": true
|
||||
},
|
||||
"tag_prefix": {
|
||||
"type": "text",
|
||||
"label": "Prefijo etiqueta",
|
||||
"default": "Etiqueta:",
|
||||
"editable": true
|
||||
},
|
||||
"author_prefix": {
|
||||
"type": "text",
|
||||
"label": "Prefijo autor",
|
||||
"default": "Articulos de:",
|
||||
"editable": true
|
||||
},
|
||||
"date_prefix": {
|
||||
"type": "text",
|
||||
"label": "Prefijo fecha",
|
||||
"default": "Archivo:",
|
||||
"editable": true
|
||||
},
|
||||
"search_prefix": {
|
||||
"type": "text",
|
||||
"label": "Prefijo busqueda",
|
||||
"default": "Resultados para:",
|
||||
"editable": true
|
||||
},
|
||||
"posts_count_singular": {
|
||||
"type": "text",
|
||||
"label": "Texto singular posts",
|
||||
"default": "publicacion",
|
||||
"editable": true
|
||||
},
|
||||
"posts_count_plural": {
|
||||
"type": "text",
|
||||
"label": "Texto plural posts",
|
||||
"default": "publicaciones",
|
||||
"editable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"label": "Tipografia",
|
||||
"priority": 30,
|
||||
"fields": {
|
||||
"heading_level": {
|
||||
"type": "select",
|
||||
"label": "Nivel de encabezado",
|
||||
"default": "h1",
|
||||
"editable": true,
|
||||
"options": ["h1", "h2", "h3", "h4", "h5", "h6"],
|
||||
"description": "Nivel semantico del titulo para SEO"
|
||||
},
|
||||
"title_size": {
|
||||
"type": "text",
|
||||
"label": "Tamano titulo",
|
||||
"default": "2rem",
|
||||
"editable": true
|
||||
},
|
||||
"title_weight": {
|
||||
"type": "text",
|
||||
"label": "Peso titulo",
|
||||
"default": "700",
|
||||
"editable": true
|
||||
},
|
||||
"description_size": {
|
||||
"type": "text",
|
||||
"label": "Tamano descripcion",
|
||||
"default": "1rem",
|
||||
"editable": true
|
||||
},
|
||||
"count_size": {
|
||||
"type": "text",
|
||||
"label": "Tamano contador",
|
||||
"default": "0.875rem",
|
||||
"editable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"label": "Colores",
|
||||
"priority": 40,
|
||||
"fields": {
|
||||
"title_color": {
|
||||
"type": "color",
|
||||
"label": "Color titulo",
|
||||
"default": "#0E2337",
|
||||
"editable": true
|
||||
},
|
||||
"description_color": {
|
||||
"type": "color",
|
||||
"label": "Color descripcion",
|
||||
"default": "#6b7280",
|
||||
"editable": true
|
||||
},
|
||||
"count_bg_color": {
|
||||
"type": "color",
|
||||
"label": "Fondo contador",
|
||||
"default": "#FF8600",
|
||||
"editable": true
|
||||
},
|
||||
"count_text_color": {
|
||||
"type": "color",
|
||||
"label": "Texto contador",
|
||||
"default": "#ffffff",
|
||||
"editable": true
|
||||
},
|
||||
"prefix_color": {
|
||||
"type": "color",
|
||||
"label": "Color prefijo",
|
||||
"default": "#6b7280",
|
||||
"editable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"label": "Espaciado",
|
||||
"priority": 50,
|
||||
"fields": {
|
||||
"margin_top": {
|
||||
"type": "text",
|
||||
"label": "Margen superior",
|
||||
"default": "2rem",
|
||||
"editable": true
|
||||
},
|
||||
"margin_bottom": {
|
||||
"type": "text",
|
||||
"label": "Margen inferior",
|
||||
"default": "2rem",
|
||||
"editable": true
|
||||
},
|
||||
"padding": {
|
||||
"type": "text",
|
||||
"label": "Padding interno",
|
||||
"default": "1.5rem",
|
||||
"editable": true
|
||||
},
|
||||
"title_margin_bottom": {
|
||||
"type": "text",
|
||||
"label": "Margen inferior titulo",
|
||||
"default": "0.5rem",
|
||||
"editable": true
|
||||
},
|
||||
"count_padding": {
|
||||
"type": "text",
|
||||
"label": "Padding contador",
|
||||
"default": "0.25rem 0.75rem",
|
||||
"editable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"behavior": {
|
||||
"label": "Comportamiento",
|
||||
"priority": 70,
|
||||
"fields": {
|
||||
"is_sticky": {
|
||||
"type": "boolean",
|
||||
"label": "Header fijo",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Mantiene el header visible al hacer scroll"
|
||||
},
|
||||
"sticky_offset": {
|
||||
"type": "text",
|
||||
"label": "Offset sticky",
|
||||
"default": "0",
|
||||
"editable": true,
|
||||
"description": "Distancia desde el top cuando es sticky"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
271
Schemas/post-grid.json
Normal file
271
Schemas/post-grid.json
Normal file
@@ -0,0 +1,271 @@
|
||||
{
|
||||
"component_name": "post-grid",
|
||||
"version": "1.0.0",
|
||||
"description": "Grid de posts para templates de listados usando el loop principal de WordPress",
|
||||
"groups": {
|
||||
"visibility": {
|
||||
"label": "Visibilidad",
|
||||
"priority": 10,
|
||||
"fields": {
|
||||
"is_enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"label": "Habilitar componente"
|
||||
},
|
||||
"show_on_desktop": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"label": "Mostrar en desktop"
|
||||
},
|
||||
"show_on_mobile": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"label": "Mostrar en movil"
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"label": "Contenido",
|
||||
"priority": 20,
|
||||
"fields": {
|
||||
"show_thumbnail": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"label": "Mostrar imagen destacada"
|
||||
},
|
||||
"show_excerpt": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"label": "Mostrar extracto"
|
||||
},
|
||||
"show_meta": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"label": "Mostrar metadatos (fecha, autor)"
|
||||
},
|
||||
"show_categories": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"label": "Mostrar categorias"
|
||||
},
|
||||
"excerpt_length": {
|
||||
"type": "select",
|
||||
"default": "20",
|
||||
"label": "Longitud del extracto (palabras)",
|
||||
"options": ["10", "15", "20", "25", "30"]
|
||||
},
|
||||
"read_more_text": {
|
||||
"type": "text",
|
||||
"default": "Leer mas",
|
||||
"label": "Texto de leer mas"
|
||||
},
|
||||
"no_posts_message": {
|
||||
"type": "text",
|
||||
"default": "No se encontraron publicaciones",
|
||||
"label": "Mensaje cuando no hay posts"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"label": "Tipografia",
|
||||
"priority": 30,
|
||||
"fields": {
|
||||
"heading_level": {
|
||||
"type": "select",
|
||||
"default": "h3",
|
||||
"label": "Nivel de encabezado de tarjetas",
|
||||
"options": ["h2", "h3", "h4", "h5", "h6"]
|
||||
},
|
||||
"card_title_size": {
|
||||
"type": "text",
|
||||
"default": "1.1rem",
|
||||
"label": "Tamano titulo de tarjeta"
|
||||
},
|
||||
"card_title_weight": {
|
||||
"type": "text",
|
||||
"default": "600",
|
||||
"label": "Peso titulo de tarjeta"
|
||||
},
|
||||
"excerpt_size": {
|
||||
"type": "text",
|
||||
"default": "0.9rem",
|
||||
"label": "Tamano de extracto"
|
||||
},
|
||||
"meta_size": {
|
||||
"type": "text",
|
||||
"default": "0.8rem",
|
||||
"label": "Tamano de metadatos"
|
||||
}
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"label": "Colores",
|
||||
"priority": 40,
|
||||
"fields": {
|
||||
"card_bg_color": {
|
||||
"type": "color",
|
||||
"default": "#ffffff",
|
||||
"label": "Fondo de tarjeta"
|
||||
},
|
||||
"card_title_color": {
|
||||
"type": "color",
|
||||
"default": "#0E2337",
|
||||
"label": "Color titulo de tarjeta"
|
||||
},
|
||||
"card_hover_bg_color": {
|
||||
"type": "color",
|
||||
"default": "#f9fafb",
|
||||
"label": "Fondo hover de tarjeta"
|
||||
},
|
||||
"card_border_color": {
|
||||
"type": "color",
|
||||
"default": "#e5e7eb",
|
||||
"label": "Color borde de tarjeta"
|
||||
},
|
||||
"card_hover_border_color": {
|
||||
"type": "color",
|
||||
"default": "#FF8600",
|
||||
"label": "Color borde hover"
|
||||
},
|
||||
"excerpt_color": {
|
||||
"type": "color",
|
||||
"default": "#6b7280",
|
||||
"label": "Color de extracto"
|
||||
},
|
||||
"meta_color": {
|
||||
"type": "color",
|
||||
"default": "#9ca3af",
|
||||
"label": "Color de metadatos"
|
||||
},
|
||||
"category_bg_color": {
|
||||
"type": "color",
|
||||
"default": "#FFF5EB",
|
||||
"label": "Fondo de categoria"
|
||||
},
|
||||
"category_text_color": {
|
||||
"type": "color",
|
||||
"default": "#FF8600",
|
||||
"label": "Color texto categoria"
|
||||
},
|
||||
"pagination_color": {
|
||||
"type": "color",
|
||||
"default": "#0E2337",
|
||||
"label": "Color de paginacion"
|
||||
},
|
||||
"pagination_active_bg": {
|
||||
"type": "color",
|
||||
"default": "#FF8600",
|
||||
"label": "Fondo paginacion activa"
|
||||
},
|
||||
"pagination_active_color": {
|
||||
"type": "color",
|
||||
"default": "#ffffff",
|
||||
"label": "Color texto paginacion activa"
|
||||
}
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"label": "Espaciado",
|
||||
"priority": 50,
|
||||
"fields": {
|
||||
"grid_gap": {
|
||||
"type": "text",
|
||||
"default": "1.5rem",
|
||||
"label": "Espacio entre tarjetas"
|
||||
},
|
||||
"card_padding": {
|
||||
"type": "text",
|
||||
"default": "1.25rem",
|
||||
"label": "Padding interno de tarjeta"
|
||||
},
|
||||
"section_margin_top": {
|
||||
"type": "text",
|
||||
"default": "0",
|
||||
"label": "Margen superior de seccion"
|
||||
},
|
||||
"section_margin_bottom": {
|
||||
"type": "text",
|
||||
"default": "2rem",
|
||||
"label": "Margen inferior de seccion"
|
||||
}
|
||||
}
|
||||
},
|
||||
"visual_effects": {
|
||||
"label": "Efectos Visuales",
|
||||
"priority": 60,
|
||||
"fields": {
|
||||
"card_border_radius": {
|
||||
"type": "text",
|
||||
"default": "0.5rem",
|
||||
"label": "Radio de borde de tarjeta"
|
||||
},
|
||||
"card_shadow": {
|
||||
"type": "text",
|
||||
"default": "0 1px 3px rgba(0,0,0,0.1)",
|
||||
"label": "Sombra de tarjeta"
|
||||
},
|
||||
"card_hover_shadow": {
|
||||
"type": "text",
|
||||
"default": "0 4px 12px rgba(0,0,0,0.15)",
|
||||
"label": "Sombra hover de tarjeta"
|
||||
},
|
||||
"card_transition": {
|
||||
"type": "text",
|
||||
"default": "all 0.3s ease",
|
||||
"label": "Transicion de tarjeta"
|
||||
},
|
||||
"image_border_radius": {
|
||||
"type": "text",
|
||||
"default": "0.375rem",
|
||||
"label": "Radio de borde de imagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"label": "Disposicion",
|
||||
"priority": 80,
|
||||
"fields": {
|
||||
"columns_desktop": {
|
||||
"type": "select",
|
||||
"default": "3",
|
||||
"label": "Columnas en desktop",
|
||||
"options": ["2", "3", "4"]
|
||||
},
|
||||
"columns_tablet": {
|
||||
"type": "select",
|
||||
"default": "2",
|
||||
"label": "Columnas en tablet",
|
||||
"options": ["1", "2", "3"]
|
||||
},
|
||||
"columns_mobile": {
|
||||
"type": "select",
|
||||
"default": "1",
|
||||
"label": "Columnas en movil",
|
||||
"options": ["1", "2"]
|
||||
},
|
||||
"image_position": {
|
||||
"type": "select",
|
||||
"default": "top",
|
||||
"label": "Posicion de imagen",
|
||||
"options": ["top", "left", "none"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"media": {
|
||||
"label": "Medios",
|
||||
"priority": 90,
|
||||
"fields": {
|
||||
"fallback_image": {
|
||||
"type": "url",
|
||||
"default": "",
|
||||
"label": "Imagen por defecto (URL)"
|
||||
},
|
||||
"fallback_image_alt": {
|
||||
"type": "text",
|
||||
"default": "Imagen por defecto",
|
||||
"label": "Texto alternativo imagen por defecto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -22,15 +22,15 @@ final class EvaluatePageVisibilityUseCase
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evalúa si el componente debe mostrarse en la página actual
|
||||
* Evalua si el componente debe mostrarse en la pagina actual
|
||||
*/
|
||||
public function execute(string $componentName): bool
|
||||
{
|
||||
$config = $this->visibilityRepository->getVisibilityConfig($componentName);
|
||||
|
||||
if (empty($config)) {
|
||||
// Usar constante compartida (DRY)
|
||||
$config = VisibilityDefaults::DEFAULT_VISIBILITY;
|
||||
// Usar defaults especificos por componente si existen
|
||||
$config = VisibilityDefaults::getForComponent($componentName);
|
||||
}
|
||||
|
||||
$pageType = $this->pageTypeDetector->detect();
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\UseCases\RenderPostGrid;
|
||||
|
||||
/**
|
||||
* RenderPostGridRequest - DTO de entrada para renderizar post grid shortcode
|
||||
*
|
||||
* RESPONSABILIDAD: Encapsular todos los atributos del shortcode [roi_post_grid]
|
||||
*
|
||||
* USO:
|
||||
* ```php
|
||||
* $request = RenderPostGridRequest::fromArray([
|
||||
* 'category' => 'precios-unitarios',
|
||||
* 'posts_per_page' => 6,
|
||||
* 'columns' => 3
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @package ROITheme\Shared\Application\UseCases\RenderPostGrid
|
||||
*/
|
||||
final readonly class RenderPostGridRequest
|
||||
{
|
||||
public function __construct(
|
||||
public string $category = '',
|
||||
public string $excludeCategory = '',
|
||||
public string $tag = '',
|
||||
public string $author = '',
|
||||
public int $postsPerPage = 9,
|
||||
public int $columns = 3,
|
||||
public string $orderby = 'date',
|
||||
public string $order = 'DESC',
|
||||
public bool $showPagination = false,
|
||||
public int $offset = 0,
|
||||
public string $excludePosts = '',
|
||||
public bool $showThumbnail = true,
|
||||
public bool $showExcerpt = true,
|
||||
public bool $showMeta = true,
|
||||
public bool $showCategories = true,
|
||||
public int $excerptLength = 20,
|
||||
public string $class = '',
|
||||
public string $id = '',
|
||||
public int $paged = 1
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Factory method: Crear desde array de atributos del shortcode
|
||||
*
|
||||
* @param array<string, mixed> $atts Atributos sanitizados del shortcode
|
||||
* @return self
|
||||
*/
|
||||
public static function fromArray(array $atts): self
|
||||
{
|
||||
return new self(
|
||||
category: (string) ($atts['category'] ?? ''),
|
||||
excludeCategory: (string) ($atts['exclude_category'] ?? ''),
|
||||
tag: (string) ($atts['tag'] ?? ''),
|
||||
author: (string) ($atts['author'] ?? ''),
|
||||
postsPerPage: self::clampInt((int) ($atts['posts_per_page'] ?? 9), 1, 50),
|
||||
columns: self::clampInt((int) ($atts['columns'] ?? 3), 1, 4),
|
||||
orderby: self::sanitizeOrderby((string) ($atts['orderby'] ?? 'date')),
|
||||
order: self::sanitizeOrder((string) ($atts['order'] ?? 'DESC')),
|
||||
showPagination: self::toBool($atts['show_pagination'] ?? false),
|
||||
offset: max(0, (int) ($atts['offset'] ?? 0)),
|
||||
excludePosts: (string) ($atts['exclude_posts'] ?? ''),
|
||||
showThumbnail: self::toBool($atts['show_thumbnail'] ?? true),
|
||||
showExcerpt: self::toBool($atts['show_excerpt'] ?? true),
|
||||
showMeta: self::toBool($atts['show_meta'] ?? true),
|
||||
showCategories: self::toBool($atts['show_categories'] ?? true),
|
||||
excerptLength: max(1, (int) ($atts['excerpt_length'] ?? 20)),
|
||||
class: (string) ($atts['class'] ?? ''),
|
||||
id: (string) ($atts['id'] ?? ''),
|
||||
paged: max(1, (int) ($atts['paged'] ?? 1))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir a array de parametros para QueryBuilder
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toQueryParams(): array
|
||||
{
|
||||
return [
|
||||
'category' => $this->category,
|
||||
'exclude_category' => $this->excludeCategory,
|
||||
'tag' => $this->tag,
|
||||
'author' => $this->author,
|
||||
'posts_per_page' => $this->postsPerPage,
|
||||
'orderby' => $this->orderby,
|
||||
'order' => $this->order,
|
||||
'offset' => $this->offset,
|
||||
'exclude_posts' => $this->excludePosts,
|
||||
'paged' => $this->paged,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertir a array de opciones para Renderer
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toRenderOptions(): array
|
||||
{
|
||||
return [
|
||||
'columns' => $this->columns,
|
||||
'show_thumbnail' => $this->showThumbnail,
|
||||
'show_excerpt' => $this->showExcerpt,
|
||||
'show_meta' => $this->showMeta,
|
||||
'show_categories' => $this->showCategories,
|
||||
'excerpt_length' => $this->excerptLength,
|
||||
'class' => $this->class,
|
||||
'id' => $this->id,
|
||||
'show_pagination' => $this->showPagination,
|
||||
'posts_per_page' => $this->postsPerPage,
|
||||
];
|
||||
}
|
||||
|
||||
private static function clampInt(int $value, int $min, int $max): int
|
||||
{
|
||||
return max($min, min($max, $value));
|
||||
}
|
||||
|
||||
private static function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
return $value === 'true' || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private static function sanitizeOrderby(string $value): string
|
||||
{
|
||||
$allowed = ['date', 'title', 'modified', 'rand', 'comment_count'];
|
||||
return in_array($value, $allowed, true) ? $value : 'date';
|
||||
}
|
||||
|
||||
private static function sanitizeOrder(string $value): string
|
||||
{
|
||||
$upper = strtoupper($value);
|
||||
return in_array($upper, ['ASC', 'DESC'], true) ? $upper : 'DESC';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\UseCases\RenderPostGrid;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
|
||||
use ROITheme\Shared\Domain\Constants\VisibilityDefaults;
|
||||
|
||||
/**
|
||||
* Caso de uso: Renderizar grid de posts para shortcode
|
||||
*
|
||||
* RESPONSABILIDAD: Orquestar la obtencion de settings, construccion de query
|
||||
* y renderizado del grid. No contiene logica de negocio, solo coordinacion.
|
||||
*
|
||||
* @package ROITheme\Shared\Application\UseCases\RenderPostGrid
|
||||
*/
|
||||
final class RenderPostGridUseCase
|
||||
{
|
||||
private const COMPONENT_NAME = 'post-grid';
|
||||
|
||||
public function __construct(
|
||||
private readonly PostGridQueryBuilderInterface $queryBuilder,
|
||||
private readonly PostGridShortcodeRendererInterface $renderer,
|
||||
private readonly ComponentSettingsRepositoryInterface $settingsRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ejecuta el caso de uso: obtiene settings, construye query y renderiza
|
||||
*
|
||||
* @param RenderPostGridRequest $request DTO con atributos del shortcode
|
||||
* @return string HTML del grid renderizado
|
||||
*/
|
||||
public function execute(RenderPostGridRequest $request): string
|
||||
{
|
||||
// 1. Obtener settings del componente post-grid desde BD
|
||||
$settings = $this->getSettings();
|
||||
|
||||
// 2. Construir query con los parametros del shortcode
|
||||
$query = $this->queryBuilder->build($request->toQueryParams());
|
||||
|
||||
// 3. Renderizar grid con query, settings y opciones
|
||||
$html = $this->renderer->render(
|
||||
$query,
|
||||
$settings,
|
||||
$request->toRenderOptions()
|
||||
);
|
||||
|
||||
// 4. Limpiar query (importante para evitar conflictos)
|
||||
wp_reset_postdata();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene settings del componente post-grid
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getSettings(): array
|
||||
{
|
||||
$settings = $this->settingsRepository->getComponentSettings(self::COMPONENT_NAME);
|
||||
|
||||
if (empty($settings)) {
|
||||
return VisibilityDefaults::getForComponent(self::COMPONENT_NAME);
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,13 @@ namespace ROITheme\Shared\Domain\Constants;
|
||||
final class VisibilityDefaults
|
||||
{
|
||||
/**
|
||||
* Configuración de visibilidad por defecto para nuevos componentes
|
||||
* Configuracion de visibilidad por defecto para componentes generales
|
||||
*
|
||||
* - Home: SÍ mostrar (página principal)
|
||||
* - Posts: SÍ mostrar (artículos del blog)
|
||||
* - Pages: SÍ mostrar (páginas estáticas)
|
||||
* - Archives: NO mostrar (listados de categorías/tags)
|
||||
* - Search: NO mostrar (resultados de búsqueda)
|
||||
* - Home: SI mostrar (pagina principal)
|
||||
* - Posts: SI mostrar (articulos del blog)
|
||||
* - Pages: SI mostrar (paginas estaticas)
|
||||
* - Archives: NO mostrar (listados de categorias/tags)
|
||||
* - Search: NO mostrar (resultados de busqueda)
|
||||
*/
|
||||
public const DEFAULT_VISIBILITY = [
|
||||
'show_on_home' => true,
|
||||
@@ -33,7 +33,39 @@ final class VisibilityDefaults
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista de campos de visibilidad válidos
|
||||
* Defaults especificos por componente (sobrescriben DEFAULT_VISIBILITY)
|
||||
*
|
||||
* Componentes de listados:
|
||||
* - archive-header: Solo en archives (home para blog title)
|
||||
* - post-grid: En home, archives y search
|
||||
* - cta-box-sidebar: Tambien en archives
|
||||
*/
|
||||
public const COMPONENT_VISIBILITY = [
|
||||
'archive-header' => [
|
||||
'show_on_home' => true, // Para mostrar blog_title
|
||||
'show_on_posts' => false,
|
||||
'show_on_pages' => false,
|
||||
'show_on_archives' => true, // Proposito principal
|
||||
'show_on_search' => true, // Mostrar "Resultados: X"
|
||||
],
|
||||
'post-grid' => [
|
||||
'show_on_home' => true, // Blog principal
|
||||
'show_on_posts' => false,
|
||||
'show_on_pages' => false,
|
||||
'show_on_archives' => true, // Listados de categoria/tag
|
||||
'show_on_search' => true, // Resultados de busqueda
|
||||
],
|
||||
'cta-box-sidebar' => [
|
||||
'show_on_home' => true,
|
||||
'show_on_posts' => true,
|
||||
'show_on_pages' => true,
|
||||
'show_on_archives' => true, // Visible en archives
|
||||
'show_on_search' => false,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista de campos de visibilidad validos
|
||||
*/
|
||||
public const VISIBILITY_FIELDS = [
|
||||
'show_on_home',
|
||||
@@ -42,4 +74,15 @@ final class VisibilityDefaults
|
||||
'show_on_archives',
|
||||
'show_on_search',
|
||||
];
|
||||
|
||||
/**
|
||||
* Obtiene los defaults para un componente especifico
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @return array<string, bool> Configuracion de visibilidad
|
||||
*/
|
||||
public static function getForComponent(string $componentName): array
|
||||
{
|
||||
return self::COMPONENT_VISIBILITY[$componentName] ?? self::DEFAULT_VISIBILITY;
|
||||
}
|
||||
}
|
||||
|
||||
51
Shared/Domain/Contracts/PostGridQueryBuilderInterface.php
Normal file
51
Shared/Domain/Contracts/PostGridQueryBuilderInterface.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Interface PostGridQueryBuilderInterface
|
||||
*
|
||||
* Contrato para construccion de queries de posts para el shortcode post-grid.
|
||||
* Define el comportamiento esperado para construir WP_Query sin depender
|
||||
* de implementaciones especificas.
|
||||
*
|
||||
* Responsabilidades:
|
||||
* - Construir WP_Query a partir de parametros de filtro
|
||||
* - Aplicar filtros por categoria, tag, autor
|
||||
* - Configurar paginacion y ordenamiento
|
||||
*
|
||||
* NO responsable de:
|
||||
* - Generar HTML
|
||||
* - Generar CSS
|
||||
* - Obtener settings de BD
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface PostGridQueryBuilderInterface
|
||||
{
|
||||
/**
|
||||
* Construye un WP_Query configurado con los parametros proporcionados.
|
||||
*
|
||||
* Ejemplo:
|
||||
* ```php
|
||||
* $params = [
|
||||
* 'category' => 'precios-unitarios',
|
||||
* 'tag' => 'concreto',
|
||||
* 'posts_per_page' => 6,
|
||||
* 'orderby' => 'date',
|
||||
* 'order' => 'DESC'
|
||||
* ];
|
||||
*
|
||||
* $query = $builder->build($params);
|
||||
* ```
|
||||
*
|
||||
* @param array<string, mixed> $params Parametros de filtro y configuracion
|
||||
* Keys soportadas: category, exclude_category, tag,
|
||||
* author, posts_per_page, orderby, order, offset,
|
||||
* exclude_posts, paged
|
||||
*
|
||||
* @return \WP_Query Query configurado listo para iterar
|
||||
*/
|
||||
public function build(array $params): \WP_Query;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Interface PostGridShortcodeRendererInterface
|
||||
*
|
||||
* Contrato para renderizado HTML del shortcode post-grid.
|
||||
* Define el comportamiento esperado para generar el HTML del grid
|
||||
* sin depender de implementaciones especificas.
|
||||
*
|
||||
* Responsabilidades:
|
||||
* - Generar HTML del grid de posts
|
||||
* - Generar CSS inline usando CSSGeneratorInterface
|
||||
* - Aplicar clases responsive de Bootstrap
|
||||
*
|
||||
* NO responsable de:
|
||||
* - Construir queries
|
||||
* - Obtener settings de BD
|
||||
* - Sanitizar atributos del shortcode
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface PostGridShortcodeRendererInterface
|
||||
{
|
||||
/**
|
||||
* Renderiza el grid de posts como HTML.
|
||||
*
|
||||
* Ejemplo:
|
||||
* ```php
|
||||
* $html = $renderer->render($query, $settings, [
|
||||
* 'columns' => 3,
|
||||
* 'show_thumbnail' => true,
|
||||
* 'show_excerpt' => true,
|
||||
* 'id' => 'grid-cursos'
|
||||
* ]);
|
||||
* ```
|
||||
*
|
||||
* @param \WP_Query $query Query con los posts a mostrar
|
||||
* @param array<string, mixed> $settings Settings del componente post-grid desde BD
|
||||
* @param array<string, mixed> $options Opciones de visualizacion del shortcode
|
||||
* Keys: columns, show_thumbnail, show_excerpt,
|
||||
* show_meta, show_categories, excerpt_length,
|
||||
* class, id, show_pagination
|
||||
*
|
||||
* @return string HTML completo del grid incluyendo CSS inline
|
||||
*/
|
||||
public function render(\WP_Query $query, array $settings, array $options): string;
|
||||
}
|
||||
@@ -42,6 +42,12 @@ use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
|
||||
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressComponentVisibilityRepository;
|
||||
use ROITheme\Shared\Application\UseCases\CheckWrapperVisibilityUseCase;
|
||||
use ROITheme\Shared\Infrastructure\Wordpress\BodyClassHooksRegistrar;
|
||||
// Post Grid Shortcode (Plan post-grid-shortcode)
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
|
||||
use ROITheme\Shared\Infrastructure\Query\PostGridQueryBuilder;
|
||||
use ROITheme\Shared\Infrastructure\Ui\PostGridShortcodeRenderer;
|
||||
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridUseCase;
|
||||
|
||||
/**
|
||||
* DIContainer - Contenedor de Inyección de Dependencias
|
||||
@@ -493,4 +499,47 @@ final class DIContainer
|
||||
}
|
||||
return $this->instances['bodyClassHooksRegistrar'];
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Post Grid Shortcode System
|
||||
// ===============================
|
||||
|
||||
/**
|
||||
* Obtiene el query builder para post grid shortcode
|
||||
*/
|
||||
public function getPostGridQueryBuilder(): PostGridQueryBuilderInterface
|
||||
{
|
||||
if (!isset($this->instances['postGridQueryBuilder'])) {
|
||||
$this->instances['postGridQueryBuilder'] = new PostGridQueryBuilder();
|
||||
}
|
||||
return $this->instances['postGridQueryBuilder'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el renderer para post grid shortcode
|
||||
*/
|
||||
public function getPostGridShortcodeRenderer(): PostGridShortcodeRendererInterface
|
||||
{
|
||||
if (!isset($this->instances['postGridShortcodeRenderer'])) {
|
||||
$this->instances['postGridShortcodeRenderer'] = new PostGridShortcodeRenderer(
|
||||
$this->getCSSGeneratorService()
|
||||
);
|
||||
}
|
||||
return $this->instances['postGridShortcodeRenderer'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el caso de uso para renderizar post grid shortcode
|
||||
*/
|
||||
public function getRenderPostGridUseCase(): RenderPostGridUseCase
|
||||
{
|
||||
if (!isset($this->instances['renderPostGridUseCase'])) {
|
||||
$this->instances['renderPostGridUseCase'] = new RenderPostGridUseCase(
|
||||
$this->getPostGridQueryBuilder(),
|
||||
$this->getPostGridShortcodeRenderer(),
|
||||
$this->getComponentSettingsRepository()
|
||||
);
|
||||
}
|
||||
return $this->instances['renderPostGridUseCase'];
|
||||
}
|
||||
}
|
||||
|
||||
82
Shared/Infrastructure/Hooks/CacheFirstHooksRegistrar.php
Normal file
82
Shared/Infrastructure/Hooks/CacheFirstHooksRegistrar.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Hooks;
|
||||
|
||||
/**
|
||||
* Registra hooks para arquitectura cache-first.
|
||||
*
|
||||
* Permite que plugins externos evalúen condiciones ANTES de servir páginas,
|
||||
* sin bloquear el cache de WordPress.
|
||||
*
|
||||
* @see openspec/specs/cache-first-architecture/spec.md
|
||||
* @package ROITheme\Shared\Infrastructure\Hooks
|
||||
*/
|
||||
final class CacheFirstHooksRegistrar
|
||||
{
|
||||
/**
|
||||
* Registra los hooks de cache-first.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
add_action('template_redirect', [$this, 'fireBeforePageServe'], 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara hook para que plugins externos evalúen acceso.
|
||||
*
|
||||
* Solo se dispara para:
|
||||
* - Páginas singulares (posts, pages, CPTs)
|
||||
* - Visitantes NO logueados (cache no aplica a usuarios logueados)
|
||||
*
|
||||
* Los plugins pueden llamar wp_safe_redirect() + exit para bloquear.
|
||||
* Si no hacen nada, la página se sirve normalmente (con cache si disponible).
|
||||
*/
|
||||
public function fireBeforePageServe(): void
|
||||
{
|
||||
// No para usuarios logueados (cache no aplica, no tiene sentido evaluar)
|
||||
if (is_user_logged_in()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Solo páginas singulares
|
||||
if (!is_singular()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No en admin/ajax/cron/REST
|
||||
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defined('REST_REQUEST') && REST_REQUEST) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_id = get_queried_object_id();
|
||||
|
||||
if ($post_id > 0) {
|
||||
/**
|
||||
* Hook: roi_theme_before_page_serve
|
||||
*
|
||||
* Permite que plugins externos evalúen condiciones antes de servir página.
|
||||
*
|
||||
* Uso típico:
|
||||
* - Rate limiters (límite de vistas por IP)
|
||||
* - Membership plugins (verificar acceso)
|
||||
* - Geolocation restrictions
|
||||
*
|
||||
* Para bloquear acceso:
|
||||
* wp_safe_redirect('/pagina-destino/', 302);
|
||||
* exit;
|
||||
*
|
||||
* Para permitir acceso:
|
||||
* return; // La página se servirá (con cache si disponible)
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @param int $post_id ID del post/page que se va a servir
|
||||
*/
|
||||
do_action('roi_theme_before_page_serve', $post_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,15 +61,35 @@ final class WordPressComponentVisibilityRepository implements WrapperVisibilityC
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Delega a PageVisibilityHelper que ya implementa:
|
||||
* Evalúa múltiples criterios de exclusión:
|
||||
* - hide_for_logged_in: Ocultar para usuarios logueados
|
||||
* - Visibilidad por tipo de página (home, posts, pages, archives, search)
|
||||
* - Exclusiones por categoría, post ID, URL pattern
|
||||
*/
|
||||
public function isNotExcluded(string $componentName): bool
|
||||
{
|
||||
// Verificar hide_for_logged_in
|
||||
if ($this->shouldHideForLoggedIn($componentName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PageVisibilityHelper::shouldShow($componentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si debe ocultarse para usuarios logueados
|
||||
*/
|
||||
private function shouldHideForLoggedIn(string $componentName): bool
|
||||
{
|
||||
$value = $this->getVisibilityAttribute($componentName, 'hide_for_logged_in');
|
||||
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->toBool($value) && is_user_logged_in();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un atributo del grupo visibility desde la BD
|
||||
*
|
||||
|
||||
109
Shared/Infrastructure/Query/PostGridQueryBuilder.php
Normal file
109
Shared/Infrastructure/Query/PostGridQueryBuilder.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Query;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridQueryBuilderInterface;
|
||||
|
||||
/**
|
||||
* Implementacion de PostGridQueryBuilderInterface
|
||||
*
|
||||
* RESPONSABILIDAD: Construir WP_Query a partir de parametros de filtro.
|
||||
* No genera HTML ni obtiene settings.
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Query
|
||||
*/
|
||||
final class PostGridQueryBuilder implements PostGridQueryBuilderInterface
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function build(array $params): \WP_Query
|
||||
{
|
||||
$args = [
|
||||
'post_type' => 'post',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => (int) ($params['posts_per_page'] ?? 9),
|
||||
'orderby' => $params['orderby'] ?? 'date',
|
||||
'order' => $params['order'] ?? 'DESC',
|
||||
'paged' => (int) ($params['paged'] ?? 1),
|
||||
];
|
||||
|
||||
// Offset
|
||||
if (!empty($params['offset'])) {
|
||||
$args['offset'] = (int) $params['offset'];
|
||||
}
|
||||
|
||||
// Filtro por categoria(s)
|
||||
if (!empty($params['category'])) {
|
||||
$args['category_name'] = $this->sanitizeSlugs($params['category']);
|
||||
}
|
||||
|
||||
// Excluir categoria(s)
|
||||
if (!empty($params['exclude_category'])) {
|
||||
$excludeIds = $this->getCategoryIds($params['exclude_category']);
|
||||
if (!empty($excludeIds)) {
|
||||
$args['category__not_in'] = $excludeIds;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtro por tag(s)
|
||||
if (!empty($params['tag'])) {
|
||||
$args['tag'] = $this->sanitizeSlugs($params['tag']);
|
||||
}
|
||||
|
||||
// Filtro por autor
|
||||
if (!empty($params['author'])) {
|
||||
$author = $params['author'];
|
||||
if (is_numeric($author)) {
|
||||
$args['author'] = (int) $author;
|
||||
} else {
|
||||
$user = get_user_by('login', $author);
|
||||
if ($user) {
|
||||
$args['author'] = $user->ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Excluir posts por ID
|
||||
if (!empty($params['exclude_posts'])) {
|
||||
$excludeIds = array_map('intval', explode(',', $params['exclude_posts']));
|
||||
$excludeIds = array_filter($excludeIds, fn($id) => $id > 0);
|
||||
if (!empty($excludeIds)) {
|
||||
$args['post__not_in'] = $excludeIds;
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Query($args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiza slugs separados por coma
|
||||
*/
|
||||
private function sanitizeSlugs(string $slugs): string
|
||||
{
|
||||
$parts = explode(',', $slugs);
|
||||
$sanitized = array_map('sanitize_title', $parts);
|
||||
return implode(',', $sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene IDs de categorias desde slugs
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function getCategoryIds(string $slugs): array
|
||||
{
|
||||
$parts = explode(',', $slugs);
|
||||
$ids = [];
|
||||
|
||||
foreach ($parts as $slug) {
|
||||
$term = get_term_by('slug', trim($slug), 'category');
|
||||
if ($term instanceof \WP_Term) {
|
||||
$ids[] = $term->term_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Busca casos variados de problemas de listas para validación exhaustiva
|
||||
*/
|
||||
|
||||
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
|
||||
$conn->set_charset("utf8mb4");
|
||||
|
||||
function detectIssues($html) {
|
||||
$issues = [];
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument("1.0", "UTF-8");
|
||||
$wrapped = '<div id="wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ["li", "script", "template"];
|
||||
foreach (["ul", "ol"] as $tag) {
|
||||
foreach ($doc->getElementsByTagName($tag) as $list) {
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$childTag = strtolower($child->nodeName);
|
||||
if (!in_array($childTag, $validChildren)) {
|
||||
$issues[] = ["parent" => $tag, "child" => $childTag];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $issues;
|
||||
}
|
||||
|
||||
echo "BUSCANDO CASOS VARIADOS...\n\n";
|
||||
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE html IS NOT NULL AND html != '' ORDER BY id";
|
||||
$result = $conn->query($query);
|
||||
|
||||
if (!$result) {
|
||||
die("Error en query: " . $conn->error);
|
||||
}
|
||||
|
||||
$cases = [
|
||||
"many_issues" => [],
|
||||
"ol_issues" => [],
|
||||
"mixed_issues" => [],
|
||||
"few_issues" => []
|
||||
];
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row["html"]);
|
||||
if (empty($issues)) continue;
|
||||
|
||||
$count = count($issues);
|
||||
$hasOl = false;
|
||||
$hasUl = false;
|
||||
|
||||
foreach ($issues as $issue) {
|
||||
if ($issue["parent"] === "ol") $hasOl = true;
|
||||
if ($issue["parent"] === "ul") $hasUl = true;
|
||||
}
|
||||
|
||||
if ($count > 10 && count($cases["many_issues"]) < 3) {
|
||||
$cases["many_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
|
||||
}
|
||||
if ($hasOl && !$hasUl && count($cases["ol_issues"]) < 3) {
|
||||
$cases["ol_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
|
||||
}
|
||||
if ($hasOl && $hasUl && count($cases["mixed_issues"]) < 3) {
|
||||
$cases["mixed_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
|
||||
}
|
||||
if ($count <= 2 && count($cases["few_issues"]) < 3) {
|
||||
$cases["few_issues"][] = ["id" => $row["id"], "url" => $row["page"], "count" => $count, "issues" => $issues];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($cases as $type => $posts) {
|
||||
echo "=== " . strtoupper($type) . " ===\n";
|
||||
if (empty($posts)) {
|
||||
echo " (ninguno encontrado)\n\n";
|
||||
continue;
|
||||
}
|
||||
foreach ($posts as $post) {
|
||||
echo "ID: {$post["id"]} - {$post["count"]} problemas\n";
|
||||
echo "URL: {$post["url"]}\n";
|
||||
echo "Tipos: ";
|
||||
$types = [];
|
||||
foreach ($post["issues"] as $i) {
|
||||
$types[] = "<{$i["parent"]}> contiene <{$i["child"]}>";
|
||||
}
|
||||
echo implode(", ", array_unique($types)) . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
@@ -1,411 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Corrector de Listas HTML Mal Formadas usando DOMDocument
|
||||
*
|
||||
* PROPÓSITO: Detectar y corregir listas con estructura inválida
|
||||
* - <ul>/<ol> conteniendo elementos no-<li> como hijos directos
|
||||
* - Listas anidadas que son hermanas en lugar de hijas de <li>
|
||||
*
|
||||
* USO:
|
||||
* php fix-malformed-lists-dom.php --mode=scan # Solo escanear
|
||||
* php fix-malformed-lists-dom.php --mode=test # Probar corrección (1 post)
|
||||
* php fix-malformed-lists-dom.php --mode=fix # Aplicar correcciones
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since Phase 4.4 Accessibility
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '512M');
|
||||
set_time_limit(600);
|
||||
|
||||
// Configuración
|
||||
$db_config = [
|
||||
'host' => 'localhost',
|
||||
'database' => 'preciosunitarios_seo',
|
||||
'username' => 'preciosunitarios_seo',
|
||||
'password' => 'ACl%EEFd=V-Yvb??',
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
// Parsear argumentos
|
||||
$mode = 'scan';
|
||||
foreach ($argv as $arg) {
|
||||
if (strpos($arg, '--mode=') === 0) {
|
||||
$mode = substr($arg, 7);
|
||||
}
|
||||
}
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " CORRECTOR DE LISTAS - DOMDocument\n";
|
||||
echo " Modo: $mode\n";
|
||||
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
/**
|
||||
* Conectar a la base de datos
|
||||
*/
|
||||
function connectDatabase(array $config): ?mysqli {
|
||||
$conn = new mysqli(
|
||||
$config['host'],
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$config['database']
|
||||
);
|
||||
if ($conn->connect_error) {
|
||||
echo "Error de conexión: " . $conn->connect_error . "\n";
|
||||
return null;
|
||||
}
|
||||
$conn->set_charset($config['charset']);
|
||||
return $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corregir listas mal formadas usando DOMDocument
|
||||
*/
|
||||
function fixMalformedLists(string $html): array {
|
||||
$result = [
|
||||
'fixed' => false,
|
||||
'html' => $html,
|
||||
'changes' => 0,
|
||||
'details' => []
|
||||
];
|
||||
|
||||
// Suprimir errores de HTML mal formado
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
|
||||
// Envolver en contenedor para preservar estructura
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
|
||||
libxml_clear_errors();
|
||||
|
||||
// Procesar todas las listas (ul y ol)
|
||||
$lists = [];
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) {
|
||||
$lists[] = $ul;
|
||||
}
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) {
|
||||
$lists[] = $ol;
|
||||
}
|
||||
|
||||
$changes = 0;
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$changes += fixListChildren($list, $result['details']);
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
// Extraer HTML corregido
|
||||
$wrapper = $doc->getElementById('temp-wrapper');
|
||||
if ($wrapper) {
|
||||
$innerHTML = '';
|
||||
foreach ($wrapper->childNodes as $child) {
|
||||
$innerHTML .= $doc->saveHTML($child);
|
||||
}
|
||||
$result['html'] = $innerHTML;
|
||||
$result['fixed'] = true;
|
||||
$result['changes'] = $changes;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corregir hijos de una lista (solo debe contener li, script, template)
|
||||
*/
|
||||
function fixListChildren(DOMElement $list, array &$details): int {
|
||||
$changes = 0;
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
$nodesToProcess = [];
|
||||
|
||||
// Recopilar nodos que necesitan corrección
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$nodesToProcess[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar cada nodo inválido
|
||||
foreach ($nodesToProcess as $node) {
|
||||
$tagName = strtolower($node->nodeName);
|
||||
|
||||
// Si es una lista anidada (ul/ol), envolverla en <li>
|
||||
if ($tagName === 'ul' || $tagName === 'ol') {
|
||||
$changes += wrapInLi($list, $node, $details);
|
||||
}
|
||||
// Otros elementos inválidos también se envuelven en <li>
|
||||
else {
|
||||
$changes += wrapInLi($list, $node, $details);
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envolver un nodo en <li> o moverlo al <li> anterior
|
||||
*/
|
||||
function wrapInLi(DOMElement $list, DOMNode $node, array &$details): int {
|
||||
$doc = $list->ownerDocument;
|
||||
$tagName = strtolower($node->nodeName);
|
||||
|
||||
// Buscar el <li> hermano anterior
|
||||
$prevLi = null;
|
||||
$prev = $node->previousSibling;
|
||||
while ($prev) {
|
||||
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
|
||||
$prevLi = $prev;
|
||||
break;
|
||||
}
|
||||
$prev = $prev->previousSibling;
|
||||
}
|
||||
|
||||
if ($prevLi) {
|
||||
// Mover el nodo al final del <li> anterior
|
||||
$prevLi->appendChild($node);
|
||||
$details[] = "Movido <$tagName> dentro del <li> anterior";
|
||||
return 1;
|
||||
} else {
|
||||
// No hay <li> anterior, crear uno nuevo
|
||||
$newLi = $doc->createElement('li');
|
||||
$list->insertBefore($newLi, $node);
|
||||
$newLi->appendChild($node);
|
||||
$details[] = "Envuelto <$tagName> en nuevo <li>";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detectar problemas en HTML sin corregir
|
||||
*/
|
||||
function detectIssues(string $html): array {
|
||||
$issues = [];
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
// Revisar ul
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) {
|
||||
foreach ($ul->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$issues[] = [
|
||||
'list_type' => 'ul',
|
||||
'invalid_child' => $tagName,
|
||||
'context' => getNodeContext($child)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Revisar ol
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) {
|
||||
foreach ($ol->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$issues[] = [
|
||||
'list_type' => 'ol',
|
||||
'invalid_child' => $tagName,
|
||||
'context' => getNodeContext($child)
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener contexto de un nodo para debug
|
||||
*/
|
||||
function getNodeContext(DOMNode $node): string {
|
||||
$doc = $node->ownerDocument;
|
||||
$html = $doc->saveHTML($node);
|
||||
return substr($html, 0, 100) . (strlen($html) > 100 ? '...' : '');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EJECUCIÓN PRINCIPAL
|
||||
// ============================================
|
||||
|
||||
$conn = connectDatabase($db_config);
|
||||
if (!$conn) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "✓ Conexión establecida\n\n";
|
||||
|
||||
// Contar registros
|
||||
$result = $conn->query("SELECT COUNT(*) as total FROM datos_seo_pagina WHERE html IS NOT NULL AND html != ''");
|
||||
$total = $result->fetch_assoc()['total'];
|
||||
echo "Total de registros: $total\n\n";
|
||||
|
||||
if ($mode === 'scan') {
|
||||
// MODO SCAN: Solo detectar problemas
|
||||
echo "MODO: ESCANEO (solo detección)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$batch_size = 100;
|
||||
$offset = 0;
|
||||
$affected = 0;
|
||||
$total_issues = 0;
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina
|
||||
WHERE html IS NOT NULL AND html != ''
|
||||
ORDER BY id LIMIT $batch_size OFFSET $offset";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['html']);
|
||||
if (!empty($issues)) {
|
||||
$affected++;
|
||||
$total_issues += count($issues);
|
||||
|
||||
if ($affected <= 20) {
|
||||
echo "[ID: {$row['id']}] " . count($issues) . " problema(s)\n";
|
||||
echo "URL: {$row['page']}\n";
|
||||
foreach (array_slice($issues, 0, 2) as $issue) {
|
||||
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
$offset += $batch_size;
|
||||
|
||||
if ($offset % 1000 == 0) {
|
||||
echo "Procesados: $offset/$total...\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "─────────────────────────────────\n";
|
||||
echo "RESUMEN:\n";
|
||||
echo " Posts afectados: $affected\n";
|
||||
echo " Total incidencias: $total_issues\n";
|
||||
|
||||
} elseif ($mode === 'test') {
|
||||
// MODO TEST: Probar corrección en 1 post
|
||||
echo "MODO: PRUEBA (sin guardar)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
// Buscar primer post con problemas
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina
|
||||
WHERE html IS NOT NULL AND html != ''
|
||||
ORDER BY id LIMIT 100";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['html']);
|
||||
if (!empty($issues)) {
|
||||
echo "POST ID: {$row['id']}\n";
|
||||
echo "URL: {$row['page']}\n";
|
||||
echo "Problemas detectados: " . count($issues) . "\n\n";
|
||||
|
||||
echo "ANTES (problemas):\n";
|
||||
foreach (array_slice($issues, 0, 3) as $issue) {
|
||||
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
|
||||
echo " Contexto: " . htmlspecialchars(substr($issue['context'], 0, 80)) . "\n";
|
||||
}
|
||||
|
||||
// Aplicar corrección
|
||||
$fixResult = fixMalformedLists($row['html']);
|
||||
|
||||
echo "\nDESPUÉS (corrección):\n";
|
||||
echo " Cambios realizados: {$fixResult['changes']}\n";
|
||||
foreach ($fixResult['details'] as $detail) {
|
||||
echo " - $detail\n";
|
||||
}
|
||||
|
||||
// Verificar que no quedan problemas
|
||||
$issuesAfter = detectIssues($fixResult['html']);
|
||||
echo "\nVERIFICACIÓN:\n";
|
||||
echo " Problemas antes: " . count($issues) . "\n";
|
||||
echo " Problemas después: " . count($issuesAfter) . "\n";
|
||||
|
||||
if (count($issuesAfter) < count($issues)) {
|
||||
echo " ✓ Reducción de problemas\n";
|
||||
}
|
||||
|
||||
// Mostrar fragmento del HTML corregido
|
||||
if ($fixResult['fixed']) {
|
||||
echo "\nMUESTRA HTML CORREGIDO (primeros 500 chars):\n";
|
||||
echo "─────────────────────────────────\n";
|
||||
echo htmlspecialchars(substr($fixResult['html'], 0, 500)) . "...\n";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} elseif ($mode === 'fix') {
|
||||
// MODO FIX: Aplicar correcciones
|
||||
echo "MODO: CORRECCIÓN (GUARDANDO CAMBIOS)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$batch_size = 50;
|
||||
$offset = 0;
|
||||
$fixed_count = 0;
|
||||
$error_count = 0;
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina
|
||||
WHERE html IS NOT NULL AND html != ''
|
||||
ORDER BY id LIMIT $batch_size OFFSET $offset";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['html']);
|
||||
|
||||
if (!empty($issues)) {
|
||||
$fixResult = fixMalformedLists($row['html']);
|
||||
|
||||
if ($fixResult['fixed']) {
|
||||
// Guardar HTML corregido
|
||||
$stmt = $conn->prepare("UPDATE datos_seo_pagina SET html = ? WHERE id = ?");
|
||||
$stmt->bind_param("si", $fixResult['html'], $row['id']);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$fixed_count++;
|
||||
echo "[ID: {$row['id']}] ✓ Corregido ({$fixResult['changes']} cambios)\n";
|
||||
} else {
|
||||
$error_count++;
|
||||
echo "[ID: {$row['id']}] ✗ Error al guardar\n";
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$offset += $batch_size;
|
||||
|
||||
if ($offset % 500 == 0) {
|
||||
echo "Procesados: $offset/$total (corregidos: $fixed_count)\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n─────────────────────────────────\n";
|
||||
echo "RESUMEN:\n";
|
||||
echo " Posts corregidos: $fixed_count\n";
|
||||
echo " Errores: $error_count\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "\n✓ Proceso completado.\n";
|
||||
@@ -1,322 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Corrector de Listas HTML Mal Formadas - WordPress Posts
|
||||
*
|
||||
* BASE DE DATOS: preciosunitarios_wp
|
||||
* TABLA: wp_posts
|
||||
* CAMPO: post_content
|
||||
*
|
||||
* USO:
|
||||
* php fix-malformed-lists-wp-posts.php --mode=scan
|
||||
* php fix-malformed-lists-wp-posts.php --mode=test
|
||||
* php fix-malformed-lists-wp-posts.php --mode=fix
|
||||
*
|
||||
* @package ROI_Theme
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '512M');
|
||||
set_time_limit(600);
|
||||
|
||||
$db_config = [
|
||||
'host' => 'localhost',
|
||||
'database' => 'preciosunitarios_wp',
|
||||
'username' => 'preciosunitarios_wp',
|
||||
'password' => 'Kq#Gk%yEt+PWpVe&HZ',
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
$mode = 'scan';
|
||||
foreach ($argv as $arg) {
|
||||
if (strpos($arg, '--mode=') === 0) {
|
||||
$mode = substr($arg, 7);
|
||||
}
|
||||
}
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " CORRECTOR DE LISTAS - WordPress Posts\n";
|
||||
echo " Base de datos: {$db_config['database']}\n";
|
||||
echo " Tabla: wp_posts (post_content)\n";
|
||||
echo " Modo: $mode\n";
|
||||
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
function connectDatabase(array $config): ?mysqli {
|
||||
$conn = new mysqli($config['host'], $config['username'], $config['password'], $config['database']);
|
||||
if ($conn->connect_error) {
|
||||
echo "Error de conexión: " . $conn->connect_error . "\n";
|
||||
return null;
|
||||
}
|
||||
$conn->set_charset($config['charset']);
|
||||
return $conn;
|
||||
}
|
||||
|
||||
function detectIssues(string $html): array {
|
||||
$issues = [];
|
||||
if (empty(trim($html))) return $issues;
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
foreach (['ul', 'ol'] as $listTag) {
|
||||
foreach ($doc->getElementsByTagName($listTag) as $list) {
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$issues[] = [
|
||||
'list_type' => $listTag,
|
||||
'invalid_child' => $tagName
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
function fixMalformedLists(string $html): array {
|
||||
$result = ['fixed' => false, 'html' => $html, 'changes' => 0, 'details' => []];
|
||||
|
||||
if (empty(trim($html))) return $result;
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$lists = [];
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
|
||||
|
||||
$changes = 0;
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$nodesToProcess = [];
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$nodesToProcess[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($nodesToProcess as $node) {
|
||||
$tagName = strtolower($node->nodeName);
|
||||
$prevLi = null;
|
||||
$prev = $node->previousSibling;
|
||||
|
||||
while ($prev) {
|
||||
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
|
||||
$prevLi = $prev;
|
||||
break;
|
||||
}
|
||||
$prev = $prev->previousSibling;
|
||||
}
|
||||
|
||||
if ($prevLi) {
|
||||
$prevLi->appendChild($node);
|
||||
$result['details'][] = "Movido <$tagName> dentro del <li> anterior";
|
||||
$changes++;
|
||||
} else {
|
||||
$newLi = $doc->createElement('li');
|
||||
$list->insertBefore($newLi, $node);
|
||||
$newLi->appendChild($node);
|
||||
$result['details'][] = "Envuelto <$tagName> en nuevo <li>";
|
||||
$changes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
$wrapper = $doc->getElementById('temp-wrapper');
|
||||
if ($wrapper) {
|
||||
$innerHTML = '';
|
||||
foreach ($wrapper->childNodes as $child) {
|
||||
$innerHTML .= $doc->saveHTML($child);
|
||||
}
|
||||
$result['html'] = $innerHTML;
|
||||
$result['fixed'] = true;
|
||||
$result['changes'] = $changes;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
// EJECUCIÓN PRINCIPAL
|
||||
$conn = connectDatabase($db_config);
|
||||
if (!$conn) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "✓ Conexión establecida\n\n";
|
||||
|
||||
// Solo posts publicados con contenido
|
||||
$countQuery = "SELECT COUNT(*) as total FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type IN ('post', 'page')
|
||||
AND post_content IS NOT NULL
|
||||
AND post_content != ''";
|
||||
$result = $conn->query($countQuery);
|
||||
$total = $result->fetch_assoc()['total'];
|
||||
echo "Total de posts/páginas publicados: $total\n\n";
|
||||
|
||||
if ($mode === 'scan') {
|
||||
echo "MODO: ESCANEO (solo detección)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$batch_size = 100;
|
||||
$offset = 0;
|
||||
$affected = 0;
|
||||
$total_issues = 0;
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT ID, post_title, post_content, guid FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type IN ('post', 'page')
|
||||
AND post_content IS NOT NULL
|
||||
AND post_content != ''
|
||||
ORDER BY ID LIMIT $batch_size OFFSET $offset";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['post_content']);
|
||||
if (!empty($issues)) {
|
||||
$affected++;
|
||||
$total_issues += count($issues);
|
||||
|
||||
if ($affected <= 20) {
|
||||
echo "[ID: {$row['ID']}] " . count($issues) . " problema(s)\n";
|
||||
echo "Título: " . substr($row['post_title'], 0, 60) . "\n";
|
||||
foreach (array_slice($issues, 0, 2) as $issue) {
|
||||
echo " - <{$issue['list_type']}> contiene <{$issue['invalid_child']}>\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
$offset += $batch_size;
|
||||
|
||||
if ($offset % 1000 == 0) {
|
||||
echo "Procesados: $offset/$total...\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "─────────────────────────────────\n";
|
||||
echo "RESUMEN:\n";
|
||||
echo " Posts afectados: $affected\n";
|
||||
echo " Total incidencias: $total_issues\n";
|
||||
|
||||
} elseif ($mode === 'test') {
|
||||
echo "MODO: PRUEBA (sin guardar)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$query = "SELECT ID, post_title, post_content FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type IN ('post', 'page')
|
||||
AND post_content IS NOT NULL
|
||||
AND post_content != ''
|
||||
ORDER BY ID LIMIT 200";
|
||||
$result = $conn->query($query);
|
||||
|
||||
$tested = 0;
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['post_content']);
|
||||
if (!empty($issues) && $tested < 5) {
|
||||
$tested++;
|
||||
echo "POST ID: {$row['ID']}\n";
|
||||
echo "Título: {$row['post_title']}\n";
|
||||
echo "Problemas detectados: " . count($issues) . "\n\n";
|
||||
|
||||
$fixResult = fixMalformedLists($row['post_content']);
|
||||
$issuesAfter = detectIssues($fixResult['html']);
|
||||
|
||||
echo "ANTES: " . count($issues) . " problemas\n";
|
||||
echo "DESPUÉS: " . count($issuesAfter) . " problemas\n";
|
||||
echo "Cambios: {$fixResult['changes']}\n";
|
||||
|
||||
// Verificar integridad
|
||||
$before_ul = substr_count($row['post_content'], '<ul');
|
||||
$after_ul = substr_count($fixResult['html'], '<ul');
|
||||
$before_li = substr_count($row['post_content'], '<li');
|
||||
$after_li = substr_count($fixResult['html'], '<li');
|
||||
|
||||
echo "Tags <ul>: $before_ul → $after_ul " . ($before_ul === $after_ul ? "✓" : "⚠️") . "\n";
|
||||
echo "Tags <li>: $before_li → $after_li " . ($before_li === $after_li ? "✓" : "⚠️") . "\n";
|
||||
|
||||
if (count($issuesAfter) === 0) {
|
||||
echo "✅ CORRECCIÓN EXITOSA\n";
|
||||
} else {
|
||||
echo "⚠️ REQUIERE REVISIÓN\n";
|
||||
}
|
||||
echo "─────────────────────────────────\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
} elseif ($mode === 'fix') {
|
||||
echo "MODO: CORRECCIÓN (GUARDANDO CAMBIOS)\n";
|
||||
echo "─────────────────────────────────\n\n";
|
||||
|
||||
$batch_size = 50;
|
||||
$offset = 0;
|
||||
$fixed_count = 0;
|
||||
$error_count = 0;
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT ID, post_content FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type IN ('post', 'page')
|
||||
AND post_content IS NOT NULL
|
||||
AND post_content != ''
|
||||
ORDER BY ID LIMIT $batch_size OFFSET $offset";
|
||||
$result = $conn->query($query);
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['post_content']);
|
||||
|
||||
if (!empty($issues)) {
|
||||
$fixResult = fixMalformedLists($row['post_content']);
|
||||
|
||||
if ($fixResult['fixed']) {
|
||||
$stmt = $conn->prepare("UPDATE wp_posts SET post_content = ? WHERE ID = ?");
|
||||
$stmt->bind_param("si", $fixResult['html'], $row['ID']);
|
||||
|
||||
if ($stmt->execute()) {
|
||||
$fixed_count++;
|
||||
echo "[ID: {$row['ID']}] ✓ Corregido ({$fixResult['changes']} cambios)\n";
|
||||
} else {
|
||||
$error_count++;
|
||||
echo "[ID: {$row['ID']}] ✗ Error al guardar\n";
|
||||
}
|
||||
$stmt->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$offset += $batch_size;
|
||||
|
||||
if ($offset % 500 == 0) {
|
||||
echo "Procesados: $offset/$total (corregidos: $fixed_count)\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n─────────────────────────────────\n";
|
||||
echo "RESUMEN:\n";
|
||||
echo " Posts corregidos: $fixed_count\n";
|
||||
echo " Errores: $error_count\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "\n✓ Proceso completado.\n";
|
||||
@@ -1,307 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Script de Diagnóstico: Listas HTML Mal Formadas
|
||||
*
|
||||
* PROPÓSITO: Identificar posts con estructura de listas inválida
|
||||
* - <ul> conteniendo <ul> como hijo directo (en lugar de dentro de <li>)
|
||||
* - <ol> conteniendo <ol> como hijo directo
|
||||
*
|
||||
* BASE DE DATOS: preciosunitarios_seo
|
||||
* TABLA: datos_seo_pagina
|
||||
* CAMPO: html
|
||||
*
|
||||
* IMPORTANTE: Este script SOLO LEE, no modifica ningún dato.
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since Phase 4.4 Accessibility
|
||||
*/
|
||||
|
||||
// Configuración de errores para debugging
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '512M');
|
||||
set_time_limit(300); // 5 minutos máximo
|
||||
|
||||
// Credenciales de base de datos (ajustar según servidor)
|
||||
$db_config = [
|
||||
'host' => 'localhost',
|
||||
'database' => 'preciosunitarios_seo',
|
||||
'username' => 'root', // Cambiar en producción
|
||||
'password' => '', // Cambiar en producción
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
// Patrones regex para detectar listas mal formadas
|
||||
$malformed_patterns = [
|
||||
// <ul> seguido directamente de <ul> (sin estar dentro de <li>)
|
||||
'ul_direct_ul' => '/<ul[^>]*>\s*(?:<li[^>]*>.*?<\/li>\s*)*<ul/is',
|
||||
|
||||
// Patrón más específico: </li> seguido de <ul> (hermanos en lugar de anidados)
|
||||
'li_sibling_ul' => '/<\/li>\s*<ul[^>]*>/is',
|
||||
|
||||
// <ol> seguido directamente de <ol>
|
||||
'ol_direct_ol' => '/<ol[^>]*>\s*(?:<li[^>]*>.*?<\/li>\s*)*<ol/is',
|
||||
|
||||
// </li> seguido de <ol> (hermanos)
|
||||
'li_sibling_ol' => '/<\/li>\s*<ol[^>]*>/is',
|
||||
];
|
||||
|
||||
/**
|
||||
* Conectar a la base de datos
|
||||
*/
|
||||
function connectDatabase(array $config): ?mysqli {
|
||||
$conn = new mysqli(
|
||||
$config['host'],
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$config['database']
|
||||
);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
echo "Error de conexión: " . $conn->connect_error . "\n";
|
||||
return null;
|
||||
}
|
||||
|
||||
$conn->set_charset($config['charset']);
|
||||
return $conn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analizar HTML en busca de listas mal formadas
|
||||
*/
|
||||
function analyzeMalformedLists(string $html, array $patterns): array {
|
||||
$issues = [];
|
||||
|
||||
foreach ($patterns as $pattern_name => $pattern) {
|
||||
if (preg_match_all($pattern, $html, $matches, PREG_OFFSET_CAPTURE)) {
|
||||
foreach ($matches[0] as $match) {
|
||||
$position = $match[1];
|
||||
$context = getContextAroundPosition($html, $position, 100);
|
||||
|
||||
$issues[] = [
|
||||
'type' => $pattern_name,
|
||||
'position' => $position,
|
||||
'context' => $context
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener contexto alrededor de una posición
|
||||
*/
|
||||
function getContextAroundPosition(string $html, int $position, int $length = 100): string {
|
||||
$start = max(0, $position - $length);
|
||||
$end = min(strlen($html), $position + $length);
|
||||
|
||||
$context = substr($html, $start, $end - $start);
|
||||
|
||||
// Limpiar para mostrar
|
||||
$context = preg_replace('/\s+/', ' ', $context);
|
||||
$context = htmlspecialchars($context);
|
||||
|
||||
if ($start > 0) {
|
||||
$context = '...' . $context;
|
||||
}
|
||||
if ($end < strlen($html)) {
|
||||
$context .= '...';
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contar total de listas en el HTML
|
||||
*/
|
||||
function countListElements(string $html): array {
|
||||
$ul_count = preg_match_all('/<ul[^>]*>/i', $html);
|
||||
$ol_count = preg_match_all('/<ol[^>]*>/i', $html);
|
||||
$li_count = preg_match_all('/<li[^>]*>/i', $html);
|
||||
|
||||
return [
|
||||
'ul' => $ul_count,
|
||||
'ol' => $ol_count,
|
||||
'li' => $li_count
|
||||
];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EJECUCIÓN PRINCIPAL
|
||||
// ============================================
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " DIAGNÓSTICO: Listas HTML Mal Formadas\n";
|
||||
echo " Base de datos: {$db_config['database']}\n";
|
||||
echo " Tabla: datos_seo_pagina\n";
|
||||
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
// Conectar
|
||||
$conn = connectDatabase($db_config);
|
||||
if (!$conn) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "✓ Conexión establecida\n\n";
|
||||
|
||||
// Obtener estructura de la tabla
|
||||
echo "Verificando estructura de tabla...\n";
|
||||
$result = $conn->query("DESCRIBE datos_seo_pagina");
|
||||
if ($result) {
|
||||
echo "Columnas encontradas:\n";
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
echo " - {$row['Field']} ({$row['Type']})\n";
|
||||
}
|
||||
echo "\n";
|
||||
}
|
||||
|
||||
// Contar registros totales
|
||||
$result = $conn->query("SELECT COUNT(*) as total FROM datos_seo_pagina WHERE html IS NOT NULL AND html != ''");
|
||||
$total = $result->fetch_assoc()['total'];
|
||||
echo "Total de registros con HTML: {$total}\n\n";
|
||||
|
||||
// Procesar en lotes
|
||||
$batch_size = 100;
|
||||
$offset = 0;
|
||||
$affected_posts = [];
|
||||
$total_issues = 0;
|
||||
$processed = 0;
|
||||
|
||||
echo "Iniciando análisis...\n";
|
||||
echo "─────────────────────────────────────────────\n";
|
||||
|
||||
while ($offset < $total) {
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina
|
||||
WHERE html IS NOT NULL AND html != ''
|
||||
ORDER BY id
|
||||
LIMIT {$batch_size} OFFSET {$offset}";
|
||||
|
||||
$result = $conn->query($query);
|
||||
|
||||
if (!$result) {
|
||||
echo "Error en consulta: " . $conn->error . "\n";
|
||||
break;
|
||||
}
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$processed++;
|
||||
$id = $row['id'];
|
||||
$url = $row['page'] ?? 'N/A';
|
||||
$html = $row['html'];
|
||||
|
||||
$issues = analyzeMalformedLists($html, $malformed_patterns);
|
||||
|
||||
if (!empty($issues)) {
|
||||
$list_counts = countListElements($html);
|
||||
|
||||
$affected_posts[] = [
|
||||
'id' => $id,
|
||||
'url' => $url,
|
||||
'issues' => $issues,
|
||||
'list_counts' => $list_counts
|
||||
];
|
||||
|
||||
$total_issues += count($issues);
|
||||
|
||||
// Mostrar progreso para posts afectados
|
||||
echo "\n[ID: {$id}] " . count($issues) . " problema(s) encontrado(s)\n";
|
||||
echo "URL: {$url}\n";
|
||||
echo "Listas: UL={$list_counts['ul']}, OL={$list_counts['ol']}, LI={$list_counts['li']}\n";
|
||||
|
||||
foreach ($issues as $idx => $issue) {
|
||||
echo " Problema " . ($idx + 1) . ": {$issue['type']} (pos: {$issue['position']})\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Mostrar progreso cada 500 registros
|
||||
if ($processed % 500 == 0) {
|
||||
echo "\rProcesados: {$processed}/{$total}...";
|
||||
}
|
||||
}
|
||||
|
||||
$offset += $batch_size;
|
||||
}
|
||||
|
||||
echo "\n\n";
|
||||
echo "==============================================\n";
|
||||
echo " RESUMEN DEL ANÁLISIS\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
echo "Registros analizados: {$processed}\n";
|
||||
echo "Posts con problemas: " . count($affected_posts) . "\n";
|
||||
echo "Total de incidencias: {$total_issues}\n\n";
|
||||
|
||||
if (count($affected_posts) > 0) {
|
||||
echo "─────────────────────────────────────────────\n";
|
||||
echo "DETALLE DE POSTS AFECTADOS\n";
|
||||
echo "─────────────────────────────────────────────\n\n";
|
||||
|
||||
// Agrupar por tipo de problema
|
||||
$by_type = [];
|
||||
foreach ($affected_posts as $post) {
|
||||
foreach ($post['issues'] as $issue) {
|
||||
$type = $issue['type'];
|
||||
if (!isset($by_type[$type])) {
|
||||
$by_type[$type] = [];
|
||||
}
|
||||
$by_type[$type][] = $post['id'];
|
||||
}
|
||||
}
|
||||
|
||||
echo "Por tipo de problema:\n";
|
||||
foreach ($by_type as $type => $ids) {
|
||||
$unique_ids = array_unique($ids);
|
||||
echo " - {$type}: " . count($unique_ids) . " posts\n";
|
||||
}
|
||||
|
||||
echo "\n─────────────────────────────────────────────\n";
|
||||
echo "LISTA DE IDs AFECTADOS (para revisión manual)\n";
|
||||
echo "─────────────────────────────────────────────\n\n";
|
||||
|
||||
$ids_list = array_column($affected_posts, 'id');
|
||||
echo "IDs: " . implode(', ', $ids_list) . "\n";
|
||||
|
||||
// Generar archivo de reporte
|
||||
$report_file = __DIR__ . '/malformed-lists-report-' . date('Ymd-His') . '.json';
|
||||
$report_data = [
|
||||
'generated_at' => date('Y-m-d H:i:s'),
|
||||
'database' => $db_config['database'],
|
||||
'table' => 'datos_seo_pagina',
|
||||
'total_analyzed' => $processed,
|
||||
'total_affected' => count($affected_posts),
|
||||
'total_issues' => $total_issues,
|
||||
'by_type' => array_map(function($ids) {
|
||||
return array_values(array_unique($ids));
|
||||
}, $by_type),
|
||||
'affected_posts' => $affected_posts
|
||||
];
|
||||
|
||||
if (file_put_contents($report_file, json_encode($report_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))) {
|
||||
echo "\n✓ Reporte JSON guardado en:\n {$report_file}\n";
|
||||
}
|
||||
|
||||
// Muestra de contexto para análisis
|
||||
echo "\n─────────────────────────────────────────────\n";
|
||||
echo "MUESTRA DE CONTEXTO (primeros 3 posts)\n";
|
||||
echo "─────────────────────────────────────────────\n\n";
|
||||
|
||||
$sample = array_slice($affected_posts, 0, 3);
|
||||
foreach ($sample as $post) {
|
||||
echo "POST ID: {$post['id']}\n";
|
||||
echo "URL: {$post['url']}\n";
|
||||
foreach ($post['issues'] as $idx => $issue) {
|
||||
echo " [{$issue['type']}]\n";
|
||||
echo " Contexto: {$issue['context']}\n\n";
|
||||
}
|
||||
echo "───────────────────────\n";
|
||||
}
|
||||
|
||||
} else {
|
||||
echo "✓ No se encontraron listas mal formadas.\n";
|
||||
}
|
||||
|
||||
$conn->close();
|
||||
echo "\n✓ Análisis completado.\n";
|
||||
@@ -1,91 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Script de PRUEBA - Muestra corrección propuesta sin aplicarla
|
||||
*
|
||||
* IMPORTANTE: Este script SOLO MUESTRA, no modifica nada.
|
||||
*/
|
||||
|
||||
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
|
||||
$conn->set_charset("utf8mb4");
|
||||
|
||||
echo "========================================\n";
|
||||
echo "ANÁLISIS DE CORRECCIÓN PROPUESTA\n";
|
||||
echo "========================================\n\n";
|
||||
|
||||
// Patrón que encuentra: </li></ul><li>TEXTO</li><ul>
|
||||
// Este patrón captura:
|
||||
// - $1: </li> inicial (con espacios)
|
||||
// - $2: espacios entre </ul> y <li>
|
||||
// - $3: contenido del <li> (ej: <strong>Texto</strong>)
|
||||
// - $4: espacios entre </li> y <ul>
|
||||
|
||||
$pattern = '#(</li>\s*)</ul>(\s*)<li>(.*?)</li>(\s*)<ul>#is';
|
||||
$replacement = '$1<li>$3$4<ul>';
|
||||
|
||||
echo "PATRÓN A BUSCAR:\n";
|
||||
echo " </li>\\s*</ul>\\s*<li>CONTENIDO</li>\\s*<ul>\n\n";
|
||||
|
||||
echo "REEMPLAZO:\n";
|
||||
echo " </li><li>CONTENIDO<ul>\n\n";
|
||||
|
||||
// Obtener HTML del post ID 3
|
||||
$result = $conn->query("SELECT id, page, html FROM datos_seo_pagina WHERE id = 3");
|
||||
$row = $result->fetch_assoc();
|
||||
$html = $row["html"];
|
||||
$page = $row["page"];
|
||||
|
||||
echo "PROBANDO CON POST ID 3:\n";
|
||||
echo "URL: $page\n";
|
||||
echo "────────────────────────────\n\n";
|
||||
|
||||
// Encontrar todas las ocurrencias
|
||||
preg_match_all($pattern, $html, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
|
||||
|
||||
echo "Ocurrencias encontradas: " . count($matches) . "\n\n";
|
||||
|
||||
// Mostrar cada ocurrencia y su corrección propuesta
|
||||
foreach (array_slice($matches, 0, 3) as $idx => $match) {
|
||||
$full_match = $match[0][0];
|
||||
$position = $match[0][1];
|
||||
|
||||
echo "[$idx] Posición: $position\n";
|
||||
echo "ANTES:\n";
|
||||
echo htmlspecialchars($full_match) . "\n\n";
|
||||
|
||||
$fixed = preg_replace($pattern, $replacement, $full_match);
|
||||
echo "DESPUÉS:\n";
|
||||
echo htmlspecialchars($fixed) . "\n";
|
||||
echo "────────────────────────────\n\n";
|
||||
}
|
||||
|
||||
// Aplicar corrección en memoria y contar diferencia
|
||||
$html_fixed = preg_replace($pattern, $replacement, $html);
|
||||
|
||||
$before = preg_match_all($pattern, $html);
|
||||
$after = preg_match_all($pattern, $html_fixed);
|
||||
|
||||
echo "========================================\n";
|
||||
echo "RESUMEN DE CORRECCIÓN (sin aplicar):\n";
|
||||
echo "========================================\n";
|
||||
echo "Ocurrencias ANTES: $before\n";
|
||||
echo "Ocurrencias DESPUÉS: $after\n";
|
||||
echo "Reducción: " . ($before - $after) . "\n\n";
|
||||
|
||||
// Verificar que la estructura es válida después de la corrección
|
||||
$ul_count_before = substr_count($html, '<ul');
|
||||
$ul_count_after = substr_count($html_fixed, '<ul');
|
||||
echo "Tags <ul> antes: $ul_count_before\n";
|
||||
echo "Tags <ul> después: $ul_count_after\n";
|
||||
|
||||
$li_count_before = substr_count($html, '<li');
|
||||
$li_count_after = substr_count($html_fixed, '<li');
|
||||
echo "Tags <li> antes: $li_count_before\n";
|
||||
echo "Tags <li> después: $li_count_after\n";
|
||||
|
||||
echo "\n========================================\n";
|
||||
echo "NOTA: Este patrón elimina el </ul> prematuro\n";
|
||||
echo "pero NO agrega el </li> faltante al final.\n";
|
||||
echo "Se necesita un segundo paso para balancear.\n";
|
||||
echo "========================================\n";
|
||||
|
||||
$conn->close();
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Prueba corrección en casos específicos variados
|
||||
*/
|
||||
|
||||
$conn = new mysqli("localhost", "preciosunitarios_seo", "ACl%EEFd=V-Yvb??", "preciosunitarios_seo");
|
||||
$conn->set_charset("utf8mb4");
|
||||
|
||||
// IDs a probar (casos variados)
|
||||
$test_ids = [20, 23, 65, 377, 98, 107, 144];
|
||||
|
||||
function detectIssues($html) {
|
||||
$issues = [];
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument("1.0", "UTF-8");
|
||||
$doc->loadHTML('<?xml encoding="UTF-8"><div id="w">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ["li", "script", "template"];
|
||||
foreach (["ul", "ol"] as $tag) {
|
||||
foreach ($doc->getElementsByTagName($tag) as $list) {
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$childTag = strtolower($child->nodeName);
|
||||
if (!in_array($childTag, $validChildren)) {
|
||||
$issues[] = "<$tag> contiene <$childTag>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $issues;
|
||||
}
|
||||
|
||||
function fixMalformedLists($html) {
|
||||
$result = ['fixed' => false, 'html' => $html, 'changes' => 0];
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument("1.0", "UTF-8");
|
||||
$doc->loadHTML('<?xml encoding="UTF-8"><div id="w">' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$lists = [];
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
|
||||
|
||||
$changes = 0;
|
||||
$validChildren = ["li", "script", "template"];
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$nodesToProcess = [];
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$nodesToProcess[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($nodesToProcess as $node) {
|
||||
$tagName = strtolower($node->nodeName);
|
||||
$prevLi = null;
|
||||
$prev = $node->previousSibling;
|
||||
|
||||
while ($prev) {
|
||||
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
|
||||
$prevLi = $prev;
|
||||
break;
|
||||
}
|
||||
$prev = $prev->previousSibling;
|
||||
}
|
||||
|
||||
if ($prevLi) {
|
||||
$prevLi->appendChild($node);
|
||||
$changes++;
|
||||
} else {
|
||||
$newLi = $doc->createElement('li');
|
||||
$list->insertBefore($newLi, $node);
|
||||
$newLi->appendChild($node);
|
||||
$changes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
$wrapper = $doc->getElementById('w');
|
||||
if ($wrapper) {
|
||||
$innerHTML = '';
|
||||
foreach ($wrapper->childNodes as $child) {
|
||||
$innerHTML .= $doc->saveHTML($child);
|
||||
}
|
||||
$result['html'] = $innerHTML;
|
||||
$result['fixed'] = true;
|
||||
$result['changes'] = $changes;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
echo "=====================================================\n";
|
||||
echo " PRUEBA DE CORRECCIÓN EN CASOS VARIADOS\n";
|
||||
echo "=====================================================\n\n";
|
||||
|
||||
$ids_str = implode(',', $test_ids);
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE id IN ($ids_str)";
|
||||
$result = $conn->query($query);
|
||||
|
||||
$all_passed = true;
|
||||
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$id = $row['id'];
|
||||
$url = $row['page'];
|
||||
$html = $row['html'];
|
||||
|
||||
echo "─────────────────────────────────────────────────\n";
|
||||
echo "POST ID: $id\n";
|
||||
echo "URL: $url\n\n";
|
||||
|
||||
// Detectar problemas antes
|
||||
$issues_before = detectIssues($html);
|
||||
echo "ANTES:\n";
|
||||
echo " Problemas: " . count($issues_before) . "\n";
|
||||
$unique_types = array_unique($issues_before);
|
||||
foreach ($unique_types as $type) {
|
||||
echo " - $type\n";
|
||||
}
|
||||
|
||||
// Aplicar corrección
|
||||
$fixResult = fixMalformedLists($html);
|
||||
|
||||
// Detectar problemas después
|
||||
$issues_after = detectIssues($fixResult['html']);
|
||||
|
||||
echo "\nDESPUÉS:\n";
|
||||
echo " Cambios aplicados: {$fixResult['changes']}\n";
|
||||
echo " Problemas restantes: " . count($issues_after) . "\n";
|
||||
|
||||
if (count($issues_after) > 0) {
|
||||
echo " ⚠️ Problemas NO resueltos:\n";
|
||||
foreach (array_unique($issues_after) as $type) {
|
||||
echo " - $type\n";
|
||||
}
|
||||
$all_passed = false;
|
||||
}
|
||||
|
||||
// Verificar integridad del HTML
|
||||
$tags_before = [
|
||||
'ul' => substr_count($html, '<ul'),
|
||||
'ol' => substr_count($html, '<ol'),
|
||||
'li' => substr_count($html, '<li'),
|
||||
];
|
||||
$tags_after = [
|
||||
'ul' => substr_count($fixResult['html'], '<ul'),
|
||||
'ol' => substr_count($fixResult['html'], '<ol'),
|
||||
'li' => substr_count($fixResult['html'], '<li'),
|
||||
];
|
||||
|
||||
echo "\nINTEGRIDAD DE TAGS:\n";
|
||||
echo " <ul>: {$tags_before['ul']} → {$tags_after['ul']} ";
|
||||
echo ($tags_before['ul'] === $tags_after['ul'] ? "✓" : "⚠️ CAMBIÓ") . "\n";
|
||||
echo " <ol>: {$tags_before['ol']} → {$tags_after['ol']} ";
|
||||
echo ($tags_before['ol'] === $tags_after['ol'] ? "✓" : "⚠️ CAMBIÓ") . "\n";
|
||||
echo " <li>: {$tags_before['li']} → {$tags_after['li']} ";
|
||||
echo ($tags_before['li'] === $tags_after['li'] ? "✓" : "⚠️ CAMBIÓ") . "\n";
|
||||
|
||||
// Resultado
|
||||
if (count($issues_after) === 0 &&
|
||||
$tags_before['ul'] === $tags_after['ul'] &&
|
||||
$tags_before['ol'] === $tags_after['ol']) {
|
||||
echo "\n✅ RESULTADO: CORRECCIÓN EXITOSA\n";
|
||||
} else {
|
||||
echo "\n❌ RESULTADO: REQUIERE REVISIÓN\n";
|
||||
$all_passed = false;
|
||||
}
|
||||
}
|
||||
|
||||
echo "\n=====================================================\n";
|
||||
if ($all_passed) {
|
||||
echo "✅ TODOS LOS CASOS PASARON LA PRUEBA\n";
|
||||
} else {
|
||||
echo "⚠️ ALGUNOS CASOS REQUIEREN REVISIÓN\n";
|
||||
}
|
||||
echo "=====================================================\n";
|
||||
|
||||
$conn->close();
|
||||
@@ -1,347 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Validador de Correcciones - Genera archivos HTML para revisión visual
|
||||
*
|
||||
* PROPÓSITO: Crear archivos comparativos ANTES/DESPUÉS para validar
|
||||
* que la corrección no rompe el contenido.
|
||||
*
|
||||
* USO: php validate-fix-lists.php
|
||||
*
|
||||
* GENERA:
|
||||
* /tmp/list-fix-validation/
|
||||
* ├── post_ID_before.html
|
||||
* ├── post_ID_after.html
|
||||
* └── comparison_report.html
|
||||
*
|
||||
* @package ROI_Theme
|
||||
*/
|
||||
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
ini_set('memory_limit', '256M');
|
||||
|
||||
$db_config = [
|
||||
'host' => 'localhost',
|
||||
'database' => 'preciosunitarios_seo',
|
||||
'username' => 'preciosunitarios_seo',
|
||||
'password' => 'ACl%EEFd=V-Yvb??',
|
||||
'charset' => 'utf8mb4'
|
||||
];
|
||||
|
||||
$output_dir = '/tmp/list-fix-validation';
|
||||
$sample_size = 5;
|
||||
|
||||
echo "==============================================\n";
|
||||
echo " VALIDADOR DE CORRECCIONES\n";
|
||||
echo " Fecha: " . date('Y-m-d H:i:s') . "\n";
|
||||
echo "==============================================\n\n";
|
||||
|
||||
// Crear directorio de salida
|
||||
if (!is_dir($output_dir)) {
|
||||
mkdir($output_dir, 0755, true);
|
||||
}
|
||||
|
||||
// Limpiar archivos anteriores
|
||||
array_map('unlink', glob("$output_dir/*.html"));
|
||||
|
||||
/**
|
||||
* Detectar problemas en HTML
|
||||
*/
|
||||
function detectIssues(string $html): array {
|
||||
$issues = [];
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
foreach (['ul', 'ol'] as $listTag) {
|
||||
foreach ($doc->getElementsByTagName($listTag) as $list) {
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$issues[] = "<$listTag> contiene <$tagName>";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corregir listas mal formadas
|
||||
*/
|
||||
function fixMalformedLists(string $html): array {
|
||||
$result = ['fixed' => false, 'html' => $html, 'changes' => 0, 'details' => []];
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument('1.0', 'UTF-8');
|
||||
$wrapped = '<div id="temp-wrapper">' . $html . '</div>';
|
||||
$doc->loadHTML('<?xml encoding="UTF-8">' . $wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
|
||||
$lists = [];
|
||||
foreach ($doc->getElementsByTagName('ul') as $ul) { $lists[] = $ul; }
|
||||
foreach ($doc->getElementsByTagName('ol') as $ol) { $lists[] = $ol; }
|
||||
|
||||
$changes = 0;
|
||||
$validChildren = ['li', 'script', 'template'];
|
||||
|
||||
foreach ($lists as $list) {
|
||||
$nodesToProcess = [];
|
||||
foreach ($list->childNodes as $child) {
|
||||
if ($child->nodeType === XML_ELEMENT_NODE) {
|
||||
$tagName = strtolower($child->nodeName);
|
||||
if (!in_array($tagName, $validChildren)) {
|
||||
$nodesToProcess[] = $child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($nodesToProcess as $node) {
|
||||
$tagName = strtolower($node->nodeName);
|
||||
$prevLi = null;
|
||||
$prev = $node->previousSibling;
|
||||
|
||||
while ($prev) {
|
||||
if ($prev->nodeType === XML_ELEMENT_NODE && strtolower($prev->nodeName) === 'li') {
|
||||
$prevLi = $prev;
|
||||
break;
|
||||
}
|
||||
$prev = $prev->previousSibling;
|
||||
}
|
||||
|
||||
if ($prevLi) {
|
||||
$prevLi->appendChild($node);
|
||||
$result['details'][] = "Movido <$tagName> dentro del <li> anterior";
|
||||
$changes++;
|
||||
} else {
|
||||
$newLi = $doc->createElement('li');
|
||||
$list->insertBefore($newLi, $node);
|
||||
$newLi->appendChild($node);
|
||||
$result['details'][] = "Envuelto <$tagName> en nuevo <li>";
|
||||
$changes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changes > 0) {
|
||||
$wrapper = $doc->getElementById('temp-wrapper');
|
||||
if ($wrapper) {
|
||||
$innerHTML = '';
|
||||
foreach ($wrapper->childNodes as $child) {
|
||||
$innerHTML .= $doc->saveHTML($child);
|
||||
}
|
||||
$result['html'] = $innerHTML;
|
||||
$result['fixed'] = true;
|
||||
$result['changes'] = $changes;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar HTML wrapper para visualización
|
||||
*/
|
||||
function wrapForVisualization(string $content, string $title, string $status): string {
|
||||
$statusColor = $status === 'error' ? '#dc3545' : '#28a745';
|
||||
return <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>$title</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; line-height: 1.6; }
|
||||
.status { padding: 10px 20px; background: $statusColor; color: white; border-radius: 4px; margin-bottom: 20px; }
|
||||
.content { border: 1px solid #ddd; padding: 20px; border-radius: 4px; background: #fafafa; }
|
||||
ul, ol { background: #fff3cd; padding: 15px 15px 15px 35px; border-left: 4px solid #ffc107; margin: 10px 0; }
|
||||
li { background: #d4edda; padding: 5px 10px; margin: 5px 0; border-left: 3px solid #28a745; }
|
||||
h1, h2, h3, h4, h5, h6 { color: #333; }
|
||||
p { color: #555; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="status">$status</div>
|
||||
<div class="content">
|
||||
$content
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
}
|
||||
|
||||
// Conectar a DB
|
||||
$conn = new mysqli($db_config['host'], $db_config['username'], $db_config['password'], $db_config['database']);
|
||||
$conn->set_charset($db_config['charset']);
|
||||
|
||||
if ($conn->connect_error) {
|
||||
die("Error de conexión: " . $conn->connect_error);
|
||||
}
|
||||
|
||||
echo "✓ Conexión establecida\n\n";
|
||||
|
||||
// Buscar posts con problemas
|
||||
$query = "SELECT id, page, html FROM datos_seo_pagina WHERE html IS NOT NULL AND html != '' ORDER BY id LIMIT 500";
|
||||
$result = $conn->query($query);
|
||||
|
||||
$samples = [];
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$issues = detectIssues($row['html']);
|
||||
if (!empty($issues) && count($samples) < $sample_size) {
|
||||
$samples[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
echo "Encontrados " . count($samples) . " posts con problemas para validar\n\n";
|
||||
|
||||
$comparison_data = [];
|
||||
|
||||
foreach ($samples as $idx => $post) {
|
||||
$id = $post['id'];
|
||||
$url = $post['page'];
|
||||
$html_before = $post['html'];
|
||||
|
||||
echo "─────────────────────────────────\n";
|
||||
echo "POST $id: $url\n";
|
||||
|
||||
// Detectar problemas antes
|
||||
$issues_before = detectIssues($html_before);
|
||||
echo " Problemas ANTES: " . count($issues_before) . "\n";
|
||||
|
||||
// Aplicar corrección
|
||||
$fixResult = fixMalformedLists($html_before);
|
||||
$html_after = $fixResult['html'];
|
||||
|
||||
// Detectar problemas después
|
||||
$issues_after = detectIssues($html_after);
|
||||
echo " Problemas DESPUÉS: " . count($issues_after) . "\n";
|
||||
echo " Cambios aplicados: " . $fixResult['changes'] . "\n";
|
||||
|
||||
// Guardar archivos HTML
|
||||
$file_before = "$output_dir/post_{$id}_BEFORE.html";
|
||||
$file_after = "$output_dir/post_{$id}_AFTER.html";
|
||||
|
||||
file_put_contents($file_before, wrapForVisualization(
|
||||
$html_before,
|
||||
"Post $id - ANTES (con errores)",
|
||||
"ANTES: " . count($issues_before) . " problemas de listas"
|
||||
));
|
||||
|
||||
file_put_contents($file_after, wrapForVisualization(
|
||||
$html_after,
|
||||
"Post $id - DESPUÉS (corregido)",
|
||||
"DESPUÉS: " . count($issues_after) . " problemas - " . $fixResult['changes'] . " correcciones aplicadas"
|
||||
));
|
||||
|
||||
echo " ✓ Archivos generados:\n";
|
||||
echo " - $file_before\n";
|
||||
echo " - $file_after\n";
|
||||
|
||||
// Guardar datos para reporte
|
||||
$comparison_data[] = [
|
||||
'id' => $id,
|
||||
'url' => $url,
|
||||
'issues_before' => count($issues_before),
|
||||
'issues_after' => count($issues_after),
|
||||
'changes' => $fixResult['changes'],
|
||||
'file_before' => "post_{$id}_BEFORE.html",
|
||||
'file_after' => "post_{$id}_AFTER.html"
|
||||
];
|
||||
}
|
||||
|
||||
// Generar reporte comparativo
|
||||
$report_html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Reporte de Validación - Corrección de Listas</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; }
|
||||
h1 { color: #333; border-bottom: 2px solid #007bff; padding-bottom: 10px; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { padding: 12px; text-align: left; border: 1px solid #ddd; }
|
||||
th { background: #007bff; color: white; }
|
||||
tr:nth-child(even) { background: #f8f9fa; }
|
||||
.success { color: #28a745; font-weight: bold; }
|
||||
.warning { color: #ffc107; font-weight: bold; }
|
||||
.error { color: #dc3545; font-weight: bold; }
|
||||
a { color: #007bff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.instructions { background: #e7f3ff; padding: 15px; border-radius: 4px; margin: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Reporte de Validación - Corrección de Listas HTML</h1>
|
||||
|
||||
<div class="instructions">
|
||||
<strong>Instrucciones:</strong>
|
||||
<ol>
|
||||
<li>Abre cada par de archivos (ANTES/DESPUÉS) en el navegador</li>
|
||||
<li>Verifica que el contenido se muestre correctamente</li>
|
||||
<li>Las listas (fondo amarillo) deben contener solo items (fondo verde)</li>
|
||||
<li>Si todo se ve bien, la corrección es segura</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>URL</th>
|
||||
<th>Problemas Antes</th>
|
||||
<th>Problemas Después</th>
|
||||
<th>Cambios</th>
|
||||
<th>Archivos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
HTML;
|
||||
|
||||
foreach ($comparison_data as $data) {
|
||||
$status_class = $data['issues_after'] == 0 ? 'success' : ($data['issues_after'] < $data['issues_before'] ? 'warning' : 'error');
|
||||
|
||||
$report_html .= <<<HTML
|
||||
<tr>
|
||||
<td>{$data['id']}</td>
|
||||
<td><a href="{$data['url']}" target="_blank">{$data['url']}</a></td>
|
||||
<td class="error">{$data['issues_before']}</td>
|
||||
<td class="$status_class">{$data['issues_after']}</td>
|
||||
<td>{$data['changes']}</td>
|
||||
<td>
|
||||
<a href="{$data['file_before']}" target="_blank">ANTES</a> |
|
||||
<a href="{$data['file_after']}" target="_blank">DESPUÉS</a>
|
||||
</td>
|
||||
</tr>
|
||||
HTML;
|
||||
}
|
||||
|
||||
$report_html .= <<<HTML
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p><strong>Generado:</strong> {$_SERVER['REQUEST_TIME_FLOAT']}</p>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
$report_file = "$output_dir/comparison_report.html";
|
||||
file_put_contents($report_file, $report_html);
|
||||
|
||||
echo "\n─────────────────────────────────\n";
|
||||
echo "REPORTE GENERADO:\n";
|
||||
echo " $report_file\n\n";
|
||||
echo "Para revisar, descarga el directorio:\n";
|
||||
echo " scp -r VPSContabo:$output_dir ./validation/\n\n";
|
||||
|
||||
$conn->close();
|
||||
echo "✓ Validación completada.\n";
|
||||
@@ -20,7 +20,7 @@ final class MigratePageVisibilityService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ejecuta la migración para todos los componentes
|
||||
* Ejecuta la migracion para todos los componentes
|
||||
*
|
||||
* @return array{created: int, skipped: int}
|
||||
*/
|
||||
@@ -37,10 +37,10 @@ final class MigratePageVisibilityService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Usar constante compartida (DRY)
|
||||
// Usar defaults especificos por componente si existen
|
||||
$this->visibilityRepository->createDefaultVisibility(
|
||||
$componentName,
|
||||
VisibilityDefaults::DEFAULT_VISIBILITY
|
||||
VisibilityDefaults::getForComponent($componentName)
|
||||
);
|
||||
$created++;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,10 @@ final class WordPressPageTypeDetector implements PageTypeDetectorInterface
|
||||
|
||||
public function isHome(): bool
|
||||
{
|
||||
return is_front_page();
|
||||
// is_front_page() = pagina de inicio configurada
|
||||
// is_home() = pagina de posts (blog)
|
||||
// Ambas cuentan como "home" para visibilidad
|
||||
return is_front_page() || is_home();
|
||||
}
|
||||
|
||||
public function isPost(): bool
|
||||
|
||||
377
Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php
Normal file
377
Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php
Normal file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\PostGridShortcodeRendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Implementacion de PostGridShortcodeRendererInterface
|
||||
*
|
||||
* RESPONSABILIDAD: Generar HTML y CSS del shortcode [roi_post_grid].
|
||||
* No construye queries ni obtiene settings de BD.
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Ui
|
||||
*/
|
||||
final class PostGridShortcodeRenderer implements PostGridShortcodeRendererInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function render(\WP_Query $query, array $settings, array $options): string
|
||||
{
|
||||
if (!$query->have_posts()) {
|
||||
return $this->renderNoPostsMessage($settings, $options);
|
||||
}
|
||||
|
||||
$css = $this->generateCSS($settings, $options);
|
||||
$html = $this->buildHTML($query, $settings, $options);
|
||||
|
||||
return sprintf("<style>%s</style>\n%s", $css, $html);
|
||||
}
|
||||
|
||||
private function renderNoPostsMessage(array $settings, array $options): string
|
||||
{
|
||||
$colors = $settings['colors'] ?? [];
|
||||
$message = 'No se encontraron publicaciones';
|
||||
|
||||
$bgColor = $colors['card_bg_color'] ?? '#ffffff';
|
||||
$textColor = $colors['excerpt_color'] ?? '#6b7280';
|
||||
$borderColor = $colors['card_border_color'] ?? '#e5e7eb';
|
||||
|
||||
$selector = $this->getSelector($options);
|
||||
|
||||
$css = $this->cssGenerator->generate("{$selector} .no-posts", [
|
||||
'background-color' => $bgColor,
|
||||
'color' => $textColor,
|
||||
'border' => "1px solid {$borderColor}",
|
||||
'border-radius' => '0.5rem',
|
||||
'padding' => '2rem',
|
||||
'text-align' => 'center',
|
||||
]);
|
||||
|
||||
$containerClass = $this->getContainerClass($options);
|
||||
|
||||
return sprintf(
|
||||
"<style>%s</style>\n<div class=\"%s\"><div class=\"no-posts\"><p class=\"mb-0\">%s</p></div></div>",
|
||||
$css,
|
||||
esc_attr($containerClass),
|
||||
esc_html($message)
|
||||
);
|
||||
}
|
||||
|
||||
private function getSelector(array $options): string
|
||||
{
|
||||
$id = $options['id'] ?? '';
|
||||
return !empty($id)
|
||||
? ".roi-post-grid-shortcode-{$id}"
|
||||
: '.roi-post-grid-shortcode';
|
||||
}
|
||||
|
||||
private function getContainerClass(array $options): string
|
||||
{
|
||||
$id = $options['id'] ?? '';
|
||||
$customClass = $options['class'] ?? '';
|
||||
|
||||
$class = !empty($id)
|
||||
? "roi-post-grid-shortcode roi-post-grid-shortcode-{$id}"
|
||||
: 'roi-post-grid-shortcode';
|
||||
|
||||
if (!empty($customClass)) {
|
||||
$class .= ' ' . sanitize_html_class($customClass);
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
private function generateCSS(array $settings, array $options): string
|
||||
{
|
||||
$colors = $settings['colors'] ?? [];
|
||||
$spacing = $settings['spacing'] ?? [];
|
||||
$effects = $settings['visual_effects'] ?? [];
|
||||
$typography = $settings['typography'] ?? [];
|
||||
|
||||
$selector = $this->getSelector($options);
|
||||
$cssRules = [];
|
||||
|
||||
// Colores
|
||||
$cardBgColor = $colors['card_bg_color'] ?? '#ffffff';
|
||||
$cardTitleColor = $colors['card_title_color'] ?? '#0E2337';
|
||||
$cardHoverBgColor = $colors['card_hover_bg_color'] ?? '#f9fafb';
|
||||
$cardBorderColor = $colors['card_border_color'] ?? '#e5e7eb';
|
||||
$cardHoverBorderColor = $colors['card_hover_border_color'] ?? '#FF8600';
|
||||
$excerptColor = $colors['excerpt_color'] ?? '#6b7280';
|
||||
$metaColor = $colors['meta_color'] ?? '#9ca3af';
|
||||
$categoryBgColor = $colors['category_bg_color'] ?? '#FFF5EB';
|
||||
$categoryTextColor = $colors['category_text_color'] ?? '#FF8600';
|
||||
|
||||
// Spacing
|
||||
$gridGap = $spacing['grid_gap'] ?? '1.5rem';
|
||||
$cardPadding = $spacing['card_padding'] ?? '1.25rem';
|
||||
|
||||
// Visual effects
|
||||
$cardBorderRadius = $effects['card_border_radius'] ?? '0.5rem';
|
||||
$cardShadow = $effects['card_shadow'] ?? '0 1px 3px rgba(0,0,0,0.1)';
|
||||
$cardHoverShadow = $effects['card_hover_shadow'] ?? '0 4px 12px rgba(0,0,0,0.15)';
|
||||
$cardTransition = $effects['card_transition'] ?? 'all 0.3s ease';
|
||||
|
||||
// Typography
|
||||
$cardTitleSize = $typography['card_title_size'] ?? '1.1rem';
|
||||
$cardTitleWeight = $typography['card_title_weight'] ?? '600';
|
||||
$excerptSize = $typography['excerpt_size'] ?? '0.9rem';
|
||||
$metaSize = $typography['meta_size'] ?? '0.8rem';
|
||||
|
||||
// Container
|
||||
$cssRules[] = $this->cssGenerator->generate($selector, [
|
||||
'margin-bottom' => '2rem',
|
||||
]);
|
||||
|
||||
// Row
|
||||
$cssRules[] = $this->cssGenerator->generate("{$selector} .row", [
|
||||
'row-gap' => $gridGap,
|
||||
]);
|
||||
|
||||
// Card
|
||||
$cssRules[] = "{$selector} .card {
|
||||
background: {$cardBgColor};
|
||||
border: 1px solid {$cardBorderColor};
|
||||
border-radius: {$cardBorderRadius};
|
||||
box-shadow: {$cardShadow};
|
||||
transition: {$cardTransition};
|
||||
height: 100%;
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .card:hover {
|
||||
background: {$cardHoverBgColor};
|
||||
border-color: {$cardHoverBorderColor};
|
||||
box-shadow: {$cardHoverShadow};
|
||||
transform: translateY(-2px);
|
||||
}";
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate("{$selector} .card-body", [
|
||||
'padding' => $cardPadding,
|
||||
]);
|
||||
|
||||
$cssRules[] = "{$selector} .card-img-top {
|
||||
border-radius: {$cardBorderRadius} {$cardBorderRadius} 0 0;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .card-title {
|
||||
color: {$cardTitleColor};
|
||||
font-size: {$cardTitleSize};
|
||||
font-weight: {$cardTitleWeight};
|
||||
line-height: 1.4;
|
||||
margin-bottom: 0.75rem;
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} a:hover .card-title {
|
||||
color: {$cardHoverBorderColor};
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .card-text {
|
||||
color: {$excerptColor};
|
||||
font-size: {$excerptSize};
|
||||
line-height: 1.6;
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .post-meta {
|
||||
color: {$metaColor};
|
||||
font-size: {$metaSize};
|
||||
}";
|
||||
|
||||
$cssRules[] = "{$selector} .post-category {
|
||||
background: {$categoryBgColor};
|
||||
color: {$categoryTextColor};
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}";
|
||||
|
||||
return implode("\n", $cssRules);
|
||||
}
|
||||
|
||||
private function buildHTML(\WP_Query $query, array $settings, array $options): string
|
||||
{
|
||||
$columns = (int) ($options['columns'] ?? 3);
|
||||
$showThumbnail = $this->toBool($options['show_thumbnail'] ?? true);
|
||||
$showExcerpt = $this->toBool($options['show_excerpt'] ?? true);
|
||||
$showMeta = $this->toBool($options['show_meta'] ?? true);
|
||||
$showCategories = $this->toBool($options['show_categories'] ?? true);
|
||||
$excerptLength = (int) ($options['excerpt_length'] ?? 20);
|
||||
$showPagination = $this->toBool($options['show_pagination'] ?? false);
|
||||
|
||||
$containerClass = $this->getContainerClass($options);
|
||||
$colClass = $this->getColumnClass($columns);
|
||||
|
||||
$html = sprintf('<div class="%s">', esc_attr($containerClass));
|
||||
$html .= '<div class="row">';
|
||||
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$html .= $this->buildCardHTML(
|
||||
$colClass,
|
||||
$showThumbnail,
|
||||
$showExcerpt,
|
||||
$showMeta,
|
||||
$showCategories,
|
||||
$excerptLength
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
if ($showPagination && $query->max_num_pages > 1) {
|
||||
$html .= $this->buildPaginationHTML($query, $options);
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function getColumnClass(int $columns): string
|
||||
{
|
||||
return match ($columns) {
|
||||
1 => 'col-12',
|
||||
2 => 'col-12 col-md-6',
|
||||
4 => 'col-12 col-md-6 col-lg-3',
|
||||
default => 'col-12 col-md-6 col-lg-4',
|
||||
};
|
||||
}
|
||||
|
||||
private function buildCardHTML(
|
||||
string $colClass,
|
||||
bool $showThumbnail,
|
||||
bool $showExcerpt,
|
||||
bool $showMeta,
|
||||
bool $showCategories,
|
||||
int $excerptLength
|
||||
): string {
|
||||
$permalink = get_permalink();
|
||||
$title = get_the_title();
|
||||
|
||||
$html = sprintf('<div class="%s mb-4">', esc_attr($colClass));
|
||||
$html .= sprintf('<a href="%s" class="text-decoration-none">', esc_url($permalink));
|
||||
$html .= '<div class="card h-100">';
|
||||
|
||||
if ($showThumbnail) {
|
||||
$html .= $this->buildImageHTML();
|
||||
}
|
||||
|
||||
$html .= '<div class="card-body">';
|
||||
|
||||
if ($showCategories) {
|
||||
$html .= $this->buildCategoriesHTML();
|
||||
}
|
||||
|
||||
$html .= sprintf('<h3 class="card-title">%s</h3>', esc_html($title));
|
||||
|
||||
if ($showMeta) {
|
||||
$html .= $this->buildMetaHTML();
|
||||
}
|
||||
|
||||
if ($showExcerpt) {
|
||||
$html .= $this->buildExcerptHTML($excerptLength);
|
||||
}
|
||||
|
||||
$html .= '</div></div></a></div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildImageHTML(): string
|
||||
{
|
||||
if (has_post_thumbnail()) {
|
||||
return get_the_post_thumbnail(null, 'medium_large', [
|
||||
'class' => 'card-img-top',
|
||||
'loading' => 'lazy'
|
||||
]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function buildCategoriesHTML(): string
|
||||
{
|
||||
$categories = get_the_category();
|
||||
if (empty($categories)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = '<div class="post-categories mb-2">';
|
||||
foreach (array_slice($categories, 0, 2) as $category) {
|
||||
$html .= sprintf(
|
||||
'<span class="post-category">%s</span>',
|
||||
esc_html($category->name)
|
||||
);
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildMetaHTML(): string
|
||||
{
|
||||
return sprintf(
|
||||
'<div class="post-meta mb-2"><small>%s | %s</small></div>',
|
||||
esc_html(get_the_date()),
|
||||
esc_html(get_the_author())
|
||||
);
|
||||
}
|
||||
|
||||
private function buildExcerptHTML(int $length): string
|
||||
{
|
||||
$excerpt = get_the_excerpt();
|
||||
if (empty($excerpt)) {
|
||||
$excerpt = wp_trim_words(get_the_content(), $length, '...');
|
||||
} else {
|
||||
$excerpt = wp_trim_words($excerpt, $length, '...');
|
||||
}
|
||||
|
||||
return sprintf('<p class="card-text">%s</p>', esc_html($excerpt));
|
||||
}
|
||||
|
||||
private function buildPaginationHTML(\WP_Query $query, array $options): string
|
||||
{
|
||||
$id = $options['id'] ?? '';
|
||||
$queryVar = !empty($id) ? "paged_{$id}" : 'paged';
|
||||
$currentPage = max(1, (int) get_query_var($queryVar, 1));
|
||||
$totalPages = $query->max_num_pages;
|
||||
|
||||
$html = '<nav class="pagination-wrapper mt-4"><ul class="pagination justify-content-center">';
|
||||
|
||||
for ($i = 1; $i <= $totalPages; $i++) {
|
||||
$activeClass = ($i === $currentPage) ? ' active' : '';
|
||||
$url = add_query_arg($queryVar, $i);
|
||||
$html .= sprintf(
|
||||
'<li class="page-item%s"><a class="page-link" href="%s">%d</a></li>',
|
||||
$activeClass,
|
||||
esc_url($url),
|
||||
$i
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</ul></nav>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
return $value === 'true' || $value === '1' || $value === 1;
|
||||
}
|
||||
}
|
||||
@@ -1,697 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use ROITheme\Shared\Domain\Contracts\FormBuilderInterface;
|
||||
|
||||
/**
|
||||
* TopNotificationBarFormBuilder - Construye formulario de configuración
|
||||
*
|
||||
* RESPONSABILIDAD: Generar formulario HTML del admin para Top Notification Bar
|
||||
*
|
||||
* CARACTERÍSTICAS:
|
||||
* - 3 secciones: Visibilidad, Contenido, Estilos
|
||||
* - 19 campos configurables
|
||||
* - Lógica condicional (data-conditional-field)
|
||||
* - WordPress Media Library integration
|
||||
* - Vista previa en tiempo real
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Ui
|
||||
*/
|
||||
final class TopNotificationBarFormBuilder implements FormBuilderInterface
|
||||
{
|
||||
public function build(Component $component): string
|
||||
{
|
||||
$data = $component->getData();
|
||||
$componentId = $component->getName();
|
||||
|
||||
$html = '<div class="roi-form-builder roi-top-notification-bar-form">';
|
||||
|
||||
// Sección de Visibilidad
|
||||
$html .= $this->buildVisibilitySection($data, $componentId);
|
||||
|
||||
// Sección de Contenido
|
||||
$html .= $this->buildContentSection($data, $componentId);
|
||||
|
||||
// Sección de Estilos
|
||||
$html .= $this->buildStylesSection($data, $componentId);
|
||||
|
||||
// Vista previa
|
||||
$html .= $this->buildPreviewSection($data);
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
// Agregar scripts de formulario
|
||||
$html .= $this->buildFormScripts($componentId);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildVisibilitySection(array $data, string $componentId): string
|
||||
{
|
||||
$html = '<div class="roi-form-section" data-section="visibility">';
|
||||
$html .= '<h3 class="roi-form-section-title">Visibilidad</h3>';
|
||||
$html .= '<div class="roi-form-section-content">';
|
||||
|
||||
// Is Enabled
|
||||
$isEnabled = $data['visibility']['is_enabled'] ?? true;
|
||||
$html .= $this->buildToggle(
|
||||
'is_enabled',
|
||||
'Mostrar barra de notificación',
|
||||
$isEnabled,
|
||||
$componentId,
|
||||
'Activa o desactiva la barra de notificación superior'
|
||||
);
|
||||
|
||||
// Show On Pages
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
|
||||
$html .= $this->buildSelect(
|
||||
'show_on_pages',
|
||||
'Mostrar en',
|
||||
$showOn,
|
||||
[
|
||||
'all' => 'Todas las páginas',
|
||||
'home' => 'Solo página de inicio',
|
||||
'posts' => 'Solo posts individuales',
|
||||
'pages' => 'Solo páginas',
|
||||
'custom' => 'Páginas específicas'
|
||||
],
|
||||
$componentId,
|
||||
'Define en qué páginas se mostrará la barra'
|
||||
);
|
||||
|
||||
// Custom Page IDs
|
||||
$customPageIds = $data['visibility']['custom_page_ids'] ?? '';
|
||||
$html .= $this->buildTextField(
|
||||
'custom_page_ids',
|
||||
'IDs de páginas específicas',
|
||||
$customPageIds,
|
||||
$componentId,
|
||||
'IDs de páginas separados por comas',
|
||||
'Ej: 1,5,10',
|
||||
['data-conditional-field' => 'show_on_pages', 'data-conditional-value' => 'custom']
|
||||
);
|
||||
|
||||
// Hide On Mobile
|
||||
$hideOnMobile = $data['visibility']['hide_on_mobile'] ?? false;
|
||||
$html .= $this->buildToggle(
|
||||
'hide_on_mobile',
|
||||
'Ocultar en dispositivos móviles',
|
||||
$hideOnMobile,
|
||||
$componentId,
|
||||
'Oculta la barra en pantallas menores a 768px'
|
||||
);
|
||||
|
||||
// Is Dismissible
|
||||
$isDismissible = $data['visibility']['is_dismissible'] ?? false;
|
||||
$html .= $this->buildToggle(
|
||||
'is_dismissible',
|
||||
'Permitir cerrar',
|
||||
$isDismissible,
|
||||
$componentId,
|
||||
'Agrega botón X para que el usuario pueda cerrar la barra'
|
||||
);
|
||||
|
||||
// Dismissible Cookie Days
|
||||
$cookieDays = $data['visibility']['dismissible_cookie_days'] ?? 7;
|
||||
$html .= $this->buildNumberField(
|
||||
'dismissible_cookie_days',
|
||||
'Días antes de volver a mostrar',
|
||||
$cookieDays,
|
||||
$componentId,
|
||||
'Días que permanece oculta después de cerrarla',
|
||||
1,
|
||||
365,
|
||||
['data-conditional-field' => 'is_dismissible', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildContentSection(array $data, string $componentId): string
|
||||
{
|
||||
$html = '<div class="roi-form-section" data-section="content">';
|
||||
$html .= '<h3 class="roi-form-section-title">Contenido</h3>';
|
||||
$html .= '<div class="roi-form-section-content">';
|
||||
|
||||
// Icon Type
|
||||
$iconType = $data['content']['icon_type'] ?? 'bootstrap';
|
||||
$html .= $this->buildSelect(
|
||||
'icon_type',
|
||||
'Tipo de ícono',
|
||||
$iconType,
|
||||
[
|
||||
'bootstrap' => 'Bootstrap Icons',
|
||||
'custom' => 'Imagen personalizada',
|
||||
'none' => 'Sin ícono'
|
||||
],
|
||||
$componentId,
|
||||
'Selecciona el tipo de ícono a mostrar'
|
||||
);
|
||||
|
||||
// Bootstrap Icon
|
||||
$bootstrapIcon = $data['content']['bootstrap_icon'] ?? 'bi-megaphone-fill';
|
||||
$html .= $this->buildTextField(
|
||||
'bootstrap_icon',
|
||||
'Clase de ícono Bootstrap',
|
||||
$bootstrapIcon,
|
||||
$componentId,
|
||||
'Nombre de la clase del ícono sin el prefijo \'bi\' (ej: megaphone-fill)',
|
||||
'Ej: bi-megaphone-fill',
|
||||
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'bootstrap']
|
||||
);
|
||||
|
||||
// Custom Icon URL
|
||||
$customIconUrl = $data['content']['custom_icon_url'] ?? '';
|
||||
$html .= $this->buildMediaField(
|
||||
'custom_icon_url',
|
||||
'Imagen personalizada',
|
||||
$customIconUrl,
|
||||
$componentId,
|
||||
'Sube una imagen personalizada (recomendado: PNG 24x24px)',
|
||||
['data-conditional-field' => 'icon_type', 'data-conditional-value' => 'custom']
|
||||
);
|
||||
|
||||
// Announcement Label
|
||||
$announcementLabel = $data['content']['announcement_label'] ?? 'Nuevo:';
|
||||
$html .= $this->buildTextField(
|
||||
'announcement_label',
|
||||
'Etiqueta del anuncio',
|
||||
$announcementLabel,
|
||||
$componentId,
|
||||
'Texto destacado en negrita antes del mensaje',
|
||||
'Ej: Nuevo:, Importante:, Aviso:'
|
||||
);
|
||||
|
||||
// Announcement Text
|
||||
$announcementText = $data['content']['announcement_text'] ?? 'Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.';
|
||||
$html .= $this->buildTextArea(
|
||||
'announcement_text',
|
||||
'Texto del anuncio',
|
||||
$announcementText,
|
||||
$componentId,
|
||||
'Mensaje principal del anuncio (máximo 200 caracteres)',
|
||||
3
|
||||
);
|
||||
|
||||
// Link Enabled
|
||||
$linkEnabled = $data['content']['link_enabled'] ?? true;
|
||||
$html .= $this->buildToggle(
|
||||
'link_enabled',
|
||||
'Mostrar enlace',
|
||||
$linkEnabled,
|
||||
$componentId,
|
||||
'Activa o desactiva el enlace de acción'
|
||||
);
|
||||
|
||||
// Link Text
|
||||
$linkText = $data['content']['link_text'] ?? 'Ver Catálogo';
|
||||
$html .= $this->buildTextField(
|
||||
'link_text',
|
||||
'Texto del enlace',
|
||||
$linkText,
|
||||
$componentId,
|
||||
'Texto del enlace de acción',
|
||||
'',
|
||||
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
// Link URL
|
||||
$linkUrl = $data['content']['link_url'] ?? '#';
|
||||
$html .= $this->buildUrlField(
|
||||
'link_url',
|
||||
'URL del enlace',
|
||||
$linkUrl,
|
||||
$componentId,
|
||||
'URL de destino del enlace',
|
||||
'https://',
|
||||
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
// Link Target
|
||||
$linkTarget = $data['content']['link_target'] ?? '_self';
|
||||
$html .= $this->buildSelect(
|
||||
'link_target',
|
||||
'Abrir enlace en',
|
||||
$linkTarget,
|
||||
[
|
||||
'_self' => 'Misma ventana',
|
||||
'_blank' => 'Nueva ventana'
|
||||
],
|
||||
$componentId,
|
||||
'Define cómo se abrirá el enlace',
|
||||
['data-conditional-field' => 'link_enabled', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildStylesSection(array $data, string $componentId): string
|
||||
{
|
||||
$html = '<div class="roi-form-section" data-section="styles">';
|
||||
$html .= '<h3 class="roi-form-section-title">Estilos</h3>';
|
||||
$html .= '<div class="roi-form-section-content">';
|
||||
|
||||
// Background Color
|
||||
$bgColor = $data['styles']['background_color'] ?? '#FF8600';
|
||||
$html .= $this->buildColorField(
|
||||
'background_color',
|
||||
'Color de fondo',
|
||||
$bgColor,
|
||||
$componentId,
|
||||
'Color de fondo de la barra (por defecto: orange primary)'
|
||||
);
|
||||
|
||||
// Text Color
|
||||
$textColor = $data['styles']['text_color'] ?? '#FFFFFF';
|
||||
$html .= $this->buildColorField(
|
||||
'text_color',
|
||||
'Color del texto',
|
||||
$textColor,
|
||||
$componentId,
|
||||
'Color del texto del anuncio'
|
||||
);
|
||||
|
||||
// Link Color
|
||||
$linkColor = $data['styles']['link_color'] ?? '#FFFFFF';
|
||||
$html .= $this->buildColorField(
|
||||
'link_color',
|
||||
'Color del enlace',
|
||||
$linkColor,
|
||||
$componentId,
|
||||
'Color del enlace de acción'
|
||||
);
|
||||
|
||||
// Font Size
|
||||
$fontSize = $data['styles']['font_size'] ?? 'small';
|
||||
$html .= $this->buildSelect(
|
||||
'font_size',
|
||||
'Tamaño de fuente',
|
||||
$fontSize,
|
||||
[
|
||||
'extra-small' => 'Muy pequeño (0.75rem)',
|
||||
'small' => 'Pequeño (0.875rem)',
|
||||
'normal' => 'Normal (1rem)',
|
||||
'large' => 'Grande (1.125rem)'
|
||||
],
|
||||
$componentId,
|
||||
'Tamaño del texto del anuncio'
|
||||
);
|
||||
|
||||
// Padding Vertical
|
||||
$padding = $data['styles']['padding_vertical'] ?? 'normal';
|
||||
$html .= $this->buildSelect(
|
||||
'padding_vertical',
|
||||
'Padding vertical',
|
||||
$padding,
|
||||
[
|
||||
'compact' => 'Compacto (0.5rem)',
|
||||
'normal' => 'Normal (0.75rem)',
|
||||
'spacious' => 'Espacioso (1rem)'
|
||||
],
|
||||
$componentId,
|
||||
'Espaciado vertical interno de la barra'
|
||||
);
|
||||
|
||||
// Text Alignment
|
||||
$alignment = $data['styles']['text_alignment'] ?? 'center';
|
||||
$html .= $this->buildSelect(
|
||||
'text_alignment',
|
||||
'Alineación del texto',
|
||||
$alignment,
|
||||
[
|
||||
'left' => 'Izquierda',
|
||||
'center' => 'Centro',
|
||||
'right' => 'Derecha'
|
||||
],
|
||||
$componentId,
|
||||
'Alineación del contenido de la barra'
|
||||
);
|
||||
|
||||
// Animation Enabled
|
||||
$animationEnabled = $data['styles']['animation_enabled'] ?? false;
|
||||
$html .= $this->buildToggle(
|
||||
'animation_enabled',
|
||||
'Activar animación',
|
||||
$animationEnabled,
|
||||
$componentId,
|
||||
'Activa animación de entrada al cargar la página'
|
||||
);
|
||||
|
||||
// Animation Type
|
||||
$animationType = $data['styles']['animation_type'] ?? 'slide-down';
|
||||
$html .= $this->buildSelect(
|
||||
'animation_type',
|
||||
'Tipo de animación',
|
||||
$animationType,
|
||||
[
|
||||
'slide-down' => 'Deslizar desde arriba',
|
||||
'fade-in' => 'Aparecer gradualmente'
|
||||
],
|
||||
$componentId,
|
||||
'Tipo de animación de entrada',
|
||||
['data-conditional-field' => 'animation_enabled', 'data-conditional-value' => 'true']
|
||||
);
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildPreviewSection(array $data): string
|
||||
{
|
||||
$html = '<div class="roi-form-section roi-preview-section">';
|
||||
$html .= '<h3 class="roi-form-section-title">Vista Previa</h3>';
|
||||
$html .= '<div class="roi-form-section-content">';
|
||||
$html .= '<div id="roi-component-preview" class="border rounded p-3 bg-light">';
|
||||
$html .= '<p class="text-muted">La vista previa se actualizará automáticamente al modificar los campos.</p>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildToggle(string $name, string $label, bool $value, string $componentId, string $description = ''): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
$checked = $value ? 'checked' : '';
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-toggle mb-3">';
|
||||
$html .= '<div class="form-check form-switch">';
|
||||
$html .= sprintf(
|
||||
'<input type="checkbox" class="form-check-input" id="%s" name="roi_component[%s][%s]" value="1" %s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
$checked
|
||||
);
|
||||
$html .= sprintf('<label class="form-check-label" for="%s">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
$html .= '</div>';
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildTextField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-text mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" placeholder="%s"%s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
esc_attr($value),
|
||||
esc_attr($placeholder),
|
||||
$attrString
|
||||
);
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildTextArea(string $name, string $label, string $value, string $componentId, string $description = '', int $rows = 3, array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-textarea mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<textarea class="form-control" id="%s" name="roi_component[%s][%s]" rows="%d"%s>%s</textarea>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
$rows,
|
||||
$attrString,
|
||||
esc_textarea($value)
|
||||
);
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildSelect(string $name, string $label, string $value, array $options, string $componentId, string $description = '', array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-select mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<select class="form-select" id="%s" name="roi_component[%s][%s]"%s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
$attrString
|
||||
);
|
||||
|
||||
foreach ($options as $optValue => $optLabel) {
|
||||
$selected = ($value === $optValue) ? 'selected' : '';
|
||||
$html .= sprintf(
|
||||
'<option value="%s" %s>%s</option>',
|
||||
esc_attr($optValue),
|
||||
$selected,
|
||||
esc_html($optLabel)
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</select>';
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildNumberField(string $name, string $label, $value, string $componentId, string $description = '', int $min = null, int $max = null, array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-number mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
|
||||
$attrs['type'] = 'number';
|
||||
if ($min !== null) {
|
||||
$attrs['min'] = $min;
|
||||
}
|
||||
if ($max !== null) {
|
||||
$attrs['max'] = $max;
|
||||
}
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<input class="form-control" id="%s" name="roi_component[%s][%s]" value="%s"%s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
esc_attr($value),
|
||||
$attrString
|
||||
);
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildUrlField(string $name, string $label, string $value, string $componentId, string $description = '', string $placeholder = '', array $attrs = []): string
|
||||
{
|
||||
$attrs['type'] = 'url';
|
||||
return $this->buildTextField($name, $label, $value, $componentId, $description, $placeholder, $attrs);
|
||||
}
|
||||
|
||||
private function buildColorField(string $name, string $label, string $value, string $componentId, string $description = ''): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-color mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
$html .= '<div class="input-group">';
|
||||
$html .= sprintf(
|
||||
'<input type="color" class="form-control form-control-color" id="%s" name="roi_component[%s][%s]" value="%s">',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
esc_attr($value)
|
||||
);
|
||||
$html .= sprintf(
|
||||
'<input type="text" class="form-control" value="%s" readonly>',
|
||||
esc_attr($value)
|
||||
);
|
||||
$html .= '</div>';
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildMediaField(string $name, string $label, string $value, string $componentId, string $description = '', array $attrs = []): string
|
||||
{
|
||||
$fieldId = "roi_{$componentId}_{$name}";
|
||||
|
||||
$html = '<div class="roi-form-field roi-form-field-media mb-3">';
|
||||
$html .= sprintf('<label for="%s" class="form-label">%s</label>', esc_attr($fieldId), esc_html($label));
|
||||
$html .= '<div class="input-group">';
|
||||
|
||||
$attrString = $this->buildAttributesString($attrs);
|
||||
|
||||
$html .= sprintf(
|
||||
'<input type="text" class="form-control" id="%s" name="roi_component[%s][%s]" value="%s" readonly%s>',
|
||||
esc_attr($fieldId),
|
||||
esc_attr($componentId),
|
||||
esc_attr($name),
|
||||
esc_attr($value),
|
||||
$attrString
|
||||
);
|
||||
$html .= sprintf(
|
||||
'<button type="button" class="btn btn-primary roi-media-upload-btn" data-target="%s">Seleccionar</button>',
|
||||
esc_attr($fieldId)
|
||||
);
|
||||
$html .= '</div>';
|
||||
|
||||
if (!empty($value)) {
|
||||
$html .= sprintf('<div class="mt-2"><img src="%s" alt="Preview" style="max-width: 100px; height: auto;"></div>', esc_url($value));
|
||||
}
|
||||
|
||||
if (!empty($description)) {
|
||||
$html .= sprintf('<small class="form-text text-muted">%s</small>', esc_html($description));
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildAttributesString(array $attrs): string
|
||||
{
|
||||
$attrString = '';
|
||||
foreach ($attrs as $key => $value) {
|
||||
$attrString .= sprintf(' %s="%s"', esc_attr($key), esc_attr($value));
|
||||
}
|
||||
return $attrString;
|
||||
}
|
||||
|
||||
private function buildFormScripts(string $componentId): string
|
||||
{
|
||||
return <<<SCRIPT
|
||||
<script>
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
$(document).ready(function() {
|
||||
// Conditional logic
|
||||
$('[data-conditional-field]').each(function() {
|
||||
const field = $(this);
|
||||
const targetFieldName = field.data('conditional-field');
|
||||
const targetValue = field.data('conditional-value');
|
||||
const targetField = $('[name*="[' + targetFieldName + ']"]');
|
||||
|
||||
function updateVisibility() {
|
||||
let currentValue;
|
||||
if (targetField.is(':checkbox')) {
|
||||
currentValue = targetField.is(':checked') ? 'true' : 'false';
|
||||
} else {
|
||||
currentValue = targetField.val();
|
||||
}
|
||||
|
||||
if (currentValue === targetValue) {
|
||||
field.closest('.roi-form-field').show();
|
||||
} else {
|
||||
field.closest('.roi-form-field').hide();
|
||||
}
|
||||
}
|
||||
|
||||
targetField.on('change', updateVisibility);
|
||||
updateVisibility();
|
||||
});
|
||||
|
||||
// Media upload
|
||||
$('.roi-media-upload-btn').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
const button = $(this);
|
||||
const targetId = button.data('target');
|
||||
const targetField = $('#' + targetId);
|
||||
|
||||
const mediaUploader = wp.media({
|
||||
title: 'Seleccionar imagen',
|
||||
button: { text: 'Usar esta imagen' },
|
||||
multiple: false
|
||||
});
|
||||
|
||||
mediaUploader.on('select', function() {
|
||||
const attachment = mediaUploader.state().get('selection').first().toJSON();
|
||||
targetField.val(attachment.url);
|
||||
|
||||
const preview = targetField.closest('.roi-form-field-media').find('img');
|
||||
if (preview.length) {
|
||||
preview.attr('src', attachment.url);
|
||||
} else {
|
||||
targetField.closest('.input-group').after('<div class="mt-2"><img src="' + attachment.url + '" alt="Preview" style="max-width: 100px; height: auto;"></div>');
|
||||
}
|
||||
});
|
||||
|
||||
mediaUploader.open();
|
||||
});
|
||||
|
||||
// Color picker sync
|
||||
$('.form-control-color').on('change', function() {
|
||||
$(this).next('input[type="text"]').val($(this).val());
|
||||
});
|
||||
|
||||
// Auto-update preview
|
||||
$('.roi-form-field input, .roi-form-field select, .roi-form-field textarea').on('change keyup', function() {
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
function updatePreview() {
|
||||
// Aquí iría la lógica para actualizar la vista previa en tiempo real
|
||||
console.log('Preview updated');
|
||||
}
|
||||
});
|
||||
})(jQuery);
|
||||
</script>
|
||||
SCRIPT;
|
||||
}
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'top-notification-bar';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Wordpress;
|
||||
|
||||
use ROITheme\Shared\Infrastructure\Di\DIContainer;
|
||||
use ROITheme\Shared\Application\UseCases\RenderPostGrid\RenderPostGridRequest;
|
||||
|
||||
/**
|
||||
* Registra el shortcode [roi_post_grid] en WordPress
|
||||
*
|
||||
* RESPONSABILIDAD:
|
||||
* - Registrar shortcode via add_shortcode
|
||||
* - Sanitizar atributos del shortcode
|
||||
* - Delegar ejecucion a RenderPostGridUseCase
|
||||
*
|
||||
* NO RESPONSABLE DE:
|
||||
* - Logica de negocio
|
||||
* - Construccion de queries
|
||||
* - Generacion de HTML/CSS
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Wordpress
|
||||
*/
|
||||
final class PostGridShortcodeRegistrar
|
||||
{
|
||||
private const SHORTCODE_TAG = 'roi_post_grid';
|
||||
|
||||
/**
|
||||
* Registra el shortcode en WordPress
|
||||
*/
|
||||
public static function register(): void
|
||||
{
|
||||
add_shortcode(self::SHORTCODE_TAG, [new self(), 'handleShortcode']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback del shortcode
|
||||
*
|
||||
* @param array|string $atts Atributos del shortcode
|
||||
* @return string HTML del grid
|
||||
*/
|
||||
public function handleShortcode($atts): string
|
||||
{
|
||||
$atts = $this->sanitizeAttributes($atts);
|
||||
|
||||
// Obtener paged desde query var si existe
|
||||
$id = $atts['id'] ?? '';
|
||||
$queryVar = !empty($id) ? "paged_{$id}" : 'paged';
|
||||
$atts['paged'] = max(1, (int) get_query_var($queryVar, 1));
|
||||
|
||||
// Crear request DTO
|
||||
$request = RenderPostGridRequest::fromArray($atts);
|
||||
|
||||
// Obtener UseCase desde DIContainer
|
||||
$container = DIContainer::getInstance();
|
||||
$useCase = $container->getRenderPostGridUseCase();
|
||||
|
||||
return $useCase->execute($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitiza atributos del shortcode
|
||||
*
|
||||
* @param array|string $atts
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function sanitizeAttributes($atts): array
|
||||
{
|
||||
$atts = shortcode_atts([
|
||||
'category' => '',
|
||||
'exclude_category' => '',
|
||||
'tag' => '',
|
||||
'author' => '',
|
||||
'posts_per_page' => '9',
|
||||
'columns' => '3',
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'show_pagination' => 'false',
|
||||
'offset' => '0',
|
||||
'exclude_posts' => '',
|
||||
'show_thumbnail' => 'true',
|
||||
'show_excerpt' => 'true',
|
||||
'show_meta' => 'true',
|
||||
'show_categories' => 'true',
|
||||
'excerpt_length' => '20',
|
||||
'class' => '',
|
||||
'id' => '',
|
||||
], $atts, self::SHORTCODE_TAG);
|
||||
|
||||
// Sanitizar cada valor
|
||||
return array_map(function ($value) {
|
||||
return is_string($value) ? sanitize_text_field($value) : $value;
|
||||
}, $atts);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Part: CTA Box Sidebar
|
||||
*
|
||||
* Caja de llamada a la acción naranja en el sidebar
|
||||
* Abre el modal de contacto al hacer clic
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
?>
|
||||
|
||||
<!-- DEBUG: CTA Box Template Loaded -->
|
||||
<!-- CTA Box Sidebar -->
|
||||
<div class="cta-box-sidebar">
|
||||
<h5 class="cta-box-title">¿Listo para potenciar tus proyectos?</h5>
|
||||
<p class="cta-box-text">Accede a nuestra biblioteca completa de APUs y herramientas profesionales.</p>
|
||||
<button class="btn btn-cta-box w-100" data-bs-toggle="modal" data-bs-target="#contactModal">
|
||||
Solicitar Información
|
||||
</button>
|
||||
</div>
|
||||
<!-- DEBUG: CTA Box Template End -->
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Part: Table of Contents (TOC)
|
||||
*
|
||||
* Genera automáticamente TOC desde los H2 del post
|
||||
* HTML exacto del template original
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
// Solo mostrar TOC si estamos en single post
|
||||
if (!is_single()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Obtener el contenido del post actual
|
||||
global $post;
|
||||
$post_content = $post->post_content;
|
||||
|
||||
// Aplicar filtros de WordPress al contenido
|
||||
$post_content = apply_filters('the_content', $post_content);
|
||||
|
||||
// Buscar todos los H2 con ID en el contenido
|
||||
preg_match_all('/<h2[^>]*id=["\']([^"\']*)["\'][^>]*>(.*?)<\/h2>/i', $post_content, $matches);
|
||||
|
||||
// Si no hay H2 con ID, no mostrar TOC
|
||||
if (empty($matches[1])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generar el TOC con el HTML del template
|
||||
?>
|
||||
<div class="toc-container">
|
||||
<h4>Tabla de Contenido</h4>
|
||||
<ol class="list-unstyled toc-list">
|
||||
<?php foreach ($matches[1] as $index => $id) : ?>
|
||||
<?php $title = strip_tags($matches[2][$index]); ?>
|
||||
<li><a href="#<?php echo esc_attr($id); ?>"><?php echo esc_html($title); ?></a></li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* CTA Box Sidebar Template
|
||||
*
|
||||
* Aparece debajo del TOC en single posts
|
||||
*
|
||||
* @package ROI_Theme
|
||||
*/
|
||||
|
||||
if (!is_single()) {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="cta-box-sidebar mt-3">
|
||||
<h5 class="cta-box-title">¿Listo para potenciar tus proyectos?</h5>
|
||||
<p class="cta-box-text">Accede a nuestra biblioteca completa de APUs y herramientas profesionales.</p>
|
||||
<button class="btn btn-cta-box w-100" data-bs-toggle="modal" data-bs-target="#contactModal">
|
||||
<i class="bi bi-calendar-check me-2"></i>Solicitar Demo
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Modal de Contacto - Bootstrap 5
|
||||
*
|
||||
* Modal activado por botón "Let's Talk" y CTA Box Sidebar
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 1.0.0
|
||||
*/
|
||||
?>
|
||||
|
||||
<!-- Contact Modal -->
|
||||
<div class="modal fade" id="contactModal" tabindex="-1" aria-labelledby="contactModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="contactModalLabel">
|
||||
<i class="bi bi-envelope-fill me-2" style="color: #FF8600;"></i>
|
||||
<?php esc_html_e( '¿Tienes alguna pregunta?', 'roi-theme' ); ?>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="<?php esc_attr_e( 'Cerrar', 'roi-theme' ); ?>"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-4">
|
||||
<?php esc_html_e( 'Completa el formulario y nuestro equipo te responderá en menos de 24 horas.', 'roi-theme' ); ?>
|
||||
</p>
|
||||
|
||||
<form id="modalContactForm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="modalFullName" class="form-label"><?php esc_html_e( 'Nombre completo', 'roi-theme' ); ?> *</label>
|
||||
<input type="text" class="form-control" id="modalFullName" name="fullName" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="modalCompany" class="form-label"><?php esc_html_e( 'Empresa', 'roi-theme' ); ?></label>
|
||||
<input type="text" class="form-control" id="modalCompany" name="company">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="modalWhatsapp" class="form-label"><?php esc_html_e( 'WhatsApp', 'roi-theme' ); ?> *</label>
|
||||
<input type="tel" class="form-control" id="modalWhatsapp" name="whatsapp" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="modalEmail" class="form-label"><?php esc_html_e( 'Correo electrónico', 'roi-theme' ); ?> *</label>
|
||||
<input type="email" class="form-control" id="modalEmail" name="email" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="modalComments" class="form-label"><?php esc_html_e( '¿En qué podemos ayudarte?', 'roi-theme' ); ?></label>
|
||||
<textarea class="form-control" id="modalComments" name="comments" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-send-fill me-2"></i><?php esc_html_e( 'Enviar Mensaje', 'roi-theme' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
<div id="modalFormMessage" class="col-12 mt-2 alert" style="display: none;"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Top Notification Bar Component
|
||||
*
|
||||
* Barra de notificaciones superior del sitio
|
||||
*
|
||||
* @package ROI_Theme
|
||||
* @since 2.0.0
|
||||
*/
|
||||
?>
|
||||
|
||||
<div class="top-notification-bar">
|
||||
<div class="container">
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<i class="bi bi-megaphone-fill me-2"></i>
|
||||
<span><strong>Nuevo:</strong> Accede a más de 200,000 Análisis de Precios Unitarios actualizados para 2025.</span>
|
||||
<a href="#" class="ms-2 text-white text-decoration-underline">Ver Catálogo</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
285
archive.php
285
archive.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying archive pages
|
||||
*
|
||||
* This template displays date-based, category, tag, author, and post type
|
||||
* archives with a dynamic title, description, and post loop.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#archive
|
||||
*
|
||||
@@ -16,211 +17,97 @@ get_header();
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
|
||||
<!-- Archive Header -->
|
||||
<header class="page-header">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
// Archive title
|
||||
the_archive_title( '<h1 class="page-title">', '</h1>' );
|
||||
|
||||
// Archive description
|
||||
$archive_description = get_the_archive_description();
|
||||
if ( ! empty( $archive_description ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Archive Header - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Social Share - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('social-share');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Post - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-post');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Related Post - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('related-post');
|
||||
}
|
||||
?>
|
||||
|
||||
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
|
||||
|
||||
<?php if ($show_sidebar): ?>
|
||||
<!-- Sidebar Column (col-lg-3) -->
|
||||
<div class="col-lg-3">
|
||||
<div class="sidebar-sticky">
|
||||
<!-- Table of Contents - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('table-of-contents');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Box Sidebar - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-box-sidebar');
|
||||
}
|
||||
?>
|
||||
<div class="archive-description">
|
||||
<?php echo wp_kses_post( wpautop( $archive_description ) ); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</header><!-- .page-header -->
|
||||
|
||||
<!-- Archive Posts Loop -->
|
||||
<div class="archive-posts">
|
||||
|
||||
<?php
|
||||
// Start the WordPress Loop
|
||||
while ( have_posts() ) :
|
||||
the_post();
|
||||
?>
|
||||
|
||||
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
|
||||
|
||||
<!-- Post Thumbnail -->
|
||||
<?php if ( has_post_thumbnail() ) : ?>
|
||||
<div class="post-thumbnail">
|
||||
<a href="<?php the_permalink(); ?>" aria-hidden="true" tabindex="-1">
|
||||
<?php
|
||||
the_post_thumbnail(
|
||||
'roi-featured-medium',
|
||||
array(
|
||||
'alt' => the_title_attribute(
|
||||
array(
|
||||
'echo' => false,
|
||||
)
|
||||
),
|
||||
'loading' => 'lazy',
|
||||
)
|
||||
);
|
||||
?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Post Content -->
|
||||
<div class="post-content">
|
||||
|
||||
<!-- Post Header -->
|
||||
<header class="entry-header">
|
||||
|
||||
<!-- Category Badges -->
|
||||
<?php
|
||||
$categories = get_the_category();
|
||||
if ( ! empty( $categories ) ) :
|
||||
?>
|
||||
<div class="entry-categories">
|
||||
<?php foreach ( $categories as $category ) : ?>
|
||||
<a href="<?php echo esc_url( get_category_link( $category->term_id ) ); ?>"
|
||||
class="category-badge"
|
||||
rel="category tag">
|
||||
<?php echo esc_html( $category->name ); ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Post Title -->
|
||||
<?php
|
||||
if ( is_singular() ) :
|
||||
the_title( '<h1 class="entry-title">', '</h1>' );
|
||||
else :
|
||||
the_title(
|
||||
sprintf(
|
||||
'<h2 class="entry-title"><a href="%s" rel="bookmark">',
|
||||
esc_url( get_permalink() )
|
||||
),
|
||||
'</a></h2>'
|
||||
);
|
||||
endif;
|
||||
?>
|
||||
|
||||
<!-- Post Meta -->
|
||||
<div class="entry-meta">
|
||||
<span class="posted-on">
|
||||
<time class="entry-date published" datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>">
|
||||
<?php echo esc_html( get_the_date() ); ?>
|
||||
</time>
|
||||
</span>
|
||||
<span class="byline">
|
||||
<span class="author vcard">
|
||||
<a class="url fn n" href="<?php echo esc_url( get_author_posts_url( get_the_author_meta( 'ID' ) ) ); ?>">
|
||||
<?php echo esc_html( get_the_author() ); ?>
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
</div><!-- .entry-meta -->
|
||||
|
||||
</header><!-- .entry-header -->
|
||||
|
||||
<!-- Post Excerpt -->
|
||||
<div class="entry-summary">
|
||||
<?php the_excerpt(); ?>
|
||||
</div><!-- .entry-summary -->
|
||||
|
||||
<!-- Read More Link -->
|
||||
<div class="entry-footer">
|
||||
<a href="<?php the_permalink(); ?>" class="read-more-link">
|
||||
<?php
|
||||
printf(
|
||||
wp_kses(
|
||||
/* translators: %s: Post title. Only visible to screen readers. */
|
||||
__( 'Continue reading<span class="screen-reader-text"> "%s"</span>', 'roi-theme' ),
|
||||
array(
|
||||
'span' => array(
|
||||
'class' => array(),
|
||||
),
|
||||
)
|
||||
),
|
||||
get_the_title()
|
||||
);
|
||||
?>
|
||||
<span class="read-more-icon" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</div><!-- .entry-footer -->
|
||||
|
||||
</div><!-- .post-content -->
|
||||
|
||||
</article><!-- #post-<?php the_ID(); ?> -->
|
||||
|
||||
<?php endwhile; ?>
|
||||
|
||||
</div><!-- .archive-posts -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Pagination
|
||||
* Display navigation to next/previous set of posts when applicable.
|
||||
*/
|
||||
the_posts_pagination(
|
||||
array(
|
||||
'mid_size' => 2,
|
||||
'prev_text' => sprintf(
|
||||
'%s <span class="nav-prev-text">%s</span>',
|
||||
'<span class="nav-icon" aria-hidden="true">«</span>',
|
||||
esc_html__( 'Previous', 'roi-theme' )
|
||||
),
|
||||
'next_text' => sprintf(
|
||||
'<span class="nav-next-text">%s</span> %s',
|
||||
esc_html__( 'Next', 'roi-theme' ),
|
||||
'<span class="nav-icon" aria-hidden="true">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
?>
|
||||
<section class="no-results not-found">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">
|
||||
<?php esc_html_e( 'Nothing Found', 'roi-theme' ); ?>
|
||||
</h1>
|
||||
</header><!-- .page-header -->
|
||||
|
||||
<div class="page-content">
|
||||
<p>
|
||||
<?php esc_html_e( 'It seems we can’t find what you’re looking for. Perhaps searching can help.', 'roi-theme' ); ?>
|
||||
</p>
|
||||
</div><!-- .page-content -->
|
||||
</section><!-- .no-results -->
|
||||
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
195
author.php
195
author.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying author archive pages
|
||||
*
|
||||
* This template displays posts from a specific author with author
|
||||
* bio and information at the top.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#author
|
||||
*
|
||||
@@ -16,148 +17,76 @@ get_header();
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
|
||||
<!-- Author Archive Header -->
|
||||
<header class="page-header author-header">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
// Get the author
|
||||
$author = get_queried_object();
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="author-info">
|
||||
<!-- Author Avatar -->
|
||||
<div class="author-avatar">
|
||||
<!-- Archive Header - Componente dinamico (detecta autor automaticamente) -->
|
||||
<?php
|
||||
echo get_avatar(
|
||||
$author->ID,
|
||||
120,
|
||||
'',
|
||||
sprintf(
|
||||
/* translators: %s: author name */
|
||||
esc_attr__( 'Avatar for %s', 'roi-theme' ),
|
||||
esc_html( $author->display_name )
|
||||
),
|
||||
array(
|
||||
'class' => 'author-avatar-img',
|
||||
)
|
||||
);
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
|
||||
|
||||
<?php if ($show_sidebar): ?>
|
||||
<!-- Sidebar Column (col-lg-3) -->
|
||||
<div class="col-lg-3">
|
||||
<div class="sidebar-sticky">
|
||||
<!-- Table of Contents - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('table-of-contents');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Box Sidebar - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-box-sidebar');
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<!-- Author Details -->
|
||||
<div class="author-details">
|
||||
<h1 class="page-title author-title">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: author display name */
|
||||
esc_html__( 'Posts by %s', 'roi-theme' ),
|
||||
'<span class="author-name">' . esc_html( $author->display_name ) . '</span>'
|
||||
);
|
||||
?>
|
||||
</h1>
|
||||
|
||||
<!-- Author Bio -->
|
||||
<?php
|
||||
$author_bio = get_the_author_meta( 'description', $author->ID );
|
||||
if ( ! empty( $author_bio ) ) :
|
||||
?>
|
||||
<div class="author-bio">
|
||||
<?php echo wp_kses_post( wpautop( $author_bio ) ); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Author Stats -->
|
||||
<div class="author-meta">
|
||||
<span class="author-posts-count">
|
||||
<?php
|
||||
$post_count = count_user_posts( $author->ID, 'post', true );
|
||||
printf(
|
||||
/* translators: %s: number of posts */
|
||||
esc_html( _n( '%s post', '%s posts', $post_count, 'roi-theme' ) ),
|
||||
esc_html( number_format_i18n( $post_count ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header><!-- .page-header -->
|
||||
|
||||
<!-- Author Posts Loop -->
|
||||
<div class="archive-posts author-posts">
|
||||
|
||||
<?php
|
||||
// Start the WordPress Loop
|
||||
while ( have_posts() ) :
|
||||
the_post();
|
||||
|
||||
/**
|
||||
* Include the Post-Type-specific template for the content.
|
||||
* If you want to override this in a child theme, then include a file
|
||||
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', get_post_type() );
|
||||
|
||||
endwhile;
|
||||
?>
|
||||
|
||||
</div><!-- .archive-posts -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Pagination
|
||||
* Display navigation to next/previous set of posts when applicable.
|
||||
*/
|
||||
the_posts_pagination(
|
||||
array(
|
||||
'mid_size' => 2,
|
||||
'prev_text' => sprintf(
|
||||
'%s <span class="nav-prev-text">%s</span>',
|
||||
'<span class="nav-icon" aria-hidden="true">«</span>',
|
||||
esc_html__( 'Previous', 'roi-theme' )
|
||||
),
|
||||
'next_text' => sprintf(
|
||||
'<span class="nav-next-text">%s</span> %s',
|
||||
esc_html__( 'Next', 'roi-theme' ),
|
||||
'<span class="nav-icon" aria-hidden="true">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
192
category.php
192
category.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying category archive pages
|
||||
*
|
||||
* This template displays posts from a specific category with category
|
||||
* information and description at the top.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#category
|
||||
*
|
||||
@@ -16,116 +17,97 @@ get_header();
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
|
||||
<!-- Category Archive Header -->
|
||||
<header class="page-header category-header">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
// Category title
|
||||
the_archive_title( '<h1 class="page-title category-title">', '</h1>' );
|
||||
|
||||
// Category description
|
||||
$category_description = category_description();
|
||||
if ( ! empty( $category_description ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
<div class="archive-description category-description">
|
||||
<?php echo wp_kses_post( wpautop( $category_description ) ); ?>
|
||||
|
||||
<!-- Archive Header - Componente dinamico (detecta categoria automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Social Share - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('social-share');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Post - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-post');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Related Post - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('related-post');
|
||||
}
|
||||
?>
|
||||
|
||||
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
|
||||
|
||||
<?php if ($show_sidebar): ?>
|
||||
<!-- Sidebar Column (col-lg-3) -->
|
||||
<div class="col-lg-3">
|
||||
<div class="sidebar-sticky">
|
||||
<!-- Table of Contents - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('table-of-contents');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Box Sidebar - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-box-sidebar');
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Category metadata -->
|
||||
<?php
|
||||
$category = get_queried_object();
|
||||
if ( $category ) :
|
||||
?>
|
||||
<div class="category-meta">
|
||||
<span class="category-count">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: number of posts */
|
||||
esc_html( _n( '%s post', '%s posts', $category->count, 'roi-theme' ) ),
|
||||
esc_html( number_format_i18n( $category->count ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</header><!-- .page-header -->
|
||||
|
||||
<!-- Category Posts Loop -->
|
||||
<div class="archive-posts category-posts">
|
||||
|
||||
<?php
|
||||
// Start the WordPress Loop
|
||||
while ( have_posts() ) :
|
||||
the_post();
|
||||
|
||||
/**
|
||||
* Include the Post-Type-specific template for the content.
|
||||
* If you want to override this in a child theme, then include a file
|
||||
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', get_post_type() );
|
||||
|
||||
endwhile;
|
||||
?>
|
||||
|
||||
</div><!-- .archive-posts -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Pagination
|
||||
* Display navigation to next/previous set of posts when applicable.
|
||||
*/
|
||||
the_posts_pagination(
|
||||
array(
|
||||
'mid_size' => 2,
|
||||
'prev_text' => sprintf(
|
||||
'%s <span class="nav-prev-text">%s</span>',
|
||||
'<span class="nav-icon" aria-hidden="true">«</span>',
|
||||
esc_html__( 'Previous', 'roi-theme' )
|
||||
),
|
||||
'next_text' => sprintf(
|
||||
'<span class="nav-next-text">%s</span> %s',
|
||||
esc_html__( 'Next', 'roi-theme' ),
|
||||
'<span class="nav-icon" aria-hidden="true">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
168
date.php
168
date.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying date-based archive pages
|
||||
*
|
||||
* This template displays posts from a specific date (year, month, or day)
|
||||
* with the date information displayed at the top.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#date
|
||||
*
|
||||
@@ -16,113 +17,76 @@ get_header();
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
|
||||
<!-- Date Archive Header -->
|
||||
<header class="page-header date-header">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
// Date archive title
|
||||
the_archive_title( '<h1 class="page-title date-title">', '</h1>' );
|
||||
|
||||
// Date archive description
|
||||
$date_description = get_the_archive_description();
|
||||
if ( ! empty( $date_description ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
<div class="archive-description date-description">
|
||||
<?php echo wp_kses_post( wpautop( $date_description ) ); ?>
|
||||
|
||||
<!-- Archive Header - Componente dinamico (detecta fecha automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
|
||||
|
||||
<?php if ($show_sidebar): ?>
|
||||
<!-- Sidebar Column (col-lg-3) -->
|
||||
<div class="col-lg-3">
|
||||
<div class="sidebar-sticky">
|
||||
<!-- Table of Contents - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('table-of-contents');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Box Sidebar - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-box-sidebar');
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Date metadata -->
|
||||
<div class="date-meta">
|
||||
<span class="posts-count">
|
||||
<?php
|
||||
global $wp_query;
|
||||
$found_posts = $wp_query->found_posts;
|
||||
printf(
|
||||
/* translators: %s: number of posts */
|
||||
esc_html( _n( '%s post', '%s posts', $found_posts, 'roi-theme' ) ),
|
||||
esc_html( number_format_i18n( $found_posts ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
</header><!-- .page-header -->
|
||||
|
||||
<!-- Date Posts Loop -->
|
||||
<div class="archive-posts date-posts">
|
||||
|
||||
<?php
|
||||
// Start the WordPress Loop
|
||||
while ( have_posts() ) :
|
||||
the_post();
|
||||
|
||||
/**
|
||||
* Include the Post-Type-specific template for the content.
|
||||
* If you want to override this in a child theme, then include a file
|
||||
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', get_post_type() );
|
||||
|
||||
endwhile;
|
||||
?>
|
||||
|
||||
</div><!-- .archive-posts -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Pagination
|
||||
* Display navigation to next/previous set of posts when applicable.
|
||||
*/
|
||||
the_posts_pagination(
|
||||
array(
|
||||
'mid_size' => 2,
|
||||
'prev_text' => sprintf(
|
||||
'%s <span class="nav-prev-text">%s</span>',
|
||||
'<span class="nav-icon" aria-hidden="true">«</span>',
|
||||
esc_html__( 'Previous', 'roi-theme' )
|
||||
),
|
||||
'next_text' => sprintf(
|
||||
'<span class="nav-next-text">%s</span> %s',
|
||||
esc_html__( 'Next', 'roi-theme' ),
|
||||
'<span class="nav-icon" aria-hidden="true">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
<?php
|
||||
|
||||
// =============================================================================
|
||||
// ROI THEME DEBUG MODE
|
||||
// =============================================================================
|
||||
// Para activar el modo debug, agregar en wp-config.php:
|
||||
// define('ROI_DEBUG', true);
|
||||
//
|
||||
// IMPORTANTE: Mantener desactivado en producción para evitar logs de GB.
|
||||
// =============================================================================
|
||||
if (!defined('ROI_DEBUG')) {
|
||||
define('ROI_DEBUG', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log de debug condicional para ROI Theme.
|
||||
* Solo escribe al log si ROI_DEBUG está activado.
|
||||
*/
|
||||
function roi_debug_log(string $message): void {
|
||||
if (ROI_DEBUG) {
|
||||
error_log($message);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AUTOLOADER PARA COMPONENTES
|
||||
// =============================================================================
|
||||
@@ -181,7 +203,7 @@ function roi_render_component(string $componentName): string {
|
||||
global $wpdb;
|
||||
|
||||
// DEBUG: Trace component rendering
|
||||
error_log("ROI Theme DEBUG: roi_render_component called with: {$componentName}");
|
||||
roi_debug_log("ROI Theme DEBUG: roi_render_component called with: {$componentName}");
|
||||
|
||||
try {
|
||||
// Obtener datos del componente desde BD normalizada
|
||||
@@ -207,8 +229,17 @@ function roi_render_component(string $componentName): string {
|
||||
// Decodificar valor
|
||||
$value = $row->attribute_value;
|
||||
|
||||
// Convertir booleanos almacenados como '1' o '0'
|
||||
if ($value === '1' || $value === '0') {
|
||||
// Solo convertir a booleano campos que realmente son booleanos
|
||||
// Los grupos 'visibility', '_page_visibility' y campos que empiezan con 'show_', 'is_', 'enable'
|
||||
$isBooleanField = (
|
||||
$row->group_name === 'visibility' ||
|
||||
$row->group_name === '_page_visibility' ||
|
||||
str_starts_with($row->attribute_name, 'show_') ||
|
||||
str_starts_with($row->attribute_name, 'is_') ||
|
||||
str_starts_with($row->attribute_name, 'enable')
|
||||
);
|
||||
|
||||
if ($isBooleanField && ($value === '1' || $value === '0')) {
|
||||
$value = ($value === '1');
|
||||
} else {
|
||||
// Intentar decodificar JSON
|
||||
@@ -288,9 +319,9 @@ function roi_render_component(string $componentName): string {
|
||||
$renderer = new \ROITheme\Public\Navbar\Infrastructure\Ui\NavbarRenderer($cssGenerator);
|
||||
break;
|
||||
case 'hero':
|
||||
error_log("ROI Theme DEBUG: Creating HeroRenderer");
|
||||
roi_debug_log("ROI Theme DEBUG: Creating HeroRenderer");
|
||||
$renderer = new \ROITheme\Public\Hero\Infrastructure\Ui\HeroRenderer($cssGenerator);
|
||||
error_log("ROI Theme DEBUG: HeroRenderer created successfully");
|
||||
roi_debug_log("ROI Theme DEBUG: HeroRenderer created successfully");
|
||||
break;
|
||||
|
||||
// Componentes sin soporte de CSS Crítico (below-the-fold)
|
||||
@@ -321,16 +352,22 @@ function roi_render_component(string $componentName): string {
|
||||
case 'footer':
|
||||
$renderer = new \ROITheme\Public\Footer\Infrastructure\Ui\FooterRenderer($cssGenerator);
|
||||
break;
|
||||
case 'archive-header':
|
||||
$renderer = new \ROITheme\Public\ArchiveHeader\Infrastructure\Ui\ArchiveHeaderRenderer($cssGenerator);
|
||||
break;
|
||||
case 'post-grid':
|
||||
$renderer = new \ROITheme\Public\PostGrid\Infrastructure\Ui\PostGridRenderer($cssGenerator);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!$renderer) {
|
||||
error_log("ROI Theme DEBUG: No renderer for {$componentName}");
|
||||
roi_debug_log("ROI Theme DEBUG: No renderer for {$componentName}");
|
||||
return '';
|
||||
}
|
||||
|
||||
error_log("ROI Theme DEBUG: Calling render() for {$componentName}");
|
||||
roi_debug_log("ROI Theme DEBUG: Calling render() for {$componentName}");
|
||||
$output = $renderer->render($component);
|
||||
error_log("ROI Theme DEBUG: render() returned " . strlen($output) . " chars for {$componentName}");
|
||||
roi_debug_log("ROI Theme DEBUG: render() returned " . strlen($output) . " chars for {$componentName}");
|
||||
return $output;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
@@ -547,3 +584,22 @@ function roi_get_adsense_search_config(): array {
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// POST GRID SHORTCODE [roi_post_grid]
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Registra el shortcode [roi_post_grid] para mostrar grids de posts
|
||||
* en cualquier pagina o entrada.
|
||||
*
|
||||
* USO:
|
||||
* [roi_post_grid]
|
||||
* [roi_post_grid category="precios-unitarios" posts_per_page="6"]
|
||||
* [roi_post_grid id="grid-1" category="cursos" show_pagination="true"]
|
||||
*
|
||||
* @see Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar
|
||||
*/
|
||||
add_action('init', function() {
|
||||
\ROITheme\Shared\Infrastructure\Wordpress\PostGridShortcodeRegistrar::register();
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
// Definir constante de versión del tema
|
||||
define('ROI_VERSION', '1.0.20');
|
||||
define('ROI_VERSION', '1.0.22');
|
||||
|
||||
// =============================================================================
|
||||
// 1. CARGAR AUTOLOADER MANUAL
|
||||
@@ -50,7 +50,7 @@ require_once get_template_directory() . '/Inc/featured-image.php';
|
||||
require_once get_template_directory() . '/Inc/category-badge.php';
|
||||
require_once get_template_directory() . '/Inc/adsense-delay.php';
|
||||
require_once get_template_directory() . '/Inc/adsense-placement.php';
|
||||
require_once get_template_directory() . '/Inc/related-posts.php';
|
||||
// ELIMINADO: Inc/related-posts.php (Plan 101 - usa RelatedPostRenderer)
|
||||
// ELIMINADO: Inc/toc.php (FASE 6 - Clean Architecture: usa TableOfContentsRenderer)
|
||||
require_once get_template_directory() . '/Inc/apu-tables.php';
|
||||
require_once get_template_directory() . '/Inc/search-disable.php';
|
||||
@@ -174,6 +174,12 @@ try {
|
||||
);
|
||||
$youtubeFacadeHooksRegistrar->register();
|
||||
|
||||
// === CACHE-FIRST ARCHITECTURE (Plan 1000.01) ===
|
||||
// Hook para plugins externos que necesitan evaluar acceso antes de servir página
|
||||
// @see openspec/specs/cache-first-architecture/spec.md
|
||||
$cacheFirstHooksRegistrar = new \ROITheme\Shared\Infrastructure\Hooks\CacheFirstHooksRegistrar();
|
||||
$cacheFirstHooksRegistrar->register();
|
||||
|
||||
// Log en modo debug
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
error_log('ROI Theme: Admin Panel initialized successfully');
|
||||
@@ -329,6 +335,21 @@ if (!is_admin()) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 5.2.1. CUSTOM CSS MANAGER BOOTSTRAP (Handler de formulario POST)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Inicializar Bootstrap de CustomCSSManager para admin
|
||||
*
|
||||
* IMPORTANTE: Este Bootstrap registra el handler de formulario POST en admin_init,
|
||||
* ANTES de que WordPress envíe headers HTTP. Esto permite que wp_redirect()
|
||||
* funcione correctamente después de guardar/eliminar snippets.
|
||||
*/
|
||||
if (is_admin()) {
|
||||
\ROITheme\Admin\CustomCSSManager\Infrastructure\Bootstrap\CustomCSSManagerBootstrap::init();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 5.3. INFORMACIÓN DE DEBUG (Solo en desarrollo)
|
||||
// =============================================================================
|
||||
|
||||
159
home.php
159
home.php
@@ -2,9 +2,9 @@
|
||||
/**
|
||||
* The template for displaying the blog posts index
|
||||
*
|
||||
* This template is used when the blog posts page is different from the front page.
|
||||
* It displays a listing of recent blog posts with pagination.
|
||||
* Set in WordPress Settings > Reading > "Posts page".
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#home
|
||||
*
|
||||
@@ -17,107 +17,76 @@ get_header();
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
|
||||
<!-- Blog Header -->
|
||||
<header class="page-header">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
// Display blog page title
|
||||
if ( is_home() && ! is_front_page() && get_option( 'page_for_posts' ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
<h1 class="page-title">
|
||||
<?php echo esc_html( get_the_title( get_option( 'page_for_posts' ) ) ); ?>
|
||||
</h1>
|
||||
|
||||
<!-- Archive Header - Componente dinamico -->
|
||||
<?php
|
||||
// Display blog page description if available
|
||||
$blog_page = get_post( get_option( 'page_for_posts' ) );
|
||||
if ( $blog_page && ! empty( $blog_page->post_content ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
<div class="page-description">
|
||||
<?php echo wp_kses_post( wpautop( $blog_page->post_excerpt ) ); ?>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
|
||||
|
||||
<?php if ($show_sidebar): ?>
|
||||
<!-- Sidebar Column (col-lg-3) -->
|
||||
<div class="col-lg-3">
|
||||
<div class="sidebar-sticky">
|
||||
<!-- Table of Contents - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('table-of-contents');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Box Sidebar - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-box-sidebar');
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else : ?>
|
||||
<h1 class="page-title">
|
||||
<?php esc_html_e( 'Blog', 'roi-theme' ); ?>
|
||||
</h1>
|
||||
<?php endif; ?>
|
||||
</header><!-- .page-header -->
|
||||
|
||||
<!-- Blog Posts Loop -->
|
||||
<div class="blog-posts">
|
||||
|
||||
<?php
|
||||
// Start the WordPress Loop
|
||||
while ( have_posts() ) :
|
||||
the_post();
|
||||
|
||||
/**
|
||||
* Include the Post-Type-specific template for the content.
|
||||
* If you want to override this in a child theme, then include a file
|
||||
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', get_post_type() );
|
||||
|
||||
endwhile;
|
||||
?>
|
||||
|
||||
</div><!-- .blog-posts -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Pagination
|
||||
* Display navigation to next/previous set of posts when applicable.
|
||||
*/
|
||||
the_posts_pagination(
|
||||
array(
|
||||
'mid_size' => 2,
|
||||
'prev_text' => sprintf(
|
||||
'%s <span class="nav-prev-text">%s</span>',
|
||||
'<span class="nav-icon" aria-hidden="true">«</span>',
|
||||
esc_html__( 'Previous', 'roi-theme' )
|
||||
),
|
||||
'next_text' => sprintf(
|
||||
'<span class="nav-next-text">%s</span> %s',
|
||||
esc_html__( 'Next', 'roi-theme' ),
|
||||
'<span class="nav-icon" aria-hidden="true">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
471
openspec/changes/add-advanced-incontent-ads/design.md
Normal file
471
openspec/changes/add-advanced-incontent-ads/design.md
Normal file
@@ -0,0 +1,471 @@
|
||||
# Design: Sistema Avanzado de In-Content Ads
|
||||
|
||||
## Context
|
||||
|
||||
El sitio analisisdepreciosunitarios.com ha experimentado una reduccion del 50% en ingresos de AdSense. El analisis indica que el sistema actual solo inserta anuncios despues de parrafos, desperdiciando oportunidades de insercion despues de otros elementos estructurales del contenido (encabezados, imagenes, listas, etc.).
|
||||
|
||||
### Stakeholders
|
||||
- Propietario del sitio (monetizacion)
|
||||
- Usuarios (experiencia de lectura)
|
||||
- Google AdSense (politicas de densidad)
|
||||
|
||||
### Constraints
|
||||
- Politicas de AdSense: No mas de 3 anuncios visibles simultaneamente en viewport
|
||||
- UX: Mantener legibilidad del contenido
|
||||
- Performance: No afectar tiempos de carga (lazy load existente)
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
- Incrementar ubicaciones potenciales de anuncios de ~8 a ~15-20
|
||||
- Proporcionar control granular por tipo de elemento
|
||||
- Mantener cumplimiento con politicas de AdSense
|
||||
- Mejorar ingresos sin sacrificar UX drasticamente
|
||||
|
||||
### Non-Goals
|
||||
- No implementar insercion dentro de parrafos (mid-paragraph)
|
||||
- No implementar anuncios de video
|
||||
- No cambiar el sistema de delay/lazy load existente
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Tipos de ubicacion soportados
|
||||
|
||||
**Seleccionados:**
|
||||
- Despues de parrafos (existente, mejorado)
|
||||
- Despues de encabezados H2
|
||||
- Despues de encabezados H3
|
||||
- Despues de imagenes/figuras
|
||||
- Despues de blockquotes
|
||||
- Despues de listas (ul/ol completadas)
|
||||
- Despues de tablas
|
||||
|
||||
**Rationale:** Estos elementos representan pausas naturales en la lectura donde un anuncio es menos intrusivo.
|
||||
|
||||
### Decision 2: Sistema de prioridades (CORREGIDO)
|
||||
|
||||
```
|
||||
Prioridad (valores fijos, no configurables):
|
||||
| Tipo | Prioridad | Justificacion |
|
||||
|-------------------|-----------|----------------------------------|
|
||||
| Despues de H2 | 10 | Ruptura tematica mayor |
|
||||
| Despues de parrafos | 8 | Ubicacion tradicional, probada |
|
||||
| Despues de H3 | 7 | Ruptura tematica menor |
|
||||
| Despues de imagenes | 6 | Pausa visual natural |
|
||||
| Despues de listas | 5 | Fin de enumeracion |
|
||||
| Despues de blockquotes | 4 | Fin de cita |
|
||||
| Despues de tablas | 3 | Fin de datos tabulares |
|
||||
```
|
||||
|
||||
**Nota:** El orden numerico refleja la prioridad real. H2 > parrafos > H3 > imagenes.
|
||||
|
||||
### Decision 3: Modos de densidad
|
||||
|
||||
| Modo | Max Ads | Espaciado Min | Ubicaciones Activas por Defecto |
|
||||
|------|---------|---------------|--------------------------------|
|
||||
| Legacy | (usa config anterior) | (usa config anterior) | Solo parrafos |
|
||||
| Conservador | 5 | 5 elementos | H2, parrafos |
|
||||
| Balanceado | 8 | 3 elementos | H2, H3, parrafos, imagenes |
|
||||
| Agresivo | 15 | 2 elementos | Todas |
|
||||
| Personalizado | Configurable | Configurable | Configurable |
|
||||
|
||||
### Decision 4: Estrategia de campos legacy
|
||||
|
||||
**Problema:** Existen campos en el grupo `behavior` que se solapan con los nuevos:
|
||||
|
||||
| Campo Legacy | Campo Nuevo | Estrategia |
|
||||
|--------------|-------------|------------|
|
||||
| post_content_enabled | N/A | Se mantiene para modo legacy |
|
||||
| post_content_max_ads (1-8) | incontent_max_total_ads (1-15) | Deprecacion suave |
|
||||
| post_content_min_paragraphs_between | incontent_min_spacing | Deprecacion suave |
|
||||
| post_content_random_mode | Probabilidades por tipo | Mapeo: true → 75% |
|
||||
| post_content_after_paragraphs | N/A | Solo aplica en modo legacy |
|
||||
|
||||
**Solucion elegida:** Deprecacion suave con modo "legacy"
|
||||
- Si `incontent_mode == "legacy"`: usar campos del grupo `behavior`
|
||||
- Si `incontent_mode != "legacy"`: usar campos de `incontent_advanced`
|
||||
- Mostrar banner de migracion en admin
|
||||
|
||||
### Decision 5: Enfoque de parsing HTML
|
||||
|
||||
**Opcion A: DOMDocument**
|
||||
```php
|
||||
$dom = new DOMDocument();
|
||||
$dom->loadHTML(mb_convert_encoding($content, 'HTML-ENTITIES', 'UTF-8'));
|
||||
```
|
||||
- Pros: Parsing robusto, manejo correcto de anidamiento
|
||||
- Contras: Puede modificar HTML, mas lento
|
||||
|
||||
**Opcion B: Regex multiple (SELECCIONADA)**
|
||||
```php
|
||||
preg_split('/(<\/(?:p|h[2-3]|figure|ul|ol|table|blockquote)>)/i', $content, -1, PREG_SPLIT_DELIM_CAPTURE)
|
||||
```
|
||||
- Pros: Rapido, no modifica HTML
|
||||
- Contras: No detecta contexto de anidamiento
|
||||
|
||||
**Justificacion:** Regex es suficiente para el caso de uso. El contexto de `<img>` dentro de `<figure>` se resuelve con validacion adicional.
|
||||
|
||||
### Decision 6: Definicion de "Elemento de Bloque Contable"
|
||||
|
||||
Para efectos de espaciado y conteo, un "elemento" se define como:
|
||||
|
||||
| Tag | Cuenta | Notas |
|
||||
|-----|--------|-------|
|
||||
| `</p>` | SI | Parrafo |
|
||||
| `</h2>`, `</h3>` | SI | Encabezados (H4 no soportado) |
|
||||
| `</figure>` | SI | Contenedor de imagen |
|
||||
| `</ul>`, `</ol>` | SI | Listas (contenedor, no items) |
|
||||
| `</table>` | SI | Tabla (contenedor) |
|
||||
| `</blockquote>` | SI | Cita en bloque |
|
||||
| `<img>` standalone | SI | Solo si NO esta dentro de `<figure>` |
|
||||
| `</li>`, `</tr>`, `</td>` | NO | Elementos internos |
|
||||
| `</div>` | NO | Divs genericos |
|
||||
|
||||
### Decision 7: Algoritmo de insercion
|
||||
|
||||
El algoritmo sigue 6 pasos secuenciales:
|
||||
|
||||
```
|
||||
PASO 1: ESCANEO
|
||||
→ Detectar todos los tags de cierre de bloques contables
|
||||
→ Registrar: {posicion, tipo, indice}
|
||||
|
||||
PASO 2: FILTRADO POR CONFIGURACION
|
||||
→ Eliminar ubicaciones con enabled=false
|
||||
|
||||
PASO 3: PROBABILIDAD DETERMINISTICA
|
||||
→ Seed: crc32(post_id . date('Y-m-d'))
|
||||
→ mt_srand(seed) + mt_rand(1, 100)
|
||||
→ Eliminar si rand > probabilidad
|
||||
|
||||
PASO 4: FILTRADO POR ESPACIADO
|
||||
→ Iterar en orden DOM
|
||||
→ Eliminar si distancia < min_spacing
|
||||
|
||||
PASO 5: LIMITE Y PRIORIDAD
|
||||
→ Si count > max_total_ads:
|
||||
→ Ordenar por prioridad DESC
|
||||
→ Tomar primeros N
|
||||
→ Reordenar por posicion DOM
|
||||
|
||||
PASO 6: INSERCION
|
||||
→ Insertar HTML de ad despues de cada tag
|
||||
```
|
||||
|
||||
### Decision 8: Probabilidad deterministica
|
||||
|
||||
**Problema:** `rand()` genera posiciones diferentes en cada request, afectando cache.
|
||||
|
||||
**Solucion:**
|
||||
```php
|
||||
$seed = crc32($post_id . date('Y-m-d'));
|
||||
mt_srand($seed);
|
||||
// mt_rand() ahora es determinístico por día
|
||||
```
|
||||
|
||||
**Beneficios:**
|
||||
- Mismo post = mismas posiciones durante el dia
|
||||
- Cache de pagina funciona correctamente
|
||||
- Al dia siguiente, posiciones cambian (variedad)
|
||||
|
||||
### Decision 9: Estrategia de seleccion configurable
|
||||
|
||||
**Problema:** El orden entre espaciado y prioridad afecta qué ubicaciones sobreviven cuando hay conflictos.
|
||||
|
||||
**Solucion:** Campo `incontent_priority_mode` con dos opciones:
|
||||
|
||||
| Modo | Orden de pasos | Resultado |
|
||||
|------|----------------|-----------|
|
||||
| `position` | Espaciado → Prioridad | Distribucion uniforme, respeta orden DOM |
|
||||
| `priority` | Prioridad → Espaciado | Maximiza valor, H2/H3 siempre ganan |
|
||||
|
||||
**Algoritmo segun modo:**
|
||||
|
||||
```
|
||||
SI incontent_priority_mode == "position":
|
||||
PASO 4: Filtrar por espaciado (orden DOM)
|
||||
PASO 5: Ordenar por prioridad, tomar max_total_ads
|
||||
|
||||
SI incontent_priority_mode == "priority":
|
||||
PASO 4: Ordenar por prioridad DESC
|
||||
PASO 5: Iterar en orden de prioridad, eliminar si viola espaciado
|
||||
Tomar max_total_ads
|
||||
```
|
||||
|
||||
**Default:** `position` (comportamiento mas predecible y uniforme)
|
||||
|
||||
---
|
||||
|
||||
## Estructura de UI (FormBuilder)
|
||||
|
||||
```html
|
||||
<div class="card shadow-sm mb-3" style="border-left: 4px solid #0d6efd;">
|
||||
<div class="card-body">
|
||||
<h5 class="fw-bold mb-3">
|
||||
<i class="bi bi-body-text me-2"></i>
|
||||
In-Content Ads Avanzado
|
||||
<span class="badge bg-success ms-2">Nuevo</span>
|
||||
</h5>
|
||||
|
||||
<!-- Indicador de densidad -->
|
||||
<div id="densityIndicator" class="alert alert-info small mb-3">
|
||||
Densidad estimada: <strong>Media</strong> <span class="badge bg-warning">~6 ads</span>
|
||||
</div>
|
||||
|
||||
<!-- Selector de modo -->
|
||||
<select class="form-select mb-4" name="incontent_mode">
|
||||
<option value="legacy">Legacy (config anterior)</option>
|
||||
<option value="conservative">Conservador</option>
|
||||
<option value="balanced" selected>Balanceado</option>
|
||||
<option value="aggressive">Agresivo</option>
|
||||
<option value="custom">Personalizado</option>
|
||||
</select>
|
||||
|
||||
<!-- Subseccion: Ubicaciones -->
|
||||
<details class="mb-3 border rounded" open>
|
||||
<summary class="p-3 bg-light fw-bold">Ubicaciones por Elemento</summary>
|
||||
<div class="p-3">
|
||||
<!-- Toggle + probabilidad por tipo -->
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Subseccion: Limites -->
|
||||
<details class="mb-3 border rounded">
|
||||
<summary class="p-3 bg-light fw-bold">Limites y Espaciado</summary>
|
||||
<div class="p-3">
|
||||
<!-- max_total_ads, min_spacing -->
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Warning densidad alta -->
|
||||
<div id="highDensityWarning" class="alert alert-warning small d-none">
|
||||
Densidad alta puede afectar UX y violar politicas de AdSense.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema JSON Completo
|
||||
|
||||
**Nota sobre formato de options:**
|
||||
- **Array** `["25", "50", "75", "100"]`: Cuando value y label son identicos
|
||||
- **Objeto** `{"2": "2 elementos"}`: Cuando label difiere del value
|
||||
|
||||
```json
|
||||
{
|
||||
"incontent_advanced": {
|
||||
"label": "In-Content Ads Avanzado",
|
||||
"priority": 69,
|
||||
"fields": {
|
||||
"incontent_mode": {
|
||||
"type": "select",
|
||||
"label": "Modo de densidad",
|
||||
"default": "legacy",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"legacy": "Legacy (config anterior)",
|
||||
"conservative": "Conservador (max 5, espaciado 5)",
|
||||
"balanced": "Balanceado (max 8, espaciado 3)",
|
||||
"aggressive": "Agresivo (max 15, espaciado 2)",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"description": "Presets que ajustan limites y ubicaciones. Legacy usa campos del grupo Ubicaciones en Posts."
|
||||
},
|
||||
"incontent_after_h2_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de H2",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de encabezados H2"
|
||||
},
|
||||
"incontent_after_h2_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad H2",
|
||||
"default": "100",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"],
|
||||
"description": "Porcentaje de probabilidad de insercion"
|
||||
},
|
||||
"incontent_after_h3_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de H3",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de encabezados H3"
|
||||
},
|
||||
"incontent_after_h3_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad H3",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_paragraphs_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de parrafos",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de parrafos"
|
||||
},
|
||||
"incontent_after_paragraphs_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad parrafos",
|
||||
"default": "75",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_images_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de imagenes",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar despues de figure o img standalone"
|
||||
},
|
||||
"incontent_after_images_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad imagenes",
|
||||
"default": "75",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_lists_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de listas",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar despues de ul/ol (minimo 3 items)"
|
||||
},
|
||||
"incontent_after_lists_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad listas",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_blockquotes_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de blockquotes",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar despues de citas en bloque"
|
||||
},
|
||||
"incontent_after_blockquotes_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad blockquotes",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_tables_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de tablas",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar despues de tablas"
|
||||
},
|
||||
"incontent_after_tables_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad tablas",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_max_total_ads": {
|
||||
"type": "select",
|
||||
"label": "Maximo total de ads",
|
||||
"default": "8",
|
||||
"editable": true,
|
||||
"options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"],
|
||||
"description": "Cantidad maxima de anuncios in-content por post"
|
||||
},
|
||||
"incontent_min_spacing": {
|
||||
"type": "select",
|
||||
"label": "Espaciado minimo",
|
||||
"default": "3",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"2": "2 elementos",
|
||||
"3": "3 elementos",
|
||||
"4": "4 elementos",
|
||||
"5": "5 elementos",
|
||||
"6": "6 elementos"
|
||||
},
|
||||
"description": "Minimo de elementos de bloque entre anuncios"
|
||||
},
|
||||
"incontent_format": {
|
||||
"type": "select",
|
||||
"label": "Formato de ads",
|
||||
"default": "in-article",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"in-article": "In-Article (fluid)",
|
||||
"auto": "Auto (responsive)"
|
||||
},
|
||||
"description": "Formato de anuncio para ubicaciones in-content"
|
||||
},
|
||||
"incontent_priority_mode": {
|
||||
"type": "select",
|
||||
"label": "Estrategia de seleccion",
|
||||
"default": "position",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"position": "Por posicion (distribucion uniforme)",
|
||||
"priority": "Por prioridad (maximizar H2/H3)"
|
||||
},
|
||||
"description": "Como resolver conflictos cuando dos ubicaciones estan muy cerca. 'Por posicion' respeta el orden del contenido. 'Por prioridad' favorece ubicaciones de mayor valor (H2 sobre parrafos)."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### Risk 1: Violacion de politicas de AdSense
|
||||
- **Probabilidad**: Media
|
||||
- **Impacto**: Alto (suspension de cuenta)
|
||||
- **Mitigacion**: Indicador de densidad en admin, warning para >10 ads
|
||||
|
||||
### Risk 2: Degradacion de UX
|
||||
- **Probabilidad**: Media-Alta
|
||||
- **Impacto**: Medio (usuarios abandonan)
|
||||
- **Mitigacion**: Modo conservador como default inicial, preview de densidad
|
||||
|
||||
### Risk 3: Conflicto con contenido corto
|
||||
- **Probabilidad**: Media
|
||||
- **Impacto**: Bajo
|
||||
- **Mitigacion**: Campo existente `min_content_length` ya maneja esto
|
||||
|
||||
### Risk 4: Complejidad de migracion
|
||||
- **Probabilidad**: Baja
|
||||
- **Impacto**: Medio
|
||||
- **Mitigacion**: Default "legacy" preserva comportamiento actual
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **Fase 1 - Schema**: Agregar grupo `incontent_advanced` con default "legacy"
|
||||
2. **Fase 2 - Sync**: `wp roi-theme sync-component adsense-placement`
|
||||
3. **Fase 3 - FormBuilder**: Nueva UI con banner de migracion
|
||||
4. **Fase 4 - Renderer**: Implementar ContentAdInjector con algoritmo de 6 pasos
|
||||
5. **Fase 5 - Testing**: Validar en posts con contenido variado
|
||||
6. **Fase 6 - Deploy**: Default "legacy", usuarios migran manualmente
|
||||
|
||||
### Rollback
|
||||
- Cambiar `incontent_mode` a "legacy" restaura comportamiento anterior
|
||||
- No hay cambios destructivos en BD
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. ~~¿Se deberia agregar un preview en vivo de donde apareceran los ads?~~ **Diferido a v2**
|
||||
2. ~~¿Implementar A/B testing entre modos?~~ **Diferido a v2**
|
||||
3. ~~¿Agregar reportes de rendimiento por tipo de ubicacion?~~ **Diferido a v2**
|
||||
36
openspec/changes/add-advanced-incontent-ads/proposal.md
Normal file
36
openspec/changes/add-advanced-incontent-ads/proposal.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Change: Ampliar opciones de In-Content Ads para maximizar ingresos
|
||||
|
||||
## Why
|
||||
|
||||
Los ingresos de AdSense han disminuido aproximadamente un 50% (de ~130 MXN/dia a ~65 MXN/dia). Se ha observado que se muestran significativamente menos anuncios que antes. El sistema actual de in-content ads solo inserta anuncios despues de parrafos, pero el contenido tiene muchos mas puntos de insercion potenciales (despues de encabezados H2/H3, despues de imagenes, despues de listas, etc.) que no se estan aprovechando.
|
||||
|
||||
## What Changes
|
||||
|
||||
### Nuevas ubicaciones de insercion
|
||||
- **ADDED** Insercion despues de encabezados H2 (configurable)
|
||||
- **ADDED** Insercion despues de encabezados H3 (configurable)
|
||||
- **ADDED** Insercion despues de imagenes/figuras
|
||||
- **ADDED** Insercion despues de blockquotes
|
||||
- **ADDED** Insercion despues de listas (ul/ol)
|
||||
- **ADDED** Insercion despues de tablas
|
||||
|
||||
### Configuracion avanzada
|
||||
- **ADDED** Cantidad maxima de ads aumentada de 8 a 15
|
||||
- **ADDED** Control individual por tipo de ubicacion (activar/desactivar cada tipo)
|
||||
- **ADDED** Prioridad de ubicaciones (orden de preferencia)
|
||||
- **ADDED** Modo agresivo vs conservador
|
||||
- **ADDED** Espaciado minimo entre cualquier tipo de ad
|
||||
|
||||
### UI Admin mejorada
|
||||
- **MODIFIED** Seccion In-Content Ads reorganizada con subsecciones
|
||||
- **ADDED** Preview visual de posibles ubicaciones
|
||||
- **ADDED** Indicadores de densidad de anuncios
|
||||
|
||||
## Impact
|
||||
|
||||
- **Affected specs**: openspec/specs/adsense-placement (a crear)
|
||||
- **Affected code**:
|
||||
- `Schemas/adsense-placement.json` - Nuevos campos
|
||||
- `Admin/AdsensePlacement/Infrastructure/Ui/AdsensePlacementFormBuilder.php` - Nueva UI
|
||||
- `Public/AdsensePlacement/Infrastructure/Services/ContentAdInjector.php` - Nueva logica de insercion
|
||||
- **Expected outcome**: Incremento significativo en impresiones de anuncios, potencialmente duplicando o triplicando los ingresos actuales al aprovechar todas las oportunidades de insercion
|
||||
@@ -0,0 +1,690 @@
|
||||
# Especificacion: AdSense Placement - In-Content Ads Avanzados
|
||||
|
||||
## Definiciones Tecnicas
|
||||
|
||||
### Definicion: Elemento de Bloque Contable
|
||||
|
||||
Un "elemento" para efectos de espaciado y conteo se define como el tag de cierre de los siguientes elementos de bloque principal:
|
||||
|
||||
| Tag | Cuenta como elemento | Notas |
|
||||
|-----|---------------------|-------|
|
||||
| `</p>` | SI | Parrafo |
|
||||
| `</h2>` | SI | Encabezado nivel 2 |
|
||||
| `</h3>` | SI | Encabezado nivel 3 |
|
||||
| `</figure>` | SI | Contenedor de imagen con caption |
|
||||
| `</ul>` | SI | Lista desordenada (el contenedor, no los `<li>`) |
|
||||
| `</ol>` | SI | Lista ordenada (el contenedor, no los `<li>`) |
|
||||
| `</table>` | SI | Tabla (el contenedor, no `<tr>` ni `<td>`) |
|
||||
| `</blockquote>` | SI | Cita en bloque |
|
||||
| `<img>` | SOLO si no esta dentro de `<figure>` | Imagen standalone |
|
||||
| `</li>` | NO | Items de lista no cuentan individualmente |
|
||||
| `</tr>`, `</td>` | NO | Elementos internos de tabla no cuentan |
|
||||
| `</div>` | NO | Divs genericos no cuentan |
|
||||
|
||||
### Definicion: Prioridades de Ubicacion (Corregidas)
|
||||
|
||||
| Tipo de Ubicacion | Prioridad | Justificacion |
|
||||
|-------------------|-----------|---------------|
|
||||
| Despues de H2 | 10 | Ruptura tematica mayor, alta visibilidad |
|
||||
| Despues de parrafos | 8 | Ubicacion tradicional, probada |
|
||||
| Despues de H3 | 7 | Ruptura tematica menor |
|
||||
| Despues de imagenes/figure | 6 | Pausa visual natural |
|
||||
| Despues de listas | 5 | Fin de enumeracion |
|
||||
| Despues de blockquotes | 4 | Fin de cita |
|
||||
| Despues de tablas | 3 | Fin de datos tabulares |
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Algoritmo de Insercion de Anuncios
|
||||
|
||||
El sistema DEBE seguir un algoritmo determinista para insertar anuncios in-content.
|
||||
|
||||
#### Scenario: Algoritmo completo de insercion
|
||||
- **GIVEN** contenido HTML con multiples elementos de bloque
|
||||
- **WHEN** se procesa el contenido para insertar ads
|
||||
- **THEN** el sistema DEBE ejecutar los siguientes pasos en orden:
|
||||
|
||||
```
|
||||
PASO 0: PRECONDICION - VALIDAR LONGITUD MINIMA
|
||||
- Obtener min_content_length del grupo forms (default: 500)
|
||||
- Si strlen(strip_tags($content)) < min_content_length:
|
||||
- Retornar contenido sin modificar
|
||||
- NO ejecutar pasos siguientes
|
||||
- Esta validacion aplica a TODOS los modos (legacy y nuevo)
|
||||
|
||||
PASO 1: ESCANEO
|
||||
- Parsear contenido usando regex: preg_split('/(<\/(?:p|h[2-3]|figure|ul|ol|table|blockquote)>)/i', ...)
|
||||
- Para cada tag de cierre, registrar: {posicion, tipo, indice_elemento}
|
||||
- Detectar <img> standalone usando logica de dos pasos:
|
||||
1. Encontrar todos los <img> con: preg_match_all('/<img[^>]*>/i', $content, $imgs, PREG_OFFSET_CAPTURE)
|
||||
2. Para cada <img> encontrado:
|
||||
- Buscar si existe <figure> abierto antes sin cerrar
|
||||
- Si NO hay <figure> abierto: registrar como ubicacion elegible tipo "image"
|
||||
- Si SI hay <figure> abierto: ignorar (se contara con </figure>)
|
||||
|
||||
PASO 2: FILTRADO POR CONFIGURACION
|
||||
- Eliminar ubicaciones cuyo tipo tenga enabled=false
|
||||
- Ejemplo: si incontent_after_h3_enabled=false, eliminar todas las ubicaciones </h3>
|
||||
|
||||
PASO 3: APLICAR PROBABILIDAD DETERMINISTICA
|
||||
- Calcular seed: crc32(post_id . date('Y-m-d'))
|
||||
- Inicializar: mt_srand(seed)
|
||||
- Para cada ubicacion restante:
|
||||
- Si mt_rand(1, 100) > probabilidad_del_tipo: eliminar ubicacion
|
||||
- Esto garantiza consistencia durante el mismo dia para cache
|
||||
|
||||
PASO 4-5: FILTRADO Y SELECCION (segun incontent_priority_mode)
|
||||
|
||||
SI incontent_priority_mode == "position" (default):
|
||||
PASO 4: FILTRAR POR ESPACIADO (orden DOM)
|
||||
- Ordenar ubicaciones por posicion en DOM
|
||||
- Iterar secuencialmente:
|
||||
- Si distancia a ubicacion anterior < min_spacing: eliminar ubicacion
|
||||
- La distancia se mide en cantidad de elementos de bloque entre ambas
|
||||
|
||||
PASO 5: APLICAR LIMITE
|
||||
- Si cantidad de ubicaciones > max_total_ads:
|
||||
- Ordenar por prioridad descendente (H2=10 primero)
|
||||
- Tomar las primeras max_total_ads ubicaciones
|
||||
- Reordenar por posicion en DOM
|
||||
|
||||
SI incontent_priority_mode == "priority":
|
||||
PASO 4: ORDENAR POR PRIORIDAD
|
||||
- Ordenar ubicaciones por prioridad DESC (H2=10 primero, luego p=8, etc.)
|
||||
|
||||
PASO 5: FILTRAR POR ESPACIADO (orden de prioridad)
|
||||
- Iterar en orden de prioridad (no DOM):
|
||||
- Si la ubicacion viola min_spacing con alguna ya seleccionada: eliminar
|
||||
- Si ya tenemos max_total_ads: parar
|
||||
- Reordenar resultado final por posicion DOM
|
||||
|
||||
PASO 6: INSERCION
|
||||
- Para cada ubicacion final, insertar HTML del ad despues del tag de cierre
|
||||
- El ad usa el formato configurado (in-article, auto, etc.)
|
||||
```
|
||||
|
||||
#### Scenario: Seed deterministico para cache
|
||||
- **GIVEN** un post con ID 12345 visitado multiples veces el mismo dia
|
||||
- **WHEN** se calculan las posiciones de ads
|
||||
- **THEN** las posiciones DEBEN ser identicas en todas las visitas del mismo dia
|
||||
- **AND** al dia siguiente las posiciones pueden cambiar (nuevo seed)
|
||||
|
||||
#### Scenario: Validacion de longitud minima de contenido
|
||||
- **GIVEN** `min_content_length` = 500 (campo del grupo `forms`)
|
||||
- **AND** el contenido tiene 300 caracteres (sin tags HTML)
|
||||
- **WHEN** se invoca el algoritmo de insercion
|
||||
- **THEN** NO se ejecuta ningun paso del algoritmo
|
||||
- **AND** se retorna el contenido original sin modificar
|
||||
- **AND** esta validacion aplica tanto a modo "legacy" como a modos nuevos
|
||||
|
||||
#### Scenario: Resolucion de conflictos de posicion
|
||||
- **GIVEN** un H2 seguido inmediatamente por un parrafo
|
||||
- **WHEN** ambos tipos estan habilitados
|
||||
- **AND** el espaciado minimo es 1
|
||||
- **THEN** solo se inserta ad en la ubicacion de mayor prioridad (H2)
|
||||
- **AND** el parrafo se cuenta para el espaciado pero no recibe ad
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Ubicaciones de insercion por tipo de elemento
|
||||
|
||||
El sistema DEBE permitir insertar anuncios despues de diferentes tipos de elementos HTML estructurales, cada uno con configuracion independiente.
|
||||
|
||||
#### Scenario: Insercion despues de encabezados H2
|
||||
- **WHEN** el campo `incontent_after_h2_enabled` es true
|
||||
- **AND** el contenido tiene elementos `</h2>`
|
||||
- **THEN** el sistema DEBE registrar cada `</h2>` como ubicacion elegible
|
||||
- **AND** la probabilidad de insercion es controlada por `incontent_after_h2_probability`
|
||||
|
||||
#### Scenario: Insercion despues de parrafos
|
||||
- **WHEN** el campo `incontent_after_paragraphs_enabled` es true
|
||||
- **AND** el contenido tiene elementos `</p>`
|
||||
- **THEN** el sistema DEBE registrar cada `</p>` como ubicacion elegible
|
||||
- **AND** la probabilidad de insercion es controlada por `incontent_after_paragraphs_probability`
|
||||
- **AND** esta es la ubicacion "tradicional" del sistema legacy
|
||||
|
||||
#### Scenario: Insercion despues de encabezados H3
|
||||
- **WHEN** el campo `incontent_after_h3_enabled` es true
|
||||
- **AND** el contenido tiene elementos `</h3>`
|
||||
- **THEN** el sistema DEBE registrar cada `</h3>` como ubicacion elegible
|
||||
- **AND** la probabilidad de insercion es controlada por `incontent_after_h3_probability`
|
||||
|
||||
#### Scenario: Insercion despues de imagenes
|
||||
- **WHEN** el campo `incontent_after_images_enabled` es true
|
||||
- **AND** el contenido tiene elementos `</figure>` o `<img>` standalone
|
||||
- **THEN** el sistema DEBE registrar como ubicacion elegible
|
||||
- **AND** si `<img>` esta dentro de `<figure>`, solo cuenta `</figure>` (no duplicar)
|
||||
- **AND** la probabilidad es controlada por `incontent_after_images_probability`
|
||||
|
||||
#### Scenario: Insercion despues de listas
|
||||
- **WHEN** el campo `incontent_after_lists_enabled` es true
|
||||
- **AND** el contenido tiene elementos `</ul>` o `</ol>`
|
||||
- **THEN** el sistema DEBE registrar cada cierre de lista como ubicacion elegible
|
||||
- **AND** NO se insertara ad si la lista tiene menos de 3 `<li>` directos
|
||||
- **AND** la probabilidad es controlada por `incontent_after_lists_probability`
|
||||
|
||||
#### Scenario: Conteo de items en listas anidadas
|
||||
- **GIVEN** el siguiente contenido:
|
||||
```html
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2
|
||||
<ul>
|
||||
<li>Sub-item A</li>
|
||||
<li>Sub-item B</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Item 3</li>
|
||||
</ul>
|
||||
```
|
||||
- **WHEN** se evalua si la lista externa es elegible para ad
|
||||
- **THEN** solo se cuentan los `<li>` directos (hijos inmediatos): 3
|
||||
- **AND** los `<li>` de la lista anidada NO se cuentan para la lista padre
|
||||
- **AND** la lista anidada se evalua por separado (tiene 2 items, no elegible)
|
||||
- **NOTA IMPLEMENTACION**: Usar `substr_count($list_content, '<li')` para contar items. Limitacion conocida: listas anidadas contaran todos los `<li>`. Esto es aceptable para v1 ya que listas anidadas son infrecuentes en el contenido del sitio.
|
||||
|
||||
#### Scenario: Todos los tipos deshabilitados
|
||||
- **GIVEN** todos los campos `*_enabled` son false (H2, H3, paragraphs, images, lists, blockquotes, tables)
|
||||
- **WHEN** se ejecuta el algoritmo de insercion
|
||||
- **THEN** PASO 2 elimina todas las ubicaciones candidatas
|
||||
- **AND** se retorna el contenido sin modificar
|
||||
- **AND** no se insertan anuncios
|
||||
|
||||
#### Scenario: Insercion despues de blockquotes
|
||||
- **WHEN** el campo `incontent_after_blockquotes_enabled` es true
|
||||
- **AND** el contenido tiene elementos `</blockquote>`
|
||||
- **THEN** el sistema DEBE registrar cada `</blockquote>` como ubicacion elegible
|
||||
- **AND** la probabilidad es controlada por `incontent_after_blockquotes_probability`
|
||||
|
||||
#### Scenario: Insercion despues de tablas
|
||||
- **WHEN** el campo `incontent_after_tables_enabled` es true
|
||||
- **AND** el contenido tiene elementos `</table>`
|
||||
- **THEN** el sistema DEBE registrar cada `</table>` como ubicacion elegible
|
||||
- **AND** la probabilidad es controlada por `incontent_after_tables_probability`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Modos de densidad de anuncios
|
||||
|
||||
El sistema DEBE ofrecer modos predefinidos que controlan la cantidad y espaciado de anuncios.
|
||||
|
||||
#### Scenario: Modo legacy (backward compatibility)
|
||||
- **WHEN** `incontent_mode` es "legacy"
|
||||
- **THEN** el sistema NO usa el grupo `incontent_advanced` para el algoritmo
|
||||
- **AND** usa los campos del grupo `behavior` (post_content_*)
|
||||
- **AND** ejecuta la logica de insercion anterior (solo despues de parrafos)
|
||||
- **AND** los campos de `incontent_advanced` se muestran deshabilitados en el UI
|
||||
- **AND** se muestra banner: "Usando configuracion legacy. Migra al nuevo sistema para mas opciones."
|
||||
|
||||
#### Scenario: Modo conservador
|
||||
- **WHEN** `incontent_mode` es "conservative"
|
||||
- **THEN** el maximo de ads in-content es 5
|
||||
- **AND** el espaciado minimo entre ads es 5 elementos
|
||||
- **AND** solo se activan ubicaciones despues de H2 y parrafos por defecto
|
||||
|
||||
#### Scenario: Modo balanceado
|
||||
- **WHEN** `incontent_mode` es "balanced"
|
||||
- **THEN** el maximo de ads in-content es 8
|
||||
- **AND** el espaciado minimo entre ads es 3 elementos
|
||||
- **AND** se activan H2, H3, imagenes y parrafos por defecto
|
||||
|
||||
#### Scenario: Modo agresivo
|
||||
- **WHEN** `incontent_mode` es "aggressive"
|
||||
- **THEN** el maximo de ads in-content es 15
|
||||
- **AND** el espaciado minimo entre ads es 2 elementos
|
||||
- **AND** se activan todas las ubicaciones por defecto
|
||||
|
||||
#### Scenario: Modo personalizado
|
||||
- **WHEN** `incontent_mode` es "custom"
|
||||
- **THEN** el usuario puede configurar cada campo individualmente
|
||||
- **AND** los valores de max/spacing no se sobreescriben al cambiar de modo
|
||||
|
||||
#### Scenario: Modificacion de campos en modo preset (auto-switch a custom)
|
||||
- **GIVEN** `incontent_mode` es "balanced" (o cualquier preset excepto "custom" y "legacy")
|
||||
- **WHEN** el usuario modifica manualmente cualquiera de estos campos:
|
||||
- `incontent_max_total_ads`
|
||||
- `incontent_min_spacing`
|
||||
- Cualquier campo `*_enabled` o `*_probability`
|
||||
- **THEN** el sistema DEBE cambiar automaticamente `incontent_mode` a "custom"
|
||||
- **AND** mostrar mensaje informativo: "Modo cambiado a Personalizado"
|
||||
- **AND** los valores modificados se preservan
|
||||
|
||||
#### Scenario: Cambio de modo preset sobreescribe valores
|
||||
- **GIVEN** `incontent_mode` es "custom" con valores personalizados
|
||||
- **WHEN** el usuario cambia `incontent_mode` a "balanced"
|
||||
- **THEN** los valores de max_total_ads, min_spacing y flags enabled se sobreescriben con los del preset
|
||||
- **AND** se muestra confirmacion antes de aplicar el cambio
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Espaciado minimo entre anuncios
|
||||
|
||||
El sistema DEBE mantener un espaciado minimo entre cualquier par de anuncios in-content.
|
||||
|
||||
#### Scenario: Calculo de espaciado
|
||||
- **GIVEN** dos ubicaciones candidatas para ads
|
||||
- **WHEN** se evalua el espaciado entre ellas
|
||||
- **THEN** el espaciado se calcula contando elementos de bloque entre ambas posiciones
|
||||
- **AND** solo se cuentan los elementos definidos en "Elemento de Bloque Contable"
|
||||
|
||||
#### Scenario: Ejemplo de calculo de espaciado
|
||||
- **GIVEN** el siguiente contenido:
|
||||
```html
|
||||
<h2>Titulo</h2> <!-- Ubicacion A (posible ad) -->
|
||||
<p>Parrafo 1</p> <!-- Elemento 1 -->
|
||||
<p>Parrafo 2</p> <!-- Elemento 2 -->
|
||||
<figure><img></figure> <!-- Elemento 3 -->
|
||||
<p>Parrafo 3</p> <!-- Ubicacion B (posible ad) - Elemento 4 -->
|
||||
```
|
||||
- **WHEN** min_spacing es 3
|
||||
- **THEN** la distancia entre A y B es 4 elementos
|
||||
- **AND** ambas ubicaciones pueden tener ads (4 >= 3)
|
||||
|
||||
#### Scenario: Espaciado insuficiente
|
||||
- **GIVEN** min_spacing es 5
|
||||
- **AND** solo hay 3 elementos entre dos ubicaciones candidatas
|
||||
- **THEN** la segunda ubicacion se elimina de las candidatas
|
||||
- **AND** se conserva la de mayor prioridad
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Estrategia de seleccion configurable
|
||||
|
||||
El sistema DEBE permitir elegir como resolver conflictos cuando dos ubicaciones estan muy cerca.
|
||||
|
||||
#### Scenario: Modo position (default)
|
||||
- **WHEN** `incontent_priority_mode` es "position"
|
||||
- **AND** hay un H2 (prioridad 10) en posicion 3 y un parrafo (prioridad 8) en posicion 1
|
||||
- **AND** min_spacing es 3
|
||||
- **THEN** el parrafo en posicion 1 se selecciona primero (por orden DOM)
|
||||
- **AND** el H2 en posicion 3 se elimina por violar espaciado (distancia 2 < 3)
|
||||
- **AND** el resultado favorece distribucion uniforme
|
||||
|
||||
#### Scenario: Modo priority
|
||||
- **WHEN** `incontent_priority_mode` es "priority"
|
||||
- **AND** hay un H2 (prioridad 10) en posicion 3 y un parrafo (prioridad 8) en posicion 1
|
||||
- **AND** min_spacing es 3
|
||||
- **THEN** el H2 se selecciona primero (por mayor prioridad)
|
||||
- **AND** el parrafo se elimina por violar espaciado con el H2
|
||||
- **AND** el resultado maximiza ubicaciones de alto valor
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Probabilidad deterministica por ubicacion
|
||||
|
||||
El sistema DEBE soportar probabilidad configurable que sea consistente durante el dia.
|
||||
|
||||
#### Scenario: Implementacion de probabilidad deterministica
|
||||
- **GIVEN** un post_id y una fecha
|
||||
- **WHEN** se calcula si insertar ad en una ubicacion
|
||||
- **THEN** el seed es `crc32(post_id . 'YYYY-MM-DD')`
|
||||
- **AND** se usa `mt_srand(seed)` antes de evaluar probabilidades
|
||||
- **AND** cada ubicacion consume un `mt_rand(1, 100)` en orden de aparicion
|
||||
|
||||
#### Scenario: Valores de probabilidad disponibles
|
||||
- **WHEN** el usuario configura probabilidad para cualquier tipo
|
||||
- **THEN** los valores disponibles son: 25, 50, 75, 100
|
||||
- **AND** el valor se interpreta como porcentaje
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Estrategia de campos legacy
|
||||
|
||||
Los campos existentes del grupo `behavior` relacionados con in-content ads DEBEN coexistir con el nuevo sistema mediante deprecacion suave.
|
||||
|
||||
#### Scenario: Campos legacy a deprecar
|
||||
- **GIVEN** los siguientes campos existentes:
|
||||
- `post_content_enabled` (behavior)
|
||||
- `post_content_max_ads` (behavior)
|
||||
- `post_content_after_paragraphs` (behavior)
|
||||
- `post_content_min_paragraphs_between` (behavior)
|
||||
- `post_content_random_mode` (behavior)
|
||||
- `post_content_format` (behavior)
|
||||
- **WHEN** el sistema tiene ambos grupos de campos
|
||||
- **THEN** el grupo `incontent_advanced` tiene precedencia si `incontent_mode` != "legacy"
|
||||
- **AND** si `incontent_mode` == "legacy", se usan los campos del grupo `behavior`
|
||||
|
||||
#### Scenario: Migracion automatica en UI
|
||||
- **WHEN** el usuario visita el panel de AdSense por primera vez despues de la actualizacion
|
||||
- **AND** tiene configuracion legacy activa (`post_content_enabled` = true)
|
||||
- **THEN** se muestra un banner informativo sobre el nuevo sistema
|
||||
- **AND** se ofrece boton "Migrar a nuevo sistema" que copia valores equivalentes
|
||||
|
||||
#### Scenario: Mapeo de campos legacy a nuevos
|
||||
| Campo Legacy | Campo Nuevo | Logica de Mapeo |
|
||||
|--------------|-------------|-----------------|
|
||||
| post_content_max_ads | incontent_max_total_ads | Copia directa (ampliar opciones) |
|
||||
| post_content_min_paragraphs_between | incontent_min_spacing | Copia directa |
|
||||
| post_content_random_mode | N/A | Si true, todas las probabilidades = 75% |
|
||||
| post_content_after_paragraphs | N/A | Se usa para primer ad, resto aleatorio |
|
||||
|
||||
---
|
||||
|
||||
### Requirement: UI de In-Content Ads reorganizada
|
||||
|
||||
La interfaz de administracion DEBE organizarse en subsecciones claras usando elementos HTML semanticos.
|
||||
|
||||
#### Scenario: Estructura HTML de la seccion
|
||||
- **WHEN** se renderiza el card de In-Content Ads Avanzado
|
||||
- **THEN** DEBE seguir esta estructura:
|
||||
|
||||
```html
|
||||
<div class="card shadow-sm mb-3" style="border-left: 4px solid #0d6efd;">
|
||||
<div class="card-body">
|
||||
<h5 class="fw-bold mb-3">
|
||||
<i class="bi bi-body-text me-2"></i>
|
||||
In-Content Ads Avanzado
|
||||
<span class="badge bg-success ms-2">Nuevo</span>
|
||||
</h5>
|
||||
|
||||
<!-- Indicador de densidad -->
|
||||
<div id="densityIndicator" class="alert alert-info small mb-3">
|
||||
<i class="bi bi-speedometer2 me-1"></i>
|
||||
Densidad estimada: <strong>Media</strong>
|
||||
<span class="badge bg-warning">~6 ads</span>
|
||||
</div>
|
||||
|
||||
<!-- Selector de modo -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">Modo de densidad</label>
|
||||
<select class="form-select" id="incontentMode">
|
||||
<option value="legacy">Legacy (usar config anterior)</option>
|
||||
<option value="conservative">Conservador (max 5, espaciado 5)</option>
|
||||
<option value="balanced" selected>Balanceado (max 8, espaciado 3)</option>
|
||||
<option value="aggressive">Agresivo (max 15, espaciado 2)</option>
|
||||
<option value="custom">Personalizado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Subseccion: Ubicaciones por elemento -->
|
||||
<details class="mb-3 border rounded" open>
|
||||
<summary class="p-3 bg-light fw-bold cursor-pointer">
|
||||
<i class="bi bi-geo-alt me-1"></i>
|
||||
Ubicaciones por Elemento
|
||||
</summary>
|
||||
<div class="p-3">
|
||||
<!-- Toggle + probabilidad para cada tipo -->
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="afterH2Enabled" checked>
|
||||
<label class="form-check-label">Despues de H2</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<select class="form-select form-select-sm" id="afterH2Prob">
|
||||
<option value="100" selected>100%</option>
|
||||
<option value="75">75%</option>
|
||||
<option value="50">50%</option>
|
||||
<option value="25">25%</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Repetir para H3, images, lists, blockquotes, tables -->
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Subseccion: Limites y espaciado -->
|
||||
<details class="mb-3 border rounded">
|
||||
<summary class="p-3 bg-light fw-bold cursor-pointer">
|
||||
<i class="bi bi-sliders me-1"></i>
|
||||
Limites y Espaciado
|
||||
</summary>
|
||||
<div class="p-3">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Maximo total de ads</label>
|
||||
<select class="form-select" id="maxTotalAds">
|
||||
<!-- Opciones 1-15 -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Espaciado minimo (elementos)</label>
|
||||
<select class="form-select" id="minSpacing">
|
||||
<option value="2">2 elementos</option>
|
||||
<option value="3" selected>3 elementos</option>
|
||||
<option value="4">4 elementos</option>
|
||||
<option value="5">5 elementos</option>
|
||||
<option value="6">6 elementos</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-12 mt-3">
|
||||
<label class="form-label">Estrategia de seleccion</label>
|
||||
<select class="form-select" id="priorityMode">
|
||||
<option value="position" selected>Por posicion (distribucion uniforme)</option>
|
||||
<option value="priority">Por prioridad (maximizar H2/H3)</option>
|
||||
</select>
|
||||
<small class="text-muted">Como resolver conflictos cuando dos ubicaciones estan muy cerca</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Warning para densidad alta -->
|
||||
<div id="highDensityWarning" class="alert alert-warning small d-none">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Atencion:</strong> Densidad alta puede afectar UX y violar politicas de AdSense.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Scenario: Indicador de densidad dinamico
|
||||
- **WHEN** el usuario modifica cualquier campo de in-content ads
|
||||
- **THEN** el indicador de densidad se actualiza en tiempo real via JavaScript
|
||||
- **AND** muestra estimacion basada en: max_ads * promedio_probabilidades / 100
|
||||
- **AND** colores: verde (<5), amarillo (5-10), rojo (>10)
|
||||
|
||||
---
|
||||
|
||||
## Schema JSON - Campos Completos
|
||||
|
||||
### Grupo: incontent_advanced (priority 69)
|
||||
|
||||
```json
|
||||
{
|
||||
"incontent_advanced": {
|
||||
"label": "In-Content Ads Avanzado",
|
||||
"priority": 69,
|
||||
"fields": {
|
||||
"incontent_mode": {
|
||||
"type": "select",
|
||||
"label": "Modo de densidad",
|
||||
"default": "legacy",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"legacy": "Legacy (config anterior)",
|
||||
"conservative": "Conservador",
|
||||
"balanced": "Balanceado",
|
||||
"aggressive": "Agresivo",
|
||||
"custom": "Personalizado"
|
||||
},
|
||||
"description": "Presets que ajustan limites y ubicaciones automaticamente. Default 'legacy' para backward compatibility."
|
||||
},
|
||||
"incontent_after_h2_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de H2",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de encabezados H2"
|
||||
},
|
||||
"incontent_after_h2_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad H2",
|
||||
"default": "100",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"],
|
||||
"description": "Porcentaje de probabilidad de insercion"
|
||||
},
|
||||
"incontent_after_h3_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de H3",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de encabezados H3"
|
||||
},
|
||||
"incontent_after_h3_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad H3",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_paragraphs_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de parrafos",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de parrafos (ubicacion tradicional)"
|
||||
},
|
||||
"incontent_after_paragraphs_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad parrafos",
|
||||
"default": "75",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"],
|
||||
"description": "Porcentaje de probabilidad de insercion despues de parrafos"
|
||||
},
|
||||
"incontent_after_images_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de imagenes",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de figure o img standalone"
|
||||
},
|
||||
"incontent_after_images_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad imagenes",
|
||||
"default": "75",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_lists_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de listas",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de ul/ol (minimo 3 items)"
|
||||
},
|
||||
"incontent_after_lists_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad listas",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_blockquotes_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de blockquotes",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de citas en bloque"
|
||||
},
|
||||
"incontent_after_blockquotes_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad blockquotes",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_after_tables_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Despues de tablas",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios despues de tablas"
|
||||
},
|
||||
"incontent_after_tables_probability": {
|
||||
"type": "select",
|
||||
"label": "Probabilidad tablas",
|
||||
"default": "50",
|
||||
"editable": true,
|
||||
"options": ["25", "50", "75", "100"]
|
||||
},
|
||||
"incontent_max_total_ads": {
|
||||
"type": "select",
|
||||
"label": "Maximo total de ads",
|
||||
"default": "8",
|
||||
"editable": true,
|
||||
"options": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"],
|
||||
"description": "Cantidad maxima de anuncios in-content por post"
|
||||
},
|
||||
"incontent_min_spacing": {
|
||||
"type": "select",
|
||||
"label": "Espaciado minimo",
|
||||
"default": "3",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"2": "2 elementos",
|
||||
"3": "3 elementos",
|
||||
"4": "4 elementos",
|
||||
"5": "5 elementos",
|
||||
"6": "6 elementos"
|
||||
},
|
||||
"description": "Minimo de elementos de bloque entre anuncios"
|
||||
},
|
||||
"incontent_format": {
|
||||
"type": "select",
|
||||
"label": "Formato de ads",
|
||||
"default": "in-article",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"in-article": "In-Article (fluid)",
|
||||
"auto": "Auto (responsive)"
|
||||
},
|
||||
"description": "Formato de anuncio para todas las ubicaciones in-content"
|
||||
},
|
||||
"incontent_priority_mode": {
|
||||
"type": "select",
|
||||
"label": "Estrategia de seleccion",
|
||||
"default": "position",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"position": "Por posicion (distribucion uniforme)",
|
||||
"priority": "Por prioridad (maximizar H2/H3)"
|
||||
},
|
||||
"description": "Como resolver conflictos cuando dos ubicaciones estan muy cerca"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementacion Tecnica
|
||||
|
||||
### Opcion de Parsing Recomendada: Regex Multiple
|
||||
|
||||
```php
|
||||
// Regex para detectar todos los elementos de bloque contables (H4 no soportado)
|
||||
$pattern = '/(<\/(?:p|h[2-3]|figure|ul|ol|table|blockquote)>)/i';
|
||||
$parts = preg_split($pattern, $content, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
// Para detectar <img> standalone (no dentro de figure)
|
||||
// Procesar en segundo paso, verificando contexto
|
||||
```
|
||||
|
||||
**Justificacion:**
|
||||
- Mas rapido que DOMDocument
|
||||
- No modifica el HTML original
|
||||
- Suficiente para el caso de uso (no necesitamos validar anidamiento complejo)
|
||||
- El contexto de `<img>` dentro de `<figure>` se resuelve verificando si hay `<figure>` abierto sin cerrar
|
||||
|
||||
### Diagrama de Dependencias de Tasks
|
||||
|
||||
```
|
||||
1.1 Schema: grupo incontent_advanced ─┐
|
||||
1.2 Schema: campos individuales ──────┼──> 1.3 Sync BD ──┬──> 2.x FormBuilder
|
||||
│ │
|
||||
│ └──> 3.x Renderer
|
||||
│ │
|
||||
│ v
|
||||
│ 4.x Validacion
|
||||
│ │
|
||||
└──────────────────────> 5.x Docs
|
||||
```
|
||||
129
openspec/changes/add-advanced-incontent-ads/tasks.md
Normal file
129
openspec/changes/add-advanced-incontent-ads/tasks.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Tasks: Implementacion de In-Content Ads Avanzados
|
||||
|
||||
## Diagrama de Dependencias
|
||||
|
||||
```
|
||||
1.1 ──┬──> 1.3 ──┬──> 2.x FormBuilder ──> 4.x
|
||||
1.2 ──┘ │
|
||||
└──> 3.x Renderer ──────> 4.x
|
||||
│
|
||||
v
|
||||
5.x Docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Schema JSON - Nuevos campos
|
||||
|
||||
**Prerequisitos:** Ninguno
|
||||
|
||||
- [ ] 1.1 Agregar grupo `incontent_advanced` con priority 69 al schema JSON
|
||||
- [ ] 1.2 Agregar todos los campos definidos en spec:
|
||||
- [ ] incontent_mode (select: legacy/conservative/balanced/aggressive/custom)
|
||||
- [ ] incontent_after_h2_enabled + probability
|
||||
- [ ] incontent_after_h3_enabled + probability
|
||||
- [ ] incontent_after_paragraphs_enabled + probability (ubicacion tradicional)
|
||||
- [ ] incontent_after_images_enabled + probability
|
||||
- [ ] incontent_after_lists_enabled + probability
|
||||
- [ ] incontent_after_blockquotes_enabled + probability
|
||||
- [ ] incontent_after_tables_enabled + probability
|
||||
- [ ] incontent_max_total_ads (1-15)
|
||||
- [ ] incontent_min_spacing (2-6)
|
||||
- [ ] incontent_format
|
||||
- [ ] incontent_priority_mode (position/priority)
|
||||
- [ ] 1.3 Sincronizar schema con BD via WP-CLI: `wp roi-theme sync-component adsense-placement`
|
||||
|
||||
---
|
||||
|
||||
## 2. FormBuilder Admin - Nueva UI
|
||||
|
||||
**Prerequisitos:** 1.3 completado
|
||||
|
||||
- [ ] 2.1 Crear metodo `buildInContentAdvancedGroup()` en AdsensePlacementFormBuilder
|
||||
- [ ] 2.2 Implementar indicador de densidad (HTML + logica de color)
|
||||
- [ ] 2.3 Implementar selector de modo con presets
|
||||
- [ ] 2.4 Crear subseccion colapsable "Ubicaciones por Elemento" usando `<details>`
|
||||
- [ ] 2.4.1 Toggle + probabilidad para H2
|
||||
- [ ] 2.4.2 Toggle + probabilidad para H3
|
||||
- [ ] 2.4.3 Toggle + probabilidad para parrafos (ubicacion tradicional)
|
||||
- [ ] 2.4.4 Toggle + probabilidad para imagenes
|
||||
- [ ] 2.4.5 Toggle + probabilidad para listas
|
||||
- [ ] 2.4.6 Toggle + probabilidad para blockquotes
|
||||
- [ ] 2.4.7 Toggle + probabilidad para tablas
|
||||
- [ ] 2.5 Crear subseccion colapsable "Limites y Espaciado"
|
||||
- [ ] 2.5.1 Select max_total_ads (1-15)
|
||||
- [ ] 2.5.2 Select min_spacing (2-6)
|
||||
- [ ] 2.6 Agregar warning visual para densidad alta (>10 ads)
|
||||
- [ ] 2.7 Agregar banner de migracion para usuarios con config legacy activa
|
||||
- [ ] 2.8 Mantener seccion legacy existente (modo "legacy" la usa)
|
||||
- [ ] 2.9 Agregar mapeos en AdsensePlacementFieldMapper para todos los campos nuevos:
|
||||
- [ ] incontent_mode
|
||||
- [ ] incontent_after_h2_enabled + probability
|
||||
- [ ] incontent_after_h3_enabled + probability
|
||||
- [ ] incontent_after_paragraphs_enabled + probability
|
||||
- [ ] incontent_after_images_enabled + probability
|
||||
- [ ] incontent_after_lists_enabled + probability
|
||||
- [ ] incontent_after_blockquotes_enabled + probability
|
||||
- [ ] incontent_after_tables_enabled + probability
|
||||
- [ ] incontent_max_total_ads
|
||||
- [ ] incontent_min_spacing
|
||||
- [ ] incontent_format
|
||||
- [ ] incontent_priority_mode
|
||||
- [ ] 2.10 Implementar logica JavaScript:
|
||||
- [ ] 2.10.1 Auto-switch a "custom" al modificar campos (con toast informativo)
|
||||
- [ ] 2.10.2 Modal de confirmacion al cambiar de "custom" a preset
|
||||
- [ ] 2.10.3 Actualizar indicador de densidad en tiempo real
|
||||
|
||||
---
|
||||
|
||||
## 3. Renderer - Logica de insercion mejorada
|
||||
|
||||
**Prerequisitos:** 1.3 completado
|
||||
|
||||
- [ ] 3.1 Crear/modificar ContentAdInjector service con nuevo algoritmo
|
||||
- [ ] 3.1.1 Implementar PASO 0: Validar min_content_length
|
||||
- [ ] 3.1.2 Implementar PASO 1: Escaneo con regex multiple
|
||||
```php
|
||||
preg_split('/(<\/(?:p|h[2-3]|figure|ul|ol|table|blockquote)>)/i', ...)
|
||||
```
|
||||
- [ ] 3.1.3 Implementar deteccion de `<img>` standalone (no dentro de figure)
|
||||
- [ ] 3.1.4 Implementar validacion de listas (usar substr_count, minimo 3 items)
|
||||
- [ ] 3.2 Implementar PASO 2: Filtrado por configuracion (enabled flags)
|
||||
- [ ] 3.3 Implementar PASO 3: Probabilidad deterministica
|
||||
- [ ] 3.3.1 Calcular seed: `crc32(post_id . date('Y-m-d'))`
|
||||
- [ ] 3.3.2 Usar `mt_srand(seed)` + `mt_rand(1, 100)`
|
||||
- [ ] 3.4 Implementar PASO 4-5: Filtrado y seleccion (segun incontent_priority_mode)
|
||||
- [ ] 3.4.1 Definir constantes de prioridad (H2=10, p=8, H3=7, img=6, lists=5, bq=4, table=3)
|
||||
- [ ] 3.4.2 Implementar modo "position": espaciado primero, luego prioridad
|
||||
- [ ] 3.4.3 Implementar modo "priority": prioridad primero, luego espaciado
|
||||
- [ ] 3.4.4 Aplicar limite max_total_ads
|
||||
- [ ] 3.4.5 Reordenar resultado final por posicion DOM
|
||||
- [ ] 3.5 Implementar PASO 6: Insercion de ads
|
||||
- [ ] 3.6 Implementar logica de precedencia legacy vs nuevo sistema
|
||||
- [ ] 3.6.1 Si incontent_mode == "legacy", usar campos del grupo behavior
|
||||
- [ ] 3.6.2 Si incontent_mode != "legacy", usar incontent_advanced
|
||||
|
||||
---
|
||||
|
||||
## 4. Validacion y Testing
|
||||
|
||||
**Prerequisitos:** 2.x y 3.x completados
|
||||
|
||||
- [ ] 4.1 Ejecutar `validate-architecture.php adsense-placement`
|
||||
- [ ] 4.2 Probar en post con contenido variado (H2, H3, images, lists, tables)
|
||||
- [ ] 4.3 Verificar que seed deterministico funciona (mismas posiciones mismo dia)
|
||||
- [ ] 4.4 Verificar que espaciado minimo se respeta
|
||||
- [ ] 4.5 Verificar que limite max_total_ads se respeta
|
||||
- [ ] 4.6 Verificar que modo legacy sigue funcionando
|
||||
- [ ] 4.7 Verificar que delay de carga sigue funcionando
|
||||
- [ ] 4.8 Probar indicador de densidad en admin
|
||||
- [ ] 4.9 Verificar ambos modos de priority_mode (position vs priority)
|
||||
|
||||
---
|
||||
|
||||
## 5. Documentacion
|
||||
|
||||
**Prerequisitos:** 4.x completado
|
||||
|
||||
- [ ] 5.1 Crear spec.md final en `openspec/specs/adsense-placement/`
|
||||
- [ ] 5.2 Archivar este change: `openspec archive add-advanced-incontent-ads`
|
||||
190
openspec/specs/cache-first-architecture/spec.md
Normal file
190
openspec/specs/cache-first-architecture/spec.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# Especificacion: Hook de Pre-Evaluacion de Pagina
|
||||
|
||||
## Purpose
|
||||
|
||||
Define un hook que permite a plugins externos evaluar condiciones ANTES de que WordPress sirva una pagina singular. Este hook es util para plugins de control de acceso, rate limiters, membership, etc.
|
||||
|
||||
**Alcance del tema:** ROI-Theme SOLO provee el hook. La implementacion de logica de acceso es responsabilidad de cada plugin.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Hook roi_theme_before_page_serve
|
||||
|
||||
The system MUST provide a hook that fires before WordPress serves a singular page, allowing external plugins to evaluate conditions and potentially redirect.
|
||||
|
||||
#### Scenario: Plugin externo evalua acceso antes de servir pagina
|
||||
- **GIVEN** un plugin de control de acceso enganchado a `roi_theme_before_page_serve`
|
||||
- **WHEN** un visitante anonimo solicita una pagina singular (post, page, CPT)
|
||||
- **THEN** el tema DEBE disparar `do_action('roi_theme_before_page_serve', $post_id)`
|
||||
- **AND** el hook DEBE ejecutarse en `template_redirect` con priority 0
|
||||
- **AND** si el plugin llama `wp_safe_redirect()` + `exit`, la pagina NO se sirve
|
||||
|
||||
#### Scenario: Ningun plugin enganchado
|
||||
- **GIVEN** ningun plugin esta escuchando `roi_theme_before_page_serve`
|
||||
- **WHEN** un visitante solicita una pagina
|
||||
- **THEN** la pagina se sirve normalmente
|
||||
- **AND** no hay impacto en rendimiento
|
||||
|
||||
#### Scenario: Solo paginas singulares
|
||||
- **GIVEN** el hook `roi_theme_before_page_serve`
|
||||
- **WHEN** la solicitud es para archivo, home, search, feed, o admin
|
||||
- **THEN** el hook NO DEBE dispararse
|
||||
|
||||
#### Scenario: Usuarios logueados excluidos
|
||||
- **GIVEN** un usuario autenticado (logged in)
|
||||
- **WHEN** solicita cualquier pagina
|
||||
- **THEN** el hook NO DEBE dispararse
|
||||
- **BECAUSE** los plugins de cache no cachean paginas para usuarios logueados
|
||||
|
||||
#### Scenario: REST API excluida
|
||||
- **GIVEN** una peticion REST API (REST_REQUEST === true)
|
||||
- **WHEN** se procesa la peticion
|
||||
- **THEN** el hook NO DEBE dispararse
|
||||
- **BECAUSE** las peticiones REST tienen su propio ciclo de vida y no sirven paginas HTML
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Contexto para Plugins
|
||||
|
||||
The hook MUST provide sufficient context for plugins to make decisions.
|
||||
|
||||
#### Scenario: Plugin accede a informacion del post
|
||||
- **GIVEN** un plugin enganchado a `roi_theme_before_page_serve`
|
||||
- **WHEN** el hook se dispara
|
||||
- **THEN** el plugin recibe `$post_id` como parametro
|
||||
- **AND** `get_queried_object()` retorna el WP_Post completo
|
||||
- **AND** funciones como `is_singular()`, `is_single()`, `is_page()` funcionan
|
||||
|
||||
#### Scenario: Plugin accede a informacion del visitante
|
||||
- **GIVEN** un plugin enganchado al hook
|
||||
- **WHEN** el hook se dispara
|
||||
- **THEN** `$_SERVER['REMOTE_ADDR']` esta disponible
|
||||
- **AND** headers HTTP estan disponibles via `$_SERVER`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: No Interferir con Cache
|
||||
|
||||
The theme MUST NOT define cache-blocking constants.
|
||||
|
||||
#### Scenario: Tema no bloquea cache
|
||||
- **GIVEN** el tema roi-theme instalado
|
||||
- **WHEN** plugins de cache (W3TC, WP Super Cache, etc.) estan activos
|
||||
- **THEN** el tema NO DEBE definir `DONOTCACHEPAGE`
|
||||
- **AND** el tema NO DEBE enviar headers `Cache-Control: no-cache`
|
||||
|
||||
#### Scenario: Plugin decide bloquear cache
|
||||
- **GIVEN** un plugin necesita bloquear cache para una pagina
|
||||
- **WHEN** el plugin esta enganchado al hook
|
||||
- **THEN** es responsabilidad del PLUGIN definir `DONOTCACHEPAGE`
|
||||
- **AND** el tema NO participa en esa decision
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Limitation 1: Page Cache Bypass
|
||||
|
||||
**Severity**: CRITICAL para plugins que requieren evaluacion en cada request
|
||||
|
||||
Cuando Page Cache esta habilitado (W3TC, WP Super Cache, etc.), el hook `roi_theme_before_page_serve` NO SE EJECUTA para paginas cacheadas.
|
||||
|
||||
**Razon tecnica:**
|
||||
```
|
||||
Request → advanced-cache.php → [Cache HIT] → HTML servido
|
||||
↓
|
||||
WordPress NUNCA carga
|
||||
↓
|
||||
Hook NUNCA se dispara
|
||||
```
|
||||
|
||||
**Implicacion para plugins:**
|
||||
- Rate limiters: Los limites no se evaluan en cache hits
|
||||
- Membership plugins: El acceso no se verifica en cache hits
|
||||
- Geolocation: Las restricciones no aplican en cache hits
|
||||
|
||||
**Solucion:** Los plugins que requieren evaluacion en cada request deben implementar su propia estrategia (JavaScript-First, cookies, edge workers, etc.). Esto esta FUERA del alcance de esta spec.
|
||||
|
||||
### Limitation 2: Solo Paginas Singulares
|
||||
|
||||
El hook solo dispara para `is_singular() === true`. Archives, taxonomies, search, y home NO disparan el hook.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Ubicacion en Clean Architecture
|
||||
|
||||
El hook DEBE registrarse en `Shared/Infrastructure/Hooks/`.
|
||||
|
||||
### Archivo: CacheFirstHooksRegistrar.php
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Hooks;
|
||||
|
||||
final class CacheFirstHooksRegistrar
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
add_action('template_redirect', [$this, 'fireBeforePageServe'], 0);
|
||||
}
|
||||
|
||||
public function fireBeforePageServe(): void
|
||||
{
|
||||
if (is_user_logged_in()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_singular()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defined('REST_REQUEST') && REST_REQUEST) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_id = get_queried_object_id();
|
||||
|
||||
if ($post_id > 0) {
|
||||
do_action('roi_theme_before_page_serve', $post_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Registro en functions.php
|
||||
|
||||
```php
|
||||
$cacheFirstHooks = new \ROITheme\Shared\Infrastructure\Hooks\CacheFirstHooksRegistrar();
|
||||
$cacheFirstHooks->register();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Hook `roi_theme_before_page_serve` se dispara en `template_redirect` priority 0
|
||||
2. Solo dispara para `is_singular() === true`
|
||||
3. NO dispara para usuarios logueados
|
||||
4. Pasa `$post_id` como parametro
|
||||
5. No define DONOTCACHEPAGE ni headers anti-cache
|
||||
6. Plugins pueden enganchar y hacer redirect/exit
|
||||
7. Sin impacto en rendimiento si ningun plugin engancha
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.0 | 2025-12-07 | Initial spec |
|
||||
| 2.0 | 2025-12-07 | Simplified: Only defines hook, removed plugin implementation details |
|
||||
410
openspec/specs/post-grid-shortcode/spec.md
Normal file
410
openspec/specs/post-grid-shortcode/spec.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# Especificacion de Shortcode Post Grid
|
||||
|
||||
## Purpose
|
||||
|
||||
Crear un shortcode `[roi_post_grid]` que permita insertar grids de posts en cualquier pagina o entrada de WordPress, con filtros configurables por categoria, tag, autor y otras opciones. Sigue Clean Architecture delegando logica a Use Cases.
|
||||
|
||||
## Context
|
||||
|
||||
### Problema Actual
|
||||
El componente `post-grid` implementado en `templates-unificados` solo funciona en templates de archivo (home.php, archive.php, etc.) porque depende del loop principal de WordPress (`global $wp_query`).
|
||||
|
||||
### Solucion
|
||||
Crear un shortcode que:
|
||||
1. Siga Clean Architecture (ShortcodeRegistrar → UseCase → Repository)
|
||||
2. Reutilice estilos del componente `post-grid` existente
|
||||
3. Permita filtrar posts por categoria, tag, autor
|
||||
4. No dependa del loop principal de WordPress
|
||||
|
||||
### Relacion con templates-unificados
|
||||
- Este shortcode **complementa** (no reemplaza) el componente post-grid
|
||||
- El componente post-grid sigue funcionando en templates de archivo
|
||||
- El shortcode permite insertar grids en contenido arbitrario
|
||||
|
||||
### Nota sobre shortcodes legacy
|
||||
Los shortcodes existentes (`apu_table`, `apu_row`) estan en `Inc/apu-tables.php` (patron legacy). Este nuevo shortcode sigue el patron moderno de Clean Architecture.
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Arquitectura del Shortcode
|
||||
|
||||
The shortcode MUST follow Clean Architecture patterns.
|
||||
|
||||
#### Scenario: Ubicacion del ShortcodeRegistrar
|
||||
- **WHEN** se implementa el shortcode
|
||||
- **THEN** DEBE estar en `Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php`
|
||||
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Wordpress`
|
||||
- **AND** DEBE registrar el shortcode via add_shortcode
|
||||
|
||||
#### Scenario: Delegacion a Use Case
|
||||
- **WHEN** el shortcode se ejecuta
|
||||
- **THEN** PostGridShortcodeRegistrar DEBE delegar a RenderPostGridUseCase
|
||||
- **AND** NO DEBE contener logica de negocio
|
||||
- **AND** solo sanitiza atributos y pasa al Use Case
|
||||
|
||||
#### Scenario: Ubicacion del Use Case
|
||||
- **WHEN** se implementa la logica del shortcode
|
||||
- **THEN** DEBE estar en `Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php`
|
||||
- **AND** DEBE usar namespace `ROITheme\Shared\Application\UseCases\RenderPostGrid`
|
||||
- **AND** DEBE orquestar Query, Renderer y Settings
|
||||
|
||||
#### Scenario: Ubicacion del QueryBuilder
|
||||
- **WHEN** se construye el WP_Query
|
||||
- **THEN** DEBE estar en `Shared/Infrastructure/Query/PostGridQueryBuilder.php`
|
||||
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Query`
|
||||
- **AND** DEBE recibir parametros y retornar WP_Query
|
||||
|
||||
#### Scenario: Ubicacion del ShortcodeRenderer
|
||||
- **WHEN** se genera el HTML del grid
|
||||
- **THEN** DEBE estar en `Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php`
|
||||
- **AND** DEBE usar namespace `ROITheme\Shared\Infrastructure\Ui`
|
||||
- **AND** DEBE inyectar CSSGeneratorInterface
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Estructura de Clases
|
||||
|
||||
Each class MUST have single responsibility following SRP.
|
||||
|
||||
#### Scenario: Responsabilidad de PostGridShortcodeRegistrar
|
||||
- **WHEN** se define PostGridShortcodeRegistrar
|
||||
- **THEN** DEBE tener metodo estatico `register()` para add_shortcode
|
||||
- **AND** DEBE tener metodo `handleShortcode(array $atts): string`
|
||||
- **AND** handleShortcode DEBE sanitizar atributos
|
||||
- **AND** handleShortcode DEBE obtener UseCase del DIContainer
|
||||
- **AND** handleShortcode DEBE retornar resultado del UseCase
|
||||
- **AND** NO DEBE tener mas de 50 lineas
|
||||
|
||||
#### Scenario: Responsabilidad de RenderPostGridUseCase
|
||||
- **WHEN** se define RenderPostGridUseCase
|
||||
- **THEN** DEBE recibir RenderPostGridRequest como input
|
||||
- **AND** DEBE inyectar PostGridQueryBuilderInterface via constructor
|
||||
- **AND** DEBE inyectar PostGridShortcodeRendererInterface via constructor
|
||||
- **AND** DEBE inyectar ComponentSettingsRepositoryInterface via constructor
|
||||
- **AND** DEBE orquestar: obtener settings, construir query, renderizar
|
||||
- **AND** DEBE retornar string HTML
|
||||
- **AND** NO DEBE tener mas de 80 lineas
|
||||
|
||||
#### Scenario: Responsabilidad de PostGridQueryBuilder
|
||||
- **WHEN** se define PostGridQueryBuilder
|
||||
- **THEN** DEBE recibir parametros de filtro (category, tag, etc.)
|
||||
- **AND** DEBE construir array de argumentos WP_Query
|
||||
- **AND** DEBE retornar WP_Query configurado
|
||||
- **AND** NO DEBE generar HTML
|
||||
- **AND** NO DEBE tener mas de 100 lineas
|
||||
|
||||
#### Scenario: Responsabilidad de PostGridShortcodeRenderer
|
||||
- **WHEN** se define PostGridShortcodeRenderer
|
||||
- **THEN** DEBE recibir WP_Query y configuracion
|
||||
- **AND** DEBE inyectar CSSGeneratorInterface
|
||||
- **AND** DEBE generar HTML del grid
|
||||
- **AND** DEBE generar CSS inline
|
||||
- **AND** NO DEBE construir queries
|
||||
- **AND** NO DEBE tener mas de 150 lineas
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Interfaces en Domain/Application
|
||||
|
||||
Interfaces MUST be defined for dependency injection.
|
||||
|
||||
#### Scenario: Interface del QueryBuilder
|
||||
- **WHEN** se define la interface
|
||||
- **THEN** DEBE estar en `Shared/Domain/Contracts/PostGridQueryBuilderInterface.php`
|
||||
- **AND** DEBE tener metodo `build(array $params): \WP_Query`
|
||||
|
||||
#### Scenario: Interface del Renderer
|
||||
- **WHEN** se define la interface
|
||||
- **THEN** DEBE estar en `Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php`
|
||||
- **AND** DEBE tener metodo `render(\WP_Query $query, array $settings, array $options): string`
|
||||
|
||||
#### Scenario: Request DTO
|
||||
- **WHEN** se define el DTO de entrada
|
||||
- **THEN** DEBE estar en `Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php`
|
||||
- **AND** DEBE ser readonly class con propiedades tipadas
|
||||
- **AND** DEBE contener todos los atributos del shortcode
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Atributos del Shortcode
|
||||
|
||||
The shortcode MUST accept configurable attributes.
|
||||
|
||||
#### Scenario: Atributo category
|
||||
- **WHEN** se usa `[roi_post_grid category="precios-unitarios"]`
|
||||
- **THEN** DEBE filtrar posts por slug de categoria
|
||||
- **AND** DEBE aceptar multiples categorias separadas por coma
|
||||
- **AND** ejemplo: `category="cat1,cat2,cat3"`
|
||||
|
||||
#### Scenario: Atributo exclude_category
|
||||
- **WHEN** se usa `[roi_post_grid exclude_category="noticias"]`
|
||||
- **THEN** DEBE excluir posts de esas categorias
|
||||
- **AND** DEBE aceptar multiples categorias separadas por coma
|
||||
|
||||
#### Scenario: Atributo tag
|
||||
- **WHEN** se usa `[roi_post_grid tag="concreto"]`
|
||||
- **THEN** DEBE filtrar posts por slug de tag
|
||||
- **AND** DEBE aceptar multiples tags separados por coma
|
||||
|
||||
#### Scenario: Atributo author
|
||||
- **WHEN** se usa `[roi_post_grid author="admin"]`
|
||||
- **THEN** DEBE filtrar posts por login de autor
|
||||
- **AND** DEBE aceptar ID numerico o login string
|
||||
|
||||
#### Scenario: Atributo posts_per_page
|
||||
- **WHEN** se usa `[roi_post_grid posts_per_page="6"]`
|
||||
- **THEN** DEBE limitar cantidad de posts mostrados
|
||||
- **AND** default DEBE ser 9
|
||||
- **AND** DEBE aceptar valores entre 1 y 50
|
||||
|
||||
#### Scenario: Atributo columns
|
||||
- **WHEN** se usa `[roi_post_grid columns="4"]`
|
||||
- **THEN** DEBE definir columnas en desktop
|
||||
- **AND** default DEBE ser 3
|
||||
- **AND** DEBE aceptar valores 1, 2, 3 o 4
|
||||
|
||||
#### Scenario: Atributo orderby
|
||||
- **WHEN** se usa `[roi_post_grid orderby="title"]`
|
||||
- **THEN** DEBE ordenar posts por ese campo
|
||||
- **AND** default DEBE ser "date"
|
||||
- **AND** opciones validas: date, title, modified, rand, comment_count
|
||||
|
||||
#### Scenario: Atributo order
|
||||
- **WHEN** se usa `[roi_post_grid order="ASC"]`
|
||||
- **THEN** DEBE definir direccion del orden
|
||||
- **AND** default DEBE ser "DESC"
|
||||
- **AND** opciones validas: ASC, DESC
|
||||
|
||||
#### Scenario: Atributo show_pagination
|
||||
- **WHEN** se usa `[roi_post_grid show_pagination="true"]`
|
||||
- **THEN** DEBE mostrar paginacion si hay mas posts
|
||||
- **AND** default DEBE ser false
|
||||
|
||||
#### Scenario: Atributo offset
|
||||
- **WHEN** se usa `[roi_post_grid offset="3"]`
|
||||
- **THEN** DEBE saltar los primeros N posts
|
||||
- **AND** default DEBE ser 0
|
||||
|
||||
#### Scenario: Atributo exclude_posts
|
||||
- **WHEN** se usa `[roi_post_grid exclude_posts="123,456"]`
|
||||
- **THEN** DEBE excluir posts por ID
|
||||
- **AND** DEBE aceptar IDs separados por coma
|
||||
|
||||
#### Scenario: Atributos de visualizacion
|
||||
- **WHEN** se usan atributos de visualizacion
|
||||
- **THEN** show_thumbnail default true
|
||||
- **AND** show_excerpt default true
|
||||
- **AND** show_meta default true
|
||||
- **AND** show_categories default true
|
||||
- **AND** excerpt_length default 20
|
||||
|
||||
#### Scenario: Atributo class
|
||||
- **WHEN** se usa `[roi_post_grid class="my-custom-grid"]`
|
||||
- **THEN** DEBE agregar clase CSS adicional al contenedor
|
||||
|
||||
#### Scenario: Atributo id para paginacion multiple
|
||||
- **WHEN** se usa `[roi_post_grid id="grid-1" show_pagination="true"]`
|
||||
- **THEN** DEBE usar query var unico `paged_grid-1`
|
||||
- **AND** permite multiples shortcodes paginados en misma pagina
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Obtencion de Settings
|
||||
|
||||
The shortcode MUST obtain styles from post-grid component settings.
|
||||
|
||||
#### Scenario: Lectura de configuracion
|
||||
- **WHEN** RenderPostGridUseCase se ejecuta
|
||||
- **THEN** DEBE usar ComponentSettingsRepositoryInterface
|
||||
- **AND** DEBE obtener settings de componente 'post-grid'
|
||||
- **AND** DEBE aplicar colores, spacing, visual_effects del componente
|
||||
|
||||
#### Scenario: Settings no encontrados
|
||||
- **WHEN** no existen settings de post-grid en BD
|
||||
- **THEN** DEBE usar valores default definidos en VisibilityDefaults
|
||||
- **AND** NO DEBE fallar con error
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Renderizado HTML
|
||||
|
||||
The shortcode MUST generate valid HTML with proper escaping.
|
||||
|
||||
#### Scenario: Estructura HTML del grid
|
||||
- **WHEN** el shortcode renderiza
|
||||
- **THEN** DEBE generar contenedor con clase `roi-post-grid-shortcode`
|
||||
- **AND** DEBE generar row con clase Bootstrap `row`
|
||||
- **AND** cada card DEBE tener columna responsive
|
||||
|
||||
#### Scenario: Clases responsive de columnas
|
||||
- **WHEN** columns es 3
|
||||
- **THEN** cada card DEBE tener `col-12 col-md-6 col-lg-4`
|
||||
- **AND** para columns=4: `col-12 col-md-6 col-lg-3`
|
||||
- **AND** para columns=2: `col-12 col-md-6`
|
||||
- **AND** para columns=1: `col-12`
|
||||
|
||||
#### Scenario: Sin resultados
|
||||
- **WHEN** el query no encuentra posts
|
||||
- **THEN** DEBE mostrar mensaje "No se encontraron publicaciones"
|
||||
- **AND** NO DEBE romper layout
|
||||
|
||||
#### Scenario: Escaping obligatorio
|
||||
- **WHEN** se genera HTML
|
||||
- **THEN** DEBE usar esc_html() para textos
|
||||
- **AND** DEBE usar esc_attr() para atributos
|
||||
- **AND** DEBE usar esc_url() para URLs
|
||||
- **AND** DEBE usar wp_kses_post() para excerpts
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CSS via CSSGenerator
|
||||
|
||||
The shortcode MUST use CSSGeneratorInterface for styles.
|
||||
|
||||
#### Scenario: Generacion de CSS
|
||||
- **WHEN** PostGridShortcodeRenderer genera CSS
|
||||
- **THEN** DEBE inyectar CSSGeneratorInterface
|
||||
- **AND** DEBE usar settings de post-grid desde BD
|
||||
- **AND** DEBE generar CSS inline en el shortcode
|
||||
|
||||
#### Scenario: Selector unico
|
||||
- **WHEN** se genera CSS
|
||||
- **THEN** DEBE usar selector `.roi-post-grid-shortcode`
|
||||
- **AND** si se especifica id, usar `.roi-post-grid-shortcode-{id}`
|
||||
- **AND** NO DEBE conflictuar con post-grid del template
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Registro del Shortcode
|
||||
|
||||
The shortcode MUST be registered in WordPress.
|
||||
|
||||
#### Scenario: Registro en bootstrap
|
||||
- **WHEN** WordPress carga el tema
|
||||
- **THEN** functions-addon.php DEBE llamar PostGridShortcodeRegistrar::register()
|
||||
- **AND** DEBE estar disponible en editor clasico y Gutenberg
|
||||
|
||||
#### Scenario: Metodo register estatico
|
||||
- **WHEN** se llama PostGridShortcodeRegistrar::register()
|
||||
- **THEN** DEBE ejecutar add_shortcode('roi_post_grid', ...)
|
||||
- **AND** DEBE usar DIContainer para obtener dependencias
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Fase 1: Interfaces y DTOs
|
||||
1. Crear `Shared/Domain/Contracts/PostGridQueryBuilderInterface.php`
|
||||
2. Crear `Shared/Domain/Contracts/PostGridShortcodeRendererInterface.php`
|
||||
3. Crear `Shared/Application/UseCases/RenderPostGrid/RenderPostGridRequest.php`
|
||||
|
||||
### Fase 2: Use Case
|
||||
1. Crear `Shared/Application/UseCases/RenderPostGrid/RenderPostGridUseCase.php`
|
||||
2. Implementar orquestacion de query, settings, render
|
||||
|
||||
### Fase 3: Infrastructure - Query
|
||||
1. Crear `Shared/Infrastructure/Query/PostGridQueryBuilder.php`
|
||||
2. Implementar construccion de WP_Query con todos los filtros
|
||||
|
||||
### Fase 4: Infrastructure - Renderer
|
||||
1. Crear `Shared/Infrastructure/Ui/PostGridShortcodeRenderer.php`
|
||||
2. Implementar generacion HTML y CSS
|
||||
|
||||
### Fase 5: Infrastructure - Registrar
|
||||
1. Crear `Shared/Infrastructure/Wordpress/PostGridShortcodeRegistrar.php`
|
||||
2. Implementar registro y sanitizacion de atributos
|
||||
|
||||
### Fase 6: Registro y DI
|
||||
1. Registrar en DIContainer las implementaciones
|
||||
2. Llamar register() en functions-addon.php
|
||||
|
||||
### Fase 7: Testing
|
||||
1. Probar en pagina estatica
|
||||
2. Verificar filtros por categoria, tag
|
||||
3. Verificar paginacion con id unico
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
Shared/
|
||||
├── Domain/
|
||||
│ └── Contracts/
|
||||
│ ├── PostGridQueryBuilderInterface.php
|
||||
│ └── PostGridShortcodeRendererInterface.php
|
||||
├── Application/
|
||||
│ └── UseCases/
|
||||
│ └── RenderPostGrid/
|
||||
│ ├── RenderPostGridRequest.php
|
||||
│ └── RenderPostGridUseCase.php
|
||||
└── Infrastructure/
|
||||
├── Query/
|
||||
│ └── PostGridQueryBuilder.php
|
||||
├── Ui/
|
||||
│ └── PostGridShortcodeRenderer.php
|
||||
└── Wordpress/
|
||||
└── PostGridShortcodeRegistrar.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Ejemplo: Grid basico
|
||||
```
|
||||
[roi_post_grid]
|
||||
```
|
||||
|
||||
### Ejemplo: Posts de una categoria
|
||||
```
|
||||
[roi_post_grid category="precios-unitarios" posts_per_page="6"]
|
||||
```
|
||||
|
||||
### Ejemplo: Multiples shortcodes con paginacion
|
||||
```
|
||||
[roi_post_grid id="grid-cursos" category="cursos" show_pagination="true"]
|
||||
|
||||
[roi_post_grid id="grid-tutoriales" category="tutoriales" show_pagination="true"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Existentes (reutilizar)
|
||||
- `CSSGeneratorInterface` - Para generar CSS
|
||||
- `ComponentSettingsRepositoryInterface` - Para leer config de post-grid
|
||||
- `DIContainer` - Para inyeccion de dependencias
|
||||
- Bootstrap 5 grid system - Para layout responsive
|
||||
|
||||
### Nuevas (crear)
|
||||
- `PostGridQueryBuilderInterface` - Contrato para query builder
|
||||
- `PostGridShortcodeRendererInterface` - Contrato para renderer
|
||||
- `RenderPostGridRequest` - DTO de entrada
|
||||
- `RenderPostGridUseCase` - Orquestador
|
||||
- `PostGridQueryBuilder` - Implementacion query
|
||||
- `PostGridShortcodeRenderer` - Implementacion render
|
||||
- `PostGridShortcodeRegistrar` - Registro WordPress
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Shortcode renderiza sin atributos
|
||||
- [ ] Filtro por categoria funciona
|
||||
- [ ] Filtro por multiples categorias funciona
|
||||
- [ ] Exclusion de categoria funciona
|
||||
- [ ] Filtro por tag funciona
|
||||
- [ ] Columnas 1, 2, 3, 4 funcionan
|
||||
- [ ] Paginacion con id unico funciona
|
||||
- [ ] Multiples shortcodes paginados funcionan
|
||||
- [ ] CSS se aplica desde settings de post-grid
|
||||
- [ ] Mensaje "sin posts" aparece cuando corresponde
|
||||
- [ ] Escaping correcto en todo el HTML
|
||||
- [ ] Funciona en editor clasico
|
||||
- [ ] Funciona en Gutenberg
|
||||
- [ ] No hay errores PHP
|
||||
- [ ] Clases tienen menos de 150 lineas
|
||||
417
openspec/specs/templates-unificados/spec.md
Normal file
417
openspec/specs/templates-unificados/spec.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Especificacion de Templates Unificados para Blog/Archive
|
||||
|
||||
## Purpose
|
||||
|
||||
Define la arquitectura para unificar todos los templates de listados (blog, categorias, tags, archives) usando la misma estructura que `single.php`, aprovechando el sistema de visibilidad existente para controlar que componentes mostrar en cada contexto. Incluye la creacion de dos nuevos componentes: `archive-header` y `post-grid`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Template Unificado para Listados
|
||||
|
||||
All listing templates MUST use the same structure as single.php.
|
||||
|
||||
#### Scenario: Estructura base de templates de listado
|
||||
- **WHEN** se implementa home.php, archive.php, category.php o tag.php
|
||||
- **THEN** DEBE usar la misma estructura que single.php
|
||||
- **AND** DEBE llamar a roi_render_component() para cada componente
|
||||
- **AND** la visibilidad se controla via PageVisibilityHelper::shouldShow()
|
||||
|
||||
#### Scenario: Componentes que se llaman en templates de listado
|
||||
- **WHEN** se renderiza un template de listado
|
||||
- **THEN** DEBE llamar a roi_render_component('hero')
|
||||
- **AND** DEBE llamar a roi_render_component('archive-header')
|
||||
- **AND** DEBE llamar a roi_render_component('post-grid')
|
||||
- **AND** DEBE llamar a roi_render_component('table-of-contents') en sidebar
|
||||
- **AND** DEBE llamar a roi_render_component('cta-box-sidebar') en sidebar
|
||||
- **AND** DEBE llamar a roi_render_component('contact-form')
|
||||
- **AND** cada componente decide si renderiza segun show_on_archives
|
||||
|
||||
#### Scenario: Determinacion de sidebar en listados
|
||||
- **WHEN** se determina si mostrar sidebar en un listado
|
||||
- **THEN** DEBE usar roi_should_render_any_wrapper(['table-of-contents', 'cta-box-sidebar'])
|
||||
- **AND** si retorna true usar col-lg-9 para contenido principal
|
||||
- **AND** si retorna false usar col-lg-12 para contenido principal
|
||||
|
||||
#### Scenario: Paginacion en templates de listado
|
||||
- **WHEN** se muestra paginacion en un listado
|
||||
- **THEN** DEBE usar the_posts_pagination() de WordPress
|
||||
- **AND** DEBE aplicar estilos Bootstrap via CSS del componente post-grid
|
||||
|
||||
#### Scenario: CSS de paginacion generado por post-grid
|
||||
- **WHEN** PostGridRenderer renderiza la paginacion
|
||||
- **THEN** el CSS de paginacion DEBE generarse via CSSGeneratorService
|
||||
- **AND** DEBE aplicar estilos Bootstrap (nav-links, page-numbers)
|
||||
- **AND** los colores DEBEN ser configurables via grupo colors del schema
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Componente archive-header
|
||||
|
||||
The archive-header component MUST display dynamic title and description for archive pages.
|
||||
|
||||
#### Scenario: Ubicacion de archivos archive-header
|
||||
- **WHEN** se crea el componente archive-header
|
||||
- **THEN** schema DEBE estar en Schemas/archive-header.json
|
||||
- **AND** Renderer DEBE estar en Public/ArchiveHeader/Infrastructure/Ui/ArchiveHeaderRenderer.php
|
||||
- **AND** FormBuilder DEBE estar en Admin/ArchiveHeader/Infrastructure/Ui/ArchiveHeaderFormBuilder.php
|
||||
- **AND** FieldMapper DEBE estar en Admin/ArchiveHeader/Infrastructure/FieldMapping/ArchiveHeaderFieldMapper.php
|
||||
|
||||
#### Scenario: Namespaces PHP de archive-header
|
||||
- **WHEN** se definen los namespaces para archive-header
|
||||
- **THEN** Renderer DEBE usar namespace `ROITheme\Public\ArchiveHeader\Infrastructure\Ui`
|
||||
- **AND** FormBuilder DEBE usar namespace `ROITheme\Admin\ArchiveHeader\Infrastructure\Ui`
|
||||
- **AND** FieldMapper DEBE usar namespace `ROITheme\Admin\ArchiveHeader\Infrastructure\FieldMapping`
|
||||
|
||||
#### Scenario: Deteccion automatica de tipo de archivo
|
||||
- **WHEN** ArchiveHeaderRenderer detecta el tipo de pagina
|
||||
- **THEN** para categoria DEBE mostrar "Categoria: [nombre]"
|
||||
- **AND** para tag DEBE mostrar "Etiqueta: [nombre]"
|
||||
- **AND** para autor DEBE mostrar "Articulos de: [nombre]"
|
||||
- **AND** para fecha DEBE mostrar "Archivo: [Mes Ano]"
|
||||
- **AND** para busqueda DEBE mostrar "Resultados para: [termino]"
|
||||
- **AND** para blog home DEBE mostrar el valor de blog_title del schema
|
||||
|
||||
#### Scenario: Grupos del schema archive-header
|
||||
- **WHEN** se define el schema archive-header.json
|
||||
- **THEN** DEBE incluir grupo visibility con priority 10
|
||||
- **AND** DEBE incluir grupo content con priority 20
|
||||
- **AND** DEBE incluir grupo typography con priority 30
|
||||
- **AND** DEBE incluir grupo colors con priority 40
|
||||
- **AND** DEBE incluir grupo spacing con priority 50
|
||||
- **AND** DEBE incluir grupo behavior con priority 70
|
||||
- **NOTE** archive-header NO incluye visual_effects (priority 60) porque es un componente de texto simple sin sombras, bordes redondeados ni transiciones hover
|
||||
|
||||
#### Scenario: Campos obligatorios de visibility en archive-header
|
||||
- **WHEN** se define grupo visibility en schema
|
||||
- **THEN** DEBE incluir is_enabled como boolean con default true
|
||||
- **AND** DEBE incluir show_on_desktop como boolean con default true
|
||||
- **AND** DEBE incluir show_on_mobile como boolean con default true
|
||||
|
||||
#### Scenario: Campos de _page_visibility en archive-header
|
||||
- **WHEN** se configura visibilidad por tipo de pagina en FieldMapper
|
||||
- **THEN** DEBE mapear campo show_on_home en grupo _page_visibility con default false
|
||||
- **AND** DEBE mapear campo show_on_posts en grupo _page_visibility con default false
|
||||
- **AND** DEBE mapear campo show_on_pages en grupo _page_visibility con default false
|
||||
- **AND** DEBE mapear campo show_on_archives en grupo _page_visibility con default true
|
||||
- **AND** DEBE mapear campo show_on_search en grupo _page_visibility con default false
|
||||
- **NOTE** Los campos _page_visibility NO van en el schema JSON, se manejan via FieldMapper y VisibilityDefaults
|
||||
- **NOTE** show_on_archives en true porque este componente solo tiene sentido en archives
|
||||
|
||||
#### Scenario: Campos de content en archive-header
|
||||
- **WHEN** se define grupo content
|
||||
- **THEN** DEBE incluir blog_title como text con default "Blog"
|
||||
- **AND** DEBE incluir show_post_count como boolean con default true
|
||||
- **AND** DEBE incluir show_description como boolean con default true
|
||||
|
||||
#### Scenario: Campos de typography en archive-header
|
||||
- **WHEN** se define grupo typography
|
||||
- **THEN** DEBE incluir heading_level como select con options ["h1", "h2", "h3", "h4", "h5", "h6"] y default "h1"
|
||||
- **AND** DEBE incluir title_size como text con default "2rem"
|
||||
- **AND** DEBE incluir title_weight como text con default "700"
|
||||
- **AND** DEBE incluir description_size como text con default "1rem"
|
||||
|
||||
#### Scenario: Campos de colors en archive-header
|
||||
- **WHEN** se define grupo colors
|
||||
- **THEN** DEBE incluir title_color como color con default "#0E2337"
|
||||
- **AND** DEBE incluir description_color como color con default "#6b7280"
|
||||
- **AND** DEBE incluir count_bg_color como color con default "#FF8600"
|
||||
- **AND** DEBE incluir count_text_color como color con default "#ffffff"
|
||||
|
||||
#### Scenario: Campos de spacing en archive-header
|
||||
- **WHEN** se define grupo spacing
|
||||
- **THEN** DEBE incluir margin_top como text con default "2rem"
|
||||
- **AND** DEBE incluir margin_bottom como text con default "2rem"
|
||||
- **AND** DEBE incluir padding como text con default "1.5rem"
|
||||
|
||||
#### Scenario: Campos de behavior en archive-header
|
||||
- **WHEN** se define grupo behavior
|
||||
- **THEN** DEBE incluir is_sticky como boolean con default false
|
||||
- **AND** DEBE incluir sticky_offset como text con default "0"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Componente post-grid
|
||||
|
||||
The post-grid component MUST display posts from the main WordPress loop in a grid layout.
|
||||
|
||||
#### Scenario: Ubicacion de archivos post-grid
|
||||
- **WHEN** se crea el componente post-grid
|
||||
- **THEN** schema DEBE estar en Schemas/post-grid.json
|
||||
- **AND** Renderer DEBE estar en Public/PostGrid/Infrastructure/Ui/PostGridRenderer.php
|
||||
- **AND** FormBuilder DEBE estar en Admin/PostGrid/Infrastructure/Ui/PostGridFormBuilder.php
|
||||
- **AND** FieldMapper DEBE estar en Admin/PostGrid/Infrastructure/FieldMapping/PostGridFieldMapper.php
|
||||
|
||||
#### Scenario: Namespaces PHP de post-grid
|
||||
- **WHEN** se definen los namespaces para post-grid
|
||||
- **THEN** Renderer DEBE usar namespace `ROITheme\Public\PostGrid\Infrastructure\Ui`
|
||||
- **AND** FormBuilder DEBE usar namespace `ROITheme\Admin\PostGrid\Infrastructure\Ui`
|
||||
- **AND** FieldMapper DEBE usar namespace `ROITheme\Admin\PostGrid\Infrastructure\FieldMapping`
|
||||
|
||||
#### Scenario: Diferencia entre post-grid y related-post
|
||||
- **WHEN** PostGridRenderer obtiene los posts
|
||||
- **THEN** DEBE usar global $wp_query para obtener posts del loop principal
|
||||
- **AND** NO DEBE crear su propio WP_Query como hace RelatedPostRenderer
|
||||
- **AND** DEBE llamar wp_reset_postdata() al finalizar si modifica el loop
|
||||
|
||||
#### Scenario: Grupos del schema post-grid
|
||||
- **WHEN** se define el schema post-grid.json
|
||||
- **THEN** DEBE incluir grupo visibility con priority 10
|
||||
- **AND** DEBE incluir grupo content con priority 20
|
||||
- **AND** DEBE incluir grupo typography con priority 30
|
||||
- **AND** DEBE incluir grupo colors con priority 40
|
||||
- **AND** DEBE incluir grupo spacing con priority 50
|
||||
- **AND** DEBE incluir grupo visual_effects con priority 60
|
||||
- **AND** DEBE incluir grupo layout con priority 80
|
||||
- **AND** DEBE incluir grupo media con priority 90
|
||||
|
||||
#### Scenario: Campos obligatorios de visibility en post-grid
|
||||
- **WHEN** se define grupo visibility en schema
|
||||
- **THEN** DEBE incluir is_enabled como boolean con default true
|
||||
- **AND** DEBE incluir show_on_desktop como boolean con default true
|
||||
- **AND** DEBE incluir show_on_mobile como boolean con default true
|
||||
|
||||
#### Scenario: Campos de _page_visibility en post-grid
|
||||
- **WHEN** se configura visibilidad por tipo de pagina en FieldMapper
|
||||
- **THEN** DEBE mapear campo show_on_home en grupo _page_visibility con default true
|
||||
- **AND** DEBE mapear campo show_on_posts en grupo _page_visibility con default false
|
||||
- **AND** DEBE mapear campo show_on_pages en grupo _page_visibility con default false
|
||||
- **AND** DEBE mapear campo show_on_archives en grupo _page_visibility con default true
|
||||
- **AND** DEBE mapear campo show_on_search en grupo _page_visibility con default true
|
||||
- **NOTE** Los campos _page_visibility NO van en el schema JSON, se manejan via FieldMapper y VisibilityDefaults
|
||||
- **NOTE** show_on_home en true para mostrar grid en pagina de blog principal
|
||||
- **NOTE** show_on_archives en true porque este componente es para listados
|
||||
- **NOTE** show_on_search en true para mostrar resultados de busqueda
|
||||
|
||||
#### Scenario: Campos de content en post-grid
|
||||
- **WHEN** se define grupo content
|
||||
- **THEN** DEBE incluir show_thumbnail como boolean con default true
|
||||
- **AND** DEBE incluir show_excerpt como boolean con default true
|
||||
- **AND** DEBE incluir show_meta como boolean con default true
|
||||
- **AND** DEBE incluir show_categories como boolean con default true
|
||||
- **AND** DEBE incluir excerpt_length como select con options ["10", "15", "20", "25", "30"] y default "20"
|
||||
- **AND** DEBE incluir read_more_text como text con default "Leer mas"
|
||||
- **AND** DEBE incluir no_posts_message como text con default "No se encontraron publicaciones"
|
||||
|
||||
#### Scenario: Campos de media en post-grid
|
||||
- **WHEN** se define grupo media
|
||||
- **THEN** DEBE incluir fallback_image como url con default ""
|
||||
- **AND** DEBE incluir fallback_image_alt como text con default "Imagen por defecto"
|
||||
- **AND** fallback_image_alt es obligatorio para accesibilidad WCAG
|
||||
|
||||
#### Scenario: Campos de typography en post-grid
|
||||
- **WHEN** se define grupo typography
|
||||
- **THEN** DEBE incluir heading_level como select con options ["h2", "h3", "h4", "h5", "h6"] y default "h3"
|
||||
- **AND** DEBE incluir card_title_size como text con default "1.1rem"
|
||||
- **AND** DEBE incluir card_title_weight como text con default "600"
|
||||
- **AND** DEBE incluir excerpt_size como text con default "0.9rem"
|
||||
- **AND** DEBE incluir meta_size como text con default "0.8rem"
|
||||
|
||||
#### Scenario: Campos de colors en post-grid
|
||||
- **WHEN** se define grupo colors
|
||||
- **THEN** DEBE incluir card_bg_color como color con default "#ffffff"
|
||||
- **AND** DEBE incluir card_title_color como color con default "#0E2337"
|
||||
- **AND** DEBE incluir card_hover_bg_color como color con default "#f9fafb"
|
||||
- **AND** DEBE incluir card_border_color como color con default "#e5e7eb"
|
||||
- **AND** DEBE incluir card_hover_border_color como color con default "#FF8600"
|
||||
- **AND** DEBE incluir excerpt_color como color con default "#6b7280"
|
||||
- **AND** DEBE incluir meta_color como color con default "#9ca3af"
|
||||
- **AND** DEBE incluir category_bg_color como color con default "#FFF5EB"
|
||||
- **AND** DEBE incluir category_text_color como color con default "#FF8600"
|
||||
|
||||
#### Scenario: Campos de spacing en post-grid
|
||||
- **WHEN** se define grupo spacing
|
||||
- **THEN** DEBE incluir grid_gap como text con default "1.5rem"
|
||||
- **AND** DEBE incluir card_padding como text con default "1.25rem"
|
||||
- **AND** DEBE incluir section_margin_top como text con default "0"
|
||||
- **AND** DEBE incluir section_margin_bottom como text con default "2rem"
|
||||
|
||||
#### Scenario: Campos de visual_effects en post-grid
|
||||
- **WHEN** se define grupo visual_effects
|
||||
- **THEN** DEBE incluir card_border_radius como text con default "0.5rem"
|
||||
- **AND** DEBE incluir card_shadow como text con default "0 1px 3px rgba(0,0,0,0.1)"
|
||||
- **AND** DEBE incluir card_hover_shadow como text con default "0 4px 12px rgba(0,0,0,0.15)"
|
||||
- **AND** DEBE incluir card_transition como text con default "all 0.3s ease"
|
||||
- **AND** DEBE incluir image_border_radius como text con default "0.375rem"
|
||||
|
||||
#### Scenario: Campos de layout en post-grid
|
||||
- **WHEN** se define grupo layout
|
||||
- **THEN** DEBE incluir columns_desktop como select con options ["2", "3", "4"] y default "3"
|
||||
- **AND** DEBE incluir columns_tablet como select con options ["1", "2", "3"] y default "2"
|
||||
- **AND** DEBE incluir columns_mobile como select con options ["1", "2"] y default "1"
|
||||
- **AND** DEBE incluir image_position como select con options ["top", "left", "none"] y default "top"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Manejo Graceful de Contenido Faltante
|
||||
|
||||
The post-grid component MUST handle missing content gracefully.
|
||||
|
||||
#### Scenario: Post sin imagen destacada
|
||||
- **WHEN** un post no tiene thumbnail y show_thumbnail es true
|
||||
- **THEN** si fallback_image tiene valor DEBE mostrar esa imagen con fallback_image_alt
|
||||
- **AND** si fallback_image esta vacio DEBE omitir la imagen sin romper el layout
|
||||
- **AND** NO DEBE mostrar imagen rota o placeholder generico
|
||||
|
||||
#### Scenario: Post sin excerpt
|
||||
- **WHEN** un post no tiene excerpt y show_excerpt es true
|
||||
- **THEN** DEBE generar excerpt automatico desde post_content
|
||||
- **AND** DEBE respetar excerpt_length del schema
|
||||
- **AND** DEBE usar wp_trim_words() para truncar
|
||||
|
||||
#### Scenario: Post sin categorias
|
||||
- **WHEN** un post no tiene categorias y show_categories es true
|
||||
- **THEN** DEBE omitir la seccion de categorias
|
||||
- **AND** NO DEBE mostrar "Sin categoria" u otro texto placeholder
|
||||
|
||||
#### Scenario: No posts found - Query vacia
|
||||
- **WHEN** have_posts() retorna false en un template de listado
|
||||
- **THEN** post-grid DEBE mostrar mensaje configurable de "no hay posts"
|
||||
- **AND** el mensaje DEBE usar campo no_posts_message del schema con default "No se encontraron publicaciones"
|
||||
- **AND** DEBE aplicar estilos consistentes con el design system
|
||||
- **AND** NO DEBE romper el layout de la pagina
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Visibilidad por Tipo de Pagina
|
||||
|
||||
Components MUST respect the show_on_archives setting in _page_visibility group.
|
||||
|
||||
#### Scenario: Patron de visibilidad por tipo de pagina
|
||||
- **WHEN** se implementa visibilidad por tipo de pagina
|
||||
- **THEN** los campos show_on_home, show_on_posts, show_on_pages, show_on_archives, show_on_search
|
||||
- **AND** DEBEN estar en grupo _page_visibility (NO en visibility)
|
||||
- **AND** DEBEN mapearse via FieldMapper del componente
|
||||
- **AND** DEBEN evaluarse via PageVisibilityHelper::shouldShow()
|
||||
|
||||
#### Scenario: Configuracion por defecto de show_on_archives para nuevos componentes
|
||||
- **WHEN** se configura _page_visibility para componentes nuevos
|
||||
- **THEN** archive-header DEBE tener show_on_archives true en _page_visibility
|
||||
- **AND** post-grid DEBE tener show_on_archives true en _page_visibility
|
||||
|
||||
#### Scenario: Componentes existentes en archives
|
||||
- **WHEN** se evalua que componentes mostrar en archives via _page_visibility
|
||||
- **THEN** hero DEBE tener show_on_archives false por defecto (configurable)
|
||||
- **AND** table-of-contents DEBE tener show_on_archives false
|
||||
- **AND** featured-image DEBE tener show_on_archives false
|
||||
- **AND** social-share DEBE tener show_on_archives false
|
||||
- **AND** related-post DEBE tener show_on_archives false
|
||||
- **AND** cta-box-sidebar DEBE tener show_on_archives true
|
||||
- **AND** contact-form DEBE tener show_on_archives configurable
|
||||
|
||||
#### Scenario: Llamada a componente con visibilidad deshabilitada (Patron Template Unificado)
|
||||
- **GIVEN** el template unificado llama a TODOS los componentes para mantener consistencia
|
||||
- **WHEN** un template llama roi_render_component() para un componente
|
||||
- **AND** ese componente tiene show_on_archives false
|
||||
- **THEN** el componente NO DEBE renderizarse (retorna string vacio)
|
||||
- **AND** esto es comportamiento correcto y esperado, NO un error
|
||||
- **AND** permite que el admin habilite/deshabilite componentes sin modificar templates
|
||||
- **NOTE** Por ejemplo: table-of-contents se llama en sidebar pero no renderiza en archives porque show_on_archives=false
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Templates a Modernizar
|
||||
|
||||
These templates MUST be updated to use the unified structure.
|
||||
|
||||
#### Scenario: Modernizar home.php
|
||||
- **WHEN** se actualiza home.php
|
||||
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
|
||||
- **AND** DEBE eliminar referencia a TemplateParts/content.php
|
||||
- **AND** DEBE usar estructura unificada con hero, archive-header, post-grid
|
||||
|
||||
#### Scenario: Modernizar archive.php
|
||||
- **WHEN** se actualiza archive.php
|
||||
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
|
||||
- **AND** DEBE eliminar referencia a TemplateParts/content.php
|
||||
- **AND** DEBE usar estructura unificada
|
||||
|
||||
#### Scenario: Modernizar category.php
|
||||
- **GIVEN** category.php existe en roi-theme/ (verificado)
|
||||
- **WHEN** se actualiza category.php
|
||||
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
|
||||
- **AND** DEBE eliminar referencia a TemplateParts/content.php
|
||||
- **AND** DEBE usar estructura unificada
|
||||
|
||||
#### Scenario: Modernizar tag.php
|
||||
- **GIVEN** tag.php existe en roi-theme/ (verificado)
|
||||
- **WHEN** se actualiza tag.php
|
||||
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
|
||||
- **AND** DEBE eliminar referencia a TemplateParts/content.php
|
||||
- **AND** DEBE usar estructura unificada
|
||||
|
||||
#### Scenario: Modernizar author.php
|
||||
- **GIVEN** author.php existe en roi-theme/ (verificado)
|
||||
- **WHEN** se actualiza author.php
|
||||
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
|
||||
- **AND** DEBE eliminar referencia a TemplateParts/content.php
|
||||
- **AND** DEBE usar estructura unificada
|
||||
- **AND** archive-header detectara automaticamente contexto de autor
|
||||
|
||||
#### Scenario: Modernizar date.php
|
||||
- **GIVEN** date.php existe en roi-theme/ (verificado)
|
||||
- **WHEN** se actualiza date.php
|
||||
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
|
||||
- **AND** DEBE eliminar referencia a TemplateParts/content.php
|
||||
- **AND** DEBE usar estructura unificada
|
||||
- **AND** archive-header detectara automaticamente contexto de fecha
|
||||
|
||||
#### Scenario: Modernizar search.php
|
||||
- **GIVEN** search.php existe en roi-theme/ (verificado)
|
||||
- **WHEN** se actualiza search.php
|
||||
- **THEN** DEBE reemplazar get_template_part() con roi_render_component()
|
||||
- **AND** DEBE eliminar referencia a TemplateParts/content.php
|
||||
- **AND** DEBE usar estructura unificada con post-grid
|
||||
- **AND** archive-header detectara automaticamente contexto de busqueda mostrando "Resultados: [termino]"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Orden de Implementacion
|
||||
|
||||
Components and templates MUST be implemented in a specific order.
|
||||
|
||||
#### Scenario: Secuencia de implementacion
|
||||
- **WHEN** se implementa esta especificacion
|
||||
- **THEN** Fase 1 es crear componente archive-header (5 pasos del flujo)
|
||||
- **AND** Fase 2 es crear componente post-grid (5 pasos del flujo)
|
||||
- **AND** Fase 3 es modernizar home.php
|
||||
- **AND** Fase 4 es modernizar archive.php
|
||||
- **AND** Fase 5 es modernizar category.php
|
||||
- **AND** Fase 6 es modernizar tag.php
|
||||
- **AND** Fase 7 es modernizar author.php
|
||||
- **AND** Fase 8 es modernizar date.php
|
||||
- **AND** Fase 9 es modernizar search.php
|
||||
- **AND** Fase 10 es configurar visibilidad de componentes existentes
|
||||
|
||||
#### Scenario: Cada componente sigue flujo de 5 fases
|
||||
- **WHEN** se crea archive-header o post-grid
|
||||
- **THEN** DEBE seguir Fase 1: Schema JSON
|
||||
- **AND** DEBE seguir Fase 2: Sincronizacion wp roi-theme sync-component
|
||||
- **AND** DEBE seguir Fase 3: Renderer
|
||||
- **AND** DEBE seguir Fase 4: FormBuilder
|
||||
- **AND** DEBE seguir Fase 5: Validacion validate-architecture.php
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Dependencias Existentes
|
||||
|
||||
The implementation MUST use existing infrastructure.
|
||||
|
||||
#### Scenario: Uso de PageVisibilityHelper
|
||||
- **WHEN** un Renderer verifica visibilidad
|
||||
- **THEN** DEBE usar PageVisibilityHelper::shouldShow(componentName)
|
||||
- **AND** esta en Shared/Infrastructure/Services/PageVisibilityHelper.php
|
||||
|
||||
#### Scenario: Uso de CSSGeneratorInterface
|
||||
- **WHEN** un Renderer genera CSS
|
||||
- **THEN** DEBE inyectar CSSGeneratorInterface via constructor
|
||||
- **AND** DEBE usar $this->cssGenerator->generate()
|
||||
|
||||
#### Scenario: Uso de roi_should_render_any_wrapper
|
||||
- **WHEN** un template determina si mostrar sidebar
|
||||
- **THEN** DEBE usar roi_should_render_any_wrapper()
|
||||
- **AND** esta definida en functions-addon.php linea 423
|
||||
|
||||
#### Scenario: Uso de DIContainer
|
||||
- **WHEN** se instancian servicios
|
||||
- **THEN** DEBE usar DIContainer::getInstance()
|
||||
- **AND** NO DEBE instanciar servicios con new directamente
|
||||
174
search.php
174
search.php
@@ -2,9 +2,15 @@
|
||||
/**
|
||||
* The template for displaying search results pages
|
||||
*
|
||||
* IMPORTANT: This theme has search functionality disabled.
|
||||
* All search attempts will be redirected to 404.
|
||||
* This template serves as a fallback and will display a 404 error.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* NOTA: Si se desea deshabilitar la busqueda, descomentar las lineas siguientes:
|
||||
* status_header( 404 );
|
||||
* nocache_headers();
|
||||
* include( get_404_template() );
|
||||
* exit;
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#search-result
|
||||
*
|
||||
@@ -12,117 +18,81 @@
|
||||
* @since 1.0.0
|
||||
*/
|
||||
|
||||
// Force 404 status
|
||||
status_header( 404 );
|
||||
nocache_headers();
|
||||
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
|
||||
<section class="error-404 not-found search-disabled" aria-labelledby="search-error-title">
|
||||
|
||||
<!-- Error Header -->
|
||||
<header class="page-header">
|
||||
<h1 id="search-error-title" class="page-title">
|
||||
<?php esc_html_e( 'Search Unavailable', 'roi-theme' ); ?>
|
||||
</h1>
|
||||
</header><!-- .page-header -->
|
||||
|
||||
<!-- Error Content -->
|
||||
<div class="page-content">
|
||||
|
||||
<p class="error-message">
|
||||
<?php esc_html_e( 'The search functionality is currently disabled on this website.', 'roi-theme' ); ?>
|
||||
</p>
|
||||
|
||||
<!-- Helpful Actions -->
|
||||
<div class="error-actions">
|
||||
|
||||
<h2><?php esc_html_e( 'How to find content:', 'roi-theme' ); ?></h2>
|
||||
|
||||
<ul class="error-suggestions">
|
||||
<li>
|
||||
<a href="<?php echo esc_url( home_url( '/' ) ); ?>">
|
||||
<?php esc_html_e( 'Go to the homepage', 'roi-theme' ); ?>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<?php esc_html_e( 'Use the navigation menu above', 'roi-theme' ); ?>
|
||||
</li>
|
||||
<li>
|
||||
<?php esc_html_e( 'Browse by category below', 'roi-theme' ); ?>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Categories -->
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
$categories = get_categories(
|
||||
array(
|
||||
'orderby' => 'count',
|
||||
'order' => 'DESC',
|
||||
'number' => 10,
|
||||
'hide_empty' => true,
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $categories ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
<div class="categories-section">
|
||||
<h3><?php esc_html_e( 'Browse by Category', 'roi-theme' ); ?></h3>
|
||||
<ul class="categories-list" role="list">
|
||||
<?php foreach ( $categories as $category ) : ?>
|
||||
<li>
|
||||
<a href="<?php echo esc_url( get_category_link( $category->term_id ) ); ?>">
|
||||
<?php echo esc_html( $category->name ); ?>
|
||||
<span class="category-count">(<?php echo esc_html( $category->count ); ?>)</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
|
||||
<!-- Archive Header - Componente dinamico (detecta busqueda automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
|
||||
|
||||
<?php if ($show_sidebar): ?>
|
||||
<!-- Sidebar Column (col-lg-3) -->
|
||||
<div class="col-lg-3">
|
||||
<div class="sidebar-sticky">
|
||||
<!-- Table of Contents - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('table-of-contents');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Box Sidebar - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-box-sidebar');
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Recent Posts -->
|
||||
<?php
|
||||
$recent_posts = wp_get_recent_posts(
|
||||
array(
|
||||
'numberposts' => 10,
|
||||
'post_status' => 'publish',
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $recent_posts ) ) :
|
||||
?>
|
||||
<div class="recent-posts-section">
|
||||
<h3><?php esc_html_e( 'Recent Posts', 'roi-theme' ); ?></h3>
|
||||
<ul class="recent-posts-list" role="list">
|
||||
<?php foreach ( $recent_posts as $recent ) : ?>
|
||||
<li>
|
||||
<a href="<?php echo esc_url( get_permalink( $recent['ID'] ) ); ?>">
|
||||
<?php echo esc_html( $recent['post_title'] ); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
<?php
|
||||
wp_reset_postdata();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .error-actions -->
|
||||
|
||||
</div><!-- .page-content -->
|
||||
|
||||
</section><!-- .error-404 -->
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
171
tag.php
171
tag.php
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* The template for displaying tag archive pages
|
||||
*
|
||||
* This template displays posts associated with a specific tag,
|
||||
* with tag information displayed at the top.
|
||||
* Estructura unificada siguiendo el patron de single.php.
|
||||
* Usa roi_render_component() para todos los componentes.
|
||||
* La visibilidad se controla via PageVisibilityHelper::shouldShow().
|
||||
*
|
||||
* @link https://developer.wordpress.org/themes/basics/template-hierarchy/#tag
|
||||
*
|
||||
@@ -16,116 +17,76 @@ get_header();
|
||||
|
||||
<main id="main-content" class="site-main" role="main">
|
||||
|
||||
<div class="content-wrapper">
|
||||
|
||||
<!-- Primary Content Area -->
|
||||
<div id="primary" class="content-area">
|
||||
|
||||
<?php if ( have_posts() ) : ?>
|
||||
|
||||
<!-- Tag Archive Header -->
|
||||
<header class="page-header tag-header">
|
||||
<!-- Hero Section - Componente dinamico -->
|
||||
<?php
|
||||
// Tag title
|
||||
the_archive_title( '<h1 class="page-title tag-title">', '</h1>' );
|
||||
|
||||
// Tag description
|
||||
$tag_description = tag_description();
|
||||
if ( ! empty( $tag_description ) ) :
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('hero');
|
||||
}
|
||||
?>
|
||||
<div class="archive-description tag-description">
|
||||
<?php echo wp_kses_post( wpautop( $tag_description ) ); ?>
|
||||
|
||||
<!-- Archive Header - Componente dinamico (detecta tag automaticamente) -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('archive-header');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<?php
|
||||
// Determinar si mostrar sidebar basandose en visibilidad de componentes
|
||||
$sidebar_components = ['table-of-contents', 'cta-box-sidebar'];
|
||||
$show_sidebar = function_exists('roi_should_render_any_wrapper')
|
||||
? roi_should_render_any_wrapper($sidebar_components)
|
||||
: false;
|
||||
$main_col_class = $show_sidebar ? 'col-lg-9' : 'col-lg-12';
|
||||
?>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Main Content Column -->
|
||||
<div class="<?php echo esc_attr($main_col_class); ?>">
|
||||
|
||||
<!-- Post Grid - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('post-grid');
|
||||
}
|
||||
?>
|
||||
|
||||
</div><!-- .<?php echo esc_attr($main_col_class); ?> -->
|
||||
|
||||
<?php if ($show_sidebar): ?>
|
||||
<!-- Sidebar Column (col-lg-3) -->
|
||||
<div class="col-lg-3">
|
||||
<div class="sidebar-sticky">
|
||||
<!-- Table of Contents - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('table-of-contents');
|
||||
}
|
||||
?>
|
||||
|
||||
<!-- CTA Box Sidebar - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('cta-box-sidebar');
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Tag metadata -->
|
||||
<?php
|
||||
$tag = get_queried_object();
|
||||
if ( $tag ) :
|
||||
?>
|
||||
<div class="tag-meta">
|
||||
<span class="tag-count">
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: number of posts */
|
||||
esc_html( _n( '%s post', '%s posts', $tag->count, 'roi-theme' ) ),
|
||||
esc_html( number_format_i18n( $tag->count ) )
|
||||
);
|
||||
?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</header><!-- .page-header -->
|
||||
|
||||
<!-- Tag Posts Loop -->
|
||||
<div class="archive-posts tag-posts">
|
||||
|
||||
<?php
|
||||
// Start the WordPress Loop
|
||||
while ( have_posts() ) :
|
||||
the_post();
|
||||
|
||||
/**
|
||||
* Include the Post-Type-specific template for the content.
|
||||
* If you want to override this in a child theme, then include a file
|
||||
* called content-___.php (where ___ is the Post Type name) and that will be used instead.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', get_post_type() );
|
||||
|
||||
endwhile;
|
||||
?>
|
||||
|
||||
</div><!-- .archive-posts -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Pagination
|
||||
* Display navigation to next/previous set of posts when applicable.
|
||||
*/
|
||||
the_posts_pagination(
|
||||
array(
|
||||
'mid_size' => 2,
|
||||
'prev_text' => sprintf(
|
||||
'%s <span class="nav-prev-text">%s</span>',
|
||||
'<span class="nav-icon" aria-hidden="true">«</span>',
|
||||
esc_html__( 'Previous', 'roi-theme' )
|
||||
),
|
||||
'next_text' => sprintf(
|
||||
'<span class="nav-next-text">%s</span> %s',
|
||||
esc_html__( 'Next', 'roi-theme' ),
|
||||
'<span class="nav-icon" aria-hidden="true">»</span>'
|
||||
),
|
||||
'before_page_number' => '<span class="screen-reader-text">' . esc_html__( 'Page', 'roi-theme' ) . ' </span>',
|
||||
'aria_label' => esc_attr__( 'Posts navigation', 'roi-theme' ),
|
||||
)
|
||||
);
|
||||
|
||||
else :
|
||||
|
||||
/**
|
||||
* No posts found
|
||||
* Display a message when no content is available.
|
||||
*/
|
||||
get_template_part( 'template-parts/content', 'none' );
|
||||
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- #primary -->
|
||||
|
||||
<?php
|
||||
/**
|
||||
* Sidebar
|
||||
* Display the sidebar if it's active.
|
||||
*/
|
||||
if ( is_active_sidebar( 'sidebar-1' ) ) :
|
||||
get_sidebar();
|
||||
endif;
|
||||
?>
|
||||
|
||||
</div><!-- .content-wrapper -->
|
||||
</div><!-- .row -->
|
||||
</div><!-- .container -->
|
||||
|
||||
</main><!-- #main-content -->
|
||||
|
||||
<!-- Contact Form Section - Componente dinamico -->
|
||||
<?php
|
||||
if (function_exists('roi_render_component')) {
|
||||
echo roi_render_component('contact-form');
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
get_footer();
|
||||
|
||||
27
test-shortcode.php
Normal file
27
test-shortcode.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Name: Test Post Grid Shortcode
|
||||
*
|
||||
* Página de prueba para el shortcode [roi_post_grid]
|
||||
*/
|
||||
get_header();
|
||||
?>
|
||||
|
||||
<main class="container py-5">
|
||||
<h1>Prueba de Shortcode [roi_post_grid]</h1>
|
||||
|
||||
<h2>Test 1: Grid básico (9 posts)</h2>
|
||||
<?php echo do_shortcode('[roi_post_grid]'); ?>
|
||||
|
||||
<hr class="my-5">
|
||||
|
||||
<h2>Test 2: 6 posts en 2 columnas</h2>
|
||||
<?php echo do_shortcode('[roi_post_grid posts_per_page="6" columns="2"]'); ?>
|
||||
|
||||
<hr class="my-5">
|
||||
|
||||
<h2>Test 3: 4 posts en 4 columnas sin meta</h2>
|
||||
<?php echo do_shortcode('[roi_post_grid posts_per_page="4" columns="4" show_meta="false"]'); ?>
|
||||
</main>
|
||||
|
||||
<?php get_footer(); ?>
|
||||
Reference in New Issue
Block a user