*/
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 '';
}
/**
* 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 '';
}
/**
* 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';
}
}