'dashboard', self::SLUG_CONTENT => 'content', self::SLUG_OFFERS => 'offers', self::SLUG_ACTIVITY => 'activity', self::SLUG_BUYERS => 'buyers', self::SLUG_WALLET => 'wallet', self::SLUG_SETTINGS => 'settings', self::SLUG_HELP => 'help', ]; /** Submenu definitions: slug → render method. Labels via submenu_label() because __() can't live in a constant. */ private const SUBMENUS = [ self::SLUG_DASHBOARD => 'render_dashboard', self::SLUG_CONTENT => 'render_content', self::SLUG_OFFERS => 'render_offers', self::SLUG_ACTIVITY => 'render_activity', self::SLUG_BUYERS => 'render_buyers', self::SLUG_WALLET => 'render_wallet', self::SLUG_SETTINGS => 'render_settings', self::SLUG_HELP => 'render_help', ]; private function submenu_label( string $slug ): string { switch ( $slug ) { case self::SLUG_DASHBOARD: return __( 'Dashboard', 'propertyrightsweb' ); case self::SLUG_CONTENT: return __( 'Content', 'propertyrightsweb' ); case self::SLUG_OFFERS: return __( 'Offers', 'propertyrightsweb' ); case self::SLUG_ACTIVITY: return __( 'Activity', 'propertyrightsweb' ); case self::SLUG_BUYERS: return __( 'Buyers', 'propertyrightsweb' ); case self::SLUG_WALLET: return __( 'Wallet', 'propertyrightsweb' ); case self::SLUG_SETTINGS: return __( 'Settings', 'propertyrightsweb' ); case self::SLUG_HELP: return __( 'Help', 'propertyrightsweb' ); default: return ucfirst( str_replace( Prweb_Brand::MENU_SLUG . '-', '', $slug ) ); } } private Prweb_Admin_Assets $assets; private Prweb_Admin_OAuth_Callback $oauth_callback; public function __construct( private readonly Prweb_Admin_Stats $stats, private readonly Prweb_Events_Store $events, ) { $this->assets = new Prweb_Admin_Assets(); $this->oauth_callback = new Prweb_Admin_OAuth_Callback( $this->events ); } // ------------------------------------------------------------------------- // Passthrough accessors called via $this inside template includes // ------------------------------------------------------------------------- public function catalog_summary(): array { return $this->stats->catalog_summary(); } public function activity_24h(): array { return $this->stats->activity_24h(); } public function money_kpis_30d(): array { return $this->stats->money_kpis_30d(); } public function asset_stats_30d( array $assets ): array { return $this->stats->asset_stats_30d( $assets ); } public function ttfl_proxy_summary( int $window_days = 7 ): array { return $this->stats->ttfl_proxy_summary( $window_days ); } public function daily_driver_activation_30d(): array { return $this->stats->daily_driver_activation_30d(); } public function recent_events( int $limit = 10, ?string $kind_prefix = null ): array { return $this->events->recent_events( $limit, $kind_prefix ); } // ------------------------------------------------------------------------- // Static forwarding shims — templates call Prweb_Plugin::humanize_event() // ------------------------------------------------------------------------- public static function humanize_event( string $kind, $payload, ?string $occurred_at = null ): array { return Prweb_Events_Store::humanize_event( $kind, $payload, $occurred_at ); } public static function relative_time( string $occurred_at ): string { return Prweb_Events_Store::relative_time( $occurred_at ); } public function register_hooks(): void { add_action( 'admin_menu', [ $this, 'admin_menu' ] ); add_action( 'admin_init', [ $this, 'register_settings' ] ); add_action( 'admin_init', [ $this, 'maybe_redirect_legacy_tab' ] ); add_action( 'admin_enqueue_scripts', [ $this->assets, 'enqueue' ] ); // Menu-label wrap guard — every admin screen, not just ours: the sidebar // renders on all of them (ORL-170). add_action( 'admin_enqueue_scripts', [ $this->assets, 'enqueue_menu_label_style' ] ); // OAuth callback handler + hidden landing page own their own hooks. $this->oauth_callback->register_hooks(); // R1.5 — emit `offer.terms_set` (and `offer.priced` on price delta) when // the publisher saves Settings → Offer defaults. Discriminator is `site-default`. add_action( 'update_option_' . Prweb_Offer_Defaults::OPTION_KEY, [ $this, 'on_offer_defaults_updated' ], 10, 2 ); add_action( 'add_option_' . Prweb_Offer_Defaults::OPTION_KEY, [ $this, 'on_offer_defaults_added' ], 10, 2 ); add_action( 'admin_notices', [ $this, 'maybe_render_config_gap_notice' ] ); } /** * Surface a hard "you forgot to configure something before launch" notice * when a `staging` / `prod` install is missing a required `wp-config.php` * value. Mirrors Prweb_Ajax_Payouts::maybe_render_stub_token_notice — the * localhost fallbacks that keep local dev frictionless are a * misconfiguration anywhere else, and every downstream call fails obscurely * without this pointer. */ public function maybe_render_config_gap_notice(): void { if ( ! current_user_can( 'manage_options' ) || ! Prweb_Config::has_blocking_gaps() ) { return; } // blocking_rows() mixes two distinct causes under SEVERITY_ERROR — a // value the operator never set, and one they set but got wrong // (present === true). "Not defined" is only accurate for the first; // saying it about the second tells the operator to add a define() // that is already there. $blocking = Prweb_Config::blocking_rows(); $missing = array_values( array_filter( $blocking, static fn( array $row ): bool => ! $row['present'] ) ); $malformed = array_values( array_filter( $blocking, static fn( array $row ): bool => $row['present'] ) ); $names = static fn( array $rows ): string => '' . implode( ', ', array_map( static fn( array $row ): string => esc_html( $row['constant'] ), $rows ) ) . ''; $parts = []; if ( [] !== $missing ) { $parts[] = sprintf( /* translators: %s is a comma-separated list of wp-config.php constant names. */ __( 'not defined: %s', 'propertyrightsweb' ), $names( $missing ) ); } if ( [] !== $malformed ) { $parts[] = sprintf( /* translators: %s is a comma-separated list of wp-config.php constant names. */ __( 'defined but malformed: %s', 'propertyrightsweb' ), $names( $malformed ) ); } $settings_url = admin_url( 'admin.php?page=' . Prweb_Brand::page( 'settings' ) . '§ion=api-webhooks' ); $message = sprintf( /* translators: 1: environment name, 2: one or two clauses listing what's wrong (already translated), 3: link to the diagnostics screen. */ __( 'running as %1$s — %2$s. Review before going live — %3$s.', 'propertyrightsweb' ), '' . esc_html( Prweb_Config::environment() ) . '', implode( '; ', $parts ), '' . esc_html__( 'review required configuration', 'propertyrightsweb' ) . '' ); echo '

