| 1 |
<?php |
| 2 |
|
| 3 |
/** |
| 4 |
* Plan Config Class |
| 5 |
* |
| 6 |
* Single source of truth for free vs. pro capability gating across ThinkRank. |
| 7 |
* Every freemium gate (REST validation, render pipeline, scheduler, mailer, UI) |
| 8 |
* reads from here. No feature code should check `defined('THINKRANK_PRO_VERSION')` |
| 9 |
* or `apply_filters('thinkrank_is_pro_active', ...)` directly — call these methods |
| 10 |
* instead so a single override flips behavior everywhere. |
| 11 |
* |
| 12 |
* The Pro plugin attaches by filtering `thinkrank_email_report_capabilities` |
| 13 |
* (or the analogous filter for other features). It never needs to fork or |
| 14 |
* monkey-patch this file. |
| 15 |
* |
| 16 |
* @package ThinkRank\Core |
| 17 |
* @since 1.9.0 |
| 18 |
*/ |
| 19 |
|
| 20 |
declare(strict_types=1); |
| 21 |
|
| 22 |
namespace ThinkRank\Core; |
| 23 |
|
| 24 |
if (!defined('ABSPATH')) { |
| 25 |
exit; |
| 26 |
} |
| 27 |
|
| 28 |
/** |
| 29 |
* Plan_Config — capability registry for free/pro feature gating. |
| 30 |
* |
| 31 |
* Usage: |
| 32 |
* if (Plan_Config::can('custom_subject', 'email_report')) { ... } |
| 33 |
* $caps = Plan_Config::email_report(); |
| 34 |
* |
| 35 |
* @since 1.9.0 |
| 36 |
*/ |
| 37 |
final class Plan_Config { |
| 38 |
|
| 39 |
/** |
| 40 |
* Whether the Pro plugin is active. |
| 41 |
* |
| 42 |
* Resolves through `thinkrank_is_pro_active` so the Pro plugin (or a |
| 43 |
* staging override) can flip the answer. Defaults to checking the |
| 44 |
* `THINKRANK_PRO_VERSION` constant the Pro plugin defines on load. |
| 45 |
*/ |
| 46 |
public static function is_pro(): bool { |
| 47 |
return (bool) apply_filters( |
| 48 |
'thinkrank_is_pro_active', |
| 49 |
defined('THINKRANK_PRO_VERSION') |
| 50 |
); |
| 51 |
} |
| 52 |
|
| 53 |
/** |
| 54 |
* Capability map for the Email Reporting feature. |
| 55 |
* |
| 56 |
* Returns a flat array of capability_key => bool|int|array describing |
| 57 |
* what the current plan can do. The Pro plugin filters this to enable |
| 58 |
* its capabilities. |
| 59 |
* |
| 60 |
* Schema (keep in sync with src/admin/config/email-report-plan.js): |
| 61 |
* - allowed_frequencies int[] Days between sends user may pick. |
| 62 |
* - max_recipients int Max addresses on the recipients field. |
| 63 |
* - recipients_locked_to string|null 'admin_email' = pre-filled & read-only. |
| 64 |
* - custom_subject bool May edit subject template. |
| 65 |
* - custom_logo bool May upload a header logo. |
| 66 |
* - logo_link bool May set a click-through URL on the logo. |
| 67 |
* - header_background bool May set a custom header background CSS. |
| 68 |
* - link_to_full_report bool May toggle the dashboard CTA at the foot. |
| 69 |
* - intro_text bool May set a custom intro paragraph. |
| 70 |
* - sections_configurable bool May enable/disable individual sections. |
| 71 |
* - footer_text bool May set a custom footer paragraph. |
| 72 |
* - additional_css bool May inject additional CSS into the email. |
| 73 |
* - ai_highlights bool May render the AI Highlights section. |
| 74 |
*/ |
| 75 |
public static function email_report(): array { |
| 76 |
$defaults = [ |
| 77 |
'allowed_frequencies' => [30], |
| 78 |
'max_recipients' => 1, |
| 79 |
'recipients_locked_to' => 'admin_email', |
| 80 |
'custom_subject' => false, |
| 81 |
'custom_logo' => false, |
| 82 |
'logo_link' => false, |
| 83 |
'header_background' => false, |
| 84 |
'link_to_full_report' => false, |
| 85 |
'intro_text' => false, |
| 86 |
'sections_configurable' => false, |
| 87 |
'footer_text' => false, |
| 88 |
'additional_css' => false, |
| 89 |
'ai_highlights' => false, |
| 90 |
]; |
| 91 |
|
| 92 |
if (self::is_pro()) { |
| 93 |
$defaults = array_merge($defaults, [ |
| 94 |
'allowed_frequencies' => [7, 15, 30], |
| 95 |
'max_recipients' => 50, |
| 96 |
'recipients_locked_to' => null, |
| 97 |
'custom_subject' => true, |
| 98 |
'custom_logo' => true, |
| 99 |
'logo_link' => true, |
| 100 |
'header_background' => true, |
| 101 |
'link_to_full_report' => true, |
| 102 |
'intro_text' => true, |
| 103 |
'sections_configurable' => true, |
| 104 |
'footer_text' => true, |
| 105 |
'additional_css' => true, |
| 106 |
// ai_highlights stays false by default — it's a separate sub-feature |
| 107 |
// the Pro plugin opts in to once the AI summary integration ships. |
| 108 |
]); |
| 109 |
} |
| 110 |
|
| 111 |
/** |
| 112 |
* Filter the Email Reporting capability map. |
| 113 |
* |
| 114 |
* The Pro plugin uses this filter (and only this filter) to enable |
| 115 |
* pro capabilities. Returning a partial array is fine; missing keys |
| 116 |
* fall back to the values above. |
| 117 |
* |
| 118 |
* @since 1.9.0 |
| 119 |
* |
| 120 |
* @param array $defaults Capability map (see schema above). |
| 121 |
*/ |
| 122 |
$caps = apply_filters('thinkrank_email_report_capabilities', $defaults); |
| 123 |
|
| 124 |
// Defensive merge: never let a filter drop required keys. |
| 125 |
return array_merge($defaults, is_array($caps) ? $caps : []); |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Capability map for the Focus Keywords feature. |
| 130 |
* |
| 131 |
* Free allows up to 5 focus keywords; ThinkRank Pro lifts the cap. The 6th |
| 132 |
* and subsequent keywords are stored but only become usable (analyzed, |
| 133 |
* output, editable) once Pro raises the limit. |
| 134 |
* |
| 135 |
* Localized to the metabox as `thinkrankMetabox.focusKeywords`. |
| 136 |
* Schema: |
| 137 |
* - max_keywords int Usable focus keywords. 0 = unlimited (Pro). |
| 138 |
* |
| 139 |
* @since 2.0.0 |
| 140 |
* |
| 141 |
* @return array Capability map. |
| 142 |
*/ |
| 143 |
public static function focus_keywords(): array { |
| 144 |
// Free default. ThinkRank Pro lifts the cap by filtering the map below |
| 145 |
// (Pro owns its own limit rather than the free plugin hard-coding it). |
| 146 |
$defaults = [ |
| 147 |
'max_keywords' => 5, |
| 148 |
]; |
| 149 |
|
| 150 |
/** |
| 151 |
* Filter the Focus Keywords capability map. |
| 152 |
* |
| 153 |
* The Pro plugin uses this filter to lift the free cap — set |
| 154 |
* `max_keywords` to 0 for unlimited, or a finite number. |
| 155 |
* |
| 156 |
* @since 2.0.0 |
| 157 |
* |
| 158 |
* @param array $defaults Capability map (see schema above). |
| 159 |
*/ |
| 160 |
$caps = apply_filters('thinkrank_focus_keywords_capabilities', $defaults); |
| 161 |
|
| 162 |
return array_merge($defaults, is_array($caps) ? $caps : []); |
| 163 |
} |
| 164 |
|
| 165 |
/** |
| 166 |
* Capability map for the AI Insights trio. |
| 167 |
* |
| 168 |
* Deliberately NOT gated on "needs an AI key" — the user pays their |
| 169 |
* provider either way, so that is not what separates free from Pro. The |
| 170 |
* split is acquisition vs. recurring depth: |
| 171 |
* |
| 172 |
* - AI Traffic analytics stays FREE and ungated. It costs nothing to run |
| 173 |
* (referrer classification, no AI call) and is the feature that shows |
| 174 |
* value on day one. No capability key exists for it on purpose. |
| 175 |
* - Brand Visibility is FREEMIUM: free runs a couple of queries by hand |
| 176 |
* and keeps a short history; Pro lifts the query cap, keeps full |
| 177 |
* history, and unlocks scheduled (unattended) checks. |
| 178 |
* - Auto AI metadata is PRO: unattended automation is the clearest Pro |
| 179 |
* trait in the lineup. |
| 180 |
* |
| 181 |
* Unlike email_report, this map has no JS mirror in src/admin/config/ on |
| 182 |
* purpose: the admin UI reads the resolved values off the AI Insights REST |
| 183 |
* responses (`plan`, `is_pro`, `available`) rather than re-declaring them |
| 184 |
* client-side, so there is nothing here that can drift out of sync. |
| 185 |
* |
| 186 |
* Schema: |
| 187 |
* brand_max_queries int Saved brand queries allowed (0 = unlimited). |
| 188 |
* brand_history_limit int History rows returned (0 = unlimited). |
| 189 |
* brand_scheduled bool Unattended scheduled brand checks. |
| 190 |
* auto_ai_meta bool Auto-generate metadata on first publish. |
| 191 |
* |
| 192 |
* @since 1.28.0 |
| 193 |
* |
| 194 |
* @return array Capability map. |
| 195 |
*/ |
| 196 |
public static function ai_visibility(): array { |
| 197 |
$defaults = [ |
| 198 |
'brand_max_queries' => 2, |
| 199 |
'brand_history_limit' => 10, |
| 200 |
'brand_scheduled' => false, |
| 201 |
'auto_ai_meta' => false, |
| 202 |
|
| 203 |
// Brand Visibility v2. Free keeps a usable "quick check" — a |
| 204 |
// couple of questions on one platform, single sample — which is |
| 205 |
// enough to see the feature work and understand what Pro measures. |
| 206 |
// Everything that turns a probe into a MEASUREMENT (sampling, |
| 207 |
// competitors, multi-platform, trends) is Pro. |
| 208 |
'brand_wizard' => false, |
| 209 |
'brand_competitors' => 0, // max competitors; 0 = none |
| 210 |
'brand_max_platforms' => 1, |
| 211 |
'brand_max_samples' => 1, |
| 212 |
'brand_sentiment' => false, |
| 213 |
'brand_history_runs' => 1, // runs kept for the trend chart |
| 214 |
]; |
| 215 |
|
| 216 |
/** |
| 217 |
* Filter the AI Insights capability map. |
| 218 |
* |
| 219 |
* ThinkRank Pro sets `brand_max_queries` to 0 (unlimited, bounded |
| 220 |
* only by what the run request itself asks for), enables |
| 221 |
* `brand_scheduled` and `auto_ai_meta`, and lifts the history limit. |
| 222 |
* |
| 223 |
* @since 1.28.0 |
| 224 |
* |
| 225 |
* @param array $defaults Capability map (see schema above). |
| 226 |
*/ |
| 227 |
$caps = apply_filters('thinkrank_ai_visibility_capabilities', $defaults); |
| 228 |
|
| 229 |
return array_merge($defaults, is_array($caps) ? $caps : []); |
| 230 |
} |
| 231 |
|
| 232 |
/** |
| 233 |
* Check a single capability for a given feature. |
| 234 |
* |
| 235 |
* Currently only the `email_report` feature is registered. Adding more |
| 236 |
* features means adding a switch case here that delegates to its own |
| 237 |
* capability builder method. |
| 238 |
* |
| 239 |
* @param string $capability Capability key (e.g. 'custom_subject'). |
| 240 |
* @param string $feature Feature scope (default 'email_report'). |
| 241 |
*/ |
| 242 |
public static function can(string $capability, string $feature = 'email_report'): bool { |
| 243 |
$caps = self::capabilities_for($feature); |
| 244 |
return ! empty($caps[$capability]); |
| 245 |
} |
| 246 |
|
| 247 |
/** |
| 248 |
* Get the full capability map for a feature. |
| 249 |
* |
| 250 |
* @param string $feature Feature scope. |
| 251 |
* @return array |
| 252 |
*/ |
| 253 |
public static function capabilities_for(string $feature): array { |
| 254 |
switch ($feature) { |
| 255 |
case 'email_report': |
| 256 |
return self::email_report(); |
| 257 |
case 'focus_keywords': |
| 258 |
return self::focus_keywords(); |
| 259 |
case 'ai_visibility': |
| 260 |
return self::ai_visibility(); |
| 261 |
default: |
| 262 |
return []; |
| 263 |
} |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* Clamp a frequency value to one the current plan allows. |
| 268 |
* |
| 269 |
* Free plans always end up at 30. Pro plans accept 7, 15, or 30. |
| 270 |
* Anything else falls back to the highest allowed value (most permissive |
| 271 |
* default that still respects the cap). |
| 272 |
* |
| 273 |
* @param int $requested Requested frequency in days. |
| 274 |
* @return int Clamped frequency. |
| 275 |
*/ |
| 276 |
public static function clamp_email_report_frequency(int $requested): int { |
| 277 |
$allowed = self::email_report()['allowed_frequencies']; |
| 278 |
if (in_array($requested, $allowed, true)) { |
| 279 |
return $requested; |
| 280 |
} |
| 281 |
return (int) max($allowed); |
| 282 |
} |
| 283 |
|
| 284 |
/** |
| 285 |
* Truncate a list of recipients to the plan-allowed maximum. |
| 286 |
* |
| 287 |
* Used at save and at render time. The save-time call gives the user |
| 288 |
* feedback; the render-time call is a defense in depth so a downgrade |
| 289 |
* never accidentally fans a report out to a list the user no longer |
| 290 |
* has the plan for. |
| 291 |
* |
| 292 |
* @param string[] $recipients Recipient email addresses. |
| 293 |
* @return string[] Truncated, de-duplicated recipients. |
| 294 |
*/ |
| 295 |
public static function clamp_email_report_recipients(array $recipients): array { |
| 296 |
$caps = self::email_report(); |
| 297 |
$unique = array_values(array_unique(array_filter(array_map('trim', $recipients)))); |
| 298 |
|
| 299 |
if ('admin_email' === $caps['recipients_locked_to']) { |
| 300 |
return [(string) get_option('admin_email')]; |
| 301 |
} |
| 302 |
|
| 303 |
return array_slice($unique, 0, (int) $caps['max_recipients']); |
| 304 |
} |
| 305 |
} |
| 306 |
|