PluginProbe
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress / 8.5.71
WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress v8.5.71
9.1.3 9.1.2 9.1.1 9.1.0 9.0.3 9.0.2 9.0.1 9.0.0 8.5.79 8.5.78 8.5.77 8.5.76 8.5.75 8.5.74 8.5.73 8.5.72 8.5.71 8.5.70 8.5.69 8.5.68 8.5.35 8.5.36 8.5.37 8.5.38 8.5.39 All 222 releases
wpvr / vendor / linno / telemetry / src / ReviewPrompt.php

ReviewPrompt.php in WPVR – 360 Panorama viewer and Virtual Tour Builder for WordPress 8.5.71, at vendor/linno/telemetry/src/ReviewPrompt.php

881 lines 39.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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?&ensp;<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">&#127881;</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