*/ public const SORT_LABELS = array( 'discount_desc' => '割引率(高い順)', 'discount_asc' => '割引率(低い順)', 'brand_asc' => 'ブランド名(昇順)', 'brand_desc' => 'ブランド名(降順)', 'title_asc' => '商品名(昇順)', 'title_desc' => '商品名(降順)', ); public const DEFAULT_SORT = 'discount_desc'; public const DEFAULT_PER_CATEGORY = 4; /** * Create the grouped card renderer. * * @param AmazonDiscountRepository $repository Snapshot repository. * @param ProductCardViewModelFactory $viewModelFactory Card view-model factory. * @param ProductCardRenderer $renderer Standard card renderer. * @param SettingsRepository|null $settings Saved plugin settings. */ public function __construct( private AmazonDiscountRepository $repository, private ProductCardViewModelFactory $viewModelFactory, private ProductCardRenderer $renderer, private ?SettingsRepository $settings = null ) { } /** * WordPress shortcode callback. * * @param mixed $attributes Raw shortcode attributes. */ public function renderShortcode( mixed $attributes ): string { return $this->render( is_array( $attributes ) ? $attributes : array(), false ); } /** * Render grouped products from normalized shortcode attributes. * * @param array $attributes Shortcode-like attributes. * @param bool $preview Whether to use editor render context. */ public function render( array $attributes, bool $preview ): string { $brands = self::brandsFromAttributes( $attributes ); $categories = self::categoriesFromAttributes( $attributes ); $sort = self::sortFromAttributes( $attributes ); $perCategory = self::perCategoryFromAttributes( $attributes ); $byBrand = $this->repository->findBrandsGrouped( $brands, $sort, $perCategory ); $grouped = $this->repository->findGrouped( $categories, $sort, $perCategory ); $settings = $this->settings !== null ? $this->settings->load() : PluginSettings::defaults(); $html = '
'; $seen = 0; foreach ( $brands as $brand ) { $products = $byBrand[ $brand ] ?? array(); if ( $products === array() ) { continue; } ++$seen; $html .= $this->renderGroup( $brand, $products, 'affilun-amazon-discounts__brand', $preview, $settings ); } foreach ( $categories as $category ) { $products = $grouped[ $category ] ?? array(); if ( $products === array() ) { continue; } ++$seen; $html .= $this->renderGroup( AmazonDiscountCategory::label( $category ), $products, 'affilun-amazon-discounts__category', $preview, $settings ); } if ( $seen === 0 ) { $html .= '

' . self::escapeHtml( '現在表示できるAmazon割引商品はありません。日次取得後に表示されます。' ) . '

'; } $html .= '
'; return $html; } /** * @param array $attributes Attributes. * * @return list */ public static function brandsFromAttributes( array $attributes ): array { return AmazonDiscountBrand::parse( $attributes['brands'] ?? array() ); } /** * @param array $attributes Attributes. * * @return list */ public static function categoriesFromAttributes( array $attributes ): array { $value = $attributes['categories'] ?? null; if ( is_string( $value ) ) { $categories = AmazonDiscountCategory::normalize( array_map( 'trim', explode( ',', $value ) ) ); } elseif ( is_array( $value ) ) { $categories = AmazonDiscountCategory::normalize( array_values( $value ) ); } else { $categories = array(); } return $categories === array() ? AmazonDiscountCategory::DEFAULT_SELECTION : $categories; } /** * @param array $attributes Attributes. */ public static function sortFromAttributes( array $attributes ): string { $sort = is_string( $attributes['sort'] ?? null ) ? trim( $attributes['sort'] ) : ''; return array_key_exists( $sort, self::SORT_LABELS ) ? $sort : self::DEFAULT_SORT; } /** * @param array $attributes Attributes. */ public static function perCategoryFromAttributes( array $attributes ): int { $value = $attributes['per_category'] ?? $attributes['perCategory'] ?? self::DEFAULT_PER_CATEGORY; $count = is_numeric( $value ) ? (int) $value : self::DEFAULT_PER_CATEGORY; return max( 1, min( 20, $count ) ); } /** * Render one brand or category group. * * @param string $heading Group heading. * @param list $products Stored products. * @param string $groupClass Group CSS class. * @param bool $preview Whether to use editor context. * @param PluginSettings $settings Saved plugin settings. */ private function renderGroup( string $heading, array $products, string $groupClass, bool $preview, PluginSettings $settings ): string { $html = '
'; $html .= '

' . self::escapeHtml( $heading ) . '

'; $html .= '
'; foreach ( $products as $product ) { $html .= $this->renderProduct( $product, $preview, $settings ); } return $html . '
'; } /** * Render one stored product through the canonical card pipeline. * * @param AmazonDiscountProduct $product Stored discount product. * @param bool $preview Whether to use editor context. * @param PluginSettings $settings Saved plugin settings. */ private function renderProduct( AmazonDiscountProduct $product, bool $preview, PluginSettings $settings ): string { try { $rendering = $settings->rendering(); $template = $rendering->defaultTemplate(); $meta = MetaFlags::defaults() ->withShowBrand( true ) ->withShowDescription( true ) ->withShowPrice( true ) ->withShowProviderBadge( $rendering->showProviderBadgeByDefault() ); $config = new EmbedConfigInput( $template, array( new ButtonConfigInput( 'Amazonで見る', 'Amazon' ) ), $meta, ProductCardVisualSettings::rootClasses( $rendering, array( 'affilun-amazon-discount-card' ) ), false ); $context = $preview ? RenderContext::editor( $template, 'ja-JP' ) : RenderContext::frontend( $template, 'ja-JP' ); $link = new AffiliateLinkResult( $product->affiliateUrl(), array( 'nofollow', 'sponsored', 'noopener' ), '_blank', true, array() ); $viewModel = $this->viewModelFactory->build( $this->snapshot( $product ), $config, $context, array( $link ) ); $html = $this->renderer->render( $viewModel, $context ); $html = ProductCardVisualSettings::appendCardRootStyle( $html, ProductCardVisualSettings::providerButtonColorStyle( $settings, array( 'amazon' ) ) ); return $this->appendDiscountBadge( $html, $this->discountLabel( $product ) ); } catch ( Throwable $error ) { if ( function_exists( 'error_log' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Frontend render failure surface. error_log( 'AFFILUN Amazon discount card render failed: ' . $error->getMessage() ); } return '

' . self::escapeHtml( $product->title() ) . '

'; } } /** * Add a fixed-style discount badge inside the canonical card body. * * @param string $html Rendered card HTML. * @param string $label Discount label. */ private function appendDiscountBadge( string $html, string $label ): string { $badge = '' . self::escapeHtml( $label ) . ''; $result = preg_replace( '/(]*\bclass=("|\')[^"\']*\baffilun-card__body\b[^"\']*\2[^>]*>)/i', '$1' . $badge, $html, 1 ); return is_string( $result ) ? $result : $html; } /** * Format the stored discount percentage for the badge. * * @param AmazonDiscountProduct $product Stored discount product. */ private function discountLabel( AmazonDiscountProduct $product ): string { return $product->savingsPercentage() === null ? '' : sprintf( '%d%% OFF', $product->savingsPercentage() ); } /** * Convert a stored discount row to a standard product snapshot. * * @param AmazonDiscountProduct $product Stored discount product. */ private function snapshot( AmazonDiscountProduct $product ): ProductSnapshot { $price = $product->priceAmount() === null ? null : new PriceInfo( $product->priceAmount(), $product->priceCurrency(), $product->priceLabel(), $product->fetchedAt(), false, false, null ); return new ProductSnapshot( 'amazon', new ProviderProductId( 'amazon', $product->asin() ), $product->title(), $product->brand(), null, $product->detailUrl(), $product->affiliateUrl(), $product->imageUrl(), array(), $price, AvailabilityInfo::unknown(), null, null, array( $product->categoryLabel() ), array( 'savings_percentage' => $product->savingsPercentage() ), $product->fetchedAt(), array( 'search_index' => $product->searchIndex(), 'min_saving_percent' => $product->minSavingPercent(), ) ); } /** * Escape visible text with a non-WordPress test fallback. * * @param string $value Raw text. */ private static function escapeHtml( string $value ): string { return function_exists( 'esc_html' ) ? esc_html( $value ) : htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); } /** * Escape an HTML attribute with a non-WordPress test fallback. * * @param string $value Raw attribute. */ private static function escapeAttribute( string $value ): string { return function_exists( 'esc_attr' ) ? esc_attr( $value ) : htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); } }