"` string. The §10.6 * availability label falls back to a AFFILUN-canonical string * keyed by locale when the provider supplies none. * * - Provider-label resolution maps the lowercase provider keys * known to Phase 5 onto their human-readable Japanese labels * (`amazon` → "Amazon", `rakuten` → "楽天市場", `yahoo` → * "Yahoo!ショッピング") and falls back to `ucfirst($provider)` * for unknown keys so a future provider always renders some * readable label without forcing a code change. * * - Custom root class composition: when {@see EmbedConfigInput::customRootClass()} * is non-null, it is appended after the canonical * `affilun-card affilun-card-{template} affilun-provider-{provider}` * tokens by reconstructing a fresh {@see CssClasses} via its * primary constructor; the other class axes are reused * verbatim from {@see CssClasses::defaultsForTemplate()}. * * - The §10.6 availability mapping closes the §7.6 status set * onto the §10.6 closed-set view tokens: `in_stock` → * STATUS_IN_STOCK, `out_of_stock` / `unavailable` → * STATUS_OUT_OF_STOCK, `limited` / `preorder` → * STATUS_LIMITED, anything else → STATUS_UNKNOWN. The view * is marked visible only when the snapshot status is one of * `in_stock`, `out_of_stock`, or `limited`. * * Instances carry only the injected builders and the * {@see self::build()} method is otherwise pure: same input, same * output, no I/O, no globals. * * @package Affilun\Application\Render\ViewModel */ declare(strict_types=1); namespace Affilun\Application\Render\ViewModel; use Affilun\Application\Render\Builder\ButtonConfigInput; use Affilun\Application\Render\Builder\DisclaimerBuilder; use Affilun\Application\Render\Builder\ImagePresentationBuilder; use Affilun\Application\Render\Builder\LinkAttributeBuilder; use Affilun\Application\Render\EmbedConfigInput; use Affilun\Application\Render\ViewModel\Parts\AvailabilityView; use Affilun\Application\Render\ViewModel\Parts\ButtonView; use Affilun\Application\Render\ViewModel\Parts\CssClasses; use Affilun\Application\Render\ViewModel\Parts\PriceView; use Affilun\Application\Render\ViewModel\Parts\ProductInfoView; use Affilun\Domain\Affiliate\AffiliateLinkResult; use Affilun\Domain\Pricing\AvailabilityInfo; use Affilun\Domain\Pricing\PriceInfo; use Affilun\Domain\Product\ProductSnapshot; use Affilun\Domain\Render\RenderContext; use DateTimeImmutable; use DateTimeZone; use Throwable; use InvalidArgumentException; /** * Phase-5 §9.1 ViewModel factory with §13.3 graceful degradation. * * See the file-level docblock for the mapping to * `rendering-and-template-spec.md` §9.1 / §10 / §13 and the * fixed-order fallback decision tree. */ final class ProductCardViewModelFactory { /** * §11 link-attribute builder. */ private LinkAttributeBuilder $linkAttributeBuilder; /** * §10.8 disclaimer builder. */ private DisclaimerBuilder $disclaimerBuilder; /** * §10.4 / §12 image-presentation builder. */ private ImagePresentationBuilder $imagePresentationBuilder; /** * Phase-5 lowercase-provider-key → human-readable-label map. * * Keys cover the providers Phase 5 ships with; unknown keys * fall back to {@see self::resolveProviderLabel()}'s * `ucfirst()` branch so a future provider always renders some * readable label without forcing a code change. * * @var array */ private const PROVIDER_LABELS = array( 'amazon' => 'Amazon', 'rakuten' => '楽天市場', 'yahoo_shopping' => 'Yahoo!ショッピング', ); /** * §6.4 template token used by the §13.3 fallback paths when * composing {@see CssClasses}. Intentionally generic so the * fallback rendering does not collide with the active card * template's BEM tree. */ private const FALLBACK_TEMPLATE_TOKEN = 'fallback'; /** * §13.3 provider-key sentinel used when no snapshot is * available (the snapshot is null and the lowercase provider * key is therefore unknown). */ private const FALLBACK_UNKNOWN_PROVIDER = 'unknown'; /** * Construct a factory with explicit builders for every * collaborator. * * @param LinkAttributeBuilder $linkAttributeBuilder §11 link-attribute builder. * @param DisclaimerBuilder $disclaimerBuilder §10.8 disclaimer builder. * @param ImagePresentationBuilder $imagePresentationBuilder §10.4 / §12 image-presentation builder. */ public function __construct( LinkAttributeBuilder $linkAttributeBuilder, DisclaimerBuilder $disclaimerBuilder, ImagePresentationBuilder $imagePresentationBuilder ) { $this->linkAttributeBuilder = $linkAttributeBuilder; $this->disclaimerBuilder = $disclaimerBuilder; $this->imagePresentationBuilder = $imagePresentationBuilder; } /** * Build the §8.2 / §8.4 view model the renderer will consume. * * @param ProductSnapshot|null $snapshot Domain product snapshot, or null when missing. * @param EmbedConfigInput $config Per-instance display configuration. * @param RenderContext $context Active render context. * @param list $affiliateResults Pre-built affiliate-link results, one per button config. * * @throws InvalidArgumentException When `$affiliateResults` is * not a list of * {@see AffiliateLinkResult}, * or when its length does not * match `$config->buttonConfigs()`. */ public function build( ?ProductSnapshot $snapshot, EmbedConfigInput $config, RenderContext $context, array $affiliateResults ): ProductCardViewModel|FallbackCardViewModel { $buttonConfigs = $config->buttonConfigs(); self::assertAffiliateResults( $buttonConfigs, $affiliateResults ); $fallbackClasses = CssClasses::defaultsForTemplate( self::FALLBACK_TEMPLATE_TOKEN, self::providerCssToken( $snapshot !== null ? $snapshot->provider() : self::FALLBACK_UNKNOWN_PROVIDER ) ); if ( $config->prefersFallback() ) { return FallbackCardViewModel::forReason( FallbackCardViewModel::REASON_INSUFFICIENT_DATA, $fallbackClasses, null, null, null ); } if ( $snapshot === null ) { return FallbackCardViewModel::forReason( FallbackCardViewModel::REASON_PRODUCT_DELETED, $fallbackClasses, null, null, null ); } if ( trim( $snapshot->title() ) === '' ) { return FallbackCardViewModel::forReason( FallbackCardViewModel::REASON_INSUFFICIENT_DATA, $fallbackClasses, null, null, null ); } if ( $snapshot->availability()->status() === AvailabilityInfo::STATUS_UNAVAILABLE ) { $buttons = $this->buildButtons( $snapshot, $context, $buttonConfigs, $affiliateResults ); $primaryButton = $buttons === array() ? null : $buttons[0]; return FallbackCardViewModel::forReason( FallbackCardViewModel::REASON_PROVIDER_UNAVAILABLE, $fallbackClasses, $snapshot->title(), $primaryButton, null ); } $providerLabel = self::resolveProviderLabel( $snapshot->provider() ); $productInfo = new ProductInfoView( 1, $snapshot->provider(), $providerLabel, $snapshot->providerProductId()->value(), $snapshot->title(), $snapshot->brand(), $snapshot->description(), $snapshot->detailUrl(), $snapshot->affiliateUrl() ); $images = $this->imagePresentationBuilder->build( $snapshot, $context ); $price = $this->buildPrice( $snapshot->price(), $config, $context ); $availability = $this->buildAvailability( $snapshot->availability(), $context ); $buttons = $this->buildButtons( $snapshot, $context, $buttonConfigs, $affiliateResults ); $disclaimers = $this->disclaimerBuilder->build( $snapshot, $snapshot->price(), $context ); $classes = $this->buildClasses( $context->template(), $snapshot->provider(), $config->customRootClass() ); return ProductCardViewModel::create( $productInfo, $images, $price, $availability, $config->meta(), $classes, $context->template(), $buttons, array(), $disclaimers ); } /** * Validate the `$affiliateResults` argument shape against the * `$buttonConfigs` list it must mirror positionally. * * @param list $buttonConfigs Button configuration inputs. * @param array $affiliateResults Pre-built affiliate-link results. * * @throws InvalidArgumentException When the lengths differ or * any entry is not an * {@see AffiliateLinkResult}. */ private static function assertAffiliateResults( array $buttonConfigs, array $affiliateResults ): void { if ( count( $affiliateResults ) !== count( $buttonConfigs ) ) { $message = sprintf( 'ProductCardViewModelFactory expected %d affiliate result(s) to match buttonConfigs; got %d.', count( $buttonConfigs ), count( $affiliateResults ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $expected = 0; foreach ( $affiliateResults as $key => $value ) { if ( $key !== $expected ) { $message = 'ProductCardViewModelFactory affiliateResults must be a zero-based contiguous list.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $value instanceof AffiliateLinkResult ) { $message = 'ProductCardViewModelFactory affiliateResults entries must be AffiliateLinkResult.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } ++$expected; } } /** * Compose the §10.7 buttons-region list, silently dropping any * single button whose {@see LinkAttributeBuilder::build()} call * raises {@see InvalidArgumentException}. * * @param ProductSnapshot $snapshot Domain product snapshot. * @param RenderContext $context Active render context. * @param list $buttonConfigs Button configuration inputs. * @param list $affiliateResults Pre-built affiliate-link results. * * @return list */ private function buildButtons( ProductSnapshot $snapshot, RenderContext $context, array $buttonConfigs, array $affiliateResults ): array { $buttons = array(); foreach ( $buttonConfigs as $index => $buttonConfig ) { $affiliate = $affiliateResults[ $index ]; try { $buttons[] = $this->linkAttributeBuilder->build( $snapshot, $affiliate, $context, $buttonConfig ); } catch ( InvalidArgumentException $e ) { // §13.3 graceful degradation: silently skip the // failing button and continue. Logging is the // observability layer's concern, not this // factory's. unset( $e ); } } return $buttons; } /** * Compose the §10.5 price view per the locale-driven label * formatting rules documented at the file level. * * @param PriceInfo|null $priceInfo Snapshot price, or null. * @param EmbedConfigInput $config Per-instance display configuration. * @param RenderContext $context Active render context. */ private function buildPrice( ?PriceInfo $priceInfo, EmbedConfigInput $config, RenderContext $context ): PriceView { if ( $priceInfo === null ) { return PriceView::hidden(); } if ( ! $config->meta()->showPrice() ) { return PriceView::hidden(); } $label = self::formatPriceLabel( $priceInfo, $context->locale() ); $observed = self::formatObservedAt( $priceInfo->observedAt() ); $currency = $priceInfo->currency() ?? 'UNKNOWN'; return new PriceView( true, $label, $priceInfo->amount(), $currency, $observed, $priceInfo->disclaimerRequired() ); } /** * Format the price observation timestamp for display. * * Persisted timestamps are stored in UTC, but the renderer is * the presentation boundary so we localize here to the WordPress * site timezone (`wp_timezone()`) when available; otherwise we * fall back to the UTC string `PriceInfo::formatDateTime()` * produces. The output stays as the spec§10.5 `'Y-m-d H:i:s'` * shape so existing template tests keep matching the structure. * * @param \DateTimeInterface|null $observedAt UTC timestamp. */ private static function formatObservedAt( ?\DateTimeInterface $observedAt ): ?string { if ( $observedAt === null ) { return null; } $tz = self::resolveDisplayTimezone(); $source = $observedAt instanceof DateTimeImmutable ? $observedAt : DateTimeImmutable::createFromInterface( $observedAt ); return $source->setTimezone( $tz )->format( 'Y-m-d H:i:s' ); } /** * Resolve the timezone in which observation timestamps should be * displayed. Prefers WordPress's site timezone (`wp_timezone()`), * falls back to UTC when the helper is unavailable or returns an * unusable value. */ private static function resolveDisplayTimezone(): DateTimeZone { if ( function_exists( 'wp_timezone' ) ) { try { $tz = wp_timezone(); if ( $tz instanceof DateTimeZone ) { return $tz; } } catch ( Throwable $e ) { unset( $e ); } } return new DateTimeZone( 'UTC' ); } /** * Compose the §10.6 availability view from the snapshot's * structured status. * * @param AvailabilityInfo $info Snapshot availability info. * @param RenderContext $context Active render context. */ private function buildAvailability( AvailabilityInfo $info, RenderContext $context ): AvailabilityView { $mapped = self::mapAvailabilityStatus( $info->status() ); $visible = self::availabilityVisibleStatuses( $info->status() ); $label = $info->label() ?? self::resolveAvailabilityLabel( $mapped, $context->locale() ); return new AvailabilityView( $visible, $mapped, $label ); } /** * Compose the §8.2 `classes` slot, optionally appending the * caller-supplied custom root-class string after the * canonical `affilun-card affilun-card-{template} affilun-provider-{provider}` * tokens. * * @param string $template §6.4 template key. * @param string $provider Lowercase provider key. * @param string|null $customRootClass Caller-supplied root class string, or null. */ private function buildClasses( string $template, string $provider, ?string $customRootClass ): CssClasses { $defaults = CssClasses::defaultsForTemplate( $template, self::providerCssToken( $provider ) ); if ( $customRootClass === null ) { return $defaults; } return new CssClasses( $defaults->root() . ' ' . $customRootClass, $defaults->image(), $defaults->body(), $defaults->title(), $defaults->price(), $defaults->buttons() ); } /** * Resolve the §10.7 provider label per the * {@see self::PROVIDER_LABELS} map, falling back to * `ucfirst($provider)` for unknown keys. * * @param string $provider Lowercase provider key. */ private static function resolveProviderLabel( string $provider ): string { if ( isset( self::PROVIDER_LABELS[ $provider ] ) ) { return self::PROVIDER_LABELS[ $provider ]; } return ucfirst( $provider ); } /** * Translate a canonical provider key (the domain-layer * `^[a-z][a-z0-9_]*$` form used across persistence / REST / * audit, e.g. `yahoo_shopping`) into the hyphenated form * {@see CssClasses::defaultsForTemplate()} accepts as a * `^[a-z0-9-]+$` token (e.g. `yahoo-shopping`). * * The CssClasses VO intentionally rejects `_` so a typo * (uppercase, embedded space, stray underscore) cannot reach * the rendered class attribute. The persistence layer however * stores `yahoo_shopping` as the canonical key (per * `Domain\Provider\ProviderInterface::key()` docblock and * `database-schema.md` §4.3 enum). This helper is the single * boundary that translates between the two forms; every * caller into `CssClasses::defaultsForTemplate()` from this * factory MUST funnel the provider key through here. * * @param string $provider Canonical provider key. */ private static function providerCssToken( string $provider ): string { return str_replace( '_', '-', $provider ); } /** * Format the §10.5 numeric price label per the locale + currency * combination documented at the file level. * * @param PriceInfo $priceInfo Snapshot price. * @param string|null $locale BCP-47 locale tag, or null. */ private static function formatPriceLabel( PriceInfo $priceInfo, ?string $locale ): string { $amount = (float) $priceInfo->amount(); $currency = $priceInfo->currency(); if ( self::isJapaneseLocale( $locale ) && $currency === 'JPY' ) { return '¥' . number_format( $amount ); } if ( self::isEnglishLocale( $locale ) && $currency === 'USD' ) { return '$' . number_format( $amount, 2 ); } $fallback = $priceInfo->amount() . ' ' . ( $currency ?? '' ); return trim( $fallback ); } /** * Map the §7.6 {@see AvailabilityInfo} status set onto the * §10.6 {@see AvailabilityView} closed set. * * @param string $snapshotStatus One of {@see AvailabilityInfo::STATUS_*}. */ private static function mapAvailabilityStatus( string $snapshotStatus ): string { switch ( $snapshotStatus ) { case AvailabilityInfo::STATUS_IN_STOCK: return AvailabilityView::STATUS_IN_STOCK; case AvailabilityInfo::STATUS_OUT_OF_STOCK: case AvailabilityInfo::STATUS_UNAVAILABLE: return AvailabilityView::STATUS_OUT_OF_STOCK; case AvailabilityInfo::STATUS_LIMITED: case AvailabilityInfo::STATUS_PREORDER: return AvailabilityView::STATUS_LIMITED; default: return AvailabilityView::STATUS_UNKNOWN; } } /** * Report whether the §10.6 availability region should render * for the supplied snapshot status. * * @param string $snapshotStatus One of {@see AvailabilityInfo::STATUS_*}. */ private static function availabilityVisibleStatuses( string $snapshotStatus ): bool { return $snapshotStatus === AvailabilityInfo::STATUS_IN_STOCK || $snapshotStatus === AvailabilityInfo::STATUS_OUT_OF_STOCK || $snapshotStatus === AvailabilityInfo::STATUS_LIMITED; } /** * Resolve a AFFILUN-canonical §10.6 availability label keyed by * the mapped view status and the active locale. * * @param string $viewStatus Mapped {@see AvailabilityView::STATUS_*} token. * @param string|null $locale BCP-47 locale tag, or null. */ private static function resolveAvailabilityLabel( string $viewStatus, ?string $locale ): ?string { $ja = self::isJapaneseLocale( $locale ); switch ( $viewStatus ) { case AvailabilityView::STATUS_IN_STOCK: return $ja ? '在庫あり' : 'In stock'; case AvailabilityView::STATUS_OUT_OF_STOCK: return $ja ? '在庫なし' : 'Out of stock'; case AvailabilityView::STATUS_LIMITED: return $ja ? '残りわずか' : 'Limited stock'; default: return null; } } /** * Test whether the supplied locale tag selects the Japanese * formatting branch. * * Null and empty locale tags select Japanese (the plugin's * default audience); explicit `ja` / `ja-*` / `ja_*` tags also * select Japanese; everything else returns false. * * @param string|null $locale BCP-47 locale tag, or null. */ private static function isJapaneseLocale( ?string $locale ): bool { if ( $locale === null ) { return true; } $normalized = strtolower( trim( $locale ) ); if ( $normalized === '' ) { return true; } if ( $normalized === 'ja' ) { return true; } return strpos( $normalized, 'ja-' ) === 0 || strpos( $normalized, 'ja_' ) === 0; } /** * Test whether the supplied locale tag selects the English * formatting branch. * * Only explicit `en` / `en-*` / `en_*` tags return true; null * and other locales return false so the generic fallback * applies. * * @param string|null $locale BCP-47 locale tag, or null. */ private static function isEnglishLocale( ?string $locale ): bool { if ( $locale === null ) { return false; } $normalized = strtolower( trim( $locale ) ); if ( $normalized === '' ) { return false; } if ( $normalized === 'en' ) { return true; } return strpos( $normalized, 'en-' ) === 0 || strpos( $normalized, 'en_' ) === 0; } }