| @@ -3,14 +3,15 @@ | ||
| 3 | 3 | namespace FluentBooking\App\Hooks\Handlers; |
| 4 | 4 | |
| 5 | 5 | use FluentBooking\App\App; |
| 6 | 6 | use FluentBooking\App\Vite; |
| 7 | +use FluentBooking\App\Models\Booking; | |
| 7 | 8 | use FluentBooking\App\Models\Calendar; |
| 8 | -use FluentBooking\App\Models\CalendarSlot; | |
| 9 | 9 | use FluentBooking\App\Services\DateTimeHelper; |
| 10 | 10 | use FluentBooking\App\Services\Helper; |
| 11 | 11 | use FluentBooking\App\Services\PermissionManager; |
| 12 | 12 | use FluentBooking\App\Services\TransStrings; |
| 13 | +use FluentBooking\Framework\Support\Arr; | |
| 13 | 14 | |
| 14 | 15 | class AdminMenuHandler |
| 15 | 16 | { |
| 16 | 17 | public function register() |
| @@ -16,16 +17,50 @@ | ||
| 16 | 17 | public function register() |
| 17 | 18 | { |
| 18 | 19 | add_action('admin_menu', [$this, 'add']); |
| 19 | 20 | |
| 20 | - add_action('admin_enqueue_scripts', function () { | |
| 21 | - if (!isset($_REQUEST['page']) || $_REQUEST['page'] != 'fluent-booking') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended | |
| 21 | + add_action('admin_enqueue_scripts', function ($hookSuffix) { | |
| 22 | + if ($hookSuffix !== 'toplevel_page_fluent-booking') { | |
| 22 | 23 | return; |
| 23 | 24 | } |
| 25 | + | |
| 24 | 26 | $this->enqueueAssets(); |
| 27 | + | |
| 28 | + add_action('admin_head', function () { | |
| 29 | + $this->printThemeClass(); | |
| 30 | + }, 1); | |
| 31 | + | |
| 25 | 32 | }, 100); |
| 26 | 33 | } |
| 27 | 34 | |
| 35 | + /** | |
| 36 | + * Put the dark class on the document before the page paints. | |
| 37 | + * | |
| 38 | + * The theme is chosen in the browser, so the server cannot know it - which means | |
| 39 | + * without this the first frame is always light, and somebody on dark gets a white flash | |
| 40 | + * on every page load. This runs in <head>, before wp-admin's own markup, and is the | |
| 41 | + * only reason it is inline rather than in global_admin.js: a file in the footer is too | |
| 42 | + * late to matter, and one in the header is still a request the paint would wait on. | |
| 43 | + * | |
| 44 | + * `fluent_theme_mode` is the key every Fluent plugin shares (see global_admin.js), and it | |
| 45 | + * holds a bare `light`, `dark` or `system`, so `system` is resolved here against the | |
| 46 | + * machine's own preference. <body> does not exist yet, so only <html> is marked; | |
| 47 | + * global_admin.js puts the same classes on <body> and #wpbody-content once it runs. | |
| 48 | + * | |
| 49 | + * @return void | |
| 50 | + */ | |
| 51 | + public function printThemeClass() | |
| 52 | + { | |
| 53 | + $script = <<<'JS' | |
| 54 | +(function(){try{var s=localStorage.getItem('fluent_theme_mode')||''; | |
| 55 | +if(!s){s=localStorage.getItem('fcal_color_mode')==='dark'?'dark':'light';} | |
| 56 | +if(s==='system'){s='system:'+(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');} | |
| 57 | +if(s==='dark'||s==='system:dark'){var e=document.documentElement;e.classList.add('fluent_theme_dark');e.classList.add('dark');}}catch(e){}})(); | |
| 58 | +JS; | |
| 59 | + | |
| 60 | + echo '<script id="fluent-booking-theme-class">' . $script . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped | |
| 61 | + } | |
| 62 | + | |
| 28 | 63 | public function add() |
| 29 | 64 | { |
| 30 | 65 | $capability = PermissionManager::getMenuPermission(); |
| 31 | 66 | $menuPriority = 26; |
| @@ -87,10 +122,90 @@ | ||
| 87 | 122 | $capability, |
| 88 | 123 | 'fluent-booking#/settings/general-settings', |
| 89 | 124 | [$this, 'render'] |
| 90 | 125 | ); |
| 126 | + | |
| 127 | + if ($this->shouldShowUpgrade()) { | |
| 128 | + add_submenu_page( | |
| 129 | + 'fluent-booking', | |
| 130 | + __('Upgrade to Pro', 'fluent-booking'), | |
| 131 | + '<span class="fcal_upgrade_pro_menu">' . esc_html__('Upgrade to Pro', 'fluent-booking') . '</span>', | |
| 132 | + 'manage_options', | |
| 133 | + Helper::getUpgradeUrl('admin_sidebar_menu') | |
| 134 | + ); | |
| 135 | + | |
| 136 | + add_action('admin_head', [$this, 'printUpgradeMenuStyle']); | |
| 137 | + add_action('admin_footer', [$this, 'printUpgradeMenuScript']); | |
| 138 | + } | |
| 91 | 139 | } |
| 92 | 140 | |
| 141 | + /** | |
| 142 | + * The upsell is for whoever can buy the license, so it stays hidden once Pro is loaded | |
| 143 | + * and from hosts who only reach the app through their own calendars. | |
| 144 | + * | |
| 145 | + * @return bool | |
| 146 | + */ | |
| 147 | + public function shouldShowUpgrade() | |
| 148 | + { | |
| 149 | + return !defined('FLUENT_BOOKING_PRO_DIR_FILE') && current_user_can('manage_options'); | |
| 150 | + } | |
| 151 | + | |
| 152 | + /** | |
| 153 | + * Onboarding asks on its final step. The dashboard card waits until the site has a completed | |
| 154 | + * booking, when FluentBooking has proven useful, and counts one the way the Bookings page's | |
| 155 | + * "Completed" tab does. | |
| 156 | + * | |
| 157 | + * @return array | |
| 158 | + */ | |
| 159 | + private function getOptinVars() | |
| 160 | + { | |
| 161 | + $showPrompt = $this->showOptinPrompt(); | |
| 162 | + | |
| 163 | + return [ | |
| 164 | + 'share_stats' => Arr::get((array) Helper::getMeta('option', 0, 'optin'), 'share_stats', ''), | |
| 165 | + 'can_manage' => PermissionManager::userCan('manage_all_data'), | |
| 166 | + 'show_prompt' => $showPrompt, | |
| 167 | + 'show_card' => $showPrompt && Booking::applyComputedStatus('completed')->exists(), | |
| 168 | + ]; | |
| 169 | + } | |
| 170 | + | |
| 171 | + /** | |
| 172 | + * The email opt-in prompt (dashboard card and onboarding) is for free sites that have not | |
| 173 | + * subscribed yet. Closing it hides it for 30 days. | |
| 174 | + * | |
| 175 | + * @return bool | |
| 176 | + */ | |
| 177 | + private function showOptinPrompt() | |
| 178 | + { | |
| 179 | + if (defined('FLUENT_BOOKING_PRO_DIR_FILE') || !PermissionManager::userCan('manage_all_data')) { | |
| 180 | + return false; | |
| 181 | + } | |
| 182 | + | |
| 183 | + $optin = (array) Helper::getMeta('option', 0, 'optin'); | |
| 184 | + | |
| 185 | + if (!empty($optin['email'])) { | |
| 186 | + return false; | |
| 187 | + } | |
| 188 | + | |
| 189 | + return empty($optin['dismissed_at']) || (time() - (int) $optin['dismissed_at']) > 30 * DAY_IN_SECONDS; | |
| 190 | + } | |
| 191 | + | |
| 192 | + public function printUpgradeMenuStyle() | |
| 193 | + { | |
| 194 | + echo '<style id="fluent-booking-upgrade-menu">#adminmenu .fcal_upgrade_pro_menu{color:#72aee6;font-weight:500;}</style>'; | |
| 195 | + } | |
| 196 | + | |
| 197 | + /** | |
| 198 | + * The submenu points off-site, and WordPress gives submenu links no target, so it would | |
| 199 | + * otherwise take the admin away from the dashboard. | |
| 200 | + * | |
| 201 | + * @return void | |
| 202 | + */ | |
| 203 | + public function printUpgradeMenuScript() | |
| 204 | + { | |
| 205 | + echo '<script id="fluent-booking-upgrade-menu-js">(function(){var s=document.querySelector("#adminmenu .fcal_upgrade_pro_menu");if(s&&s.parentNode){s.parentNode.setAttribute("target","_blank");s.parentNode.setAttribute("rel","noopener");}})();</script>'; | |
| 206 | + } | |
| 207 | + | |
| 93 | 208 | public function render() |
| 94 | 209 | { |
| 95 | 210 | if (!as_has_scheduled_action('fluent_booking_five_minutes_tasks')) { |
| 96 | 211 | as_schedule_recurring_action(time(), (60 * 5), 'fluent_booking_five_minutes_tasks', [], 'fluent-booking', true); |
| @@ -95,8 +210,15 @@ | ||
| 95 | 210 | if (!as_has_scheduled_action('fluent_booking_five_minutes_tasks')) { |
| 96 | 211 | as_schedule_recurring_action(time(), (60 * 5), 'fluent_booking_five_minutes_tasks', [], 'fluent-booking', true); |
| 97 | 212 | } |
| 98 | 213 | |
| 214 | + // The daily task carries the summary report and the MCP record purge. | |
| 215 | + // A site that never got it — a secondary blog on a network activation, | |
| 216 | + // or one that lost it — would grow fcal_mcp_g_* rows forever. | |
| 217 | + if (!as_has_scheduled_action('fluent_booking/daily_tasks')) { | |
| 218 | + as_schedule_recurring_action(time(), (60 * 60 * 24), 'fluent_booking/daily_tasks', [], 'fluent-booking', true); | |
| 219 | + } | |
| 220 | + | |
| 99 | 221 | $this->changeFooter(); |
| 100 | 222 | |
| 101 | 223 | $app = App::getInstance(); |
| 102 | 224 | |
| @@ -150,15 +272,16 @@ | ||
| 150 | 272 | |
| 151 | 273 | $assets = $app['url.assets']; |
| 152 | 274 | |
| 153 | 275 | $portalVars = apply_filters('fluent_booking/admin_portal_vars', [ |
| 154 | - 'name' => $name, | |
| 155 | - 'slug' => $slug, | |
| 156 | - 'menuItems' => $menuItems, | |
| 157 | - 'settings' => $settingItems, | |
| 158 | - 'baseUrl' => $baseUrl, | |
| 159 | - 'logo' => $assets . 'images/logo.svg', | |
| 160 | - 'dark_logo' => $assets . 'images/logo_dark.svg', | |
| 276 | + 'name' => $name, | |
| 277 | + 'slug' => $slug, | |
| 278 | + 'menuItems' => $menuItems, | |
| 279 | + 'settings' => $settingItems, | |
| 280 | + 'baseUrl' => $baseUrl, | |
| 281 | + 'logo' => $assets . 'images/logo.svg', | |
| 282 | + 'dark_logo' => $assets . 'images/logo_dark.svg', | |
| 283 | + 'upgradeUrl' => $this->shouldShowUpgrade() ? Helper::getUpgradeUrl('top_bar') : '', | |
| 161 | 284 | ]); |
| 162 | 285 | |
| 163 | 286 | do_action('fluent_booking/admin_app_rendering'); |
| 164 | 287 | |
| @@ -177,9 +300,9 @@ | ||
| 177 | 300 | return FLUENT_BOOKING_VERSION; |
| 178 | 301 | }); |
| 179 | 302 | } |
| 180 | 303 | |
| 181 | - public function enqueueAssets() | |
| 304 | + public function enqueueAssets($withApp = true) | |
| 182 | 305 | { |
| 183 | 306 | $app = App::getInstance(); |
| 184 | 307 | |
| 185 | 308 | $isRtl = Helper::fluentbooking_is_rtl(); |
| @@ -283,8 +406,14 @@ | ||
| 283 | 406 | }, 999999); |
| 284 | 407 | |
| 285 | 408 | Vite::enqueueStyle('fluent_booing_admin_app', 'admin_css', [], $assetsVersion, 'all'); |
| 286 | 409 | |
| 410 | + // The dashboard payload is per-user and includes every host's email address, | |
| 411 | + // so the app bundle is only ever emitted for an authenticated, permitted viewer. | |
| 412 | + if (!$withApp || !get_current_user_id()) { | |
| 413 | + return; | |
| 414 | + } | |
| 415 | + | |
| 287 | 416 | do_action($slug . '_loading_app'); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound |
| 288 | 417 | |
| 289 | 418 | Vite::enqueueScript($slug . '_admin_app', 'admin_app', array('jquery'), $assetsVersion); |
| 290 | 419 | |
| @@ -345,9 +474,8 @@ | ||
| 345 | 474 | $weekSelectTimes = Helper::getWeekSelectTimes(); |
| 346 | 475 | $overrideSelectTimes = Helper::getOverrideSelectTimes(); |
| 347 | 476 | $statusChangingTimes = Helper::getBookingStatusChangingTimes(); |
| 348 | 477 | $defaultTermsAndConditions = Helper::getDefaultTermsAndConditions(); |
| 349 | - $locationFields = (new CalendarSlot())->getLocationFields(); | |
| 350 | 478 | |
| 351 | 479 | return apply_filters('fluent_booking/admin_vars', [ |
| 352 | 480 | 'slug' => $slug = $app->config->get('app.slug'), |
| 353 | 481 | 'nonce' => wp_create_nonce($slug), |
| @@ -359,9 +487,8 @@ | ||
| 359 | 487 | 'multi_durations' => $multiDurations, |
| 360 | 488 | 'buffer_times' => $bufferTimes, |
| 361 | 489 | 'slot_intervals' => $slotIntervals, |
| 362 | 490 | 'schedule_schema' => $scheduleSchema, |
| 363 | - 'location_fields' => $locationFields, | |
| 364 | 491 | 'custom_field_types' => $customFieldTypes, |
| 365 | 492 | 'week_select_times' => $weekSelectTimes, |
| 366 | 493 | 'duration_lookup' => $durationLookup, |
| 367 | 494 | 'multi_duration_lookup' => $multiDurationLookup, |
| @@ -371,8 +498,9 @@ | ||
| 371 | 498 | 'me' => [ |
| 372 | 499 | 'id' => $currentUser->ID, |
| 373 | 500 | 'calendar_id' => $calendarId, |
| 374 | 501 | 'full_name' => $currentUsername, |
| 502 | + 'avatar' => get_avatar_url($currentUser->ID, ['size' => 96]), | |
| 375 | 503 | 'email' => $currentUser->user_email, |
| 376 | 504 | 'is_admin' => $hasAllAccess, |
| 377 | 505 | 'permissions' => PermissionManager::getUserPermissions($currentUser, false), |
| 378 | 506 | ], |
| @@ -379,8 +507,9 @@ | ||
| 379 | 507 | 'all_hosts' => Calendar::getAllHosts(), |
| 380 | 508 | 'is_new' => $isNew, |
| 381 | 509 | 'require_slug' => $requireSlug, |
| 382 | 510 | 'site_url' => site_url('/'), |
| 511 | + 'site_title' => get_bloginfo('name'), | |
| 383 | 512 | 'upgrade_url' => Helper::getUpgradeUrl(), |
| 384 | 513 | 'timezones' => DateTimeHelper::getTimeZones(true), |
| 385 | 514 | 'features' => Helper::getFeatures(), |
| 386 | 515 | 'supported_features' => apply_filters('fluent_booking/supported_featured', [ |
| @@ -391,8 +520,9 @@ | ||
| 391 | 520 | ], |
| 392 | 521 | 'has_pro' => defined('FLUENT_BOOKING_PRO_DIR_FILE'), |
| 393 | 522 | 'require_upgrade' => defined('FLUENT_BOOKING_PRO_DIR_FILE') && !defined('FLUENT_BOOKING_LITE'), |
| 394 | 523 | 'dashboard_notices' => apply_filters('fluent_booking/dashboard_notices', []), |
| 524 | + 'optin' => $this->getOptinVars(), | |
| 395 | 525 | 'payment_methods' => apply_filters('fluent_booking/payment/get_all_methods', []), |
| 396 | 526 | 'trans' => TransStrings::getStrings(), |
| 397 | 527 | 'date_format' => DateTimeHelper::getDateFormatter(true), |
| 398 | 528 | 'time_format' => DateTimeHelper::getTimeFormatter(true), |