fix(structure): Correct case-sensitivity for Linux compatibility

Rename folders to match PHP PSR-4 autoloading conventions:
- schemas → Schemas
- shared → Shared
- Wordpress → WordPress (in all locations)

Fixes deployment issues on Linux servers where filesystem is case-sensitive.

🤖 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 22:53:34 -06:00
parent a2548ab5c2
commit 90863cd8f5
92 changed files with 0 additions and 0 deletions

View File

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para controlador AJAX
*/
interface AjaxControllerInterface {
// Se implementará completamente en Fase 5
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface CSSGeneratorInterface
*
* Contrato para servicios que generan CSS a partir de configuraciones de componentes.
* Define el comportamiento esperado para la generación de reglas CSS sin depender
* de implementaciones específicas o frameworks.
*
* Responsabilidades:
* - Generar CSS válido a partir de un selector y estilos
* - Formatear reglas CSS correctamente
* - Convertir nombres de propiedades (snake_case → kebab-case)
*
* NO responsable de:
* - Media queries (manejado por Renderer con clases Bootstrap)
* - Visibilidad responsive (manejado por Renderer)
* - Persistencia o caché de CSS
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface CSSGeneratorInterface
{
/**
* Genera una regla CSS completa a partir de un selector y sus estilos.
*
* Convierte un array de estilos en una regla CSS válida y formateada.
* Los nombres de propiedades en snake_case se convierten automáticamente
* a kebab-case según el estándar CSS.
*
* Ejemplo:
* ```php
* $styles = [
* 'background_color' => '#FF8600',
* 'text_color' => '#FFFFFF',
* 'font_size' => '1rem',
* 'padding' => '1rem 0'
* ];
*
* $css = $generator->generate('.navbar', $styles);
*
* // Resultado:
* // .navbar {
* // background-color: #FF8600;
* // color: #FFFFFF;
* // font-size: 1rem;
* // padding: 1rem 0;
* // }
* ```
*
* @param string $selector Selector CSS (ej: '.navbar', '#header', 'body')
* @param array<string, string> $styles Array asociativo de propiedades CSS y sus valores
* Formato: ['property_name' => 'value']
*
* @return string Regla CSS completa y formateada, o string vacío si no hay estilos
*/
public function generate(string $selector, array $styles): string;
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para servicio de cache
*/
interface CacheServiceInterface {
// Se implementará completamente en Fase 5
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para servicio de limpieza
*/
interface CleanupServiceInterface {
// Se implementará completamente en Fase 5
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\ValueObjects\ComponentName;
use ROITheme\Shared\Domain\ValueObjects\ComponentConfiguration;
/**
* ComponentDefaultsRepositoryInterface - Contrato para valores por defecto de componentes
*
* RESPONSABILIDAD: Definir contrato para gestión de configuración por defecto de componentes
*
* PROPÓSITO:
* - Almacenar configuración "factory" de cada tipo de componente
* - Permitir resetear componentes a valores por defecto
* - Proporcionar plantillas para nuevos componentes
*
* CASO DE USO:
* ```php
* // Crear nuevo componente top_bar con valores por defecto
* $defaults = $defaultsRepo->getByName(ComponentName::fromString('top_bar'));
* $component = new Component(
* ComponentName::fromString('top_bar'),
* $defaults // configuración por defecto
* );
*
* // Resetear componente a valores por defecto
* $component = $component->updateConfiguration($defaults);
* ```
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface ComponentDefaultsRepositoryInterface
{
/**
* Obtener configuración por defecto de un componente
*
* @param ComponentName $name Nombre del componente
* @return ComponentConfiguration Configuración por defecto
*/
public function getByName(ComponentName $name): ComponentConfiguration;
/**
* Guardar configuración por defecto para un componente
*
* @param ComponentName $name Nombre del componente
* @param ComponentConfiguration $configuration Configuración por defecto
* @return void
*/
public function save(ComponentName $name, ComponentConfiguration $configuration): void;
/**
* Verificar si existen defaults para un componente
*
* @param ComponentName $name
* @return bool
*/
public function exists(ComponentName $name): bool;
/**
* Obtener todos los defaults
*
* @return array<string, ComponentConfiguration> Array asociativo nombre => configuración
*/
public function findAll(): array;
/**
* Eliminar defaults de un componente
*
* @param ComponentName $name
* @return bool True si se eliminó, false si no existía
*/
public function delete(ComponentName $name): bool;
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para facade ComponentManager
*/
interface ComponentManagerInterface {
// Se implementará completamente en Fase 5
}

View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
use ROITheme\Shared\Domain\Entities\Component;
use ROITheme\Shared\Domain\ValueObjects\ComponentName;
use ROITheme\Shared\Domain\Exceptions\ComponentNotFoundException;
/**
* ComponentRepositoryInterface - Contrato para persistencia de componentes
*
* RESPONSABILIDAD: Definir contrato para operaciones CRUD de componentes
*
* INVERSIÓN DE DEPENDENCIAS:
* - Esta interfaz está en Domain (núcleo)
* - Infrastructure implementa esta interfaz
* - Application depende de esta interfaz (NO de la implementación)
*
* IMPLEMENTACIONES ESPERADAS:
* - WordPressComponentRepository (usa wpdb y tablas WP)
* - InMemoryComponentRepository (para testing)
* - FileSystemComponentRepository (futuro: archivos JSON)
*
* PRINCIPIOS:
* - Métodos retornan entidades de dominio (Component)
* - Parámetros son Value Objects o primitivos
* - Excepciones son de dominio
* - Sin conocimiento de WordPress/BD
*
* USO:
* ```php
* // En Application Layer
* class SaveComponentUseCase {
* public function __construct(
* private ComponentRepositoryInterface $repository
* ) {}
*
* public function execute(SaveComponentRequest $request): void {
* $component = new Component(...);
* $this->repository->save($component);
* }
* }
* ```
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface ComponentRepositoryInterface
{
/**
* Guardar o actualizar un componente
*
* Si el componente ya existe (por nombre), se actualiza.
* Si no existe, se crea.
*
* @param Component $component Componente a guardar
* @return Component Componente guardado (con timestamps actualizados)
*/
public function save(Component $component): Component;
/**
* Buscar componente por nombre
*
* @param ComponentName $name Nombre del componente
* @return Component|null Componente encontrado o null
*/
public function findByName(ComponentName $name): ?Component;
/**
* Obtener componente por nombre (lanza excepción si no existe)
*
* @param ComponentName $name
* @return Component
* @throws ComponentNotFoundException
*/
public function getByName(ComponentName $name): Component;
/**
* Obtener todos los componentes
*
* @return Component[] Array de componentes
*/
public function findAll(): array;
/**
* Obtener componentes habilitados
*
* @return Component[] Array de componentes habilitados
*/
public function findEnabled(): array;
/**
* Verificar si existe un componente con el nombre dado
*
* @param ComponentName $name
* @return bool
*/
public function exists(ComponentName $name): bool;
/**
* Eliminar un componente
*
* @param ComponentName $name Nombre del componente a eliminar
* @return bool True si se eliminó, false si no existía
*/
public function delete(ComponentName $name): bool;
/**
* Obtener cantidad total de componentes
*
* @return int
*/
public function count(): int;
/**
* Obtener componentes por grupo de configuración
*
* Ejemplo: Obtener todos los componentes que tienen configuración de 'content'
*
* @param string $group Grupo de configuración (visibility, content, styles, general)
* @return Component[]
*/
public function findByConfigGroup(string $group): array;
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para el repositorio de configuraciones de componentes
*
* Domain Layer - Define el contrato para acceso a datos de configuraci<63>n
* Trabaja con la tabla wp_roi_theme_component_settings (normalizada)
*
* Esta interfaz es gen<65>rica y funciona para todos los componentes del tema.
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface ComponentSettingsRepositoryInterface
{
/**
* Obtiene todas las configuraciones de un componente agrupadas por grupo
*
* @param string $componentName Nombre del componente (ej: 'top-notification-bar')
* @return array<string, array<string, mixed>> Configuraciones agrupadas
* Ejemplo: [
* 'visibility' => ['enabled' => true, 'show_on_mobile' => true, ...],
* 'content' => ['icon_class' => 'bi-star', 'label_text' => 'Nuevo', ...],
* 'styles' => ['background_color' => '#0E2337', ...]
* ]
*/
public function getComponentSettings(string $componentName): array;
/**
* Guarda las configuraciones de un componente
*
* @param string $componentName Nombre del componente
* @param array<string, array<string, mixed>> $settings Configuraciones agrupadas
* Ejemplo: [
* 'visibility' => ['enabled' => true],
* 'content' => ['icon_class' => 'bi-star']
* ]
* @return int N<>mero de campos actualizados
*/
public function saveComponentSettings(string $componentName, array $settings): int;
/**
* Obtiene el valor de un campo espec<65>fico
*
* @param string $componentName Nombre del componente
* @param string $groupName Nombre del grupo
* @param string $attributeName Nombre del atributo
* @return mixed|null Valor del campo o null si no existe
*/
public function getFieldValue(string $componentName, string $groupName, string $attributeName): mixed;
/**
* Guarda el valor de un campo espec<65>fico
*
* @param string $componentName Nombre del componente
* @param string $groupName Nombre del grupo
* @param string $attributeName Nombre del atributo
* @param mixed $value Valor a guardar
* @return bool True si se guard<72> correctamente
*/
public function saveFieldValue(string $componentName, string $groupName, string $attributeName, mixed $value): bool;
/**
* Restaura un componente a sus valores por defecto desde el schema JSON
*
* @param string $componentName Nombre del componente
* @param string $schemaPath Ruta al archivo schema JSON
* @return int N<>mero de campos restaurados
*/
public function resetToDefaults(string $componentName, string $schemaPath): int;
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para servicio de configuración
*/
interface ConfigurationServiceInterface {
// Se implementará completamente en Fase 5
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para repositorio de defaults
*/
interface DefaultRepositoryInterface {
// Se implementará completamente en Fase 5
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
use ROITheme\Shared\Domain\Entities\Component;
/**
* RendererInterface - Contrato para renderizadores de componentes
*
* RESPONSABILIDAD: Definir el contrato que deben cumplir todos los renderizadores
* de componentes para generar HTML a partir de los datos del componente.
*
* PRINCIPIOS:
* - Interface Segregation: Una sola responsabilidad - renderizar HTML
* - Dependency Inversion: Depender de abstracción, no de implementación
*
* USO:
* ```php
* final class MyRenderer implements RendererInterface
* {
* public function render(Component $component): string
* {
* $data = $component->getData();
* // Generar HTML
* return $html;
* }
*
* public function supports(string $componentType): bool
* {
* return $componentType === 'my-component';
* }
* }
* ```
*
* @package ROITheme\Domain\Component
*/
interface RendererInterface
{
/**
* Renderizar un componente a HTML
*
* @param Component $component Componente a renderizar
* @return string HTML generado
*/
public function render(Component $component): string;
/**
* Verificar si este renderizador soporta un tipo de componente
*
* @param string $componentType Tipo de componente (ej: 'navbar', 'footer')
* @return bool True si soporta el tipo, false en caso contrario
*/
public function supports(string $componentType): bool;
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para servicio de sincronización de schemas
*/
interface SchemaSyncServiceInterface {
// Se implementará completamente en Fase 5
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* Interface para gestión de transacciones de base de datos
*/
interface TransactionManagerInterface {
// Se implementará completamente en Fase 5
}

View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Contracts;
/**
* ValidationServiceInterface - Contrato para servicio de validación
*
* RESPONSABILIDAD: Definir contrato para validación de datos de entrada
*
* PROPÓSITO:
* - Validar datos antes de crear entidades de dominio
* - Sanitizar entrada del usuario
* - Retornar errores de validación estructurados
*
* UBICACIÓN EN ARQUITECTURA:
* - Interfaz definida en Domain
* - Implementación en Infrastructure (puede usar WordPress functions)
* - Usado por Application Layer (Use Cases)
*
* IMPLEMENTACIONES ESPERADAS:
* - WordPressValidationService (usa sanitize_*, wp_kses, etc.)
* - StrictValidationService (validación más estricta para prod)
* - LenientValidationService (validación más permisiva para dev)
*
* USO:
* ```php
* // En Application Layer
* class SaveComponentUseCase {
* public function __construct(
* private ValidationServiceInterface $validator
* ) {}
*
* public function execute(SaveComponentRequest $request): void {
* $result = $this->validator->validate(
* $request->getData(),
* $request->getComponentName()
* );
*
* if (!$result->isValid()) {
* throw new ValidationException($result->getErrors());
* }
*
* // ... crear componente con datos validados
* }
* }
* ```
*
* @package ROITheme\Shared\Domain\Contracts
*/
interface ValidationServiceInterface
{
/**
* Validar datos de un componente
*
* @param array $data Datos a validar
* @param string $componentName Nombre del componente (para reglas específicas)
* @return ValidationResult Resultado de validación
*/
public function validate(array $data, string $componentName): ValidationResult;
/**
* Sanitizar datos de entrada
*
* @param array $data Datos a sanitizar
* @param string $componentName Nombre del componente
* @return array Datos sanitizados
*/
public function sanitize(array $data, string $componentName): array;
/**
* Validar una URL
*
* @param string $url URL a validar
* @return bool
*/
public function isValidUrl(string $url): bool;
/**
* Validar un color hexadecimal
*
* @param string $color Color a validar (ej: #000000)
* @return bool
*/
public function isValidColor(string $color): bool;
/**
* Validar nombre de componente
*
* @param string $name Nombre a validar
* @return bool
*/
public function isValidComponentName(string $name): bool;
}
/**
* ValidationResult - Value Object para resultado de validación
*
* RESPONSABILIDAD: Encapsular resultado de una validación
*
* @package ROITheme\Shared\Domain\Contracts
*/
final readonly class ValidationResult
{
/**
* Constructor
*
* @param bool $isValid Si la validación pasó
* @param array $errors Array de errores (campo => mensaje)
* @param array $sanitizedData Datos sanitizados
*/
public function __construct(
private bool $isValid,
private array $errors = [],
private array $sanitizedData = []
) {}
/**
* Verificar si la validación pasó
*
* @return bool
*/
public function isValid(): bool
{
return $this->isValid;
}
/**
* Obtener errores de validación
*
* @return array Array asociativo campo => mensaje
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Obtener datos sanitizados
*
* @return array
*/
public function getSanitizedData(): array
{
return $this->sanitizedData;
}
/**
* Verificar si hay error en campo específico
*
* @param string $field
* @return bool
*/
public function hasError(string $field): bool
{
return isset($this->errors[$field]);
}
/**
* Obtener error de un campo específico
*
* @param string $field
* @return string|null
*/
public function getError(string $field): ?string
{
return $this->errors[$field] ?? null;
}
/**
* Factory: Resultado exitoso
*
* @param array $sanitizedData
* @return self
*/
public static function success(array $sanitizedData): self
{
return new self(
isValid: true,
errors: [],
sanitizedData: $sanitizedData
);
}
/**
* Factory: Resultado con errores
*
* @param array $errors
* @return self
*/
public static function failure(array $errors): self
{
return new self(
isValid: false,
errors: $errors,
sanitizedData: []
);
}
}

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Entities;
use ROITheme\Shared\Domain\ValueObjects\ComponentName;
use ROITheme\Shared\Domain\ValueObjects\ComponentConfiguration;
use ROITheme\Shared\Domain\ValueObjects\ComponentVisibility;
use ROITheme\Shared\Domain\Exceptions\InvalidComponentException;
/**
* Component - Entidad del Dominio
*
* RESPONSABILIDAD: Representar un componente del tema con toda su lógica de negocio
*
* INVARIANTES (Reglas que SIEMPRE deben cumplirse):
* 1. Un componente siempre tiene un nombre válido
* 2. Un componente siempre tiene configuración (puede estar vacía)
* 3. Si tiene CTA URL debe tener CTA text (validado en ComponentConfiguration)
* 4. Los colores siempre están en formato hexadecimal válido
* 5. El timestamp de updated_at es siempre >= created_at
*
* ENTIDAD vs VALUE OBJECT:
* - Component es ENTIDAD porque tiene identidad (puede cambiar configuración pero sigue siendo el mismo componente)
* - ComponentName es VALUE OBJECT (dos nombres iguales son indistinguibles)
* - ComponentConfiguration es VALUE OBJECT (inmutable)
*
* USO:
* ```php
* $component = new Component(
* ComponentName::fromString('top_bar'),
* ComponentConfiguration::fromFlat([
* 'enabled' => true,
* 'message_text' => 'Welcome!'
* ]),
* ComponentVisibility::allDevices()
* );
*
* // Actualizar configuración (inmutabilidad)
* $updated = $component->updateConfiguration(
* $component->configuration()->withValue('content', 'message_text', 'Hello!')
* );
* ```
*
* @package ROITheme\Shared\Domain\Entities
*/
final class Component
{
/**
* @var ComponentName Nombre del componente (inmutable)
*/
private ComponentName $name;
/**
* @var ComponentConfiguration Configuración del componente
*/
private ComponentConfiguration $configuration;
/**
* @var ComponentVisibility Visibilidad del componente
*/
private ComponentVisibility $visibility;
/**
* @var bool Componente habilitado
*/
private bool $isEnabled;
/**
* @var string Versión del schema
*/
private string $schemaVersion;
/**
* @var \DateTimeImmutable Fecha de creación
*/
private \DateTimeImmutable $createdAt;
/**
* @var \DateTimeImmutable Fecha de última actualización
*/
private \DateTimeImmutable $updatedAt;
/**
* Constructor
*
* @param ComponentName $name
* @param ComponentConfiguration $configuration
* @param ComponentVisibility $visibility
* @param bool $isEnabled
* @param string $schemaVersion
* @param \DateTimeImmutable|null $createdAt
* @param \DateTimeImmutable|null $updatedAt
* @throws InvalidComponentException Si se violan invariantes
*/
public function __construct(
ComponentName $name,
ComponentConfiguration $configuration,
ComponentVisibility $visibility,
bool $isEnabled = true,
string $schemaVersion = '1.0.0',
?\DateTimeImmutable $createdAt = null,
?\DateTimeImmutable $updatedAt = null
) {
$this->name = $name;
$this->configuration = $configuration;
$this->visibility = $visibility;
$this->isEnabled = $isEnabled;
$this->schemaVersion = $schemaVersion;
$this->createdAt = $createdAt ?? new \DateTimeImmutable();
$this->updatedAt = $updatedAt ?? new \DateTimeImmutable();
$this->validateInvariants();
}
/**
* Obtener nombre del componente
*
* @return ComponentName
*/
public function name(): ComponentName
{
return $this->name;
}
/**
* Obtener configuración del componente
*
* @return ComponentConfiguration
*/
public function configuration(): ComponentConfiguration
{
return $this->configuration;
}
/**
* Obtener visibilidad del componente
*
* @return ComponentVisibility
*/
public function visibility(): ComponentVisibility
{
return $this->visibility;
}
/**
* Verificar si el componente está habilitado
*
* @return bool
*/
public function isEnabled(): bool
{
return $this->isEnabled;
}
/**
* Obtener versión del schema
*
* @return string
*/
public function schemaVersion(): string
{
return $this->schemaVersion;
}
/**
* Obtener fecha de creación
*
* @return \DateTimeImmutable
*/
public function createdAt(): \DateTimeImmutable
{
return $this->createdAt;
}
/**
* Obtener fecha de última actualización
*
* @return \DateTimeImmutable
*/
public function updatedAt(): \DateTimeImmutable
{
return $this->updatedAt;
}
/**
* Crear nuevo componente con configuración actualizada
* (Inmutabilidad: retorna nueva instancia)
*
* @param ComponentConfiguration $configuration Nueva configuración
* @return self Nueva instancia
*/
public function updateConfiguration(ComponentConfiguration $configuration): self
{
return new self(
$this->name,
$configuration,
$this->visibility,
$this->isEnabled,
$this->schemaVersion,
$this->createdAt,
new \DateTimeImmutable()
);
}
/**
* Crear nuevo componente con visibilidad actualizada
* (Inmutabilidad: retorna nueva instancia)
*
* @param ComponentVisibility $visibility Nueva visibilidad
* @return self Nueva instancia
*/
public function updateVisibility(ComponentVisibility $visibility): self
{
return new self(
$this->name,
$this->configuration,
$visibility,
$this->isEnabled,
$this->schemaVersion,
$this->createdAt,
new \DateTimeImmutable()
);
}
/**
* Crear nuevo componente habilitado
* (Inmutabilidad: retorna nueva instancia)
*
* @return self Nueva instancia con isEnabled=true
*/
public function enable(): self
{
return new self(
$this->name,
$this->configuration,
$this->visibility,
true,
$this->schemaVersion,
$this->createdAt,
new \DateTimeImmutable()
);
}
/**
* Crear nuevo componente deshabilitado
* (Inmutabilidad: retorna nueva instancia)
*
* @return self Nueva instancia con isEnabled=false
*/
public function disable(): self
{
return new self(
$this->name,
$this->configuration,
$this->visibility,
false,
$this->schemaVersion,
$this->createdAt,
new \DateTimeImmutable()
);
}
/**
* Comparar con otro componente
* (Dos componentes son iguales si tienen el mismo nombre)
*
* @param Component $other Otro componente
* @return bool True si son el mismo componente
*/
public function equals(Component $other): bool
{
return $this->name->equals($other->name);
}
/**
* Obtener datos del componente para renderizado
* Retorna la configuración completa en formato array
*
* @return array
*/
public function getData(): array
{
return $this->configuration->all();
}
/**
* Convertir a array para serialización
*
* @return array
*/
public function toArray(): array
{
return [
'name' => $this->name->value(),
'configuration' => $this->configuration->all(),
'visibility' => $this->visibility->toArray(),
'is_enabled' => $this->isEnabled,
'schema_version' => $this->schemaVersion,
'created_at' => $this->createdAt->format('Y-m-d H:i:s'),
'updated_at' => $this->updatedAt->format('Y-m-d H:i:s')
];
}
/**
* Convertir a string para debugging
*
* @return string
*/
public function __toString(): string
{
return sprintf(
'Component(%s, enabled=%s, schema=%s)',
$this->name->value(),
$this->isEnabled ? 'true' : 'false',
$this->schemaVersion
);
}
/**
* Validar invariantes del componente
*
* @throws InvalidComponentException Si se viola algún invariante
* @return void
*/
private function validateInvariants(): void
{
// Invariante: updated_at >= created_at
if ($this->updatedAt < $this->createdAt) {
throw new InvalidComponentException(
'Updated timestamp cannot be before created timestamp'
);
}
}
}

View File

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Exceptions;
/**
* ComponentNotFoundException - Excepción de Dominio para componente no encontrado
*
* RESPONSABILIDAD: Representar errores cuando un componente solicitado no existe
*
* CUÁNDO LANZAR:
* - Búsqueda de componente por nombre que no existe en el repositorio
* - Intento de actualizar/eliminar componente inexistente
* - Referencia a componente que fue eliminado
*
* USO:
* ```php
* $component = $repository->findByName($name);
*
* if ($component === null) {
* throw ComponentNotFoundException::withName($name);
* }
* ```
*
* @package ROITheme\Shared\Domain\Exceptions
*/
class ComponentNotFoundException extends \RuntimeException
{
/**
* Constructor
*
* @param string $message Mensaje de error
* @param int $code Código de error (opcional)
* @param \Throwable|null $previous Excepción anterior (opcional)
*/
public function __construct(
string $message,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
/**
* Crear excepción para componente no encontrado por nombre
*
* @param string $componentName
* @return self
*/
public static function withName(string $componentName): self
{
return new self(
sprintf('Component "%s" not found', $componentName)
);
}
/**
* Crear excepción para componente no encontrado por ID
*
* @param int $componentId
* @return self
*/
public static function withId(int $componentId): self
{
return new self(
sprintf('Component with ID %d not found', $componentId)
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\Exceptions;
/**
* InvalidComponentException - Excepción de Dominio para componentes inválidos
*
* RESPONSABILIDAD: Representar errores de validación de reglas de negocio de componentes
*
* CUÁNDO LANZAR:
* - Nombre de componente inválido (formato, longitud)
* - Configuración inválida (CTA URL sin CTA text, colores mal formateados)
* - Visibility inválida (combinaciones no permitidas)
* - Content inválido (URLs mal formateadas, textos vacíos cuando requeridos)
* - Cualquier violación de invariantes del dominio
*
* USO:
* ```php
* if (empty($name)) {
* throw new InvalidComponentException('Component name cannot be empty');
* }
* ```
*
* @package ROITheme\Shared\Domain\Exceptions
*/
class InvalidComponentException extends \DomainException
{
/**
* Constructor
*
* @param string $message Mensaje de error
* @param int $code Código de error (opcional)
* @param \Throwable|null $previous Excepción anterior (opcional)
*/
public function __construct(
string $message,
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
/**
* Crear excepción para nombre inválido
*
* @param string $name
* @param string $reason
* @return self
*/
public static function invalidName(string $name, string $reason): self
{
return new self(
sprintf('Invalid component name "%s": %s', $name, $reason)
);
}
/**
* Crear excepción para configuración inválida
*
* @param string $componentName
* @param string $reason
* @return self
*/
public static function invalidConfiguration(string $componentName, string $reason): self
{
return new self(
sprintf('Invalid configuration for component "%s": %s', $componentName, $reason)
);
}
/**
* Crear excepción para CTA inválido
*
* @param string $componentName
* @return self
*/
public static function ctaUrlRequiresText(string $componentName): self
{
return new self(
sprintf('Component "%s" has CTA URL but missing CTA text', $componentName)
);
}
/**
* Crear excepción para color inválido
*
* @param string $colorKey
* @param string $value
* @return self
*/
public static function invalidColor(string $colorKey, string $value): self
{
return new self(
sprintf('Invalid color format for "%s": "%s". Expected hexadecimal (e.g., #000000)', $colorKey, $value)
);
}
}

127
Shared/Domain/README.md Normal file
View File

@@ -0,0 +1,127 @@
# Capa de Dominio - Shared (Fundación)
## Propósito
La capa de Dominio de `shared/` contiene la **lógica de negocio compartida** que utilizan todos los contextos
(admin y public). Es la fundación del proyecto y no debe tener dependencias de frameworks, librerías externas, o
capas superiores.
## Principios
1. **Sin dependencias externas**: No depende de WordPress, plugins, o librerías
2. **Lógica pura**: Solo reglas de negocio y objetos de dominio
3. **Inmutabilidad**: Los Value Objects son inmutables
4. **Validación**: Los objetos se validan a sí mismos
## Estructura
```
shared/Domain/
├── ValueObjects/ # Value Objects compartidos (ComponentID, SettingValue, etc.)
├── Exceptions/ # Excepciones de dominio (InvalidComponentException, etc.)
└── Contracts/ # Interfaces de repositorios y servicios de dominio
```
## Ejemplos de Uso
### Value Objects
Value Objects que representan conceptos del dominio:
```php
namespace ROITheme\Shared\Domain\ValueObjects;
final class ComponentID
{
private int $value;
public function __construct(int $value)
{
if ($value <= 0) {
throw new \InvalidArgumentException('Component ID must be positive');
}
$this->value = $value;
}
public function value(): int
{
return $this->value;
}
public function equals(ComponentID $other): bool
{
return $this->value === $other->value;
}
}
```
### Excepciones
Excepciones específicas del dominio:
```php
namespace ROITheme\Shared\Domain\Exceptions;
class InvalidComponentException extends \DomainException
{
public static function withId(int $id): self
{
return new self("Component with ID {$id} is invalid");
}
}
```
### Contracts (Interfaces)
Interfaces que definen comportamientos:
```php
namespace ROITheme\Shared\Domain\Contracts;
interface ComponentRepositoryInterface
{
public function findById(ComponentID $id): ?Component;
public function save(Component $component): void;
}
```
## Reglas de Dependencia
**PUEDE** depender de:
- Otros objetos dentro de `shared/Domain/`
- SPL (Standard PHP Library)
- Nada más
**NO PUEDE** depender de:
- `shared/Application/`
- `shared/Infrastructure/`
- `admin/` o `public/`
- WordPress functions
- Librerías externas
## Testing
Los objetos de esta capa se testean con **tests unitarios puros**, sin necesidad de WordPress:
```php
// tests/Unit/Shared/Domain/ValueObjects/ComponentIDTest.php
public function test_creates_valid_component_id()
{
$id = new ComponentID(123);
$this->assertEquals(123, $id->value());
}
```
## Cuándo Agregar Código Aquí
Agrega código a `shared/Domain/` cuando:
- Es lógica de negocio pura (sin WordPress)
- Es compartido por admin/ y public/
- Es un concepto fundamental del dominio
- Necesita alta cohesión y bajo acoplamiento
No agregues aquí:
- Código que depende de WordPress
- Lógica específica de un solo contexto
- Implementaciones concretas de servicios

View File

View File

@@ -0,0 +1,388 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\InvalidComponentException;
/**
* ComponentConfiguration - Value Object inmutable para configuración de componente
*
* RESPONSABILIDAD: Representar la configuración completa de un componente agrupada por categorías
*
* REGLAS DE NEGOCIO:
* - La configuración se organiza en grupos: visibility, content, styles, general
* - Cada grupo contiene pares clave-valor
* - Los valores pueden ser: string, boolean, integer, float, array
* - Si existe cta_url debe existir cta_text (y viceversa)
* - Los colores deben estar en formato hexadecimal válido
*
* INVARIANTES:
* - Una vez creada, la configuración no puede cambiar (inmutable)
* - La estructura de grupos siempre está presente (aunque vacía)
* - Los tipos de datos se preservan correctamente
*
* ESTRUCTURA:
* ```php
* [
* 'visibility' => [
* 'enabled' => true,
* 'visible_desktop' => true,
* 'visible_mobile' => false
* ],
* 'content' => [
* 'message_text' => 'Welcome!',
* 'cta_text' => 'Click here',
* 'cta_url' => 'https://example.com'
* ],
* 'styles' => [
* 'background_color' => '#000000',
* 'text_color' => '#ffffff',
* 'height' => 50
* ],
* 'general' => [
* 'priority' => 10,
* 'version' => '1.0.0'
* ]
* ]
* ```
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final readonly class ComponentConfiguration
{
/**
* Grupos de configuración válidos
*
* Basado en 10.00-flujo-de-trabajo-fuente-de-verdad.md líneas 304-348
* 12 grupos estándar + grupos adicionales en uso
*/
private const VALID_GROUPS = [
// 12 Grupos estándar del flujo de trabajo
'visibility', // Control de visibilidad
'content', // Textos y contenido
'typography', // Estilos de texto y semántica HTML
'colors', // Paleta de colores
'spacing', // Márgenes y padding
'visual_effects', // Efectos visuales decorativos
'behavior', // Comportamiento interactivo
'layout', // Estructura y posicionamiento
'links', // Enlaces y URLs
'icons', // Configuración de íconos
'media', // Multimedia
'forms', // Formularios
// Grupos adicionales/legacy en uso
'styles', // Legacy: combina colors, spacing, visual_effects
'general', // Configuración general del componente
'menu', // Específico para navbar
'categories', // Categorización
'title', // Título del componente
'networks', // Específico para social-share
// Grupos específicos para contact-form
'contact_info', // Info de contacto (teléfono, email, ubicación)
'form_labels', // Labels y placeholders del formulario
'integration', // Configuración de webhook
'messages', // Mensajes de éxito/error/validación
// Grupos específicos para footer
'widget_1', // Widget 1 del footer (menú)
'widget_1b', // Widget 1B del footer (menú secundario columna 1)
'widget_2', // Widget 2 del footer (menú)
'widget_3', // Widget 3 del footer (menú)
'newsletter', // Sección newsletter del footer
'footer_bottom', // Pie del footer (copyright)
];
/**
* @param array $configuration Configuración agrupada
* @throws InvalidComponentException
*/
public function __construct(private array $configuration)
{
$this->validate();
}
/**
* Obtener toda la configuración
*
* @return array
*/
public function all(): array
{
return $this->configuration;
}
/**
* Obtener configuración de un grupo específico
*
* @param string $group Nombre del grupo (visibility, content, styles, general)
* @return array
*/
public function getGroup(string $group): array
{
return $this->configuration[$group] ?? [];
}
/**
* Obtener valor de una clave específica dentro de un grupo
*
* @param string $group Nombre del grupo
* @param string $key Clave de configuración
* @param mixed $default Valor por defecto si no existe
* @return mixed
*/
public function get(string $group, string $key, mixed $default = null): mixed
{
return $this->configuration[$group][$key] ?? $default;
}
/**
* Verificar si existe una clave en un grupo
*
* @param string $group
* @param string $key
* @return bool
*/
public function has(string $group, string $key): bool
{
return isset($this->configuration[$group][$key]);
}
/**
* Verificar si el componente tiene CTA URL
*
* @return bool
*/
public function hasCTAURL(): bool
{
return $this->has('content', 'cta_url') && !empty($this->get('content', 'cta_url'));
}
/**
* Verificar si el componente tiene CTA text
*
* @return bool
*/
public function hasCTAText(): bool
{
return $this->has('content', 'cta_text') && !empty($this->get('content', 'cta_text'));
}
/**
* Verificar si el componente está habilitado
*
* @return bool
*/
public function isEnabled(): bool
{
return (bool) $this->get('visibility', 'enabled', false);
}
/**
* Crear nueva configuración con un valor actualizado
* (Inmutabilidad: retorna nueva instancia)
*
* @param string $group
* @param string $key
* @param mixed $value
* @return self
*/
public function withValue(string $group, string $key, mixed $value): self
{
$newConfiguration = $this->configuration;
if (!isset($newConfiguration[$group])) {
$newConfiguration[$group] = [];
}
$newConfiguration[$group][$key] = $value;
return new self($newConfiguration);
}
/**
* Crear nueva configuración con un grupo actualizado
* (Inmutabilidad: retorna nueva instancia)
*
* @param string $group
* @param array $values
* @return self
*/
public function withGroup(string $group, array $values): self
{
$newConfiguration = $this->configuration;
$newConfiguration[$group] = $values;
return new self($newConfiguration);
}
/**
* Validar reglas de negocio de la configuración
*
* @throws InvalidComponentException
* @return void
*/
private function validate(): void
{
// Regla 1: Debe ser un array
if (!is_array($this->configuration)) {
throw new InvalidComponentException('Configuration must be an array');
}
// Regla 2: Los grupos deben ser válidos
foreach (array_keys($this->configuration) as $group) {
if (!in_array($group, self::VALID_GROUPS, true)) {
throw new InvalidComponentException(
sprintf(
'Invalid configuration group "%s". Valid groups are: %s',
$group,
implode(', ', self::VALID_GROUPS)
)
);
}
}
// Regla 3: Si existe cta_url debe existir cta_text (y viceversa)
if ($this->hasCTAURL() && !$this->hasCTAText()) {
throw new InvalidComponentException('CTA URL requires CTA text');
}
if ($this->hasCTAText() && !$this->hasCTAURL()) {
throw new InvalidComponentException('CTA text requires CTA URL');
}
// Regla 4: Los colores deben ser hexadecimales válidos
$this->validateColors();
}
/**
* Validar que los colores estén en formato hexadecimal
*
* @throws InvalidComponentException
* @return void
*/
private function validateColors(): void
{
$styles = $this->getGroup('styles');
foreach ($styles as $key => $value) {
if (str_ends_with($key, '_color') && !empty($value)) {
if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $value)) {
throw new InvalidComponentException(
sprintf(
'Color "%s" must be in hexadecimal format (e.g., #000000). Got: %s',
$key,
$value
)
);
}
}
}
}
/**
* Crear desde array flat (legacy)
*
* Convierte de:
* ```php
* [
* 'enabled' => true,
* 'message_text' => 'Hello',
* 'background_color' => '#000'
* ]
* ```
*
* A estructura agrupada.
*
* @param array $flatConfig
* @return self
*/
public static function fromFlat(array $flatConfig): self
{
$grouped = [
'visibility' => [],
'content' => [],
'styles' => [],
'general' => []
];
foreach ($flatConfig as $key => $value) {
$group = self::inferGroup($key);
$grouped[$group][$key] = $value;
}
return new self($grouped);
}
/**
* Crear desde array agrupado
*
* Espera estructura ya agrupada:
* ```php
* [
* 'visibility' => ['enabled' => true, ...],
* 'content' => ['message_text' => 'Hello', ...],
* 'styles' => ['background_color' => '#000', ...],
* 'general' => ['priority' => 10, ...]
* ]
* ```
*
* @param array $groupedConfig Configuración ya agrupada
* @return self
*/
public static function fromArray(array $groupedConfig): self
{
// Si ya está agrupado, usarlo directamente
return new self($groupedConfig);
}
/**
* Inferir grupo desde clave (heurística)
*
* @param string $key
* @return string
*/
private static function inferGroup(string $key): string
{
// Visibility
if (in_array($key, ['enabled', 'visible_desktop', 'visible_mobile', 'visible_tablet'], true)) {
return 'visibility';
}
// Content
if (str_starts_with($key, 'message_') ||
str_starts_with($key, 'cta_') ||
str_starts_with($key, 'title_')) {
return 'content';
}
// Styles
if (str_ends_with($key, '_color') ||
str_ends_with($key, '_height') ||
str_ends_with($key, '_width') ||
str_ends_with($key, '_size') ||
str_ends_with($key, '_font')) {
return 'styles';
}
// Fallback
return 'general';
}
/**
* Crear configuración vacía
*
* @return self
*/
public static function empty(): self
{
return new self([
'visibility' => [],
'content' => [],
'styles' => [],
'general' => []
]);
}
}

View File

@@ -0,0 +1,272 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\InvalidComponentException;
/**
* ComponentContent - Value Object inmutable para contenido de componente
*
* RESPONSABILIDAD: Representar y validar el contenido textual y de CTA de un componente
*
* REGLAS DE NEGOCIO:
* - El mensaje de texto puede estar vacío (componentes sin texto)
* - Si existe CTA URL debe existir CTA text (y viceversa)
* - CTA URL debe ser una URL válida (formato http/https)
* - CTA text no puede estar vacío si existe CTA URL
* - Title es opcional
*
* CASOS DE USO:
* - Componente solo con mensaje de texto
* - Componente con mensaje y CTA
* - Componente con title, mensaje y CTA
* - Componente sin contenido textual (solo estilos/visibilidad)
*
* USO:
* ```php
* // Componente con mensaje y CTA
* $content = new ComponentContent(
* messageText: 'Welcome to our site!',
* ctaText: 'Learn More',
* ctaUrl: 'https://example.com/about'
* );
*
* // Componente solo con mensaje
* $content = ComponentContent::messageOnly('Hello World!');
*
* // Componente vacío
* $content = ComponentContent::empty();
* ```
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final readonly class ComponentContent
{
/**
* Constructor
*
* @param string $messageText Texto del mensaje principal
* @param string|null $ctaText Texto del Call-to-Action
* @param string|null $ctaUrl URL del Call-to-Action
* @param string|null $title Título del componente
* @throws InvalidComponentException
*/
public function __construct(
private string $messageText = '',
private ?string $ctaText = null,
private ?string $ctaUrl = null,
private ?string $title = null
) {
$this->validate();
}
/**
* Obtener mensaje de texto
*
* @return string
*/
public function messageText(): string
{
return $this->messageText;
}
/**
* Obtener texto del CTA
*
* @return string|null
*/
public function ctaText(): ?string
{
return $this->ctaText;
}
/**
* Obtener URL del CTA
*
* @return string|null
*/
public function ctaUrl(): ?string
{
return $this->ctaUrl;
}
/**
* Obtener título
*
* @return string|null
*/
public function title(): ?string
{
return $this->title;
}
/**
* Verificar si tiene mensaje de texto
*
* @return bool
*/
public function hasMessage(): bool
{
return !empty($this->messageText);
}
/**
* Verificar si tiene CTA
*
* @return bool
*/
public function hasCTA(): bool
{
return !empty($this->ctaText) && !empty($this->ctaUrl);
}
/**
* Verificar si tiene título
*
* @return bool
*/
public function hasTitle(): bool
{
return !empty($this->title);
}
/**
* Verificar si está completamente vacío
*
* @return bool
*/
public function isEmpty(): bool
{
return empty($this->messageText) &&
empty($this->ctaText) &&
empty($this->ctaUrl) &&
empty($this->title);
}
/**
* Convertir a array
*
* @return array
*/
public function toArray(): array
{
return array_filter([
'message_text' => $this->messageText,
'cta_text' => $this->ctaText,
'cta_url' => $this->ctaUrl,
'title' => $this->title
], fn($value) => $value !== null && $value !== '');
}
/**
* Validar reglas de negocio
*
* @throws InvalidComponentException
* @return void
*/
private function validate(): void
{
// Regla 1: Si existe CTA URL debe existir CTA text
if (!empty($this->ctaUrl) && empty($this->ctaText)) {
throw new InvalidComponentException('CTA URL requires CTA text');
}
// Regla 2: Si existe CTA text debe existir CTA URL
if (!empty($this->ctaText) && empty($this->ctaUrl)) {
throw new InvalidComponentException('CTA text requires CTA URL');
}
// Regla 3: CTA URL debe ser URL válida
if (!empty($this->ctaUrl) && !$this->isValidUrl($this->ctaUrl)) {
throw new InvalidComponentException(
sprintf('Invalid CTA URL format: %s', $this->ctaUrl)
);
}
}
/**
* Validar si una string es URL válida
*
* @param string $url
* @return bool
*/
private function isValidUrl(string $url): bool
{
// Debe comenzar con http:// o https://
if (!preg_match('/^https?:\/\//i', $url)) {
return false;
}
// Usar filter_var para validación completa
return filter_var($url, FILTER_VALIDATE_URL) !== false;
}
/**
* Factory: Contenido vacío
*
* @return self
*/
public static function empty(): self
{
return new self();
}
/**
* Factory: Solo mensaje
*
* @param string $message
* @return self
*/
public static function messageOnly(string $message): self
{
return new self(messageText: $message);
}
/**
* Factory: Mensaje con CTA
*
* @param string $message
* @param string $ctaText
* @param string $ctaUrl
* @return self
*/
public static function withCTA(string $message, string $ctaText, string $ctaUrl): self
{
return new self(
messageText: $message,
ctaText: $ctaText,
ctaUrl: $ctaUrl
);
}
/**
* Crear desde array
*
* @param array $data
* @return self
*/
public static function fromArray(array $data): self
{
return new self(
messageText: $data['message_text'] ?? '',
ctaText: $data['cta_text'] ?? null,
ctaUrl: $data['cta_url'] ?? null,
title: $data['title'] ?? null
);
}
/**
* Comparar con otro ComponentContent
*
* @param ComponentContent $other
* @return bool
*/
public function equals(ComponentContent $other): bool
{
return $this->messageText === $other->messageText &&
$this->ctaText === $other->ctaText &&
$this->ctaUrl === $other->ctaUrl &&
$this->title === $other->title;
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\InvalidComponentException;
/**
* ComponentName - Value Object inmutable para nombre de componente
*
* RESPONSABILIDAD: Representar y validar el nombre de un componente del tema
*
* REGLAS DE NEGOCIO:
* - El nombre no puede estar vacío
* - El nombre debe tener entre 3 y 50 caracteres
* - El nombre solo puede contener letras minúsculas, números, guiones bajos y guiones
* - El nombre debe comenzar con una letra
*
* INVARIANTES:
* - Una vez creado, el nombre no puede cambiar (inmutable)
* - El nombre siempre está en formato normalizado (lowercase, sin espacios)
*
* USO:
* ```php
* $name = new ComponentName('top_bar');
* echo $name->value(); // "top_bar"
* echo $name->toString(); // "top_bar"
* ```
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final readonly class ComponentName
{
/**
* Longitud mínima del nombre
*/
private const MIN_LENGTH = 3;
/**
* Longitud máxima del nombre
*/
private const MAX_LENGTH = 50;
/**
* Patrón regex para validar formato del nombre
* - Debe comenzar con letra minúscula
* - Puede contener letras minúsculas, números, guiones bajos y guiones
*/
private const PATTERN = '/^[a-z][a-z0-9_-]*$/';
/**
* @param string $value Nombre del componente
* @throws InvalidComponentException Si el nombre no cumple las reglas de negocio
*/
public function __construct(private string $value)
{
$this->validate();
}
/**
* Obtener el valor del nombre
*
* @return string
*/
public function value(): string
{
return $this->value;
}
/**
* Convertir a string
*
* @return string
*/
public function toString(): string
{
return $this->value;
}
/**
* Comparar con otro ComponentName
*
* @param ComponentName $other
* @return bool
*/
public function equals(ComponentName $other): bool
{
return $this->value === $other->value;
}
/**
* Validar reglas de negocio del nombre
*
* @throws InvalidComponentException
* @return void
*/
private function validate(): void
{
// Regla 1: No puede estar vacío
if (empty($this->value)) {
throw new InvalidComponentException('Component name cannot be empty');
}
// Regla 2: Longitud entre MIN y MAX
$length = strlen($this->value);
if ($length < self::MIN_LENGTH || $length > self::MAX_LENGTH) {
throw new InvalidComponentException(
sprintf(
'Component name must be between %d and %d characters (got %d)',
self::MIN_LENGTH,
self::MAX_LENGTH,
$length
)
);
}
// Regla 3: Formato válido (lowercase, números, guiones bajos, comienza con letra)
if (!preg_match(self::PATTERN, $this->value)) {
throw new InvalidComponentException(
'Component name must start with a letter and contain only lowercase letters, numbers, underscores, and hyphens'
);
}
}
/**
* Crear desde string (factory method)
*
* @param string $value
* @return self
*/
public static function fromString(string $value): self
{
return new self($value);
}
/**
* Normalizar string a formato de nombre válido
*
* Convierte:
* - "Top Bar" → "top_bar"
* - "FOOTER-CTA" → "footer_cta"
* - " sidebar " → "sidebar"
*
* @param string $value
* @return self
*/
public static function fromNormalized(string $value): self
{
// Trim espacios
$normalized = trim($value);
// Convertir a minúsculas
$normalized = strtolower($normalized);
// Reemplazar espacios y guiones por guiones bajos
$normalized = str_replace([' ', '-'], '_', $normalized);
// Eliminar caracteres no permitidos
$normalized = preg_replace('/[^a-z0-9_]/', '', $normalized);
// Eliminar guiones bajos duplicados
$normalized = preg_replace('/_+/', '_', $normalized);
// Eliminar guiones bajos al inicio/final
$normalized = trim($normalized, '_');
return new self($normalized);
}
}

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Domain\ValueObjects;
use ROITheme\Shared\Domain\Exceptions\InvalidComponentException;
/**
* ComponentVisibility - Value Object inmutable para visibilidad de componente
*
* RESPONSABILIDAD: Representar y validar la visibilidad de un componente en diferentes dispositivos
*
* REGLAS DE NEGOCIO:
* - Un componente puede estar habilitado o deshabilitado globalmente
* - Un componente puede tener visibilidad específica por dispositivo (desktop, tablet, mobile)
* - Si está deshabilitado globalmente, las visibilidades por dispositivo no importan
* - Al menos un dispositivo debe tener visibilidad si está habilitado globalmente
*
* CASOS DE USO:
* - Componente visible solo en desktop
* - Componente visible solo en mobile
* - Componente visible en todos los dispositivos
* - Componente completamente deshabilitado
*
* USO:
* ```php
* // Visible en todos los dispositivos
* $visibility = ComponentVisibility::allDevices();
*
* // Visible solo en desktop
* $visibility = ComponentVisibility::desktopOnly();
*
* // Personalizado
* $visibility = new ComponentVisibility(
* enabled: true,
* visibleDesktop: true,
* visibleTablet: true,
* visibleMobile: false
* );
* ```
*
* @package ROITheme\Shared\Domain\ValueObjects
*/
final readonly class ComponentVisibility
{
/**
* Constructor
*
* @param bool $enabled Componente habilitado globalmente
* @param bool $visibleDesktop Visible en desktop
* @param bool $visibleTablet Visible en tablet
* @param bool $visibleMobile Visible en mobile
* @throws InvalidComponentException
*/
public function __construct(
private bool $enabled,
private bool $visibleDesktop,
private bool $visibleTablet,
private bool $visibleMobile
) {
$this->validate();
}
/**
* Verificar si está habilitado globalmente
*
* @return bool
*/
public function isEnabled(): bool
{
return $this->enabled;
}
/**
* Verificar si es visible en desktop
*
* @return bool
*/
public function isVisibleOnDesktop(): bool
{
return $this->enabled && $this->visibleDesktop;
}
/**
* Verificar si es visible en tablet
*
* @return bool
*/
public function isVisibleOnTablet(): bool
{
return $this->enabled && $this->visibleTablet;
}
/**
* Verificar si es visible en mobile
*
* @return bool
*/
public function isVisibleOnMobile(): bool
{
return $this->enabled && $this->visibleMobile;
}
/**
* Verificar si es visible en al menos un dispositivo
*
* @return bool
*/
public function isVisibleOnAnyDevice(): bool
{
return $this->enabled && (
$this->visibleDesktop ||
$this->visibleTablet ||
$this->visibleMobile
);
}
/**
* Verificar si es visible en todos los dispositivos
*
* @return bool
*/
public function isVisibleOnAllDevices(): bool
{
return $this->enabled &&
$this->visibleDesktop &&
$this->visibleTablet &&
$this->visibleMobile;
}
/**
* Convertir a array
*
* @return array
*/
public function toArray(): array
{
return [
'enabled' => $this->enabled,
'visible_desktop' => $this->visibleDesktop,
'visible_tablet' => $this->visibleTablet,
'visible_mobile' => $this->visibleMobile
];
}
/**
* Validar reglas de negocio
*
* @throws InvalidComponentException
* @return void
*/
private function validate(): void
{
// Regla: Si está habilitado, al menos un dispositivo debe tener visibilidad
if ($this->enabled && !$this->visibleDesktop && !$this->visibleTablet && !$this->visibleMobile) {
throw new InvalidComponentException(
'Component is enabled but not visible on any device. At least one device must be visible.'
);
}
}
/**
* Factory: Componente deshabilitado
*
* @return self
*/
public static function disabled(): self
{
return new self(
enabled: false,
visibleDesktop: false,
visibleTablet: false,
visibleMobile: false
);
}
/**
* Factory: Componente visible en todos los dispositivos
*
* @return self
*/
public static function allDevices(): self
{
return new self(
enabled: true,
visibleDesktop: true,
visibleTablet: true,
visibleMobile: true
);
}
/**
* Factory: Componente visible solo en desktop
*
* @return self
*/
public static function desktopOnly(): self
{
return new self(
enabled: true,
visibleDesktop: true,
visibleTablet: false,
visibleMobile: false
);
}
/**
* Factory: Componente visible solo en mobile
*
* @return self
*/
public static function mobileOnly(): self
{
return new self(
enabled: true,
visibleDesktop: false,
visibleTablet: false,
visibleMobile: true
);
}
/**
* Factory: Componente visible solo en tablet
*
* @return self
*/
public static function tabletOnly(): self
{
return new self(
enabled: true,
visibleDesktop: false,
visibleTablet: true,
visibleMobile: false
);
}
/**
* Crear desde array
*
* @param array $data
* @return self
*/
public static function fromArray(array $data): self
{
return new self(
enabled: (bool) ($data['enabled'] ?? false),
visibleDesktop: (bool) ($data['visible_desktop'] ?? true),
visibleTablet: (bool) ($data['visible_tablet'] ?? true),
visibleMobile: (bool) ($data['visible_mobile'] ?? true)
);
}
/**
* Comparar con otro ComponentVisibility
*
* @param ComponentVisibility $other
* @return bool
*/
public function equals(ComponentVisibility $other): bool
{
return $this->enabled === $other->enabled &&
$this->visibleDesktop === $other->visibleDesktop &&
$this->visibleTablet === $other->visibleTablet &&
$this->visibleMobile === $other->visibleMobile;
}
}