| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Metricool\Controllers; |
| 6 |
|
| 7 |
if (!defined('ABSPATH')) { |
| 8 |
exit; |
| 9 |
} |
| 10 |
|
| 11 |
use Metricool\Support\Helpers\Storages\LanguagesConfig; |
| 12 |
use Metricool\Traits\HasViews; |
| 13 |
use Metricool\Traits\HasUserAccess; |
| 14 |
use Metricool\Services\DashboardService; |
| 15 |
use Metricool\Traits\HasAllowlistControl; |
| 16 |
use Metricool\Http\Metricool\MetricoolApi; |
| 17 |
use Metricool\Interfaces\ControllerInterface; |
| 18 |
use Metricool\Services\MetricoolAccountService; |
| 19 |
use Metricool\Support\Helpers\Storages\EnvironmentConfig; |
| 20 |
|
| 21 |
class DashboardController implements ControllerInterface |
| 22 |
{ |
| 23 |
use HasViews; |
| 24 |
use HasUserAccess; |
| 25 |
use HasAllowlistControl; |
| 26 |
|
| 27 |
private EnvironmentConfig $env; |
| 28 |
private MetricoolApi $metricool; |
| 29 |
private DashboardService $service; |
| 30 |
private MetricoolAccountService $account; |
| 31 |
private LanguagesConfig $languages; |
| 32 |
|
| 33 |
public function __construct(EnvironmentConfig $env, MetricoolApi $metricool, DashboardService $service, MetricoolAccountService $account, LanguagesConfig $languages) |
| 34 |
{ |
| 35 |
$this->env = $env; |
| 36 |
$this->metricool = $metricool; |
| 37 |
$this->service = $service; |
| 38 |
$this->account = $account; |
| 39 |
$this->languages = $languages; |
| 40 |
} |
| 41 |
|
| 42 |
public function register(): void |
| 43 |
{ |
| 44 |
// Show forced login screen when coming from legacy |
| 45 |
add_action('metricool_plugin_legacy_upgrade', [$this->service, 'enableForcedLogin']); |
| 46 |
add_action('metricool_onboarding_completed', [$this->service, 'disableForcedLogin']); |
| 47 |
|
| 48 |
// Redirect on the activation hook, but do it after anything else. |
| 49 |
add_action('metricool_activation', [$this, 'maybeRedirectToDashboard'], 9999); |
| 50 |
|
| 51 |
add_action('admin_menu', [$this, 'addDashboardPage']); |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* Redirect to metricool dashboard page on activation, but only if the user |
| 56 |
* manually activated the plugin via the plugins overview. React will handle |
| 57 |
* redirect to onboarding if needed. |
| 58 |
* |
| 59 |
* @param string $pageSource The page where the activation was triggered, |
| 60 |
* usually 'plugins.php' but can be other pages as well. |
| 61 |
*/ |
| 62 |
public function maybeRedirectToDashboard(string $pageSource = ''): void |
| 63 |
{ |
| 64 |
if ($pageSource !== 'plugins.php') { |
| 65 |
return; |
| 66 |
} |
| 67 |
|
| 68 |
wp_safe_redirect($this->env->getUrl('plugin.dashboard_url')); |
| 69 |
exit; |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* Add the dashboard page to the admin menu of WordPress. Also triggers the |
| 74 |
* action to enqueue scripts and styles |
| 75 |
* @uses apply_filters metricool_menu_position |
| 76 |
*/ |
| 77 |
public function addDashboardPage(): void |
| 78 |
{ |
| 79 |
/** |
| 80 |
* Filter: metricool_menu_position |
| 81 |
* Can be used to change the position of the menu item in the admin menu. |
| 82 |
* @param int $menuPosition |
| 83 |
* @return int Default 59 to be positioned after "wp-menu-separator" and |
| 84 |
* before "Appearance". |
| 85 |
*/ |
| 86 |
$menuPosition = apply_filters('metricool_menu_position', 59); |
| 87 |
|
| 88 |
$pageHookSuffix = add_menu_page( |
| 89 |
esc_html__('Metricool', 'metricool'), |
| 90 |
esc_html__('Metricool', 'metricool'), |
| 91 |
'metricool_manage', |
| 92 |
'metricool', |
| 93 |
[$this, 'renderReactApp'], |
| 94 |
$this->getMenuItemIconSVG(), |
| 95 |
$menuPosition, |
| 96 |
); |
| 97 |
|
| 98 |
add_action("admin_print_scripts-$pageHookSuffix", [$this, 'enqueueReactScripts']); |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* Render the React app in the WordPress admin |
| 103 |
*/ |
| 104 |
public function renderReactApp(): void |
| 105 |
{ |
| 106 |
$this->render('admin/dashboard', [], 'html'); |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* Returns the SVG icon for the menu item in the admin menu. |
| 111 |
* @return string Base64 encoded SVG image |
| 112 |
*/ |
| 113 |
public function getMenuItemIconSVG(): string |
| 114 |
{ |
| 115 |
$svg = '<svg width="20" height="20" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg"> |
| 116 |
<path d="M44 0C47.3137 1.28851e-07 50 2.68629 50 6V44C50 47.3137 47.3137 50 44 50H6C2.68629 50 0 47.3137 0 44V6C1.28855e-07 2.68629 2.68629 0 6 0H44ZM34.7744 17.001C32.8644 16.9787 30.9998 17.5888 29.4648 18.7383L23.3008 23.0029C22.4171 19.344 19.1215 17.001 15.3154 17.001C10.7691 17.001 7 20.2068 7 24.9854C7.00016 29.7938 10.769 33 15.3154 33C17.2729 33.0189 19.1774 32.3565 20.71 31.125L26.7002 26.9805C27.5862 30.6571 30.8925 33 34.7744 33C39.2309 32.9999 42.9998 29.7937 43 24.9854C43 20.2068 39.2314 17.001 34.7744 17.001ZM15.3154 21.0527C17.1699 21.0528 18.9346 22.6264 18.9346 24.9854C18.9344 27.3743 17.1698 28.9169 15.3154 28.917C13.3715 28.917 11.6067 27.3742 11.6064 24.9854C11.6064 22.6263 13.3714 21.0527 15.3154 21.0527ZM34.7744 21.0527C36.629 21.0528 38.3936 22.6264 38.3936 24.9854C38.3933 27.3743 36.6289 28.9169 34.7744 28.917C32.8305 28.917 31.0656 27.3742 31.0654 24.9854C31.0654 22.6263 32.8303 21.0527 34.7744 21.0527Z" fill="#a7aaad"/> |
| 117 |
</svg>'; |
| 118 |
|
| 119 |
return 'data:image/svg+xml;base64,' . base64_encode($svg); |
| 120 |
} |
| 121 |
|
| 122 |
/** |
| 123 |
* Enqueue the Tailwind CSS for the dashboard in the header |
| 124 |
*/ |
| 125 |
public function enqueueDashboardStyles(): void |
| 126 |
{ |
| 127 |
$chunkTranslation = $this->getReactChunkTranslations(); |
| 128 |
if (empty($chunkTranslation)) { |
| 129 |
return; |
| 130 |
} |
| 131 |
|
| 132 |
wp_enqueue_style( |
| 133 |
'metricool-tailwind', |
| 134 |
$this->env->getUrl('plugin.assets_url') . '/css/tailwind.generated.css', // todo - feel free to place this in a different location |
| 135 |
[], |
| 136 |
($chunkTranslation['version'] ?? '') |
| 137 |
); |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Enqueue the React scripts and styles for the dashboard: |
| 142 |
* All files to enqueue are listed in manifest.json, generated by Vite, |
| 143 |
* and available in react/build/assets |
| 144 |
* |
| 145 |
* Load translations for the React app |
| 146 |
*/ |
| 147 |
public function enqueueReactScripts(): void |
| 148 |
{ |
| 149 |
$manifest = $this->env->getString('plugin.react_path') . '/build/.vite/manifest.json'; |
| 150 |
$json_translations = []; |
| 151 |
if (file_exists($manifest)) { |
| 152 |
$manifest_contents = file_get_contents($manifest); |
| 153 |
$decoded_manifest = json_decode($manifest_contents, true); |
| 154 |
foreach ($decoded_manifest as $key => $value) { |
| 155 |
if (substr($value['file'], -3) === '.js') { |
| 156 |
wp_register_script( |
| 157 |
$key, |
| 158 |
$this->env->getUrl('plugin.react_url') . '/build/' . $value['file'], |
| 159 |
[], |
| 160 |
null, |
| 161 |
false, |
| 162 |
); |
| 163 |
$translation_data = load_script_textdomain($key, 'metricool'); |
| 164 |
if (!empty($translation_data)) { |
| 165 |
$json_translations[] = $translation_data; |
| 166 |
} |
| 167 |
|
| 168 |
if (!empty($value['isEntry']) && $value['isEntry'] === true) { |
| 169 |
wp_enqueue_script( |
| 170 |
'metricool-main-script', |
| 171 |
$this->env->getUrl('plugin.react_url') . '/build/' . $value['file'], |
| 172 |
[], |
| 173 |
null, |
| 174 |
false, |
| 175 |
); |
| 176 |
} |
| 177 |
} |
| 178 |
if (!empty($value['css'])) { |
| 179 |
wp_enqueue_style( |
| 180 |
'metricool-tailwind', |
| 181 |
$this->env->getUrl('plugin.react_url') . '/build/' . $value['css'][0], |
| 182 |
[], |
| 183 |
null, |
| 184 |
); |
| 185 |
} |
| 186 |
} |
| 187 |
} |
| 188 |
|
| 189 |
add_filter('script_loader_tag', [$this, 'loadMainScriptsAsModule'], 10, 2); |
| 190 |
|
| 191 |
wp_localize_script( |
| 192 |
'metricool-main-script', |
| 193 |
'metricool', |
| 194 |
['values' => $this->localizedReactSettings(['json_translations' => $json_translations])], |
| 195 |
); |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* WordPress doesn't allow for translation of chunks resulting of code |
| 200 |
* splitting. Several workarounds have popped up in JetPack and Woocommerce. |
| 201 |
* Below is mainly based on the Woocommerce solution, which seems to be the |
| 202 |
* simplest approach. Simplicity is king here. |
| 203 |
* @see https://wordpress.com/blog/2022/01/06/wordpress-plugin-i18n-webpack-and-composer/ |
| 204 |
*/ |
| 205 |
private function getReactChunkTranslations(): array |
| 206 |
{ |
| 207 |
$cacheName = 'metricool-react-chunk-translations'; |
| 208 |
if ($cache = wp_cache_get($cacheName, 'metricool')) { |
| 209 |
return $cache; |
| 210 |
} |
| 211 |
|
| 212 |
// get all files from the settings/build folder |
| 213 |
$buildDirPath = $this->env->getString('plugin.react_path') . '/build'; |
| 214 |
$filenames = scandir($buildDirPath); |
| 215 |
|
| 216 |
$jsFileName = ''; |
| 217 |
$assetFilename = ''; |
| 218 |
$jsonTranslations = []; |
| 219 |
|
| 220 |
// filter the filenames to get the JavaScript and asset filenames |
| 221 |
foreach ($filenames as $filename) { |
| 222 |
if (strpos($filename, 'index.') === 0) { |
| 223 |
if (substr($filename, -3) === '.js') { |
| 224 |
$jsFileName = $filename; |
| 225 |
} elseif (substr($filename, -10) === '.asset.php') { |
| 226 |
$assetFilename = $filename; |
| 227 |
} |
| 228 |
} |
| 229 |
|
| 230 |
if (strpos($filename, '.js') === false) { |
| 231 |
continue; |
| 232 |
} |
| 233 |
|
| 234 |
// remove extension from $filename |
| 235 |
$chunkHandle = str_replace('.js', '', $filename); |
| 236 |
// temporarily register the script, so we can get a translations object. |
| 237 |
$chunkSource = $this->env->getUrl('plugin.react_url') . '/build/' . $filename; |
| 238 |
wp_register_script($chunkHandle, $chunkSource, [], $this->env->getString('plugin.version'), true); |
| 239 |
|
| 240 |
//as there is no pro version of this plugin, no need to declare a path |
| 241 |
$localeData = load_script_textdomain($chunkHandle, 'metricool'); |
| 242 |
if (!empty($localeData)) { |
| 243 |
$jsonTranslations[] = $localeData; |
| 244 |
} |
| 245 |
|
| 246 |
wp_deregister_script($chunkHandle); |
| 247 |
} |
| 248 |
|
| 249 |
if (empty($jsFileName)) { |
| 250 |
return []; |
| 251 |
} |
| 252 |
|
| 253 |
$assetFileData = require $buildDirPath . '/' . $assetFilename; |
| 254 |
$chunkTranslations = [ |
| 255 |
'json_translations' => $jsonTranslations, |
| 256 |
'js_file_name' => $jsFileName, |
| 257 |
'dependencies' => $assetFileData['dependencies'] ?? [], |
| 258 |
'version' => $assetFileData['version'] ?? '', |
| 259 |
]; |
| 260 |
|
| 261 |
wp_cache_set($cacheName, $chunkTranslations, 'metricool'); |
| 262 |
return $chunkTranslations; |
| 263 |
} |
| 264 |
|
| 265 |
/** |
| 266 |
* Build the localization array for the React script with the translations |
| 267 |
* @uses apply_filters metricool_localize_dashboard_script |
| 268 |
*/ |
| 269 |
private function localizedReactSettings(array $chunkTranslation): array |
| 270 |
{ |
| 271 |
$settings = [ |
| 272 |
'nonce' => wp_create_nonce('metricool_nonce'), |
| 273 |
'x_wp_nonce' => wp_create_nonce('wp_rest'), |
| 274 |
'ajax_url' => admin_url('admin-ajax.php'), |
| 275 |
'rest_url' => get_rest_url(), |
| 276 |
'rest_namespace' => $this->env->getString('http.namespace'), |
| 277 |
'rest_version' => $this->env->getString('http.version'), |
| 278 |
'api_url' => trailingslashit( |
| 279 |
get_rest_url(null, $this->env->getString('http.namespace') . '/' . $this->env->getString('http.version')) |
| 280 |
), |
| 281 |
'dashboard_url' => $this->env->getString('plugin.dashboard_url'), |
| 282 |
'site_url' => site_url(), |
| 283 |
'assets_url' => $this->env->getUrl('plugin.assets_url'), |
| 284 |
'json_translations' => ($chunkTranslation['json_translations'] ?? []), |
| 285 |
'trusted_urls' => $this->env->get('frontend.trusted_urls'), |
| 286 |
'onboarding' => [ |
| 287 |
'state' => $this->service->state(), |
| 288 |
'mode' => $this->service->mode(), |
| 289 |
], |
| 290 |
'support' => $this->env->getUrl('metricool.support'), |
| 291 |
'metricool_base_url' => $this->env->getUrl('metricool.base_url'), |
| 292 |
'metricool_help_url' => $this->env->getUrl('metricool.help_url'), |
| 293 |
'locale' => str_replace("_", "-", get_user_locale()), |
| 294 |
'supported_languages' => $this->languages->all(), |
| 295 |
'google_recaptcha_url' => $this->getGoogleRecaptchaUrl(), |
| 296 |
'google_recaptcha_key' => $this->env->getString('metricool.google_recaptcha_key'), |
| 297 |
]; |
| 298 |
|
| 299 |
if ($this->metricool->hasAuthentication()) { |
| 300 |
$settings['account'] = $this->account->fetch(); |
| 301 |
} |
| 302 |
|
| 303 |
return apply_filters('metricool_localize_dashboard_script', $settings); |
| 304 |
} |
| 305 |
|
| 306 |
/** |
| 307 |
* Get the Google reCAPTCHA URL with the key from the environment config. |
| 308 |
*/ |
| 309 |
private function getGoogleRecaptchaUrl(): string |
| 310 |
{ |
| 311 |
return 'https://www.google.com/recaptcha/enterprise.js?render=' . $this->env->getString('metricool.google_recaptcha_key'); |
| 312 |
} |
| 313 |
|
| 314 |
public function loadMainScriptsAsModule(string $tag, string $handle): string |
| 315 |
{ |
| 316 |
if (str_contains($handle, 'metricool-main-script') === false) { |
| 317 |
return $tag; |
| 318 |
} |
| 319 |
|
| 320 |
return str_replace('<script ', '<script type="module" ', $tag); |
| 321 |
} |
| 322 |
} |
| 323 |
|