From f52a395e0d278d46c0297ecaac844caaa8841908 Mon Sep 17 00:00:00 2001 From: FrankZamora Date: Wed, 26 Nov 2025 21:58:14 -0600 Subject: [PATCH] feat(admin): Add theme-settings component for global configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Schemas/theme-settings.json with analytics and custom_code groups - Add ThemeSettingsFormBuilder for Admin Panel UI - Add ThemeSettingsFieldMapper for AJAX field mapping - Add ThemeSettingsRenderer for injecting GA/CSS/JS - Add ThemeSettingsInjector for wp_head/wp_footer hooks - Register component in AdminDashboardRenderer::getComponents() - Register FieldMapper in FieldMapperProvider 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Ui/AdminDashboardRenderer.php | 5 + .../Ui/Assets/Css/admin-dashboard.css | 2 +- .../FieldMapping/FieldMapperProvider.php | 1 + .../FieldMapping/ThemeSettingsFieldMapper.php | 37 +++ .../Ui/ThemeSettingsFormBuilder.php | 190 +++++++++++ .../Services/ThemeSettingsInjector.php | 135 ++++++++ .../Ui/ThemeSettingsRenderer.php | 308 ++++++++++++++++++ schemas/theme-settings.json | 54 +++ 8 files changed, 731 insertions(+), 1 deletion(-) create mode 100644 Admin/ThemeSettings/Infrastructure/FieldMapping/ThemeSettingsFieldMapper.php create mode 100644 Admin/ThemeSettings/Infrastructure/Ui/ThemeSettingsFormBuilder.php create mode 100644 Public/ThemeSettings/Infrastructure/Services/ThemeSettingsInjector.php create mode 100644 Public/ThemeSettings/Infrastructure/Ui/ThemeSettingsRenderer.php create mode 100644 schemas/theme-settings.json diff --git a/Admin/Infrastructure/Ui/AdminDashboardRenderer.php b/Admin/Infrastructure/Ui/AdminDashboardRenderer.php index 600b9a32..27fddea7 100644 --- a/Admin/Infrastructure/Ui/AdminDashboardRenderer.php +++ b/Admin/Infrastructure/Ui/AdminDashboardRenderer.php @@ -106,6 +106,11 @@ final class AdminDashboardRenderer implements DashboardRendererInterface 'label' => 'Footer', 'icon' => 'bi-layout-text-window-reverse', ], + 'theme-settings' => [ + 'id' => 'theme-settings', + 'label' => 'Theme Settings', + 'icon' => 'bi-gear', + ], ]; } diff --git a/Admin/Infrastructure/Ui/Assets/Css/admin-dashboard.css b/Admin/Infrastructure/Ui/Assets/Css/admin-dashboard.css index 22a3d474..1a71e6bc 100644 --- a/Admin/Infrastructure/Ui/Assets/Css/admin-dashboard.css +++ b/Admin/Infrastructure/Ui/Assets/Css/admin-dashboard.css @@ -75,7 +75,7 @@ color: #6c757d; border: none; border-bottom: 3px solid transparent; - padding: 0.3rem 0.4rem; + padding: 0.3rem 0.3rem; font-weight: 600; font-size: 0.9rem; transition: all 0.3s ease; diff --git a/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php b/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php index fc6af168..d32c3e90 100644 --- a/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php +++ b/Admin/Shared/Infrastructure/FieldMapping/FieldMapperProvider.php @@ -31,6 +31,7 @@ final class FieldMapperProvider 'RelatedPost', 'ContactForm', 'Footer', + 'ThemeSettings', ]; public function __construct( diff --git a/Admin/ThemeSettings/Infrastructure/FieldMapping/ThemeSettingsFieldMapper.php b/Admin/ThemeSettings/Infrastructure/FieldMapping/ThemeSettingsFieldMapper.php new file mode 100644 index 00000000..64a2fc5a --- /dev/null +++ b/Admin/ThemeSettings/Infrastructure/FieldMapping/ThemeSettingsFieldMapper.php @@ -0,0 +1,37 @@ + ['group' => 'analytics', 'attribute' => 'ga_tracking_id'], + 'themeSettingsGaAnonymizeIp' => ['group' => 'analytics', 'attribute' => 'ga_anonymize_ip'], + + // Custom Code + 'themeSettingsCustomCss' => ['group' => 'custom_code', 'attribute' => 'custom_css'], + 'themeSettingsCustomJsHeader' => ['group' => 'custom_code', 'attribute' => 'custom_js_header'], + 'themeSettingsCustomJsFooter' => ['group' => 'custom_code', 'attribute' => 'custom_js_footer'], + ]; + } +} diff --git a/Admin/ThemeSettings/Infrastructure/Ui/ThemeSettingsFormBuilder.php b/Admin/ThemeSettings/Infrastructure/Ui/ThemeSettingsFormBuilder.php new file mode 100644 index 00000000..aff3f5ef --- /dev/null +++ b/Admin/ThemeSettings/Infrastructure/Ui/ThemeSettingsFormBuilder.php @@ -0,0 +1,190 @@ +buildHeader($componentId); + + $html .= '
'; + + // Columna izquierda - Analytics + $html .= '
'; + $html .= $this->buildAnalyticsGroup($componentId); + $html .= '
'; + + // Columna derecha - Custom Code + $html .= '
'; + $html .= $this->buildCustomCodeGroup($componentId); + $html .= '
'; + + $html .= '
'; + + return $html; + } + + private function buildHeader(string $componentId): string + { + $html = '
renderer->getFieldValue($componentId, 'analytics', 'ga_tracking_id', ''); + $html .= $this->buildTextInput('themeSettingsGaTrackingId', 'Google Analytics ID', 'bi-bar-chart', $gaTrackingId); + + $html .= '
Formato: G-XXXXXXXXXX o UA-XXXXXXXX-X
'; + + $gaAnonymizeIp = $this->renderer->getFieldValue($componentId, 'analytics', 'ga_anonymize_ip', true); + $html .= $this->buildSwitch('themeSettingsGaAnonymizeIp', 'Anonimizar IP (GDPR)', 'bi-shield-check', $gaAnonymizeIp); + + $html .= '
'; + $html .= ' '; + $html .= ' Recomendado activar para cumplir con GDPR/RGPD'; + $html .= '
'; + + $html .= '
'; + $html .= ''; + + return $html; + } + + private function buildCustomCodeGroup(string $componentId): string + { + $html = '
'; + $html .= '
'; + $html .= '
'; + $html .= ' '; + $html .= ' Codigo Personalizado'; + $html .= '
'; + + $customCss = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_css', ''); + $html .= $this->buildTextareaCode('themeSettingsCustomCss', 'CSS Personalizado', 'bi-filetype-css', $customCss, 'Se inyecta en wp_head. No incluir etiquetas <style>'); + + $customJsHeader = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_header', ''); + $html .= $this->buildTextareaCode('themeSettingsCustomJsHeader', 'JavaScript en Header', 'bi-filetype-js', $customJsHeader, 'Se inyecta en wp_head. No incluir etiquetas <script>'); + + $customJsFooter = $this->renderer->getFieldValue($componentId, 'custom_code', 'custom_js_footer', ''); + $html .= $this->buildTextareaCode('themeSettingsCustomJsFooter', 'JavaScript en Footer', 'bi-filetype-js', $customJsFooter, 'Se inyecta en wp_footer. No incluir etiquetas <script>'); + + $html .= '
'; + $html .= ' '; + $html .= ' Advertencia: El codigo personalizado puede afectar el rendimiento y seguridad del sitio.'; + $html .= '
'; + + $html .= '
'; + $html .= '
'; + + return $html; + } + + // Helper methods + private function buildSwitch(string $id, string $label, string $icon, $value): string + { + $checked = $value === true || $value === '1' || $value === 1 ? 'checked' : ''; + + $html = '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + return $html; + } + + private function buildTextInput(string $id, string $label, string $icon, mixed $value): string + { + $value = $this->normalizeStringValue($value); + + $html = '
'; + $html .= ' '; + $html .= ' '; + $html .= '
'; + + return $html; + } + + private function buildTextareaCode(string $id, string $label, string $icon, mixed $value, string $helpText = ''): string + { + $value = $this->normalizeStringValue($value); + + $html = '
'; + $html .= ' '; + $html .= ' '; + if (!empty($helpText)) { + $html .= '
' . $helpText . '
'; + } + $html .= '
'; + + return $html; + } + + /** + * Normaliza un valor a string para inputs de formulario + */ + private function normalizeStringValue(mixed $value): string + { + if ($value === false) { + return '0'; + } + if ($value === true) { + return '1'; + } + return (string) $value; + } +} diff --git a/Public/ThemeSettings/Infrastructure/Services/ThemeSettingsInjector.php b/Public/ThemeSettings/Infrastructure/Services/ThemeSettingsInjector.php new file mode 100644 index 00000000..59ad85c1 --- /dev/null +++ b/Public/ThemeSettings/Infrastructure/Services/ThemeSettingsInjector.php @@ -0,0 +1,135 @@ +getSettings(); + + if (empty($settings)) { + return; + } + + $content = $this->renderer->renderHeadContent($settings); + + if (!empty($content)) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $content; + } + } catch (\Throwable $e) { + $this->logError('Error injecting head content', $e); + } + } + + /** + * Inyecta contenido en wp_footer + * + * Callback para el hook wp_footer. + * Genera y muestra: Custom JS Footer + * + * @return void + */ + public function injectFooterContent(): void + { + try { + $settings = $this->getSettings(); + + if (empty($settings)) { + return; + } + + $content = $this->renderer->renderFooterContent($settings); + + if (!empty($content)) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $content; + } + } catch (\Throwable $e) { + $this->logError('Error injecting footer content', $e); + } + } + + /** + * Obtiene las configuraciones del componente theme-settings + * + * @return array Configuraciones agrupadas o array vacio si no hay + */ + private function getSettings(): array + { + return $this->repository->getComponentSettings(self::COMPONENT_NAME); + } + + /** + * Registra errores en el log de WordPress + * + * @param string $message Mensaje de error + * @param \Throwable $e Excepcion + * @return void + */ + private function logError(string $message, \Throwable $e): void + { + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log(sprintf( + 'ROI Theme - ThemeSettingsInjector: %s - %s in %s:%d', + $message, + $e->getMessage(), + $e->getFile(), + $e->getLine() + )); + } + } +} diff --git a/Public/ThemeSettings/Infrastructure/Ui/ThemeSettingsRenderer.php b/Public/ThemeSettings/Infrastructure/Ui/ThemeSettingsRenderer.php new file mode 100644 index 00000000..8ce7db6a --- /dev/null +++ b/Public/ThemeSettings/Infrastructure/Ui/ThemeSettingsRenderer.php @@ -0,0 +1,308 @@ +isEnabled($data)) { + return ''; + } + + $output = ''; + + // Google Analytics + $gaOutput = $this->renderGoogleAnalytics($data); + if (!empty($gaOutput)) { + $output .= $gaOutput . "\n"; + } + + // Custom CSS + $cssOutput = $this->renderCustomCSS($data); + if (!empty($cssOutput)) { + $output .= $cssOutput . "\n"; + } + + // Custom JS Header + $jsHeaderOutput = $this->renderCustomJSHeader($data); + if (!empty($jsHeaderOutput)) { + $output .= $jsHeaderOutput . "\n"; + } + + return $output; + } + + /** + * Genera contenido para wp_footer + * + * Incluye: + * - Custom JS Footer (si configurado) + * + * @param array $data Datos del componente desde BD + * @return string Contenido para wp_footer + */ + public function renderFooterContent(array $data): string + { + // Validar visibilidad general + if (!$this->isEnabled($data)) { + return ''; + } + + return $this->renderCustomJSFooter($data); + } + + /** + * Verifica si el componente esta habilitado + * + * NOTA: Theme Settings es un componente de configuracion global + * que siempre esta activo. No tiene grupo visibility. + * Si el usuario no quiere GA o CSS custom, simplemente deja + * los campos vacios. + * + * @param array $data Datos del componente (no usado) + * @return bool Siempre true + */ + private function isEnabled(array $data): bool + { + // Theme Settings siempre esta activo (configuraciones globales) + // Los campos individuales se validan en sus metodos respectivos + return true; + } + + /** + * Genera el script de Google Analytics + * + * @param array $data Datos del componente + * @return string Script de GA o vacio si no configurado + */ + private function renderGoogleAnalytics(array $data): string + { + $trackingId = trim($data['analytics']['ga_tracking_id'] ?? ''); + + if (empty($trackingId)) { + return ''; + } + + // Verificar si GA ya esta cargado por otro plugin + if ($this->isGoogleAnalyticsLoaded()) { + return ''; + } + + $anonymizeIp = ($data['analytics']['ga_anonymize_ip'] ?? true) === true; + + // Detectar tipo de ID (GA4 vs Universal Analytics) + if (strpos($trackingId, 'G-') === 0) { + // Google Analytics 4 + return $this->renderGA4Script($trackingId, $anonymizeIp); + } elseif (strpos($trackingId, 'UA-') === 0) { + // Universal Analytics (legacy) + return $this->renderUniversalAnalyticsScript($trackingId, $anonymizeIp); + } + + return ''; + } + + /** + * Genera script de Google Analytics 4 + * + * @param string $trackingId ID de GA4 (G-XXXXXXXXXX) + * @param bool $anonymizeIp Si anonimizar IP + * @return string Script HTML + */ + private function renderGA4Script(string $trackingId, bool $anonymizeIp): string + { + $config = $anonymizeIp ? "{ 'anonymize_ip': true }" : '{}'; + + return sprintf( + ' + +', + esc_attr($trackingId), + $config + ); + } + + /** + * Genera script de Universal Analytics (legacy) + * + * @param string $trackingId ID de UA (UA-XXXXXXXX-X) + * @param bool $anonymizeIp Si anonimizar IP + * @return string Script HTML + */ + private function renderUniversalAnalyticsScript(string $trackingId, bool $anonymizeIp): string + { + $anonymizeConfig = $anonymizeIp ? "ga('set', 'anonymizeIp', true);" : ''; + + return sprintf( + ' +', + esc_attr($trackingId), + $anonymizeConfig + ); + } + + /** + * Verifica si Google Analytics ya esta cargado + * + * @return bool True si ya esta cargado por otro plugin + */ + private function isGoogleAnalyticsLoaded(): bool + { + // Verificar plugins comunes de GA + if (function_exists('gtag')) { + return true; + } + + // Verificar si MonsterInsights esta activo + if (class_exists('MonsterInsights_Lite') || class_exists('MonsterInsights')) { + return true; + } + + // Verificar si Site Kit de Google esta activo + if (class_exists('Google\Site_Kit\Plugin')) { + return true; + } + + return false; + } + + /** + * Genera el CSS personalizado + * + * @param array $data Datos del componente + * @return string Bloque style o vacio si no hay CSS + */ + private function renderCustomCSS(array $data): string + { + $css = trim($data['custom_code']['custom_css'] ?? ''); + + if (empty($css)) { + return ''; + } + + return sprintf( + ' +', + $css // No escapar CSS - usuario avanzado responsable + ); + } + + /** + * Genera el JavaScript personalizado para header + * + * @param array $data Datos del componente + * @return string Bloque script o vacio si no hay JS + */ + private function renderCustomJSHeader(array $data): string + { + $js = trim($data['custom_code']['custom_js_header'] ?? ''); + + if (empty($js)) { + return ''; + } + + return sprintf( + ' +', + $js // No escapar JS - usuario avanzado responsable + ); + } + + /** + * Genera el JavaScript personalizado para footer + * + * @param array $data Datos del componente + * @return string Bloque script o vacio si no hay JS + */ + private function renderCustomJSFooter(array $data): string + { + $js = trim($data['custom_code']['custom_js_footer'] ?? ''); + + if (empty($js)) { + return ''; + } + + return sprintf( + ' +', + $js // No escapar JS - usuario avanzado responsable + ); + } +} diff --git a/schemas/theme-settings.json b/schemas/theme-settings.json new file mode 100644 index 00000000..a94a16ad --- /dev/null +++ b/schemas/theme-settings.json @@ -0,0 +1,54 @@ +{ + "component_name": "theme-settings", + "version": "1.1.0", + "description": "Configuraciones globales del tema: analytics y codigo personalizado", + "groups": { + "analytics": { + "label": "Analytics", + "priority": 10, + "fields": { + "ga_tracking_id": { + "type": "text", + "label": "Google Analytics ID", + "default": "", + "editable": true, + "description": "ID de seguimiento de Google Analytics (ej: G-XXXXXXXXXX o UA-XXXXXXXX-X)" + }, + "ga_anonymize_ip": { + "type": "boolean", + "label": "Anonimizar IP", + "default": true, + "editable": true, + "description": "Anonimiza las direcciones IP de los visitantes (recomendado para GDPR)" + } + } + }, + "custom_code": { + "label": "Codigo Personalizado", + "priority": 20, + "fields": { + "custom_css": { + "type": "textarea", + "label": "CSS Personalizado", + "default": "", + "editable": true, + "description": "CSS personalizado que se inyecta en wp_head. No incluir etiquetas