` so the operator can confirm the * persisted value against the upstream Associates / RMS / * DG console. The Settings page is `manage_options`-gated * at the page-callback level (§3), so only admin-tier * users reach this surface; the operator UX preference * here is to expose the saved value so it can be audited * in place. Logs / diagnostics surfaces continue to mask * via {@see \Affilun\Application\Settings\SettingsCredentialMask} * so secrets never leak into operational telemetry. * - The associated `` carries the * placeholder `(leave blank to keep current)` so the user * understands the rotating-input contract. * - Empty submission preserves the current value; a * non-empty submission rotates. The actual rotation logic * lives in * {@see \Affilun\Application\Settings\UseCase\SaveSettingsUseCase}. * * Status-flash protocol: * * - The optional `$statusFlash` parameter accepts the literal * `'saved'` / `'error'` / `'forbidden'` / `'invalid_nonce'` * and renders the corresponding `notice notice-success` or * `notice notice-error` banner. Other values (and `null`) * suppress the banner. * * Escaping invariants: * * - Every visible text emission flows through * {@see esc_html()}. * - Every attribute value flows through {@see esc_attr()}. * - The `
` URL flows through * {@see esc_url()}. * * @package Affilun\Presentation\Admin\Page */ declare(strict_types=1); namespace Affilun\Presentation\Admin\Page; use Affilun\Application\Render\EmbedConfigInput; use Affilun\Application\Render\ProductCardRenderer; use Affilun\Application\Render\ViewModel\Parts\ButtonView; use Affilun\Application\Render\ViewModel\Parts\MetaFlags; use Affilun\Application\Render\ViewModel\ProductCardViewModel; use Affilun\Application\Render\ViewModel\ProductCardViewModelFactory; use Affilun\Application\Cloud\CloudHttpOptions; use Affilun\Application\Migration\ExternalAffiliateProductImportUseCase; use Affilun\Application\Settings\CacheSettings; use Affilun\Application\Settings\CloudSettings; use Affilun\Application\Settings\LinkDefaults; use Affilun\Application\Settings\PluginSettings; use Affilun\Application\Settings\ProviderCredentials; use Affilun\Application\Settings\RenderingDefaults; use Affilun\Application\Settings\SettingsRepository; use Affilun\Application\Settings\SyncSettings; use Affilun\Application\Affiliate\OptionStoreAffiliateRoutingRepository; use Affilun\Domain\Pricing\AvailabilityInfo; use Affilun\Domain\Pricing\PriceInfo; use Affilun\Domain\Product\ProductSnapshot; use Affilun\Domain\Provider\ProviderProductId; use Affilun\Domain\Render\RenderContext; use Affilun\Infrastructure\Persistence\OptionStore; use Affilun\Presentation\Card\ProductCardVisualSettings; use DateTimeImmutable; use DateTimeZone; use Throwable; /** * Renders the §13 Settings admin page form to stdout. * * See the file-level docblock for the repository-access * rationale, the §6.3 rotation flow for provider credentials, * and the status-flash protocol the Save handler drives. */ final class SettingsPageRenderer { /** * Per-provider field schema used to render the §13.4 * Providers section. * * Keyed by provider key; each entry carries the * human-readable provider label and an ordered list of * `[secret_key => human_label]` pairs. * * @var array}> */ private const PROVIDER_SCHEMA = array( 'amazon' => array( 'label' => 'Amazon (Creators API)', 'secrets' => array( 'credential_id' => '認証情報ID', 'credential_secret' => 'シークレット', 'version' => 'バージョン', 'partner_tag' => 'StoreID', ), ), 'rakuten' => array( 'label' => '楽天市場', 'secrets' => array( 'application_id' => 'Application ID (アプリ ID)', 'access_key' => 'Access Key (アクセスキー)', 'affiliate_id' => 'Affiliate ID (アフィリエイト ID)', ), ), 'yahoo_shopping' => array( 'label' => 'Yahoo!ショッピング', 'secrets' => array( 'app_id' => 'Client ID', 'affiliate_sid' => 'ValueCommerce sid', 'affiliate_pid' => 'ValueCommerce pid', ), ), 'mercari' => array( 'label' => 'メルカリ (メルカリアンバサダー)', 'secrets' => array( 'afid' => 'メルカリアンバサダー ID (afid)', ), ), ); /** * HTML id of the settings-save form. Individual controls are * associated with this form via the HTML5 `form` attribute so * the Advanced tab can contain the standalone Amazon re-sync * form without invalid nested forms. */ private const SETTINGS_FORM_ID = 'affilun-settings-form'; /** * Public setup guides shown immediately before each affiliate * enablement checkbox. * * @var array */ private const SETUP_GUIDE_LINKS = array( 'amazon' => array( 'label' => 'Amazonの有効化方法を見る', 'url' => 'https://affilun.st/amazon', ), 'rakuten' => array( 'label' => '楽天市場の有効化方法を見る', 'url' => 'https://affilun.st/rakuten', ), 'yahoo_shopping' => array( 'label' => 'Yahoo!ショッピングの有効化方法を見る', 'url' => 'https://affilun.st/yahoo', ), 'mercari' => array( 'label' => 'メルカリの有効化方法を見る', 'url' => 'https://affilun.st/mercari', ), ); /** * Tab definitions for the settings page. * * @var array */ private const TAB_DEFINITIONS = array( 'basic' => array( 'label' => '基本設定', 'panel_id' => 'affilun-settings-tab-basic', ), 'amazon' => array( 'label' => 'Amazon', 'panel_id' => 'affilun-settings-tab-amazon', ), 'rakuten' => array( 'label' => '楽天市場', 'panel_id' => 'affilun-settings-tab-rakuten', ), 'yahoo_shopping' => array( 'label' => 'Yahoo!ショッピング', 'panel_id' => 'affilun-settings-tab-yahoo-shopping', ), 'mercari' => array( 'label' => 'メルカリ', 'panel_id' => 'affilun-settings-tab-mercari', ), 'asp' => array( 'label' => 'ASPルーティング(上級者向け)', 'panel_id' => 'affilun-settings-tab-asp', ), 'cloud' => array( 'label' => 'affilun Cloud', 'panel_id' => 'affilun-settings-tab-cloud', ), 'operations' => array( 'label' => '運用ツール', 'panel_id' => 'affilun-settings-tab-operations', ), 'advanced' => array( 'label' => '高度な設定', 'panel_id' => 'affilun-settings-tab-advanced', ), ); /** * Closed set of allowed `placeholder_image_policy` values * mirrored from {@see RenderingDefaults}. * * @var list */ private const PLACEHOLDER_POLICIES = array( 'none', 'transparent', 'low-res' ); /** * Closed set of default template choices. * * @var list */ private const DEFAULT_TEMPLATE_OPTIONS = array( 'single-default-card', 'single-compact-card', 'single-minimal-cta' ); /** * Closed set of default card design presets. * * @var list */ private const DESIGN_PRESET_OPTIONS = array( 'default', 'flat', 'outlined', 'soft' ); /** * Closed set of default card color schemes. * * @var list */ private const COLOR_SCHEME_OPTIONS = array( 'light', 'dark', 'system' ); /** * Closed set of default button layouts. * * @var list */ private const BUTTON_LAYOUT_OPTIONS = array( 'wrap', 'stacked', 'full' ); /** * Closed set of default button shapes. * * @var list */ private const BUTTON_SHAPE_OPTIONS = array( 'square', 'rounded', 'pill' ); /** * Closed set of allowed `retry_policy_preset` values * mirrored from {@see SyncSettings}. * * @var list */ private const RETRY_POLICY_PRESETS = array( 'none', 'fast', 'balanced', 'patient' ); /** * Settings repository the renderer reads from. */ private SettingsRepository $repository; /** * Optional ASP affiliate-routing repository. */ private ?OptionStoreAffiliateRoutingRepository $affiliateRoutingRepository; /** * Optional canonical card ViewModel factory for the Settings preview. */ private ?ProductCardViewModelFactory $previewViewModelFactory; /** * Optional canonical product-card renderer for the Settings preview. */ private ?ProductCardRenderer $previewRenderer; /** * Optional embedded migration renderer for the Operations tab. */ private ?MigrationPageRenderer $migrationRenderer = null; /** * Optional embedded diagnostics renderer for the Operations tab. */ private ?DiagnosticsPageRenderer $diagnosticsRenderer = null; /** * Optional embedded logs renderer for the Operations tab. */ private ?LogsPageRenderer $logsRenderer = null; /** * Optional embedded sync-jobs renderer for the Operations tab. */ private ?SyncJobsPageRenderer $syncJobsRenderer = null; /** * Optional embedded audit renderer for the Operations tab. */ private ?AuditPageRenderer $auditRenderer = null; /** * Optional logs query parser for the embedded Operations tab. */ private ?LogsListQueryParser $logsListQueryParser = null; /** * Optional sync-jobs query parser for the embedded Operations tab. */ private ?SyncJobsListQueryParser $syncJobsListQueryParser = null; /** * Optional audit query parser for the embedded Operations tab. */ private ?AuditListQueryParser $auditListQueryParser = null; /** * Current request query forwarded by the admin page dispatcher. * * @var array */ private array $operationRequest = array(); /** * Nonce-field emitter callable. * * Signature: `function (string $action, string $name): void`. * Default forwards to `wp_nonce_field()` when available; * tests inject recording closures. * * @var callable */ private $nonceFieldEmitter; /** * Option key storing the explicit "hide this message" preference. */ public const EXTERNAL_AFFILIATE_NOTICE_HIDDEN_OPTION = 'affilun_external_affiliate_import_notice_hidden'; public const EXTERNAL_AFFILIATE_IMPORT_ACTION = 'affilun_import_external_affiliate_data'; public const EXTERNAL_AFFILIATE_IMPORT_NONCE_ACTION = 'affilun_import_external_affiliate_data'; public const EXTERNAL_AFFILIATE_IMPORT_NONCE_FIELD = '_affilun_import_external_affiliate_data_nonce'; public const EXTERNAL_AFFILIATE_DISMISS_ACTION = 'affilun_dismiss_external_affiliate_data_notice'; public const EXTERNAL_AFFILIATE_DISMISS_NONCE_ACTION = 'affilun_dismiss_external_affiliate_data_notice'; public const EXTERNAL_AFFILIATE_DISMISS_NONCE_FIELD = '_affilun_dismiss_external_affiliate_data_notice_nonce'; /** * Existing affiliate-link data importer. */ private ?ExternalAffiliateProductImportUseCase $externalAffiliateImporter; /** * Option store for notice preferences. */ private ?OptionStore $optionStore; /** * Construct the renderer. * * @param SettingsRepository $repository Settings repository. * @param callable|null $nonceFieldEmitter Nonce emitter. * @param OptionStoreAffiliateRoutingRepository|null $routingRepository ASP repo. * @param ProductCardViewModelFactory|null $previewFactory Canonical preview ViewModel factory. * @param ProductCardRenderer|null $previewRenderer Canonical preview renderer. * @param ExternalAffiliateProductImportUseCase|null $externalImporter Existing data importer. * @param OptionStore|null $optionStore Option store for UI preferences. * * @phpstan-param (callable(string,string):void)|null $nonceFieldEmitter */ public function __construct( SettingsRepository $repository, ?callable $nonceFieldEmitter = null, ?OptionStoreAffiliateRoutingRepository $routingRepository = null, ?ProductCardViewModelFactory $previewFactory = null, ?ProductCardRenderer $previewRenderer = null, ?ExternalAffiliateProductImportUseCase $externalImporter = null, ?OptionStore $optionStore = null ) { $this->repository = $repository; $this->nonceFieldEmitter = $nonceFieldEmitter ?? self::defaultNonceFieldEmitter(); $this->affiliateRoutingRepository = $routingRepository; $this->previewViewModelFactory = $previewFactory; $this->previewRenderer = $previewRenderer; $this->externalAffiliateImporter = $externalImporter; $this->optionStore = $optionStore; } /** * Echo the full Settings page HTML to stdout. * * @param string|null $statusFlash Optional status flash literal: `'saved'` / * `'error'` / `'forbidden'` / `'invalid_nonce'`. * @param array $request Current request parameters for embedded operations. */ public function render( ?string $statusFlash = null, array $request = array() ): void { $settings = $this->repository->load(); $formAction = self::resolveAdminPostUrl(); $this->operationRequest = $request; echo '
'; $this->renderBrandHero(); $this->renderStatusFlash( $statusFlash ); echo ''; echo ''; ( $this->nonceFieldEmitter )( SettingsFormToken::NONCE_ACTION, SettingsFormToken::NONCE_FIELD ); echo ''; echo '
'; $this->renderTabNavigation(); $this->renderTabPanelOpen( 'basic' ); $this->renderExternalAffiliateImportNotice( $formAction ); $this->renderRenderingSection( $settings ); echo '
'; $this->renderLinksSection( $settings->links() ); $this->renderTabPanelClose(); $this->renderProviderTabPanel( 'amazon', $settings ); $this->renderProviderTabPanel( 'rakuten', $settings ); $this->renderProviderTabPanel( 'yahoo_shopping', $settings ); $this->renderProviderTabPanel( 'mercari', $settings ); $this->renderTabPanelOpen( 'asp' ); $this->renderAffiliateRoutingSection(); $this->renderTabPanelClose(); $this->renderTabPanelOpen( 'cloud' ); $this->renderCloudSection( $settings->cloud() ); $this->renderTabPanelClose(); $this->renderTabPanelOpen( 'operations' ); $this->renderOperationsSection(); $this->renderTabPanelClose(); $this->renderTabPanelOpen( 'advanced' ); $this->renderSyncSection( $settings->sync() ); echo '
'; $this->renderCacheSection( $settings->cache() ); $this->renderAmazonResyncForm( $formAction ); echo '
'; $this->renderAdvancedSection( $settings ); $this->renderTabPanelClose(); echo '
'; echo '

'; echo ''; echo '

'; echo '
'; } /** * Configure embedded operation-tool renderers for the Operations tab. * * @param MigrationPageRenderer|null $migrationRenderer Migration renderer. * @param DiagnosticsPageRenderer|null $diagnosticsRenderer Diagnostics renderer. * @param LogsPageRenderer|null $logsRenderer Logs renderer. * @param SyncJobsPageRenderer|null $syncJobsRenderer Sync jobs renderer. * @param AuditPageRenderer|null $auditRenderer Audit renderer. * @param LogsListQueryParser|null $logsListQueryParser Logs query parser. * @param SyncJobsListQueryParser|null $syncJobsListQueryParser Sync jobs query parser. * @param AuditListQueryParser|null $auditListQueryParser Audit query parser. */ public function configureOperationTools( ?MigrationPageRenderer $migrationRenderer, ?DiagnosticsPageRenderer $diagnosticsRenderer, ?LogsPageRenderer $logsRenderer, ?SyncJobsPageRenderer $syncJobsRenderer, ?AuditPageRenderer $auditRenderer, ?LogsListQueryParser $logsListQueryParser, ?SyncJobsListQueryParser $syncJobsListQueryParser, ?AuditListQueryParser $auditListQueryParser ): void { $this->migrationRenderer = $migrationRenderer; $this->diagnosticsRenderer = $diagnosticsRenderer; $this->logsRenderer = $logsRenderer; $this->syncJobsRenderer = $syncJobsRenderer; $this->auditRenderer = $auditRenderer; $this->logsListQueryParser = $logsListQueryParser; $this->syncJobsListQueryParser = $syncJobsListQueryParser; $this->auditListQueryParser = $auditListQueryParser; } /** * Render the affilun-branded settings page hero. */ private function renderBrandHero(): void { $logoUrl = defined( 'AFFILUN_PLUGIN_URL' ) ? 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 the optional status-flash banner at the top of the * page. * * @param string|null $statusFlash Status literal, or null * to suppress the banner. */ private function renderStatusFlash( ?string $statusFlash ): void { if ( $statusFlash === null || $statusFlash === '' ) { return; } switch ( $statusFlash ) { case 'saved': $this->renderNotice( 'success', '設定を保存しました。' ); return; case 'error': $this->renderNotice( 'error', '設定の保存に失敗しました。' ); return; case 'forbidden': $this->renderNotice( 'error', 'アクセス権限がありません。' ); return; case 'invalid_nonce': $this->renderNotice( 'error', 'セキュリティトークンが不正です。もう一度お試しください。' ); return; case 'amazon_resync_queued': $this->renderNotice( 'success', '既存のAmazon商品をCreators APIで再同期するジョブを登録しました。' ); return; case 'amazon_resync_none': $this->renderNotice( 'success', '再同期対象のAmazon商品はありませんでした。' ); return; case 'amazon_resync_error': $this->renderNotice( 'error', 'Amazon商品の再同期ジョブ登録に失敗しました。' ); return; case 'external_affiliate_imported': $this->renderNotice( 'success', '既存のアフィリエイトリンクデータをaffilunに読み込みました。' ); return; case 'external_affiliate_import_none': $this->renderNotice( 'success', '読み込みが必要な既存のアフィリエイトリンクデータはありませんでした。' ); return; case 'external_affiliate_import_error': $this->renderNotice( 'error', '既存のアフィリエイトリンクデータの読み込みに失敗しました。' ); return; case 'external_affiliate_notice_hidden': $this->renderNotice( 'success', '既存のアフィリエイトリンクデータの案内を非表示にしました。' ); return; case 'yahoo_vc_invalid': $this->renderNotice( 'error', 'ValueCommerceのHTMLコードからsid/pidを抽出できませんでした。Yahoo!ショッピングの自由テキストリンクHTMLを貼り付けてください。' ); return; default: return; } } /** * Render a single WordPress admin notice. * * @param string $kind Either `'success'` or `'error'`. * @param string $message Human-readable message. */ private function renderNotice( string $kind, string $message ): void { $class = $kind === 'success' ? 'notice notice-success' : 'notice notice-error'; echo '

' . esc_html( $message ) . '

'; } /** * Render the neutral prompt for importing existing affiliate-link data. * * @param string $formAction admin-post endpoint URL. */ private function renderExternalAffiliateImportNotice( string $formAction ): void { if ( $this->externalAffiliateImporter === null || $this->isExternalAffiliateNoticeHidden() ) { return; } if ( $this->externalAffiliateImporter->detectedCount() < 1 ) { return; } echo '
'; echo '

' . esc_html( '既存のアフィリエイトリンクデータを検出しました。' ) . '

'; echo '
'; $this->renderExternalAffiliateNoticeForm( $formAction, self::EXTERNAL_AFFILIATE_IMPORT_ACTION, self::EXTERNAL_AFFILIATE_IMPORT_NONCE_ACTION, self::EXTERNAL_AFFILIATE_IMPORT_NONCE_FIELD, 'affilunに読み込む', 'button button-primary' ); $this->renderExternalAffiliateNoticeForm( $formAction, self::EXTERNAL_AFFILIATE_DISMISS_ACTION, self::EXTERNAL_AFFILIATE_DISMISS_NONCE_ACTION, self::EXTERNAL_AFFILIATE_DISMISS_NONCE_FIELD, 'このメッセージを非表示', 'button button-secondary' ); echo '
'; echo '
'; } /** * Whether the operator hid the existing-data notice. */ private function isExternalAffiliateNoticeHidden(): bool { if ( $this->optionStore === null ) { return false; } return (bool) $this->optionStore->get( self::EXTERNAL_AFFILIATE_NOTICE_HIDDEN_OPTION, false ); } /** * Render one form button in the existing-data notice. * * @param string $formAction admin-post endpoint URL. * @param string $action admin-post action. * @param string $nonceAction Nonce action. * @param string $nonceField Nonce field name. * @param string $label Button label. * @param string $buttonClass Button CSS class. */ private function renderExternalAffiliateNoticeForm( string $formAction, string $action, string $nonceAction, string $nonceField, string $label, string $buttonClass ): void { echo '
'; echo ''; ( $this->nonceFieldEmitter )( $nonceAction, $nonceField ); echo ''; echo '
'; } /** * Render the tab navigation for the Settings page. */ private function renderTabNavigation(): void { echo ''; } /** * Open a tab panel wrapper. * * @param string $tabKey Tab key from {@see self::TAB_DEFINITIONS}. */ private function renderTabPanelOpen( string $tabKey ): void { if ( ! array_key_exists( $tabKey, self::TAB_DEFINITIONS ) ) { return; } $tab = self::TAB_DEFINITIONS[ $tabKey ]; $isFirst = $tabKey === array_key_first( self::TAB_DEFINITIONS ); $classes = $isFirst ? 'affilun-settings-tab-panel affilun-settings-tab-panel-active' : 'affilun-settings-tab-panel'; echo '
'; } /** * Close the current tab panel wrapper. */ private function renderTabPanelClose(): void { echo '
'; } /** * Render a provider-specific tab panel. * * @param string $providerKey Provider key. * @param PluginSettings $settings Current settings snapshot. */ private function renderProviderTabPanel( string $providerKey, PluginSettings $settings ): void { if ( ! isset( self::PROVIDER_SCHEMA[ $providerKey ], self::TAB_DEFINITIONS[ $providerKey ] ) ) { return; } $this->renderTabPanelOpen( $providerKey ); $this->renderProviderBlock( $providerKey, self::PROVIDER_SCHEMA[ $providerKey ], $settings->provider( $providerKey ) ); $this->renderTabPanelClose(); } /** * Render the standalone Amazon bulk-resync form. * * This form is intentionally outside the settings-save form so * pressing the button does not rotate credentials or alter any * §13 settings; it only enqueues sync jobs for existing Amazon * product rows. * * @param string $formAction admin-post.php URL. */ private function renderAmazonResyncForm( string $formAction ): void { echo '
'; echo '

' . esc_html( 'Amazon 再同期' ) . '

'; echo '

' . esc_html( 'Eligibility 獲得後、既存のAmazon商品をCreators APIで再取得するための同期ジョブを登録します。' . '価格・在庫・画像などの表示は同期完了後に更新されます。' ) . '

'; echo '
'; echo ''; ( $this->nonceFieldEmitter )( SettingsFormToken::AMAZON_RESYNC_NONCE_ACTION, SettingsFormToken::AMAZON_RESYNC_NONCE_FIELD ); echo '

'; echo ''; echo '

'; echo '
'; } /** * Render the §13.5 Rendering section. * * @param PluginSettings $settings Current settings snapshot. */ private function renderRenderingSection( PluginSettings $settings ): void { $rendering = $settings->rendering(); echo '

' . esc_html( '表示設定' ) . '

'; echo '

' . esc_html( '商品カードの初期デザインです。各ブロック側で個別に上書きできます。' ) . '

'; echo '
'; echo '
'; echo ''; $this->renderSelectRow( 'カードテンプレート', $this->fieldName( 'rendering', 'default_template' ), $rendering->defaultTemplate(), self::DEFAULT_TEMPLATE_OPTIONS ); $this->renderSelectRow( 'デザインプリセット', $this->fieldName( 'rendering', 'default_design_preset' ), $rendering->defaultDesignPreset(), self::DESIGN_PRESET_OPTIONS ); $this->renderSelectRow( 'カラーモード', $this->fieldName( 'rendering', 'default_color_scheme' ), $rendering->defaultColorScheme(), self::COLOR_SCHEME_OPTIONS ); $this->renderSelectRow( 'ボタン配置', $this->fieldName( 'rendering', 'default_button_layout' ), $rendering->defaultButtonLayout(), self::BUTTON_LAYOUT_OPTIONS ); $this->renderSelectRow( 'ボタン形状', $this->fieldName( 'rendering', 'default_button_shape' ), $rendering->defaultButtonShape(), self::BUTTON_SHAPE_OPTIONS ); $this->renderCheckboxRow( 'テーマによるテンプレート上書きを有効化', $this->fieldName( 'rendering', 'theme_override_enabled' ), $rendering->themeOverrideEnabled() ); $this->renderCheckboxRow( 'プロバイダーバッジをデフォルトで表示', $this->fieldName( 'rendering', 'show_provider_badge_by_default' ), $rendering->showProviderBadgeByDefault() ); $this->renderCheckboxRow( '免責事項をデフォルトで表示', $this->fieldName( 'rendering', 'show_disclaimer_by_default' ), $rendering->showDisclaimerByDefault() ); $this->renderCheckboxRow( '画像を遅延読み込み', $this->fieldName( 'rendering', 'lazy_load_images' ), $rendering->lazyLoadImages() ); $this->renderSelectRow( 'プレースホルダー画像ポリシー', $this->fieldName( 'rendering', 'placeholder_image_policy' ), $rendering->placeholderImagePolicy(), self::PLACEHOLDER_POLICIES ); echo '
'; echo '
'; $this->renderRenderingPreview( $rendering, $settings ); echo '
'; } /** * Render a live preview for the rendering default controls. * * @param RenderingDefaults $rendering Current rendering defaults. * @param PluginSettings $settings Current settings snapshot. */ private function renderRenderingPreview( RenderingDefaults $rendering, PluginSettings $settings ): void { $rootClass = $this->renderingPreviewRootClass( $rendering ); $providers = $this->renderingPreviewProviders( $settings ); $enabledProviders = array_filter( $providers, static fn ( array $provider ): bool => (bool) $provider['enabled'] ); echo ''; } /** * Render the Settings preview through the same card renderer used by * frontend product-card blocks. * * @param RenderingDefaults $rendering Current rendering defaults. * @param list $providers Provider rows. */ private function renderCanonicalRenderingPreview( RenderingDefaults $rendering, array $providers ): ?string { if ( $this->previewViewModelFactory === null || $this->previewRenderer === null ) { return null; } try { $template = $rendering->defaultTemplate(); $meta = MetaFlags::defaults() ->withShowDescription( true ) ->withShowProviderBadge( $rendering->showProviderBadgeByDefault() ); $config = new EmbedConfigInput( $template, array(), $meta, $this->renderingPreviewExtraRootClass( $rendering ), false ); $context = RenderContext::frontend( $template ); $snapshot = $this->buildRenderingPreviewSnapshot(); $viewModel = $this->previewViewModelFactory->build( $snapshot, $config, $context, array() ); if ( $viewModel instanceof ProductCardViewModel ) { $viewModel = $viewModel->withButtons( $this->buildRenderingPreviewButtons( $providers, $snapshot->title() ) ); } $html = $this->previewRenderer->render( $viewModel, $context ); return $this->decorateRenderingPreviewHtml( $html, $providers ); } catch ( Throwable $unused ) { unset( $unused ); return null; } } /** * Build a deterministic sample product snapshot for the Settings preview. */ private function buildRenderingPreviewSnapshot(): ProductSnapshot { $now = new DateTimeImmutable( '2026-01-01 00:00:00', new DateTimeZone( 'UTC' ) ); $imageUrl = defined( 'AFFILUN_PLUGIN_URL' ) ? (string) constant( 'AFFILUN_PLUGIN_URL' ) . 'assets/img/brand/logo-square.svg' : 'https://affilun.st/wp-content/plugins/affilun/assets/img/brand/logo-square.svg'; return new ProductSnapshot( 'amazon', new ProviderProductId( 'amazon', 'settings-preview-sample' ), 'サンプル商品カードのタイトル', 'affilun サンプルブランド', '商品説明が入ったときの余白、枠線、影、ボタン配置を確認するためのサンプルです。', 'https://affilun.st/sample-product', null, $imageUrl, array(), new PriceInfo( '12800', 'JPY', 'サンプル価格', $now, true, true, null ), new AvailabilityInfo( AvailabilityInfo::STATUS_IN_STOCK, '在庫あり', true ), null, null, array( 'settings-preview' ), array(), $now, array() ); } /** * Build preview buttons for every provider so the Settings JS can * show/hide them as the operator toggles provider enablement. * * @param list $providers Provider rows. * @param string $title Sample product title. * * @return list */ private function buildRenderingPreviewButtons( array $providers, string $title ): array { $buttons = array(); foreach ( $providers as $provider ) { $url = $this->renderingPreviewProviderUrl( $provider['key'] ); if ( $url === null ) { continue; } $buttons[] = new ButtonView( $provider['key'], $provider['label'], $url, ButtonView::TARGET_BLANK, 'nofollow sponsored noopener noreferrer', 'affilun-button affilun-button-' . $provider['key'], sprintf( '%s を %s で見る', $title, $provider['label'] ) ); } return $buttons; } /** * Add Settings-preview data attributes to canonical renderer HTML. * * @param string $html Rendered card HTML. * @param list $providers Provider rows. */ private function decorateRenderingPreviewHtml( string $html, array $providers ): string { $style = $this->renderingPreviewProviderStyle( $providers ); $html = preg_replace( '/ 'https://www.amazon.co.jp/s?k=affilun', 'rakuten' => 'https://search.rakuten.co.jp/search/mall/affilun/', 'yahoo_shopping' => 'https://shopping.yahoo.co.jp/search?p=affilun', 'mercari' => 'https://jp.mercari.com/search?keyword=affilun', default => null, }; } /** * Return provider rows used by the rendering preview. * * @param PluginSettings $settings Current settings snapshot. * * @return list */ private function renderingPreviewProviders( PluginSettings $settings ): array { $rows = array(); foreach ( self::PROVIDER_SCHEMA as $key => $schema ) { $current = $settings->provider( $key ); $color = $current !== null ? (string) $current->secret( 'button_color' ) : ''; $textColor = $current !== null ? (string) $current->secret( 'button_text_color' ) : ''; $rows[] = array( 'key' => $key, 'label' => $this->renderingPreviewProviderButtonLabel( $key, $schema['label'] ), 'enabled' => $current !== null && $current->enabled(), 'color' => ProviderCredentials::normalizeButtonColor( $key, $color ), 'text_color' => ProviderCredentials::normalizeButtonTextColor( $textColor ), ); } return $rows; } /** * Build inline CSS variables for provider preview button colors. * * @param list $providers Provider rows. */ private function renderingPreviewProviderStyle( array $providers ): string { $style = ''; foreach ( $providers as $provider ) { $style .= ProductCardVisualSettings::providerButtonColorDeclaration( $provider['key'], $provider['color'], $provider['text_color'] ); } return $style; } /** * Return the provider preview button label. * * @param string $key Provider key. * @param string $fallback Fallback provider label. */ private function renderingPreviewProviderButtonLabel( string $key, string $fallback ): string { return match ( $key ) { 'amazon' => 'Amazon', 'rakuten' => '楽天市場', 'yahoo_shopping' => 'Yahoo!ショッピング', 'mercari' => 'メルカリ', default => $fallback, }; } /** * Compose the root classes used by the fallback rendering preview card. * * @param RenderingDefaults $rendering Current rendering defaults. */ private function renderingPreviewRootClass( RenderingDefaults $rendering ): string { $extra = $this->renderingPreviewExtraRootClass( $rendering ); return trim( 'affilun-card affilun-card-' . $rendering->defaultTemplate() . ' affilun-provider-amazon ' . $extra ); } /** * Compose the visual-modifier classes passed to the canonical * card ViewModel factory. * * @param RenderingDefaults $rendering Current rendering defaults. */ private function renderingPreviewExtraRootClass( RenderingDefaults $rendering ): string { return ProductCardVisualSettings::rootClasses( $rendering ); } /** * Render the §13.6 Links section. * * @param LinkDefaults $links Current link defaults. */ private function renderLinksSection( LinkDefaults $links ): void { echo '

' . esc_html( 'リンク設定' ) . '

'; echo ''; $this->renderCheckboxRow( '新しいタブで開く', $this->fieldName( 'links', 'open_in_new_tab_default' ), $links->openInNewTabDefault() ); $this->renderCheckboxRow( 'rel="nofollow" を追加', $this->fieldName( 'links', 'add_nofollow' ), $links->addNofollow() ); $this->renderCheckboxRow( 'rel="sponsored" を追加', $this->fieldName( 'links', 'add_sponsored' ), $links->addSponsored() ); $this->renderCheckboxRow( 'rel="noopener" を追加', $this->fieldName( 'links', 'add_noopener' ), $links->addNoopener() ); $this->renderCheckboxRow( 'rel="noreferrer" を追加', $this->fieldName( 'links', 'add_noreferrer' ), $links->addNoreferrer() ); $this->renderCheckboxRow( 'トラッキングをデフォルトで有効化', $this->fieldName( 'links', 'tracking_enabled_default' ), $links->trackingEnabledDefault() ); echo '
'; } /** * Render the §13.7 Sync section. * * @param SyncSettings $sync Current sync settings. */ private function renderSyncSection( SyncSettings $sync ): void { echo '

' . esc_html( '同期 (Sync)' ) . '

'; echo ''; $this->renderCheckboxRow( '同期を有効化', $this->fieldName( 'sync', 'enabled' ), $sync->enabled() ); $this->renderCheckboxRow( 'スケジュール同期を有効化', $this->fieldName( 'sync', 'scheduled_sync_enabled' ), $sync->scheduledSyncEnabled() ); $this->renderNumberRow( 'フレッシュとして扱う期間 (秒)', $this->fieldName( 'sync', 'fresh_ttl_seconds' ), $sync->freshTtlSeconds() ); $this->renderNumberRow( 'ステールとして扱う期間 (秒)', $this->fieldName( 'sync', 'stale_ttl_seconds' ), $sync->staleTtlSeconds() ); $this->renderNumberRow( '期限切れとみなす期間 (秒)', $this->fieldName( 'sync', 'expired_ttl_seconds' ), $sync->expiredTtlSeconds() ); $this->renderNumberRow( '同時実行ジョブ最大数', $this->fieldName( 'sync', 'max_concurrent_jobs' ), $sync->maxConcurrentJobs() ); $this->renderNumberRow( 'バッチサイズ', $this->fieldName( 'sync', 'batch_size' ), $sync->batchSize() ); $this->renderSelectRow( 'リトライポリシー', $this->fieldName( 'sync', 'retry_policy_preset' ), $sync->retryPolicyPreset(), self::RETRY_POLICY_PRESETS ); echo '
'; } /** * Render the §13.8 Cache section. * * @param CacheSettings $cache Current cache settings. */ private function renderCacheSection( CacheSettings $cache ): void { echo '

' . esc_html( 'キャッシュ設定' ) . '

'; echo ''; $this->renderCheckboxRow( '検索キャッシュを有効化', $this->fieldName( 'cache', 'search_cache_enabled' ), $cache->searchCacheEnabled() ); $this->renderNumberRow( '検索キャッシュの有効期間 (秒)', $this->fieldName( 'cache', 'search_cache_ttl_seconds' ), $cache->searchCacheTtlSeconds() ); $this->renderNumberRow( 'ルックアップキャッシュの有効期間 (秒)', $this->fieldName( 'cache', 'lookup_cache_ttl_seconds' ), $cache->lookupCacheTtlSeconds() ); $this->renderCheckboxRow( '描画キャッシュを有効化', $this->fieldName( 'cache', 'render_cache_enabled' ), $cache->renderCacheEnabled() ); $this->renderNumberRow( '描画キャッシュの有効期間 (秒)', $this->fieldName( 'cache', 'render_cache_ttl_seconds' ), $cache->renderCacheTtlSeconds() ); $this->renderNumberRow( 'プロバイダーヘルスキャッシュの有効期間 (秒)', $this->fieldName( 'cache', 'provider_health_cache_ttl_seconds' ), $cache->providerHealthCacheTtlSeconds() ); echo '
'; } /** * Render a single provider's section. * * @param string $providerKey Provider key. * @param array $schema Per-provider schema entry. * @param ProviderCredentials|null $current Currently persisted credentials, or null. * * @phpstan-param array{label: string, secrets: array} $schema */ private function renderProviderBlock( string $providerKey, array $schema, ?ProviderCredentials $current ): void { $this->renderSetupGuideLink( $providerKey ); if ( $providerKey === 'yahoo_shopping' ) { echo '

' . esc_html( 'Yahoo!ショッピングの報酬化には、Yahoo Developer Client ID に加えて、ValueCommerce の sid / pid をこのタブで設定します。' ) . '

'; } if ( $providerKey === 'rakuten' ) { echo '

' . esc_html( '2026年版の楽天ウェブサービスで再登録した Application ID と Access Key を設定してください。楽天の「許可されたWebサイト」には、このWordPressサイトのURLを登録してください。Affiliate ID は成果報酬リンクの取得に使用します。' ) . '

'; } echo ''; $this->renderCheckboxRow( '有効化', $this->fieldName( 'providers', $providerKey, 'enabled' ), $current !== null && $current->enabled() ); foreach ( $schema['secrets'] as $secretKey => $secretLabel ) { if ( $providerKey === 'yahoo_shopping' && in_array( $secretKey, array( 'affiliate_sid', 'affiliate_pid' ), true ) && ! $this->hasYahooValueCommerceCredentials( $current ) ) { continue; } $plain = $current !== null ? (string) $current->secret( $secretKey ) : ''; $name = $this->fieldName( 'providers', $providerKey, $secretKey ); $inputValue = $this->providerInputDefaultValue( $providerKey, $secretKey, $plain ); echo ''; echo ''; echo ''; echo ''; } if ( $providerKey === 'yahoo_shopping' && ! $this->hasYahooValueCommerceCredentials( $current ) ) { $this->renderYahooValueCommercePasteRow(); } $this->renderProviderButtonColorRow( $providerKey, $current ); $this->renderProviderButtonTextColorRow( $providerKey, $current ); echo '
' . esc_html( $secretLabel ) . ''; if ( $plain !== '' ) { echo '' . esc_html( '現在の値: ' ) . esc_html( $plain ) . '
'; } else { echo '' . esc_html( '現在の値: (未設定)' ) . '
'; } // Admin-tier (`manage_options`) Settings page is the only // place these values are surfaced verbatim. The rotating- // input contract (§6.3) still applies: blank submission // keeps the current persisted value. Users explicitly // requested plain display so the saved credentials can be // confirmed against the upstream Associates / RMS / DG // console; treat the admin session accordingly. echo ''; echo '
'; } /** * Render the Yahoo ValueCommerce free-text-link paste helper. */ private function renderYahooValueCommercePasteRow(): void { $fieldName = $this->fieldName( 'providers', 'yahoo_shopping', 'valuecommerce_html' ); echo ''; echo ''; echo ''; echo ''; echo '

' . esc_html( 'ValueCommerceでYahoo!ショッピングの自由テキストリンクHTMLを作成し、そのHTMLコードまたは referral URL を貼り付けて保存してください。affilunはsid/pidのみを抽出し、貼り付けたHTMLは保存しません。' ) . '

'; echo ''; echo ''; } /** * Report whether Yahoo ValueCommerce sid / pid are both configured. * * @param ProviderCredentials|null $current Current Yahoo credentials. */ private function hasYahooValueCommerceCredentials( ?ProviderCredentials $current ): bool { return $current !== null && trim( (string) $current->secret( 'affiliate_sid' ) ) !== '' && trim( (string) $current->secret( 'affiliate_pid' ) ) !== ''; } /** * Render a provider-specific affiliate-button color picker. * * @param string $providerKey Provider key. * @param ProviderCredentials|null $current Current credentials. */ private function renderProviderButtonColorRow( string $providerKey, ?ProviderCredentials $current ): void { $defaultColor = ProviderCredentials::defaultButtonColor( $providerKey ); $currentColor = $current !== null ? (string) $current->secret( 'button_color' ) : ''; $value = ProviderCredentials::normalizeButtonColor( $providerKey, $currentColor ); $fieldName = $this->fieldName( 'providers', $providerKey, 'button_color' ); $inputId = 'affilun-provider-' . str_replace( '_', '-', $providerKey ) . '-button-color'; echo ''; echo ''; echo ''; echo ' '; echo ''; echo '

' . esc_html( '商品カードに表示されるこのプロバイダーのアフィリエイトボタン背景色です。' ) . '

'; echo ''; echo ''; } /** * Render a provider-specific affiliate-button text color selector. * * @param string $providerKey Provider key. * @param ProviderCredentials|null $current Current credentials. */ private function renderProviderButtonTextColorRow( string $providerKey, ?ProviderCredentials $current ): void { $currentValue = $current !== null ? (string) $current->secret( 'button_text_color' ) : ''; $value = ProviderCredentials::normalizeButtonTextColor( $currentValue ); $fieldName = $this->fieldName( 'providers', $providerKey, 'button_text_color' ); $inputId = 'affilun-provider-' . str_replace( '_', '-', $providerKey ) . '-button-text-color'; $options = array( ProviderCredentials::BUTTON_TEXT_COLOR_AUTO => '自動(APCA)', ProviderCredentials::BUTTON_TEXT_COLOR_WHITE => '白', ProviderCredentials::BUTTON_TEXT_COLOR_BLACK => '黒', ); echo ''; echo ''; echo ''; echo ''; echo '

' . esc_html( '自動は APCA ベースで黒または白を選びます。ブランド感を優先したい場合は白/黒を固定できます。' ) . '

'; echo ''; echo ''; } /** * Return the initial input value for provider fields that have * a product-level default. Most credential fields still use the * rotating-input contract (`value=""` means keep current), but * Amazon's version is non-secret and should default to the * Japan-focused Creators API v3.3 value on first setup. * * @param string $providerKey Provider key. * @param string $secretKey Secret-field key. * @param string $plain Currently persisted value, if any. */ private function providerInputDefaultValue( string $providerKey, string $secretKey, string $plain ): string { if ( $providerKey === 'amazon' && $secretKey === 'version' && trim( $plain ) === '' ) { return ProviderCredentials::AMAZON_DEFAULT_VERSION; } return ''; } /** * Render advanced ASP affiliate-routing settings. */ private function renderAffiliateRoutingSection(): void { $this->renderSetupGuideLink( 'asp' ); if ( $this->affiliateRoutingRepository === null ) { echo '

' . esc_html( 'ASPリンク設定リポジトリが利用できません。' ) . '

'; return; } $templates = $this->affiliateRoutingRepository->loadRaw(); echo '

' . esc_html( '通常は各プロバイダーのタブでアフィリエイト設定を行います。ここは独自ASPや移行用URLテンプレートを使う上級者向け設定です。' ) . '

'; echo ''; $this->renderCheckboxRow( '有効化', $this->fieldName( 'affiliate_routing', 'enabled' ), $this->affiliateRoutingRepository->enabled() ); foreach ( OptionStoreAffiliateRoutingRepository::PROVIDERS as $provider ) { $value = $templates[ $provider ] ?? ''; echo ''; } echo ''; } /** * Render affilun Cloud settings. * * @param CloudSettings $cloud Current Cloud settings. */ private function renderCloudSection( CloudSettings $cloud ): void { $siteUrl = self::defaultSiteUrl(); $siteName = self::defaultSiteName(); $adminEmail = self::defaultAdminEmail(); $cloudConfigured = $cloud->isConfigured(); $cloudAccountUrl = self::cloudAccountUrlFromEndpoint( $cloud->endpoint() ); $cloudPlansUrl = self::resolveRestUrl( 'affilun/v1/settings/cloud/plans' ); $checkoutUrl = self::resolveRestUrl( 'affilun/v1/settings/cloud/checkout-session' ); $provisionUrl = self::resolveRestUrl( 'affilun/v1/settings/cloud/provision-site' ); $connectionEmailUrl = self::resolveRestUrl( 'affilun/v1/settings/cloud/connection-email' ); $statusUrl = self::resolveRestUrl( 'affilun/v1/settings/cloud/status' ); $connectionUrl = self::resolveRestUrl( 'affilun/v1/settings/cloud/connection-token' ); $credentialsUrl = self::resolveRestUrl( 'affilun/v1/settings/cloud/credentials' ); $restNonce = function_exists( 'wp_create_nonce' ) ? (string) wp_create_nonce( 'wp_rest' ) : ''; $remoteStatus = $cloudConfigured ? null : self::remoteCloudStatus( $cloud, $siteUrl ); $remoteCloudAccess = is_array( $remoteStatus ) && (bool) ( $remoteStatus['cloud_access'] ?? false ) && (bool) ( $remoteStatus['connection_required'] ?? false ); $remotePlan = is_array( $remoteStatus ) && is_scalar( $remoteStatus['plan'] ?? null ) ? (string) $remoteStatus['plan'] : ''; $this->renderHiddenInput( $this->fieldName( 'cloud', 'enabled' ), $cloud->enabled() ? '1' : '0', 'affilun-cloud-enabled' ); $this->renderHiddenInput( $this->fieldName( 'cloud', 'endpoint' ), $cloud->endpoint(), 'affilun-cloud-endpoint' ); $this->renderHiddenInput( $this->fieldName( 'cloud', 'site_id' ), $cloud->siteId(), 'affilun-cloud-site-id' ); $this->renderHiddenInput( $this->fieldName( 'cloud', 'api_key' ), '', 'affilun-cloud-api-key' ); $this->renderHiddenInput( $this->fieldName( 'cloud', 'plan' ), $cloud->plan(), 'affilun-cloud-plan' ); echo '
'; echo '

'; if ( $cloudConfigured ) { echo esc_html( 'affilun Cloud は設定済みです。現在のプラン: ' . ( $cloud->plan() !== '' ? $cloud->plan() : '不明' ) ); } elseif ( $remoteCloudAccess ) { echo esc_html( 'affilun Cloud の契約は確認済みです。プラグインから利用するには、メール認証してCloudアカウントに接続してください。現在のプラン: ' . ( $remotePlan !== '' ? $remotePlan : '不明' ) ); } else { echo esc_html( 'affilun Cloud は未設定です。最初に、下の入力内容を確認して、メール認証してください。' ); echo '
' . esc_html( 'affilun Cloudは初回登録に限り30日間は無料でお試しいただけます。' ) . ''; } echo '

'; if ( $cloudConfigured ) { echo '

'; echo '' . esc_html( 'Cloudアカウントページを開く' ) . ' '; echo '' . esc_html( '契約内容の変更・解約・アカウント設定は affilun Cloud アカウントページで行います。' ) . '

'; echo '
'; $this->renderCloudArticleDraftDefaults( $cloud ); } else { echo ''; } echo '
'; echo '

' . esc_html( 'affilun Cloud 利用開始ウィザード' ) . '

'; echo ''; echo ''; echo ''; echo ''; echo ''; echo '
'; echo '

' . esc_html( '提供予定の機能' ) . '

'; echo '
    '; echo '
  • ' . esc_html( 'Personal' ) . ' — ' . esc_html( '1サイトまで。AIキーワード抽出、AI記事下書き生成、リンクヘルスチェック、キャンペーン提案、売れ筋アフィリエイト候補、利用状況の確認' ) . '
  • '; echo '
  • ' . esc_html( 'Growth(近日リリース)' ) . ' — ' . esc_html( '5サイトまで。Personal の全機能、分析レコメンド、分析イベント同期、スケジュール型リンクヘルス' ) . '
  • '; echo '
  • ' . esc_html( 'Agency(近日リリース)' ) . ' — ' . esc_html( 'サイト数制限なし。Growth の全機能、より高い利用枠、複数サイト管理' ) . '
  • '; echo '
'; echo '

' . esc_html( $remoteCloudAccess ? 'このサイトは既にCloud側で契約済みです。新規契約ではなく、メール認証後にCloudアカウントへログインして接続を完了してください。' : '契約プランと月額・年額の選択は、メール認証後の affilun Cloud アカウントページで行います。' ) . '

'; echo '
'; echo '

'; echo '

'; echo '
'; echo '
'; } /** * Render operation tool links. */ private function renderOperationsSection(): void { $this->renderOperationSectionOpen( '移行 / スキャン', '既存記事から商品リンク設置候補を探し、移行候補を確認します。' ); if ( $this->migrationRenderer !== null ) { $this->migrationRenderer->renderEmbedded( $this->operationRequest, 'affilun-settings', '#affilun-settings-tab-operations' ); } else { $this->renderOperationUnavailable(); } $this->renderOperationSectionClose(); $this->renderOperationSectionOpen( '診断情報', '環境・設定・接続状態を確認します。' ); if ( $this->diagnosticsRenderer !== null ) { $this->diagnosticsRenderer->renderEmbedded(); } else { $this->renderOperationUnavailable(); } $this->renderOperationSectionClose(); $this->renderOperationSectionOpen( 'ログ', 'プラグインの動作ログを確認します。' ); if ( $this->logsRenderer !== null && $this->logsListQueryParser !== null ) { $this->logsRenderer->renderEmbedded( $this->logsListQueryParser->parse( $this->operationRequest ), 'affilun-settings', '#affilun-settings-tab-operations' ); } else { $this->renderOperationUnavailable(); } $this->renderOperationSectionClose(); $this->renderOperationSectionOpen( '問題ジョブ', '同期や処理キューの失敗を確認します。' ); if ( $this->syncJobsRenderer !== null && $this->syncJobsListQueryParser !== null ) { $this->syncJobsRenderer->renderEmbedded( $this->syncJobsListQueryParser->parse( $this->operationRequest ), 'affilun-settings', '#affilun-settings-tab-operations' ); } else { $this->renderOperationUnavailable(); } $this->renderOperationSectionClose(); $this->renderOperationSectionOpen( '監査イベント', '管理操作や重要イベントを確認します。' ); if ( $this->auditRenderer !== null && $this->auditListQueryParser !== null ) { $this->auditRenderer->renderEmbedded( $this->auditListQueryParser->parse( $this->operationRequest ), 'affilun-settings', '#affilun-settings-tab-operations' ); } else { $this->renderOperationUnavailable(); } $this->renderOperationSectionClose(); } /** * Open an Operations tab subsection. * * @param string $title Section title. * @param string $description Section description. */ private function renderOperationSectionOpen( string $title, string $description ): void { echo '
'; echo '

' . esc_html( $title ) . '

'; echo '

' . esc_html( $description ) . '

'; } /** * Close an Operations tab subsection with a separator. */ private function renderOperationSectionClose(): void { echo '

'; } /** * Render a degraded message when an operation renderer is absent. */ private function renderOperationUnavailable(): void { echo '

' . esc_html( 'この運用ツールは現在利用できません。' ) . '

'; } /** * Render the §13.9 Advanced section. * * @param PluginSettings $settings Current settings snapshot. */ private function renderAdvancedSection( PluginSettings $settings ): void { echo '

' . esc_html( 'デバッグ' ) . '

'; echo ''; $this->renderCheckboxRow( 'デバッグモードを有効化', $this->fieldName( 'debug_mode' ), $settings->debugMode() ); echo '
'; } /** * Render a hidden setting input associated with the main settings form. * * @param string $name HTML name attribute. * @param string $value HTML value attribute. * @param string $id HTML id attribute. */ private function renderHiddenInput( string $name, string $value, string $id ): void { echo ''; } /** * Render affilun Cloud article-draft defaults. * * @param CloudSettings $cloud Current Cloud settings. */ private function renderCloudArticleDraftDefaults( CloudSettings $cloud ): void { $tones = array( 'professional_blog' => 'プロらしいブログ調', 'friendly' => '親しみやすい紹介調', 'review_comparison' => '比較レビュー調', 'concise' => '簡潔な要点整理', ); $lengths = array( 'short' => '短め', 'medium' => '標準', 'long' => '長め', ); $reviewPolicies = array( 'product_url' => '商品URLを参考URLとして送信する', 'none' => '参考URLを送信しない', ); echo '
'; echo '

' . esc_html( 'AI記事生成の設定' ) . '

'; echo '

' . esc_html( '保存済み商品から記事下書きを生成するときの共通設定です。商品ごとの細かな指定は行わず、この設定を既定値として使います。' ) . '

'; echo ''; $this->renderCloudSelectRow( '文体', $this->fieldName( 'cloud', 'article_draft_tone' ), 'affilun-cloud-article-draft-tone', $cloud->articleDraftTone(), $tones ); $this->renderCloudSelectRow( '長さ', $this->fieldName( 'cloud', 'article_draft_target_length' ), 'affilun-cloud-article-draft-target-length', $cloud->articleDraftTargetLength(), $lengths ); $this->renderCloudSelectRow( '参考URL', $this->fieldName( 'cloud', 'article_draft_review_source_policy' ), 'affilun-cloud-article-draft-review-source-policy', $cloud->articleDraftReviewSourcePolicy(), $reviewPolicies ); echo ''; echo '

'; echo ''; echo '

'; echo '
'; } /** * Render a select row in the affilun Cloud tab. * * @param string $label Row label. * @param string $name Field name. * @param string $id Field id. * @param string $value Current value. * @param array $options Option labels by value. */ private function renderCloudSelectRow( string $label, string $name, string $id, string $value, array $options ): void { echo ''; echo ''; } /** * Default site URL for the affilun Cloud onboarding form. */ private static function defaultSiteUrl(): string { if ( function_exists( 'home_url' ) ) { return (string) home_url( '/' ); } if ( function_exists( 'get_option' ) ) { $value = get_option( 'home', '' ); return is_scalar( $value ) ? (string) $value : ''; } return ''; } /** * Default site name for the affilun Cloud onboarding form. */ private static function defaultSiteName(): string { if ( function_exists( 'get_bloginfo' ) ) { return (string) get_bloginfo( 'name' ); } if ( function_exists( 'get_option' ) ) { $value = get_option( 'blogname', '' ); return is_scalar( $value ) ? (string) $value : ''; } return ''; } /** * Default admin email for the affilun Cloud onboarding form. */ private static function defaultAdminEmail(): string { if ( function_exists( 'get_option' ) ) { $value = get_option( 'admin_email', '' ); return is_scalar( $value ) ? (string) $value : ''; } return ''; } /** * Fetch Cloud-side site status for the initial onboarding render. * * @param CloudSettings $cloud Current Cloud settings. * @param string $siteUrl Site URL shown in the onboarding form. * * @return array|null */ private static function remoteCloudStatus( CloudSettings $cloud, string $siteUrl ): ?array { if ( $siteUrl === '' || ! function_exists( 'wp_remote_post' ) || ! function_exists( 'wp_json_encode' ) ) { return null; } $body = wp_json_encode( array( 'site_url' => $siteUrl ) ); if ( ! is_string( $body ) ) { return null; } try { $response = CloudHttpOptions::safePost( $cloud->endpoint() . '/billing/plugin/site-status', array( 'timeout' => 5, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => $body, ) ); if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) { return null; } $status = is_array( $response ) && function_exists( 'wp_remote_retrieve_response_code' ) ? (int) wp_remote_retrieve_response_code( $response ) : 0; if ( $status < 200 || $status >= 300 ) { return null; } $raw = is_array( $response ) && function_exists( 'wp_remote_retrieve_body' ) ? (string) wp_remote_retrieve_body( $response ) : ''; $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { return null; } $payload = $data['data'] ?? $data; return is_array( $payload ) ? $payload : null; } catch ( Throwable $unused ) { unset( $unused ); return null; } } /** * Resolve the affilun Cloud account app URL from an API endpoint. * * @param string $endpoint affilun Cloud API endpoint. */ private static function cloudAccountUrlFromEndpoint( string $endpoint ): string { $base = preg_replace( '#/api/v1/?\z#', '', rtrim( $endpoint, '/' ) ); if ( ! is_string( $base ) || $base === '' ) { $base = 'https://api.affilun.st'; } return rtrim( $base, '/' ) . '/account/'; } /** * Render a single number-input row. * * @param string $label Visible label. * @param string $name HTML `name` attribute value. * @param int $value Current value. */ private function renderNumberRow( string $label, string $name, int $value ): void { echo ''; echo '' . esc_html( $label ) . ''; echo ''; echo ''; echo ''; echo ''; } /** * Render the setup-guide link associated with an affiliate * settings tab. * * @param string $key Provider or affiliate-routing tab key. */ private function renderSetupGuideLink( string $key ): void { $link = self::SETUP_GUIDE_LINKS[ $key ] ?? null; if ( $link === null ) { return; } echo ''; } /** * Render a single checkbox row. * * @param string $label Visible label. * @param string $name HTML `name` attribute value. * @param bool $checked Whether the box should render checked. */ private function renderCheckboxRow( string $label, string $name, bool $checked ): void { echo ''; // Checkbox rows skip the row-label entirely and let // the cell span both columns so the box hugs the form-table's // left edge. The visible