PressNative options page (Jetpack-style connect entry). * * @return void */ public static function add_settings_subpage() { add_options_page( __( 'PressNative', 'pressnative-apps' ), __( 'PressNative', 'pressnative-apps' ), 'manage_options', 'pressnative-settings', array( __CLASS__, 'render_pressnative_settings_page' ) ); } /** * Handles Connect: registers this site with PressNative Cloud server-to-server. * * The registry proves wp-admin ownership by calling back to /verify-ownership with a * single-use challenge, then returns the API key in the response body. The key never * travels through a browser redirect. New sites get the free plan. * * @return void */ public static function handle_connect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'pressnative-apps' ) ); } check_admin_referer( 'pressnative_connect' ); $hub_opt_in = ! empty( $_POST['pressnative_hub_opt_in'] ); $email = isset( $_POST['pressnative_account_email'] ) ? sanitize_email( wp_unslash( $_POST['pressnative_account_email'] ) ) : ''; $result = self::register_with_registry( $hub_opt_in, $email ); if ( is_wp_error( $result ) ) { set_transient( 'pressnative_connect_error', $result->get_error_message(), 5 * MINUTE_IN_SECONDS ); } wp_safe_redirect( admin_url( 'options-general.php?page=pressnative-settings' ) ); exit; } /** * Calls POST /api/v1/sites/register and stores the returned API key. * * @param bool $hub_opt_in Whether to list the site in the PressNative Hub. * @param string $email Optional account email for service notices. * @return true|WP_Error */ public static function register_with_registry( $hub_opt_in, $email = '' ) { $challenge = wp_generate_password( 48, false ); set_transient( 'pressnative_verify_nonce', $challenge, 5 * MINUTE_IN_SECONDS ); $response = wp_remote_post( rtrim( self::get_registry_url(), '/' ) . '/api/v1/sites/register', array( 'timeout' => 20, 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( array( 'site_url' => home_url(), 'verify_url' => rest_url( 'pressnative/v1/verify-ownership' ), 'challenge' => $challenge, 'hub_opt_in' => (bool) $hub_opt_in, 'tags' => PressNative_Options::get_app_categories(), 'site_name' => get_bloginfo( 'name' ), 'admin_email' => $email, 'wp_version' => get_bloginfo( 'version' ), 'plugin_version' => PRESSNATIVE_VERSION, ) ), ) ); delete_transient( 'pressnative_verify_nonce' ); if ( is_wp_error( $response ) ) { return new WP_Error( 'pressnative_connect_unreachable', /* translators: %s: error message */ sprintf( __( 'Could not reach PressNative Cloud: %s', 'pressnative-apps' ), $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 || empty( $data['api_key'] ) || ! is_string( $data['api_key'] ) ) { $message = is_array( $data ) && ! empty( $data['error'] ) ? (string) $data['error'] : sprintf( 'HTTP %d', $code ); if ( is_array( $data ) && ! empty( $data['hint'] ) ) { $message .= ' ' . (string) $data['hint']; } /* translators: %s: error returned by PressNative Cloud */ return new WP_Error( 'pressnative_connect_failed', sprintf( __( 'Connect failed: %s', 'pressnative-apps' ), $message ) ); } // Saved before the key so this change does not trigger a sync of its own. update_option( PressNative_Options::OPTION_HUB_OPTED_IN, $hub_opt_in ? '1' : '0' ); // Registration already proved ownership; skip the separate /api/verify-site round trip. remove_action( 'update_option_' . self::OPTION_API_KEY, array( __CLASS__, 'trigger_site_verification' ), 10 ); remove_action( 'add_option_' . self::OPTION_API_KEY, array( __CLASS__, 'trigger_site_verification_on_add' ), 10 ); $api_key = sanitize_text_field( $data['api_key'] ); update_option( self::OPTION_API_KEY, $api_key ); update_option( 'pressnative_site_verified', current_time( 'mysql' ) ); delete_transient( 'pressnative_connect_error' ); PressNative_Registry_Notify::sync_now( $api_key ); PressNative_AOT_Compiler::run_initial_sweep( 10 ); return true; } /** * Handles Disconnect: clears API key and redirects to Settings > PressNative. * * @return void */ public static function handle_disconnect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'pressnative-apps' ) ); } check_admin_referer( 'pressnative_disconnect' ); delete_option( self::OPTION_API_KEY ); wp_safe_redirect( admin_url( 'options-general.php?page=pressnative-settings' ) ); exit; } /** * Renders the Settings > PressNative page: Connect hero or Connected state with masked key and Disconnect. * * @return void */ public static function render_pressnative_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $api_key = get_option( self::OPTION_API_KEY, '' ); $connect_error = get_transient( 'pressnative_connect_error' ); ?>


Terms and Privacy Policy.', 'pressnative-apps' ), array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array() ) ) ), esc_url( 'https://pressnative.app/terms' ), esc_url( 'https://pressnative.app/privacy' ) ); ?>


8 ? substr( $api_key, 0, 8 ) . '****' . substr( $api_key, -4 ) : '****' . substr( $api_key, -4 ); ?>

$item ) { if ( ! in_array( $slug, $order, true ) ) { $reordered[] = $item; } } $submenu['pressnative'] = $reordered; $submenu['pressnative'][0][0] = __( 'Dashboard', 'pressnative-apps' ); } /** * Registers Registry URL setting. * * @return void */ public static function register_settings() { register_setting( 'pressnative_settings', self::OPTION_REGISTRY_URL, array( 'type' => 'string', 'sanitize_callback' => array( __CLASS__, 'sanitize_registry_url' ), 'default' => self::DEFAULT_REGISTRY_URL, ) ); register_setting( 'pressnative_settings', self::OPTION_API_KEY, array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); // App Settings (branding) register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_APP_NAME, array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => PressNative_Options::DEFAULT_APP_NAME, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_PRIMARY_COLOR, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_primary_color' ), 'default' => PressNative_Options::DEFAULT_PRIMARY_COLOR, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_ACCENT_COLOR, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_hex' ), 'default' => PressNative_Options::DEFAULT_ACCENT_COLOR, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_LOGO_ATTACHMENT, array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'default' => 0, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_BACKGROUND_COLOR, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_hex' ), 'default' => PressNative_Options::DEFAULT_BACKGROUND_COLOR, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_BACKGROUND_IMAGE, array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'default' => 0, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_TILE_BACKGROUND_COLOR, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_hex' ), 'default' => PressNative_Options::DEFAULT_TILE_BACKGROUND_COLOR, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_TILE_BACKGROUND, array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'default' => 0, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_TEXT_COLOR, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_text_color' ), 'default' => PressNative_Options::DEFAULT_TEXT_COLOR, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_BORDER_COLOR, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_hex' ), 'default' => PressNative_Options::DEFAULT_BORDER_COLOR, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_TILE_TEXT_COLOR, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_hex' ), 'default' => PressNative_Options::DEFAULT_TILE_TEXT_COLOR, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_FONT_FAMILY, array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { $allowed = array( 'sans-serif', 'serif', 'monospace' ); return in_array( $v, $allowed, true ) ? $v : PressNative_Options::DEFAULT_FONT_FAMILY; }, 'default' => PressNative_Options::DEFAULT_FONT_FAMILY, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_BASE_FONT_SIZE, array( 'type' => 'integer', 'sanitize_callback' => function ( $v ) { $v = absint( $v ); return max( 12, min( 24, $v ) ); }, 'default' => PressNative_Options::DEFAULT_BASE_FONT_SIZE, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_APP_CATEGORIES, array( 'type' => 'array', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_app_categories' ), 'default' => PressNative_Options::DEFAULT_APP_CATEGORIES, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_NOTIFICATION_PREFERENCES, array( 'type' => 'array', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_notification_preferences' ), 'default' => PressNative_Options::DEFAULT_NOTIFICATION_PREFERENCES, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_HUB_OPTED_IN, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_hub_opted_in' ), 'default' => PressNative_Options::DEFAULT_HUB_OPTED_IN ? '1' : '0', ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_SHORT_DESCRIPTION, array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_FULL_DESCRIPTION, array( 'type' => 'string', 'sanitize_callback' => 'sanitize_textarea_field', 'default' => '', ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_KEYWORDS, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_store_keywords' ), 'default' => '', ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_CATEGORY, array( 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'default' => PressNative_Options::DEFAULT_STORE_CATEGORY, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_PRIVACY_POLICY_URL, array( 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'default' => '', ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_SUPPORT_URL, array( 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'default' => '', ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_MARKETING_URL, array( 'type' => 'string', 'sanitize_callback' => 'esc_url_raw', 'default' => '', ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_COPYRIGHT, array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_ICON_ATTACHMENT_ID, array( 'type' => 'integer', 'sanitize_callback' => 'absint', 'default' => 0, ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_STORE_CONTENT_RATING, array( 'type' => 'string', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_content_rating' ), 'default' => wp_json_encode( PressNative_Options::DEFAULT_CONTENT_RATING ), ) ); register_setting( 'pressnative_app_settings', PressNative_Options::OPTION_APP_LINK_SETTINGS, array( 'type' => 'array', 'sanitize_callback' => array( 'PressNative_Options', 'sanitize_app_link_settings' ), 'default' => PressNative_Options::DEFAULT_APP_LINK_SETTINGS, ) ); register_setting( 'pressnative_commerce_settings', PressNative_Cart_Recovery::OPTION_ENABLED, array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { return '0' === (string) $v ? '0' : '1'; }, 'default' => '1', ) ); register_setting( 'pressnative_commerce_settings', PressNative_Cart_Recovery::OPTION_DELAY_MINUTES, array( 'type' => 'integer', 'sanitize_callback' => function ( $v ) { return max( 15, min( 24 * 60, absint( $v ) ) ); }, 'default' => 60, ) ); register_setting( 'pressnative_commerce_settings', PressNative_Cart_Recovery::OPTION_BACK_IN_STOCK_ENABLED, array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { return '0' === (string) $v ? '0' : '1'; }, 'default' => '1', ) ); // Product Display Settings (WooCommerce) register_setting( 'pressnative_app_settings', 'pressnative_product_in_post_style', array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { $allowed = array( 'compact_row', 'card', 'mini_card' ); return in_array( $v, $allowed, true ) ? $v : 'compact_row'; }, 'default' => 'compact_row', ) ); register_setting( 'pressnative_app_settings', 'pressnative_product_grid_style', array( 'type' => 'string', 'sanitize_callback' => function ( $v ) { $allowed = array( 'compact_row', 'card', 'mini_card' ); return in_array( $v, $allowed, true ) ? $v : 'card'; }, 'default' => 'card', ) ); // Layout Settings register_setting( 'pressnative_layout_settings', PressNative_Layout_Options::OPTION_HERO_CATEGORY_SLUG, array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => PressNative_Layout_Options::DEFAULT_HERO_CATEGORY_SLUG, ) ); register_setting( 'pressnative_layout_settings', PressNative_Layout_Options::OPTION_HERO_MAX_ITEMS, array( 'type' => 'integer', 'sanitize_callback' => function ( $v ) { $v = absint( $v ); return max( 1, min( 10, $v ) ); }, 'default' => PressNative_Layout_Options::DEFAULT_HERO_MAX_ITEMS, ) ); register_setting( 'pressnative_layout_settings', PressNative_Layout_Options::OPTION_POST_GRID_COLUMNS, array( 'type' => 'integer', 'sanitize_callback' => function ( $v ) { $v = absint( $v ); return max( 1, min( 4, $v ) ); }, 'default' => PressNative_Layout_Options::DEFAULT_POST_GRID_COLUMNS, ) ); register_setting( 'pressnative_layout_settings', PressNative_Layout_Options::OPTION_POST_GRID_PER_PAGE, array( 'type' => 'integer', 'sanitize_callback' => function ( $v ) { $v = absint( $v ); return max( 1, min( 50, $v ) ); }, 'default' => PressNative_Layout_Options::DEFAULT_POST_GRID_PER_PAGE, ) ); register_setting( 'pressnative_layout_settings', PressNative_Layout_Options::OPTION_ENABLED_CATEGORIES, array( 'type' => 'array', 'sanitize_callback' => array( __CLASS__, 'sanitize_category_ids' ), 'default' => array(), ) ); register_setting( 'pressnative_layout_settings', PressNative_Layout_Options::OPTION_ENABLED_COMPONENTS, array( 'type' => 'array', 'sanitize_callback' => array( __CLASS__, 'sanitize_component_ids' ), 'default' => PressNative_Layout_Options::COMPONENT_IDS, ) ); register_setting( 'pressnative_layout_settings', PressNative_Layout_Options::OPTION_SPONSOR_ARTICLE_INTERVAL, array( 'type' => 'integer', 'sanitize_callback' => function ( $v ) { $v = absint( $v ); return max( 0, min( 50, $v ) ); }, 'default' => PressNative_Layout_Options::DEFAULT_SPONSOR_ARTICLE_INTERVAL, ) ); } /** * Sanitizes category IDs array. * * @param mixed $value Raw value. * @return int[] */ public static function sanitize_category_ids( $value ) { if ( empty( $value ) || ! is_array( $value ) ) { return array(); } $ids = array_map( 'absint', $value ); $ids = array_filter( $ids ); return array_values( array_unique( $ids ) ); } /** * Sanitizes component IDs (comma-separated string or array). * * @param mixed $value Raw value (string "id1,id2" or array). * @return string[] */ public static function sanitize_component_ids( $value ) { $ids = array(); if ( is_string( $value ) && '' !== trim( $value ) ) { $ids = array_map( 'trim', explode( ',', $value ) ); } elseif ( is_array( $value ) ) { $ids = array_map( 'sanitize_text_field', $value ); } $valid = array_values( array_intersect( array_filter( $ids ), PressNative_Layout_Options::COMPONENT_IDS ) ); return empty( $valid ) ? PressNative_Layout_Options::COMPONENT_IDS : $valid; } /** * Sanitizes Registry URL (ensure no trailing slash, valid URL). * * @param string $value Raw input. * @return string */ public static function sanitize_registry_url( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return self::DEFAULT_REGISTRY_URL; } $value = rtrim( $value, '/' ); if ( ! preg_match( '#^https?://#', $value ) ) { return 'http://' . $value; } return $value; } const SUBSCRIPTION_CACHE_KEY = 'pressnative_subscription_status'; const SUBSCRIPTION_CACHE_TTL = 300; /** * Fetches the subscription status from the Registry via Stripe API. * Cached for 5 minutes to avoid Registry calls on every admin page load. * * @return array|null Subscription data or null on failure. */ /** * Whether the connected site's plan includes paid features (ad-hoc push, analytics). * Unknown (registry unreachable or older registry) counts as yes so nothing is hidden. * * @return bool */ public static function has_paid_features() { $status = self::fetch_subscription_status(); if ( ! is_array( $status ) || ! array_key_exists( 'paid_features', $status ) ) { return true; } return (bool) $status['paid_features']; } /** * Inline (non-dismissible, page-scoped) note explaining what the free plan includes. * * @param string $feature Human-readable feature name. * @return void */ private static function render_free_plan_note( $feature ) { if ( '' === (string) get_option( self::OPTION_API_KEY, '' ) || self::has_paid_features() ) { return; } ?>

See plans.', 'pressnative-apps' ), array( 'a' => array( 'href' => array(), 'target' => array(), 'rel' => array() ) ) ), esc_html( $feature ), esc_url( 'https://pressnative.app/pricing' ) ); ?>

10, 'headers' => array( 'X-PressNative-API-Key' => $api_key, ), ) ); if ( is_wp_error( $response ) ) { return null; } $code = wp_remote_retrieve_response_code( $response ); if ( $code !== 200 ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( is_array( $data ) ) { set_transient( $cache_key, $data, self::SUBSCRIPTION_CACHE_TTL ); return $data; } return null; } /** * Creates a Stripe Customer Portal session URL. * * @return string|null Portal URL or null on failure. */ public static function get_stripe_portal_url() { $api_key = get_option( self::OPTION_API_KEY, '' ); $registry_url = self::get_registry_url(); if ( empty( $api_key ) || empty( $registry_url ) ) { return null; } $url = rtrim( $registry_url, '/' ) . '/api/stripe/portal'; $response = wp_remote_post( $url, array( 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json', 'X-PressNative-API-Key' => $api_key, ), 'body' => wp_json_encode( array( 'return_url' => admin_url( 'admin.php?page=pressnative' ), ) ), ) ); if ( is_wp_error( $response ) ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); return isset( $data['url'] ) ? $data['url'] : null; } /** * Renders the PressNative settings page. * * @return void */ public static function render_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $registry_url = get_option( self::OPTION_REGISTRY_URL, self::DEFAULT_REGISTRY_URL ); $api_key = get_option( self::OPTION_API_KEY, '' ); $verified = get_option( self::OPTION_SCHEMA_VERIFIED, '' ); $site_verified = get_option( 'pressnative_site_verified', '' ); $sub_status = ! empty( $api_key ) ? self::fetch_subscription_status() : null; ?>

—

—

0 ) : ?>
/ (%)
= 10 ) : ?> %

0 ) : ?>

