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