' . esc_html( Prweb_Brand::NAME ) . ': ' . wp_kses( $message, [ 'code' => [], 'a' => [ 'href' => [] ] ] ) . '

'; } /** WP fires add_option_* on first save, update_option_* afterwards — both route to the diff emit. */ public function on_offer_defaults_added( string $option, $value ): void { $this->emit_offer_defaults_changes( [], is_array( $value ) ? $value : [] ); } public function on_offer_defaults_updated( $old, $new ): void { $this->emit_offer_defaults_changes( is_array( $old ) ? $old : [], is_array( $new ) ? $new : [] ); } private function emit_offer_defaults_changes( array $old, array $new ): void { // Hydrate both blobs through canonicalize() so missing fields default // identically and the diff isn't fooled by absent keys. $before = Prweb_Offer_Defaults::canonicalize( $old ); $after = Prweb_Offer_Defaults::canonicalize( $new ); $scope = (string) ( home_url( '/' ) ); if ( Prweb_Offer_Defaults::diff_priced( $before, $after ) ) { $this->events->audit( 'offer.priced', [ 'offer_id' => 'site-default', 'scope' => $scope, 'price_leos' => (int) $after['price_leos'], 'prior_price_leos' => isset( $old['price_leos'] ) ? (int) $old['price_leos'] : null, 'source' => 'site-default', ] ); } $changed = Prweb_Offer_Defaults::diff_terms( $before, $after ); if ( ! empty( $changed ) ) { $this->events->audit( 'offer.terms_set', [ 'offer_id' => 'site-default', 'scope' => $scope, 'changed_fields' => $changed, 'source' => 'site-default', ] ); } } /** Redirect legacy `?tab=X` deep links from a moved-out tab; admin_init so headers haven't shipped yet. */ public function maybe_redirect_legacy_tab(): void { if ( ! isset( $_GET['page'], $_GET['tab'] ) ) { return; } $page = sanitize_key( wp_unslash( (string) $_GET['page'] ) ); if ( self::SLUG_SETTINGS !== $page ) { return; } $legacy = sanitize_key( wp_unslash( (string) $_GET['tab'] ) ); if ( ! isset( self::LEGACY_TAB_REDIRECTS[ $legacy ] ) ) { return; } if ( ! current_user_can( self::CAP ) ) { return; } wp_safe_redirect( admin_url( 'admin.php?page=' . self::LEGACY_TAB_REDIRECTS[ $legacy ] ), 301 ); exit; } // ------------------------------------------------------------------------- // Menu // ------------------------------------------------------------------------- public function admin_menu(): void { // Brand display strings flow through Prweb_Brand at the menu boundary // (top-level page title + menu title). The translatable form names // the brand via %s so localizers translate the surrounding copy // without baking the brand value into the .pot. // // The menu title is the full brand (ORL-170). It fits the fixed 160px menu // column because Prweb_Admin_Assets::enqueue_menu_label_style() sets the // desktop sidebar's type scale to 13px — the label itself gets no special // handling. Do not shorten it here to work around a layout problem. add_menu_page( sprintf( /* translators: %s is the long-form plugin display name. */ __( '%s - RSL licensing', 'propertyrightsweb' ), Prweb_Brand::DISPLAY_NAME ), Prweb_Brand::NAME, self::CAP, self::SLUG_DASHBOARD, [ $this, 'render_dashboard' ], 'dashicons-cloud', 30 ); foreach ( self::SUBMENUS as $slug => $callback ) { $label = $this->submenu_label( $slug ); add_submenu_page( self::SLUG_DASHBOARD, $label, $label, self::CAP, $slug, [ $this, $callback ] ); } // Hidden OAuth callback landing page is registered by Prweb_Admin_OAuth_Callback. } // Settings — register_setting() definitions live in Prweb_Settings_Registry. public function register_settings(): void { ( new Prweb_Settings_Registry() )->register(); } public static function sanitize_offer_defaults( $value ): array { return Prweb_Settings_Registry::sanitize_offer_defaults( $value ); } // Page renderers public function render_dashboard(): void { if ( $this->maybe_render_onboarding() ) return; include PRWEB_PATH . 'templates/dashboard.php'; } public function render_content(): void { if ( $this->maybe_render_onboarding() ) return; // ?post= renders the content-detail workspace; absent (or missing row) falls back to list. $detail_post_id = isset( $_GET['post'] ) ? absint( wp_unslash( $_GET['post'] ) ) : 0; if ( $detail_post_id > 0 && get_post( $detail_post_id ) ) { global $wpdb; $meta_table = Prweb_Brand::table( 'metadata' ); $meta = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE post_id = %d', $meta_table, $detail_post_id ) ); if ( $meta ) { $content_detail_post = get_post( $detail_post_id ); $content_detail_meta = $meta; include PRWEB_PATH . 'templates/content-detail.php'; return; } } include PRWEB_PATH . 'templates/content.php'; } public function render_offers(): void { if ( $this->maybe_render_onboarding() ) return; include PRWEB_PATH . 'templates/offers.php'; } public function render_activity(): void { if ( $this->maybe_render_onboarding() ) return; include PRWEB_PATH . 'templates/activity.php'; } public function render_buyers(): void { if ( $this->maybe_render_onboarding() ) return; include PRWEB_PATH . 'templates/buyers.php'; } public function render_wallet(): void { if ( $this->maybe_render_onboarding() ) return; include PRWEB_PATH . 'templates/wallet.php'; } public function render_help(): void { if ( $this->maybe_render_onboarding() ) return; include PRWEB_PATH . 'templates/help.php'; } private function maybe_render_onboarding(): bool { if ( ! Prweb_Ajax_Onboarding::wizard_in_progress() ) { return false; } $oauth_error = isset( $_GET['prweb_oauth_error'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['prweb_oauth_error'] ) ) : ''; $step = $oauth_error !== '' ? Prweb_Wizard_Stages::entry_step() : Prweb_Ajax_Onboarding::get_stage(); $step_ids = Prweb_Wizard_Stages::step_ids(); $step_idx = Prweb_Wizard_Stages::index_of( $step ); $stage = $step; $stages = $step_ids; $stage_idx = $step_idx; $just_connected = isset( $_GET['prweb_connected'] ) && '1' === sanitize_key( wp_unslash( (string) $_GET['prweb_connected'] ) ); include PRWEB_PATH . 'templates/onboarding.php'; return true; } /** * Settings sub-sections — slug → [ display title, one-line description ]. * Built at call time because __() requires the runtime; brand mentions * flow through %s so localizers don't see the brand value baked in. */ private static function build_settings_sections(): array { return [ 'identity' => [ __( 'Identity', 'propertyrightsweb' ), __( 'Connect, sign out, and confirm which PDS account this site speaks for.', 'propertyrightsweb' ), ], 'rsl' => [ __( 'rsl.xml & robots.txt', 'propertyrightsweb' ), __( 'Discovery feed mode, validator URL, static publish, and crawler-facing robots.txt hints.', 'propertyrightsweb' ), ], 'offers' => [ __( 'Offer defaults', 'propertyrightsweb' ), __( 'Site-wide pricing + license terms applied when content registers. Per-content overrides live on the Content tab; per-offer overrides on Offers.', 'propertyrightsweb' ), ], 'payments' => [ __( 'Payments', 'propertyrightsweb' ), __( 'Wallet pointer + payout cadence; deep-links into Stripe Connect.', 'propertyrightsweb' ), ], 'webapp-page' => [ sprintf( /* translators: %s is the brand name (Webapp). */ __( '%s page', 'propertyrightsweb' ), Prweb_Brand::NAME ), sprintf( /* translators: %s is the brand name (Webapp). */ __( 'Your public publisher handle on the standalone %s site.', 'propertyrightsweb' ), Prweb_Brand::NAME ), ], 'validators' => [ __( 'Validators', 'propertyrightsweb' ), __( 'Primary and secondary validators that verify the same RSL feed.', 'propertyrightsweb' ), ], 'notifications' => [ __( 'Notifications', 'propertyrightsweb' ), __( 'Digest routing for licensing activity and validator warnings.', 'propertyrightsweb' ), ], 'api-webhooks' => [ __( 'API & webhooks', 'propertyrightsweb' ), __( 'Signing secret, webhook test fire, and live diagnostics probes.', 'propertyrightsweb' ), ], 'danger' => [ __( 'Danger zone', 'propertyrightsweb' ), __( 'Disconnect, clear local credentials, and review uninstall consequences.', 'propertyrightsweb' ), ], ]; } /** Ordered list of section slugs — kept as a class constant because it doesn't carry any translatable copy. */ private const SETTINGS_SECTION_SLUGS = [ 'identity', 'rsl', 'offers', 'payments', 'webapp-page', 'validators', 'notifications', 'api-webhooks', 'danger', ]; /** Legacy `?tab=` values that map onto a current Settings section. */ private const LEGACY_SECTION_MAP = [ 'account' => 'identity', 'distribution' => 'rsl', 'diagnostics' => 'api-webhooks', 'webhooks' => 'api-webhooks', ]; /** Legacy tabs that have moved out of Settings entirely (slug → page). */ private const LEGACY_TAB_REDIRECTS = [ 'wallet' => self::SLUG_WALLET, ]; /** Public accessor so templates can iterate without duplicating the table. */ public static function settings_sections(): array { return self::build_settings_sections(); } public function render_settings(): void { if ( $this->maybe_render_onboarding() ) return; // Legacy `?tab=wallet` etc. that 301-redirect to a top-level tab are // handled at admin_init (maybe_redirect_legacy_tab) so the redirect // fires before admin-header.php sends headers. By the time we get // here those URLs have already navigated away. $section = ''; if ( isset( $_GET['section'] ) ) { $section = sanitize_key( wp_unslash( $_GET['section'] ) ); } elseif ( isset( $_GET['tab'] ) ) { $legacy = sanitize_key( wp_unslash( $_GET['tab'] ) ); $section = self::LEGACY_SECTION_MAP[ $legacy ] ?? ''; } $settings_sections = self::build_settings_sections(); if ( ! isset( $settings_sections[ $section ] ) ) { $section = 'identity'; } $settings_section = $section; // exposed to template // Legacy alias used by templates we haven't yet migrated. $settings_tab = $section; include PRWEB_PATH . 'templates/settings.php'; } }