rest_url(), 'nonce' => wp_create_nonce( 'wp_rest' ), 'woocommerceActive' => class_exists( 'PressNative_WooCommerce' ) && PressNative_WooCommerce::is_active(), ) ); } if ( $layout_settings ) { wp_enqueue_script( 'jquery-ui-sortable' ); wp_add_inline_script( 'jquery-ui-sortable', self::get_component_order_script(), 'after' ); wp_add_inline_style( 'pressnative-preview', '.pressnative-sortable-placeholder{height:40px;background:#f0f0f1;border:2px dashed #c3c4c7;margin-bottom:6px;list-style:none;}' . '.pressnative-sortable-list{list-style:none;margin:0;padding:0;}' . '.pressnative-sortable-item{margin-bottom:6px;padding:8px;background:#f6f7f7;border:1px solid #c3c4c7;border-radius:4px;}' . '.pressnative-drag-handle{cursor:move;margin-right:8px;color:#787c82;}' . '.pressnative-sortable-item label{cursor:pointer;margin:0;}' ); } if ( $analytics_page ) { wp_enqueue_script( 'pressnative-chartjs', plugins_url( 'assets/js/vendor/chart.umd.min.js', PRESSNATIVE_PLUGIN_DIR . 'pressnative.php' ), array(), '4.5.1', true ); $analytics_js = PRESSNATIVE_PLUGIN_DIR . 'assets/js/analytics-dashboard.js'; $analytics_css = PRESSNATIVE_PLUGIN_DIR . 'assets/css/analytics-dashboard.css'; if ( file_exists( $analytics_js ) ) { wp_enqueue_script( 'pressnative-analytics', plugins_url( 'assets/js/analytics-dashboard.js', PRESSNATIVE_PLUGIN_DIR . 'pressnative.php' ), array( 'pressnative-chartjs' ), filemtime( $analytics_js ), true ); wp_localize_script( 'pressnative-analytics', 'pressnativeAnalytics', array( 'restUrl' => rest_url( 'pressnative/v1' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'editPostUrl' => admin_url( 'post.php' ), ) ); } if ( file_exists( $analytics_css ) ) { wp_enqueue_style( 'pressnative-analytics', plugins_url( 'assets/css/analytics-dashboard.css', PRESSNATIVE_PLUGIN_DIR . 'pressnative.php' ), array(), filemtime( $analytics_css ) ); } } if ( $app_settings ) { wp_enqueue_style( 'wp-color-picker' ); wp_enqueue_script( 'wp-color-picker' ); wp_enqueue_media(); wp_add_inline_script( 'wp-color-picker', 'jQuery(function($){var t;function schedule(){if(t)clearTimeout(t);t=setTimeout(function(){t=null;if(typeof window.pressnativePreviewRefresh==="function")window.pressnativePreviewRefresh();},300);}$(".pressnative-color-picker").wpColorPicker({change:schedule,clear:schedule});});' ); wp_add_inline_script( 'wp-color-picker', self::get_logo_upload_script() . "\n" . self::get_media_upload_script() . "\n" . self::get_app_categories_script() . "\n" . self::get_app_links_script(), 'after' ); } } /** * Inline script for component order sortable and hidden input sync. * * @return string */ private static function get_component_order_script() { return " jQuery(function($){ var \$list = $('#pressnative-component-order'); var \$hidden = $('#pressnative-component-order-value'); function updateOrder() { var order = []; \$list.find('li').each(function(){ var cb = $(this).find('input[type=checkbox]'); if (cb.length && cb.prop('checked')) { order.push(cb.val()); } }); \$hidden.val(order.join(',')); if (typeof window.pressnativePreviewRefresh === 'function') { window.pressnativePreviewRefresh(); } } \$list.sortable({ handle: '.pressnative-drag-handle', placeholder: 'pressnative-sortable-placeholder', items: '> li' }); \$list.on('sortupdate', updateOrder); \$list.on('change', 'input[type=checkbox]', updateOrder); }); "; } /** * Inline script for logo upload (WP Media Library). * * @return string */ private static function get_logo_upload_script() { return " jQuery(function($){ var frame; $('#pressnative_logo_select').on('click', function(e){ e.preventDefault(); if (frame) { frame.open(); return; } frame = wp.media({ title: 'Select or upload logo', library: { type: 'image' }, button: { text: 'Use this image' }, multiple: false }); frame.on('select', function(){ var att = frame.state().get('selection').first().toJSON(); $('#pressnative_logo_attachment_id').val(att.id); $('#pressnative_logo_preview').html(''); if (typeof window.pressnativePreviewRefresh === 'function') { window.pressnativePreviewRefresh(); } }); frame.open(); }); $('#pressnative_logo_remove').on('click', function(e){ e.preventDefault(); $('#pressnative_logo_attachment_id').val(''); $('#pressnative_logo_preview').html(''); if (typeof window.pressnativePreviewRefresh === 'function') { window.pressnativePreviewRefresh(); } }); }); "; } /** * Inline script for generic media upload buttons (background image, tile background). * * @return string */ private static function get_media_upload_script() { return " jQuery(function($){ var frames = {}; $('.pressnative-media-upload').on('click', function(e){ e.preventDefault(); var \$btn = $(this); var target = \$btn.attr('data-target'); var preview = \$btn.attr('data-preview'); var title = \$btn.attr('data-title') || 'Select or upload image'; var buttonText = \$btn.attr('data-button') || 'Use this image'; if (frames[target]) { frames[target].open(); return; } frames[target] = wp.media({ title: title, library: { type: 'image' }, button: { text: buttonText }, multiple: false }); frames[target].on('select', function(){ var att = frames[target].state().get('selection').first().toJSON(); $(target).val(att.id); if (preview) { $(preview).attr('src', att.url).show(); \$btn.siblings('.pressnative-media-remove').show(); } if (typeof window.pressnativePreviewRefresh === 'function') { window.pressnativePreviewRefresh(); } }); frames[target].open(); }); $('.pressnative-media-remove').on('click', function(e){ e.preventDefault(); var \$btn = $(this); var target = \$btn.attr('data-target'); var preview = \$btn.attr('data-preview'); if (target) $(target).val(''); if (preview) $(preview).attr('src', '').hide(); \$btn.hide(); if (typeof window.pressnativePreviewRefresh === 'function') { window.pressnativePreviewRefresh(); } }); }); "; } /** * Inline script for App Categories tag pills (pill display, add via Enter/comma, remove). * * @return string */ private static function get_app_categories_script() { return " jQuery(function($){ var \$hidden = $('#pressnative_app_categories'); var \$textInput = $('#pressnative_app_categories_input'); var \$wrapper = $('.pressnative-tag-input-wrapper'); var max = parseInt(\$hidden.attr('data-max') || 5, 10); function getTags() { var val = (\$hidden.val() || '').trim(); return val ? val.split(',').map(function(t){ return t.trim(); }).filter(Boolean) : []; } function setTags(tags) { tags = tags.slice(0, max); \$hidden.val(tags.join(',')); \$wrapper.find('.pressnative-tag-pill-display').remove(); tags.forEach(function(tag) { var safeTag = $('').text(tag).html(); var safeAttr = tag.replace(/\"/g, '"'); var pill = '' + safeTag + ' ×' + ''; $(pill).insertBefore(\$textInput); }); updateSuggestedPills(); } function updateSuggestedPills() { var tags = getTags(); $('.pressnative-tag-pill').each(function() { var tag = String($(this).attr('data-tag') || ''); var active = tags.indexOf(tag) >= 0; $(this).toggleClass('active', active).css({ 'background': active ? '#2271b1' : '', 'color': active ? '#fff' : '', 'border-color': active ? '#2271b1' : '#8c8f94' }); }); } $('.pressnative-tag-pill').on('click', function() { var tag = String($(this).attr('data-tag') || ''); var tags = getTags(); var idx = tags.indexOf(tag); if (idx >= 0) { tags.splice(idx, 1); } else if (tags.length < max) { tags.push(tag); } setTags(tags); }); \$textInput.on('keydown', function(e) { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); var val = ($(this).val() || '').trim(); if (val) { var tags = getTags(); if (tags.indexOf(val) < 0 && tags.length < max) { tags.push(val); setTags(tags); } $(this).val(''); } } else if (e.key === 'Backspace' && !$(this).val()) { var tags = getTags(); if (tags.length) { tags.pop(); setTags(tags); } } }); \$textInput.on('blur', function() { var val = ($(this).val() || '').trim(); if (val) { var tags = getTags(); if (tags.indexOf(val) < 0 && tags.length < max) { tags.push(val); setTags(tags); } $(this).val(''); } }); $(document).on('click', '.pressnative-tag-remove', function(e) { e.preventDefault(); e.stopPropagation(); var tag = String($(this).attr('data-tag') || ''); if (!tag) return; var tags = getTags().filter(function(t) { return t !== tag; }); setTags(tags); }); setTags(getTags()); }); "; } /** * Inline script for App Links verify and download buttons. * * @return string */ private static function get_app_links_script() { $rest_url = esc_url_raw( rest_url( 'pressnative/v1' ) ); $nonce = wp_create_nonce( 'wp_rest' ); return " jQuery(function($){ var restUrl = " . wp_json_encode( $rest_url ) . "; var nonce = " . wp_json_encode( $nonce ) . "; $('#pressnative-verify-app-links').on('click', function(){ var \$btn = $(this); var \$status = $('#pressnative-app-links-status'); \$btn.prop('disabled', true); \$status.text('Verifying...'); $.ajax({ url: restUrl + '/verify-app-links', method: 'POST', beforeSend: function(xhr){ xhr.setRequestHeader('X-WP-Nonce', nonce); }, success: function(r){ if (r.ok) { \$status.html('✓ Association files verified'); } else { var msgs = []; if (r.results && r.results.aasa && r.results.aasa.errors) msgs = msgs.concat(r.results.aasa.errors); if (r.results && r.results.assetlinks && r.results.assetlinks.errors) msgs = msgs.concat(r.results.assetlinks.errors); \$status.html('' + (msgs.join('; ') || 'Verification failed') + ''); } }, error: function(xhr){ \$status.html('Error: ' + (xhr.responseJSON && xhr.responseJSON.message ? xhr.responseJSON.message : 'Request failed') + ''); }, complete: function(){ \$btn.prop('disabled', false); } }); }); }); "; } /** * Renders the App Settings page (branding: app name, colors, logo). * * @return void */ public static function render_app_settings_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $app_name = get_option( PressNative_Options::OPTION_APP_NAME, PressNative_Options::DEFAULT_APP_NAME ); $primary_color = get_option( PressNative_Options::OPTION_PRIMARY_COLOR, PressNative_Options::DEFAULT_PRIMARY_COLOR ); $accent_color = get_option( PressNative_Options::OPTION_ACCENT_COLOR, PressNative_Options::DEFAULT_ACCENT_COLOR ); $logo_attachment = (int) get_option( PressNative_Options::OPTION_LOGO_ATTACHMENT, 0 ); $logo_preview = ''; if ( $logo_attachment > 0 ) { $url = wp_get_attachment_image_url( $logo_attachment, 'thumbnail' ); if ( $url ) { $logo_preview = ''; } } ?>

' . esc_html__( 'Launch kit applied. Review colors and layout below, then save if you customize further.', 'pressnative-apps' ) . '

'; } ?>

$kit ) : ?>

