PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Services / NoticeService.php

NoticeService.php in Yatra – Travel Booking & Tour Operator Software 3.0.13, at app/Services/NoticeService.php

492 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use WP_Error;
8 use Yatra\Hooks\TelemetryHookNames;
9 use Yatra\Repositories\BookingRepository;
10 use Yatra\Repositories\TripRepository;
11
12 defined('ABSPATH') || exit;
13
14 /**
15 * Centralized notices service (React Admin UI + WP admin notices).
16 *
17 * Rules implemented:
18 * - Review notice: after 1 published trip; dismiss cycles 15d → 60d → disabled.
19 * - Buy Pro notice (only if Pro not active): after 1 booking; dismiss cycles:
20 * 30d (then show only if bookings > 1) → 90d (then show only if bookings > 5) → disabled.
21 */
22 final class NoticeService
23 {
24 public const NOTICE_REVIEW = 'review';
25 public const NOTICE_BUY_PRO = 'buy_pro';
26
27 private const OPT_FIRST_TRIP_PUBLISHED_AT = 'yatra_notice_first_trip_published_at';
28 private const OPT_FIRST_BOOKING_RECEIVED_AT = 'yatra_notice_first_booking_received_at';
29
30 private const META_REVIEW_DISABLED = 'yatra_notice_review_disabled';
31 private const META_REVIEW_DISMISS_COUNT = 'yatra_notice_review_dismiss_count';
32 private const META_REVIEW_NEXT_SHOW_AT = 'yatra_notice_review_next_show_at';
33
34 private const META_PRO_DISABLED = 'yatra_notice_buy_pro_disabled';
35 private const META_PRO_STAGE = 'yatra_notice_buy_pro_stage';
36 private const META_PRO_NEXT_SHOW_AT = 'yatra_notice_buy_pro_next_show_at';
37
38 /**
39 * Register hooks.
40 */
41 public static function init(): void
42 {
43 if (!is_admin()) {
44 return;
45 }
46
47 add_action('admin_notices', [self::class, 'renderWordPressNotices'], 30);
48 add_action('admin_enqueue_scripts', [self::class, 'enqueueWordPressNoticeScript']);
49 add_action('wp_ajax_yatra_dismiss_notice', [self::class, 'ajaxDismissNotice']);
50
51 // Lifecycle signals that unlock notices.
52 add_action(TelemetryHookNames::BOOKING_CREATED, [self::class, 'markFirstBookingReceived'], 10, 2);
53 add_action('yatra_trip_created_with_relations', [self::class, 'maybeMarkFirstTripPublished'], 10, 3);
54 add_action('yatra_trip_updated_with_relations', [self::class, 'maybeMarkFirstTripPublished'], 10, 3);
55 }
56
57 /**
58 * REST: build notices payload for current user.
59 *
60 * @return array<int, array<string, mixed>>
61 */
62 public static function getActiveNoticesForCurrentUser(): array
63 {
64 $userId = get_current_user_id();
65 if ($userId <= 0) {
66 return [];
67 }
68
69 $notices = [];
70
71 if (self::shouldShowReviewNotice($userId)) {
72 $notices[] = self::buildReviewNotice();
73 }
74
75 if (self::shouldShowBuyProNotice($userId)) {
76 $notices[] = self::buildBuyProNotice();
77 }
78
79 return $notices;
80 }
81
82 /**
83 * REST/AJAX: dismiss notice for current user.
84 *
85 * @return bool|\WP_Error True when dismissed/ignored; WP_Error on failure.
86 */
87 public static function dismissForCurrentUser(string $noticeId)
88 {
89 $userId = get_current_user_id();
90 if ($userId <= 0) {
91 return new WP_Error('yatra_notice_unauthorized', __('Unauthorized.', 'yatra'), ['status' => 401]);
92 }
93
94 if (!current_user_can('manage_options') && !current_user_can('manage_yatra')) {
95 return new WP_Error('yatra_notice_forbidden', __('Forbidden.', 'yatra'), ['status' => 403]);
96 }
97
98 $now = current_time('timestamp');
99
100 if ($noticeId === self::NOTICE_REVIEW) {
101 $disabled = (bool) get_user_meta($userId, self::META_REVIEW_DISABLED, true);
102 if ($disabled) {
103 return true;
104 }
105
106 $count = (int) get_user_meta($userId, self::META_REVIEW_DISMISS_COUNT, true);
107 $count++;
108
109 update_user_meta($userId, self::META_REVIEW_DISMISS_COUNT, $count);
110
111 if ($count === 1) {
112 update_user_meta($userId, self::META_REVIEW_NEXT_SHOW_AT, $now + 15 * DAY_IN_SECONDS);
113 } elseif ($count === 2) {
114 update_user_meta($userId, self::META_REVIEW_NEXT_SHOW_AT, $now + 60 * DAY_IN_SECONDS);
115 } else {
116 update_user_meta($userId, self::META_REVIEW_DISABLED, 1);
117 delete_user_meta($userId, self::META_REVIEW_NEXT_SHOW_AT);
118 }
119
120 return true;
121 }
122
123 if ($noticeId === self::NOTICE_BUY_PRO) {
124 $disabled = (bool) get_user_meta($userId, self::META_PRO_DISABLED, true);
125 if ($disabled) {
126 return true;
127 }
128
129 $stage = (int) get_user_meta($userId, self::META_PRO_STAGE, true);
130 $stage = max(0, $stage);
131 $stage++;
132
133 update_user_meta($userId, self::META_PRO_STAGE, $stage);
134
135 if ($stage === 1) {
136 update_user_meta($userId, self::META_PRO_NEXT_SHOW_AT, $now + 30 * DAY_IN_SECONDS);
137 } elseif ($stage === 2) {
138 update_user_meta($userId, self::META_PRO_NEXT_SHOW_AT, $now + 90 * DAY_IN_SECONDS);
139 } else {
140 update_user_meta($userId, self::META_PRO_DISABLED, 1);
141 delete_user_meta($userId, self::META_PRO_NEXT_SHOW_AT);
142 }
143
144 return true;
145 }
146
147 return new WP_Error('yatra_notice_invalid', __('Invalid notice.', 'yatra'), ['status' => 400]);
148 }
149
150 /**
151 * WordPress admin notice renderer (standard WP UI).
152 */
153 public static function renderWordPressNotices(): void
154 {
155 if (!current_user_can('manage_options') && !current_user_can('manage_yatra')) {
156 return;
157 }
158
159 $notices = self::getActiveNoticesForCurrentUser();
160 if ($notices === []) {
161 return;
162 }
163
164 foreach ($notices as $notice) {
165 $id = isset($notice['id']) ? (string) $notice['id'] : '';
166 $title = isset($notice['title']) ? (string) $notice['title'] : '';
167 $message = isset($notice['message']) ? (string) $notice['message'] : '';
168 $actions = isset($notice['actions']) && is_array($notice['actions']) ? $notice['actions'] : [];
169
170 if ($id === '' || $message === '') {
171 continue;
172 }
173
174 // Upgrade notice — match screenshot UI exactly.
175 if ($id === self::NOTICE_BUY_PRO) {
176 $primary = $actions[0] ?? null;
177 $ctaLabel = is_array($primary) && !empty($primary['label']) ? (string) $primary['label'] : esc_html__('Upgrade to Pro', 'yatra');
178 $ctaUrl = is_array($primary) && !empty($primary['url']) ? (string) $primary['url'] : 'https://wpyatra.com/pricing/';
179 $ctaTarget = is_array($primary) && !empty($primary['target']) ? (string) $primary['target'] : '_blank';
180 $ctaAttrs = $ctaTarget === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
181
182 $bookingCount = self::getTotalBookingsCount();
183 if ($bookingCount < 0) {
184 $bookingCount = 0;
185 }
186
187 // Use the exact Easy Invoice style markup (adapted for Yatra).
188 echo '<div id="yatra-promotion-notice" class="notice is-dismissible yatra-notice" data-yatra-notice-id="' . esc_attr($id) . '" style="background: linear-gradient(135deg, #fdf6f0 0%, #f8f9fa 50%, #fff5ee 100%); border: 1px solid #f0e6d8; border-left: 4px solid #ff9500; border-radius: 6px; margin: 15px 0; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); position: relative;">';
189
190 echo '<div style="position: absolute; top: 8px; right: 80px; background: #ff9500; color: white; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px;">⚡ ' . esc_html__('Limited Time', 'yatra') . '</div>';
191
192 echo '<div class="yatra-promotion-content" style="padding: 18px;">';
193 echo '<div style="display: flex; align-items: flex-start; gap: 15px;">';
194 echo '<div style="flex: 1;">';
195
196 echo '<h3 style="margin: 0 0 10px 0; color: #2c3e50; font-size: 17px; font-weight: 600;">🚀 ' . esc_html__('Upgrade to Yatra Pro - Save 30%+!', 'yatra') . '</h3>';
197
198 echo '<p style="margin: 0 0 12px 0; font-size: 14px; line-height: 1.5; color: #495057;">';
199 echo wp_kses_post(
200 sprintf(
201 /* translators: %d: booking count */
202 __('You’ve received <strong style="color: #ff9500;">%d</strong> booking(s)! Get <strong style="color: #ff9500;">30%%+ OFF</strong> on Yatra Pro. Unlock premium payment gateways, advanced modules, automation tools, and priority support.', 'yatra'),
203 (int) $bookingCount
204 )
205 );
206 echo '</p>';
207
208 echo '<div style="background: rgba(255, 149, 0, 0.08); padding: 10px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #ff9500;">';
209 echo '<p style="margin: 0; font-size: 13px; color: #495057; font-weight: 600;">🎉 <strong>' . esc_html__('Special Offer:', 'yatra') . '</strong> ' . esc_html__('Save 30%+ on your Pro upgrade with premium features and priority support!', 'yatra') . '</p>';
210 echo '</div>';
211
212 echo '<div style="display: flex; align-items: center; gap: 12px;">';
213 echo '<a href="' . esc_url($ctaUrl) . '"' . $ctaAttrs . ' class="button button-primary" style="background-color: #ff9500; border-color: #ff9500; color: white; padding: 6px 14px; font-weight: 600; font-size: 13px; border-radius: 4px; text-decoration: none; box-shadow: 0 1px 4px rgba(255, 149, 0, 0.25); transition: all 0.3s ease;">⚡ ' . esc_html__('Save 30%+ - Upgrade to Pro', 'yatra') . '</a>';
214 echo '<a href="#" id="yatra-promotion-dismiss" data-yatra-notice-dismiss="1" style="color: #6c757d; text-decoration: none; font-size: 13px; transition: color 0.3s ease;">' . esc_html__('Maybe later', 'yatra') . '</a>';
215 echo '</div>';
216
217 // Close: flex:1, flex row, content wrapper. Outer notice stays open for dismiss button.
218 echo '</div></div></div>';
219
220 echo '<button type="button" class="notice-dismiss"><span class="screen-reader-text">' . esc_html__('Dismiss this notice.', 'yatra') . '</span></button>';
221 echo '</div>';
222 continue;
223 }
224
225 // Default notice card (currently only used for review notice).
226 $class = 'notice is-dismissible yatra-notice yatra-notice-card yatra-notice-card--review';
227
228 $iconSvg = '';
229 // star icon
230 $iconSvg = '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 17.3l-6.18 3.7 1.64-7.03L2 9.24l7.19-.61L12 2l2.81 6.63 7.19.61-5.46 4.73L18.18 21z"/></svg>';
231
232 echo '<div class="' . esc_attr($class) . '" data-yatra-notice-id="' . esc_attr($id) . '">';
233 echo '<div class="yatra-notice-card__wrap">';
234 echo '<div class="yatra-notice-card__row">';
235
236 echo '<div class="yatra-notice-card__left">';
237 echo '<div class="yatra-notice-card__icon" aria-hidden="true">' . $iconSvg . '</div>';
238 echo '<div class="yatra-notice-card__content">';
239 echo '<div class="yatra-notice-card__meta">';
240 if ($title !== '') {
241 echo '<div class="yatra-notice-card__title">' . esc_html($title) . '</div>';
242 }
243 echo '</div>';
244 echo '<div class="yatra-notice-card__message">' . wp_kses_post($message) . '</div>';
245 echo '</div>'; // content
246 echo '</div>'; // left
247
248 echo '<div class="yatra-notice-card__actions">';
249 if ($actions !== []) {
250 $primary = $actions[0] ?? null;
251 if (is_array($primary)) {
252 $label = isset($primary['label']) ? (string) $primary['label'] : '';
253 $url = isset($primary['url']) ? (string) $primary['url'] : '';
254 $target = isset($primary['target']) ? (string) $primary['target'] : '';
255 if ($label !== '' && $url !== '') {
256 $attrs = $target === '_blank'
257 ? ' target="_blank" rel="noopener noreferrer"'
258 : '';
259 echo '<a class="button button-primary yatra-notice-card__cta" href="' . esc_url($url) . '"' . $attrs . '>' . esc_html($label) . '</a>';
260 }
261 }
262 }
263 echo '</div>'; // actions
264
265 echo '</div>'; // row
266 echo '</div>'; // wrap
267 echo '</div>'; // notice
268 }
269 }
270
271 public static function enqueueWordPressNoticeScript(): void
272 {
273 if (!is_admin()) {
274 return;
275 }
276 if (!current_user_can('manage_options') && !current_user_can('manage_yatra')) {
277 return;
278 }
279
280 // Only enqueue if there is at least one notice to show.
281 $notices = self::getActiveNoticesForCurrentUser();
282 if ($notices === []) {
283 return;
284 }
285
286 wp_enqueue_style(
287 'yatra-admin-notices',
288 YATRA_PLUGIN_URL . 'assets/admin/css/notices.css',
289 [],
290 defined('YATRA_VERSION') ? YATRA_VERSION : null
291 );
292
293 wp_enqueue_script(
294 'yatra-wp-notices',
295 YATRA_PLUGIN_URL . 'assets/admin/js/wp-notices.js',
296 ['jquery'],
297 defined('YATRA_VERSION') ? YATRA_VERSION : null,
298 true
299 );
300
301 wp_localize_script('yatra-wp-notices', 'yatraWpNotices', [
302 'ajaxUrl' => admin_url('admin-ajax.php'),
303 'nonce' => wp_create_nonce('yatra_dismiss_notice'),
304 ]);
305 }
306
307 public static function ajaxDismissNotice(): void
308 {
309 check_ajax_referer('yatra_dismiss_notice', 'nonce');
310
311 $noticeId = isset($_POST['notice_id']) ? sanitize_key((string) wp_unslash($_POST['notice_id'])) : '';
312 $result = self::dismissForCurrentUser($noticeId);
313 if ($result === true) {
314 wp_send_json_success(['dismissed' => true]);
315 }
316
317 wp_send_json_error([
318 'code' => $result->get_error_code(),
319 'message' => $result->get_error_message(),
320 ], (int) ($result->get_error_data()['status'] ?? 400));
321 }
322
323 public static function markFirstBookingReceived(int $bookingId, object $booking): void
324 {
325 if (get_option(self::OPT_FIRST_BOOKING_RECEIVED_AT)) {
326 return;
327 }
328
329 update_option(self::OPT_FIRST_BOOKING_RECEIVED_AT, current_time('timestamp'), false);
330 }
331
332 /**
333 * Called for both create_with_relations and update_with_relations.
334 *
335 * @param int $tripId
336 * @param array $relationships
337 * @param array $data
338 */
339 public static function maybeMarkFirstTripPublished(int $tripId, array $relationships, array $data): void
340 {
341 if (get_option(self::OPT_FIRST_TRIP_PUBLISHED_AT)) {
342 return;
343 }
344
345 $status = isset($data['status']) ? (string) $data['status'] : '';
346 if ($status === '') {
347 // If status isn't in payload, fallback to current DB row.
348 $repo = new TripRepository();
349 $trip = $repo->find((int) $tripId);
350 $status = is_object($trip) && isset($trip->status) ? (string) $trip->status : '';
351 }
352
353 if (!in_array($status, ['publish', 'published'], true)) {
354 return;
355 }
356
357 update_option(self::OPT_FIRST_TRIP_PUBLISHED_AT, current_time('timestamp'), false);
358 }
359
360 private static function isProActive(): bool
361 {
362 return defined('YATRA_PRO_VERSION') || defined('YATRA_PRO_ACTIVE') || class_exists('Yatra_Pro');
363 }
364
365 private static function shouldShowReviewNotice(int $userId): bool
366 {
367 $publishedAt = (int) get_option(self::OPT_FIRST_TRIP_PUBLISHED_AT, 0);
368 if ($publishedAt <= 0) {
369 // Backfill for sites that already have published trips.
370 $tripRepo = new TripRepository();
371 $publishedCount = $tripRepo->countByStatus('publish') + $tripRepo->countByStatus('published');
372 if ($publishedCount > 0) {
373 $publishedAt = current_time('timestamp');
374 update_option(self::OPT_FIRST_TRIP_PUBLISHED_AT, $publishedAt, false);
375 }
376 }
377 if ($publishedAt <= 0) {
378 return false;
379 }
380
381 if ((bool) get_user_meta($userId, self::META_REVIEW_DISABLED, true)) {
382 return false;
383 }
384
385 $nextShowAt = (int) get_user_meta($userId, self::META_REVIEW_NEXT_SHOW_AT, true);
386 if ($nextShowAt > 0 && current_time('timestamp') < $nextShowAt) {
387 return false;
388 }
389
390 return true;
391 }
392
393 private static function shouldShowBuyProNotice(int $userId): bool
394 {
395 if (self::isProActive()) {
396 return false;
397 }
398
399 $firstBookingAt = (int) get_option(self::OPT_FIRST_BOOKING_RECEIVED_AT, 0);
400 if ($firstBookingAt <= 0) {
401 // Backfill for sites that already have bookings.
402 if (self::getTotalBookingsCount() > 0) {
403 $firstBookingAt = current_time('timestamp');
404 update_option(self::OPT_FIRST_BOOKING_RECEIVED_AT, $firstBookingAt, false);
405 }
406 }
407 if ($firstBookingAt <= 0) {
408 return false;
409 }
410
411 if ((bool) get_user_meta($userId, self::META_PRO_DISABLED, true)) {
412 return false;
413 }
414
415 $stage = (int) get_user_meta($userId, self::META_PRO_STAGE, true);
416 $stage = max(0, $stage);
417
418 $bookingCount = self::getTotalBookingsCount();
419
420 // Gate by booking thresholds per stage.
421 if ($stage === 0 && $bookingCount < 1) {
422 return false;
423 }
424 if ($stage === 1 && $bookingCount < 2) {
425 return false;
426 }
427 if ($stage >= 2 && $bookingCount < 6) {
428 return false;
429 }
430
431 $nextShowAt = (int) get_user_meta($userId, self::META_PRO_NEXT_SHOW_AT, true);
432 if ($nextShowAt > 0 && current_time('timestamp') < $nextShowAt) {
433 return false;
434 }
435
436 return true;
437 }
438
439 private static function getTotalBookingsCount(): int
440 {
441 $repo = new BookingRepository();
442 $table = $repo->getBookingsTableName();
443 global $wpdb;
444
445 $n = $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
446 return (int) $n;
447 }
448
449 /**
450 * @return array<string, mixed>
451 */
452 private static function buildReviewNotice(): array
453 {
454 return [
455 'id' => self::NOTICE_REVIEW,
456 'type' => 'info',
457 'title' => __('How’s Yatra working for you?', 'yatra'),
458 // Keep text domain on the same line (CI grep expects it).
459 'message' => __('You’ve published your first trip — congratulations. If Yatra is helping your business, a quick 5‑star review would mean a lot and helps other site owners choose with confidence.', 'yatra'),
460 'actions' => [
461 [
462 'label' => __('Leave a 5‑star review', 'yatra'),
463 'url' => 'https://wordpress.org/support/plugin/yatra/reviews/?filter=5#new-post',
464 'target' => '_blank',
465 ],
466 ],
467 ];
468 }
469
470 /**
471 * @return array<string, mixed>
472 */
473 private static function buildBuyProNotice(): array
474 {
475 return [
476 'id' => self::NOTICE_BUY_PRO,
477 'type' => 'warning',
478 'title' => __('Upgrade to Yatra Pro — save time on every booking', 'yatra'),
479 // Keep text domain on the same line (CI grep expects it).
480 'message' => __('You’re now receiving bookings. Yatra Pro helps you scale with premium payment gateways, advanced modules, and automation tools — built to reduce admin work and increase conversions.', 'yatra'),
481 'actions' => [
482 [
483 'label' => __('Upgrade to Pro', 'yatra'),
484 'url' => 'https://wpyatra.com/pricing/',
485 'target' => '_blank',
486 ],
487 ],
488 ];
489 }
490 }
491
492