*/ private const PROVIDERS = array( 'amazon' => 'Amazon', 'rakuten' => '楽天市場', 'yahoo_shopping' => 'Yahoo!ショッピング', 'mercari' => 'メルカリ', ); private ?ProductRepository $repository = null; private ?ProductMetaRepository $metaRepository = null; private ?ProductUrlRepository $urlRepository = null; private ?ProviderRegistry $providerRegistry = null; private ?RenderCache $renderCache = null; /** * Register repository. * * @throws LogicException When repository binding is missing/invalid. */ public function register( ServiceProviderRegistry $registry ): void { $key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY; if ( ! $registry->has( $key ) ) { throw new LogicException( 'ProductEditAdminServiceProvider requires ProductRepository.' ); } $repository = $registry->get( $key ); if ( ! $repository instanceof ProductRepository ) { throw new LogicException( 'ProductEditAdminServiceProvider expected ProductRepository.' ); } $metaKey = PersistenceServiceProvider::BINDING_PRODUCT_META_REPOSITORY; if ( ! $registry->has( $metaKey ) ) { throw new LogicException( 'ProductEditAdminServiceProvider requires ProductMetaRepository.' ); } $metaRepository = $registry->get( $metaKey ); if ( ! $metaRepository instanceof ProductMetaRepository ) { throw new LogicException( 'ProductEditAdminServiceProvider expected ProductMetaRepository.' ); } $urlKey = PersistenceServiceProvider::BINDING_PRODUCT_URL_REPOSITORY; if ( $registry->has( $urlKey ) ) { $urlRepository = $registry->get( $urlKey ); if ( ! $urlRepository instanceof ProductUrlRepository ) { throw new LogicException( 'ProductEditAdminServiceProvider expected ProductUrlRepository.' ); } $this->urlRepository = $urlRepository; } $providerRegistryKey = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY; if ( $registry->has( $providerRegistryKey ) ) { $providerRegistry = $registry->get( $providerRegistryKey ); if ( $providerRegistry instanceof ProviderRegistry ) { $this->providerRegistry = $providerRegistry; } } $this->repository = $repository; $this->metaRepository = $metaRepository; $cacheKey = CacheServiceProvider::BINDING_RENDER_CACHE; if ( $registry->has( $cacheKey ) ) { $cache = $registry->get( $cacheKey ); $this->renderCache = $cache instanceof RenderCache ? $cache : null; } } /** * Register hooks. */ public function boot( HookRegistrar $hooks ): void { $hooks->addAction( 'admin_menu', array( $this, 'onAdminMenu' ) ); $hooks->addAction( 'admin_post_' . self::FORM_ACTION, array( $this, 'onSavePost' ) ); } /** * Add submenu page. */ public function onAdminMenu(): void { if ( ! function_exists( 'add_submenu_page' ) ) { return; } add_submenu_page( '', 'affilun 商品編集', 'affilun 商品編集', 'edit_posts', self::MENU_SLUG, array( $this, 'renderPage' ) ); } /** * Render edit page. */ public function renderPage(): void { if ( $this->repository === null ) { return; } $getData = self::currentGetData(); $productId = self::requestInt( $getData, 'product_id' ); $record = $productId === null ? null : $this->repository->findById( $productId ); echo '
'; $this->renderHero(); if ( $record === null ) { echo '

' . esc_html( '編集する商品を選択してください。' ) . '

'; return; } echo '
'; $this->renderForm( $record ); echo '
'; echo ''; } /** * Render affilun branded page hero. */ private function renderHero(): void { $logoUrl = defined( 'AFFILUN_PLUGIN_URL' ) ? (string) constant( 'AFFILUN_PLUGIN_URL' ) . 'assets/img/brand/logo-landscape-transparent.png' : ''; echo '
'; echo '
'; if ( $logoUrl !== '' ) { echo ''; } echo '

' . esc_html( 'つなぐ・分析する・成長する' ) . '

'; echo '

' . esc_html( 'アフィリエイト管理' ) . '

'; echo '
'; echo ''; echo '
'; } /** * Render form. */ private function renderForm( ProductRecord $record ): void { $id = $record->id(); if ( $id === null ) { return; } echo '
'; echo ''; echo ''; if ( function_exists( 'wp_nonce_field' ) ) { wp_nonce_field( self::nonceAction( $id ), self::NONCE_FIELD ); } $this->renderTextInput( 'title', '商品名', $record->title(), true ); $this->renderUrlInput( 'image_primary_url', '画像URL', $record->imagePrimaryUrl() ?? '', false ); $this->renderUrlInput( 'detail_url', '主要詳細URL', $record->detailUrl(), true ); $this->renderUrlInput( 'affiliate_url', '主要アフィリエイトURL', $record->affiliateUrl() ?? '', false ); $this->renderProviderUrlInputs( $record ); $this->renderTextInput( 'price_label', '価格ラベル', $record->priceLabel() ?? '', false ); $this->renderLifecycleSelect( $record->lifecycleState() ); echo '

'; echo '
'; } /** * Render text input. */ private function renderTextInput( string $name, string $label, string $value, bool $required ): void { $this->renderInputRow( $name, $label, $value, $required, 'text', null ); } /** * Render URL input with an adjacent open link. */ private function renderUrlInput( string $name, string $label, string $value, bool $required ): void { $this->renderInputRow( $name, $label, $value, $required, 'url', $value ); } /** * Render one label/control row. */ private function renderInputRow( string $name, string $label, string $value, bool $required, string $type, ?string $linkUrl ): void { $id = 'affilun-product-' . $name; echo '
'; echo ''; echo '
'; echo ''; $this->renderOpenLink( $linkUrl ); echo '
'; echo '
'; } /** * Render provider URL rows. */ private function renderProviderUrlInputs( ProductRecord $record ): void { $id = $record->id(); if ( $id === null ) { return; } $urls = $this->loadProviderUrlInputs( $record ); echo '
'; echo '' . esc_html( 'プロバイダー別リンク' ) . ''; echo '

' . esc_html( '商品カードの各プロバイダーボタンに使う商品ページURLです。空欄にするとそのプロバイダーの紐付けを削除します。' ) . '

'; foreach ( $this->editableProviders() as $provider => $label ) { $row = $urls[ $provider ] ?? array( 'url' => '', 'provider_item_id' => '', ); $url = is_string( $row['url'] ?? null ) ? $row['url'] : ''; $providerItemId = is_string( $row['provider_item_id'] ?? null ) ? $row['provider_item_id'] : ''; echo '
'; echo '

' . esc_html( $label ) . '

'; $this->renderProviderInputRow( $provider, 'provider_item_id', '商品ID', $providerItemId, 'text', null ); $this->renderProviderInputRow( $provider, 'url', 'URL', $url, 'url', $url ); echo '
'; } echo '
'; } /** * Providers currently editable on this site. * * @return array */ private function editableProviders(): array { if ( $this->providerRegistry === null ) { return self::PROVIDERS; } $providers = array(); foreach ( $this->providerRegistry->keys() as $provider ) { if ( array_key_exists( $provider, self::PROVIDERS ) ) { $providers[ $provider ] = self::PROVIDERS[ $provider ]; } } return $providers; } /** * Render one provider-link input row. */ private function renderProviderInputRow( string $provider, string $field, string $label, string $value, string $type, ?string $linkUrl ): void { $id = 'affilun-product-provider-' . $provider . '-' . $field; echo '
'; echo ''; echo '
'; echo ''; $this->renderOpenLink( $linkUrl ); echo '
'; echo '
'; } /** * Render an adjacent URL link when a valid HTTP(S) URL exists. */ private function renderOpenLink( ?string $url ): void { $url = is_string( $url ) ? trim( $url ) : ''; if ( $url === '' || preg_match( '#\Ahttps?://#i', $url ) !== 1 ) { return; } echo '' . esc_html( '開く' ) . ''; } /** * Load provider URLs for the edit form. * * @return array */ private function loadProviderUrlInputs( ProductRecord $record ): array { $result = array(); $id = $record->id(); if ( $id !== null && $this->urlRepository !== null ) { try { foreach ( $this->urlRepository->findByProductId( $id ) as $urlRecord ) { /** @var ProductUrlRecord $urlRecord */ $provider = $urlRecord->providerType(); if ( ! array_key_exists( $provider, self::PROVIDERS ) || array_key_exists( $provider, $result ) ) { continue; } $result[ $provider ] = array( 'url' => $urlRecord->url(), 'provider_item_id' => $urlRecord->providerItemId() ?? '', ); } } catch ( Throwable $unused ) { unset( $unused ); } } $primary = $record->provider(); if ( array_key_exists( $primary, self::PROVIDERS ) && ! array_key_exists( $primary, $result ) ) { $result[ $primary ] = array( 'url' => $record->detailUrl(), 'provider_item_id' => $record->providerItemId(), ); } return $result; } /** * Render lifecycle select. */ private function renderLifecycleSelect( string $current ): void { $states = array( ProductListQuery::LIFECYCLE_STATE_ACTIVE => '公開済み', ProductListQuery::LIFECYCLE_STATE_ARCHIVED => '未使用', ProductListQuery::LIFECYCLE_STATE_DELETED_PENDING => 'ゴミ箱', ); echo '
'; echo ''; echo '
'; echo ''; echo '
'; echo '
'; } /** * Save post handler. */ public function onSavePost(): void { if ( $this->repository === null || ! self::canEdit() ) { self::redirectList( 'forbidden' ); return; } $postData = self::currentPostData(); $productId = self::requestInt( $postData, 'product_id' ); if ( $productId === null || ! self::verifyNonce( $productId, $postData ) ) { self::redirectList( 'invalid_nonce' ); return; } $fields = self::fieldsFromPost( $postData ); try { $useCase = new UpdateProductManualFieldsUseCase( $this->repository, $this->metaRepository, $this->urlRepository ); $useCase->execute( $productId, $fields ); $this->flushRenderCache(); self::redirectList( 'saved' ); } catch ( Throwable $unused ) { unset( $unused ); self::redirectEdit( $productId, 'error' ); } } /** * Flush rendered card HTML after a product display-field update. */ private function flushRenderCache(): void { if ( $this->renderCache === null ) { return; } try { $this->renderCache->flush(); } catch ( Throwable $unused ) { unset( $unused ); } } /** * Extract fields from POST. * * @param array $postData POST data. * * @return array */ private static function fieldsFromPost( array $postData ): array { $keys = array( 'title', 'image_primary_url', 'detail_url', 'affiliate_url', 'price_label', 'lifecycle_state', ); $fields = array(); foreach ( $keys as $key ) { $value = $postData[ $key ] ?? ''; $fields[ $key ] = is_scalar( $value ) ? (string) $value : ''; } $fields['provider_urls'] = self::providerUrlsFromPost( $postData['provider_urls'] ?? array() ); return $fields; } /** * Extract provider URL fields from POST. * * @param mixed $raw Raw provider URL data. * * @return array */ private static function providerUrlsFromPost( mixed $raw ): array { if ( ! is_array( $raw ) ) { return array(); } $result = array(); foreach ( self::PROVIDERS as $provider => $unusedLabel ) { if ( ! array_key_exists( $provider, $raw ) ) { continue; } $entry = $raw[ $provider ]; if ( ! is_array( $entry ) ) { continue; } $urlRaw = $entry['url'] ?? ''; $itemRaw = $entry['provider_item_id'] ?? ''; $result[ $provider ] = array( 'url' => is_scalar( $urlRaw ) ? (string) $urlRaw : '', 'provider_item_id' => is_scalar( $itemRaw ) ? (string) $itemRaw : '', ); } return $result; } /** * Verify nonce. * * @param int $productId Product id. * @param array $postData POST data. */ private static function verifyNonce( int $productId, array $postData ): bool { $value = $postData[ self::NONCE_FIELD ] ?? ''; $nonce = is_scalar( $value ) ? self::sanitizeTextField( (string) $value ) : ''; return $nonce !== '' && function_exists( 'wp_verify_nonce' ) && (bool) wp_verify_nonce( $nonce, self::nonceAction( $productId ) ); } /** * Verify the edit-page URL nonce when a product id is supplied. * * @param int $productId Product id. * @param array $getData Sanitized GET data. */ private static function verifyViewNonce( int $productId, array $getData ): bool { $value = $getData[ self::VIEW_NONCE_FIELD ] ?? ''; $nonce = is_scalar( $value ) ? self::sanitizeTextField( (string) $value ) : ''; return $nonce !== '' && function_exists( 'wp_verify_nonce' ) && (bool) wp_verify_nonce( $nonce, self::viewNonceAction( $productId ) ); } /** * Request int helper. * * @param array $source Source. */ private static function requestInt( array $source, string $key ): ?int { $value = $source[ $key ] ?? null; $value = is_scalar( $value ) ? trim( (string) $value ) : ''; return preg_match( '/\A\d+\z/', $value ) === 1 && (int) $value > 0 ? (int) $value : null; } /** * Nonce action. */ private static function nonceAction( int $productId ): string { return self::FORM_ACTION . '_' . $productId; } /** * Edit-page URL nonce action. */ private static function viewNonceAction( int $productId ): string { return 'affilun_edit_product_' . $productId; } /** * Capability check. */ private static function canEdit(): bool { return function_exists( 'current_user_can' ) && current_user_can( 'edit_posts' ); } /** * Return sanitized POST data for the product-edit form. * * @return array */ private static function currentPostData(): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce is verified before callers mutate state. $raw = $_POST; return self::sanitizeRequestData( $raw ); } /** * Return sanitized GET data for the read-only product-edit page. * * @return array */ private static function currentGetData(): array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Product id is rejected below unless its product-specific view nonce verifies. $raw = $_GET; $data = self::sanitizeRequestData( $raw ); $productId = self::requestInt( $data, 'product_id' ); if ( $productId !== null && ! self::verifyViewNonce( $productId, $data ) ) { return array(); } return $data; } /** * Sanitize a GET/POST-like array recursively after unslashing. * * @param array $data Request data. * * @return array */ private static function sanitizeRequestData( array $data ): array { $unslashed = function_exists( 'wp_unslash' ) ? wp_unslash( $data ) : $data; if ( ! is_array( $unslashed ) ) { return array(); } $sanitized = array(); foreach ( $unslashed as $key => $value ) { if ( ! is_string( $key ) ) { continue; } $sanitized[ $key ] = self::sanitizeRequestValue( $key, $value ); } return $sanitized; } /** * Sanitize one request value using the field context. * * @param string $key Request key. * @param mixed $value Request value. * * @return mixed */ private static function sanitizeRequestValue( string $key, $value ) { if ( is_array( $value ) ) { $result = array(); foreach ( $value as $childKey => $childValue ) { if ( is_string( $childKey ) ) { $result[ $childKey ] = self::sanitizeRequestValue( $childKey, $childValue ); continue; } if ( is_int( $childKey ) ) { $result[ $childKey ] = self::sanitizeRequestValue( $key, $childValue ); } } return $result; } if ( ! is_scalar( $value ) ) { return ''; } $string = (string) $value; if ( str_contains( $key, 'url' ) ) { return self::sanitizeUrlField( $string ); } if ( $key === 'lifecycle_state' ) { return function_exists( 'sanitize_key' ) ? (string) sanitize_key( $string ) : strtolower( preg_replace( '/[^a-z0-9_\-]/i', '', $string ) ?? '' ); } return self::sanitizeTextField( $string ); } /** * Sanitize a URL field, preserving empty values for optional fields. * * @param string $value Raw URL. */ private static function sanitizeUrlField( string $value ): string { $value = trim( $value ); if ( $value === '' ) { return ''; } if ( function_exists( 'sanitize_url' ) ) { return (string) sanitize_url( $value ); } if ( function_exists( 'esc_url_raw' ) ) { return (string) esc_url_raw( $value ); } return filter_var( $value, FILTER_VALIDATE_URL ) === false ? '' : $value; } /** * Sanitize a single-line text field. * * @param string $value Raw value. */ private static function sanitizeTextField( string $value ): string { if ( function_exists( 'sanitize_text_field' ) ) { return (string) sanitize_text_field( $value ); } $value = preg_replace( '/<[^>]*>/', '', $value ) ?? $value; return trim( (string) preg_replace( '/\s+/', ' ', $value ) ); } /** * Redirect to edit page. */ private static function redirectEdit( int $productId, string $status ): void { $url = self::adminPageUrl() . '&product_id=' . rawurlencode( (string) $productId ) . '&affilun_status=' . rawurlencode( $status ); self::safeRedirect( $url ); } /** * Redirect to list page. */ private static function redirectList( string $status ): void { self::safeRedirect( self::listPageUrl() . '&affilun_status=' . rawurlencode( $status ) ); } /** * Safe redirect. */ private static function safeRedirect( string $url ): void { if ( function_exists( 'wp_safe_redirect' ) ) { wp_safe_redirect( $url ); exit; } } /** * Admin-post URL. */ private static function adminPostUrl(): string { return function_exists( 'admin_url' ) ? (string) admin_url( 'admin-post.php' ) : 'admin-post.php'; } /** * Admin edit URL. */ private static function adminPageUrl(): string { return function_exists( 'admin_url' ) ? (string) admin_url( 'admin.php?page=' . self::MENU_SLUG ) : 'admin.php?page=' . self::MENU_SLUG; } /** * Products list URL. */ private static function listPageUrl(): string { return function_exists( 'admin_url' ) ? (string) admin_url( 'admin.php?page=affilun-products' ) : 'admin.php?page=affilun-products'; } }