feat(admin): Add theme-settings component for global configurations

- 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 <noreply@anthropic.com>
This commit is contained in:
FrankZamora
2025-11-26 21:58:14 -06:00
parent 6e75527157
commit f52a395e0d
8 changed files with 731 additions and 1 deletions

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ThemeSettings\Infrastructure\Services;
use ROITheme\Shared\Domain\Contracts\ComponentSettingsRepositoryInterface;
use ROITheme\Public\ThemeSettings\Infrastructure\Ui\ThemeSettingsRenderer;
/**
* ThemeSettingsInjector
*
* Servicio que inyecta las configuraciones globales del tema
* en los hooks de WordPress (wp_head y wp_footer).
*
* Responsabilidades:
* - Registrar hooks de WordPress
* - Obtener configuracion de theme-settings desde BD
* - Delegar renderizado a ThemeSettingsRenderer
* - Inyectar contenido en los hooks correspondientes
*
* @package ROITheme\Public\ThemeSettings\Infrastructure\Services
*/
final class ThemeSettingsInjector
{
private const COMPONENT_NAME = 'theme-settings';
/**
* @param ComponentSettingsRepositoryInterface $repository Repositorio para leer configuraciones
* @param ThemeSettingsRenderer $renderer Renderer para generar contenido
*/
public function __construct(
private readonly ComponentSettingsRepositoryInterface $repository,
private readonly ThemeSettingsRenderer $renderer
) {}
/**
* Registra los hooks de WordPress para inyeccion
*
* @return void
*/
public function register(): void
{
// Inyectar en wp_head con prioridad alta para GA y CSS
add_action('wp_head', [$this, 'injectHeadContent'], 5);
// Inyectar en wp_footer con prioridad baja (al final)
add_action('wp_footer', [$this, 'injectFooterContent'], 99);
}
/**
* Inyecta contenido en wp_head
*
* Callback para el hook wp_head.
* Genera y muestra: Google Analytics, Custom CSS, Custom JS Header
*
* @return void
*/
public function injectHeadContent(): void
{
try {
$settings = $this->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()
));
}
}
}

View File

@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
namespace ROITheme\Public\ThemeSettings\Infrastructure\Ui;
use ROITheme\Shared\Domain\Contracts\RendererInterface;
use ROITheme\Shared\Domain\Entities\Component;
/**
* ThemeSettingsRenderer
*
* Renderizador del componente Theme Settings.
* A diferencia de otros componentes, no renderiza HTML visual
* sino que genera codigo para inyectar en wp_head y wp_footer.
*
* NOTA: Este es un componente especial que NO requiere:
* - CSSGeneratorInterface (no genera CSS, solo inyecta CSS del usuario)
* - Grupo visibility (siempre esta activo, configuraciones globales)
* - Metodo getVisibilityClasses (no es un componente visual)
*
* Responsabilidades:
* - Generar script de Google Analytics
* - Generar CSS personalizado
* - Generar JavaScript para header
* - Generar JavaScript para footer
*
* @package ROITheme\Public\ThemeSettings\Infrastructure\Ui
*/
final class ThemeSettingsRenderer implements RendererInterface
{
/**
* {@inheritDoc}
*
* Para este componente, render() no se usa directamente.
* Se usan los metodos especificos: renderHeadContent() y renderFooterContent()
*/
public function render(Component $component): string
{
// Este componente no renderiza HTML visual
// Los contenidos se inyectan via hooks wp_head y wp_footer
return '';
}
/**
* {@inheritDoc}
*/
public function supports(string $componentType): bool
{
return $componentType === 'theme-settings';
}
/**
* Genera contenido para wp_head
*
* Incluye:
* - Google Analytics script (si configurado)
* - Custom CSS (si configurado)
* - Custom JS Header (si configurado)
*
* @param array $data Datos del componente desde BD
* @return string Contenido para wp_head
*/
public function renderHeadContent(array $data): string
{
// Validar visibilidad general
if (!$this->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(
'<!-- Google Analytics 4 (ROI Theme) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=%1$s"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag("js", new Date());
gtag("config", "%1$s", %2$s);
</script>',
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(
'<!-- Universal Analytics (ROI Theme) -->
<script>
(function(i,s,o,g,r,a,m){i["GoogleAnalyticsObject"]=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,"script","https://www.google-analytics.com/analytics.js","ga");
ga("create", "%s", "auto");
%s
ga("send", "pageview");
</script>',
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(
'<!-- Custom CSS (ROI Theme) -->
<style id="roi-theme-custom-css">
%s
</style>',
$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(
'<!-- Custom JS Header (ROI Theme) -->
<script id="roi-theme-custom-js-header">
%s
</script>',
$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(
'<!-- Custom JS Footer (ROI Theme) -->
<script id="roi-theme-custom-js-footer">
%s
</script>',
$js // No escapar JS - usuario avanzado responsable
);
}
}