Compare commits
23 Commits
ce66eeba6d
...
pre-fix-cu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d2ba57b9 | ||
|
|
1c0750604b | ||
|
|
bf304f08fc | ||
|
|
30b30b065b | ||
|
|
b2d5cdfb57 | ||
|
|
b40e5b671a | ||
|
|
61c67acca5 | ||
|
|
ffe6ea8e65 | ||
|
|
36d5cf56de | ||
|
|
23339e3349 | ||
|
|
caa6413bc6 | ||
|
|
ea695010f3 | ||
|
|
e4c79d3f26 | ||
|
|
f4b45b7e17 | ||
|
|
c28fedd6e7 | ||
|
|
14138e7762 | ||
|
|
8735962f52 | ||
|
|
7fb5eda108 | ||
|
|
4cdc4db397 | ||
|
|
c732b5af05 | ||
|
|
29a69617e4 | ||
|
|
9e37ea93eb | ||
|
|
7472dbad11 |
17
.claude/commands/openspec/apply.md
Normal file
17
.claude/commands/openspec/apply.md
Normal file
@@ -0,0 +1,17 @@
|
||||
Implement the OpenSpec change: $ARGUMENTS
|
||||
|
||||
**Guardrails**
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
|
||||
**Steps**
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
|
||||
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
|
||||
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
|
||||
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
|
||||
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
|
||||
|
||||
**Reference**
|
||||
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
|
||||
21
.claude/commands/openspec/archive.md
Normal file
21
.claude/commands/openspec/archive.md
Normal file
@@ -0,0 +1,21 @@
|
||||
Archive the completed OpenSpec change: $ARGUMENTS
|
||||
|
||||
**Guardrails**
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
|
||||
**Steps**
|
||||
1. Determine the change ID to archive:
|
||||
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
|
||||
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
|
||||
- Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
|
||||
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
|
||||
2. Validate the change ID by running `openspec list` (or `openspec show <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
|
||||
3. Run `openspec archive <id> --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work).
|
||||
4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
|
||||
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
|
||||
|
||||
**Reference**
|
||||
- Use `openspec list` to confirm change IDs before archiving.
|
||||
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
|
||||
22
.claude/commands/openspec/proposal.md
Normal file
22
.claude/commands/openspec/proposal.md
Normal file
@@ -0,0 +1,22 @@
|
||||
Create an OpenSpec change proposal for: $ARGUMENTS
|
||||
|
||||
**Guardrails**
|
||||
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
|
||||
- Keep changes tightly scoped to the requested outcome.
|
||||
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
|
||||
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
|
||||
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
|
||||
|
||||
**Steps**
|
||||
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
|
||||
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
|
||||
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
|
||||
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant.
|
||||
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
|
||||
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
|
||||
|
||||
**Reference**
|
||||
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
|
||||
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
|
||||
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
|
||||
43
.commitlintrc.json
Normal file
43
.commitlintrc.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"],
|
||||
"rules": {
|
||||
"type-enum": [
|
||||
2,
|
||||
"always",
|
||||
[
|
||||
"feat",
|
||||
"fix",
|
||||
"docs",
|
||||
"style",
|
||||
"refactor",
|
||||
"perf",
|
||||
"test",
|
||||
"build",
|
||||
"ci",
|
||||
"chore",
|
||||
"revert"
|
||||
]
|
||||
],
|
||||
"scope-enum": [
|
||||
2,
|
||||
"always",
|
||||
[
|
||||
"theme",
|
||||
"templates",
|
||||
"assets",
|
||||
"css",
|
||||
"js",
|
||||
"php",
|
||||
"admin",
|
||||
"api",
|
||||
"schema",
|
||||
"seo",
|
||||
"config",
|
||||
"deps"
|
||||
]
|
||||
],
|
||||
"subject-case": [2, "always", "lower-case"],
|
||||
"subject-max-length": [2, "always", 72],
|
||||
"body-max-line-length": [2, "always", 72]
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -73,5 +73,4 @@ _testing-suite/
|
||||
# Claude Code tools
|
||||
.playwright-mcp/
|
||||
.serena/
|
||||
.claude/
|
||||
nul
|
||||
|
||||
1
.husky/commit-msg
Normal file
1
.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit $1
|
||||
18
AGENTS.md
Normal file
18
AGENTS.md
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
@@ -95,6 +95,29 @@ final class AdsensePlacementFieldMapper implements FieldMapperInterface
|
||||
'adsense-placementVignetteReshowEnabled' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_enabled'],
|
||||
'adsense-placementVignetteReshowTime' => ['group' => 'vignette_ads', 'attribute' => 'vignette_reshow_time'],
|
||||
'adsense-placementVignetteMaxPerSession' => ['group' => 'vignette_ads', 'attribute' => 'vignette_max_per_session'],
|
||||
|
||||
// SEARCH RESULTS (ROI APU Search)
|
||||
'adsense-placementSearchAdsEnabled' => ['group' => 'search_results', 'attribute' => 'search_ads_enabled'],
|
||||
'adsense-placementSearchTopAdEnabled' => ['group' => 'search_results', 'attribute' => 'search_top_ad_enabled'],
|
||||
'adsense-placementSearchTopAdFormat' => ['group' => 'search_results', 'attribute' => 'search_top_ad_format'],
|
||||
'adsense-placementSearchBetweenEnabled' => ['group' => 'search_results', 'attribute' => 'search_between_enabled'],
|
||||
'adsense-placementSearchBetweenMax' => ['group' => 'search_results', 'attribute' => 'search_between_max'],
|
||||
'adsense-placementSearchBetweenFormat' => ['group' => 'search_results', 'attribute' => 'search_between_format'],
|
||||
'adsense-placementSearchBetweenPosition' => ['group' => 'search_results', 'attribute' => 'search_between_position'],
|
||||
'adsense-placementSearchBetweenEvery' => ['group' => 'search_results', 'attribute' => 'search_between_every'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'adsense-placementVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'adsense-placementVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'adsense-placementVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'adsense-placementVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'adsense-placementVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'adsense-placementExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\AdsensePlacement\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para AdSense Placement y Google Analytics
|
||||
@@ -57,6 +58,7 @@ final class AdsensePlacementFormBuilder
|
||||
$html .= $this->buildRailAdsGroup($componentId);
|
||||
$html .= $this->buildAnchorAdsGroup($componentId);
|
||||
$html .= $this->buildVignetteAdsGroup($componentId);
|
||||
$html .= $this->buildSearchResultsGroup($componentId);
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= '</div>';
|
||||
@@ -95,6 +97,47 @@ final class AdsensePlacementFormBuilder
|
||||
$html .= '<small class="text-muted d-block" style="margin-top: -8px; margin-left: 40px;">No mostrar anuncios a usuarios con sesion iniciada en WordPress</small>';
|
||||
$html .= '</div>';
|
||||
|
||||
// =============================================
|
||||
// Visibilidad por tipo de pagina
|
||||
// Grupo especial: _page_visibility (Plan 99.11)
|
||||
// =============================================
|
||||
$html .= '<hr class="my-3">';
|
||||
$html .= '<p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-layout-text-window me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en tipos de pagina';
|
||||
$html .= '</p>';
|
||||
|
||||
$showOnHome = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_home', true);
|
||||
$showOnPosts = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_posts', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_archives', true);
|
||||
$showOnSearch = $this->renderer->getFieldValue($cid, '_page_visibility', 'show_on_search', true);
|
||||
|
||||
$html .= '<div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox($cid . 'VisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($cid, $cid);
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -708,6 +751,101 @@ final class AdsensePlacementFormBuilder
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seccion para anuncios en resultados de busqueda (ROI APU Search)
|
||||
*/
|
||||
private function buildSearchResultsGroup(string $cid): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #fd7e14;">';
|
||||
$html .= ' <div class="card-body">';
|
||||
$html .= ' <h5 class="fw-bold mb-3" style="color: #1e3a5f;">';
|
||||
$html .= ' <i class="bi bi-search me-2" style="color: #fd7e14;"></i>';
|
||||
$html .= ' Resultados de Busqueda';
|
||||
$html .= ' <span class="badge bg-secondary ms-2">ROI APU Search</span>';
|
||||
$html .= ' </h5>';
|
||||
$html .= ' <p class="small text-muted mb-3">Insertar anuncios en los resultados del buscador de Analisis de Precios Unitarios.</p>';
|
||||
|
||||
// Master switch
|
||||
$searchAdsEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_ads_enabled', false);
|
||||
$html .= $this->buildSwitch($cid . 'SearchAdsEnabled', 'Activar ads en busqueda', $searchAdsEnabled, 'bi-power');
|
||||
|
||||
// Anuncio superior
|
||||
$html .= '<div class="border rounded p-3 mb-3" style="background: #fff8f0;">';
|
||||
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
|
||||
$html .= ' <span class="badge" style="background: #fd7e14;">ANUNCIO SUPERIOR</span>';
|
||||
$html .= ' <small class="text-muted">Debajo del campo de busqueda</small>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<div class="row g-2">';
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$topEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_top_ad_enabled', true);
|
||||
$html .= $this->buildSwitch($cid . 'SearchTopAdEnabled', 'Activar', $topEnabled);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$topFormat = $this->renderer->getFieldValue($cid, 'search_results', 'search_top_ad_format', 'auto');
|
||||
$html .= $this->buildSelect($cid . 'SearchTopAdFormat', 'Formato',
|
||||
(string)$topFormat,
|
||||
['auto' => 'Auto (responsive)', 'display' => 'Display (fijo)', 'in-article' => 'In-Article (fluid)']
|
||||
);
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Anuncios entre resultados
|
||||
$html .= '<div class="border rounded p-3" style="background: #fff8f0;">';
|
||||
$html .= '<div class="d-flex align-items-center gap-2 mb-2">';
|
||||
$html .= ' <span class="badge" style="background: #fd7e14;">ENTRE RESULTADOS</span>';
|
||||
$html .= ' <small class="text-muted">Intercalados con los resultados</small>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<div class="row g-2 mb-2">';
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$betweenEnabled = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_enabled', true);
|
||||
$html .= $this->buildSwitch($cid . 'SearchBetweenEnabled', 'Activar', $betweenEnabled);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$betweenMax = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_max', '1');
|
||||
$html .= $this->buildSelect($cid . 'SearchBetweenMax', 'Maximo ads',
|
||||
(string)$betweenMax,
|
||||
['1' => '1 anuncio', '2' => '2 anuncios', '3' => '3 anuncios (max)']
|
||||
);
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<div class="row g-2 mb-2">';
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$betweenFormat = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_format', 'in-article');
|
||||
$html .= $this->buildSelect($cid . 'SearchBetweenFormat', 'Formato',
|
||||
(string)$betweenFormat,
|
||||
['in-article' => 'In-Article (fluid)', 'auto' => 'Auto (responsive)', 'autorelaxed' => 'Autorelaxed (feed)']
|
||||
);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$betweenPosition = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_position', 'random');
|
||||
$html .= $this->buildSelect($cid . 'SearchBetweenPosition', 'Posicion',
|
||||
(string)$betweenPosition,
|
||||
['random' => 'Aleatorio', 'fixed' => 'Fijo (cada N)', 'first_half' => 'Primera mitad']
|
||||
);
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<div class="row g-2">';
|
||||
$html .= ' <div class="col-md-6">';
|
||||
$betweenEvery = $this->renderer->getFieldValue($cid, 'search_results', 'search_between_every', '5');
|
||||
$html .= $this->buildSelect($cid . 'SearchBetweenEvery', 'Cada N resultados (si es fijo)',
|
||||
(string)$betweenEvery,
|
||||
['3' => 'Cada 3', '4' => 'Cada 4', '5' => 'Cada 5', '6' => 'Cada 6', '7' => 'Cada 7', '8' => 'Cada 8', '10' => 'Cada 10']
|
||||
);
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildExclusionsGroup(string $cid): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #6c757d;">';
|
||||
@@ -825,4 +963,23 @@ final class AdsensePlacementFormBuilder
|
||||
esc_attr($id), esc_html($label), esc_attr($id), $optionsHtml
|
||||
);
|
||||
}
|
||||
|
||||
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, $value): string
|
||||
{
|
||||
$checked = checked($value, true, false);
|
||||
|
||||
return sprintf(
|
||||
'<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="%s" %s>
|
||||
<label class="form-check-label small" for="%s">
|
||||
<i class="bi %s me-1" style="color: #6c757d;"></i>%s
|
||||
</label>
|
||||
</div>',
|
||||
esc_attr($id),
|
||||
$checked,
|
||||
esc_attr($id),
|
||||
esc_attr($icon),
|
||||
esc_html($label)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,19 @@ final class ContactFormFieldMapper implements FieldMapperInterface
|
||||
'contactFormEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'contactFormShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'contactFormShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'contactFormShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'contactFormVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'contactFormVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'contactFormVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'contactFormVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'contactFormVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'contactFormExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'contactFormExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'contactFormExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'contactFormExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'contactFormSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\ContactForm\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para Contact Form
|
||||
@@ -93,19 +94,47 @@ final class ContactFormFormBuilder
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
|
||||
$html .= $this->buildSwitch('contactFormShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
$html .= ' <label for="contactFormShowOnPages" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="contactFormShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
|
||||
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
|
||||
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
|
||||
$html .= ' </select>';
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('contactFormVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'contactForm');
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -598,4 +627,26 @@ final class ContactFormFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,20 @@ final class CtaBoxSidebarFieldMapper implements FieldMapperInterface
|
||||
'ctaEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'ctaShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'ctaShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'ctaShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
'ctaHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'ctaVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'ctaVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'ctaVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'ctaVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'ctaVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'ctaExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'ctaExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'ctaExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'ctaExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'ctaTitle' => ['group' => 'content', 'attribute' => 'title'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\CtaBoxSidebar\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para el CTA Box Sidebar
|
||||
@@ -94,18 +95,61 @@ final class CtaBoxSidebarFormBuilder
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
|
||||
$html .= $this->buildSwitch('ctaShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
// show_on_pages
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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>';
|
||||
|
||||
// Obtener valores de _page_visibility (grupo especial)
|
||||
$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);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
// Grid 3 columnas según Design System
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'cta');
|
||||
|
||||
// Switch: Ocultar para usuarios logueados (Plan 99.16)
|
||||
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
$html .= ' <label for="ctaShowOnPages" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="ctaShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
|
||||
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
|
||||
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= ' <input class="form-check-input" type="checkbox" id="ctaHideForLoggedIn" ';
|
||||
$html .= checked($hideForLoggedIn, true, false) . '>';
|
||||
$html .= ' <label class="form-check-label small" for="ctaHideForLoggedIn" style="color: #495057;">';
|
||||
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
|
||||
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
@@ -515,4 +559,29 @@ final class CtaBoxSidebarFormBuilder
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un checkbox de visibilidad por tipo de pagina
|
||||
*
|
||||
* Sigue Design System: form-check-checkbox es obligatorio
|
||||
*/
|
||||
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, bool $checked): string
|
||||
{
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,20 @@ final class CtaLetsTalkFieldMapper implements FieldMapperInterface
|
||||
'ctaLetsTalkEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'ctaLetsTalkShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'ctaLetsTalkShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'ctaLetsTalkShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
'ctaLetsTalkHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'ctaLetsTalkVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'ctaLetsTalkVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'ctaLetsTalkVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'ctaLetsTalkVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'ctaLetsTalkVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'letsTalkExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'letsTalkExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'letsTalkExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'letsTalkExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'ctaLetsTalkButtonText' => ['group' => 'content', 'attribute' => 'button_text'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\CtaLetsTalk\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* Class CtaLetsTalkFormBuilder
|
||||
@@ -120,16 +121,73 @@ final class CtaLetsTalkFormBuilder
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Select: Show on Pages
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaLetsTalkVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'letsTalk');
|
||||
|
||||
// Switch: CSS Crítico
|
||||
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
|
||||
$html .= ' <div class="mb-2 mt-3">';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkIsCritical" ';
|
||||
$html .= checked($isCritical, true, false) . '>';
|
||||
$html .= ' <label class="form-check-label small" for="ctaLetsTalkIsCritical" style="color: #495057;">';
|
||||
$html .= ' <i class="bi bi-lightning-charge me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' <strong>CSS Crítico</strong>';
|
||||
$html .= ' <small class="text-muted d-block">Inyectar CSS en <head> para optimizar LCP</small>';
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Switch: Ocultar para usuarios logueados (Plan 99.16)
|
||||
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <label for="ctaLetsTalkShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>';
|
||||
$html .= ' <select id="ctaLetsTalkShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
|
||||
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
|
||||
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
|
||||
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= ' <input class="form-check-input" type="checkbox" id="ctaLetsTalkHideForLoggedIn" ';
|
||||
$html .= checked($hideForLoggedIn, true, false) . '>';
|
||||
$html .= ' <label class="form-check-label small" for="ctaLetsTalkHideForLoggedIn" style="color: #495057;">';
|
||||
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
|
||||
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
@@ -447,4 +505,26 @@ final class CtaLetsTalkFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,20 @@ final class CtaPostFieldMapper implements FieldMapperInterface
|
||||
'ctaPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'ctaPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'ctaPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'ctaPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
'ctaPostHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'ctaPostVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'ctaPostVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'ctaPostVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'ctaPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'ctaPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'ctaPostExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'ctaPostExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'ctaPostExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'ctaPostExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'ctaPostTitle' => ['group' => 'content', 'attribute' => 'title'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\CtaPost\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para CTA Post
|
||||
@@ -85,17 +86,59 @@ final class CtaPostFormBuilder
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
|
||||
$html .= $this->buildSwitch('ctaPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('ctaPostVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'ctaPost');
|
||||
|
||||
// Switch: Ocultar para usuarios logueados (Plan 99.16)
|
||||
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
$html .= ' <label for="ctaPostShowOnPages" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="ctaPostShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
|
||||
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
|
||||
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
|
||||
$html .= ' </select>';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= ' <input class="form-check-input" type="checkbox" id="ctaPostHideForLoggedIn" ';
|
||||
$html .= checked($hideForLoggedIn, true, false) . '>';
|
||||
$html .= ' <label class="form-check-label small" for="ctaPostHideForLoggedIn" style="color: #495057;">';
|
||||
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
|
||||
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesion iniciada</small>';
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
@@ -437,4 +480,26 @@ final class CtaPostFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,19 @@ final class FeaturedImageFieldMapper implements FieldMapperInterface
|
||||
'featuredImageEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'featuredImageShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'featuredImageShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'featuredImageShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'featuredImageVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'featuredImageVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'featuredImageVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'featuredImageVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'featuredImageVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'featuredImageExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'featuredImageExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'featuredImageExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'featuredImageExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'featuredImageSize' => ['group' => 'content', 'attribute' => 'image_size'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\FeaturedImage\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
final class FeaturedImageFormBuilder
|
||||
{
|
||||
@@ -100,25 +101,75 @@ final class FeaturedImageFormBuilder
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
$html .= ' <label for="featuredImageShowOnPages" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="featuredImageShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>';
|
||||
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
|
||||
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
|
||||
$html .= ' </select>';
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', false);
|
||||
$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);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('featuredImageVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'featuredImage');
|
||||
|
||||
$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 buildContentGroup(string $componentId): string
|
||||
{
|
||||
$html = '<div class="card shadow-sm mb-3" style="border-left: 4px solid #1e3a5f;">';
|
||||
|
||||
@@ -27,6 +27,19 @@ final class FooterFieldMapper implements FieldMapperInterface
|
||||
'footerShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'footerShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'footerVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'footerVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'footerVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'footerVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'footerVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'footerExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'footerExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'footerExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'footerExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Widget 1
|
||||
'footerWidget1Visible' => ['group' => 'widget_1', 'attribute' => 'widget_1_visible'],
|
||||
'footerWidget1Title' => ['group' => 'widget_1', 'attribute' => 'widget_1_title'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\Footer\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para Footer
|
||||
@@ -90,6 +91,47 @@ final class FooterFormBuilder
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
|
||||
$html .= $this->buildSwitch('footerShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$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('footerVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('footerVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('footerVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'footer');
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -410,4 +452,19 @@ final class FooterFormBuilder
|
||||
}
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
private function buildPageVisibilityCheckbox(string $id, string $label, string $icon, $value): string
|
||||
{
|
||||
$checked = $value === true || $value === '1' || $value === 1 ? 'checked' : '';
|
||||
|
||||
$html = '<div class="form-check">';
|
||||
$html .= ' <input class="form-check-input" type="checkbox" id="' . esc_attr($id) . '" ' . $checked . '>';
|
||||
$html .= ' <label class="form-check-label small" for="' . esc_attr($id) . '">';
|
||||
$html .= ' <i class="bi ' . esc_attr($icon) . ' me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' ' . esc_html($label);
|
||||
$html .= ' </label>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,9 +26,21 @@ final class HeroFieldMapper implements FieldMapperInterface
|
||||
'heroEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'heroShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'heroShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'heroShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
'heroIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'heroVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'heroVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'heroVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'heroVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'heroVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'heroExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'heroExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'heroExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'heroExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'heroShowCategories' => ['group' => 'content', 'attribute' => 'show_categories'],
|
||||
'heroShowBadgeIcon' => ['group' => 'content', 'attribute' => 'show_badge_icon'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\Hero\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
final class HeroFormBuilder
|
||||
{
|
||||
@@ -102,20 +103,47 @@ final class HeroFormBuilder
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
|
||||
$html .= ' <div class="mb-2 mt-3">';
|
||||
$html .= ' <label for="heroShowOnPages" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="heroShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
|
||||
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
|
||||
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
|
||||
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
|
||||
$html .= ' </select>';
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', false);
|
||||
$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);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('heroVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('heroVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'hero');
|
||||
|
||||
// Switch: CSS Crítico
|
||||
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
@@ -427,4 +455,26 @@ final class HeroFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,15 @@ final class AdminAssetEnqueuer
|
||||
true
|
||||
);
|
||||
|
||||
// Script de toggle para exclusiones (Plan 99.11)
|
||||
wp_enqueue_script(
|
||||
'roi-exclusion-toggle',
|
||||
$this->themeUri . '/Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js',
|
||||
['roi-admin-dashboard'],
|
||||
filemtime(get_template_directory() . '/Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js'),
|
||||
true
|
||||
);
|
||||
|
||||
// Pasar variables al JavaScript
|
||||
wp_localize_script(
|
||||
'roi-admin-dashboard',
|
||||
|
||||
@@ -26,10 +26,22 @@ final class NavbarFieldMapper implements FieldMapperInterface
|
||||
'navbarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'navbarShowMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'navbarShowDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'navbarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
'navbarSticky' => ['group' => 'visibility', 'attribute' => 'sticky_enabled'],
|
||||
'navbarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'navbarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'navbarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'navbarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'navbarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'navbarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'navbarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'navbarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'navbarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'navbarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Layout
|
||||
'navbarContainerType' => ['group' => 'layout', 'attribute' => 'container_type'],
|
||||
'navbarPaddingVertical' => ['group' => 'layout', 'attribute' => 'padding_vertical'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\Navbar\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
final class NavbarFormBuilder
|
||||
{
|
||||
@@ -105,18 +106,47 @@ final class NavbarFormBuilder
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Select: Show on Pages
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
|
||||
$html .= ' <div class="mb-2">';
|
||||
$html .= ' <label for="navbarShowOnPages" class="form-label small mb-1 fw-semibold">Mostrar en</label>';
|
||||
$html .= ' <select id="navbarShowOnPages" name="visibility[show_on_pages]" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
|
||||
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
|
||||
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
|
||||
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
|
||||
$html .= ' </select>';
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$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('navbarVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('navbarVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'navbar');
|
||||
|
||||
// Switch: Sticky
|
||||
$sticky = $this->renderer->getFieldValue($componentId, 'visibility', 'sticky_enabled', true);
|
||||
$html .= ' <div class="mb-2">';
|
||||
@@ -527,4 +557,26 @@ final class NavbarFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,19 @@ final class RelatedPostFieldMapper implements FieldMapperInterface
|
||||
'relatedPostEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'relatedPostShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'relatedPostShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'relatedPostShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'relatedPostVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'relatedPostVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'relatedPostVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'relatedPostVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'relatedPostVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'relatedPostExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'relatedPostExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'relatedPostExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'relatedPostExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'relatedPostSectionTitle' => ['group' => 'content', 'attribute' => 'section_title'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\RelatedPost\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para Related Posts
|
||||
@@ -86,19 +87,47 @@ final class RelatedPostFormBuilder
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
|
||||
$html .= $this->buildSwitch('relatedPostShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
$html .= ' <label for="relatedPostShowOnPages" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="relatedPostShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
|
||||
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
|
||||
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
|
||||
$html .= ' </select>';
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('relatedPostVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'relatedPost');
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -498,4 +527,26 @@ final class RelatedPostFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace ROITheme\Admin\Shared\Infrastructure\Api\WordPress;
|
||||
|
||||
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
|
||||
use ROITheme\Admin\Shared\Infrastructure\FieldMapping\FieldMapperRegistry;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Services\ExclusionFieldProcessor;
|
||||
|
||||
/**
|
||||
* Handler para peticiones AJAX del panel de administracion
|
||||
@@ -73,10 +74,16 @@ final class AdminAjaxHandler
|
||||
|
||||
/**
|
||||
* Mapea settings de field IDs a grupos/atributos
|
||||
*
|
||||
* Soporta tipos especiales para campos de exclusion:
|
||||
* - json_array: Convierte "a, b, c" a ["a", "b", "c"]
|
||||
* - json_array_int: Convierte "1, 2, 3" a [1, 2, 3]
|
||||
* - json_array_lines: Convierte lineas a array
|
||||
*/
|
||||
private function mapSettings(array $settings, array $fieldMapping): array
|
||||
{
|
||||
$mappedSettings = [];
|
||||
$fieldProcessor = new ExclusionFieldProcessor();
|
||||
|
||||
foreach ($settings as $fieldId => $value) {
|
||||
if (!isset($fieldMapping[$fieldId])) {
|
||||
@@ -86,11 +93,17 @@ final class AdminAjaxHandler
|
||||
$mapping = $fieldMapping[$fieldId];
|
||||
$groupName = $mapping['group'];
|
||||
$attributeName = $mapping['attribute'];
|
||||
$type = $mapping['type'] ?? null;
|
||||
|
||||
if (!isset($mappedSettings[$groupName])) {
|
||||
$mappedSettings[$groupName] = [];
|
||||
}
|
||||
|
||||
// Procesar valor segun tipo
|
||||
if ($type !== null && is_string($value)) {
|
||||
$value = $fieldProcessor->process($value, $type);
|
||||
}
|
||||
|
||||
$mappedSettings[$groupName][$attributeName] = $value;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\Shared\Infrastructure\Services;
|
||||
|
||||
/**
|
||||
* Servicio para procesar campos de exclusion antes de guardar en BD
|
||||
*
|
||||
* Convierte formatos de UI a JSON para almacenamiento.
|
||||
*
|
||||
* v1.1: Extraido de AdminAjaxHandler (SRP)
|
||||
*
|
||||
* @package ROITheme\Admin\Shared\Infrastructure\Services
|
||||
*/
|
||||
final class ExclusionFieldProcessor
|
||||
{
|
||||
/**
|
||||
* Procesa un valor de campo de exclusion segun su tipo
|
||||
*
|
||||
* @param string $value Valor del campo (desde UI)
|
||||
* @param string $type Tipo de campo: json_array, json_array_int, json_array_lines
|
||||
* @return string JSON string para almacenar en BD
|
||||
*/
|
||||
public function process(string $value, string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'json_array' => $this->processJsonArray($value),
|
||||
'json_array_int' => $this->processJsonArrayInt($value),
|
||||
'json_array_lines' => $this->processJsonArrayLines($value),
|
||||
default => $value,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "a, b, c" -> ["a", "b", "c"]
|
||||
*/
|
||||
private function processJsonArray(string $value): string
|
||||
{
|
||||
$items = array_map('trim', explode(',', $value));
|
||||
$items = array_filter($items, fn($item) => $item !== '');
|
||||
return json_encode(array_values($items), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* "1, 2, 3" -> [1, 2, 3]
|
||||
*/
|
||||
private function processJsonArrayInt(string $value): string
|
||||
{
|
||||
$items = array_map('trim', explode(',', $value));
|
||||
$items = array_filter($items, 'is_numeric');
|
||||
$items = array_map('intval', $items);
|
||||
return json_encode(array_values($items));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lineas separadas -> array
|
||||
*/
|
||||
private function processJsonArrayLines(string $value): string
|
||||
{
|
||||
$items = preg_split('/\r\n|\r|\n/', $value);
|
||||
$items = array_map('trim', $items);
|
||||
$items = array_filter($items, fn($item) => $item !== '');
|
||||
return json_encode(array_values($items), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
31
Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js
Normal file
31
Admin/Shared/Infrastructure/Ui/Assets/Js/exclusion-toggle.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Toggle para mostrar/ocultar reglas de exclusion en FormBuilders
|
||||
*
|
||||
* Escucha cambios en checkboxes con ID que termine en "ExclusionsEnabled"
|
||||
* y muestra/oculta el contenedor de reglas correspondiente.
|
||||
*
|
||||
* @package ROITheme\Admin
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function initExclusionToggles() {
|
||||
document.querySelectorAll('[id$="ExclusionsEnabled"]').forEach(function(checkbox) {
|
||||
// Handler para cambios
|
||||
checkbox.addEventListener('change', function() {
|
||||
const prefix = this.id.replace('ExclusionsEnabled', '');
|
||||
const rulesContainer = document.getElementById(prefix + 'ExclusionRules');
|
||||
if (rulesContainer) {
|
||||
rulesContainer.style.display = this.checked ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Inicializar cuando DOM este listo
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initExclusionToggles);
|
||||
} else {
|
||||
initExclusionToggles();
|
||||
}
|
||||
})();
|
||||
260
Admin/Shared/Infrastructure/Ui/ExclusionFormPartial.php
Normal file
260
Admin/Shared/Infrastructure/Ui/ExclusionFormPartial.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Admin\Shared\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
|
||||
/**
|
||||
* Componente UI parcial reutilizable para reglas de exclusion
|
||||
*
|
||||
* Genera el HTML para la seccion de exclusiones en FormBuilders.
|
||||
* Debe ser incluido despues de la seccion de visibilidad por tipo de pagina.
|
||||
*
|
||||
* Uso en FormBuilder:
|
||||
* ```php
|
||||
* $exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
* $html .= $exclusionPartial->render($componentId, 'prefijo');
|
||||
* ```
|
||||
*
|
||||
* @package ROITheme\Admin\Shared\Infrastructure\Ui
|
||||
*/
|
||||
final class ExclusionFormPartial
|
||||
{
|
||||
private const GROUP_NAME = '_exclusions';
|
||||
|
||||
public function __construct(
|
||||
private readonly AdminDashboardRenderer $renderer
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Renderiza la seccion de exclusiones
|
||||
*
|
||||
* @param string $componentId ID del componente (kebab-case)
|
||||
* @param string $prefix Prefijo para IDs de campos (ej: 'cta' genera 'ctaExclusionsEnabled')
|
||||
* @return string HTML de la seccion
|
||||
*/
|
||||
public function render(string $componentId, string $prefix): string
|
||||
{
|
||||
$html = '';
|
||||
|
||||
$html .= $this->buildExclusionHeader();
|
||||
$html .= $this->buildExclusionToggle($componentId, $prefix);
|
||||
$html .= $this->buildExclusionRules($componentId, $prefix);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildExclusionHeader(): string
|
||||
{
|
||||
$html = '<hr class="my-3">';
|
||||
$html .= '<p class="small fw-semibold mb-2">';
|
||||
$html .= ' <i class="bi bi-funnel me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Reglas de exclusion avanzadas';
|
||||
$html .= '</p>';
|
||||
$html .= '<p class="small text-muted mb-2">';
|
||||
$html .= ' Excluir este componente de categorias, posts o URLs especificos.';
|
||||
$html .= '</p>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildExclusionToggle(string $componentId, string $prefix): string
|
||||
{
|
||||
$enabled = $this->renderer->getFieldValue(
|
||||
$componentId,
|
||||
self::GROUP_NAME,
|
||||
'exclusions_enabled',
|
||||
false
|
||||
);
|
||||
$checked = $this->toBool($enabled);
|
||||
|
||||
$id = $prefix . 'ExclusionsEnabled';
|
||||
|
||||
$html = '<div class="mb-3">';
|
||||
$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 .= ' <i class="bi bi-filter-circle me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' <strong>Activar reglas de exclusion</strong>';
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildExclusionRules(string $componentId, string $prefix): string
|
||||
{
|
||||
$enabled = $this->renderer->getFieldValue(
|
||||
$componentId,
|
||||
self::GROUP_NAME,
|
||||
'exclusions_enabled',
|
||||
false
|
||||
);
|
||||
$display = $this->toBool($enabled) ? 'block' : 'none';
|
||||
|
||||
$html = sprintf(
|
||||
'<div id="%sExclusionRules" style="display: %s;">',
|
||||
esc_attr($prefix),
|
||||
$display
|
||||
);
|
||||
|
||||
$html .= $this->buildCategoryField($componentId, $prefix);
|
||||
$html .= $this->buildPostIdsField($componentId, $prefix);
|
||||
$html .= $this->buildUrlPatternsField($componentId, $prefix);
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildCategoryField(string $componentId, string $prefix): string
|
||||
{
|
||||
$value = $this->renderer->getFieldValue(
|
||||
$componentId,
|
||||
self::GROUP_NAME,
|
||||
'exclude_categories',
|
||||
'[]'
|
||||
);
|
||||
$categories = $this->jsonToCommaList($value);
|
||||
|
||||
$id = $prefix . 'ExcludeCategories';
|
||||
|
||||
$html = '<div class="mb-3">';
|
||||
$html .= sprintf(
|
||||
' <label for="%s" class="form-label small mb-1 fw-semibold">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= ' <i class="bi bi-folder me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Excluir en categorias';
|
||||
$html .= ' </label>';
|
||||
$html .= sprintf(
|
||||
' <input type="text" id="%s" class="form-control form-control-sm" value="%s" placeholder="noticias, eventos, tutoriales">',
|
||||
esc_attr($id),
|
||||
esc_attr($categories)
|
||||
);
|
||||
$html .= ' <small class="text-muted">Slugs de categorias separados por comas</small>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildPostIdsField(string $componentId, string $prefix): string
|
||||
{
|
||||
$value = $this->renderer->getFieldValue(
|
||||
$componentId,
|
||||
self::GROUP_NAME,
|
||||
'exclude_post_ids',
|
||||
'[]'
|
||||
);
|
||||
$postIds = $this->jsonToCommaList($value);
|
||||
|
||||
$id = $prefix . 'ExcludePostIds';
|
||||
|
||||
$html = '<div class="mb-3">';
|
||||
$html .= sprintf(
|
||||
' <label for="%s" class="form-label small mb-1 fw-semibold">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= ' <i class="bi bi-hash me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Excluir en posts/paginas';
|
||||
$html .= ' </label>';
|
||||
$html .= sprintf(
|
||||
' <input type="text" id="%s" class="form-control form-control-sm" value="%s" placeholder="123, 456, 789">',
|
||||
esc_attr($id),
|
||||
esc_attr($postIds)
|
||||
);
|
||||
$html .= ' <small class="text-muted">IDs de posts o paginas separados por comas</small>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function buildUrlPatternsField(string $componentId, string $prefix): string
|
||||
{
|
||||
$value = $this->renderer->getFieldValue(
|
||||
$componentId,
|
||||
self::GROUP_NAME,
|
||||
'exclude_url_patterns',
|
||||
'[]'
|
||||
);
|
||||
$patterns = $this->jsonToLineList($value);
|
||||
|
||||
$id = $prefix . 'ExcludeUrlPatterns';
|
||||
|
||||
$html = '<div class="mb-0">';
|
||||
$html .= sprintf(
|
||||
' <label for="%s" class="form-label small mb-1 fw-semibold">',
|
||||
esc_attr($id)
|
||||
);
|
||||
$html .= ' <i class="bi bi-link-45deg me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Excluir por patrones URL';
|
||||
$html .= ' </label>';
|
||||
$html .= sprintf(
|
||||
' <textarea id="%s" class="form-control form-control-sm" rows="3" placeholder="/privado/ /landing-especial/ /^\/categoria\/\d+$/">%s</textarea>',
|
||||
esc_attr($id),
|
||||
esc_textarea($patterns)
|
||||
);
|
||||
$html .= ' <small class="text-muted">Un patron por linea. Soporta texto simple o regex (ej: /^\/blog\/\d+$/)</small>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte JSON array o array a lista separada por comas
|
||||
*
|
||||
* @param string|array $value Valor desde BD (puede ser JSON string o array ya deserializado)
|
||||
*/
|
||||
private function jsonToCommaList(string|array $value): string
|
||||
{
|
||||
// Si ya es array, usarlo directamente
|
||||
if (is_array($value)) {
|
||||
return empty($value) ? '' : implode(', ', $value);
|
||||
}
|
||||
|
||||
// Si es string, intentar decodificar JSON
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
if (!is_array($decoded) || empty($decoded)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode(', ', $decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte JSON array o array a lista separada por lineas
|
||||
*
|
||||
* @param string|array $value Valor desde BD (puede ser JSON string o array ya deserializado)
|
||||
*/
|
||||
private function jsonToLineList(string|array $value): string
|
||||
{
|
||||
// Si ya es array, usarlo directamente
|
||||
if (is_array($value)) {
|
||||
return empty($value) ? '' : implode("\n", $value);
|
||||
}
|
||||
|
||||
// Si es string, intentar decodificar JSON
|
||||
$decoded = json_decode($value, true);
|
||||
|
||||
if (!is_array($decoded) || empty($decoded)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return implode("\n", $decoded);
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,19 @@ final class SocialShareFieldMapper implements FieldMapperInterface
|
||||
'socialShareEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'socialShareShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'socialShareShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'socialShareShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'socialShareVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'socialShareVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'socialShareVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'socialShareVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'socialShareVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'socialShareExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'socialShareExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'socialShareExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'socialShareExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'socialShareShowLabel' => ['group' => 'content', 'attribute' => 'show_label'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\SocialShare\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para Social Share
|
||||
@@ -94,20 +95,47 @@ final class SocialShareFormBuilder
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', true);
|
||||
$html .= $this->buildSwitch('socialShareShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
// show_on_pages
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
$html .= ' <label for="socialShareShowOnPages" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="socialShareShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all"' . ($showOnPages === 'all' ? ' selected' : '') . '>Todos</option>';
|
||||
$html .= ' <option value="posts"' . ($showOnPages === 'posts' ? ' selected' : '') . '>Solo posts</option>';
|
||||
$html .= ' <option value="pages"' . ($showOnPages === 'pages' ? ' selected' : '') . '>Solo paginas</option>';
|
||||
$html .= ' </select>';
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('socialShareVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'socialShare');
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -526,4 +554,26 @@ final class SocialShareFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,19 @@ final class TableOfContentsFieldMapper implements FieldMapperInterface
|
||||
'tocEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'tocShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'tocShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'tocShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'tocVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'tocVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'tocVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'tocVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'tocVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'tocExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'tocExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'tocExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'tocExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'tocTitle' => ['group' => 'content', 'attribute' => 'title'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\TableOfContents\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
/**
|
||||
* FormBuilder para la Tabla de Contenido
|
||||
@@ -94,20 +95,47 @@ final class TableOfContentsFormBuilder
|
||||
$showOnMobile = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_mobile', false);
|
||||
$html .= $this->buildSwitch('tocShowOnMobile', 'Mostrar en movil', 'bi-phone', $showOnMobile);
|
||||
|
||||
// show_on_pages
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'posts');
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
$html .= ' <label for="tocShowOnPages" class="form-label small mb-1 fw-semibold">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="tocShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las paginas</option>';
|
||||
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts</option>';
|
||||
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo paginas</option>';
|
||||
$html .= ' </select>';
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', false);
|
||||
$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);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('tocVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('tocVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'toc');
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -585,4 +613,26 @@ final class TableOfContentsFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,21 @@ final class TopNotificationBarFieldMapper implements FieldMapperInterface
|
||||
'topBarEnabled' => ['group' => 'visibility', 'attribute' => 'is_enabled'],
|
||||
'topBarShowOnMobile' => ['group' => 'visibility', 'attribute' => 'show_on_mobile'],
|
||||
'topBarShowOnDesktop' => ['group' => 'visibility', 'attribute' => 'show_on_desktop'],
|
||||
'topBarShowOnPages' => ['group' => 'visibility', 'attribute' => 'show_on_pages'],
|
||||
'topBarIsCritical' => ['group' => 'visibility', 'attribute' => 'is_critical'],
|
||||
'topBarHideForLoggedIn' => ['group' => 'visibility', 'attribute' => 'hide_for_logged_in'],
|
||||
|
||||
// Page Visibility (grupo especial _page_visibility)
|
||||
'topBarVisibilityHome' => ['group' => '_page_visibility', 'attribute' => 'show_on_home'],
|
||||
'topBarVisibilityPosts' => ['group' => '_page_visibility', 'attribute' => 'show_on_posts'],
|
||||
'topBarVisibilityPages' => ['group' => '_page_visibility', 'attribute' => 'show_on_pages'],
|
||||
'topBarVisibilityArchives' => ['group' => '_page_visibility', 'attribute' => 'show_on_archives'],
|
||||
'topBarVisibilitySearch' => ['group' => '_page_visibility', 'attribute' => 'show_on_search'],
|
||||
|
||||
// Exclusions (grupo especial _exclusions - Plan 99.11)
|
||||
'topBarExclusionsEnabled' => ['group' => '_exclusions', 'attribute' => 'exclusions_enabled'],
|
||||
'topBarExcludeCategories' => ['group' => '_exclusions', 'attribute' => 'exclude_categories', 'type' => 'json_array'],
|
||||
'topBarExcludePostIds' => ['group' => '_exclusions', 'attribute' => 'exclude_post_ids', 'type' => 'json_array_int'],
|
||||
'topBarExcludeUrlPatterns' => ['group' => '_exclusions', 'attribute' => 'exclude_url_patterns', 'type' => 'json_array_lines'],
|
||||
|
||||
// Content
|
||||
'topBarIconClass' => ['group' => 'content', 'attribute' => 'icon_class'],
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Admin\TopNotificationBar\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Admin\Infrastructure\Ui\AdminDashboardRenderer;
|
||||
use ROITheme\Admin\Shared\Infrastructure\Ui\ExclusionFormPartial;
|
||||
|
||||
final class TopNotificationBarFormBuilder
|
||||
{
|
||||
@@ -105,24 +106,50 @@ final class TopNotificationBarFormBuilder
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Select: Show on Pages
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, 'visibility', 'show_on_pages', 'all');
|
||||
$html .= ' <div class="mb-2 mt-3">';
|
||||
$html .= ' <label for="topBarShowOnPages" class="form-label small mb-1 fw-semibold" style="color: #495057;">';
|
||||
$html .= ' <i class="bi bi-file-earmark-text me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' Mostrar en';
|
||||
$html .= ' </label>';
|
||||
$html .= ' <select id="topBarShowOnPages" class="form-select form-select-sm">';
|
||||
$html .= ' <option value="all" ' . selected($showOnPages, 'all', false) . '>Todas las páginas</option>';
|
||||
$html .= ' <option value="home" ' . selected($showOnPages, 'home', false) . '>Solo página de inicio</option>';
|
||||
$html .= ' <option value="posts" ' . selected($showOnPages, 'posts', false) . '>Solo posts individuales</option>';
|
||||
$html .= ' <option value="pages" ' . selected($showOnPages, 'pages', false) . '>Solo páginas</option>';
|
||||
$html .= ' </select>';
|
||||
// =============================================
|
||||
// Checkboxes de visibilidad por tipo de página
|
||||
// Grupo especial: _page_visibility
|
||||
// =============================================
|
||||
$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', true);
|
||||
$showOnPages = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_pages', true);
|
||||
$showOnArchives = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_archives', false);
|
||||
$showOnSearch = $this->renderer->getFieldValue($componentId, '_page_visibility', 'show_on_search', false);
|
||||
|
||||
$html .= ' <div class="row g-2">';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityHome', 'Home', 'bi-house', $showOnHome);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityPosts', 'Posts', 'bi-file-earmark-text', $showOnPosts);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityPages', 'Paginas', 'bi-file-earmark', $showOnPages);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilityArchives', 'Archivos', 'bi-archive', $showOnArchives);
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div class="col-md-4">';
|
||||
$html .= $this->buildPageVisibilityCheckbox('topBarVisibilitySearch', 'Busqueda', 'bi-search', $showOnSearch);
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// =============================================
|
||||
// Reglas de exclusion avanzadas
|
||||
// Grupo especial: _exclusions (Plan 99.11)
|
||||
// =============================================
|
||||
$exclusionPartial = new ExclusionFormPartial($this->renderer);
|
||||
$html .= $exclusionPartial->render($componentId, 'topBar');
|
||||
|
||||
// Switch: CSS Crítico
|
||||
$isCritical = $this->renderer->getFieldValue($componentId, 'visibility', 'is_critical', true);
|
||||
$html .= ' <div class="mb-0 mt-3">';
|
||||
$html .= ' <div class="mb-2 mt-3">';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= ' <input class="form-check-input" type="checkbox" id="topBarIsCritical" ';
|
||||
$html .= checked($isCritical, true, false) . '>';
|
||||
@@ -134,6 +161,20 @@ final class TopNotificationBarFormBuilder
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
// Switch: Ocultar para usuarios logueados (Plan 99.16)
|
||||
$hideForLoggedIn = $this->renderer->getFieldValue($componentId, 'visibility', 'hide_for_logged_in', false);
|
||||
$html .= ' <div class="mb-0">';
|
||||
$html .= ' <div class="form-check form-switch">';
|
||||
$html .= ' <input class="form-check-input" type="checkbox" id="topBarHideForLoggedIn" ';
|
||||
$html .= checked($hideForLoggedIn, true, false) . '>';
|
||||
$html .= ' <label class="form-check-label small" for="topBarHideForLoggedIn" style="color: #495057;">';
|
||||
$html .= ' <i class="bi bi-person-lock me-1" style="color: #FF8600;"></i>';
|
||||
$html .= ' <strong>Ocultar para usuarios logueados</strong>';
|
||||
$html .= ' <small class="text-muted d-block">No mostrar a usuarios con sesión iniciada</small>';
|
||||
$html .= ' </label>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' </div>';
|
||||
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
|
||||
@@ -319,4 +360,26 @@ final class TopNotificationBarFormBuilder
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* NO contiene CSS personalizado (ese va en critical-custom-temp.css - TIPO 3).
|
||||
*
|
||||
* Componentes Bootstrap incluidos:
|
||||
* - System Fonts (CERO flash - sin @font-face externos)
|
||||
* - Fonts (@font-face Poppins)
|
||||
* - Variables CSS (:root)
|
||||
* - Resets (box-sizing, body)
|
||||
* - Container system
|
||||
@@ -30,29 +30,45 @@
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
SYSTEM FONTS - CERO Flash (sin fuentes externas)
|
||||
CRITICAL FONTS (Poppins - LCP optimization)
|
||||
|
||||
Usa fuentes nativas del sistema operativo:
|
||||
- macOS/iOS: -apple-system, BlinkMacSystemFont
|
||||
- Windows: Segoe UI
|
||||
- Android: Roboto
|
||||
- Linux: Ubuntu/Cantarell
|
||||
- Fallback: sans-serif
|
||||
|
||||
VENTAJAS:
|
||||
- 0 KB descarga (fuentes ya instaladas)
|
||||
- 0 flash/parpadeo (disponibles instantaneamente)
|
||||
- Mejor rendimiento LCP/FCP
|
||||
- Familiar para usuarios (fuentes nativas)
|
||||
font-display: swap + preload = fuente carga rapido y siempre se muestra
|
||||
size-adjust: 100.6% = fallback casi identico a Poppins (minimiza CLS)
|
||||
========================================================================== */
|
||||
@font-face {
|
||||
font-family: 'Poppins Fallback';
|
||||
src: local('Arial'), local('Helvetica Neue'), local('sans-serif');
|
||||
size-adjust: 106%;
|
||||
ascent-override: 105%;
|
||||
descent-override: 35%;
|
||||
line-gap-override: 10%;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-600.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
src: url('/wp-content/themes/roi-theme/Assets/Fonts/poppins-v24-latin-700.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* System Font Stack - CERO flash garantizado */
|
||||
--font-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, "Noto Sans", "Liberation Sans",
|
||||
sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
--font-primary: var(--font-system);
|
||||
--bs-body-font-family: var(--font-system);
|
||||
/* Fonts */
|
||||
--font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
|
||||
--bs-body-font-family: 'Poppins', 'Poppins Fallback', sans-serif;
|
||||
|
||||
/* Theme Colors (críticos para above-the-fold) */
|
||||
--color-navy-dark: #0E2337;
|
||||
@@ -372,7 +388,7 @@ button:focus:not(:focus-visible) {
|
||||
--bs-navbar-toggler-border-radius: var(--bs-border-radius, 0.375rem);
|
||||
--bs-navbar-toggler-focus-width: 0.25rem;
|
||||
--bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out;
|
||||
position: relative;
|
||||
/* position: controlado por CriticalCSSService según sticky_enabled */
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
* Sistema de Tipografías - ROI Theme
|
||||
*
|
||||
* RESPONSABILIDAD: SOLO definición de fuentes y variables tipográficas
|
||||
* - Declaraciones @font-face (comentadas - usar Google Fonts)
|
||||
* - Variables CSS de tipografía (:root)
|
||||
* - Clases utilitarias de fuentes
|
||||
*
|
||||
* NOTA: Usando SYSTEM FONTS para CERO flash/parpadeo
|
||||
* Las fuentes del sistema están disponibles instantáneamente.
|
||||
*
|
||||
* NO debe contener:
|
||||
* - Estilos de body (van en style.css)
|
||||
* - Estilos de elementos HTML (van en style.css)
|
||||
@@ -18,20 +16,20 @@
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
SYSTEM FONTS - CERO Flash
|
||||
SYSTEM FONTS (Por defecto - Recomendado)
|
||||
============================================ */
|
||||
|
||||
:root {
|
||||
/* Stack de fuentes del sistema - disponibles instantáneamente */
|
||||
/* Stack de fuentes del sistema - Fallback */
|
||||
--font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans',
|
||||
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
|
||||
|
||||
/* Fuente primaria - System fonts (CERO flash) */
|
||||
--font-primary: var(--font-system);
|
||||
/* Fuente primaria - Poppins con fallback ajustado (Fase 4.3 PageSpeed)
|
||||
'Poppins Fallback' tiene size-adjust para reducir CLS durante font swap */
|
||||
--font-primary: 'Poppins', 'Poppins Fallback', sans-serif;
|
||||
|
||||
/* Fuente para encabezados - System fonts */
|
||||
--font-headings: var(--font-system);
|
||||
/* Fuente para encabezados - Poppins con fallback ajustado */
|
||||
--font-headings: 'Poppins', 'Poppins Fallback', sans-serif;
|
||||
|
||||
/* Fuente para código (monospace) */
|
||||
--font-mono: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono',
|
||||
@@ -48,22 +46,70 @@
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
POPPINS - DESHABILITADO
|
||||
POPPINS (Self-hosted)
|
||||
============================================
|
||||
|
||||
Las @font-face de Poppins fueron eliminadas para
|
||||
garantizar CERO flash/parpadeo en la carga de página.
|
||||
Fuentes Poppins alojadas localmente para:
|
||||
- Eliminar dependencia de Google Fonts
|
||||
- Mejorar rendimiento (sin requests externos)
|
||||
- Cumplimiento GDPR (sin tracking de Google)
|
||||
|
||||
El sitio ahora usa fuentes del sistema (--font-system)
|
||||
que están disponibles instantáneamente en todos los
|
||||
dispositivos sin necesidad de descarga.
|
||||
Pesos incluidos: 400, 500, 600, 700
|
||||
Formato: WOFF2 (mejor compresión)
|
||||
|
||||
Para reactivar Poppins en el futuro, descomentar las
|
||||
declaraciones @font-face y actualizar las variables
|
||||
--font-primary y --font-headings.
|
||||
Fase 4.3 PageSpeed: Fallback con size-adjust para reducir CLS
|
||||
- size-adjust: 100.6% ajustado para coincidir mejor con Poppins
|
||||
- font-display: swap + preload = carga rapida sin salto visual
|
||||
- Preload en CriticalCSSInjector P:-2 acelera descarga de fuentes
|
||||
|
||||
NOTA: El valor 100.6% fue calibrado empiricamente.
|
||||
- 106% causaba un salto visual notable (navbar se "achicaba")
|
||||
- 100.6% minimiza el CLS manteniendo legibilidad del fallback
|
||||
|
||||
============================================ */
|
||||
|
||||
/* Fallback font con metricas ajustadas para Poppins */
|
||||
@font-face {
|
||||
font-family: 'Poppins Fallback';
|
||||
src: local('Arial'), local('Helvetica Neue'), local('Helvetica'), local('sans-serif');
|
||||
size-adjust: 106%;
|
||||
ascent-override: 105%;
|
||||
descent-override: 35%;
|
||||
line-gap-override: 10%;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
src: url('../Fonts/poppins-v24-latin-regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
src: url('../Fonts/poppins-v24-latin-500.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
src: url('../Fonts/poppins-v24-latin-600.woff2') format('woff2');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
src: url('../Fonts/poppins-v24-latin-700.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
UTILIDADES DE FUENTES
|
||||
============================================ */
|
||||
|
||||
@@ -88,3 +88,43 @@
|
||||
.transition-none {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
COMPONENT VISIBILITY FAILSAFE (Plan 99.15)
|
||||
|
||||
CSS failsafe: Oculta wrappers de componentes
|
||||
cuando body tiene clases roi-hide-*
|
||||
|
||||
Estas clases se agregan via BodyClassHooksRegistrar
|
||||
cuando los componentes están deshabilitados/excluidos.
|
||||
======================================== */
|
||||
|
||||
/* Navbar hidden */
|
||||
body.roi-hide-navbar .navbar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Table of Contents hidden */
|
||||
body.roi-hide-toc .roi-toc-container {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* CTA Sidebar hidden */
|
||||
body.roi-hide-cta-sidebar .roi-cta-box {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Generic sidebar hidden */
|
||||
body.roi-hide-sidebar .sidebar-sticky {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* When ALL sidebar components are hidden, expand main column */
|
||||
body.roi-sidebar-empty .col-lg-9 {
|
||||
flex: 0 0 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
body.roi-sidebar-empty .col-lg-3 {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -182,10 +182,72 @@
|
||||
}, CONFIG.timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activa slots de AdSense insertados dinamicamente
|
||||
* Escucha el evento 'roi-adsense-activate' disparado por otros scripts
|
||||
*/
|
||||
function setupDynamicAdsListener() {
|
||||
window.addEventListener('roi-adsense-activate', function() {
|
||||
debugLog('Evento roi-adsense-activate recibido');
|
||||
|
||||
// Si AdSense aun no ha cargado, forzar carga ahora
|
||||
if (!adsenseLoaded) {
|
||||
debugLog('AdSense no cargado, forzando carga...');
|
||||
loadAdSense();
|
||||
return;
|
||||
}
|
||||
|
||||
// AdSense ya cargado - activar nuevos slots
|
||||
debugLog('Activando nuevos slots dinamicos...');
|
||||
activateDynamicSlots();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Activa slots de AdSense que fueron insertados despues de la carga inicial
|
||||
*/
|
||||
function activateDynamicSlots() {
|
||||
// Buscar scripts de push que aun no han sido ejecutados
|
||||
var pendingPushScripts = document.querySelectorAll('script[data-adsense-push][type="text/plain"]');
|
||||
|
||||
if (pendingPushScripts.length === 0) {
|
||||
debugLog('No hay slots pendientes por activar');
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Activando ' + pendingPushScripts.length + ' slot(s) dinamico(s)');
|
||||
|
||||
// Asegurar que adsbygoogle existe
|
||||
window.adsbygoogle = window.adsbygoogle || [];
|
||||
|
||||
pendingPushScripts.forEach(function(oldScript) {
|
||||
try {
|
||||
// Crear nuevo script ejecutable
|
||||
var newScript = document.createElement('script');
|
||||
newScript.type = 'text/javascript';
|
||||
newScript.innerHTML = oldScript.innerHTML;
|
||||
|
||||
// Reemplazar el placeholder con el script real
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
} catch (e) {
|
||||
debugLog('Error activando slot: ' + e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa el cargador retrasado de AdSense
|
||||
*/
|
||||
function init() {
|
||||
// =========================================================================
|
||||
// NUEVO: Siempre configurar listener para ads dinamicos
|
||||
// IMPORTANTE: Esto debe ejecutarse ANTES del early return
|
||||
// porque los ads dinamicos pueden necesitar activarse aunque
|
||||
// el delay global este deshabilitado
|
||||
// =========================================================================
|
||||
setupDynamicAdsListener();
|
||||
debugLog('Listener para ads dinamicos configurado');
|
||||
|
||||
// Verificar si el retardo de AdSense está habilitado
|
||||
if (!window.roiAdsenseDelayed) {
|
||||
debugLog('Retardo de AdSense no habilitado');
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,9 @@ if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
|
||||
|
||||
/**
|
||||
* Renderiza un slot de anuncio en una ubicacion
|
||||
*
|
||||
@@ -47,16 +50,21 @@ function roi_render_ad_slot(string $location): string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar si ocultar para usuarios logueados
|
||||
if (roi_should_hide_for_logged_in($settings)) {
|
||||
// Verificar visibilidad por usuario logueado (Plan 99.16)
|
||||
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar exclusiones
|
||||
// Verificar exclusiones legacy (forms group)
|
||||
if (roi_is_ad_excluded($settings)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
|
||||
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Obtener renderer desde DIContainer (DIP compliant)
|
||||
$renderer = $container->getAdsensePlacementRenderer();
|
||||
|
||||
@@ -72,17 +80,17 @@ function roi_render_ad_slot(string $location): string
|
||||
|
||||
/**
|
||||
* Verifica si se deben ocultar anuncios para usuarios logueados
|
||||
*
|
||||
* @deprecated Plan 99.16: Usar UserVisibilityHelper::shouldShowForUser() en su lugar.
|
||||
* Esta función se mantiene para compatibilidad hacia atrás.
|
||||
*
|
||||
* @param array $settings Configuración del componente
|
||||
* @return bool true si se debe ocultar, false si se debe mostrar
|
||||
*/
|
||||
function roi_should_hide_for_logged_in(array $settings): bool
|
||||
{
|
||||
// Si la opcion esta activada Y el usuario esta logueado, ocultar ads
|
||||
$hideForLoggedIn = $settings['visibility']['hide_for_logged_in'] ?? false;
|
||||
|
||||
if ($hideForLoggedIn && is_user_logged_in()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Delegar a UserVisibilityHelper (Plan 99.16)
|
||||
return !UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,16 +146,21 @@ function roi_render_rail_ads(): string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar si ocultar para usuarios logueados
|
||||
if (roi_should_hide_for_logged_in($settings)) {
|
||||
// Verificar visibilidad por usuario logueado (Plan 99.16)
|
||||
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar exclusiones
|
||||
// Verificar exclusiones legacy (forms group)
|
||||
if (roi_is_ad_excluded($settings)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
|
||||
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Obtener renderer desde DIContainer (DIP compliant)
|
||||
$renderer = $container->getAdsensePlacementRenderer();
|
||||
|
||||
@@ -188,8 +201,13 @@ function roi_enqueue_adsense_script(): void
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar si ocultar para usuarios logueados
|
||||
if (roi_should_hide_for_logged_in($settings)) {
|
||||
// Verificar visibilidad por usuario logueado (Plan 99.16)
|
||||
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
|
||||
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,16 +259,21 @@ function roi_inject_content_ads(string $content): string
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Verificar si ocultar para usuarios logueados
|
||||
if (roi_should_hide_for_logged_in($settings)) {
|
||||
// Verificar visibilidad por usuario logueado (Plan 99.16)
|
||||
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Verificar exclusiones
|
||||
// Verificar exclusiones legacy (forms group)
|
||||
if (roi_is_ad_excluded($settings)) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
|
||||
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
$renderer = $container->getAdsensePlacementRenderer();
|
||||
|
||||
// Inyectar anuncio al inicio (post-top)
|
||||
@@ -441,16 +464,21 @@ function roi_render_anchor_ads(): string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar si ocultar para usuarios logueados
|
||||
if (roi_should_hide_for_logged_in($settings)) {
|
||||
// Verificar visibilidad por usuario logueado (Plan 99.16)
|
||||
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar exclusiones
|
||||
// Verificar exclusiones legacy (forms group)
|
||||
if (roi_is_ad_excluded($settings)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
|
||||
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Obtener renderer desde DIContainer (DIP compliant)
|
||||
$renderer = $container->getAdsensePlacementRenderer();
|
||||
|
||||
@@ -485,16 +513,21 @@ function roi_render_vignette_ad(): string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar si ocultar para usuarios logueados
|
||||
if (roi_should_hide_for_logged_in($settings)) {
|
||||
// Verificar visibilidad por usuario logueado (Plan 99.16)
|
||||
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar exclusiones
|
||||
// Verificar exclusiones legacy (forms group)
|
||||
if (roi_is_ad_excluded($settings)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
|
||||
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Obtener renderer desde DIContainer (DIP compliant)
|
||||
$renderer = $container->getAdsensePlacementRenderer();
|
||||
|
||||
@@ -551,8 +584,13 @@ function roi_enqueue_anchor_vignette_scripts(): void
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar si ocultar para usuarios logueados
|
||||
if (roi_should_hide_for_logged_in($settings)) {
|
||||
// Verificar visibilidad por usuario logueado (Plan 99.16)
|
||||
if (!UserVisibilityHelper::shouldShowForUser($settings['visibility'] ?? [])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar exclusiones modernas (Plan 99.11: _exclusions, _page_visibility)
|
||||
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ define('ROI_DEFERRED_CSS', [
|
||||
'roi-utilities',
|
||||
'roi-accessibility',
|
||||
'roi-responsive',
|
||||
'bootstrap-icons',
|
||||
// NOTA: bootstrap-icons REMOVIDO de diferido - ahora crítico para evitar flash
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -125,19 +125,19 @@ function roi_enqueue_bootstrap() {
|
||||
'roi-bootstrap',
|
||||
get_template_directory_uri() . '/Assets/Vendor/Bootstrap/Css/bootstrap-subset.min.css',
|
||||
array('roi-fonts'),
|
||||
'5.3.2-subset',
|
||||
'5.3.2-subset-2', // v2: removed position:relative from .navbar
|
||||
'print' // DIFERIDO - critical CSS inline evita CLS
|
||||
);
|
||||
|
||||
// Bootstrap Icons CSS - SUBSET OPTIMIZADO (Fase 4.1 PageSpeed)
|
||||
// Original: 211 KB (2050 iconos) -> Subset: 13 KB (104 iconos) = 94% reduccion
|
||||
// DIFERIDO: Fase 4.3 - no crítico para renderizado inicial
|
||||
// CRITICO: Carga inmediata para evitar flash de iconos (4.4KB)
|
||||
wp_enqueue_style(
|
||||
'bootstrap-icons',
|
||||
get_template_directory_uri() . '/Assets/Vendor/bootstrap-icons-subset.min.css',
|
||||
array('roi-bootstrap'),
|
||||
ROI_VERSION,
|
||||
'print'
|
||||
'all' // CRITICO - no diferir para evitar parpadeo de iconos
|
||||
);
|
||||
|
||||
// Variables CSS del Template RDash - DIFERIDO
|
||||
|
||||
@@ -50,6 +50,13 @@ function roi_get_featured_image($post_id = null, $size = 'roi-featured-large', $
|
||||
return ''; // No placeholder - retornar vacío
|
||||
}
|
||||
|
||||
// Verificar que el archivo físico exista, no solo el attachment ID
|
||||
$thumbnailId = get_post_thumbnail_id($post_id);
|
||||
$filePath = get_attached_file($thumbnailId);
|
||||
if (empty($filePath) || !file_exists($filePath)) {
|
||||
return ''; // Archivo no existe en servidor
|
||||
}
|
||||
|
||||
// Obtener tipo de post
|
||||
$post_type = get_post_type($post_id);
|
||||
|
||||
@@ -145,6 +152,13 @@ function roi_get_post_thumbnail($post_id = null, $with_link = true) {
|
||||
return ''; // No placeholder - retornar vacío
|
||||
}
|
||||
|
||||
// Verificar que el archivo físico exista
|
||||
$thumbnailId = get_post_thumbnail_id($post_id);
|
||||
$filePath = get_attached_file($thumbnailId);
|
||||
if (empty($filePath) || !file_exists($filePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Obtener la imagen con clases Bootstrap
|
||||
$image = get_the_post_thumbnail($post_id, 'roi-featured-medium', array(
|
||||
'class' => 'img-fluid post-thumbnail',
|
||||
@@ -216,6 +230,13 @@ function roi_get_post_thumbnail_small($post_id = null, $with_link = true) {
|
||||
return ''; // No placeholder - retornar vacío
|
||||
}
|
||||
|
||||
// Verificar que el archivo físico exista
|
||||
$thumbnailId = get_post_thumbnail_id($post_id);
|
||||
$filePath = get_attached_file($thumbnailId);
|
||||
if (empty($filePath) || !file_exists($filePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Obtener la imagen
|
||||
$image = get_the_post_thumbnail($post_id, 'roi-thumbnail', array(
|
||||
'class' => 'img-fluid post-thumbnail-small',
|
||||
@@ -287,6 +308,13 @@ function roi_should_show_featured_image($post_id = null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar que el archivo físico exista
|
||||
$thumbnailId = get_post_thumbnail_id($post_id);
|
||||
$filePath = get_attached_file($thumbnailId);
|
||||
if (empty($filePath) || !file_exists($filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Obtener tipo de post
|
||||
$post_type = get_post_type($post_id);
|
||||
|
||||
@@ -338,6 +366,13 @@ function roi_get_featured_image_url($post_id = null, $size = 'roi-featured-large
|
||||
return ''; // No placeholder - retornar vacío
|
||||
}
|
||||
|
||||
// Verificar que el archivo físico exista
|
||||
$thumbnailId = get_post_thumbnail_id($post_id);
|
||||
$filePath = get_attached_file($thumbnailId);
|
||||
if (empty($filePath) || !file_exists($filePath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Obtener URL de la imagen
|
||||
$image_url = get_the_post_thumbnail_url($post_id, $size);
|
||||
|
||||
|
||||
@@ -552,30 +552,22 @@ add_filter( 'wp_lazy_loading_enabled', 'roi_enable_image_dimensions' );
|
||||
/**
|
||||
* Optimizar buffer de salida HTML
|
||||
*
|
||||
* Habilita compresión GZIP si está disponible y no está ya habilitada.
|
||||
* DESACTIVADO: Esta función causa conflicto con W3 Total Cache.
|
||||
* Cuando zlib.output_compression está activo, W3TC no puede cachear
|
||||
* las páginas porque recibe "Response is compressed".
|
||||
*
|
||||
* La compresión GZIP la maneja nginx a nivel de servidor.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @deprecated 1.0.1 Conflicto con W3TC page cache - Issue #XX
|
||||
*/
|
||||
function roi_enable_gzip_compression() {
|
||||
// Solo en frontend
|
||||
if ( is_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar si GZIP ya está habilitado
|
||||
if ( ! ini_get( 'zlib.output_compression' ) && 'ob_gzhandler' !== ini_get( 'output_handler' ) ) {
|
||||
// Verificar si la extensión está disponible
|
||||
if ( function_exists( 'gzencode' ) && extension_loaded( 'zlib' ) ) {
|
||||
// Verificar headers
|
||||
if ( ! headers_sent() ) {
|
||||
// Habilitar compresión
|
||||
ini_set( 'zlib.output_compression', 'On' );
|
||||
ini_set( 'zlib.output_compression_level', '6' ); // Balance entre compresión y CPU
|
||||
}
|
||||
}
|
||||
}
|
||||
// DESACTIVADO - No hacer nada
|
||||
// La compresión la maneja nginx, no PHP
|
||||
return;
|
||||
}
|
||||
add_action( 'template_redirect', 'roi_enable_gzip_compression', 0 );
|
||||
// DESACTIVADO - Conflicto con W3 Total Cache page cache
|
||||
// add_action( 'template_redirect', 'roi_enable_gzip_compression', 0 );
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
namespace ROITheme\Public\AdsensePlacement\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
|
||||
/**
|
||||
* Renderer para slots de AdSense
|
||||
@@ -36,6 +37,11 @@ final class AdsensePlacementRenderer
|
||||
*/
|
||||
public function renderSlot(array $settings, string $location): string
|
||||
{
|
||||
// 0. Verificar visibilidad por tipo de página y exclusiones (Plan 99.10/99.11)
|
||||
if (!PageVisibilityHelper::shouldShow('adsense-placement')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 1. Validar is_enabled
|
||||
if (!($settings['visibility']['is_enabled'] ?? false)) {
|
||||
return '';
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ROITheme\Public\ContactForm\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;
|
||||
|
||||
/**
|
||||
* ContactFormRenderer - Renderiza formulario de contacto con webhook
|
||||
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class ContactFormRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'contact-form';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
@@ -34,7 +37,7 @@ final class ContactFormRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -67,7 +70,7 @@ final class ContactFormRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'contact-form';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
@@ -76,22 +79,6 @@ final class ContactFormRenderer implements RendererInterface
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function getVisibilityClass(array $data): ?string
|
||||
{
|
||||
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
|
||||
|
||||
@@ -9,11 +9,10 @@ use ROITheme\Public\CriticalCSS\Domain\Contracts\CriticalCSSCacheInterface;
|
||||
* Inyecta CSS critico en wp_head
|
||||
*
|
||||
* Prioridades:
|
||||
* - P:-2 Font preload (antes de variables)
|
||||
* - P:-1 Variables CSS (antes de Bootstrap)
|
||||
* - P:2 Responsive critico (despues de Bootstrap critico)
|
||||
*
|
||||
* NOTA: Font preload deshabilitado - usando system fonts para CERO flash
|
||||
*
|
||||
* @package ROITheme\Public\CriticalCSS\Infrastructure\Services
|
||||
*/
|
||||
final class CriticalCSSInjector
|
||||
@@ -22,11 +21,23 @@ final class CriticalCSSInjector
|
||||
private readonly CriticalCSSCacheInterface $cache
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fuentes criticas para preload (pesos usados en navbar above-the-fold)
|
||||
*/
|
||||
private const CRITICAL_FONTS = [
|
||||
'/Assets/Fonts/poppins-v24-latin-regular.woff2', // 400 - body text
|
||||
'/Assets/Fonts/poppins-v24-latin-600.woff2', // 600 - navbar brand
|
||||
'/Assets/Fonts/poppins-v24-latin-700.woff2', // 700 - headings
|
||||
];
|
||||
|
||||
/**
|
||||
* Registra hooks de WordPress
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Font preload: P:-2 (antes de todo, incluso variables)
|
||||
add_action('wp_head', [$this, 'preloadFonts'], -2);
|
||||
|
||||
// Variables CSS: P:-1 (antes de CriticalBootstrapService P:0)
|
||||
add_action('wp_head', [$this, 'injectVariables'], -1);
|
||||
|
||||
@@ -38,6 +49,25 @@ final class CriticalCSSInjector
|
||||
add_action('wp_enqueue_scripts', [$this, 'dequeueInlinedCSS'], 999);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inyecta preload links para fuentes criticas
|
||||
*
|
||||
* Resuelve el problema de "font swap" donde el fallback (106% size-adjust)
|
||||
* causa un salto visual cuando Poppins se carga.
|
||||
* Con preload, las fuentes llegan antes del primer paint.
|
||||
*/
|
||||
public function preloadFonts(): void
|
||||
{
|
||||
echo "<!-- TIPO 4: Font preload para evitar CLS -->\n";
|
||||
|
||||
foreach (self::CRITICAL_FONTS as $font) {
|
||||
printf(
|
||||
'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "\n",
|
||||
esc_url(get_template_directory_uri() . $font)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inyecta variables CSS criticas
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace ROITheme\Public\CtaBoxSidebar\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;
|
||||
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
|
||||
|
||||
/**
|
||||
* CtaBoxSidebarRenderer - Renderiza caja CTA en sidebar
|
||||
@@ -27,6 +29,12 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class CtaBoxSidebarRenderer implements RendererInterface
|
||||
{
|
||||
/**
|
||||
* Nombre del componente para visibilidad
|
||||
* Evita strings hardcodeados y facilita mantenimiento
|
||||
*/
|
||||
private const COMPONENT_NAME = 'cta-box-sidebar';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
@@ -39,7 +47,13 @@ final class CtaBoxSidebarRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
// Evaluar visibilidad por tipo de página (usa Helper, NO cambia constructor)
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Validar visibilidad por usuario logueado
|
||||
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -52,7 +66,7 @@ final class CtaBoxSidebarRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'cta-box-sidebar';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
@@ -60,22 +74,6 @@ final class CtaBoxSidebarRenderer implements RendererInterface
|
||||
return ($data['visibility']['is_enabled'] ?? false) === true;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCSS(array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace ROITheme\Public\CtaLetsTalk\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;
|
||||
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
|
||||
|
||||
/**
|
||||
* Class CtaLetsTalkRenderer
|
||||
@@ -34,6 +36,8 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class CtaLetsTalkRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'cta-lets-talk';
|
||||
|
||||
/**
|
||||
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
|
||||
*/
|
||||
@@ -54,7 +58,12 @@ final class CtaLetsTalkRenderer implements RendererInterface
|
||||
}
|
||||
|
||||
// Validar visibilidad por página
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Validar visibilidad por usuario logueado
|
||||
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -77,7 +86,7 @@ final class CtaLetsTalkRenderer implements RendererInterface
|
||||
*/
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'cta-lets-talk';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,25 +100,6 @@ final class CtaLetsTalkRenderer implements RendererInterface
|
||||
return ($data['visibility']['is_enabled'] ?? false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si debe mostrarse en la página actual
|
||||
*
|
||||
* @param array $data Datos del componente
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
|
||||
|
||||
return match ($showOn) {
|
||||
'all' => true,
|
||||
'home' => is_front_page(),
|
||||
'posts' => is_single(),
|
||||
'pages' => is_page(),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular clases de visibilidad responsive
|
||||
*
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace ROITheme\Public\CtaPost\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;
|
||||
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
|
||||
|
||||
/**
|
||||
* CtaPostRenderer - Renderiza CTA promocional debajo del contenido
|
||||
@@ -22,6 +24,8 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class CtaPostRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'cta-post';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
@@ -34,7 +38,12 @@ final class CtaPostRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Validar visibilidad por usuario logueado
|
||||
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -46,7 +55,7 @@ final class CtaPostRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'cta-post';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
@@ -55,22 +64,6 @@ final class CtaPostRenderer implements RendererInterface
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCSS(array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ROITheme\Public\FeaturedImage\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;
|
||||
|
||||
/**
|
||||
* FeaturedImageRenderer - Renderiza la imagen destacada del post
|
||||
@@ -27,6 +28,8 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class FeaturedImageRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'featured-image';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
@@ -39,7 +42,7 @@ final class FeaturedImageRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ final class FeaturedImageRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'featured-image';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
@@ -71,25 +74,24 @@ final class FeaturedImageRenderer implements RendererInterface
|
||||
return ($data['visibility']['is_enabled'] ?? false) === true;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function hasPostThumbnail(): bool
|
||||
{
|
||||
return is_singular() && has_post_thumbnail();
|
||||
if (!is_singular() || !has_post_thumbnail()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar que el archivo físico exista, no solo el attachment ID
|
||||
$thumbnailId = get_post_thumbnail_id();
|
||||
if (!$thumbnailId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$filePath = get_attached_file($thumbnailId);
|
||||
if (empty($filePath) || !file_exists($filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ROITheme\Public\Footer\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;
|
||||
|
||||
/**
|
||||
* FooterRenderer - Renderiza el footer del sitio
|
||||
@@ -34,9 +35,14 @@ final class FooterRenderer implements RendererInterface
|
||||
|
||||
public function render(Component $component): string
|
||||
{
|
||||
// Verificar visibilidad por tipo de página y exclusiones (Plan 99.10/99.11)
|
||||
if (!PageVisibilityHelper::shouldShow('footer')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$data = $component->getData();
|
||||
|
||||
// Validar visibilidad
|
||||
// Validar visibilidad básica
|
||||
$visibility = $data['visibility'] ?? [];
|
||||
if (!($visibility['is_enabled'] ?? true)) {
|
||||
return '';
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ROITheme\Public\Hero\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;
|
||||
|
||||
/**
|
||||
* Class HeroRenderer
|
||||
@@ -33,6 +34,8 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class HeroRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'hero';
|
||||
|
||||
/**
|
||||
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
|
||||
*/
|
||||
@@ -48,7 +51,7 @@ final class HeroRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -68,7 +71,7 @@ final class HeroRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'hero';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
@@ -76,24 +79,6 @@ final class HeroRenderer implements RendererInterface
|
||||
return ($data['visibility']['is_enabled'] ?? false) === true;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'home':
|
||||
return is_front_page() || is_home();
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generar CSS usando CSSGeneratorService
|
||||
*
|
||||
@@ -148,6 +133,9 @@ final class HeroRenderer implements RendererInterface
|
||||
'padding' => "{$paddingVertical} 0",
|
||||
'margin-bottom' => $marginBottom,
|
||||
'min-height' => $minHeight,
|
||||
'display' => 'flex',
|
||||
'align-items' => 'center',
|
||||
'justify-content' => 'center',
|
||||
]);
|
||||
|
||||
$cssRules[] = $this->cssGenerator->generate('.hero-section__title', [
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Public\HeroSection\Infrastructure\Ui;
|
||||
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use ROITheme\Shared\Domain\Contracts\RendererInterface;
|
||||
|
||||
/**
|
||||
* HeroSectionRenderer - Renderiza la sección hero con badges y título
|
||||
*
|
||||
* RESPONSABILIDAD: Generar HTML de la sección hero
|
||||
*
|
||||
* CARACTERÍSTICAS:
|
||||
* - Badges de categorías con múltiples fuentes de datos
|
||||
* - Título H1 con gradiente opcional
|
||||
* - Múltiples tipos de fondo (color, gradiente, imagen)
|
||||
* - Lógica condicional de visibilidad por tipo de página
|
||||
*
|
||||
* @package ROITheme\Public\HeroSection\Presentation
|
||||
*/
|
||||
final class HeroSectionRenderer implements RendererInterface
|
||||
{
|
||||
public function render(Component $component): string
|
||||
{
|
||||
$data = $component->getData();
|
||||
|
||||
if (!$this->isEnabled($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$classes = $this->buildSectionClasses($data);
|
||||
$styles = $this->buildInlineStyles($data);
|
||||
|
||||
$html = sprintf(
|
||||
'<div class="%s"%s>',
|
||||
esc_attr($classes),
|
||||
$styles ? ' style="' . esc_attr($styles) . '"' : ''
|
||||
);
|
||||
|
||||
$html .= '<div class="container">';
|
||||
|
||||
// Categories badges
|
||||
if ($this->shouldShowCategories($data)) {
|
||||
$html .= $this->buildCategoriesBadges($data);
|
||||
}
|
||||
|
||||
// Title
|
||||
$html .= $this->buildTitle($data);
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
// Custom styles
|
||||
$html .= $this->buildCustomStyles($data);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
{
|
||||
return isset($data['visibility']['is_enabled']) &&
|
||||
$data['visibility']['is_enabled'] === true;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
|
||||
case 'home':
|
||||
return is_front_page();
|
||||
|
||||
case 'posts':
|
||||
return is_single() && get_post_type() === 'post';
|
||||
|
||||
case 'pages':
|
||||
return is_page();
|
||||
|
||||
case 'custom':
|
||||
$postTypes = $data['visibility']['custom_post_types'] ?? '';
|
||||
$allowedTypes = array_map('trim', explode(',', $postTypes));
|
||||
return in_array(get_post_type(), $allowedTypes, true);
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldShowCategories(array $data): bool
|
||||
{
|
||||
return isset($data['categories']['show_categories']) &&
|
||||
$data['categories']['show_categories'] === true;
|
||||
}
|
||||
|
||||
private function buildSectionClasses(array $data): string
|
||||
{
|
||||
$classes = ['container-fluid', 'hero-title'];
|
||||
|
||||
$paddingClass = $this->getPaddingClass($data['styles']['padding_vertical'] ?? 'normal');
|
||||
$classes[] = $paddingClass;
|
||||
|
||||
$marginClass = $this->getMarginClass($data['styles']['margin_bottom'] ?? 'normal');
|
||||
if ($marginClass) {
|
||||
$classes[] = $marginClass;
|
||||
}
|
||||
|
||||
return implode(' ', $classes);
|
||||
}
|
||||
|
||||
private function getPaddingClass(string $padding): string
|
||||
{
|
||||
$paddings = [
|
||||
'compact' => 'py-3',
|
||||
'normal' => 'py-5',
|
||||
'spacious' => 'py-6',
|
||||
'extra-spacious' => 'py-7'
|
||||
];
|
||||
|
||||
return $paddings[$padding] ?? 'py-5';
|
||||
}
|
||||
|
||||
private function getMarginClass(string $margin): string
|
||||
{
|
||||
$margins = [
|
||||
'none' => '',
|
||||
'small' => 'mb-2',
|
||||
'normal' => 'mb-4',
|
||||
'large' => 'mb-5'
|
||||
];
|
||||
|
||||
return $margins[$margin] ?? 'mb-4';
|
||||
}
|
||||
|
||||
private function buildInlineStyles(array $data): string
|
||||
{
|
||||
$styles = [];
|
||||
$backgroundType = $data['styles']['background_type'] ?? 'gradient';
|
||||
|
||||
switch ($backgroundType) {
|
||||
case 'color':
|
||||
$bgColor = $data['styles']['background_color'] ?? '#1e3a5f';
|
||||
$styles[] = "background-color: {$bgColor}";
|
||||
break;
|
||||
|
||||
case 'gradient':
|
||||
$startColor = $data['styles']['gradient_start_color'] ?? '#1e3a5f';
|
||||
$endColor = $data['styles']['gradient_end_color'] ?? '#2c5282';
|
||||
$angle = $data['styles']['gradient_angle'] ?? 135;
|
||||
$styles[] = "background: linear-gradient({$angle}deg, {$startColor}, {$endColor})";
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
$imageUrl = $data['styles']['background_image_url'] ?? '';
|
||||
if (!empty($imageUrl)) {
|
||||
$styles[] = "background-image: url('" . esc_url($imageUrl) . "')";
|
||||
$styles[] = "background-size: cover";
|
||||
$styles[] = "background-position: center";
|
||||
$styles[] = "background-repeat: no-repeat";
|
||||
|
||||
if (isset($data['styles']['background_overlay']) && $data['styles']['background_overlay']) {
|
||||
$opacity = ($data['styles']['overlay_opacity'] ?? 60) / 100;
|
||||
$styles[] = "position: relative";
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Text color
|
||||
if (!empty($data['styles']['text_color'])) {
|
||||
$styles[] = 'color: ' . $data['styles']['text_color'];
|
||||
}
|
||||
|
||||
return implode('; ', $styles);
|
||||
}
|
||||
|
||||
private function buildCategoriesBadges(array $data): string
|
||||
{
|
||||
$categories = $this->getCategories($data);
|
||||
|
||||
if (empty($categories)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$maxCategories = $data['categories']['max_categories'] ?? 5;
|
||||
$categories = array_slice($categories, 0, $maxCategories);
|
||||
|
||||
$alignment = $data['categories']['categories_alignment'] ?? 'center';
|
||||
$alignmentClasses = [
|
||||
'left' => 'justify-content-start',
|
||||
'center' => 'justify-content-center',
|
||||
'right' => 'justify-content-end'
|
||||
];
|
||||
$alignmentClass = $alignmentClasses[$alignment] ?? 'justify-content-center';
|
||||
|
||||
$icon = $data['categories']['category_icon'] ?? 'bi-folder-fill';
|
||||
if (strpos($icon, 'bi-') !== 0) {
|
||||
$icon = 'bi-' . $icon;
|
||||
}
|
||||
|
||||
$html = sprintf('<div class="mb-3 d-flex %s">', esc_attr($alignmentClass));
|
||||
$html .= '<div class="d-flex gap-2 flex-wrap justify-content-center">';
|
||||
|
||||
foreach ($categories as $category) {
|
||||
$html .= sprintf(
|
||||
'<a href="%s" class="category-badge category-badge-hero"><i class="bi %s me-1"></i>%s</a>',
|
||||
esc_url($category['url']),
|
||||
esc_attr($icon),
|
||||
esc_html($category['name'])
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function getCategories(array $data): array
|
||||
{
|
||||
$source = $data['categories']['categories_source'] ?? 'post_categories';
|
||||
|
||||
switch ($source) {
|
||||
case 'post_categories':
|
||||
return $this->getPostCategories();
|
||||
|
||||
case 'post_tags':
|
||||
return $this->getPostTags();
|
||||
|
||||
case 'custom_taxonomy':
|
||||
$taxonomy = $data['categories']['custom_taxonomy_name'] ?? '';
|
||||
return $this->getCustomTaxonomyTerms($taxonomy);
|
||||
|
||||
case 'custom_list':
|
||||
$list = $data['categories']['custom_categories_list'] ?? '';
|
||||
return $this->parseCustomCategoriesList($list);
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function getPostCategories(): array
|
||||
{
|
||||
$categories = get_the_category();
|
||||
if (empty($categories)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($categories as $category) {
|
||||
$result[] = [
|
||||
'name' => $category->name,
|
||||
'url' => get_category_link($category->term_id)
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getPostTags(): array
|
||||
{
|
||||
$tags = get_the_tags();
|
||||
if (empty($tags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($tags as $tag) {
|
||||
$result[] = [
|
||||
'name' => $tag->name,
|
||||
'url' => get_tag_link($tag->term_id)
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function getCustomTaxonomyTerms(string $taxonomy): array
|
||||
{
|
||||
if (empty($taxonomy)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$terms = get_the_terms(get_the_ID(), $taxonomy);
|
||||
if (empty($terms) || is_wp_error($terms)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($terms as $term) {
|
||||
$result[] = [
|
||||
'name' => $term->name,
|
||||
'url' => get_term_link($term)
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function parseCustomCategoriesList(string $list): array
|
||||
{
|
||||
if (empty($list)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = explode("\n", $list);
|
||||
$result = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode('|', $line);
|
||||
if (count($parts) >= 2) {
|
||||
$result[] = [
|
||||
'name' => trim($parts[0]),
|
||||
'url' => trim($parts[1])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function buildTitle(array $data): string
|
||||
{
|
||||
$titleText = $this->getTitleText($data);
|
||||
|
||||
if (empty($titleText)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$titleTag = $data['title']['title_tag'] ?? 'h1';
|
||||
$titleClasses = $data['title']['title_classes'] ?? 'display-5 fw-bold';
|
||||
$alignment = $data['title']['title_alignment'] ?? 'center';
|
||||
|
||||
$alignmentClasses = [
|
||||
'left' => 'text-start',
|
||||
'center' => 'text-center',
|
||||
'right' => 'text-end'
|
||||
];
|
||||
$alignmentClass = $alignmentClasses[$alignment] ?? 'text-center';
|
||||
|
||||
$classes = trim($titleClasses . ' ' . $alignmentClass);
|
||||
|
||||
$titleStyle = '';
|
||||
if (isset($data['title']['enable_gradient']) && $data['title']['enable_gradient']) {
|
||||
$titleStyle = $this->buildGradientStyle($data);
|
||||
$classes .= ' roi-gradient-text';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'<%s class="%s"%s>%s</%s>',
|
||||
esc_attr($titleTag),
|
||||
esc_attr($classes),
|
||||
$titleStyle ? ' style="' . esc_attr($titleStyle) . '"' : '',
|
||||
esc_html($titleText),
|
||||
esc_attr($titleTag)
|
||||
);
|
||||
}
|
||||
|
||||
private function getTitleText(array $data): string
|
||||
{
|
||||
$source = $data['title']['title_source'] ?? 'post_title';
|
||||
|
||||
switch ($source) {
|
||||
case 'post_title':
|
||||
return get_the_title();
|
||||
|
||||
case 'custom_field':
|
||||
$fieldName = $data['title']['custom_field_name'] ?? '';
|
||||
if (!empty($fieldName)) {
|
||||
$value = get_post_meta(get_the_ID(), $fieldName, true);
|
||||
return is_string($value) ? $value : '';
|
||||
}
|
||||
return '';
|
||||
|
||||
case 'custom_text':
|
||||
return $data['title']['custom_text'] ?? '';
|
||||
|
||||
default:
|
||||
return get_the_title();
|
||||
}
|
||||
}
|
||||
|
||||
private function buildGradientStyle(array $data): string
|
||||
{
|
||||
$startColor = $data['title']['gradient_color_start'] ?? '#1e3a5f';
|
||||
$endColor = $data['title']['gradient_color_end'] ?? '#FF8600';
|
||||
$direction = $data['title']['gradient_direction'] ?? 'to-right';
|
||||
|
||||
$directions = [
|
||||
'to-right' => 'to right',
|
||||
'to-left' => 'to left',
|
||||
'to-bottom' => 'to bottom',
|
||||
'to-top' => 'to top',
|
||||
'diagonal' => '135deg'
|
||||
];
|
||||
|
||||
$gradientDirection = $directions[$direction] ?? 'to right';
|
||||
|
||||
return "background: linear-gradient({$gradientDirection}, {$startColor}, {$endColor}); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;";
|
||||
}
|
||||
|
||||
private function buildCustomStyles(array $data): string
|
||||
{
|
||||
$badgeBg = $data['styles']['category_badge_background'] ?? 'rgba(255, 255, 255, 0.2)';
|
||||
$badgeTextColor = $data['styles']['category_badge_text_color'] ?? '#FFFFFF';
|
||||
$badgeBlur = isset($data['styles']['category_badge_blur']) && $data['styles']['category_badge_blur'];
|
||||
|
||||
$blurStyle = $badgeBlur ? 'backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);' : '';
|
||||
|
||||
$overlayStyle = '';
|
||||
if (($data['styles']['background_type'] ?? '') === 'image' &&
|
||||
isset($data['styles']['background_overlay']) &&
|
||||
$data['styles']['background_overlay']) {
|
||||
$opacity = ($data['styles']['overlay_opacity'] ?? 60) / 100;
|
||||
$overlayStyle = <<<CSS
|
||||
.hero-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, {$opacity});
|
||||
z-index: 0;
|
||||
}
|
||||
.hero-title > .container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
CSS;
|
||||
}
|
||||
|
||||
return <<<STYLES
|
||||
<style>
|
||||
.category-badge-hero {
|
||||
background-color: {$badgeBg};
|
||||
color: {$badgeTextColor};
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 2rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: all 0.3s ease;
|
||||
{$blurStyle}
|
||||
}
|
||||
.category-badge-hero:hover {
|
||||
background-color: rgba(255, 134, 0, 0.3);
|
||||
color: {$badgeTextColor};
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.roi-gradient-text {
|
||||
display: inline-block;
|
||||
}
|
||||
{$overlayStyle}
|
||||
</style>
|
||||
STYLES;
|
||||
}
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'hero-section';
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace ROITheme\Public\Navbar\Infrastructure\Ui;
|
||||
use ROITheme\Shared\Domain\Entities\Component;
|
||||
use ROITheme\Shared\Domain\Contracts\RendererInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\CSSGeneratorInterface;
|
||||
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
use Walker_Nav_Menu;
|
||||
|
||||
/**
|
||||
@@ -28,6 +29,8 @@ use Walker_Nav_Menu;
|
||||
*/
|
||||
final class NavbarRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'navbar';
|
||||
|
||||
/**
|
||||
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
|
||||
*/
|
||||
@@ -43,6 +46,10 @@ final class NavbarRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$html = $this->buildMenu($data);
|
||||
|
||||
// Si is_critical=true, CSS ya fue inyectado en <head> por CriticalCSSService
|
||||
@@ -281,7 +288,7 @@ final class NavbarRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'navbar';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ROITheme\Public\RelatedPost\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;
|
||||
|
||||
/**
|
||||
* RelatedPostRenderer - Renderiza seccion de posts relacionados
|
||||
@@ -22,6 +23,8 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class RelatedPostRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'related-post';
|
||||
|
||||
public function __construct(
|
||||
private CSSGeneratorInterface $cssGenerator
|
||||
) {}
|
||||
@@ -34,7 +37,7 @@ final class RelatedPostRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -51,7 +54,7 @@ final class RelatedPostRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'related-post';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
@@ -60,22 +63,6 @@ final class RelatedPostRenderer implements RendererInterface
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function getVisibilityClass(array $data): ?string
|
||||
{
|
||||
$showDesktop = $data['visibility']['show_on_desktop'] ?? true;
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ROITheme\Public\SocialShare\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;
|
||||
|
||||
/**
|
||||
* SocialShareRenderer - Renderiza botones de compartir en redes sociales
|
||||
@@ -27,6 +28,8 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class SocialShareRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'social-share';
|
||||
|
||||
private const NETWORKS = [
|
||||
'facebook' => [
|
||||
'field' => 'show_facebook',
|
||||
@@ -84,7 +87,7 @@ final class SocialShareRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ final class SocialShareRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'social-share';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
@@ -105,22 +108,6 @@ final class SocialShareRenderer implements RendererInterface
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateCSS(array $data): string
|
||||
{
|
||||
$colors = $data['colors'] ?? [];
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ROITheme\Public\TableOfContents\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;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
||||
@@ -30,6 +31,8 @@ use DOMXPath;
|
||||
*/
|
||||
final class TableOfContentsRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'table-of-contents';
|
||||
|
||||
private array $headingCounter = [];
|
||||
|
||||
public function __construct(
|
||||
@@ -44,7 +47,7 @@ final class TableOfContentsRenderer implements RendererInterface
|
||||
return '';
|
||||
}
|
||||
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -63,7 +66,7 @@ final class TableOfContentsRenderer implements RendererInterface
|
||||
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'table-of-contents';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
private function isEnabled(array $data): bool
|
||||
@@ -71,22 +74,6 @@ final class TableOfContentsRenderer implements RendererInterface
|
||||
return ($data['visibility']['is_enabled'] ?? false) === true;
|
||||
}
|
||||
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'posts';
|
||||
|
||||
switch ($showOn) {
|
||||
case 'all':
|
||||
return true;
|
||||
case 'posts':
|
||||
return is_single();
|
||||
case 'pages':
|
||||
return is_page();
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function getVisibilityClasses(bool $desktop, bool $mobile): ?string
|
||||
{
|
||||
if (!$desktop && !$mobile) {
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace ROITheme\Public\TopNotificationBar\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;
|
||||
use ROITheme\Shared\Infrastructure\Services\UserVisibilityHelper;
|
||||
|
||||
/**
|
||||
* Class TopNotificationBarRenderer
|
||||
@@ -34,6 +36,8 @@ use ROITheme\Shared\Domain\Entities\Component;
|
||||
*/
|
||||
final class TopNotificationBarRenderer implements RendererInterface
|
||||
{
|
||||
private const COMPONENT_NAME = 'top-notification-bar';
|
||||
|
||||
/**
|
||||
* @param CSSGeneratorInterface $cssGenerator Servicio de generación de CSS
|
||||
*/
|
||||
@@ -54,7 +58,12 @@ final class TopNotificationBarRenderer implements RendererInterface
|
||||
}
|
||||
|
||||
// Validar visibilidad por página
|
||||
if (!$this->shouldShowOnCurrentPage($data)) {
|
||||
if (!PageVisibilityHelper::shouldShow(self::COMPONENT_NAME)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Validar visibilidad por usuario logueado
|
||||
if (!UserVisibilityHelper::shouldShowForUser($data['visibility'] ?? [])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -78,7 +87,7 @@ final class TopNotificationBarRenderer implements RendererInterface
|
||||
*/
|
||||
public function supports(string $componentType): bool
|
||||
{
|
||||
return $componentType === 'top-notification-bar';
|
||||
return $componentType === self::COMPONENT_NAME;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,46 +101,6 @@ final class TopNotificationBarRenderer implements RendererInterface
|
||||
return ($data['visibility']['is_enabled'] ?? false) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si debe mostrarse en la página actual
|
||||
*
|
||||
* @param array $data Datos del componente
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldShowOnCurrentPage(array $data): bool
|
||||
{
|
||||
$showOn = $data['visibility']['show_on_pages'] ?? 'all';
|
||||
|
||||
return match ($showOn) {
|
||||
'all' => true,
|
||||
'home' => is_front_page(),
|
||||
'posts' => is_single(),
|
||||
'pages' => is_page(),
|
||||
'custom' => $this->isInCustomPages($data),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si está en páginas personalizadas
|
||||
*
|
||||
* @param array $data Datos del componente
|
||||
* @return bool
|
||||
*/
|
||||
private function isInCustomPages(array $data): bool
|
||||
{
|
||||
$pageIds = $data['visibility']['custom_page_ids'] ?? '';
|
||||
|
||||
if (empty($pageIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedIds = array_map('trim', explode(',', $pageIds));
|
||||
$currentId = (string) get_the_ID();
|
||||
|
||||
return in_array($currentId, $allowedIds, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar si el componente fue dismissed por el usuario
|
||||
*
|
||||
|
||||
@@ -110,3 +110,14 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
FIX: Legacy wrapper with padding-top
|
||||
Removes duplicate aspect-ratio from parent
|
||||
containers that use the old padding-top trick
|
||||
(prevents double spacing above videos)
|
||||
======================================== */
|
||||
|
||||
div[style*="padding-top"]:has(> .youtube-facade) {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
@@ -437,6 +437,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"search_results": {
|
||||
"label": "Resultados de Busqueda (ROI APU Search)",
|
||||
"priority": 73,
|
||||
"fields": {
|
||||
"search_ads_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Activar ads en busqueda",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "Insertar anuncios en resultados del buscador APU"
|
||||
},
|
||||
"search_top_ad_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Anuncio fijo arriba",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Mostrar anuncio debajo del campo de busqueda"
|
||||
},
|
||||
"search_top_ad_format": {
|
||||
"type": "select",
|
||||
"label": "Formato anuncio superior",
|
||||
"default": "auto",
|
||||
"editable": true,
|
||||
"options": ["auto", "display", "in-article"]
|
||||
},
|
||||
"search_between_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Anuncios entre resultados",
|
||||
"default": true,
|
||||
"editable": true
|
||||
},
|
||||
"search_between_max": {
|
||||
"type": "select",
|
||||
"label": "Maximo anuncios entre resultados",
|
||||
"default": "1",
|
||||
"editable": true,
|
||||
"options": ["1", "2", "3"],
|
||||
"description": "Maximo 3 por politicas AdSense"
|
||||
},
|
||||
"search_between_format": {
|
||||
"type": "select",
|
||||
"label": "Formato entre resultados",
|
||||
"default": "in-article",
|
||||
"editable": true,
|
||||
"options": ["auto", "in-article", "autorelaxed"]
|
||||
},
|
||||
"search_between_position": {
|
||||
"type": "select",
|
||||
"label": "Posicion de anuncios",
|
||||
"default": "random",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"random": "Aleatorio",
|
||||
"fixed": "Fijo (cada N resultados)",
|
||||
"first_half": "Primera mitad"
|
||||
}
|
||||
},
|
||||
"search_between_every": {
|
||||
"type": "select",
|
||||
"label": "Cada N resultados (si es fijo)",
|
||||
"default": "5",
|
||||
"editable": true,
|
||||
"options": ["3", "4", "5", "6", "7", "8", "10"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"label": "Ubicaciones Archivos/Globales",
|
||||
"priority": 80,
|
||||
|
||||
@@ -27,14 +27,6 @@
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Muestra el componente en pantallas < 992px"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "all",
|
||||
"editable": true,
|
||||
"options": ["all", "posts", "pages"],
|
||||
"description": "Tipos de contenido donde se muestra"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,13 +28,12 @@
|
||||
"editable": true,
|
||||
"description": "Muestra el componente en pantallas < 992px"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "posts",
|
||||
"hide_for_logged_in": {
|
||||
"type": "boolean",
|
||||
"label": "Ocultar para usuarios logueados",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"options": ["all", "posts", "pages"],
|
||||
"description": "Tipos de contenido donde se muestra"
|
||||
"description": "No mostrar el CTA a usuarios con sesión iniciada en WordPress"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -29,26 +29,19 @@
|
||||
"editable": true,
|
||||
"description": "Muestra el botón en pantallas móviles (<992px). Por defecto oculto para ahorrar espacio en navbar móvil"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "all",
|
||||
"editable": true,
|
||||
"required": true,
|
||||
"options": {
|
||||
"all": "Todas las páginas",
|
||||
"home": "Solo página de inicio",
|
||||
"posts": "Solo posts individuales",
|
||||
"pages": "Solo páginas"
|
||||
},
|
||||
"description": "Define en qué páginas se mostrará el botón"
|
||||
},
|
||||
"is_critical": {
|
||||
"type": "boolean",
|
||||
"label": "CSS Crítico",
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Inyectar CSS inline en <head> para optimizar LCP (componente above-the-fold)"
|
||||
},
|
||||
"hide_for_logged_in": {
|
||||
"type": "boolean",
|
||||
"label": "Ocultar para usuarios logueados",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "No mostrar el botón a usuarios con sesión iniciada en WordPress"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,13 +28,12 @@
|
||||
"editable": true,
|
||||
"description": "Muestra el componente en pantallas < 992px"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "posts",
|
||||
"hide_for_logged_in": {
|
||||
"type": "boolean",
|
||||
"label": "Ocultar para usuarios logueados",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"options": ["all", "posts", "pages"],
|
||||
"description": "Tipos de contenido donde se muestra"
|
||||
"description": "No mostrar el CTA a usuarios con sesión iniciada en WordPress"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,19 +30,6 @@
|
||||
"editable": true,
|
||||
"required": true,
|
||||
"description": "Muestra la imagen en dispositivos moviles (<768px)"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "posts",
|
||||
"editable": true,
|
||||
"required": true,
|
||||
"options": {
|
||||
"all": "Todas las paginas",
|
||||
"posts": "Solo posts individuales",
|
||||
"pages": "Solo paginas"
|
||||
},
|
||||
"description": "Define en que tipo de contenido se muestra la imagen"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -31,20 +31,6 @@
|
||||
"required": true,
|
||||
"description": "Muestra el hero en dispositivos móviles (<768px)"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "posts",
|
||||
"editable": true,
|
||||
"required": true,
|
||||
"options": {
|
||||
"all": "Todas las páginas",
|
||||
"posts": "Solo posts individuales",
|
||||
"pages": "Solo páginas",
|
||||
"home": "Solo página de inicio"
|
||||
},
|
||||
"description": "Define en qué tipo de contenido se mostrará el hero"
|
||||
},
|
||||
"is_critical": {
|
||||
"type": "boolean",
|
||||
"label": "CSS Crítico",
|
||||
|
||||
@@ -29,19 +29,6 @@
|
||||
"editable": true,
|
||||
"description": "Muestra el menú en dispositivos de escritorio (≥768px)"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "all",
|
||||
"editable": true,
|
||||
"options": {
|
||||
"all": "Todas las páginas",
|
||||
"home": "Solo página de inicio",
|
||||
"posts": "Solo posts individuales",
|
||||
"pages": "Solo páginas"
|
||||
},
|
||||
"description": "Define en qué páginas se muestra el navbar"
|
||||
},
|
||||
"sticky_enabled": {
|
||||
"type": "boolean",
|
||||
"label": "Navbar fijo (sticky)",
|
||||
|
||||
@@ -27,14 +27,6 @@
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Muestra el componente en pantallas < 992px"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "posts",
|
||||
"editable": true,
|
||||
"options": ["all", "posts", "pages"],
|
||||
"description": "Tipos de contenido donde se muestra"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -27,14 +27,6 @@
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Muestra el componente en pantallas < 992px"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "posts",
|
||||
"editable": true,
|
||||
"options": ["all", "posts", "pages"],
|
||||
"description": "Tipos de contenido donde se muestra"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -28,14 +28,6 @@
|
||||
"editable": true,
|
||||
"description": "Muestra el componente en pantallas < 992px"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "posts",
|
||||
"editable": true,
|
||||
"options": ["all", "posts", "pages"],
|
||||
"description": "Tipos de contenido donde se muestra"
|
||||
},
|
||||
"is_critical": {
|
||||
"type": "boolean",
|
||||
"label": "CSS Crítico",
|
||||
|
||||
@@ -15,20 +15,6 @@
|
||||
"required": true,
|
||||
"description": "Activa o desactiva la barra de notificación superior"
|
||||
},
|
||||
"show_on_pages": {
|
||||
"type": "select",
|
||||
"label": "Mostrar en",
|
||||
"default": "all",
|
||||
"editable": true,
|
||||
"required": true,
|
||||
"options": {
|
||||
"all": "Todas las páginas",
|
||||
"home": "Solo página de inicio",
|
||||
"posts": "Solo posts individuales",
|
||||
"pages": "Solo páginas"
|
||||
},
|
||||
"description": "Define en qué páginas se mostrará la barra"
|
||||
},
|
||||
"show_on_desktop": {
|
||||
"type": "boolean",
|
||||
"label": "Mostrar en desktop",
|
||||
@@ -51,6 +37,13 @@
|
||||
"default": true,
|
||||
"editable": true,
|
||||
"description": "Inyectar CSS inline en <head> para optimizar LCP (componente above-the-fold)"
|
||||
},
|
||||
"hide_for_logged_in": {
|
||||
"type": "boolean",
|
||||
"label": "Ocultar para usuarios logueados",
|
||||
"default": false,
|
||||
"editable": true,
|
||||
"description": "No mostrar la barra a usuarios con sesión iniciada en WordPress"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\UseCases;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
|
||||
|
||||
/**
|
||||
* UseCase: Verificar si un wrapper de componente debe renderizarse
|
||||
*
|
||||
* Responsabilidad: Orquestar la lógica de verificación de visibilidad
|
||||
* combinando múltiples criterios:
|
||||
* 1. Componente habilitado (is_enabled)
|
||||
* 2. Visible en dispositivo actual (show_on_mobile/desktop)
|
||||
* 3. No excluido por reglas (categoría, post ID, URL, page visibility)
|
||||
*
|
||||
* @package ROITheme\Shared\Application\UseCases
|
||||
* @see Plan 99.15 - Fix Empty Layout Wrappers
|
||||
*/
|
||||
final class CheckWrapperVisibilityUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly WrapperVisibilityCheckerInterface $visibilityChecker
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Ejecuta la verificación de visibilidad del wrapper
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @param bool $isMobile True si es dispositivo móvil
|
||||
* @return bool True si el wrapper debe renderizarse
|
||||
*/
|
||||
public function execute(string $componentName, bool $isMobile): bool
|
||||
{
|
||||
// Criterio 1: Debe estar habilitado
|
||||
if (!$this->visibilityChecker->isEnabled($componentName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Criterio 2: Debe ser visible en el dispositivo actual
|
||||
if (!$this->visibilityChecker->isVisibleOnDevice($componentName, $isMobile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Criterio 3: No debe estar excluido
|
||||
if (!$this->visibilityChecker->isNotExcluded($componentName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\UseCases\EvaluateComponentVisibility;
|
||||
|
||||
use ROITheme\Shared\Application\UseCases\EvaluatePageVisibility\EvaluatePageVisibilityUseCase;
|
||||
use ROITheme\Shared\Application\UseCases\EvaluateExclusions\EvaluateExclusionsUseCase;
|
||||
|
||||
/**
|
||||
* Caso de uso: Evaluar visibilidad completa de un componente
|
||||
*
|
||||
* Orquesta la evaluacion de:
|
||||
* 1. Visibilidad por tipo de pagina (Plan 99.10)
|
||||
* 2. Reglas de exclusion (Plan 99.11)
|
||||
*
|
||||
* El componente se muestra SOLO si:
|
||||
* - Pasa la verificacion de tipo de pagina
|
||||
* - NO esta excluido por ninguna regla
|
||||
*
|
||||
* PATRON: Facade/Orchestrator - combina dos UseCases
|
||||
*
|
||||
* @package ROITheme\Shared\Application\UseCases\EvaluateComponentVisibility
|
||||
*/
|
||||
final class EvaluateComponentVisibilityUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EvaluatePageVisibilityUseCase $pageVisibilityUseCase,
|
||||
private readonly EvaluateExclusionsUseCase $exclusionsUseCase
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evalua si el componente debe mostrarse en la pagina actual
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @return bool True si debe mostrarse
|
||||
*/
|
||||
public function execute(string $componentName): bool
|
||||
{
|
||||
// Paso 1: Verificar visibilidad por tipo de pagina
|
||||
$visibleByPageType = $this->pageVisibilityUseCase->execute($componentName);
|
||||
|
||||
if (!$visibleByPageType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Paso 2: Verificar exclusiones
|
||||
$isExcluded = $this->exclusionsUseCase->execute($componentName);
|
||||
|
||||
// Mostrar si NO esta excluido
|
||||
return !$isExcluded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\UseCases\EvaluateExclusions;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\ExclusionRepositoryInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\PageContextProviderInterface;
|
||||
|
||||
/**
|
||||
* Caso de uso: Evaluar si un componente debe excluirse en la pagina actual
|
||||
*
|
||||
* Obtiene las reglas de exclusion del repositorio y evalua si aplican
|
||||
* al contexto actual (post ID, categorias, URL).
|
||||
*
|
||||
* DIP: Depende de interfaces, no implementaciones.
|
||||
*
|
||||
* @package ROITheme\Shared\Application\UseCases\EvaluateExclusions
|
||||
*/
|
||||
final class EvaluateExclusionsUseCase
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ExclusionRepositoryInterface $exclusionRepository,
|
||||
private readonly PageContextProviderInterface $contextProvider
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evalua si el componente debe excluirse
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @return bool True si debe EXCLUIRSE (NO mostrar)
|
||||
*/
|
||||
public function execute(string $componentName): bool
|
||||
{
|
||||
$exclusions = $this->exclusionRepository->getExclusions($componentName);
|
||||
|
||||
if (!$exclusions->isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$context = $this->contextProvider->getCurrentContext();
|
||||
|
||||
return $exclusions->shouldExclude($context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Application\UseCases\EvaluatePageVisibility;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\PageTypeDetectorInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\PageVisibilityRepositoryInterface;
|
||||
use ROITheme\Shared\Domain\Constants\VisibilityDefaults;
|
||||
|
||||
/**
|
||||
* Caso de uso: Evaluar si un componente debe mostrarse en la página actual
|
||||
*
|
||||
* @package ROITheme\Shared\Application\UseCases\EvaluatePageVisibility
|
||||
*/
|
||||
final class EvaluatePageVisibilityUseCase
|
||||
{
|
||||
// NOTA: Usa VisibilityDefaults::DEFAULT_VISIBILITY para cumplir DRY
|
||||
|
||||
public function __construct(
|
||||
private readonly PageTypeDetectorInterface $pageTypeDetector,
|
||||
private readonly PageVisibilityRepositoryInterface $visibilityRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evalúa si el componente debe mostrarse en la página actual
|
||||
*/
|
||||
public function execute(string $componentName): bool
|
||||
{
|
||||
$config = $this->visibilityRepository->getVisibilityConfig($componentName);
|
||||
|
||||
if (empty($config)) {
|
||||
// Usar constante compartida (DRY)
|
||||
$config = VisibilityDefaults::DEFAULT_VISIBILITY;
|
||||
}
|
||||
|
||||
$pageType = $this->pageTypeDetector->detect();
|
||||
$visibilityField = $pageType->toVisibilityField();
|
||||
|
||||
return $this->toBool($config[$visibilityField] ?? true);
|
||||
}
|
||||
|
||||
private function toBool(mixed $value): bool
|
||||
{
|
||||
return $value === true || $value === '1' || $value === 1;
|
||||
}
|
||||
}
|
||||
37
Shared/Domain/Constants/ExclusionDefaults.php
Normal file
37
Shared/Domain/Constants/ExclusionDefaults.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Constants;
|
||||
|
||||
/**
|
||||
* Constantes de exclusion por defecto para componentes
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Constants
|
||||
*/
|
||||
final class ExclusionDefaults
|
||||
{
|
||||
/**
|
||||
* Configuracion de exclusion por defecto (sin exclusiones)
|
||||
*/
|
||||
public const DEFAULT_EXCLUSIONS = [
|
||||
'exclusions_enabled' => false,
|
||||
'exclude_categories' => '[]',
|
||||
'exclude_post_ids' => '[]',
|
||||
'exclude_url_patterns' => '[]',
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista de campos de exclusion validos
|
||||
*/
|
||||
public const EXCLUSION_FIELDS = [
|
||||
'exclusions_enabled',
|
||||
'exclude_categories',
|
||||
'exclude_post_ids',
|
||||
'exclude_url_patterns',
|
||||
];
|
||||
|
||||
/**
|
||||
* Nombre del grupo en BD
|
||||
*/
|
||||
public const GROUP_NAME = '_exclusions';
|
||||
}
|
||||
45
Shared/Domain/Constants/VisibilityDefaults.php
Normal file
45
Shared/Domain/Constants/VisibilityDefaults.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Constants;
|
||||
|
||||
/**
|
||||
* Constantes de visibilidad por defecto para componentes
|
||||
*
|
||||
* Centraliza los valores por defecto para cumplir con DRY.
|
||||
* Usado por:
|
||||
* - EvaluatePageVisibilityUseCase (cuando no hay config en BD)
|
||||
* - MigratePageVisibilityService (para crear registros iniciales)
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Constants
|
||||
*/
|
||||
final class VisibilityDefaults
|
||||
{
|
||||
/**
|
||||
* Configuración de visibilidad por defecto para nuevos componentes
|
||||
*
|
||||
* - 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)
|
||||
*/
|
||||
public const DEFAULT_VISIBILITY = [
|
||||
'show_on_home' => true,
|
||||
'show_on_posts' => true,
|
||||
'show_on_pages' => true,
|
||||
'show_on_archives' => false,
|
||||
'show_on_search' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
* Lista de campos de visibilidad válidos
|
||||
*/
|
||||
public const VISIBILITY_FIELDS = [
|
||||
'show_on_home',
|
||||
'show_on_posts',
|
||||
'show_on_pages',
|
||||
'show_on_archives',
|
||||
'show_on_search',
|
||||
];
|
||||
}
|
||||
36
Shared/Domain/Contracts/ExclusionRepositoryInterface.php
Normal file
36
Shared/Domain/Contracts/ExclusionRepositoryInterface.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
use ROITheme\Shared\Domain\ValueObjects\ExclusionRuleSet;
|
||||
|
||||
/**
|
||||
* Contrato para acceder a la configuracion de exclusiones
|
||||
*
|
||||
* Metodos: 3 (cumple ISP < 5 metodos)
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface ExclusionRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Obtiene las exclusiones configuradas para un componente
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @return ExclusionRuleSet Configuracion de exclusiones
|
||||
*/
|
||||
public function getExclusions(string $componentName): ExclusionRuleSet;
|
||||
|
||||
/**
|
||||
* Guarda la configuracion de exclusiones de un componente
|
||||
*
|
||||
* @param ExclusionRuleSet $exclusions Configuracion a guardar
|
||||
*/
|
||||
public function saveExclusions(ExclusionRuleSet $exclusions): void;
|
||||
|
||||
/**
|
||||
* Verifica si existe configuracion de exclusiones para un componente
|
||||
*/
|
||||
public function hasExclusions(string $componentName): bool;
|
||||
}
|
||||
33
Shared/Domain/Contracts/PageContextProviderInterface.php
Normal file
33
Shared/Domain/Contracts/PageContextProviderInterface.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Contrato para obtener el contexto de la pagina actual
|
||||
*
|
||||
* Abstrae la obtencion de datos del contexto actual (WordPress).
|
||||
* Permite testear UseCases sin dependencia de WordPress.
|
||||
*
|
||||
* v1.1: Renombrado de ExclusionEvaluatorInterface (nombre semantico incorrecto)
|
||||
* El nombre refleja que PROVEE contexto, no que EVALUA.
|
||||
*
|
||||
* Metodos: 1 (cumple ISP < 5 metodos)
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface PageContextProviderInterface
|
||||
{
|
||||
/**
|
||||
* Obtiene el contexto actual para evaluacion de exclusiones
|
||||
*
|
||||
* @return array{
|
||||
* post_id: int,
|
||||
* categories: array<array{term_id: int, slug: string, name: string}>,
|
||||
* url: string,
|
||||
* request_uri: string,
|
||||
* post_type: string
|
||||
* }
|
||||
*/
|
||||
public function getCurrentContext(): array;
|
||||
}
|
||||
25
Shared/Domain/Contracts/PageTypeDetectorInterface.php
Normal file
25
Shared/Domain/Contracts/PageTypeDetectorInterface.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
use ROITheme\Shared\Domain\ValueObjects\PageType;
|
||||
|
||||
/**
|
||||
* Contrato para detectar el tipo de página actual
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface PageTypeDetectorInterface
|
||||
{
|
||||
/**
|
||||
* Detecta y retorna el tipo de página actual
|
||||
*/
|
||||
public function detect(): PageType;
|
||||
|
||||
public function isHome(): bool;
|
||||
public function isPost(): bool;
|
||||
public function isPage(): bool;
|
||||
public function isArchive(): bool;
|
||||
public function isSearch(): bool;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Contrato para acceder a la configuración de visibilidad por página
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface PageVisibilityRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Obtiene la configuración de visibilidad de un componente
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @return array<string, bool> Mapa de campo => habilitado
|
||||
*/
|
||||
public function getVisibilityConfig(string $componentName): array;
|
||||
|
||||
/**
|
||||
* Guarda la configuración de visibilidad de un componente
|
||||
*
|
||||
* @param string $componentName Nombre del componente
|
||||
* @param array<string, bool> $config Configuración a guardar
|
||||
*/
|
||||
public function saveVisibilityConfig(string $componentName, array $config): void;
|
||||
|
||||
/**
|
||||
* Verifica si existe configuración de visibilidad para un componente
|
||||
*/
|
||||
public function hasVisibilityConfig(string $componentName): bool;
|
||||
|
||||
/**
|
||||
* Obtiene lista de todos los componentes registrados
|
||||
*
|
||||
* @return array<string> Lista de nombres de componentes
|
||||
*/
|
||||
public function getAllComponentNames(): array;
|
||||
|
||||
/**
|
||||
* Crea configuración de visibilidad por defecto para un componente
|
||||
*
|
||||
* @param string $componentName Nombre del componente
|
||||
* @param array<string, bool> $defaults Valores por defecto
|
||||
*/
|
||||
public function createDefaultVisibility(string $componentName, array $defaults): void;
|
||||
}
|
||||
27
Shared/Domain/Contracts/ServerRequestProviderInterface.php
Normal file
27
Shared/Domain/Contracts/ServerRequestProviderInterface.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Contrato para obtener datos del request HTTP
|
||||
*
|
||||
* Encapsula el acceso a $_SERVER para:
|
||||
* - Evitar acceso directo a superglobales en Infrastructure
|
||||
* - Permitir testear sin dependencia de $_SERVER
|
||||
*
|
||||
* v1.1: Nuevo - encapsular acceso a $_SERVER
|
||||
*
|
||||
* Metodos: 1 (cumple ISP < 5 metodos)
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
*/
|
||||
interface ServerRequestProviderInterface
|
||||
{
|
||||
/**
|
||||
* Obtiene el Request URI actual
|
||||
*
|
||||
* @return string URI del request (ej: "/blog/mi-post/")
|
||||
*/
|
||||
public function getRequestUri(): string;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\Contracts;
|
||||
|
||||
/**
|
||||
* Interface para verificar visibilidad de wrappers de componentes
|
||||
*
|
||||
* Responsabilidad: Definir contrato para determinar si un wrapper
|
||||
* de componente debe renderizarse basándose en:
|
||||
* - Estado habilitado/deshabilitado
|
||||
* - Visibilidad por dispositivo
|
||||
* - Reglas de exclusión (categoría, post ID, URL pattern, page visibility)
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\Contracts
|
||||
* @see Plan 99.15 - Fix Empty Layout Wrappers
|
||||
*/
|
||||
interface WrapperVisibilityCheckerInterface
|
||||
{
|
||||
/**
|
||||
* Verifica si el componente está habilitado globalmente
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @return bool True si is_enabled = true en BD
|
||||
*/
|
||||
public function isEnabled(string $componentName): bool;
|
||||
|
||||
/**
|
||||
* Verifica si el componente es visible en el dispositivo actual
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @param bool $isMobile True si es dispositivo móvil
|
||||
* @return bool True si show_on_mobile/show_on_desktop según corresponda
|
||||
*/
|
||||
public function isVisibleOnDevice(string $componentName, bool $isMobile): bool;
|
||||
|
||||
/**
|
||||
* Verifica si el componente NO está excluido para la página actual
|
||||
*
|
||||
* Evalúa todas las reglas de exclusión:
|
||||
* - Exclusión por categoría
|
||||
* - Exclusión por post ID
|
||||
* - Exclusión por URL pattern
|
||||
* - Page visibility (home, posts, pages, archives, search)
|
||||
*
|
||||
* @param string $componentName Nombre del componente (kebab-case)
|
||||
* @return bool True si el componente NO está excluido
|
||||
*/
|
||||
public function isNotExcluded(string $componentName): bool;
|
||||
}
|
||||
100
Shared/Domain/ValueObjects/CategoryExclusion.php
Normal file
100
Shared/Domain/ValueObjects/CategoryExclusion.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object: Exclusion por categoria
|
||||
*
|
||||
* Evalua si un post pertenece a alguna de las categorias excluidas.
|
||||
* Soporta matching por slug o term_id.
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\ValueObjects
|
||||
*/
|
||||
final class CategoryExclusion extends ExclusionRule
|
||||
{
|
||||
/**
|
||||
* @param array<int|string> $excludedCategories Lista de slugs o IDs de categorias
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $excludedCategories = []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Contexto esperado:
|
||||
* - categories: array<array{term_id: int, slug: string, name: string}>
|
||||
*/
|
||||
public function matches(array $context): bool
|
||||
{
|
||||
if (!$this->hasValues()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$postCategories = $context['categories'] ?? [];
|
||||
|
||||
if (empty($postCategories)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($postCategories as $category) {
|
||||
// Buscar por slug
|
||||
if (in_array($category['slug'], $this->excludedCategories, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Buscar por term_id
|
||||
if (in_array($category['term_id'], $this->excludedCategories, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Buscar por term_id como string (para comparaciones flexibles)
|
||||
if (in_array((string) $category['term_id'], $this->excludedCategories, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasValues(): bool
|
||||
{
|
||||
return !empty($this->excludedCategories);
|
||||
}
|
||||
|
||||
public function serialize(): string
|
||||
{
|
||||
return json_encode($this->excludedCategories, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string>
|
||||
*/
|
||||
public function getExcludedCategories(): array
|
||||
{
|
||||
return $this->excludedCategories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea instancia desde JSON
|
||||
*/
|
||||
public static function fromJson(string $json): self
|
||||
{
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
return new self($decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea instancia vacia
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,12 @@ final readonly class ComponentConfiguration
|
||||
'widget_3', // Widget 3 del footer (menú)
|
||||
'newsletter', // Sección newsletter del footer
|
||||
'footer_bottom', // Pie del footer (copyright)
|
||||
|
||||
// Sistema de visibilidad por página
|
||||
'_page_visibility', // Visibilidad por tipo de página (home, posts, pages, archives, search)
|
||||
|
||||
// Sistema de exclusiones (Plan 99.11)
|
||||
'_exclusions', // Reglas de exclusión por categoría, post ID, URL pattern
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
37
Shared/Domain/ValueObjects/ExclusionRule.php
Normal file
37
Shared/Domain/ValueObjects/ExclusionRule.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\ValueObjects;
|
||||
|
||||
/**
|
||||
* Clase base abstracta para reglas de exclusion
|
||||
*
|
||||
* Define el contrato comun para todos los tipos de exclusion.
|
||||
* Cada implementacion concreta define su logica de matching.
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\ValueObjects
|
||||
*/
|
||||
abstract class ExclusionRule
|
||||
{
|
||||
/**
|
||||
* Evalua si el contexto actual coincide con la regla
|
||||
*
|
||||
* @param array<string, mixed> $context Contexto de la pagina actual
|
||||
* @return bool True si el contexto coincide (debe excluirse)
|
||||
*/
|
||||
abstract public function matches(array $context): bool;
|
||||
|
||||
/**
|
||||
* Verifica si la regla tiene valores configurados
|
||||
*
|
||||
* @return bool True si hay valores configurados
|
||||
*/
|
||||
abstract public function hasValues(): bool;
|
||||
|
||||
/**
|
||||
* Serializa los valores para almacenamiento
|
||||
*
|
||||
* @return string JSON string
|
||||
*/
|
||||
abstract public function serialize(): string;
|
||||
}
|
||||
100
Shared/Domain/ValueObjects/ExclusionRuleSet.php
Normal file
100
Shared/Domain/ValueObjects/ExclusionRuleSet.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object Compuesto: Conjunto de reglas de exclusion
|
||||
*
|
||||
* Agrupa todas las reglas de exclusion para un componente.
|
||||
* Evalua con logica OR (si cualquier regla coincide, se excluye).
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\ValueObjects
|
||||
*/
|
||||
final class ExclusionRuleSet
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $componentName,
|
||||
private readonly bool $enabled,
|
||||
private readonly CategoryExclusion $categoryExclusion,
|
||||
private readonly PostIdExclusion $postIdExclusion,
|
||||
private readonly UrlPatternExclusion $urlPatternExclusion
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Evalua si el componente debe excluirse segun el contexto actual
|
||||
*
|
||||
* @param array<string, mixed> $context Contexto de la pagina actual
|
||||
* @return bool True si debe excluirse (NO mostrar)
|
||||
*/
|
||||
public function shouldExclude(array $context): bool
|
||||
{
|
||||
if (!$this->enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Evaluar cada tipo de exclusion (OR logico)
|
||||
if ($this->categoryExclusion->matches($context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->postIdExclusion->matches($context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->urlPatternExclusion->matches($context)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si tiene alguna regla configurada
|
||||
*/
|
||||
public function hasAnyRule(): bool
|
||||
{
|
||||
return $this->categoryExclusion->hasValues()
|
||||
|| $this->postIdExclusion->hasValues()
|
||||
|| $this->urlPatternExclusion->hasValues();
|
||||
}
|
||||
|
||||
public function getComponentName(): string
|
||||
{
|
||||
return $this->componentName;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function getCategoryExclusion(): CategoryExclusion
|
||||
{
|
||||
return $this->categoryExclusion;
|
||||
}
|
||||
|
||||
public function getPostIdExclusion(): PostIdExclusion
|
||||
{
|
||||
return $this->postIdExclusion;
|
||||
}
|
||||
|
||||
public function getUrlPatternExclusion(): UrlPatternExclusion
|
||||
{
|
||||
return $this->urlPatternExclusion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una instancia sin exclusiones (por defecto)
|
||||
*/
|
||||
public static function empty(string $componentName): self
|
||||
{
|
||||
return new self(
|
||||
$componentName,
|
||||
false,
|
||||
CategoryExclusion::empty(),
|
||||
PostIdExclusion::empty(),
|
||||
UrlPatternExclusion::empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
90
Shared/Domain/ValueObjects/PageType.php
Normal file
90
Shared/Domain/ValueObjects/PageType.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object que representa los tipos de página válidos
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\ValueObjects
|
||||
*/
|
||||
final class PageType
|
||||
{
|
||||
public const HOME = 'home';
|
||||
public const POST = 'post';
|
||||
public const PAGE = 'page';
|
||||
public const ARCHIVE = 'archive';
|
||||
public const SEARCH = 'search';
|
||||
public const UNKNOWN = 'unknown';
|
||||
|
||||
private const VALID_TYPES = [
|
||||
self::HOME,
|
||||
self::POST,
|
||||
self::PAGE,
|
||||
self::ARCHIVE,
|
||||
self::SEARCH,
|
||||
self::UNKNOWN,
|
||||
];
|
||||
|
||||
private function __construct(
|
||||
private readonly string $value
|
||||
) {}
|
||||
|
||||
public static function fromString(string $type): self
|
||||
{
|
||||
if (!in_array($type, self::VALID_TYPES, true)) {
|
||||
return new self(self::UNKNOWN);
|
||||
}
|
||||
return new self($type);
|
||||
}
|
||||
|
||||
public static function home(): self
|
||||
{
|
||||
return new self(self::HOME);
|
||||
}
|
||||
|
||||
public static function post(): self
|
||||
{
|
||||
return new self(self::POST);
|
||||
}
|
||||
|
||||
public static function page(): self
|
||||
{
|
||||
return new self(self::PAGE);
|
||||
}
|
||||
|
||||
public static function archive(): self
|
||||
{
|
||||
return new self(self::ARCHIVE);
|
||||
}
|
||||
|
||||
public static function search(): self
|
||||
{
|
||||
return new self(self::SEARCH);
|
||||
}
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna el nombre del campo de visibilidad correspondiente
|
||||
*/
|
||||
public function toVisibilityField(): string
|
||||
{
|
||||
return match ($this->value) {
|
||||
self::HOME => 'show_on_home',
|
||||
self::POST => 'show_on_posts',
|
||||
self::PAGE => 'show_on_pages',
|
||||
self::ARCHIVE => 'show_on_archives',
|
||||
self::SEARCH => 'show_on_search',
|
||||
default => 'show_on_posts',
|
||||
};
|
||||
}
|
||||
}
|
||||
86
Shared/Domain/ValueObjects/PostIdExclusion.php
Normal file
86
Shared/Domain/ValueObjects/PostIdExclusion.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object: Exclusion por ID de post/pagina
|
||||
*
|
||||
* Evalua si el post/pagina actual esta en la lista de IDs excluidos.
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\ValueObjects
|
||||
*/
|
||||
final class PostIdExclusion extends ExclusionRule
|
||||
{
|
||||
/**
|
||||
* @param array<int> $excludedPostIds Lista de IDs de posts/paginas
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $excludedPostIds = []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Contexto esperado:
|
||||
* - post_id: int
|
||||
*/
|
||||
public function matches(array $context): bool
|
||||
{
|
||||
if (!$this->hasValues()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$postId = $context['post_id'] ?? 0;
|
||||
|
||||
if ($postId === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($postId, $this->excludedPostIds, true);
|
||||
}
|
||||
|
||||
public function hasValues(): bool
|
||||
{
|
||||
return !empty($this->excludedPostIds);
|
||||
}
|
||||
|
||||
public function serialize(): string
|
||||
{
|
||||
return json_encode($this->excludedPostIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int>
|
||||
*/
|
||||
public function getExcludedPostIds(): array
|
||||
{
|
||||
return $this->excludedPostIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea instancia desde JSON
|
||||
*/
|
||||
public static function fromJson(string $json): self
|
||||
{
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
// Asegurar que son enteros
|
||||
$ids = array_map('intval', $decoded);
|
||||
$ids = array_filter($ids, fn(int $id): bool => $id > 0);
|
||||
|
||||
return new self(array_values($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea instancia vacia
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
}
|
||||
182
Shared/Domain/ValueObjects/UrlPatternExclusion.php
Normal file
182
Shared/Domain/ValueObjects/UrlPatternExclusion.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Domain\ValueObjects;
|
||||
|
||||
/**
|
||||
* Value Object: Exclusion por patron URL
|
||||
*
|
||||
* Evalua si la URL actual coincide con alguno de los patrones configurados.
|
||||
* Soporta:
|
||||
* - Substring simple: "/privado/" coincide con cualquier URL que contenga ese texto
|
||||
* - Regex: Patrones que empiezan y terminan con "/" son evaluados como regex
|
||||
*
|
||||
* @package ROITheme\Shared\Domain\ValueObjects
|
||||
*/
|
||||
final class UrlPatternExclusion extends ExclusionRule
|
||||
{
|
||||
/**
|
||||
* @param array<string> $urlPatterns Lista de patrones (substring o regex)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $urlPatterns = []
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*
|
||||
* Contexto esperado:
|
||||
* - request_uri: string (URI del request)
|
||||
* - url: string (URL completa, opcional)
|
||||
*/
|
||||
public function matches(array $context): bool
|
||||
{
|
||||
if (!$this->hasValues()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$requestUri = $context['request_uri'] ?? '';
|
||||
$url = $context['url'] ?? '';
|
||||
|
||||
if ($requestUri === '' && $url === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->urlPatterns as $pattern) {
|
||||
if ($this->matchesPattern($pattern, $requestUri, $url)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evalua si un patron coincide con el request_uri o url
|
||||
*/
|
||||
private function matchesPattern(string $pattern, string $requestUri, string $url): bool
|
||||
{
|
||||
// Detectar si es regex (empieza con /)
|
||||
if ($this->isRegex($pattern)) {
|
||||
return $this->matchesRegex($pattern, $requestUri);
|
||||
}
|
||||
|
||||
// Substring matching
|
||||
return $this->matchesSubstring($pattern, $requestUri, $url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta si el patron es una expresion regular
|
||||
*/
|
||||
private function isRegex(string $pattern): bool
|
||||
{
|
||||
// Un patron regex debe empezar con / y terminar con / (posiblemente con flags)
|
||||
return preg_match('#^/.+/[gimsux]*$#', $pattern) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evalua coincidencia regex
|
||||
*/
|
||||
private function matchesRegex(string $pattern, string $subject): bool
|
||||
{
|
||||
// Suprimir warnings de regex invalidos
|
||||
$result = @preg_match($pattern, $subject);
|
||||
|
||||
return $result === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evalua coincidencia por substring o wildcard
|
||||
*
|
||||
* Soporta wildcards simples:
|
||||
* - `*sct*` coincide con URLs que contengan "sct"
|
||||
* - `*` se convierte a `.*` en regex
|
||||
* - Sin wildcards: busca substring literal
|
||||
*/
|
||||
private function matchesSubstring(string $pattern, string $requestUri, string $url): bool
|
||||
{
|
||||
// Detectar si tiene wildcards (*)
|
||||
if (str_contains($pattern, '*')) {
|
||||
return $this->matchesWildcard($pattern, $requestUri, $url);
|
||||
}
|
||||
|
||||
// Substring literal
|
||||
if ($requestUri !== '' && str_contains($requestUri, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($url !== '' && str_contains($url, $pattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evalua coincidencia con patron wildcard
|
||||
*
|
||||
* Convierte wildcards (*) a regex (.*)
|
||||
*/
|
||||
private function matchesWildcard(string $pattern, string $requestUri, string $url): bool
|
||||
{
|
||||
// Convertir wildcard a regex:
|
||||
// 1. Escapar caracteres especiales de regex (excepto *)
|
||||
// 2. Convertir * a .*
|
||||
$regexPattern = preg_quote($pattern, '#');
|
||||
$regexPattern = str_replace('\\*', '.*', $regexPattern);
|
||||
$regexPattern = '#' . $regexPattern . '#i';
|
||||
|
||||
if ($requestUri !== '' && preg_match($regexPattern, $requestUri) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($url !== '' && preg_match($regexPattern, $url) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasValues(): bool
|
||||
{
|
||||
return !empty($this->urlPatterns);
|
||||
}
|
||||
|
||||
public function serialize(): string
|
||||
{
|
||||
return json_encode($this->urlPatterns, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getUrlPatterns(): array
|
||||
{
|
||||
return $this->urlPatterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea instancia desde JSON
|
||||
*/
|
||||
public static function fromJson(string $json): self
|
||||
{
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (!is_array($decoded)) {
|
||||
return self::empty();
|
||||
}
|
||||
|
||||
// Filtrar valores vacios
|
||||
$patterns = array_filter($decoded, fn($p): bool => is_string($p) && $p !== '');
|
||||
|
||||
return new self(array_values($patterns));
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea instancia vacia
|
||||
*/
|
||||
public static function empty(): self
|
||||
{
|
||||
return new self([]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Api\WordPress;
|
||||
|
||||
use ROITheme\Shared\Infrastructure\Di\DIContainer;
|
||||
|
||||
/**
|
||||
* WP-CLI Command para Sincronización de Schemas
|
||||
*
|
||||
@@ -297,6 +299,298 @@ final class MigrationCommand
|
||||
'stats' => $stats
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Migra configuración de visibilidad para todos los componentes
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* wp roi-theme migrate-visibility
|
||||
*
|
||||
* @when after_wp_load
|
||||
*/
|
||||
public function migrate_visibility(): void
|
||||
{
|
||||
$container = DIContainer::getInstance();
|
||||
$service = $container->getMigratePageVisibilityService();
|
||||
|
||||
$result = $service->migrate();
|
||||
|
||||
\WP_CLI::success(sprintf(
|
||||
'Migración completada: %d creados, %d omitidos',
|
||||
$result['created'],
|
||||
$result['skipped']
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcodes que DEBEN ser preservados
|
||||
*/
|
||||
private const PROTECTED_SHORTCODES = ['[roi_apu_search', '[roi_'];
|
||||
|
||||
/**
|
||||
* Máximo porcentaje de contenido que puede eliminarse
|
||||
*/
|
||||
private const MAX_CONTENT_LOSS_PERCENT = 50;
|
||||
|
||||
/**
|
||||
* Limpia contenido Thrive congelado de páginas (H2 y paginación)
|
||||
*
|
||||
* LIMPIEZA QUIRÚRGICA CON VALIDACIONES DE SEGURIDAD:
|
||||
* - Elimina H2 con data-shortcode="tcb_post_title"
|
||||
* - Elimina paginación rota ([tcb_pagination_current_page], [tcb_pagination_total_pages])
|
||||
* - PRESERVA todo el demás contenido incluyendo shortcodes [roi_apu_search]
|
||||
* - Verifica que shortcodes importantes NO sean eliminados
|
||||
* - Aborta si se detecta pérdida excesiva de contenido (>50%)
|
||||
*
|
||||
* ## OPTIONS
|
||||
*
|
||||
* [--dry-run]
|
||||
* : Mostrar qué se limpiaría sin modificar nada (OBLIGATORIO primero)
|
||||
*
|
||||
* [--force]
|
||||
* : Ejecutar la limpieza real después de verificar dry-run
|
||||
*
|
||||
* [--include-others]
|
||||
* : Incluir otras páginas afectadas (Blog, Curso)
|
||||
*
|
||||
* ## EXAMPLES
|
||||
*
|
||||
* # Ver qué se limpiaría (modo seguro) - SIEMPRE PRIMERO
|
||||
* wp roi-theme clean_thrive --dry-run
|
||||
*
|
||||
* # Ejecutar limpieza real (requiere --force)
|
||||
* wp roi-theme clean_thrive --force
|
||||
*
|
||||
* @when after_wp_load
|
||||
*/
|
||||
public function clean_thrive(array $args, array $assoc_args): void
|
||||
{
|
||||
$affectedPageIds = [
|
||||
107264, 107312, 107340, 107345, 107351, 107357, 107362,
|
||||
107369, 107374, 107379, 107384, 107389, 107395, 107399,
|
||||
107403, 107407, 107411, 107416, 107421, 107425, 185752
|
||||
];
|
||||
$otherAffectedIds = [252030, 290709];
|
||||
|
||||
$dryRun = isset($assoc_args['dry-run']);
|
||||
$includeOthers = isset($assoc_args['include-others']);
|
||||
$force = isset($assoc_args['force']);
|
||||
|
||||
$pageIds = $affectedPageIds;
|
||||
if ($includeOthers) {
|
||||
$pageIds = array_merge($pageIds, $otherAffectedIds);
|
||||
}
|
||||
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::line('╔══════════════════════════════════════════════════════════════════╗');
|
||||
\WP_CLI::line('║ LIMPIEZA QUIRÚRGICA DE CONTENIDO THRIVE CONGELADO (v2.0) ║');
|
||||
\WP_CLI::line('║ Con validaciones de seguridad para proteger shortcodes ║');
|
||||
\WP_CLI::line('╚══════════════════════════════════════════════════════════════════╝');
|
||||
\WP_CLI::line('');
|
||||
|
||||
if ($dryRun) {
|
||||
\WP_CLI::warning('MODO DRY-RUN: No se modificará ningún contenido');
|
||||
} else {
|
||||
\WP_CLI::error('MODO REAL DESHABILITADO: Ejecuta primero con --dry-run', false);
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::line('Para ejecutar la limpieza real, primero revisa el dry-run:');
|
||||
\WP_CLI::line(' wp roi-theme clean_thrive --dry-run');
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::line('Si el dry-run es correcto y deseas ejecutar:');
|
||||
\WP_CLI::line(' wp roi-theme clean_thrive --force');
|
||||
|
||||
if (!$force) {
|
||||
return;
|
||||
}
|
||||
\WP_CLI::warning('MODO REAL CON --force: Se modificará el contenido');
|
||||
}
|
||||
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::line('Páginas a procesar: ' . count($pageIds));
|
||||
\WP_CLI::line('Shortcodes protegidos: ' . implode(', ', self::PROTECTED_SHORTCODES));
|
||||
\WP_CLI::line('Máxima pérdida permitida: ' . self::MAX_CONTENT_LOSS_PERCENT . '%');
|
||||
\WP_CLI::line('');
|
||||
|
||||
$totalH2Removed = 0;
|
||||
$totalPaginationRemoved = 0;
|
||||
$totalBytesFreed = 0;
|
||||
$pagesModified = 0;
|
||||
$pagesSkipped = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($pageIds as $id) {
|
||||
$page = get_post($id);
|
||||
if (!$page) {
|
||||
\WP_CLI::warning("Página {$id} no encontrada, saltando...");
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalContent = $page->post_content;
|
||||
$originalSize = strlen($originalContent);
|
||||
|
||||
$hasThrive = strpos($originalContent, 'tcb_post_title') !== false ||
|
||||
strpos($originalContent, 'tcb_pagination') !== false;
|
||||
|
||||
if (!$hasThrive) {
|
||||
\WP_CLI::line(sprintf("[SIN THRIVE] ID %d: %s", $id, mb_substr($page->post_title, 0, 50)));
|
||||
continue;
|
||||
}
|
||||
|
||||
$h2Count = preg_match_all('/<h2[^>]*>\s*<span[^>]*data-shortcode="tcb_post_title"[^>]*>.*?<\/span>\s*<\/h2>/s', $originalContent);
|
||||
$protectedBefore = $this->countProtectedShortcodes($originalContent);
|
||||
$cleanResult = $this->cleanThriveContentSafely($originalContent);
|
||||
|
||||
if ($cleanResult['error']) {
|
||||
$errors[] = "ID {$id}: {$cleanResult['error']}";
|
||||
\WP_CLI::error(sprintf("[ERROR] ID %d: %s - %s", $id, mb_substr($page->post_title, 0, 40), $cleanResult['error']), false);
|
||||
$pagesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cleanedContent = $cleanResult['content'];
|
||||
$newSize = strlen($cleanedContent);
|
||||
$protectedAfter = $this->countProtectedShortcodes($cleanedContent);
|
||||
|
||||
if ($protectedAfter < $protectedBefore) {
|
||||
$errors[] = "ID {$id}: Se perderían shortcodes protegidos ({$protectedBefore} → {$protectedAfter})";
|
||||
\WP_CLI::error(sprintf("[ABORTADO] ID %d: Se perderían shortcodes protegidos (%d → %d)", $id, $protectedBefore, $protectedAfter), false);
|
||||
$pagesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$lossPercent = $originalSize > 0 ? (($originalSize - $newSize) / $originalSize) * 100 : 0;
|
||||
if ($lossPercent > self::MAX_CONTENT_LOSS_PERCENT) {
|
||||
$errors[] = "ID {$id}: Pérdida excesiva de contenido ({$lossPercent}%)";
|
||||
\WP_CLI::error(sprintf("[ABORTADO] ID %d: Pérdida excesiva %.1f%% (máx %d%%)", $id, $lossPercent, self::MAX_CONTENT_LOSS_PERCENT), false);
|
||||
$pagesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$hasChanges = $originalContent !== $cleanedContent;
|
||||
$bytesSaved = $originalSize - $newSize;
|
||||
$paginationRemoved = (strpos($originalContent, 'tcb_pagination_current_page') !== false && strpos($cleanedContent, 'tcb_pagination_current_page') === false) ? 1 : 0;
|
||||
|
||||
if ($hasChanges) {
|
||||
$pagesModified++;
|
||||
$totalH2Removed += $h2Count;
|
||||
$totalPaginationRemoved += $paginationRemoved;
|
||||
$totalBytesFreed += $bytesSaved;
|
||||
|
||||
$status = $dryRun ? '[DRY-RUN]' : '[LIMPIADO]';
|
||||
\WP_CLI::line(sprintf("%s ID %d: %s", $status, $id, mb_substr($page->post_title, 0, 50) . (mb_strlen($page->post_title) > 50 ? '...' : '')));
|
||||
\WP_CLI::line(sprintf(" → H2 eliminados: %d | Paginación: %s | Pérdida: %.1f%%", $h2Count, $paginationRemoved ? 'Sí' : 'No', $lossPercent));
|
||||
\WP_CLI::line(sprintf(" → Shortcodes [roi_*] preservados: %d | Bytes liberados: %s", $protectedAfter, $this->formatBytes($bytesSaved)));
|
||||
|
||||
if (!$dryRun && $force) {
|
||||
wp_update_post(['ID' => $id, 'post_content' => $cleanedContent]);
|
||||
}
|
||||
} else {
|
||||
\WP_CLI::line(sprintf("[SIN CAMBIOS] ID %d: %s", $id, mb_substr($page->post_title, 0, 50)));
|
||||
}
|
||||
}
|
||||
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::line('════════════════════════════════════════════════════════════════════');
|
||||
\WP_CLI::line('RESUMEN:');
|
||||
\WP_CLI::line(sprintf(' Páginas modificadas: %d', $pagesModified));
|
||||
\WP_CLI::line(sprintf(' Páginas omitidas: %d', $pagesSkipped));
|
||||
\WP_CLI::line(sprintf(' Total H2 eliminados: %d', $totalH2Removed));
|
||||
\WP_CLI::line(sprintf(' Paginaciones removidas: %d', $totalPaginationRemoved));
|
||||
\WP_CLI::line(sprintf(' Espacio liberado: %s', $this->formatBytes($totalBytesFreed)));
|
||||
\WP_CLI::line('════════════════════════════════════════════════════════════════════');
|
||||
|
||||
if (count($errors) > 0) {
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::warning('ERRORES ENCONTRADOS:');
|
||||
foreach ($errors as $error) {
|
||||
\WP_CLI::line(" - {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun && $pagesModified > 0 && count($errors) === 0) {
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::success('Dry-run completado SIN errores.');
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::warning('Para ejecutar la limpieza real:');
|
||||
\WP_CLI::line(' wp roi-theme clean_thrive --force');
|
||||
} elseif (!$dryRun && $force && $pagesModified > 0) {
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::success('Limpieza completada exitosamente.');
|
||||
\WP_CLI::line('');
|
||||
\WP_CLI::warning('IMPORTANTE: Purga el caché del sitio para ver los cambios.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el contenido con validaciones de seguridad
|
||||
* @return array{content: string, error: string|null}
|
||||
*/
|
||||
private function cleanThriveContentSafely(string $content): array
|
||||
{
|
||||
$originalContent = $content;
|
||||
|
||||
// Patrón específico: H2 que contiene span con data-shortcode="tcb_post_title"
|
||||
// Estructura: <h2><span data-shortcode="tcb_post_title"...>...</span></h2>
|
||||
$result = preg_replace('/<h2[^>]*>\s*<span[^>]*data-shortcode="tcb_post_title"[^>]*>.*?<\/span>\s*<\/h2>/s', '', $content);
|
||||
if ($result === null) {
|
||||
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón H2'];
|
||||
}
|
||||
$content = $result;
|
||||
|
||||
$result = preg_replace('/<p[^>]*>.*?\[tcb_pagination_current_page\].*?\[tcb_pagination_total_pages\].*?<\/p>/s', '', $content);
|
||||
if ($result === null) {
|
||||
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón paginación'];
|
||||
}
|
||||
$content = $result;
|
||||
|
||||
$result = preg_replace('/<p[^>]*data-button_layout="[^"]*"[^>]*data-page="[^"]*"[^>]*>.*?<\/p>/s', '', $content);
|
||||
if ($result === null) {
|
||||
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón botones'];
|
||||
}
|
||||
$content = $result;
|
||||
|
||||
$content = str_replace('[tcb_pagination_current_page]', '', $content);
|
||||
$content = str_replace('[tcb_pagination_total_pages]', '', $content);
|
||||
|
||||
$result = preg_replace('/(\r?\n){3,}/', "\n\n", $content);
|
||||
if ($result === null) {
|
||||
return ['content' => $originalContent, 'error' => 'preg_replace falló en limpieza líneas'];
|
||||
}
|
||||
$content = trim($result);
|
||||
|
||||
if (empty($content) && !empty($originalContent)) {
|
||||
return ['content' => $originalContent, 'error' => 'El contenido quedó vacío'];
|
||||
}
|
||||
|
||||
return ['content' => $content, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuenta shortcodes protegidos en el contenido
|
||||
*/
|
||||
private function countProtectedShortcodes(string $content): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach (self::PROTECTED_SHORTCODES as $shortcode) {
|
||||
$count += substr_count($content, $shortcode);
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea bytes a formato legible
|
||||
*/
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes . ' B';
|
||||
} elseif ($bytes < 1048576) {
|
||||
return round($bytes / 1024, 1) . ' KB';
|
||||
} else {
|
||||
return round($bytes / 1048576, 2) . ' MB';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Registrar comando WP-CLI
|
||||
|
||||
352
Shared/Infrastructure/CLI/CleanThriveContentCommand.php
Normal file
352
Shared/Infrastructure/CLI/CleanThriveContentCommand.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\CLI;
|
||||
|
||||
use WP_CLI;
|
||||
|
||||
/**
|
||||
* Comando WP-CLI para limpiar contenido Thrive congelado de páginas
|
||||
*
|
||||
* LIMPIEZA QUIRÚRGICA CON VALIDACIONES DE SEGURIDAD:
|
||||
* - Elimina H2 con data-shortcode="tcb_post_title"
|
||||
* - Elimina paginación rota ([tcb_pagination_current_page], [tcb_pagination_total_pages])
|
||||
* - PRESERVA todo el demás contenido incluyendo shortcodes [roi_apu_search]
|
||||
* - Verifica que shortcodes importantes NO sean eliminados
|
||||
* - Aborta si se detecta pérdida excesiva de contenido (>50%)
|
||||
*
|
||||
* USO:
|
||||
* wp roi-theme clean_thrive --dry-run # Ver qué se limpiaría (OBLIGATORIO primero)
|
||||
* wp roi-theme clean_thrive # Ejecutar limpieza real
|
||||
*
|
||||
* SEGURIDAD:
|
||||
* - Verifica preservación de shortcodes [roi_apu_search]
|
||||
* - Máximo 50% de reducción de contenido permitida
|
||||
* - Valida cada preg_replace para evitar null returns
|
||||
*/
|
||||
final class CleanThriveContentCommand
|
||||
{
|
||||
/**
|
||||
* IDs de páginas buscar-apus afectadas
|
||||
*/
|
||||
private const AFFECTED_PAGE_IDS = [
|
||||
107264, 107312, 107340, 107345, 107351, 107357, 107362,
|
||||
107369, 107374, 107379, 107384, 107389, 107395, 107399,
|
||||
107403, 107407, 107411, 107416, 107421, 107425, 185752
|
||||
];
|
||||
|
||||
/**
|
||||
* Otras páginas con contenido Thrive (Blog, Curso)
|
||||
*/
|
||||
private const OTHER_AFFECTED_IDS = [252030, 290709];
|
||||
|
||||
/**
|
||||
* Shortcodes que DEBEN ser preservados
|
||||
*/
|
||||
private const PROTECTED_SHORTCODES = [
|
||||
'[roi_apu_search',
|
||||
'[roi_',
|
||||
];
|
||||
|
||||
/**
|
||||
* Máximo porcentaje de contenido que puede eliminarse
|
||||
*/
|
||||
private const MAX_CONTENT_LOSS_PERCENT = 50;
|
||||
|
||||
public function __invoke(array $args, array $assoc_args): void
|
||||
{
|
||||
$dryRun = isset($assoc_args['dry-run']);
|
||||
$includeOthers = isset($assoc_args['include-others']);
|
||||
$force = isset($assoc_args['force']);
|
||||
|
||||
$pageIds = self::AFFECTED_PAGE_IDS;
|
||||
if ($includeOthers) {
|
||||
$pageIds = array_merge($pageIds, self::OTHER_AFFECTED_IDS);
|
||||
}
|
||||
|
||||
WP_CLI::log('');
|
||||
WP_CLI::log('╔══════════════════════════════════════════════════════════════════╗');
|
||||
WP_CLI::log('║ LIMPIEZA QUIRÚRGICA DE CONTENIDO THRIVE CONGELADO (v2.0) ║');
|
||||
WP_CLI::log('║ Con validaciones de seguridad para proteger shortcodes ║');
|
||||
WP_CLI::log('╚══════════════════════════════════════════════════════════════════╝');
|
||||
WP_CLI::log('');
|
||||
|
||||
if ($dryRun) {
|
||||
WP_CLI::warning('MODO DRY-RUN: No se modificará ningún contenido');
|
||||
} else {
|
||||
WP_CLI::error('MODO REAL DESHABILITADO: Ejecuta primero con --dry-run', false);
|
||||
WP_CLI::log('');
|
||||
WP_CLI::log('Para ejecutar la limpieza real, primero revisa el dry-run:');
|
||||
WP_CLI::log(' wp roi-theme clean_thrive --dry-run');
|
||||
WP_CLI::log('');
|
||||
WP_CLI::log('Si el dry-run es correcto y deseas ejecutar:');
|
||||
WP_CLI::log(' wp roi-theme clean_thrive --force');
|
||||
|
||||
if (!$force) {
|
||||
return;
|
||||
}
|
||||
WP_CLI::warning('MODO REAL CON --force: Se modificará el contenido');
|
||||
}
|
||||
|
||||
WP_CLI::log('');
|
||||
WP_CLI::log('Páginas a procesar: ' . count($pageIds));
|
||||
WP_CLI::log('Shortcodes protegidos: ' . implode(', ', self::PROTECTED_SHORTCODES));
|
||||
WP_CLI::log('Máxima pérdida permitida: ' . self::MAX_CONTENT_LOSS_PERCENT . '%');
|
||||
WP_CLI::log('');
|
||||
|
||||
$totalH2Removed = 0;
|
||||
$totalPaginationRemoved = 0;
|
||||
$totalBytesFreed = 0;
|
||||
$pagesModified = 0;
|
||||
$pagesSkipped = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($pageIds as $id) {
|
||||
$page = get_post($id);
|
||||
if (!$page) {
|
||||
WP_CLI::warning("Página {$id} no encontrada, saltando...");
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalContent = $page->post_content;
|
||||
$originalSize = strlen($originalContent);
|
||||
|
||||
// Verificar si tiene contenido Thrive que limpiar
|
||||
$hasThrive = strpos($originalContent, 'tcb_post_title') !== false ||
|
||||
strpos($originalContent, 'tcb_pagination') !== false;
|
||||
|
||||
if (!$hasThrive) {
|
||||
WP_CLI::log(sprintf(
|
||||
"[SIN THRIVE] ID %d: %s",
|
||||
$id,
|
||||
mb_substr($page->post_title, 0, 50)
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Contar elementos antes de limpiar
|
||||
$h2Count = preg_match_all('/<h2[^>]*>.*?data-shortcode="tcb_post_title".*?<\/h2>/s', $originalContent);
|
||||
|
||||
// Contar shortcodes protegidos antes
|
||||
$protectedBefore = $this->countProtectedShortcodes($originalContent);
|
||||
|
||||
// Limpiar contenido con validación
|
||||
$cleanResult = $this->cleanContentSafely($originalContent);
|
||||
|
||||
if ($cleanResult['error']) {
|
||||
$errors[] = "ID {$id}: {$cleanResult['error']}";
|
||||
WP_CLI::error(sprintf(
|
||||
"[ERROR] ID %d: %s - %s",
|
||||
$id,
|
||||
mb_substr($page->post_title, 0, 40),
|
||||
$cleanResult['error']
|
||||
), false);
|
||||
$pagesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$cleanedContent = $cleanResult['content'];
|
||||
$newSize = strlen($cleanedContent);
|
||||
|
||||
// Contar shortcodes protegidos después
|
||||
$protectedAfter = $this->countProtectedShortcodes($cleanedContent);
|
||||
|
||||
// VALIDACIÓN CRÍTICA: Verificar shortcodes protegidos
|
||||
if ($protectedAfter < $protectedBefore) {
|
||||
$errors[] = "ID {$id}: Se perderían shortcodes protegidos ({$protectedBefore} → {$protectedAfter})";
|
||||
WP_CLI::error(sprintf(
|
||||
"[ABORTADO] ID %d: Se perderían shortcodes protegidos (%d → %d)",
|
||||
$id,
|
||||
$protectedBefore,
|
||||
$protectedAfter
|
||||
), false);
|
||||
$pagesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verificar pérdida excesiva de contenido
|
||||
$lossPercent = $originalSize > 0 ? (($originalSize - $newSize) / $originalSize) * 100 : 0;
|
||||
if ($lossPercent > self::MAX_CONTENT_LOSS_PERCENT) {
|
||||
$errors[] = "ID {$id}: Pérdida excesiva de contenido ({$lossPercent}%)";
|
||||
WP_CLI::error(sprintf(
|
||||
"[ABORTADO] ID %d: Pérdida excesiva %.1f%% (máx %d%%)",
|
||||
$id,
|
||||
$lossPercent,
|
||||
self::MAX_CONTENT_LOSS_PERCENT
|
||||
), false);
|
||||
$pagesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Verificar si hubo cambios
|
||||
$hasChanges = $originalContent !== $cleanedContent;
|
||||
$bytesSaved = $originalSize - $newSize;
|
||||
|
||||
// Contar paginación removida
|
||||
$paginationRemoved = (
|
||||
strpos($originalContent, 'tcb_pagination_current_page') !== false &&
|
||||
strpos($cleanedContent, 'tcb_pagination_current_page') === false
|
||||
) ? 1 : 0;
|
||||
|
||||
if ($hasChanges) {
|
||||
$pagesModified++;
|
||||
$totalH2Removed += $h2Count;
|
||||
$totalPaginationRemoved += $paginationRemoved;
|
||||
$totalBytesFreed += $bytesSaved;
|
||||
|
||||
$status = $dryRun ? '[DRY-RUN]' : '[LIMPIADO]';
|
||||
WP_CLI::log(sprintf(
|
||||
"%s ID %d: %s",
|
||||
$status,
|
||||
$id,
|
||||
mb_substr($page->post_title, 0, 50) . (mb_strlen($page->post_title) > 50 ? '...' : '')
|
||||
));
|
||||
WP_CLI::log(sprintf(
|
||||
" → H2 eliminados: %d | Paginación: %s | Pérdida: %.1f%%",
|
||||
$h2Count,
|
||||
$paginationRemoved ? 'Sí' : 'No',
|
||||
$lossPercent
|
||||
));
|
||||
WP_CLI::log(sprintf(
|
||||
" → Shortcodes [roi_*] preservados: %d | Bytes liberados: %s",
|
||||
$protectedAfter,
|
||||
$this->formatBytes($bytesSaved)
|
||||
));
|
||||
|
||||
if (!$dryRun && $force) {
|
||||
wp_update_post([
|
||||
'ID' => $id,
|
||||
'post_content' => $cleanedContent
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
WP_CLI::log(sprintf(
|
||||
"[SIN CAMBIOS] ID %d: %s",
|
||||
$id,
|
||||
mb_substr($page->post_title, 0, 50)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
WP_CLI::log('');
|
||||
WP_CLI::log('════════════════════════════════════════════════════════════════════');
|
||||
WP_CLI::log('RESUMEN:');
|
||||
WP_CLI::log(sprintf(' Páginas modificadas: %d', $pagesModified));
|
||||
WP_CLI::log(sprintf(' Páginas omitidas: %d', $pagesSkipped));
|
||||
WP_CLI::log(sprintf(' Total H2 eliminados: %d', $totalH2Removed));
|
||||
WP_CLI::log(sprintf(' Paginaciones removidas: %d', $totalPaginationRemoved));
|
||||
WP_CLI::log(sprintf(' Espacio liberado: %s', $this->formatBytes($totalBytesFreed)));
|
||||
WP_CLI::log('════════════════════════════════════════════════════════════════════');
|
||||
|
||||
if (count($errors) > 0) {
|
||||
WP_CLI::log('');
|
||||
WP_CLI::warning('ERRORES ENCONTRADOS:');
|
||||
foreach ($errors as $error) {
|
||||
WP_CLI::log(" - {$error}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun && $pagesModified > 0 && count($errors) === 0) {
|
||||
WP_CLI::log('');
|
||||
WP_CLI::success('Dry-run completado SIN errores.');
|
||||
WP_CLI::log('');
|
||||
WP_CLI::warning('Para ejecutar la limpieza real:');
|
||||
WP_CLI::log(' wp roi-theme clean_thrive --force');
|
||||
} elseif (!$dryRun && $force && $pagesModified > 0) {
|
||||
WP_CLI::log('');
|
||||
WP_CLI::success('Limpieza completada exitosamente.');
|
||||
WP_CLI::log('');
|
||||
WP_CLI::warning('IMPORTANTE: Purga el caché del sitio para ver los cambios.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el contenido con validaciones de seguridad
|
||||
*
|
||||
* @return array{content: string, error: string|null}
|
||||
*/
|
||||
private function cleanContentSafely(string $content): array
|
||||
{
|
||||
$originalContent = $content;
|
||||
|
||||
// 1. Eliminar H2 con data-shortcode="tcb_post_title"
|
||||
$result = preg_replace(
|
||||
'/<h2[^>]*>.*?data-shortcode="tcb_post_title".*?<\/h2>/s',
|
||||
'',
|
||||
$content
|
||||
);
|
||||
if ($result === null) {
|
||||
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón H2'];
|
||||
}
|
||||
$content = $result;
|
||||
|
||||
// 2. Eliminar paginación Thrive rota
|
||||
$result = preg_replace(
|
||||
'/<p[^>]*>.*?\[tcb_pagination_current_page\].*?\[tcb_pagination_total_pages\].*?<\/p>/s',
|
||||
'',
|
||||
$content
|
||||
);
|
||||
if ($result === null) {
|
||||
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón paginación'];
|
||||
}
|
||||
$content = $result;
|
||||
|
||||
// 3. Eliminar botones de paginación Thrive
|
||||
$result = preg_replace(
|
||||
'/<p[^>]*data-button_layout="[^"]*"[^>]*data-page="[^"]*"[^>]*>.*?<\/p>/s',
|
||||
'',
|
||||
$content
|
||||
);
|
||||
if ($result === null) {
|
||||
return ['content' => $originalContent, 'error' => 'preg_replace falló en patrón botones'];
|
||||
}
|
||||
$content = $result;
|
||||
|
||||
// 4. Eliminar shortcodes Thrive huérfanos
|
||||
$content = str_replace('[tcb_pagination_current_page]', '', $content);
|
||||
$content = str_replace('[tcb_pagination_total_pages]', '', $content);
|
||||
|
||||
// 5. Limpiar múltiples líneas vacías (con validación)
|
||||
$result = preg_replace('/(\r?\n){3,}/', "\n\n", $content);
|
||||
if ($result === null) {
|
||||
return ['content' => $originalContent, 'error' => 'preg_replace falló en limpieza líneas'];
|
||||
}
|
||||
$content = $result;
|
||||
|
||||
// 6. Trim
|
||||
$content = trim($content);
|
||||
|
||||
// Validación final: no retornar vacío si original tenía contenido
|
||||
if (empty($content) && !empty($originalContent)) {
|
||||
return ['content' => $originalContent, 'error' => 'El contenido quedó vacío'];
|
||||
}
|
||||
|
||||
return ['content' => $content, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuenta shortcodes protegidos en el contenido
|
||||
*/
|
||||
private function countProtectedShortcodes(string $content): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach (self::PROTECTED_SHORTCODES as $shortcode) {
|
||||
$count += substr_count($content, $shortcode);
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea bytes a formato legible
|
||||
*/
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes < 1024) {
|
||||
return $bytes . ' B';
|
||||
} elseif ($bytes < 1048576) {
|
||||
return round($bytes / 1024, 1) . ' KB';
|
||||
} else {
|
||||
return round($bytes / 1048576, 2) . ' MB';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,26 @@ use ROITheme\Shared\Infrastructure\Services\CriticalCSSCollector;
|
||||
use ROITheme\Shared\Application\UseCases\GetComponentSettings\GetComponentSettingsUseCase;
|
||||
use ROITheme\Shared\Application\UseCases\SaveComponentSettings\SaveComponentSettingsUseCase;
|
||||
use ROITheme\Public\AdsensePlacement\Infrastructure\Ui\AdsensePlacementRenderer;
|
||||
use ROITheme\Shared\Domain\Contracts\PageVisibilityRepositoryInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\PageTypeDetectorInterface;
|
||||
use ROITheme\Shared\Infrastructure\Services\WordPressPageTypeDetector;
|
||||
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressPageVisibilityRepository;
|
||||
use ROITheme\Shared\Application\UseCases\EvaluatePageVisibility\EvaluatePageVisibilityUseCase;
|
||||
use ROITheme\Shared\Infrastructure\Services\MigratePageVisibilityService;
|
||||
// Exclusion System (Plan 99.11)
|
||||
use ROITheme\Shared\Domain\Contracts\ExclusionRepositoryInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\PageContextProviderInterface;
|
||||
use ROITheme\Shared\Domain\Contracts\ServerRequestProviderInterface;
|
||||
use ROITheme\Shared\Infrastructure\Persistence\WordPress\WordPressExclusionRepository;
|
||||
use ROITheme\Shared\Infrastructure\Services\WordPressPageContextProvider;
|
||||
use ROITheme\Shared\Infrastructure\Services\WordPressServerRequestProvider;
|
||||
use ROITheme\Shared\Application\UseCases\EvaluateExclusions\EvaluateExclusionsUseCase;
|
||||
use ROITheme\Shared\Application\UseCases\EvaluateComponentVisibility\EvaluateComponentVisibilityUseCase;
|
||||
// Wrapper Visibility System (Plan 99.15)
|
||||
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;
|
||||
|
||||
/**
|
||||
* DIContainer - Contenedor de Inyección de Dependencias
|
||||
@@ -46,10 +66,38 @@ final class DIContainer
|
||||
{
|
||||
private array $instances = [];
|
||||
|
||||
/**
|
||||
* Instancia singleton del contenedor
|
||||
* @var self|null
|
||||
*/
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* Obtiene la instancia singleton del contenedor
|
||||
*
|
||||
* NOTA: Se debe haber creado una instancia previamente en functions.php
|
||||
* El constructor registra automáticamente la instancia.
|
||||
*
|
||||
* @return self
|
||||
* @throws \RuntimeException Si no se ha inicializado el contenedor
|
||||
*/
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
throw new \RuntimeException(
|
||||
'DIContainer no ha sido inicializado. Asegúrate de que functions.php se haya ejecutado primero.'
|
||||
);
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private \wpdb $wpdb,
|
||||
private string $schemasPath
|
||||
) {}
|
||||
) {
|
||||
// Registrar como instancia singleton
|
||||
self::$instance = $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener repositorio de componentes
|
||||
@@ -272,4 +320,177 @@ final class DIContainer
|
||||
|
||||
return $this->instances['criticalCSSCollector'];
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Page Visibility System
|
||||
// ===============================
|
||||
|
||||
/**
|
||||
* Obtiene el repositorio de visibilidad de página
|
||||
*
|
||||
* IMPORTANTE: Inyecta $wpdb para consistencia con el resto del código
|
||||
* (WordPressComponentSettingsRepository también recibe $wpdb por constructor)
|
||||
*/
|
||||
public function getPageVisibilityRepository(): PageVisibilityRepositoryInterface
|
||||
{
|
||||
if (!isset($this->instances['pageVisibilityRepository'])) {
|
||||
// Inyectar $wpdb siguiendo el patrón existente
|
||||
$this->instances['pageVisibilityRepository'] = new WordPressPageVisibilityRepository($this->wpdb);
|
||||
}
|
||||
return $this->instances['pageVisibilityRepository'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el detector de tipo de página
|
||||
*/
|
||||
public function getPageTypeDetector(): PageTypeDetectorInterface
|
||||
{
|
||||
if (!isset($this->instances['pageTypeDetector'])) {
|
||||
$this->instances['pageTypeDetector'] = new WordPressPageTypeDetector();
|
||||
}
|
||||
return $this->instances['pageTypeDetector'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el caso de uso de evaluación de visibilidad
|
||||
*/
|
||||
public function getEvaluatePageVisibilityUseCase(): EvaluatePageVisibilityUseCase
|
||||
{
|
||||
if (!isset($this->instances['evaluatePageVisibilityUseCase'])) {
|
||||
$this->instances['evaluatePageVisibilityUseCase'] = new EvaluatePageVisibilityUseCase(
|
||||
$this->getPageTypeDetector(),
|
||||
$this->getPageVisibilityRepository()
|
||||
);
|
||||
}
|
||||
return $this->instances['evaluatePageVisibilityUseCase'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el servicio de migración de visibilidad
|
||||
*/
|
||||
public function getMigratePageVisibilityService(): MigratePageVisibilityService
|
||||
{
|
||||
if (!isset($this->instances['migratePageVisibilityService'])) {
|
||||
$this->instances['migratePageVisibilityService'] = new MigratePageVisibilityService(
|
||||
$this->getPageVisibilityRepository()
|
||||
);
|
||||
}
|
||||
return $this->instances['migratePageVisibilityService'];
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Exclusion System (Plan 99.11)
|
||||
// ===============================
|
||||
|
||||
/**
|
||||
* Obtiene el proveedor de request HTTP
|
||||
*
|
||||
* Encapsula acceso a $_SERVER
|
||||
*/
|
||||
public function getServerRequestProvider(): ServerRequestProviderInterface
|
||||
{
|
||||
if (!isset($this->instances['serverRequestProvider'])) {
|
||||
$this->instances['serverRequestProvider'] = new WordPressServerRequestProvider();
|
||||
}
|
||||
return $this->instances['serverRequestProvider'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el repositorio de exclusiones
|
||||
*/
|
||||
public function getExclusionRepository(): ExclusionRepositoryInterface
|
||||
{
|
||||
if (!isset($this->instances['exclusionRepository'])) {
|
||||
$this->instances['exclusionRepository'] = new WordPressExclusionRepository($this->wpdb);
|
||||
}
|
||||
return $this->instances['exclusionRepository'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el proveedor de contexto de página
|
||||
*/
|
||||
public function getPageContextProvider(): PageContextProviderInterface
|
||||
{
|
||||
if (!isset($this->instances['pageContextProvider'])) {
|
||||
$this->instances['pageContextProvider'] = new WordPressPageContextProvider(
|
||||
$this->getServerRequestProvider()
|
||||
);
|
||||
}
|
||||
return $this->instances['pageContextProvider'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el caso de uso de evaluación de exclusiones
|
||||
*/
|
||||
public function getEvaluateExclusionsUseCase(): EvaluateExclusionsUseCase
|
||||
{
|
||||
if (!isset($this->instances['evaluateExclusionsUseCase'])) {
|
||||
$this->instances['evaluateExclusionsUseCase'] = new EvaluateExclusionsUseCase(
|
||||
$this->getExclusionRepository(),
|
||||
$this->getPageContextProvider()
|
||||
);
|
||||
}
|
||||
return $this->instances['evaluateExclusionsUseCase'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el caso de uso orquestador de visibilidad completa
|
||||
*
|
||||
* Combina visibilidad por tipo de página + exclusiones
|
||||
*/
|
||||
public function getEvaluateComponentVisibilityUseCase(): EvaluateComponentVisibilityUseCase
|
||||
{
|
||||
if (!isset($this->instances['evaluateComponentVisibilityUseCase'])) {
|
||||
$this->instances['evaluateComponentVisibilityUseCase'] = new EvaluateComponentVisibilityUseCase(
|
||||
$this->getEvaluatePageVisibilityUseCase(),
|
||||
$this->getEvaluateExclusionsUseCase()
|
||||
);
|
||||
}
|
||||
return $this->instances['evaluateComponentVisibilityUseCase'];
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Wrapper Visibility System (Plan 99.15)
|
||||
// ===============================
|
||||
|
||||
/**
|
||||
* Obtiene el repositorio de visibilidad de wrappers
|
||||
*
|
||||
* Implementa WrapperVisibilityCheckerInterface
|
||||
*/
|
||||
public function getWrapperVisibilityChecker(): WrapperVisibilityCheckerInterface
|
||||
{
|
||||
if (!isset($this->instances['wrapperVisibilityChecker'])) {
|
||||
$this->instances['wrapperVisibilityChecker'] = new WordPressComponentVisibilityRepository($this->wpdb);
|
||||
}
|
||||
return $this->instances['wrapperVisibilityChecker'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el caso de uso para verificar visibilidad de wrappers
|
||||
*
|
||||
* Usado por WrapperVisibilityService para templates
|
||||
*/
|
||||
public function getCheckWrapperVisibilityUseCase(): CheckWrapperVisibilityUseCase
|
||||
{
|
||||
if (!isset($this->instances['checkWrapperVisibilityUseCase'])) {
|
||||
$this->instances['checkWrapperVisibilityUseCase'] = new CheckWrapperVisibilityUseCase(
|
||||
$this->getWrapperVisibilityChecker()
|
||||
);
|
||||
}
|
||||
return $this->instances['checkWrapperVisibilityUseCase'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el registrador de hooks para body_class
|
||||
*
|
||||
* CSS failsafe: Agrega clases cuando componentes están ocultos
|
||||
*/
|
||||
public function getBodyClassHooksRegistrar(): BodyClassHooksRegistrar
|
||||
{
|
||||
if (!isset($this->instances['bodyClassHooksRegistrar'])) {
|
||||
$this->instances['bodyClassHooksRegistrar'] = new BodyClassHooksRegistrar();
|
||||
}
|
||||
return $this->instances['bodyClassHooksRegistrar'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,28 +109,67 @@ final class WordPressComponentSettingsRepository implements ComponentSettingsRep
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Implementa UPSERT: si el registro no existe, lo crea; si existe, lo actualiza.
|
||||
* Esto es necesario para grupos especiales como _page_visibility y _exclusions
|
||||
* que no vienen del schema JSON.
|
||||
*/
|
||||
public function saveFieldValue(string $componentName, string $groupName, string $attributeName, mixed $value): bool
|
||||
{
|
||||
// Serializar valor
|
||||
$serializedValue = $this->serializeValue($value);
|
||||
|
||||
// Intentar actualizar
|
||||
$result = $this->wpdb->update(
|
||||
$this->tableName,
|
||||
['attribute_value' => $serializedValue],
|
||||
[
|
||||
'component_name' => $componentName,
|
||||
'group_name' => $groupName,
|
||||
'attribute_name' => $attributeName
|
||||
],
|
||||
['%s'],
|
||||
['%s', '%s', '%s']
|
||||
);
|
||||
// Verificar si el registro existe
|
||||
$exists = $this->fieldExists($componentName, $groupName, $attributeName);
|
||||
|
||||
if ($exists) {
|
||||
// UPDATE
|
||||
$result = $this->wpdb->update(
|
||||
$this->tableName,
|
||||
['attribute_value' => $serializedValue],
|
||||
[
|
||||
'component_name' => $componentName,
|
||||
'group_name' => $groupName,
|
||||
'attribute_name' => $attributeName
|
||||
],
|
||||
['%s'],
|
||||
['%s', '%s', '%s']
|
||||
);
|
||||
} else {
|
||||
// INSERT - crear nuevo registro
|
||||
$result = $this->wpdb->insert(
|
||||
$this->tableName,
|
||||
[
|
||||
'component_name' => $componentName,
|
||||
'group_name' => $groupName,
|
||||
'attribute_name' => $attributeName,
|
||||
'attribute_value' => $serializedValue
|
||||
],
|
||||
['%s', '%s', '%s', '%s']
|
||||
);
|
||||
}
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un campo existe en la BD
|
||||
*/
|
||||
private function fieldExists(string $componentName, string $groupName, string $attributeName): bool
|
||||
{
|
||||
$sql = $this->wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->tableName}
|
||||
WHERE component_name = %s
|
||||
AND group_name = %s
|
||||
AND attribute_name = %s",
|
||||
$componentName,
|
||||
$groupName,
|
||||
$attributeName
|
||||
);
|
||||
|
||||
return (int) $this->wpdb->get_var($sql) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Persistence\WordPress;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\WrapperVisibilityCheckerInterface;
|
||||
use ROITheme\Shared\Infrastructure\Services\PageVisibilityHelper;
|
||||
|
||||
/**
|
||||
* Implementación de WrapperVisibilityCheckerInterface para WordPress
|
||||
*
|
||||
* Responsabilidad: Consultar BD y evaluar visibilidad de wrappers de componentes
|
||||
*
|
||||
* - Consulta tabla wp_roi_theme_component_settings para is_enabled, show_on_mobile, show_on_desktop
|
||||
* - Delega evaluación de exclusiones a PageVisibilityHelper (DRY)
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Persistence\WordPress
|
||||
* @see Plan 99.15 - Fix Empty Layout Wrappers
|
||||
*/
|
||||
final class WordPressComponentVisibilityRepository implements WrapperVisibilityCheckerInterface
|
||||
{
|
||||
private string $tableName;
|
||||
|
||||
public function __construct(
|
||||
private \wpdb $wpdb
|
||||
) {
|
||||
$this->tableName = $this->wpdb->prefix . 'roi_theme_component_settings';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function isEnabled(string $componentName): bool
|
||||
{
|
||||
$value = $this->getVisibilityAttribute($componentName, 'is_enabled');
|
||||
|
||||
// Si no existe el registro, asumir habilitado por defecto
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->toBool($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public function isVisibleOnDevice(string $componentName, bool $isMobile): bool
|
||||
{
|
||||
$attribute = $isMobile ? 'show_on_mobile' : 'show_on_desktop';
|
||||
$value = $this->getVisibilityAttribute($componentName, $attribute);
|
||||
|
||||
// Si no existe el registro, asumir visible por defecto
|
||||
if ($value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->toBool($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* Delega a PageVisibilityHelper que ya implementa:
|
||||
* - 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
|
||||
{
|
||||
return PageVisibilityHelper::shouldShow($componentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene un atributo del grupo visibility desde la BD
|
||||
*
|
||||
* @param string $componentName
|
||||
* @param string $attributeName
|
||||
* @return string|null
|
||||
*/
|
||||
private function getVisibilityAttribute(string $componentName, string $attributeName): ?string
|
||||
{
|
||||
$sql = $this->wpdb->prepare(
|
||||
"SELECT attribute_value
|
||||
FROM {$this->tableName}
|
||||
WHERE component_name = %s
|
||||
AND group_name = %s
|
||||
AND attribute_name = %s
|
||||
LIMIT 1",
|
||||
$componentName,
|
||||
'visibility',
|
||||
$attributeName
|
||||
);
|
||||
|
||||
$result = $this->wpdb->get_var($sql);
|
||||
|
||||
return $result !== null ? (string) $result : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte string a boolean
|
||||
*
|
||||
* @param string $value
|
||||
* @return bool
|
||||
*/
|
||||
private function toBool(string $value): bool
|
||||
{
|
||||
return $value === '1' || strtolower($value) === 'true';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ROITheme\Shared\Infrastructure\Persistence\WordPress;
|
||||
|
||||
use ROITheme\Shared\Domain\Contracts\ExclusionRepositoryInterface;
|
||||
use ROITheme\Shared\Domain\ValueObjects\ExclusionRuleSet;
|
||||
use ROITheme\Shared\Domain\ValueObjects\CategoryExclusion;
|
||||
use ROITheme\Shared\Domain\ValueObjects\PostIdExclusion;
|
||||
use ROITheme\Shared\Domain\ValueObjects\UrlPatternExclusion;
|
||||
use ROITheme\Shared\Domain\Constants\ExclusionDefaults;
|
||||
|
||||
/**
|
||||
* Implementacion WordPress del repositorio de exclusiones
|
||||
*
|
||||
* Almacena exclusiones en wp_roi_theme_component_settings
|
||||
* con group_name = '_exclusions'
|
||||
*
|
||||
* @package ROITheme\Shared\Infrastructure\Persistence\WordPress
|
||||
*/
|
||||
final class WordPressExclusionRepository implements ExclusionRepositoryInterface
|
||||
{
|
||||
private const TABLE_SUFFIX = 'roi_theme_component_settings';
|
||||
|
||||
public function __construct(
|
||||
private readonly \wpdb $wpdb
|
||||
) {}
|
||||
|
||||
public function getExclusions(string $componentName): ExclusionRuleSet
|
||||
{
|
||||
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
|
||||
$groupName = ExclusionDefaults::GROUP_NAME;
|
||||
|
||||
$results = $this->wpdb->get_results(
|
||||
$this->wpdb->prepare(
|
||||
"SELECT attribute_name, attribute_value
|
||||
FROM {$table}
|
||||
WHERE component_name = %s
|
||||
AND group_name = %s",
|
||||
$componentName,
|
||||
$groupName
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if (empty($results)) {
|
||||
return ExclusionRuleSet::empty($componentName);
|
||||
}
|
||||
|
||||
$data = [];
|
||||
foreach ($results as $row) {
|
||||
$data[$row['attribute_name']] = $row['attribute_value'];
|
||||
}
|
||||
|
||||
return $this->hydrateExclusions($componentName, $data);
|
||||
}
|
||||
|
||||
public function saveExclusions(ExclusionRuleSet $exclusions): void
|
||||
{
|
||||
$componentName = $exclusions->getComponentName();
|
||||
|
||||
$data = [
|
||||
'exclusions_enabled' => $exclusions->isEnabled() ? '1' : '0',
|
||||
'exclude_categories' => $exclusions->getCategoryExclusion()->serialize(),
|
||||
'exclude_post_ids' => $exclusions->getPostIdExclusion()->serialize(),
|
||||
'exclude_url_patterns' => $exclusions->getUrlPatternExclusion()->serialize(),
|
||||
];
|
||||
|
||||
foreach ($data as $field => $value) {
|
||||
$this->upsertField($componentName, $field, $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function hasExclusions(string $componentName): bool
|
||||
{
|
||||
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
|
||||
$groupName = ExclusionDefaults::GROUP_NAME;
|
||||
|
||||
$count = $this->wpdb->get_var($this->wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table}
|
||||
WHERE component_name = %s
|
||||
AND group_name = %s",
|
||||
$componentName,
|
||||
$groupName
|
||||
));
|
||||
|
||||
return (int) $count > 0;
|
||||
}
|
||||
|
||||
private function hydrateExclusions(string $componentName, array $data): ExclusionRuleSet
|
||||
{
|
||||
$enabled = ($data['exclusions_enabled'] ?? '0') === '1';
|
||||
|
||||
$categoryExclusion = CategoryExclusion::fromJson($data['exclude_categories'] ?? '[]');
|
||||
$postIdExclusion = PostIdExclusion::fromJson($data['exclude_post_ids'] ?? '[]');
|
||||
$urlPatternExclusion = UrlPatternExclusion::fromJson($data['exclude_url_patterns'] ?? '[]');
|
||||
|
||||
return new ExclusionRuleSet(
|
||||
$componentName,
|
||||
$enabled,
|
||||
$categoryExclusion,
|
||||
$postIdExclusion,
|
||||
$urlPatternExclusion
|
||||
);
|
||||
}
|
||||
|
||||
private function upsertField(string $componentName, string $field, string $value): void
|
||||
{
|
||||
$table = $this->wpdb->prefix . self::TABLE_SUFFIX;
|
||||
$groupName = ExclusionDefaults::GROUP_NAME;
|
||||
|
||||
$exists = $this->wpdb->get_var($this->wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table}
|
||||
WHERE component_name = %s
|
||||
AND group_name = %s
|
||||
AND attribute_name = %s",
|
||||
$componentName,
|
||||
$groupName,
|
||||
$field
|
||||
));
|
||||
|
||||
if ($exists) {
|
||||
$this->wpdb->update(
|
||||
$table,
|
||||
[
|
||||
'attribute_value' => $value,
|
||||
'updated_at' => current_time('mysql'),
|
||||
],
|
||||
[
|
||||
'component_name' => $componentName,
|
||||
'group_name' => $groupName,
|
||||
'attribute_name' => $field,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$this->wpdb->insert($table, [
|
||||
'component_name' => $componentName,
|
||||
'group_name' => $groupName,
|
||||
'attribute_name' => $field,
|
||||
'attribute_value' => $value,
|
||||
'is_editable' => 1,
|
||||
'created_at' => current_time('mysql'),
|
||||
'updated_at' => current_time('mysql'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user