| 1 |
<?php |
| 2 |
/** |
| 3 |
* ContextTools — discovery and setup abilities. |
| 4 |
* |
| 5 |
* `get-funnel-context` is the first call any agent should make: it reports what |
| 6 |
* this install actually supports so the model never proposes a step type, |
| 7 |
* builder or offer this site cannot build. |
| 8 |
* |
| 9 |
* @package WPFunnels\MCP |
| 10 |
* @since 3.13.0 |
| 11 |
*/ |
| 12 |
|
| 13 |
namespace WPFunnels\MCP\Tools; |
| 14 |
|
| 15 |
defined( 'ABSPATH' ) || exit; |
| 16 |
|
| 17 |
use WPFunnels\MCP\Helpers\MCPHelper; |
| 18 |
use WPFunnels\Wpfnl_functions; |
| 19 |
use WPFunnels\Rest\Controllers\SettingsController; |
| 20 |
|
| 21 |
/** |
| 22 |
* Class ContextTools |
| 23 |
*/ |
| 24 |
class ContextTools { |
| 25 |
|
| 26 |
/** |
| 27 |
* Transient holding the cached context payload. |
| 28 |
*/ |
| 29 |
private const CACHE_KEY = '_wpfnl_mcp_context_cache'; |
| 30 |
|
| 31 |
/** |
| 32 |
* Cache lifetime in seconds. Short: funnels change while an agent works. |
| 33 |
*/ |
| 34 |
private const CACHE_TTL = 60; |
| 35 |
|
| 36 |
/** |
| 37 |
* WPFunnels-owned option keys the generic settings mechanism may read/write. |
| 38 |
* |
| 39 |
* This is a verbatim copy of the explicit allow-list inside |
| 40 |
* `SettingsController::is_allowed_settings_group()`. That method is a |
| 41 |
* security boundary: group_id is passed straight into |
| 42 |
* get_option()/update_option(), so an unguarded value could target |
| 43 |
* arbitrary WordPress options (e.g. `wp_user_roles`). The source method is |
| 44 |
* `protected` with no legitimate inheritance path from this class, so the |
| 45 |
* list is duplicated rather than called into — keep it byte-for-byte in |
| 46 |
* sync with the controller if that list ever changes. |
| 47 |
* |
| 48 |
* @see \WPFunnels\Rest\Controllers\SettingsController::is_allowed_settings_group() |
| 49 |
*/ |
| 50 |
private const ALLOWED_SETTINGS_GROUPS = [ |
| 51 |
'_wpfunnels_general_settings', |
| 52 |
'_wpfunnels_permalink_settings', |
| 53 |
'_wpfunnels_optin_settings', |
| 54 |
'_wpfunnels_advanced_settings', |
| 55 |
'_wpfunnels_integrations', |
| 56 |
'_wpfunnels_notification_settings', |
| 57 |
'_wpfunnels_lms_settings', |
| 58 |
'_wpfunnels_recaptcha_setting', |
| 59 |
'_wpfunnels_google_map_api_key', |
| 60 |
'_wpfunnels_user_roles', |
| 61 |
]; |
| 62 |
|
| 63 |
/** |
| 64 |
* Ability definitions for this domain. |
| 65 |
* |
| 66 |
* @return array |
| 67 |
*/ |
| 68 |
public static function definitions() { |
| 69 |
return [ |
| 70 |
'wpfunnels/get-funnel-context' => [ |
| 71 |
'label' => __( 'Get Funnel Context', 'wpfnl' ), |
| 72 |
'description' => 'Snapshot of this WPFunnels install: plugin versions, licence tier, active page builder, WooCommerce and Mail Mint availability, currency, funnel counts by type, supported step types, and the guidelines for building funnels here. Call this FIRST in any session — it tells you what this site can actually do.', |
| 73 |
'input_schema' => [ |
| 74 |
'type' => 'object', |
| 75 |
'properties' => [], |
| 76 |
], |
| 77 |
'execute_callback' => [ __CLASS__, 'getFunnelContext' ], |
| 78 |
'permission_callback' => MCPHelper::currentUserCan(), |
| 79 |
'annotations' => [ 'readonly' ], |
| 80 |
], |
| 81 |
'wpfunnels/get-site-design-tokens' => [ |
| 82 |
'label' => __( 'Get Site Design Tokens', 'wpfnl' ), |
| 83 |
'description' => 'The site\'s Elementor design tokens (currently just the kit\'s primary brand color, if Elementor is active). compose-step-layout already applies this automatically to the widgets it builds — call this only to answer a direct question about the site\'s brand color, not before composing a page.', |
| 84 |
'input_schema' => [ |
| 85 |
'type' => 'object', |
| 86 |
'properties' => [], |
| 87 |
], |
| 88 |
'execute_callback' => [ __CLASS__, 'getSiteDesignTokens' ], |
| 89 |
'permission_callback' => MCPHelper::currentUserCan(), |
| 90 |
'annotations' => [ 'readonly' ], |
| 91 |
], |
| 92 |
'wpfunnels/check-setup-status' => [ |
| 93 |
'label' => __( 'Check Setup Status', 'wpfnl' ), |
| 94 |
'description' => 'Audit whether this install is ready to run funnels: setup wizard completion, page builder availability, WooCommerce state, permalink structure, global settings and licence. Returns a list of issues with the admin URL to fix each one.', |
| 95 |
'input_schema' => [ |
| 96 |
'type' => 'object', |
| 97 |
'properties' => [], |
| 98 |
], |
| 99 |
'execute_callback' => [ __CLASS__, 'checkSetupStatus' ], |
| 100 |
'permission_callback' => MCPHelper::currentUserCan(), |
| 101 |
'annotations' => [ 'readonly' ], |
| 102 |
], |
| 103 |
'wpfunnels/update-global-settings' => [ |
| 104 |
'label' => __( 'Update Global Settings', 'wpfnl' ), |
| 105 |
'description' => 'Update WPFunnels\' site-wide settings (default page builder, order bump enable, A/B testing enable, funnel builder mode, and similar toggles from Settings → General). Only recognized keys are written; unknown keys are ignored. This is a merge, not a full replace — existing settings not mentioned are left alone.', |
| 106 |
'input_schema' => [ |
| 107 |
'type' => 'object', |
| 108 |
'properties' => [ |
| 109 |
'settings' => [ |
| 110 |
'type' => 'object', |
| 111 |
'description' => 'Key/value pairs matching the keys returned by get-funnel-context\'s settings section (e.g. builder, order_bump, ab_testing, funnel_builder_mode).', |
| 112 |
], |
| 113 |
], |
| 114 |
'required' => [ 'settings' ], |
| 115 |
], |
| 116 |
'execute_callback' => [ __CLASS__, 'updateGlobalSettings' ], |
| 117 |
'permission_callback' => MCPHelper::currentUserCan(), |
| 118 |
'annotations' => [ 'destructive' ], |
| 119 |
], |
| 120 |
'wpfunnels/update-permalink-settings' => [ |
| 121 |
'label' => __( 'Update Permalink Settings', 'wpfnl' ), |
| 122 |
'description' => 'Update WPFunnels\' funnel/step URL structure: the permalink style and the URL base segments used for funnel and step pages. Changes site URL structure — every existing funnel/step link changes if the base segments change.', |
| 123 |
'input_schema' => [ |
| 124 |
'type' => 'object', |
| 125 |
'properties' => [ |
| 126 |
'structure' => [ |
| 127 |
'type' => 'string', |
| 128 |
'description' => 'Permalink style for funnel/step URLs.', |
| 129 |
], |
| 130 |
'funnel_base' => [ |
| 131 |
'type' => 'string', |
| 132 |
'description' => 'URL base segment for funnels.', |
| 133 |
], |
| 134 |
'step_base' => [ |
| 135 |
'type' => 'string', |
| 136 |
'description' => 'URL base segment for steps.', |
| 137 |
], |
| 138 |
], |
| 139 |
], |
| 140 |
'execute_callback' => [ __CLASS__, 'updatePermalinkSettings' ], |
| 141 |
'permission_callback' => MCPHelper::currentUserCan( 'manage_options' ), |
| 142 |
'annotations' => [ 'destructive' ], |
| 143 |
], |
| 144 |
'wpfunnels/get-settings-group' => [ |
| 145 |
'label' => __( 'Get Settings Group', 'wpfnl' ), |
| 146 |
'description' => 'Read one WPFunnels site-wide settings group by its option-group ID. Covers tabs with no dedicated ability: _wpfunnels_optin_settings (global opt-in email defaults — sender_name, sender_email, email_subject), _wpfunnels_lms_settings (lms_provider and other LMS defaults), _wpfunnels_notification_settings (revenue report emails — enable_revenue_report, revenue_report_frequency, revenue_report_recipient, revenue_report_subject, send_time), _wpfunnels_recaptcha_setting (enable_recaptcha, recaptcha_site_key, recaptcha_site_secret), _wpfunnels_advanced_settings (show_supported_payment_gateway), _wpfunnels_google_map_api_key (a plain string, returned as {value: "..."}), _wpfunnels_user_roles (map of role slug to "yes"/"no" funnel-management access), and _wpfunnels_integrations (reserved for a future integrations tab; not currently populated by core). _wpfunnels_general_settings and _wpfunnels_permalink_settings also work here but get-funnel-context / update-global-settings / update-permalink-settings are the better-typed way to read/write those. Only WPFunnels-owned _wpfunnels_* option keys are accepted; anything else is rejected.', |
| 147 |
'input_schema' => [ |
| 148 |
'type' => 'object', |
| 149 |
'properties' => [ |
| 150 |
'group_id' => [ |
| 151 |
'type' => 'string', |
| 152 |
'description' => 'Settings group / option key to read, e.g. _wpfunnels_optin_settings, _wpfunnels_notification_settings, _wpfunnels_recaptcha_setting, _wpfunnels_lms_settings, _wpfunnels_advanced_settings, _wpfunnels_google_map_api_key, _wpfunnels_user_roles, _wpfunnels_integrations, _wpfunnels_general_settings, _wpfunnels_permalink_settings.', |
| 153 |
], |
| 154 |
], |
| 155 |
'required' => [ 'group_id' ], |
| 156 |
], |
| 157 |
'execute_callback' => [ __CLASS__, 'getSettingsGroup' ], |
| 158 |
'permission_callback' => MCPHelper::currentUserCan(), |
| 159 |
'annotations' => [ 'readonly' ], |
| 160 |
], |
| 161 |
'wpfunnels/update-settings-group' => [ |
| 162 |
'label' => __( 'Update Settings Group', 'wpfnl' ), |
| 163 |
'description' => 'Update one or more keys in a WPFunnels site-wide settings group (see get-settings-group for the field names each group holds). This is a merge into the existing group, applied one key at a time — a later key failing does not roll back keys already applied. Only WPFunnels-owned _wpfunnels_* option keys are accepted, the same allow-list get-settings-group uses; anything else is rejected. Changes here are site-wide and can affect every funnel (e.g. disabling reCAPTCHA, changing revenue report recipients, changing the LMS provider) — use narrower abilities like update-global-settings or update-permalink-settings when they already cover the field you need.', |
| 164 |
'input_schema' => [ |
| 165 |
'type' => 'object', |
| 166 |
'properties' => [ |
| 167 |
'group_id' => [ |
| 168 |
'type' => 'string', |
| 169 |
'description' => 'Settings group / option key to update. See get-settings-group for the allowed groups and their fields.', |
| 170 |
], |
| 171 |
'settings' => [ |
| 172 |
'type' => 'object', |
| 173 |
'description' => 'Key/value pairs to merge into this group, e.g. {"enable_recaptcha": "yes", "recaptcha_site_key": "..."}. For the scalar _wpfunnels_google_map_api_key group, pass {"value": "..."}.', |
| 174 |
], |
| 175 |
], |
| 176 |
'required' => [ 'group_id', 'settings' ], |
| 177 |
], |
| 178 |
'execute_callback' => [ __CLASS__, 'updateSettingsGroup' ], |
| 179 |
'permission_callback' => MCPHelper::currentUserCan( 'manage_options' ), |
| 180 |
'annotations' => [ 'destructive' ], |
| 181 |
], |
| 182 |
]; |
| 183 |
} |
| 184 |
|
| 185 |
/** |
| 186 |
* Invalidate the cached context. Wired to funnel/step mutations. |
| 187 |
* |
| 188 |
* @return void |
| 189 |
*/ |
| 190 |
public static function invalidateCache() { |
| 191 |
delete_transient( self::CACHE_KEY ); |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Build (or serve cached) install context. |
| 196 |
* |
| 197 |
* @return array |
| 198 |
*/ |
| 199 |
public static function getFunnelContext() { |
| 200 |
$cached = get_transient( self::CACHE_KEY ); |
| 201 |
if ( is_array( $cached ) ) { |
| 202 |
return $cached; |
| 203 |
} |
| 204 |
|
| 205 |
$general = Wpfnl_functions::get_general_settings(); |
| 206 |
$wc = Wpfnl_functions::is_wc_active(); |
| 207 |
$pro = Wpfnl_functions::is_wpfnl_pro_activated(); |
| 208 |
$counts = self::funnelCounts(); |
| 209 |
$builder = Wpfnl_functions::get_builder_type(); |
| 210 |
|
| 211 |
$context = [ |
| 212 |
'site' => [ |
| 213 |
'name' => get_bloginfo( 'name' ), |
| 214 |
'url' => home_url(), |
| 215 |
'admin_url' => admin_url(), |
| 216 |
'locale' => get_locale(), |
| 217 |
'timezone' => wp_timezone_string(), |
| 218 |
'today' => current_time( 'Y-m-d' ), |
| 219 |
], |
| 220 |
'plugin' => [ |
| 221 |
'version' => defined( 'WPFNL_VERSION' ) ? WPFNL_VERSION : '', |
| 222 |
'pro_active' => $pro, |
| 223 |
'pro_version' => defined( 'WPFNL_PRO_VERSION' ) ? WPFNL_PRO_VERSION : '', |
| 224 |
], |
| 225 |
'capabilities' => [ |
| 226 |
'page_builder' => $builder ? $builder : 'gutenberg', |
| 227 |
'elementor_active' => Wpfnl_functions::is_elementor_active(), |
| 228 |
'woocommerce' => $wc, |
| 229 |
'currency' => $wc && function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', |
| 230 |
'currency_symbol' => $wc && function_exists( 'get_woocommerce_currency_symbol' ) ? html_entity_decode( get_woocommerce_currency_symbol() ) : '', |
| 231 |
'mail_mint' => class_exists( '\Mint\MRM\Internal\AI\AIInit' ) || defined( 'MAIL_MINT_VERSION' ), |
| 232 |
'lms' => Wpfnl_functions::is_lms_addon_active(), |
| 233 |
'supported_step_types' => MCPHelper::supportedStepTypes(), |
| 234 |
'offer_steps' => $wc && $pro, |
| 235 |
'order_bump' => $wc, |
| 236 |
'ab_testing' => $pro, |
| 237 |
'analytics' => $pro, |
| 238 |
], |
| 239 |
'funnels' => $counts, |
| 240 |
'settings' => [ |
| 241 |
'permalink_base' => isset( $general['permalink'] ) ? $general['permalink'] : '', |
| 242 |
'funnel_builder_mode' => isset( $general['funnel_builder_mode'] ) ? $general['funnel_builder_mode'] : 'horizontal', |
| 243 |
], |
| 244 |
'guidelines' => self::guidelines( $wc, $pro ), |
| 245 |
]; |
| 246 |
|
| 247 |
set_transient( self::CACHE_KEY, $context, self::CACHE_TTL ); |
| 248 |
|
| 249 |
return $context; |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* The site's Elementor design tokens. |
| 254 |
* |
| 255 |
* @return array |
| 256 |
*/ |
| 257 |
public static function getSiteDesignTokens() { |
| 258 |
return MCPHelper::getSiteDesignTokens(); |
| 259 |
} |
| 260 |
|
| 261 |
/** |
| 262 |
* Setup audit with actionable links. |
| 263 |
* |
| 264 |
* @return array |
| 265 |
*/ |
| 266 |
public static function checkSetupStatus() { |
| 267 |
$issues = []; |
| 268 |
$general = Wpfnl_functions::get_general_settings(); |
| 269 |
$wc = Wpfnl_functions::is_wc_active(); |
| 270 |
|
| 271 |
if ( ! $wc && ! Wpfnl_functions::is_lms_addon_active() ) { |
| 272 |
$issues[] = [ |
| 273 |
'severity' => 'warning', |
| 274 |
'issue' => 'WooCommerce is not active, so checkout, order bump and offer steps are unavailable.', |
| 275 |
'fix' => 'Install and activate WooCommerce, or build lead-generation funnels instead.', |
| 276 |
'url' => admin_url( 'plugin-install.php?s=woocommerce&tab=search&type=term' ), |
| 277 |
]; |
| 278 |
} |
| 279 |
|
| 280 |
if ( ! get_option( 'permalink_structure' ) ) { |
| 281 |
$issues[] = [ |
| 282 |
'severity' => 'error', |
| 283 |
'issue' => 'Plain permalinks are enabled; funnel step URLs will not resolve.', |
| 284 |
'fix' => 'Switch to any pretty permalink structure.', |
| 285 |
'url' => admin_url( 'options-permalink.php' ), |
| 286 |
]; |
| 287 |
} |
| 288 |
|
| 289 |
if ( empty( $general['builder'] ) ) { |
| 290 |
$issues[] = [ |
| 291 |
'severity' => 'warning', |
| 292 |
'issue' => 'No page builder is selected, so new steps fall back to the block editor.', |
| 293 |
'fix' => 'Pick a page builder in WPFunnels settings.', |
| 294 |
'url' => admin_url( 'admin.php?page=' . ( defined( 'WPFNL_GLOBAL_SETTINGS_SLUG' ) ? WPFNL_GLOBAL_SETTINGS_SLUG : 'wpfnl_settings' ) ), |
| 295 |
]; |
| 296 |
} |
| 297 |
|
| 298 |
if ( ! Wpfnl_functions::is_wpfnl_pro_activated() ) { |
| 299 |
$issues[] = [ |
| 300 |
'severity' => 'info', |
| 301 |
'issue' => 'WPFunnels Pro is not active: A/B testing, analytics, one-click upsells and CRM integrations are unavailable.', |
| 302 |
'fix' => 'Activate WPFunnels Pro to unlock those tools.', |
| 303 |
'url' => admin_url( 'admin.php?page=wpf-license' ), |
| 304 |
]; |
| 305 |
} |
| 306 |
|
| 307 |
$counts = self::funnelCounts(); |
| 308 |
if ( 0 === $counts['total'] ) { |
| 309 |
$issues[] = [ |
| 310 |
'severity' => 'info', |
| 311 |
'issue' => 'No funnels exist yet.', |
| 312 |
'fix' => 'Create one with wpfunnels/create-funnel.', |
| 313 |
'url' => admin_url( 'admin.php?page=' . ( defined( 'WPFNL_FUNNEL_PAGE_SLUG' ) ? WPFNL_FUNNEL_PAGE_SLUG : 'wp_funnels' ) ), |
| 314 |
]; |
| 315 |
} |
| 316 |
|
| 317 |
return [ |
| 318 |
'ready' => empty( |
| 319 |
array_filter( |
| 320 |
$issues, |
| 321 |
static function ( $issue ) { |
| 322 |
return 'error' === $issue['severity']; |
| 323 |
} |
| 324 |
) |
| 325 |
), |
| 326 |
'issues' => $issues, |
| 327 |
]; |
| 328 |
} |
| 329 |
|
| 330 |
/** |
| 331 |
* Merge changes into WPFunnels' general settings option. |
| 332 |
* |
| 333 |
* Only keys already present in `get_general_settings()`'s defaults are |
| 334 |
* writable — this is the same option the Settings → General screen reads |
| 335 |
* and writes, so an unrecognized key here would just be dead weight in |
| 336 |
* the option, never surfaced anywhere. |
| 337 |
* |
| 338 |
* @param array $input Tool input. |
| 339 |
* @return array|\WP_Error |
| 340 |
*/ |
| 341 |
public static function updateGlobalSettings( $input = [] ) { |
| 342 |
$changes = isset( $input['settings'] ) && is_array( $input['settings'] ) ? $input['settings'] : []; |
| 343 |
if ( empty( $changes ) ) { |
| 344 |
return MCPHelper::error( 'missing_settings', 'Provide at least one setting to update.' ); |
| 345 |
} |
| 346 |
|
| 347 |
$current = Wpfnl_functions::get_general_settings(); |
| 348 |
$allowed = array_intersect_key( $changes, $current ); |
| 349 |
|
| 350 |
if ( empty( $allowed ) ) { |
| 351 |
return MCPHelper::error( |
| 352 |
'no_recognized_keys', |
| 353 |
'None of the given keys are recognized general settings.', |
| 354 |
[ 'recognized_keys' => array_keys( $current ) ] |
| 355 |
); |
| 356 |
} |
| 357 |
|
| 358 |
$updated = array_merge( $current, $allowed ); |
| 359 |
Wpfnl_functions::update_admin_settings( '_wpfunnels_general_settings', $updated ); |
| 360 |
|
| 361 |
self::invalidateCache(); |
| 362 |
|
| 363 |
return [ |
| 364 |
'success' => true, |
| 365 |
'changed' => array_keys( $allowed ), |
| 366 |
'settings' => $updated, |
| 367 |
]; |
| 368 |
} |
| 369 |
|
| 370 |
/** |
| 371 |
* Merge changes into WPFunnels' funnel/step permalink settings. |
| 372 |
* |
| 373 |
* @param array $input Tool input. |
| 374 |
* @return array|\WP_Error |
| 375 |
*/ |
| 376 |
public static function updatePermalinkSettings( $input = [] ) { |
| 377 |
$changes = array_intersect_key( $input, array_flip( [ 'structure', 'funnel_base', 'step_base' ] ) ); |
| 378 |
if ( empty( $changes ) ) { |
| 379 |
return MCPHelper::error( 'missing_settings', 'Provide at least one of structure, funnel_base, step_base.' ); |
| 380 |
} |
| 381 |
|
| 382 |
$current = Wpfnl_functions::get_permalink_settings(); |
| 383 |
$updated = array_merge( $current, array_map( 'sanitize_text_field', $changes ) ); |
| 384 |
|
| 385 |
Wpfnl_functions::update_admin_settings( '_wpfunnels_permalink_settings', $updated ); |
| 386 |
Wpfnl_functions::update_admin_settings( '_wpfunnels_permalink_saved', true ); |
| 387 |
flush_rewrite_rules(); |
| 388 |
|
| 389 |
self::invalidateCache(); |
| 390 |
|
| 391 |
return [ |
| 392 |
'success' => true, |
| 393 |
'changed' => array_keys( $changes ), |
| 394 |
'settings' => $updated, |
| 395 |
]; |
| 396 |
} |
| 397 |
|
| 398 |
/** |
| 399 |
* Read one allow-listed WPFunnels settings group. |
| 400 |
* |
| 401 |
* Reuses `SettingsController::get_group_settings()` for the actual read |
| 402 |
* (including its own internal allow-list check) so this stays byte-for-byte |
| 403 |
* consistent with what the Settings screens themselves read. |
| 404 |
* |
| 405 |
* @param array $input Tool input. |
| 406 |
* @return array|\WP_Error |
| 407 |
*/ |
| 408 |
public static function getSettingsGroup( $input = [] ) { |
| 409 |
$group_id = isset( $input['group_id'] ) ? sanitize_text_field( $input['group_id'] ) : ''; |
| 410 |
|
| 411 |
if ( ! self::isAllowedSettingsGroup( $group_id ) ) { |
| 412 |
return MCPHelper::error( |
| 413 |
'invalid_settings_group', |
| 414 |
'group_id is not a recognized WPFunnels settings group.', |
| 415 |
[ 'allowed_groups' => self::ALLOWED_SETTINGS_GROUPS ] |
| 416 |
); |
| 417 |
} |
| 418 |
|
| 419 |
$controller = new SettingsController(); |
| 420 |
$result = $controller->get_group_settings( $group_id ); |
| 421 |
|
| 422 |
if ( is_wp_error( $result ) ) { |
| 423 |
return MCPHelper::error( 'settings_group_error', $result->get_error_message() ); |
| 424 |
} |
| 425 |
|
| 426 |
if ( empty( $result ) ) { |
| 427 |
// get_group_settings() only flattens array-shaped options; a scalar |
| 428 |
// option (currently only _wpfunnels_google_map_api_key) foreach()es |
| 429 |
// to nothing there, so fall back to the raw stored value. |
| 430 |
$raw = Wpfnl_functions::get_admin_settings( $group_id ); |
| 431 |
if ( ! empty( $raw ) && ! is_array( $raw ) ) { |
| 432 |
return [ |
| 433 |
'group_id' => $group_id, |
| 434 |
'settings' => [ 'value' => $raw ], |
| 435 |
]; |
| 436 |
} |
| 437 |
|
| 438 |
return [ |
| 439 |
'group_id' => $group_id, |
| 440 |
'settings' => [], |
| 441 |
]; |
| 442 |
} |
| 443 |
|
| 444 |
$settings = []; |
| 445 |
foreach ( $result as $entry ) { |
| 446 |
$settings[ $entry['id'] ] = $entry['value']; |
| 447 |
} |
| 448 |
|
| 449 |
return [ |
| 450 |
'group_id' => $group_id, |
| 451 |
'settings' => $settings, |
| 452 |
]; |
| 453 |
} |
| 454 |
|
| 455 |
/** |
| 456 |
* Merge changes into one allow-listed WPFunnels settings group. |
| 457 |
* |
| 458 |
* Reuses `SettingsController::update_settings()` for the actual write, one |
| 459 |
* key at a time (that method is built around updating a single named |
| 460 |
* setting per call). `type` is forced to `ignore_activation` so this never |
| 461 |
* triggers the controller's plugin-activation side effect, which is meant |
| 462 |
* for the setup wizard's own flow, not a generic settings write. |
| 463 |
* |
| 464 |
* @param array $input Tool input. |
| 465 |
* @return array|\WP_Error |
| 466 |
*/ |
| 467 |
public static function updateSettingsGroup( $input = [] ) { |
| 468 |
$group_id = isset( $input['group_id'] ) ? sanitize_text_field( $input['group_id'] ) : ''; |
| 469 |
|
| 470 |
if ( ! self::isAllowedSettingsGroup( $group_id ) ) { |
| 471 |
return MCPHelper::error( |
| 472 |
'invalid_settings_group', |
| 473 |
'group_id is not a recognized WPFunnels settings group.', |
| 474 |
[ 'allowed_groups' => self::ALLOWED_SETTINGS_GROUPS ] |
| 475 |
); |
| 476 |
} |
| 477 |
|
| 478 |
$settings = isset( $input['settings'] ) ? $input['settings'] : null; |
| 479 |
if ( ! is_array( $settings ) || empty( $settings ) ) { |
| 480 |
return MCPHelper::error( 'missing_settings', 'Provide a settings object with at least one key to update.' ); |
| 481 |
} |
| 482 |
|
| 483 |
// _wpfunnels_google_map_api_key stores a plain string, not a keyed |
| 484 |
// array. update_settings() assumes every group is an associative array |
| 485 |
// and would corrupt a string option via `$settings['x'] = ...` (illegal |
| 486 |
// string offset), so this one group is written directly instead of |
| 487 |
// being looped through that method. |
| 488 |
if ( '_wpfunnels_google_map_api_key' === $group_id ) { |
| 489 |
$value = isset( $settings['value'] ) ? $settings['value'] : reset( $settings ); |
| 490 |
Wpfnl_functions::update_admin_settings( $group_id, sanitize_text_field( $value ) ); |
| 491 |
|
| 492 |
self::invalidateCache(); |
| 493 |
|
| 494 |
return [ |
| 495 |
'success' => true, |
| 496 |
'group_id' => $group_id, |
| 497 |
'changed' => [ 'value' ], |
| 498 |
'settings' => [ 'value' => Wpfnl_functions::get_admin_settings( $group_id, '' ) ], |
| 499 |
]; |
| 500 |
} |
| 501 |
|
| 502 |
$controller = new SettingsController(); |
| 503 |
$changed = []; |
| 504 |
|
| 505 |
foreach ( $settings as $key => $value ) { |
| 506 |
$key = sanitize_text_field( $key ); |
| 507 |
|
| 508 |
$result = $controller->update_settings( |
| 509 |
[ |
| 510 |
'group_id' => $group_id, |
| 511 |
'settings_id' => $key, |
| 512 |
'value' => $value, |
| 513 |
'type' => 'ignore_activation', |
| 514 |
'slug' => '', |
| 515 |
'permission' => '', |
| 516 |
] |
| 517 |
); |
| 518 |
|
| 519 |
if ( is_wp_error( $result ) ) { |
| 520 |
return MCPHelper::error( |
| 521 |
'update_failed', |
| 522 |
$result->get_error_message(), |
| 523 |
[ |
| 524 |
'failed_key' => $key, |
| 525 |
'changed_before_failure' => $changed, |
| 526 |
] |
| 527 |
); |
| 528 |
} |
| 529 |
|
| 530 |
$changed[] = $key; |
| 531 |
} |
| 532 |
|
| 533 |
self::invalidateCache(); |
| 534 |
|
| 535 |
$fresh = self::getSettingsGroup( [ 'group_id' => $group_id ] ); |
| 536 |
|
| 537 |
return [ |
| 538 |
'success' => true, |
| 539 |
'group_id' => $group_id, |
| 540 |
'changed' => $changed, |
| 541 |
'settings' => is_wp_error( $fresh ) ? null : $fresh['settings'], |
| 542 |
]; |
| 543 |
} |
| 544 |
|
| 545 |
/** |
| 546 |
* Re-check of SettingsController::is_allowed_settings_group(): the same |
| 547 |
* explicit allow-list, plus the same `_wpfunnels_` prefix fallback so |
| 548 |
* add-on-registered groups keep working. See ALLOWED_SETTINGS_GROUPS for |
| 549 |
* why this is a duplicate rather than a call into the real method. |
| 550 |
* |
| 551 |
* @param string $group_id Candidate option key. |
| 552 |
* @return bool |
| 553 |
*/ |
| 554 |
private static function isAllowedSettingsGroup( $group_id ) { |
| 555 |
if ( ! is_string( $group_id ) || '' === $group_id ) { |
| 556 |
return false; |
| 557 |
} |
| 558 |
|
| 559 |
if ( in_array( $group_id, self::ALLOWED_SETTINGS_GROUPS, true ) ) { |
| 560 |
return true; |
| 561 |
} |
| 562 |
|
| 563 |
return 0 === strpos( $group_id, '_wpfunnels_' ); |
| 564 |
} |
| 565 |
|
| 566 |
/** |
| 567 |
* Funnel counts by type and status. |
| 568 |
* |
| 569 |
* @return array |
| 570 |
*/ |
| 571 |
private static function funnelCounts() { |
| 572 |
$counts = [ |
| 573 |
'total' => 0, |
| 574 |
'published' => 0, |
| 575 |
'draft' => 0, |
| 576 |
'by_type' => [], |
| 577 |
'store_checkout' => 0, |
| 578 |
]; |
| 579 |
|
| 580 |
$funnels = get_posts( |
| 581 |
[ |
| 582 |
'post_type' => WPFNL_FUNNELS_POST_TYPE, |
| 583 |
'post_status' => [ 'publish', 'draft' ], |
| 584 |
'numberposts' => -1, |
| 585 |
'fields' => 'ids', |
| 586 |
] |
| 587 |
); |
| 588 |
|
| 589 |
foreach ( $funnels as $funnel_id ) { |
| 590 |
$counts['total']++; |
| 591 |
|
| 592 |
if ( 'publish' === get_post_status( $funnel_id ) ) { |
| 593 |
$counts['published']++; |
| 594 |
} else { |
| 595 |
$counts['draft']++; |
| 596 |
} |
| 597 |
|
| 598 |
$type = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true ) ?: 'wc'; |
| 599 |
if ( 'store_checkout' === $type ) { |
| 600 |
$counts['store_checkout']++; |
| 601 |
} |
| 602 |
|
| 603 |
$counts['by_type'][ $type ] = isset( $counts['by_type'][ $type ] ) ? $counts['by_type'][ $type ] + 1 : 1; |
| 604 |
} |
| 605 |
|
| 606 |
return $counts; |
| 607 |
} |
| 608 |
|
| 609 |
/** |
| 610 |
* Build guidelines, trimmed to what this install supports. |
| 611 |
* |
| 612 |
* @param bool $wc WooCommerce active. |
| 613 |
* @param bool $pro Pro active. |
| 614 |
* @return array |
| 615 |
*/ |
| 616 |
private static function guidelines( $wc, $pro ) { |
| 617 |
$lines = [ |
| 618 |
'Resolve names to IDs before writing: use list-funnels, list-steps and search-products. Never invent an ID.', |
| 619 |
'Build order: create-funnel, then create-step per step, then assign-products-to-step on checkout/upsell/downsell steps.', |
| 620 |
'Funnel types are wc (WooCommerce sales), lms, lead (opt-in) and store_checkout. A funnel keeps one type.', |
| 621 |
'A funnel has at most one landing, one checkout and one thank-you step. Canonical order is landing, checkout, upsell, downsell, thank-you.', |
| 622 |
'Products can only be attached to checkout, upsell and downsell steps.', |
| 623 |
]; |
| 624 |
|
| 625 |
if ( ! $wc ) { |
| 626 |
$lines[] = 'WooCommerce is inactive here: build lead funnels with landing, opt-in and thank-you steps only.'; |
| 627 |
} |
| 628 |
if ( ! $pro ) { |
| 629 |
$lines[] = 'Pro is inactive here: one-click upsell/downsell, A/B testing and analytics tools are unavailable. Say so plainly rather than pretending.'; |
| 630 |
} |
| 631 |
|
| 632 |
return $lines; |
| 633 |
} |
| 634 |
} |
| 635 |
|