__( 'News', 'pressnative-apps' ), 'business' => __( 'Business', 'pressnative-apps' ), 'education' => __( 'Education', 'pressnative-apps' ), 'entertainment' => __( 'Entertainment', 'pressnative-apps' ), 'lifestyle' => __( 'Lifestyle', 'pressnative-apps' ), 'shopping' => __( 'Shopping', 'pressnative-apps' ), 'sports' => __( 'Sports', 'pressnative-apps' ), 'utilities' => __( 'Utilities', 'pressnative-apps' ), ); ?>

false, 'parent' => 0 ) ); $component_labels = array( 'nav-menu' => __( 'Nav Menu', 'pressnative-apps' ), 'hero-carousel' => __( 'Hero Carousel', 'pressnative-apps' ), 'post-grid' => __( 'Post Grid', 'pressnative-apps' ), 'category-list' => __( 'Category List', 'pressnative-apps' ), 'page-list' => __( 'Page List', 'pressnative-apps' ), 'block-sponsor' => __( 'Block Sponsor', 'pressnative-apps' ), 'product-grid' => __( 'Product Grid (WooCommerce)', 'pressnative-apps' ), 'product-category-list' => __( 'Product Categories (WooCommerce)', 'pressnative-apps' ), 'product-carousel' => __( 'Product Carousel (WooCommerce)', 'pressnative-apps' ), ); $sponsor_interval = PressNative_Layout_Options::get_sponsor_article_interval(); ?>

