'', 'min_feedback_length' => 50, 'days_after_install' => 3, 'snooze_days' => 30, 'snooze_schedule' => [], // Progressive schedule e.g. [7, 30, 90]. When non-empty, enables event-triggered mode. 'nps_question' => '', 'low_score_threshold' => 7, // 0–6 = detractors, 7–10 = promoters 'position' => 'middle', // middle | bottom-right | bottom-left | top-right | top-left 'review_url' => '', 'support_url' => '', 'privacy_url' => 'https://rextheme.com/privacy-policy/', 'installed_option_key' => '', 'condition_callback' => null, 'allowed_screens' => [], ]; // ------------------------------------------------------------------------- // Properties // ------------------------------------------------------------------------- private Client $client; private string $slug; private array $config; /** @var bool|null Cached decision for the current request. */ private ?bool $should_show_cache = null; /** * Trigger context resolved during should_show() for use during render. * * @var array|null */ private ?array $current_trigger = null; // ------------------------------------------------------------------------- // Bootstrap // ------------------------------------------------------------------------- public function __construct( Client $client, array $config = [] ) { $this->client = $client; $this->slug = $client->get_slug(); $this->config = array_merge( self::DEFAULTS, $config ); // Fill dynamic defaults that depend on slug / plugin name. if ( empty( $this->config['nps_question'] ) ) { $this->config['nps_question'] = sprintf( 'How likely are you to recommend %s to your friends or colleagues?', $client->get_plugin_name() ); } if ( empty( $this->config['review_url'] ) ) { $this->config['review_url'] = 'https://wordpress.org/support/plugin/' . $this->slug . '/reviews/#new-post'; } if ( empty( $this->config['installed_option_key'] ) ) { $this->config['installed_option_key'] = $this->slug . '_installed_time'; } } /** * Register WordPress hooks. */ public function init(): void { $this->maybe_bootstrap_snooze(); add_action( 'admin_enqueue_scripts', [ $this, 'maybe_output_style' ] ); add_action( 'admin_footer', [ $this, 'render_prompt' ] ); add_action( 'wp_ajax_' . $this->get_ajax_action(), [ $this, 'handle_ajax' ] ); } // ------------------------------------------------------------------------- // Identifier helpers // ------------------------------------------------------------------------- private function get_status_option(): string { return self::OPTION_STATUS . $this->slug; } private function get_snooze_option(): string { return self::OPTION_SNOOZE . $this->slug; } private function get_ajax_action(): string { return $this->slug . '_review_action'; } private function get_nonce_action(): string { return $this->slug . '_review_nonce'; } /** JS global variable name (hyphens are not valid in JS identifiers). */ private function get_js_global(): string { return 'linnoReview_' . str_replace( '-', '_', $this->slug ); } private function get_snooze_count_option(): string { return self::OPTION_SNOOZE_COUNT . $this->slug; } private function get_trigger_option(): string { return self::OPTION_TRIGGER . $this->slug; } private function get_bootstrapped_option(): string { return self::OPTION_BOOTSTRAPPED . $this->slug; } // ------------------------------------------------------------------------- // First-run bootstrap // ------------------------------------------------------------------------- /** * Seed an initial snooze for existing installations when event-triggered * mode is activated for the first time. * * Without this, an old plugin install that already satisfies a trigger * condition (e.g. 7+ days old with no funnels) would show the prompt * immediately on the very first page load after the code update. * * Logic: * - Runs only when snooze_schedule is set (event-triggered mode). * - Runs only once per site (guarded by OPTION_BOOTSTRAPPED). * - If the plugin was installed more than one day ago, seeds * the snooze timestamp to "now" so the first interval starts * from today rather than from the past install date. * - New installs (≤ 1 day old) are unaffected — the schedule * runs naturally from first trigger. * * @return void */ private function maybe_bootstrap_snooze(): void { if ( empty( $this->config['snooze_schedule'] ) ) { return; } if ( get_option( $this->get_bootstrapped_option() ) ) { return; } // Mark as bootstrapped immediately to avoid running again. update_option( $this->get_bootstrapped_option(), '1' ); $installed_time = (int) get_option( $this->config['installed_option_key'], 0 ); if ( ! $installed_time ) { return; } $age_days = ( time() - $installed_time ) / DAY_IN_SECONDS; // Only seed for existing installs — new installs (≤ 1 day) start clean. if ( $age_days > 1.0 ) { update_option( $this->get_snooze_option(), time() ); } } // ------------------------------------------------------------------------- // Event-triggered prompt API // ------------------------------------------------------------------------- /** * Store a pending trigger that will cause the prompt to appear on the next * eligible admin page load. * * Call this method from any WordPress hook that represents a meaningful user * success or failure event (e.g. order placed, feature used, no funnel created). * * The $context array may contain prompt copy overrides: * - modal_title (string) Modal header title. * - nps_question (string) The NPS question shown to the user. * - feedback_msg (string) Message above the detractor feedback textarea. * - thank_you_title (string) * - thank_you_text (string) * * @param string $event_key Short slug identifying the trigger type (e.g. 'funnel_order'). * @param array $context Optional metadata and copy-override keys. * @return void */ public function trigger_prompt( string $event_key, array $context = [] ): void { if ( 'completed' === get_option( $this->get_status_option() ) ) { return; } update_option( $this->get_trigger_option(), wp_json_encode( [ 'event_key' => sanitize_key( $event_key ), 'context' => $context, 'triggered_at' => time(), ] ), false // Do not autoload — only needed on admin pages. ); } // ------------------------------------------------------------------------- // Progressive snooze helper // ------------------------------------------------------------------------- /** * Return the effective snooze duration in days for the given dismiss count. * * When a snooze_schedule array is configured (e.g. [7, 30, 90]) the Nth * dismissal uses schedule[N-1]; once the last value is reached it repeats. * Falls back to the legacy scalar snooze_days when no schedule is set. * * @param int $snooze_count Total number of times the user has dismissed. * @return int Days to snooze. */ private function compute_effective_snooze_days( int $snooze_count ): int { $schedule = (array) $this->config['snooze_schedule']; if ( empty( $schedule ) ) { return (int) $this->config['snooze_days']; } $index = max( 0, $snooze_count - 1 ); $index = min( $index, count( $schedule ) - 1 ); return (int) $schedule[ $index ]; } // ------------------------------------------------------------------------- // Visibility logic // ------------------------------------------------------------------------- /** * Determine whether the prompt should be rendered on this page load. */ public function should_show(): bool { // Developer test-mode bypass: ?{slug}_test_review=1 // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET[ $this->slug . '_test_review' ] ) && '1' === $_GET[ $this->slug . '_test_review' ] ) { return $this->set_cache( true ); } if ( null !== $this->should_show_cache ) { return $this->should_show_cache; } if ( ! current_user_can( 'manage_options' ) ) { return $this->set_cache( false ); } if ( ! $this->is_allowed_screen() ) { return $this->set_cache( false ); } if ( 'completed' === get_option( $this->get_status_option() ) ) { return $this->set_cache( false ); } // ----------------------------------------------------------------- // Event-triggered mode: snooze_schedule is set. // The prompt only shows when trigger_prompt() has stored a pending // trigger and the progressive snooze window has expired. // ----------------------------------------------------------------- if ( ! empty( $this->config['snooze_schedule'] ) ) { return $this->set_cache( $this->should_show_event_triggered() ); } // ----------------------------------------------------------------- // Default page-load mode (legacy). // ----------------------------------------------------------------- $snooze_time = (int) get_option( $this->get_snooze_option(), 0 ); if ( $snooze_time && time() < $snooze_time + ( (int) $this->config['snooze_days'] * DAY_IN_SECONDS ) ) { return $this->set_cache( false ); } // If a custom condition callback is provided, delegate entirely to it. if ( is_callable( $this->config['condition_callback'] ) ) { return $this->set_cache( (bool) call_user_func( $this->config['condition_callback'] ) ); } // Default gate: show only after N days from plugin install. $installed_time = (int) get_option( $this->config['installed_option_key'], 0 ); if ( ! $installed_time || time() < $installed_time + ( (int) $this->config['days_after_install'] * DAY_IN_SECONDS ) ) { return $this->set_cache( false ); } return $this->set_cache( true ); } /** * Visibility decision for event-triggered mode. * * Returns true when a pending trigger exists and was stored after the * current snooze window expired. * * @return bool */ private function should_show_event_triggered(): bool { $trigger_json = get_option( $this->get_trigger_option() ); if ( ! $trigger_json ) { return false; } $trigger = json_decode( $trigger_json, true ); if ( ! is_array( $trigger ) || empty( $trigger['triggered_at'] ) ) { return false; } $trigger_time = (int) $trigger['triggered_at']; // Check whether the trigger occurred inside an active snooze window. // Bootstrap-seeded snooze (snooze_count = 0) does NOT block event triggers — // only a user-initiated dismiss (snooze_count > 0) should suppress the prompt. $snooze_set_at = (int) get_option( $this->get_snooze_option(), 0 ); if ( $snooze_set_at ) { $snooze_count = (int) get_option( $this->get_snooze_count_option(), 0 ); if ( $snooze_count > 0 ) { $snooze_days = $this->compute_effective_snooze_days( $snooze_count ); $snooze_until = $snooze_set_at + ( $snooze_days * DAY_IN_SECONDS ); if ( $trigger_time < $snooze_until ) { return false; } } } // Persist resolved trigger for use during render. $this->current_trigger = $trigger; return true; } private function set_cache( bool $value ): bool { $this->should_show_cache = $value; return $value; } /** * Check whether the current admin screen is in the allowed list. * An empty allowed_screens array means "show everywhere in wp-admin". */ private function is_allowed_screen(): bool { if ( ! is_admin() ) { return false; } $allowed = (array) $this->config['allowed_screens']; if ( empty( $allowed ) ) { return true; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( $page && in_array( $page, $allowed, true ) ) { return true; } if ( function_exists( 'get_current_screen' ) ) { $screen = get_current_screen(); if ( $screen && in_array( $screen->id, $allowed, true ) ) { return true; } } return false; } // ------------------------------------------------------------------------- // Inline CSS // ------------------------------------------------------------------------- /** * Output the prompt stylesheet as an inline should_show() ) { return; } $slug = esc_attr( $this->slug ); $min_chars = (int) $this->config['min_feedback_length']; $low_threshold = (int) $this->config['low_score_threshold']; // 0 – (threshold-1) = low $position = sanitize_key( $this->config['position'] ?: 'middle' ); // Dynamic copy: trigger context values override static config defaults. $ctx = is_array( $this->current_trigger ) ? (array) ( $this->current_trigger['context'] ?? [] ) : []; $nps_question = esc_html( ! empty( $ctx['nps_question'] ) ? $ctx['nps_question'] : $this->config['nps_question'] ); $modal_title = esc_html( ! empty( $ctx['modal_title'] ) ? $ctx['modal_title'] : 'Share Your Feedback' ); $feedback_msg = esc_html( ! empty( $ctx['feedback_msg'] ) ? $ctx['feedback_msg'] : "We're sorry to hear that. What's not working for you?" ); $thank_you_title = esc_html( ! empty( $ctx['thank_you_title'] ) ? $ctx['thank_you_title'] : 'Thank you for your support!' ); $thank_you_text = esc_html( ! empty( $ctx['thank_you_text'] ) ? $ctx['thank_you_text'] : "We're thrilled you love it! Your review helps other users discover us." ); $privacy_url = esc_url( $this->config['privacy_url'] ); $support_url = esc_url( $this->config['support_url'] ); ?>
get_nonce_action(), 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Unauthorized' ); return; } $type = isset( $_POST['linno_action_type'] ) ? sanitize_text_field( wp_unslash( $_POST['linno_action_type'] ) ) : ''; if ( 'snooze' === $type ) { // In event-triggered mode, increment the dismiss counter so the // next snooze interval is fetched from the progressive schedule. if ( ! empty( $this->config['snooze_schedule'] ) ) { $snooze_count = (int) get_option( $this->get_snooze_count_option(), 0 ) + 1; update_option( $this->get_snooze_count_option(), $snooze_count ); } update_option( $this->get_snooze_option(), time() ); } elseif ( 'completed' === $type ) { update_option( $this->get_status_option(), 'completed' ); $nps_score = isset( $_POST['nps_score'] ) && is_numeric( $_POST['nps_score'] ) ? (int) $_POST['nps_score'] : null; if ( null !== $nps_score ) { $this->track_nps_to_posthog( $nps_score, '' ); } } elseif ( 'feedback' === $type ) { update_option( $this->get_status_option(), 'completed' ); $feedback = isset( $_POST['feedback'] ) ? sanitize_textarea_field( wp_unslash( $_POST['feedback'] ) ) : ''; $nps_score = isset( $_POST['nps_score'] ) && is_numeric( $_POST['nps_score'] ) ? (int) $_POST['nps_score'] : null; $this->track_nps_to_posthog( $nps_score, $feedback ); if ( ! empty( $feedback ) && ! empty( $this->config['webhook'] ) ) { $this->send_feedback( $feedback, $nps_score ); } } wp_send_json_success(); } // ------------------------------------------------------------------------- // PostHog NPS tracking // ------------------------------------------------------------------------- /** * Send the NPS submission to PostHog via the parent Client. * * Event name : nps_survey_submitted * Properties : * nps_score int 0–10 raw score. * nps_category string promoter | passive | detractor. * feedback string Detractor feedback text (empty for promoters). * trigger_event string The event_key that triggered this prompt. * snooze_count int How many times the user dismissed before submitting. * product_slug string Plugin slug. * * Uses track_immediate() so the event is dispatched in the same request, * bypassing the background queue to guarantee delivery on form submit. * * @param int|null $nps_score Raw score (0–10), or null if not captured. * @param string $feedback Detractor feedback text. * @return void */ private function track_nps_to_posthog( ?int $nps_score, string $feedback ): void { $low_threshold = (int) $this->config['low_score_threshold']; if ( null !== $nps_score ) { if ( $nps_score < $low_threshold ) { $category = 'detractor'; } elseif ( $nps_score <= 8 ) { $category = 'passive'; } else { $category = 'promoter'; } } else { $category = 'unknown'; } // Resolve the trigger event key from the stored trigger payload. $trigger_json = get_option( $this->get_trigger_option() ); $trigger_data = $trigger_json ? json_decode( $trigger_json, true ) : []; $trigger_event = isset( $trigger_data['event_key'] ) ? sanitize_key( $trigger_data['event_key'] ) : 'unknown'; $snooze_count = (int) get_option( $this->get_snooze_count_option(), 0 ); $properties = [ 'nps_score' => $nps_score, 'nps_category' => $category, 'feedback' => $feedback, 'trigger_event' => $trigger_event, 'snooze_count' => $snooze_count, 'product_slug' => $this->slug, ]; try { // track_immediate() sends directly to the configured driver (PostHog) // without queuing, using override=true to bypass the opt-in check // since this is an explicit user action (they chose to submit). $this->client->track_immediate( 'nps_survey_submitted', $properties, true ); } catch ( \Exception $e ) { // Failure-safe — NPS tracking must not surface errors to the user. error_log( '[Linno Review Prompt] PostHog NPS track failed for ' . $this->slug . ': ' . $e->getMessage() ); } } // ------------------------------------------------------------------------- // Feedback delivery // ------------------------------------------------------------------------- private function send_feedback( string $feedback, ?int $nps_score ): void { $current_user = wp_get_current_user(); $payload = [ 'productSlug' => $this->slug, 'productName' => $this->client->get_plugin_name(), 'feedback' => $feedback, 'npsScore' => $nps_score, 'siteUrl' => get_site_url(), 'userEmail' => ( $current_user instanceof \WP_User ) ? $current_user->user_email : '', 'userName' => ( $current_user instanceof \WP_User ) ? $current_user->display_name : '', 'submittedAt' => current_time( 'mysql' ), ]; $is_local = in_array( wp_get_environment_type(), [ 'local', 'development' ], true ); $response = wp_remote_post( $this->config['webhook'], [ 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $payload ), 'timeout' => 8, 'sslverify' => ! $is_local, ] ); if ( is_wp_error( $response ) ) { error_log( '[Linno Review Prompt] webhook failed for ' . $this->slug . ': ' . $response->get_error_message() ); } } }