| 1 |
<?php |
| 2 |
/** |
| 3 |
* ReviewPrompt Class |
| 4 |
* |
| 5 |
* Renders a centered NPS modal in the WordPress admin. Fully self-contained: |
| 6 |
* CSS and JS are output inline so no external asset URLs are required. |
| 7 |
* |
| 8 |
* Step 1 — NPS scale (0–10). "Not likely" … "Very likely". |
| 9 |
* Step 2a — Score 0–6 (detractor): feedback textarea, required, min chars. |
| 10 |
* Step 2b — Score 7–10 (promoter): opens review URL and closes. |
| 11 |
* |
| 12 |
* Config options |
| 13 |
* -------------- |
| 14 |
* webhook (string) Webhook URL that receives feedback payloads. |
| 15 |
* min_feedback_length (int) Minimum textarea chars before submit (default 50). |
| 16 |
* days_after_install (int) Days after install before showing (default 3). |
| 17 |
* snooze_days (int) Days between re-shows after snooze (default 30). |
| 18 |
* nps_question (string) NPS question text. |
| 19 |
* low_score_threshold (int) Scores BELOW this value show the feedback form (default 7). |
| 20 |
* review_url (string) URL opened for high scores. |
| 21 |
* support_url (string) "Contact support" link shown on the feedback form. |
| 22 |
* privacy_url (string) Privacy policy link in the form footer. |
| 23 |
* installed_option_key (string) WP option key holding the install timestamp. |
| 24 |
* condition_callback (callable) Optional. Return true to show, false to hide. |
| 25 |
* allowed_screens (string[]) Admin page slugs / screen IDs. Empty = any admin screen. |
| 26 |
* |
| 27 |
* @package LinnoSDK\Telemetry |
| 28 |
* @since 1.1.0 |
| 29 |
*/ |
| 30 |
|
| 31 |
namespace LinnoSDK\Telemetry; |
| 32 |
|
| 33 |
class ReviewPrompt { |
| 34 |
|
| 35 |
// ------------------------------------------------------------------------- |
| 36 |
// Constants |
| 37 |
// ------------------------------------------------------------------------- |
| 38 |
|
| 39 |
private const OPTION_STATUS = 'linno_review_status_'; |
| 40 |
private const OPTION_SNOOZE = 'linno_review_snooze_'; |
| 41 |
private const OPTION_SNOOZE_COUNT = 'linno_review_snooze_count_'; |
| 42 |
private const OPTION_TRIGGER = 'linno_review_trigger_'; |
| 43 |
|
| 44 |
/** |
| 45 |
* Tracks whether the initial snooze seed has been written for this plugin. |
| 46 |
* Prevents the prompt from firing immediately on existing installations |
| 47 |
* when event-triggered mode is activated for the first time. |
| 48 |
*/ |
| 49 |
private const OPTION_BOOTSTRAPPED = 'linno_review_bootstrapped_'; |
| 50 |
|
| 51 |
private const DEFAULTS = [ |
| 52 |
'webhook' => '', |
| 53 |
'min_feedback_length' => 50, |
| 54 |
'days_after_install' => 3, |
| 55 |
'snooze_days' => 30, |
| 56 |
'snooze_schedule' => [], // Progressive schedule e.g. [7, 30, 90]. When non-empty, enables event-triggered mode. |
| 57 |
'nps_question' => '', |
| 58 |
'low_score_threshold' => 7, // 0–6 = detractors, 7–10 = promoters |
| 59 |
'position' => 'middle', // middle | bottom-right | bottom-left | top-right | top-left |
| 60 |
'review_url' => '', |
| 61 |
'support_url' => '', |
| 62 |
'privacy_url' => 'https://rextheme.com/privacy-policy/', |
| 63 |
'installed_option_key' => '', |
| 64 |
'condition_callback' => null, |
| 65 |
'allowed_screens' => [], |
| 66 |
]; |
| 67 |
|
| 68 |
// ------------------------------------------------------------------------- |
| 69 |
// Properties |
| 70 |
// ------------------------------------------------------------------------- |
| 71 |
|
| 72 |
private Client $client; |
| 73 |
private string $slug; |
| 74 |
private array $config; |
| 75 |
|
| 76 |
/** @var bool|null Cached decision for the current request. */ |
| 77 |
private ?bool $should_show_cache = null; |
| 78 |
|
| 79 |
/** |
| 80 |
* Trigger context resolved during should_show() for use during render. |
| 81 |
* |
| 82 |
* @var array|null |
| 83 |
*/ |
| 84 |
private ?array $current_trigger = null; |
| 85 |
|
| 86 |
// ------------------------------------------------------------------------- |
| 87 |
// Bootstrap |
| 88 |
// ------------------------------------------------------------------------- |
| 89 |
|
| 90 |
public function __construct( Client $client, array $config = [] ) { |
| 91 |
$this->client = $client; |
| 92 |
$this->slug = $client->get_slug(); |
| 93 |
$this->config = array_merge( self::DEFAULTS, $config ); |
| 94 |
|
| 95 |
// Fill dynamic defaults that depend on slug / plugin name. |
| 96 |
if ( empty( $this->config['nps_question'] ) ) { |
| 97 |
$this->config['nps_question'] = sprintf( |
| 98 |
'How likely are you to recommend %s to your friends or colleagues?', |
| 99 |
$client->get_plugin_name() |
| 100 |
); |
| 101 |
} |
| 102 |
if ( empty( $this->config['review_url'] ) ) { |
| 103 |
$this->config['review_url'] = 'https://wordpress.org/support/plugin/' . $this->slug . '/reviews/#new-post'; |
| 104 |
} |
| 105 |
if ( empty( $this->config['installed_option_key'] ) ) { |
| 106 |
$this->config['installed_option_key'] = $this->slug . '_installed_time'; |
| 107 |
} |
| 108 |
} |
| 109 |
|
| 110 |
/** |
| 111 |
* Register WordPress hooks. |
| 112 |
*/ |
| 113 |
public function init(): void { |
| 114 |
$this->maybe_bootstrap_snooze(); |
| 115 |
add_action( 'admin_enqueue_scripts', [ $this, 'maybe_output_style' ] ); |
| 116 |
add_action( 'admin_footer', [ $this, 'render_prompt' ] ); |
| 117 |
add_action( 'wp_ajax_' . $this->get_ajax_action(), [ $this, 'handle_ajax' ] ); |
| 118 |
} |
| 119 |
|
| 120 |
// ------------------------------------------------------------------------- |
| 121 |
// Identifier helpers |
| 122 |
// ------------------------------------------------------------------------- |
| 123 |
|
| 124 |
private function get_status_option(): string { |
| 125 |
return self::OPTION_STATUS . $this->slug; |
| 126 |
} |
| 127 |
|
| 128 |
private function get_snooze_option(): string { |
| 129 |
return self::OPTION_SNOOZE . $this->slug; |
| 130 |
} |
| 131 |
|
| 132 |
private function get_ajax_action(): string { |
| 133 |
return $this->slug . '_review_action'; |
| 134 |
} |
| 135 |
|
| 136 |
private function get_nonce_action(): string { |
| 137 |
return $this->slug . '_review_nonce'; |
| 138 |
} |
| 139 |
|
| 140 |
/** JS global variable name (hyphens are not valid in JS identifiers). */ |
| 141 |
private function get_js_global(): string { |
| 142 |
return 'linnoReview_' . str_replace( '-', '_', $this->slug ); |
| 143 |
} |
| 144 |
|
| 145 |
private function get_snooze_count_option(): string { |
| 146 |
return self::OPTION_SNOOZE_COUNT . $this->slug; |
| 147 |
} |
| 148 |
|
| 149 |
private function get_trigger_option(): string { |
| 150 |
return self::OPTION_TRIGGER . $this->slug; |
| 151 |
} |
| 152 |
|
| 153 |
private function get_bootstrapped_option(): string { |
| 154 |
return self::OPTION_BOOTSTRAPPED . $this->slug; |
| 155 |
} |
| 156 |
|
| 157 |
// ------------------------------------------------------------------------- |
| 158 |
// First-run bootstrap |
| 159 |
// ------------------------------------------------------------------------- |
| 160 |
|
| 161 |
/** |
| 162 |
* Seed an initial snooze for existing installations when event-triggered |
| 163 |
* mode is activated for the first time. |
| 164 |
* |
| 165 |
* Without this, an old plugin install that already satisfies a trigger |
| 166 |
* condition (e.g. 7+ days old with no funnels) would show the prompt |
| 167 |
* immediately on the very first page load after the code update. |
| 168 |
* |
| 169 |
* Logic: |
| 170 |
* - Runs only when snooze_schedule is set (event-triggered mode). |
| 171 |
* - Runs only once per site (guarded by OPTION_BOOTSTRAPPED). |
| 172 |
* - If the plugin was installed more than one day ago, seeds |
| 173 |
* the snooze timestamp to "now" so the first interval starts |
| 174 |
* from today rather than from the past install date. |
| 175 |
* - New installs (≤ 1 day old) are unaffected — the schedule |
| 176 |
* runs naturally from first trigger. |
| 177 |
* |
| 178 |
* @return void |
| 179 |
*/ |
| 180 |
private function maybe_bootstrap_snooze(): void { |
| 181 |
if ( empty( $this->config['snooze_schedule'] ) ) { |
| 182 |
return; |
| 183 |
} |
| 184 |
|
| 185 |
if ( get_option( $this->get_bootstrapped_option() ) ) { |
| 186 |
return; |
| 187 |
} |
| 188 |
|
| 189 |
// Mark as bootstrapped immediately to avoid running again. |
| 190 |
update_option( $this->get_bootstrapped_option(), '1' ); |
| 191 |
|
| 192 |
$installed_time = (int) get_option( $this->config['installed_option_key'], 0 ); |
| 193 |
if ( ! $installed_time ) { |
| 194 |
return; |
| 195 |
} |
| 196 |
|
| 197 |
$age_days = ( time() - $installed_time ) / DAY_IN_SECONDS; |
| 198 |
|
| 199 |
// Only seed for existing installs — new installs (≤ 1 day) start clean. |
| 200 |
if ( $age_days > 1.0 ) { |
| 201 |
update_option( $this->get_snooze_option(), time() ); |
| 202 |
} |
| 203 |
} |
| 204 |
|
| 205 |
// ------------------------------------------------------------------------- |
| 206 |
// Event-triggered prompt API |
| 207 |
// ------------------------------------------------------------------------- |
| 208 |
|
| 209 |
/** |
| 210 |
* Store a pending trigger that will cause the prompt to appear on the next |
| 211 |
* eligible admin page load. |
| 212 |
* |
| 213 |
* Call this method from any WordPress hook that represents a meaningful user |
| 214 |
* success or failure event (e.g. order placed, feature used, no funnel created). |
| 215 |
* |
| 216 |
* The $context array may contain prompt copy overrides: |
| 217 |
* - modal_title (string) Modal header title. |
| 218 |
* - nps_question (string) The NPS question shown to the user. |
| 219 |
* - feedback_msg (string) Message above the detractor feedback textarea. |
| 220 |
* - thank_you_title (string) |
| 221 |
* - thank_you_text (string) |
| 222 |
* |
| 223 |
* @param string $event_key Short slug identifying the trigger type (e.g. 'funnel_order'). |
| 224 |
* @param array $context Optional metadata and copy-override keys. |
| 225 |
* @return void |
| 226 |
*/ |
| 227 |
public function trigger_prompt( string $event_key, array $context = [] ): void { |
| 228 |
if ( 'completed' === get_option( $this->get_status_option() ) ) { |
| 229 |
return; |
| 230 |
} |
| 231 |
|
| 232 |
update_option( |
| 233 |
$this->get_trigger_option(), |
| 234 |
wp_json_encode( [ |
| 235 |
'event_key' => sanitize_key( $event_key ), |
| 236 |
'context' => $context, |
| 237 |
'triggered_at' => time(), |
| 238 |
] ), |
| 239 |
false // Do not autoload — only needed on admin pages. |
| 240 |
); |
| 241 |
} |
| 242 |
|
| 243 |
// ------------------------------------------------------------------------- |
| 244 |
// Progressive snooze helper |
| 245 |
// ------------------------------------------------------------------------- |
| 246 |
|
| 247 |
/** |
| 248 |
* Return the effective snooze duration in days for the given dismiss count. |
| 249 |
* |
| 250 |
* When a snooze_schedule array is configured (e.g. [7, 30, 90]) the Nth |
| 251 |
* dismissal uses schedule[N-1]; once the last value is reached it repeats. |
| 252 |
* Falls back to the legacy scalar snooze_days when no schedule is set. |
| 253 |
* |
| 254 |
* @param int $snooze_count Total number of times the user has dismissed. |
| 255 |
* @return int Days to snooze. |
| 256 |
*/ |
| 257 |
private function compute_effective_snooze_days( int $snooze_count ): int { |
| 258 |
$schedule = (array) $this->config['snooze_schedule']; |
| 259 |
if ( empty( $schedule ) ) { |
| 260 |
return (int) $this->config['snooze_days']; |
| 261 |
} |
| 262 |
$index = max( 0, $snooze_count - 1 ); |
| 263 |
$index = min( $index, count( $schedule ) - 1 ); |
| 264 |
return (int) $schedule[ $index ]; |
| 265 |
} |
| 266 |
|
| 267 |
// ------------------------------------------------------------------------- |
| 268 |
// Visibility logic |
| 269 |
// ------------------------------------------------------------------------- |
| 270 |
|
| 271 |
/** |
| 272 |
* Determine whether the prompt should be rendered on this page load. |
| 273 |
*/ |
| 274 |
public function should_show(): bool { |
| 275 |
// Developer test-mode bypass: ?{slug}_test_review=1 |
| 276 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 277 |
if ( isset( $_GET[ $this->slug . '_test_review' ] ) && '1' === $_GET[ $this->slug . '_test_review' ] ) { |
| 278 |
return $this->set_cache( true ); |
| 279 |
} |
| 280 |
|
| 281 |
if ( null !== $this->should_show_cache ) { |
| 282 |
return $this->should_show_cache; |
| 283 |
} |
| 284 |
|
| 285 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 286 |
return $this->set_cache( false ); |
| 287 |
} |
| 288 |
|
| 289 |
if ( ! $this->is_allowed_screen() ) { |
| 290 |
return $this->set_cache( false ); |
| 291 |
} |
| 292 |
|
| 293 |
if ( 'completed' === get_option( $this->get_status_option() ) ) { |
| 294 |
return $this->set_cache( false ); |
| 295 |
} |
| 296 |
|
| 297 |
// ----------------------------------------------------------------- |
| 298 |
// Event-triggered mode: snooze_schedule is set. |
| 299 |
// The prompt only shows when trigger_prompt() has stored a pending |
| 300 |
// trigger and the progressive snooze window has expired. |
| 301 |
// ----------------------------------------------------------------- |
| 302 |
if ( ! empty( $this->config['snooze_schedule'] ) ) { |
| 303 |
return $this->set_cache( $this->should_show_event_triggered() ); |
| 304 |
} |
| 305 |
|
| 306 |
// ----------------------------------------------------------------- |
| 307 |
// Default page-load mode (legacy). |
| 308 |
// ----------------------------------------------------------------- |
| 309 |
$snooze_time = (int) get_option( $this->get_snooze_option(), 0 ); |
| 310 |
if ( $snooze_time && time() < $snooze_time + ( (int) $this->config['snooze_days'] * DAY_IN_SECONDS ) ) { |
| 311 |
return $this->set_cache( false ); |
| 312 |
} |
| 313 |
|
| 314 |
// If a custom condition callback is provided, delegate entirely to it. |
| 315 |
if ( is_callable( $this->config['condition_callback'] ) ) { |
| 316 |
return $this->set_cache( (bool) call_user_func( $this->config['condition_callback'] ) ); |
| 317 |
} |
| 318 |
|
| 319 |
// Default gate: show only after N days from plugin install. |
| 320 |
$installed_time = (int) get_option( $this->config['installed_option_key'], 0 ); |
| 321 |
if ( ! $installed_time || time() < $installed_time + ( (int) $this->config['days_after_install'] * DAY_IN_SECONDS ) ) { |
| 322 |
return $this->set_cache( false ); |
| 323 |
} |
| 324 |
|
| 325 |
return $this->set_cache( true ); |
| 326 |
} |
| 327 |
|
| 328 |
/** |
| 329 |
* Visibility decision for event-triggered mode. |
| 330 |
* |
| 331 |
* Returns true when a pending trigger exists and was stored after the |
| 332 |
* current snooze window expired. |
| 333 |
* |
| 334 |
* @return bool |
| 335 |
*/ |
| 336 |
private function should_show_event_triggered(): bool { |
| 337 |
$trigger_json = get_option( $this->get_trigger_option() ); |
| 338 |
if ( ! $trigger_json ) { |
| 339 |
return false; |
| 340 |
} |
| 341 |
|
| 342 |
$trigger = json_decode( $trigger_json, true ); |
| 343 |
if ( ! is_array( $trigger ) || empty( $trigger['triggered_at'] ) ) { |
| 344 |
return false; |
| 345 |
} |
| 346 |
|
| 347 |
$trigger_time = (int) $trigger['triggered_at']; |
| 348 |
|
| 349 |
// Check whether the trigger occurred inside an active snooze window. |
| 350 |
// Bootstrap-seeded snooze (snooze_count = 0) does NOT block event triggers — |
| 351 |
// only a user-initiated dismiss (snooze_count > 0) should suppress the prompt. |
| 352 |
$snooze_set_at = (int) get_option( $this->get_snooze_option(), 0 ); |
| 353 |
if ( $snooze_set_at ) { |
| 354 |
$snooze_count = (int) get_option( $this->get_snooze_count_option(), 0 ); |
| 355 |
if ( $snooze_count > 0 ) { |
| 356 |
$snooze_days = $this->compute_effective_snooze_days( $snooze_count ); |
| 357 |
$snooze_until = $snooze_set_at + ( $snooze_days * DAY_IN_SECONDS ); |
| 358 |
|
| 359 |
if ( $trigger_time < $snooze_until ) { |
| 360 |
return false; |
| 361 |
} |
| 362 |
} |
| 363 |
} |
| 364 |
|
| 365 |
// Persist resolved trigger for use during render. |
| 366 |
$this->current_trigger = $trigger; |
| 367 |
return true; |
| 368 |
} |
| 369 |
|
| 370 |
private function set_cache( bool $value ): bool { |
| 371 |
$this->should_show_cache = $value; |
| 372 |
return $value; |
| 373 |
} |
| 374 |
|
| 375 |
/** |
| 376 |
* Check whether the current admin screen is in the allowed list. |
| 377 |
* An empty allowed_screens array means "show everywhere in wp-admin". |
| 378 |
*/ |
| 379 |
private function is_allowed_screen(): bool { |
| 380 |
if ( ! is_admin() ) { |
| 381 |
return false; |
| 382 |
} |
| 383 |
|
| 384 |
$allowed = (array) $this->config['allowed_screens']; |
| 385 |
if ( empty( $allowed ) ) { |
| 386 |
return true; |
| 387 |
} |
| 388 |
|
| 389 |
// phpcs:ignore WordPress.Security.NonceVerification.Recommended |
| 390 |
$page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; |
| 391 |
if ( $page && in_array( $page, $allowed, true ) ) { |
| 392 |
return true; |
| 393 |
} |
| 394 |
|
| 395 |
if ( function_exists( 'get_current_screen' ) ) { |
| 396 |
$screen = get_current_screen(); |
| 397 |
if ( $screen && in_array( $screen->id, $allowed, true ) ) { |
| 398 |
return true; |
| 399 |
} |
| 400 |
} |
| 401 |
|
| 402 |
return false; |
| 403 |
} |
| 404 |
|
| 405 |
// ------------------------------------------------------------------------- |
| 406 |
// Inline CSS |
| 407 |
// ------------------------------------------------------------------------- |
| 408 |
|
| 409 |
/** |
| 410 |
* Output the prompt stylesheet as an inline <style> block. Runs on |
| 411 |
* admin_enqueue_scripts so it fires before admin_footer markup. |
| 412 |
*/ |
| 413 |
public function maybe_output_style(): void { |
| 414 |
if ( ! $this->should_show() ) { |
| 415 |
return; |
| 416 |
} |
| 417 |
?> |
| 418 |
<style id="<?php echo esc_attr( $this->slug ); ?>-review-style"> |
| 419 |
/* === Wrapper (no overlay — background stays fully interactive) === */ |
| 420 |
.linno-nps-overlay{position:fixed;inset:0;background:transparent;z-index:99998;display:none;pointer-events:none} |
| 421 |
.linno-nps-overlay.is-visible{display:block} |
| 422 |
/* === Modal card: anchored to a corner via fixed positioning === */ |
| 423 |
.linno-nps-modal{position:fixed;background:#fff;border-radius:16px;width:400px;max-width:calc(100vw - 32px);box-shadow:0 8px 32px rgba(0,0,0,.14),0 1.5px 6px rgba(0,0,0,.08);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif;overflow:hidden;pointer-events:all;animation:linno-slide-in .25s ease} |
| 424 |
@keyframes linno-slide-in{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}} |
| 425 |
/* position variants — applied to the modal card itself */ |
| 426 |
.linno-nps-pos-middle .linno-nps-modal{top:50%;left:50%;transform:translate(-50%,-50%)} |
| 427 |
.linno-nps-pos-middle .linno-nps-modal:not([style]){animation:linno-fade-in .25s ease} |
| 428 |
@keyframes linno-fade-in{from{opacity:0}to{opacity:1}} |
| 429 |
.linno-nps-pos-bottom-right .linno-nps-modal{bottom:24px;right:24px} |
| 430 |
.linno-nps-pos-bottom-left .linno-nps-modal{bottom:24px;left:24px} |
| 431 |
.linno-nps-pos-top-right .linno-nps-modal{top:52px;right:24px} |
| 432 |
.linno-nps-pos-top-left .linno-nps-modal{top:52px;left:24px} |
| 433 |
/* === Header === */ |
| 434 |
.linno-nps-header{padding:18px 20px 0;display:flex;justify-content:space-between;align-items:center} |
| 435 |
.linno-nps-title{color:#1D2327;font-size:16px;font-weight:600;line-height:1} |
| 436 |
.linno-nps-close{cursor:pointer;padding:2px;border:none;background:none;display:flex;color:#6b7280} |
| 437 |
.linno-nps-close:hover{color:#374151} |
| 438 |
/* === Body === */ |
| 439 |
.linno-nps-body{padding:16px 20px 20px} |
| 440 |
/* === Step 1: NPS question === */ |
| 441 |
.linno-nps-question{color:#374151;font-size:14px;line-height:1.55;margin-bottom:16px} |
| 442 |
/* NPS buttons row */ |
| 443 |
.linno-nps-scores{display:grid;grid-template-columns:repeat(11,1fr);gap:5px;margin-bottom:8px} |
| 444 |
.linno-nps-score-btn{aspect-ratio:1;width:100%;border-radius:50%;border:1.5px solid #d1d5db;background:#fff;cursor:pointer;font-size:12px;font-weight:600;color:#374151;display:flex;align-items:center;justify-content:center;transition:all .15s ease;padding:0} |
| 445 |
.linno-nps-score-btn:hover{border-color:#6E42D3;color:#6E42D3;background:#f5f0ff} |
| 446 |
/* selected colours by sentiment */ |
| 447 |
.linno-nps-score-btn.nps-selected-low {border-color:#ef4444;background:#fef2f2;color:#b91c1c} |
| 448 |
.linno-nps-score-btn.nps-selected-mid {border-color:#f59e0b;background:#fffbeb;color:#92400e} |
| 449 |
.linno-nps-score-btn.nps-selected-high{border-color:#22c55e;background:#f0fdf4;color:#15803d} |
| 450 |
/* Not likely / Very likely labels */ |
| 451 |
.linno-nps-labels{display:flex;justify-content:space-between;font-size:12px;color:#6b7280;margin-bottom:4px} |
| 452 |
/* === Step 2: feedback form === */ |
| 453 |
.linno-nps-feedback{display:none;margin-top:4px} |
| 454 |
.linno-nps-feedback-msg{color:#374151;font-size:14px;line-height:1.5;margin-bottom:16px} |
| 455 |
.linno-nps-feedback-label{display:block;font-size:13px;font-weight:600;color:#1D2327;margin-bottom:6px} |
| 456 |
.linno-nps-feedback-label .req{color:#6E42D3} |
| 457 |
.linno-nps-textarea{width:100%;min-height:96px;padding:10px 12px;border:1.5px solid #d1d5db;border-radius:10px;resize:vertical;font-family:inherit;font-size:14px;line-height:1.5;box-sizing:border-box;outline:none} |
| 458 |
.linno-nps-textarea:focus{border-color:#6E42D3;box-shadow:0 0 0 3px rgba(110,66,211,.12)} |
| 459 |
.linno-nps-char-counter{font-size:12px;text-align:right;margin-top:4px;display:block} |
| 460 |
.linno-nps-support-link{display:inline-flex;align-items:center;gap:4px;font-size:13px;color:#6E42D3;text-decoration:none;margin-top:12px;margin-bottom:4px} |
| 461 |
.linno-nps-support-link:hover{text-decoration:underline} |
| 462 |
/* === Step 2: action bar === */ |
| 463 |
.linno-nps-actions{display:flex;gap:10px;margin-top:20px;padding-top:16px;border-top:1px solid #F3F4F6} |
| 464 |
.linno-nps-btn-cancel,.linno-nps-btn-submit{padding:9px 22px;border-radius:8px;cursor:pointer;font-size:14px;font-weight:500;border:none;transition:all .2s} |
| 465 |
.linno-nps-btn-cancel{background:#F3F4F6;color:#374151} |
| 466 |
.linno-nps-btn-cancel:hover{background:#e5e7eb} |
| 467 |
.linno-nps-btn-submit{background:#6E42D3;color:#fff;margin-left:auto} |
| 468 |
.linno-nps-btn-submit:hover{background:#5b36b3} |
| 469 |
.linno-nps-btn-submit:disabled{opacity:.55;cursor:not-allowed} |
| 470 |
.linno-nps-privacy{display:block;font-size:11.5px;color:#9ca3af;margin-top:10px} |
| 471 |
.linno-nps-privacy a{color:#6E42D3} |
| 472 |
/* === Step 2 (high score): thank-you === */ |
| 473 |
.linno-nps-thankyou{display:none;text-align:center;padding:8px 0 4px} |
| 474 |
.linno-nps-thankyou-icon{font-size:40px;line-height:1;margin-bottom:12px} |
| 475 |
.linno-nps-thankyou-title{font-size:17px;font-weight:600;color:#1D2327;margin-bottom:8px} |
| 476 |
.linno-nps-thankyou-text{font-size:14px;color:#6b7280;line-height:1.55} |
| 477 |
</style> |
| 478 |
<?php |
| 479 |
} |
| 480 |
|
| 481 |
// ------------------------------------------------------------------------- |
| 482 |
// Render |
| 483 |
// ------------------------------------------------------------------------- |
| 484 |
|
| 485 |
/** |
| 486 |
* Output the prompt HTML and inline JS. Runs on admin_footer. |
| 487 |
*/ |
| 488 |
public function render_prompt(): void { |
| 489 |
if ( ! $this->should_show() ) { |
| 490 |
return; |
| 491 |
} |
| 492 |
|
| 493 |
$slug = esc_attr( $this->slug ); |
| 494 |
$min_chars = (int) $this->config['min_feedback_length']; |
| 495 |
$low_threshold = (int) $this->config['low_score_threshold']; // 0 – (threshold-1) = low |
| 496 |
$position = sanitize_key( $this->config['position'] ?: 'middle' ); |
| 497 |
|
| 498 |
// Dynamic copy: trigger context values override static config defaults. |
| 499 |
$ctx = is_array( $this->current_trigger ) ? (array) ( $this->current_trigger['context'] ?? [] ) : []; |
| 500 |
$nps_question = esc_html( ! empty( $ctx['nps_question'] ) ? $ctx['nps_question'] : $this->config['nps_question'] ); |
| 501 |
$modal_title = esc_html( ! empty( $ctx['modal_title'] ) ? $ctx['modal_title'] : 'Share Your Feedback' ); |
| 502 |
$feedback_msg = esc_html( ! empty( $ctx['feedback_msg'] ) ? $ctx['feedback_msg'] : "We're sorry to hear that. What's not working for you?" ); |
| 503 |
$thank_you_title = esc_html( ! empty( $ctx['thank_you_title'] ) ? $ctx['thank_you_title'] : 'Thank you for your support!' ); |
| 504 |
$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." ); |
| 505 |
$privacy_url = esc_url( $this->config['privacy_url'] ); |
| 506 |
$support_url = esc_url( $this->config['support_url'] ); |
| 507 |
?> |
| 508 |
|
| 509 |
<!-- Linno NPS overlay --> |
| 510 |
<div id="<?php echo $slug; ?>-nps-overlay" class="linno-nps-overlay linno-nps-pos-<?php echo esc_attr( $position ); ?>"> |
| 511 |
<div class="linno-nps-modal" role="dialog" aria-modal="true" |
| 512 |
aria-labelledby="<?php echo $slug; ?>-nps-title"> |
| 513 |
|
| 514 |
<!-- Header --> |
| 515 |
<div class="linno-nps-header"> |
| 516 |
<span class="linno-nps-title" id="<?php echo $slug; ?>-nps-title"><?php echo $modal_title; ?></span> |
| 517 |
<button type="button" class="linno-nps-close" id="<?php echo $slug; ?>-nps-close" |
| 518 |
aria-label="<?php esc_attr_e( 'Close', 'linno-telemetry' ); ?>"> |
| 519 |
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"> |
| 520 |
<path d="M15 5L5 15M5 5l10 10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/> |
| 521 |
</svg> |
| 522 |
</button> |
| 523 |
</div> |
| 524 |
|
| 525 |
<!-- Body --> |
| 526 |
<div class="linno-nps-body"> |
| 527 |
|
| 528 |
<!-- Step 1: NPS question + score buttons --> |
| 529 |
<div id="<?php echo $slug; ?>-nps-step1"> |
| 530 |
<p class="linno-nps-question"><?php echo $nps_question; ?></p> |
| 531 |
|
| 532 |
<div class="linno-nps-scores" role="group" aria-label="NPS score 0 to 10" |
| 533 |
id="<?php echo $slug; ?>-nps-scores"> |
| 534 |
<?php for ( $i = 0; $i <= 10; $i++ ) : ?> |
| 535 |
<button type="button" |
| 536 |
class="linno-nps-score-btn" |
| 537 |
data-score="<?php echo $i; ?>" |
| 538 |
aria-label="Score <?php echo $i; ?>"> |
| 539 |
<?php echo $i; ?> |
| 540 |
</button> |
| 541 |
<?php endfor; ?> |
| 542 |
</div> |
| 543 |
|
| 544 |
<div class="linno-nps-labels"> |
| 545 |
<span>Not likely</span> |
| 546 |
<span>Very likely</span> |
| 547 |
</div> |
| 548 |
</div> |
| 549 |
|
| 550 |
<!-- Step 2a: Low-score feedback form (scores 0 – threshold-1) --> |
| 551 |
<div id="<?php echo $slug; ?>-nps-feedback" class="linno-nps-feedback"> |
| 552 |
<p class="linno-nps-feedback-msg" id="<?php echo $slug; ?>-nps-feedback-msg"> |
| 553 |
<?php echo $feedback_msg; ?> |
| 554 |
</p> |
| 555 |
|
| 556 |
<label class="linno-nps-feedback-label" for="<?php echo $slug; ?>-nps-textarea"> |
| 557 |
What should we fix? <span class="req">(Required)</span> |
| 558 |
</label> |
| 559 |
<textarea id="<?php echo $slug; ?>-nps-textarea" |
| 560 |
class="linno-nps-textarea" |
| 561 |
placeholder="Describe the issue so we can fix it..." |
| 562 |
rows="4"></textarea> |
| 563 |
<span class="linno-nps-char-counter" id="<?php echo $slug; ?>-nps-counter" |
| 564 |
style="color:#ef4444"> |
| 565 |
0 / <?php echo esc_html( $min_chars ); ?> characters minimum |
| 566 |
</span> |
| 567 |
|
| 568 |
<?php if ( $support_url ) : ?> |
| 569 |
<a class="linno-nps-support-link" |
| 570 |
href="<?php echo $support_url; ?>" |
| 571 |
target="_blank" rel="noopener noreferrer"> |
| 572 |
Or contact our support team directly |
| 573 |
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"> |
| 574 |
<path d="M2.5 7h9M7.5 3l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> |
| 575 |
</svg> |
| 576 |
</a> |
| 577 |
<?php endif; ?> |
| 578 |
|
| 579 |
<div class="linno-nps-actions"> |
| 580 |
<button type="button" class="linno-nps-btn-cancel" |
| 581 |
id="<?php echo $slug; ?>-nps-cancel">Cancel</button> |
| 582 |
<button type="button" class="linno-nps-btn-submit" |
| 583 |
id="<?php echo $slug; ?>-nps-submit" disabled>Submit</button> |
| 584 |
</div> |
| 585 |
|
| 586 |
<span class="linno-nps-privacy"> |
| 587 |
By submitting, you agree to our |
| 588 |
<a href="<?php echo $privacy_url; ?>" target="_blank" rel="noopener noreferrer">Privacy Policy</a>. |
| 589 |
</span> |
| 590 |
</div> |
| 591 |
|
| 592 |
<!-- Step 2b: High-score thank-you --> |
| 593 |
<div id="<?php echo $slug; ?>-nps-thankyou" class="linno-nps-thankyou"> |
| 594 |
<div class="linno-nps-thankyou-icon">🎉</div> |
| 595 |
<div class="linno-nps-thankyou-title"><?php echo $thank_you_title; ?></div> |
| 596 |
<p class="linno-nps-thankyou-text"> |
| 597 |
<?php echo $thank_you_text; ?> |
| 598 |
</p> |
| 599 |
</div> |
| 600 |
|
| 601 |
</div><!-- /.linno-nps-body --> |
| 602 |
</div><!-- /.linno-nps-modal --> |
| 603 |
</div><!-- /.linno-nps-overlay --> |
| 604 |
|
| 605 |
<script type="text/javascript"> |
| 606 |
(function ($, w) { |
| 607 |
'use strict'; |
| 608 |
|
| 609 |
var slug = <?php echo wp_json_encode( $this->slug ); ?>; |
| 610 |
var reviewUrl = <?php echo wp_json_encode( $this->config['review_url'] ); ?>; |
| 611 |
var minChars = <?php echo (int) $min_chars; ?>; |
| 612 |
var lowThreshold = <?php echo (int) $low_threshold; ?>; // scores < lowThreshold = detractors |
| 613 |
var ajaxUrl = (typeof w.ajaxurl !== 'undefined') ? w.ajaxurl : ''; |
| 614 |
var ajaxAction = <?php echo wp_json_encode( $this->get_ajax_action() ); ?>; |
| 615 |
var nonce = <?php echo wp_json_encode( wp_create_nonce( $this->get_nonce_action() ) ); ?>; |
| 616 |
|
| 617 |
var overlay = '#' + slug + '-nps-overlay'; |
| 618 |
var step1 = '#' + slug + '-nps-step1'; |
| 619 |
var feedbackEl = '#' + slug + '-nps-feedback'; |
| 620 |
var thankyouEl = '#' + slug + '-nps-thankyou'; |
| 621 |
var textareaEl = '#' + slug + '-nps-textarea'; |
| 622 |
var counterEl = '#' + slug + '-nps-counter'; |
| 623 |
var submitBtn = '#' + slug + '-nps-submit'; |
| 624 |
var cancelBtn = '#' + slug + '-nps-cancel'; |
| 625 |
var closeBtn = '#' + slug + '-nps-close'; |
| 626 |
var scoresEl = '#' + slug + '-nps-scores'; |
| 627 |
|
| 628 |
var selectedScore = null; |
| 629 |
|
| 630 |
function sendAction(type, data) { |
| 631 |
if (!ajaxUrl) { return; } |
| 632 |
$.post(ajaxUrl, $.extend({ action: ajaxAction, linno_action_type: type, nonce: nonce }, data || {})); |
| 633 |
} |
| 634 |
|
| 635 |
function closeModal() { |
| 636 |
$(overlay).removeClass('is-visible'); |
| 637 |
sendAction('snooze'); |
| 638 |
} |
| 639 |
|
| 640 |
function resetFeedback() { |
| 641 |
$(textareaEl).val(''); |
| 642 |
updateCounter(0); |
| 643 |
$(submitBtn).prop('disabled', true); |
| 644 |
} |
| 645 |
|
| 646 |
function updateCounter(len) { |
| 647 |
$(counterEl).text(len + ' / ' + minChars + ' characters minimum') |
| 648 |
.css('color', len >= minChars ? '#16a34a' : '#ef4444'); |
| 649 |
$(submitBtn).prop('disabled', len < minChars); |
| 650 |
} |
| 651 |
|
| 652 |
function getScoreClass(score) { |
| 653 |
if (score < lowThreshold) { return 'nps-selected-low'; } |
| 654 |
if (score <= 8) { return 'nps-selected-mid'; } |
| 655 |
return 'nps-selected-high'; |
| 656 |
} |
| 657 |
|
| 658 |
$(function () { |
| 659 |
|
| 660 |
// Show after a short delay (avoids interrupting page load). |
| 661 |
setTimeout(function () { $(overlay).addClass('is-visible'); }, 2000); |
| 662 |
|
| 663 |
// Close button — snooze. |
| 664 |
$(closeBtn).on('click', closeModal); |
| 665 |
|
| 666 |
// NPS score buttons. |
| 667 |
$(scoresEl).on('click', '.linno-nps-score-btn', function () { |
| 668 |
var score = parseInt($(this).data('score'), 10); |
| 669 |
selectedScore = score; |
| 670 |
|
| 671 |
// Highlight the selected button. |
| 672 |
$(scoresEl).find('.linno-nps-score-btn') |
| 673 |
.removeClass('nps-selected-low nps-selected-mid nps-selected-high'); |
| 674 |
$(this).addClass(getScoreClass(score)); |
| 675 |
|
| 676 |
if (score < lowThreshold) { |
| 677 |
// Detractor — show feedback form. |
| 678 |
$(step1).hide(); |
| 679 |
resetFeedback(); |
| 680 |
$(feedbackEl).fadeIn(200); |
| 681 |
} else { |
| 682 |
// Promoter / passive — open review URL and show thank-you. |
| 683 |
$(step1).hide(); |
| 684 |
if (reviewUrl) { w.open(reviewUrl, '_blank', 'noopener,noreferrer'); } |
| 685 |
$(thankyouEl).fadeIn(200); |
| 686 |
sendAction('completed', { nps_score: score }); |
| 687 |
setTimeout(function () { $(overlay).removeClass('is-visible'); }, 3000); |
| 688 |
} |
| 689 |
}); |
| 690 |
|
| 691 |
// Cancel — go back to score step. |
| 692 |
$(cancelBtn).on('click', function () { |
| 693 |
$(feedbackEl).hide(); |
| 694 |
resetFeedback(); |
| 695 |
$(scoresEl).find('.linno-nps-score-btn') |
| 696 |
.removeClass('nps-selected-low nps-selected-mid nps-selected-high'); |
| 697 |
selectedScore = null; |
| 698 |
$(step1).fadeIn(200); |
| 699 |
}); |
| 700 |
|
| 701 |
// Live char counter. |
| 702 |
$(textareaEl).on('input', function () { |
| 703 |
updateCounter($(this).val().trim().length); |
| 704 |
}); |
| 705 |
|
| 706 |
// Submit. |
| 707 |
$(submitBtn).on('click', function () { |
| 708 |
var val = $(textareaEl).val().trim(); |
| 709 |
if (val.length < minChars) { return; } |
| 710 |
|
| 711 |
$(this).text('Submitting…').prop('disabled', true); |
| 712 |
sendAction('feedback', { feedback: val, nps_score: selectedScore !== null ? selectedScore : '' }); |
| 713 |
setTimeout(function () { $(overlay).removeClass('is-visible'); }, 500); |
| 714 |
}); |
| 715 |
|
| 716 |
}); |
| 717 |
}(jQuery, window)); |
| 718 |
</script> |
| 719 |
<?php |
| 720 |
} |
| 721 |
|
| 722 |
// ------------------------------------------------------------------------- |
| 723 |
// AJAX handler |
| 724 |
// ------------------------------------------------------------------------- |
| 725 |
|
| 726 |
public function handle_ajax(): void { |
| 727 |
check_ajax_referer( $this->get_nonce_action(), 'nonce' ); |
| 728 |
|
| 729 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 730 |
wp_send_json_error( 'Unauthorized' ); |
| 731 |
return; |
| 732 |
} |
| 733 |
|
| 734 |
$type = isset( $_POST['linno_action_type'] ) |
| 735 |
? sanitize_text_field( wp_unslash( $_POST['linno_action_type'] ) ) |
| 736 |
: ''; |
| 737 |
|
| 738 |
if ( 'snooze' === $type ) { |
| 739 |
// In event-triggered mode, increment the dismiss counter so the |
| 740 |
// next snooze interval is fetched from the progressive schedule. |
| 741 |
if ( ! empty( $this->config['snooze_schedule'] ) ) { |
| 742 |
$snooze_count = (int) get_option( $this->get_snooze_count_option(), 0 ) + 1; |
| 743 |
update_option( $this->get_snooze_count_option(), $snooze_count ); |
| 744 |
} |
| 745 |
update_option( $this->get_snooze_option(), time() ); |
| 746 |
|
| 747 |
} elseif ( 'completed' === $type ) { |
| 748 |
update_option( $this->get_status_option(), 'completed' ); |
| 749 |
|
| 750 |
$nps_score = isset( $_POST['nps_score'] ) && is_numeric( $_POST['nps_score'] ) |
| 751 |
? (int) $_POST['nps_score'] : null; |
| 752 |
|
| 753 |
if ( null !== $nps_score ) { |
| 754 |
$this->track_nps_to_posthog( $nps_score, '' ); |
| 755 |
} |
| 756 |
|
| 757 |
} elseif ( 'feedback' === $type ) { |
| 758 |
update_option( $this->get_status_option(), 'completed' ); |
| 759 |
|
| 760 |
$feedback = isset( $_POST['feedback'] ) |
| 761 |
? sanitize_textarea_field( wp_unslash( $_POST['feedback'] ) ) : ''; |
| 762 |
$nps_score = isset( $_POST['nps_score'] ) && is_numeric( $_POST['nps_score'] ) |
| 763 |
? (int) $_POST['nps_score'] : null; |
| 764 |
|
| 765 |
$this->track_nps_to_posthog( $nps_score, $feedback ); |
| 766 |
|
| 767 |
if ( ! empty( $feedback ) && ! empty( $this->config['webhook'] ) ) { |
| 768 |
$this->send_feedback( $feedback, $nps_score ); |
| 769 |
} |
| 770 |
} |
| 771 |
|
| 772 |
wp_send_json_success(); |
| 773 |
} |
| 774 |
|
| 775 |
// ------------------------------------------------------------------------- |
| 776 |
// PostHog NPS tracking |
| 777 |
// ------------------------------------------------------------------------- |
| 778 |
|
| 779 |
/** |
| 780 |
* Send the NPS submission to PostHog via the parent Client. |
| 781 |
* |
| 782 |
* Event name : nps_survey_submitted |
| 783 |
* Properties : |
| 784 |
* nps_score int 0–10 raw score. |
| 785 |
* nps_category string promoter | passive | detractor. |
| 786 |
* feedback string Detractor feedback text (empty for promoters). |
| 787 |
* trigger_event string The event_key that triggered this prompt. |
| 788 |
* snooze_count int How many times the user dismissed before submitting. |
| 789 |
* product_slug string Plugin slug. |
| 790 |
* |
| 791 |
* Uses track_immediate() so the event is dispatched in the same request, |
| 792 |
* bypassing the background queue to guarantee delivery on form submit. |
| 793 |
* |
| 794 |
* @param int|null $nps_score Raw score (0–10), or null if not captured. |
| 795 |
* @param string $feedback Detractor feedback text. |
| 796 |
* @return void |
| 797 |
*/ |
| 798 |
private function track_nps_to_posthog( ?int $nps_score, string $feedback ): void { |
| 799 |
$low_threshold = (int) $this->config['low_score_threshold']; |
| 800 |
|
| 801 |
if ( null !== $nps_score ) { |
| 802 |
if ( $nps_score < $low_threshold ) { |
| 803 |
$category = 'detractor'; |
| 804 |
} elseif ( $nps_score <= 8 ) { |
| 805 |
$category = 'passive'; |
| 806 |
} else { |
| 807 |
$category = 'promoter'; |
| 808 |
} |
| 809 |
} else { |
| 810 |
$category = 'unknown'; |
| 811 |
} |
| 812 |
|
| 813 |
// Resolve the trigger event key from the stored trigger payload. |
| 814 |
$trigger_json = get_option( $this->get_trigger_option() ); |
| 815 |
$trigger_data = $trigger_json ? json_decode( $trigger_json, true ) : []; |
| 816 |
$trigger_event = isset( $trigger_data['event_key'] ) ? sanitize_key( $trigger_data['event_key'] ) : 'unknown'; |
| 817 |
$snooze_count = (int) get_option( $this->get_snooze_count_option(), 0 ); |
| 818 |
|
| 819 |
$properties = [ |
| 820 |
'nps_score' => $nps_score, |
| 821 |
'nps_category' => $category, |
| 822 |
'feedback' => $feedback, |
| 823 |
'trigger_event' => $trigger_event, |
| 824 |
'snooze_count' => $snooze_count, |
| 825 |
'product_slug' => $this->slug, |
| 826 |
]; |
| 827 |
|
| 828 |
try { |
| 829 |
// track_immediate() sends directly to the configured driver (PostHog) |
| 830 |
// without queuing, using override=true to bypass the opt-in check |
| 831 |
// since this is an explicit user action (they chose to submit). |
| 832 |
$this->client->track_immediate( 'nps_survey_submitted', $properties, true ); |
| 833 |
} catch ( \Exception $e ) { |
| 834 |
// Failure-safe — NPS tracking must not surface errors to the user. |
| 835 |
error_log( '[Linno Review Prompt] PostHog NPS track failed for ' . $this->slug . ': ' . $e->getMessage() ); |
| 836 |
} |
| 837 |
} |
| 838 |
|
| 839 |
// ------------------------------------------------------------------------- |
| 840 |
// Feedback delivery |
| 841 |
// ------------------------------------------------------------------------- |
| 842 |
|
| 843 |
private function send_feedback( string $feedback, ?int $nps_score ): void { |
| 844 |
$current_user = wp_get_current_user(); |
| 845 |
|
| 846 |
$payload = [ |
| 847 |
'productSlug' => $this->slug, |
| 848 |
'productName' => $this->client->get_plugin_name(), |
| 849 |
'feedback' => $feedback, |
| 850 |
'npsScore' => $nps_score, |
| 851 |
'siteUrl' => get_site_url(), |
| 852 |
'userEmail' => ( $current_user instanceof \WP_User ) ? $current_user->user_email : '', |
| 853 |
'userName' => ( $current_user instanceof \WP_User ) ? $current_user->display_name : '', |
| 854 |
'submittedAt' => current_time( 'mysql' ), |
| 855 |
]; |
| 856 |
|
| 857 |
$is_local = in_array( |
| 858 |
wp_get_environment_type(), |
| 859 |
[ 'local', 'development' ], |
| 860 |
true |
| 861 |
); |
| 862 |
|
| 863 |
$response = wp_remote_post( |
| 864 |
$this->config['webhook'], |
| 865 |
[ |
| 866 |
'headers' => [ 'Content-Type' => 'application/json' ], |
| 867 |
'body' => wp_json_encode( $payload ), |
| 868 |
'timeout' => 8, |
| 869 |
'sslverify' => ! $is_local, |
| 870 |
] |
| 871 |
); |
| 872 |
|
| 873 |
if ( is_wp_error( $response ) ) { |
| 874 |
error_log( |
| 875 |
'[Linno Review Prompt] webhook failed for ' . $this->slug |
| 876 |
. ': ' . $response->get_error_message() |
| 877 |
); |
| 878 |
} |
| 879 |
} |
| 880 |
} |
| 881 |
|