get_error_message() ), admin_url( 'admin.php?page=pressnative-app-settings' ) ) ); exit; } wp_safe_redirect( add_query_arg( 'pressnative_kit_applied', '1', admin_url( 'admin.php?page=pressnative-app-settings' ) ) ); exit; } /** * Handles ad-hoc push form submission. POSTs to Registry with API key. * * @return void */ public static function handle_send_push() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'pressnative-apps' ) ); } if ( ! isset( $_POST['pressnative_push_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['pressnative_push_nonce'] ) ), 'pressnative_send_push' ) ) { wp_safe_redirect( add_query_arg( 'pressnative_push_error', 'nonce', admin_url( 'admin.php?page=pressnative-push' ) ) ); exit; } $api_key = get_option( self::OPTION_API_KEY, '' ); $registry_url = self::get_registry_url(); if ( ! $api_key || ! $registry_url ) { wp_safe_redirect( add_query_arg( 'pressnative_push_error', 'no_api_key', admin_url( 'admin.php?page=pressnative-push' ) ) ); exit; } $title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : ''; $body = isset( $_POST['body'] ) ? sanitize_textarea_field( wp_unslash( $_POST['body'] ) ) : ''; if ( ! $title || ! $body ) { wp_safe_redirect( add_query_arg( 'pressnative_push_error', 'missing_fields', admin_url( 'admin.php?page=pressnative-push' ) ) ); exit; } $link = isset( $_POST['link'] ) ? esc_url_raw( wp_unslash( $_POST['link'] ) ) : ''; $image_url = isset( $_POST['image_url'] ) ? esc_url_raw( wp_unslash( $_POST['image_url'] ) ) : ''; $device_type = isset( $_POST['device_type'] ) ? sanitize_text_field( wp_unslash( $_POST['device_type'] ) ) : 'all'; if ( ! in_array( $device_type, array( 'all', 'ios', 'android' ), true ) ) { $device_type = 'all'; } $engaged_since_days = isset( $_POST['engaged_since_days'] ) ? sanitize_text_field( wp_unslash( $_POST['engaged_since_days'] ) ) : ''; if ( in_array( $engaged_since_days, array( '7', '30' ), true ) ) { $engaged_since_days = (int) $engaged_since_days; } else { $engaged_since_days = null; } $url = rtrim( $registry_url, '/' ) . '/api/v1/push/send'; $body_data = array( 'title' => $title, 'body' => $body, 'link' => $link, 'image_url' => $image_url, 'device_type' => $device_type, 'engaged_since_days' => $engaged_since_days, ); $response = wp_remote_post( $url, array( 'timeout' => 15, 'blocking' => true, 'headers' => array( 'Content-Type' => 'application/json', 'X-PressNative-API-Key' => $api_key, ), 'body' => wp_json_encode( $body_data ), ) ); if ( is_wp_error( $response ) ) { wp_safe_redirect( add_query_arg( 'pressnative_push_error', 'request_failed', admin_url( 'admin.php?page=pressnative-push' ) ) ); exit; } $code = wp_remote_retrieve_response_code( $response ); $body_res = wp_remote_retrieve_body( $response ); $data = json_decode( $body_res, true ); if ( $code >= 200 && $code < 300 ) { $sent = isset( $data['sent'] ) ? (int) $data['sent'] : 0; wp_safe_redirect( add_query_arg( 'pressnative_push_sent', $sent, admin_url( 'admin.php?page=pressnative-push' ) ) ); exit; } if ( ! empty( $data['message'] ) ) { $err_msg = (string) $data['message']; } else { $err_msg = isset( $data['error'] ) ? $data['error'] : __( 'Unknown error', 'pressnative-apps' ); } wp_safe_redirect( add_query_arg( array( 'pressnative_push_error' => 'registry', 'pressnative_push_message' => rawurlencode( $err_msg ) ), admin_url( 'admin.php?page=pressnative-push' ) ) ); exit; } /** * Renders the Push Notifications page (ad-hoc send). * * @return void */ public static function render_push_page() { if ( ! current_user_can( 'manage_options' ) ) { return; } $api_key = get_option( self::OPTION_API_KEY, '' ); $registry_url = self::get_registry_url(); $has_config = ! empty( $api_key ) && ! empty( $registry_url ); // phpcs:disable WordPress.Security.NonceVerification.Recommended -- display-only result params from redirect after sending push $error = isset( $_GET['pressnative_push_error'] ) ? sanitize_text_field( wp_unslash( $_GET['pressnative_push_error'] ) ) : ''; $sent = isset( $_GET['pressnative_push_sent'] ) ? (int) $_GET['pressnative_push_sent'] : 0; $err_msg = isset( $_GET['pressnative_push_message'] ) ? sanitize_text_field( wp_unslash( $_GET['pressnative_push_message'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $stats = $has_config ? self::fetch_push_subscriber_stats() : null; $stats = is_array( $stats ) ? $stats : array( 'total' => 0, 'ios' => 0, 'android' => 0, 'engaged_7d' => 0, 'engaged_30d' => 0 ); ?>

0 ) : ?>

0 || ( $stats['engaged_30d'] ?? 0 ) > 0 ) : ?>

0 ) : ?>

