*/ private const VALID_REASONS = array( self::REASON_PRODUCT_DELETED, self::REASON_SYNC_FAILED, self::REASON_PROVIDER_UNAVAILABLE, self::REASON_INSUFFICIENT_DATA, self::REASON_IMAGE_MISSING, self::REASON_PRICE_HIDDEN, ); /** * §8.4 fixed discriminator string. */ private string $type; /** * §6.4 template key the renderer should pick. */ private string $template; /** * §8.4 optional title string. */ private ?string $title; /** * §8.4 optional primary button. */ private ?ButtonView $primaryButton; /** * One of the {@see self::REASON_*} tokens. */ private string $reason; /** * §13.3 / §23.1 admin-facing diagnostic note, or null. */ private ?string $note; /** * §8.4 BEM class container. */ private CssClasses $classes; /** * Construct a fallback card view model with explicit values * for every field. * * Validation summary: * * - `type` is trimmed and matched against {@see self::TYPE}; * out-of-set tokens raise {@see InvalidArgumentException}. * - `template` is trimmed and rejected when empty after * trimming. * - `title`, `note`, when non-null, are trimmed and rejected * when the trim leaves an empty string. * - `reason` is trimmed and matched against * {@see self::VALID_REASONS}; out-of-set tokens are * rejected with the offending value verbatim and the * accepted-token list for diagnosis. * * @param string $type §8.4 discriminator string. * @param string $template §6.4 template key. * @param string|null $title Optional title string. * @param ButtonView|null $primaryButton Optional primary button. * @param string $reason One of {@see self::VALID_REASONS}. * @param string|null $note Optional admin-facing note. * @param CssClasses $classes §8.4 BEM class container. * * @throws InvalidArgumentException When any field violates the * per-field rules summarized * above. */ public function __construct( string $type, string $template, ?string $title, ?ButtonView $primaryButton, string $reason, ?string $note, CssClasses $classes ) { $this->type = self::normalizeType( $type ); $this->template = self::normalizeTemplate( $template ); $this->title = self::normalizeOptionalString( 'title', $title ); $this->primaryButton = $primaryButton; $this->reason = self::normalizeReason( $reason ); $this->note = self::normalizeOptionalString( 'note', $note ); $this->classes = $classes; } /** * Construct a fallback card view model keyed off a reason * token. * * Convenience factory so call sites do not have to spell out * the fixed `type` argument and can default the template to * {@see self::DEFAULT_TEMPLATE} when the canonical fallback * template suffices. * * @param string $reason One of {@see self::VALID_REASONS}. * @param CssClasses $classes §8.4 BEM class container. * @param string|null $title Optional title string. * @param ButtonView|null $primaryButton Optional primary button. * @param string|null $note Optional admin-facing note. * @param string $template §6.4 template key. * * @throws InvalidArgumentException When any field violates the * per-field rules documented * on the constructor. */ public static function forReason( string $reason, CssClasses $classes, ?string $title = null, ?ButtonView $primaryButton = null, ?string $note = null, string $template = self::DEFAULT_TEMPLATE ): self { return new self( self::TYPE, $template, $title, $primaryButton, $reason, $note, $classes ); } /** * Closed set of accepted §8.4 reason tokens, in declaration * order. * * @return list */ public static function validReasons(): array { return self::VALID_REASONS; } /** * §8.4 fixed `type` discriminator string. */ public function type(): string { return $this->type; } /** * §6.4 template key. */ public function template(): string { return $this->template; } /** * §8.4 optional title string. */ public function title(): ?string { return $this->title; } /** * §8.4 optional primary button. */ public function primaryButton(): ?ButtonView { return $this->primaryButton; } /** * §8.4 reason token. */ public function reason(): string { return $this->reason; } /** * §13.3 / §23.1 admin-facing diagnostic note, or null. */ public function note(): ?string { return $this->note; } /** * §8.4 BEM class container. */ public function classes(): CssClasses { return $this->classes; } /** * Report whether this aggregator is the §8.4 fallback variant. * * Always true for {@see FallbackCardViewModel}; the * {@see ProductCardViewModel} sibling returns false. */ public function isFallback(): bool { return true; } /** * Report whether this view model equals the supplied one. * * Equality is structural: every scalar field must match with * strict comparison, the optional {@see ButtonView} slot must * agree on presence and content, and the {@see CssClasses} * slot must compare equal under its own `equals()`. * * @param self $other Other view model to compare against. */ public function equals( self $other ): bool { if ( $this->type !== $other->type ) { return false; } if ( $this->template !== $other->template ) { return false; } if ( $this->title !== $other->title ) { return false; } if ( ( $this->primaryButton === null ) !== ( $other->primaryButton === null ) ) { return false; } if ( $this->primaryButton !== null && $other->primaryButton !== null && ! $this->primaryButton->equals( $other->primaryButton ) ) { return false; } if ( $this->reason !== $other->reason ) { return false; } if ( $this->note !== $other->note ) { return false; } if ( ! $this->classes->equals( $other->classes ) ) { return false; } return true; } /** * Validate and normalize the `type` field at construction. * * @param string $type Raw discriminator as supplied by the caller. * * @throws InvalidArgumentException When `$type` is not the * §8.4 discriminator string. */ private static function normalizeType( string $type ): string { $trimmed = trim( $type ); if ( $trimmed !== self::TYPE ) { $message = sprintf( 'FallbackCardViewModel type must be "%s"; got "%s".', self::TYPE, $type ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `template` field at construction. * * @param string $template Raw template key as supplied by the caller. * * @throws InvalidArgumentException When `$template` is empty * after trimming. */ private static function normalizeTemplate( string $template ): string { $trimmed = trim( $template ); if ( $trimmed === '' ) { $message = 'FallbackCardViewModel template must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize the `reason` field at construction. * * The token is trimmed first so a carelessly-decoded payload * is normalized to the same canonical form a freshly- * constructed token would carry. The trimmed value MUST * appear in {@see self::VALID_REASONS}. * * @param string $reason Raw token as supplied by the caller. * * @throws InvalidArgumentException When `$reason` is not a * member of the closed set. */ private static function normalizeReason( string $reason ): string { $trimmed = trim( $reason ); if ( ! in_array( $trimmed, self::VALID_REASONS, true ) ) { $message = sprintf( 'FallbackCardViewModel reason must be one of [%s]; got "%s".', implode( ', ', self::VALID_REASONS ), $reason ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } /** * Validate and normalize a nullable string field that, when * non-null, must carry non-empty content. * * @param string $field Field name for the error message. * @param string|null $value Raw value, or null. * * @throws InvalidArgumentException When `$value` is the empty * string after trimming. */ private static function normalizeOptionalString( string $field, ?string $value ): ?string { if ( $value === null ) { return null; } $trimmed = trim( $value ); if ( $trimmed === '' ) { $message = sprintf( 'FallbackCardViewModel %s must be null or a non-empty string.', $field ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $trimmed; } }