$config ) : ?>

—
—
—
—
—

—
—
—

—  ·  —

—
—
—
—

—
—
—
—
—
—

10, 'headers' => array( 'Accept' => 'application/json', 'X-PressNative-API-Key' => $api_key, ), ) ); if ( is_wp_error( $response ) ) { return null; } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return null; } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! is_array( $data ) ) { return null; } return array( 'total' => isset( $data['total'] ) ? (int) $data['total'] : 0, 'ios' => isset( $data['ios'] ) ? (int) $data['ios'] : 0, 'android' => isset( $data['android'] ) ? (int) $data['android'] : 0, 'engaged_7d' => isset( $data['engaged_7d'] ) ? (int) $data['engaged_7d'] : 0, 'engaged_30d' => isset( $data['engaged_30d'] ) ? (int) $data['engaged_30d'] : 0, ); } /** * Returns the configured Registry URL. * Production default is https://pressnative.app. * On localhost, uses the user-configured value (which defaults to localhost:3000 for dev). * * @return string */ public static function get_registry_url() { if ( self::is_localhost() ) { $saved = get_option( self::OPTION_REGISTRY_URL, '' ); return ! empty( $saved ) ? $saved : 'http://localhost:3000'; } return self::DEFAULT_REGISTRY_URL; } /** * Checks if the current WordPress site is running on localhost. * * @return bool */ public static function is_localhost() { $home = home_url(); $parsed = wp_parse_url( $home ); $host = isset( $parsed['host'] ) ? strtolower( $parsed['host'] ) : ''; return in_array( $host, array( 'localhost', '127.0.0.1' ), true ); } /** * Handles the Stripe portal redirect when ?pressnative_portal=1 is present. * * @return void */ public static function handle_stripe_portal_redirect() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- redirect trigger from our settings page; capability checked below if ( empty( $_GET['pressnative_portal'] ) || '1' !== $_GET['pressnative_portal'] ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } // Only handle on our settings page. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- page slug check for redirect target if ( empty( $_GET['page'] ) || 'pressnative' !== $_GET['page'] ) { return; } $portal_url = self::get_stripe_portal_url(); if ( $portal_url ) { wp_safe_redirect( $portal_url ); exit; } // If portal URL failed, redirect back with an error. wp_safe_redirect( add_query_arg( 'pressnative_portal_error', '1', admin_url( 'admin.php?page=pressnative' ) ) ); exit; } /** * Pings the Registry to verify schema (GET /api/v1/schema). Saves verification timestamp on success. * * @return bool True if schema was verified, false otherwise. */ public static function verify_registry_schema() { $base = self::get_registry_url(); $url = rtrim( $base, '/' ) . '/api/v1/schema'; $response = wp_remote_get( $url, array( 'timeout' => 10, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { return false; } $code = wp_remote_retrieve_response_code( $response ); if ( $code !== 200 ) { return false; } $body = wp_remote_retrieve_body( $response ); $json = json_decode( $body, true ); if ( ! is_array( $json ) || ! isset( $json['branding'] ) || ! isset( $json['screen'] ) || ! isset( $json['components'] ) ) { return false; } update_option( self::OPTION_SCHEMA_VERIFIED, current_time( 'mysql' ) ); return true; } /** * Triggers site verification when the API key is saved/updated. * * @param string $old_value Previous API key. * @param string $new_value New API key. * @return bool True if verification succeeded. */ public static function trigger_site_verification( $old_value, $new_value ) { return self::attempt_site_verification( sanitize_text_field( $new_value ) ); } /** * Triggers site verification when the API key is first added (not just updated). * * @param string $option Option name. * @param string $value Option value. * @return bool True if verification succeeded. */ public static function trigger_site_verification_on_add( $option, $value ) { return self::attempt_site_verification( sanitize_text_field( $value ) ); } /** * Admin action handler: manual "Verify Now" button. * * @return void */ public static function handle_verify_site() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'pressnative-apps' ) ); } check_admin_referer( 'pressnative_verify_site' ); $api_key = get_option( self::OPTION_API_KEY, '' ); $result = self::attempt_site_verification( $api_key ); if ( ! $result ) { $error = get_transient( 'pressnative_verify_error' ); if ( empty( $error ) ) { set_transient( 'pressnative_verify_error', 'Unknown error during verification.', 60 ); } } wp_safe_redirect( admin_url( 'admin.php?page=pressnative' ) ); exit; } /** * Attempts site verification with the Registry. * * Generates a one-time nonce, stores it as a transient, then calls the * Registry's /api/verify-site endpoint. The Registry calls back to * /wp-json/pressnative/v1/verify-ownership to confirm the nonce, * proving this WordPress site has wp-admin access for the given API key. * * @param string $api_key The API key to verify with. * @return bool True if verification succeeded. */ public static function attempt_site_verification( $api_key ) { if ( empty( $api_key ) ) { set_transient( 'pressnative_verify_error', 'No API key provided.', 5 * MINUTE_IN_SECONDS ); return false; } $nonce = wp_generate_password( 48, false ); set_transient( 'pressnative_verify_nonce', $nonce, 5 * MINUTE_IN_SECONDS ); $registry_url = self::get_registry_url(); $verify_url = rtrim( $registry_url, '/' ) . '/api/verify-site'; $site_url = home_url(); $response = wp_remote_post( $verify_url, array( 'timeout' => 15, 'headers' => array( 'Content-Type' => 'application/json', 'X-PressNative-API-Key' => $api_key, ), 'body' => wp_json_encode( array( 'site_url' => $site_url, 'nonce' => $nonce, ) ), ) ); if ( is_wp_error( $response ) ) { set_transient( 'pressnative_verify_error', sprintf( 'Could not reach Registry at %s: %s', $registry_url, $response->get_error_message() ), 5 * MINUTE_IN_SECONDS ); return false; } $code = wp_remote_retrieve_response_code( $response ); $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( $code >= 200 && $code < 300 && ! empty( $data['ok'] ) ) { delete_transient( 'pressnative_verify_error' ); update_option( 'pressnative_site_verified', current_time( 'mysql' ) ); return true; } $error_detail = ! empty( $data['error'] ) ? $data['error'] : ''; $hint = ! empty( $data['hint'] ) ? ' ' . $data['hint'] : ''; set_transient( 'pressnative_verify_error', sprintf( 'Registry returned HTTP %d: %s%s', $code, $error_detail, $hint ), 5 * MINUTE_IN_SECONDS ); return false; } /** * Show cache invalidation notice when settings are updated. * * @return void */ public static function show_cache_invalidation_notice() { $cache_data = get_transient( 'pressnative_cache_invalidated' ); if ( ! $cache_data ) { return; } // Only show on PressNative admin pages $screen = get_current_screen(); if ( ! $screen || strpos( $screen->id, 'pressnative' ) === false ) { return; } $option = $cache_data['option']; $old_value = $cache_data['old_value']; $new_value = $cache_data['new_value']; // Make the option name more user-friendly $option_display = str_replace( array( 'pressnative_product_', '_' ), array( '', ' ' ), $option ); $option_display = ucwords( $option_display ); ?>

get_header( 'X-PressNative-Registry-Key' ); $admin_key = defined( 'PRESSNATIVE_ADMIN_KEY' ) ? PRESSNATIVE_ADMIN_KEY : getenv( 'ADMIN_API_KEY' ); if ( is_string( $admin_key ) && '' !== $admin_key && is_string( $registry_key ) && hash_equals( $admin_key, $registry_key ) ) { return true; } return current_user_can( 'manage_options' ); } /** * REST permission callback for POST /pressnative/v1/verify-ownership. * Requires the single-use verification nonce stored by wp-admin. * * @param WP_REST_Request $request Request. * @return true|WP_Error */ public static function permission_check_verify_ownership( WP_REST_Request $request ) { $params = $request->get_json_params(); $nonce = is_array( $params ) && isset( $params['nonce'] ) ? sanitize_text_field( (string) $params['nonce'] ) : ''; if ( '' === $nonce ) { return new WP_Error( 'missing_nonce', 'nonce is required', array( 'status' => 400 ) ); } $stored_nonce = get_transient( 'pressnative_verify_nonce' ); if ( empty( $stored_nonce ) || ! is_string( $stored_nonce ) || ! hash_equals( $stored_nonce, $nonce ) ) { return new WP_Error( 'invalid_nonce', 'Verification nonce is invalid or expired', array( 'status' => 403 ) ); } return true; } }