| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\Core; |
| 4 |
|
| 5 |
/** |
| 6 |
* SiteProfiler — Phase 1 of the AI "Generate Sample Docs" feature. |
| 7 |
* |
| 8 |
* Runs entirely inside WordPress (no AI call). Produces a small, deterministic, |
| 9 |
* privacy-safe profile of the site that the hosted proxy turns into tailored |
| 10 |
* docs/FAQs. Detection only targets; the proxy writes. |
| 11 |
* |
| 12 |
* The profile contains only public-facing labels (titles, category names) — no |
| 13 |
* user data, no order data, no PII. |
| 14 |
* |
| 15 |
* @since 4.5.3 |
| 16 |
*/ |
| 17 |
class SiteProfiler { |
| 18 |
/** |
| 19 |
* Transient key for the cached profile (per-locale). |
| 20 |
* @var string |
| 21 |
*/ |
| 22 |
const CACHE_KEY = 'betterdocs_site_profile'; |
| 23 |
|
| 24 |
/** |
| 25 |
* How long to cache the computed profile so re-renders don't recompute. |
| 26 |
* @var int |
| 27 |
*/ |
| 28 |
const CACHE_TTL = HOUR_IN_SECONDS; |
| 29 |
|
| 30 |
/** |
| 31 |
* Build the full site profile. |
| 32 |
* |
| 33 |
* @param bool $fresh Skip the cache and recompute. |
| 34 |
* @return array |
| 35 |
*/ |
| 36 |
public function build( $fresh = false ) { |
| 37 |
$cache_key = self::CACHE_KEY . '_' . get_locale(); |
| 38 |
|
| 39 |
if ( ! $fresh ) { |
| 40 |
$cached = get_transient( $cache_key ); |
| 41 |
if ( is_array( $cached ) ) { |
| 42 |
return $cached; |
| 43 |
} |
| 44 |
} |
| 45 |
|
| 46 |
$site = [ |
| 47 |
'title' => sanitize_text_field( get_bloginfo( 'name' ) ), |
| 48 |
'tagline' => sanitize_text_field( get_bloginfo( 'description' ) ), |
| 49 |
'url' => esc_url_raw( home_url() ), |
| 50 |
'locale' => get_locale(), |
| 51 |
'theme' => sanitize_text_field( wp_get_theme()->get( 'Name' ) ), |
| 52 |
]; |
| 53 |
|
| 54 |
$type = $this->detect_type(); |
| 55 |
$topics = $this->topic_hints(); |
| 56 |
$niche = $this->detect_niche( $site, $type, $topics ); |
| 57 |
|
| 58 |
$profile = [ |
| 59 |
'site' => $site, |
| 60 |
'type' => $type, |
| 61 |
// Human-friendly niche descriptor for the detection UI and the proxy |
| 62 |
// prompt (e.g. "AI / LLM platform"), inferred from real site content. |
| 63 |
'niche' => $niche['key'], |
| 64 |
'niche_label' => $niche['label'], |
| 65 |
'signals' => $this->collect_signals(), |
| 66 |
'woocommerce' => $this->woo_context(), |
| 67 |
'topics' => $topics, |
| 68 |
]; |
| 69 |
|
| 70 |
/** |
| 71 |
* Filter the computed site profile before it is cached/returned. |
| 72 |
* |
| 73 |
* @param array $profile |
| 74 |
*/ |
| 75 |
$profile = apply_filters( 'betterdocs_site_profile', $profile ); |
| 76 |
|
| 77 |
set_transient( $cache_key, $profile, self::CACHE_TTL ); |
| 78 |
|
| 79 |
return $profile; |
| 80 |
} |
| 81 |
|
| 82 |
/** |
| 83 |
* Primary site type, keyed off active plugins (highest signal). |
| 84 |
* First match wins. |
| 85 |
* |
| 86 |
* @return string |
| 87 |
*/ |
| 88 |
public function detect_type() { |
| 89 |
$active = $this->active_plugins(); |
| 90 |
$has = function ( $needle ) use ( $active ) { |
| 91 |
foreach ( $active as $plugin ) { |
| 92 |
if ( stripos( $plugin, $needle ) !== false ) { |
| 93 |
return true; |
| 94 |
} |
| 95 |
} |
| 96 |
return false; |
| 97 |
}; |
| 98 |
|
| 99 |
if ( $has( 'woocommerce' ) ) { |
| 100 |
return 'ecommerce'; |
| 101 |
} |
| 102 |
if ( $has( 'learndash' ) || $has( 'tutor' ) || $has( 'lifterlms' ) || $has( 'sensei' ) ) { |
| 103 |
return 'course_lms'; |
| 104 |
} |
| 105 |
if ( $has( 'easy-digital-downloads' ) ) { |
| 106 |
return 'digital_downloads'; |
| 107 |
} |
| 108 |
if ( $has( 'paid-memberships-pro' ) || $has( 'memberpress' ) || $has( 'restrict-content' ) ) { |
| 109 |
return 'membership'; |
| 110 |
} |
| 111 |
if ( $has( 'bbpress' ) || $has( 'buddypress' ) ) { |
| 112 |
return 'community'; |
| 113 |
} |
| 114 |
if ( $has( 'elementor' ) || $has( 'beaver' ) || $has( 'divi' ) ) { |
| 115 |
return 'business_site'; |
| 116 |
} |
| 117 |
|
| 118 |
return 'general'; |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Human-friendly site descriptor for the detection UI ("We detected …") and |
| 123 |
* to ground the proxy prompt. Prefers a strong content-based niche signal |
| 124 |
* (title/tagline/pages/categories/nav/existing docs); falls back to the |
| 125 |
* plugin-detected type, then a generic label. No AI — keyword scoring only. |
| 126 |
* |
| 127 |
* @param array $site |
| 128 |
* @param string $type |
| 129 |
* @param array $topics |
| 130 |
* @return array { key:string, label:string } |
| 131 |
*/ |
| 132 |
public function detect_niche( array $site, $type, array $topics ) { |
| 133 |
$parts = array_merge( |
| 134 |
[ isset( $site['title'] ) ? $site['title'] : '', isset( $site['tagline'] ) ? $site['tagline'] : '' ], |
| 135 |
isset( $topics['page_titles'] ) ? (array) $topics['page_titles'] : [], |
| 136 |
isset( $topics['post_categories'] ) ? (array) $topics['post_categories'] : [], |
| 137 |
isset( $topics['nav_items'] ) ? (array) $topics['nav_items'] : [], |
| 138 |
isset( $topics['doc_categories'] ) ? (array) $topics['doc_categories'] : [], |
| 139 |
isset( $topics['doc_titles'] ) ? (array) $topics['doc_titles'] : [] |
| 140 |
); |
| 141 |
|
| 142 |
// Pad + collapse whitespace so " ai " etc. match as whole words anywhere. |
| 143 |
$text = ' ' . preg_replace( '/\s+/', ' ', strtolower( implode( ' ', $parts ) ) ) . ' '; |
| 144 |
|
| 145 |
// Most-specific first. Leading spaces on short tokens avoid false positives |
| 146 |
// (e.g. " api" never matches "therapist"). |
| 147 |
$niches = [ |
| 148 |
'ai_llm' => [ |
| 149 |
'label' => 'AI / LLM platform', |
| 150 |
'keywords' => [ ' llm', ' gpt', ' ai ', 'a.i.', 'artificial intelligence', 'machine learning', 'language model', 'large language', 'prompt', 'openai', 'anthropic', 'claude', 'chatbot', 'embedding', 'fine-tun', 'inference', 'generative', 'neural' ], |
| 151 |
], |
| 152 |
'developer' => [ |
| 153 |
'label' => 'developer platform', |
| 154 |
'keywords' => [ ' api', ' sdk', 'developer', 'endpoint', 'webhook', ' cli ', 'integration', 'documentation', 'rate limit', 'oauth', 'deploy' ], |
| 155 |
], |
| 156 |
'ecommerce' => [ |
| 157 |
'label' => 'online store', |
| 158 |
'keywords' => [ ' shop', ' store', ' cart', 'checkout', 'product', 'shipping', 'returns', ' order', 'fashion', 'clothing', 'apparel' ], |
| 159 |
], |
| 160 |
'education' => [ |
| 161 |
'label' => 'online learning site', |
| 162 |
'keywords' => [ ' course', 'lesson', ' learn', 'student', 'curriculum', 'enroll', 'academy', 'tutorial', ' class', 'teacher' ], |
| 163 |
], |
| 164 |
'agency' => [ |
| 165 |
'label' => 'agency or services site', |
| 166 |
'keywords' => [ 'agency', 'clients', 'marketing', ' seo', 'branding', 'portfolio', 'services' ], |
| 167 |
], |
| 168 |
'health' => [ |
| 169 |
'label' => 'health & wellness site', |
| 170 |
'keywords' => [ 'health', 'clinic', 'medical', 'wellness', 'therapy', 'patient', 'fitness', 'nutrition' ], |
| 171 |
], |
| 172 |
'finance' => [ |
| 173 |
'label' => 'finance site', |
| 174 |
'keywords' => [ 'finance', ' bank', 'invest', 'crypto', 'trading', ' loan', 'insurance' ], |
| 175 |
], |
| 176 |
'food' => [ |
| 177 |
'label' => 'food & restaurant site', |
| 178 |
'keywords' => [ 'restaurant', ' menu', 'recipe', ' food', ' cafe', 'coffee', 'cuisine', ' dish' ], |
| 179 |
], |
| 180 |
]; |
| 181 |
|
| 182 |
$scores = []; |
| 183 |
foreach ( $niches as $key => $def ) { |
| 184 |
$score = 0; |
| 185 |
foreach ( $def['keywords'] as $kw ) { |
| 186 |
if ( strpos( $text, $kw ) !== false ) { |
| 187 |
$score++; |
| 188 |
} |
| 189 |
} |
| 190 |
$scores[ $key ] = $score; |
| 191 |
} |
| 192 |
|
| 193 |
// AI/LLM is the most notable/specific niche — let it win whenever there are |
| 194 |
// a couple of independent AI signals, even if "developer" also scores high. |
| 195 |
if ( $scores['ai_llm'] >= 2 ) { |
| 196 |
return [ 'key' => 'ai_llm', 'label' => $niches['ai_llm']['label'] ]; |
| 197 |
} |
| 198 |
|
| 199 |
arsort( $scores ); |
| 200 |
$best = key( $scores ); |
| 201 |
$top = current( $scores ); |
| 202 |
if ( $best && $top >= 2 ) { |
| 203 |
return [ 'key' => $best, 'label' => $niches[ $best ]['label'] ]; |
| 204 |
} |
| 205 |
|
| 206 |
// Fall back to the plugin-detected type, then a generic label. |
| 207 |
$type_labels = [ |
| 208 |
'ecommerce' => 'online store', |
| 209 |
'course_lms' => 'course / LMS site', |
| 210 |
'digital_downloads' => 'digital downloads store', |
| 211 |
'membership' => 'membership site', |
| 212 |
'community' => 'community site', |
| 213 |
'business_site' => 'business site', |
| 214 |
]; |
| 215 |
if ( isset( $type_labels[ $type ] ) ) { |
| 216 |
return [ 'key' => $type, 'label' => $type_labels[ $type ] ]; |
| 217 |
} |
| 218 |
|
| 219 |
return [ 'key' => 'general', 'label' => 'website' ]; |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Raw signals — give the AI evidence, not just a label. |
| 224 |
* |
| 225 |
* @return array |
| 226 |
*/ |
| 227 |
public function collect_signals() { |
| 228 |
$active = $this->active_plugins(); |
| 229 |
$known = [ |
| 230 |
'woocommerce' => 'WooCommerce', |
| 231 |
'learndash' => 'LearnDash', |
| 232 |
'tutor' => 'Tutor LMS', |
| 233 |
'lifterlms' => 'LifterLMS', |
| 234 |
'easy-digital' => 'Easy Digital Downloads', |
| 235 |
'memberpress' => 'MemberPress', |
| 236 |
'bbpress' => 'bbPress', |
| 237 |
'buddypress' => 'BuddyPress', |
| 238 |
'elementor' => 'Elementor', |
| 239 |
'wpforms' => 'WPForms', |
| 240 |
'yoast' => 'Yoast SEO', |
| 241 |
]; |
| 242 |
|
| 243 |
$detected = []; |
| 244 |
foreach ( $known as $needle => $label ) { |
| 245 |
foreach ( $active as $plugin ) { |
| 246 |
if ( stripos( $plugin, $needle ) !== false ) { |
| 247 |
$detected[] = $label; |
| 248 |
break; |
| 249 |
} |
| 250 |
} |
| 251 |
} |
| 252 |
|
| 253 |
return [ |
| 254 |
'active_plugins' => array_values( array_unique( $detected ) ), |
| 255 |
'theme' => sanitize_text_field( wp_get_theme()->get( 'Name' ) ), |
| 256 |
]; |
| 257 |
} |
| 258 |
|
| 259 |
/** |
| 260 |
* WooCommerce context — only if Woo is active. |
| 261 |
* |
| 262 |
* @return array|null |
| 263 |
*/ |
| 264 |
public function woo_context() { |
| 265 |
if ( ! class_exists( 'WooCommerce' ) ) { |
| 266 |
return null; |
| 267 |
} |
| 268 |
|
| 269 |
$cats = get_terms( |
| 270 |
[ |
| 271 |
'taxonomy' => 'product_cat', |
| 272 |
'orderby' => 'count', |
| 273 |
'order' => 'DESC', |
| 274 |
'number' => 8, |
| 275 |
'hide_empty' => true, |
| 276 |
] |
| 277 |
); |
| 278 |
$cat_names = is_wp_error( $cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $cats, 'name' ) ); |
| 279 |
|
| 280 |
$products = get_posts( |
| 281 |
[ |
| 282 |
'post_type' => 'product', |
| 283 |
'posts_per_page' => 8, |
| 284 |
'orderby' => 'date', |
| 285 |
'order' => 'DESC', |
| 286 |
'fields' => 'ids', |
| 287 |
] |
| 288 |
); |
| 289 |
$product_names = array_map( |
| 290 |
function ( $id ) { |
| 291 |
return sanitize_text_field( get_the_title( $id ) ); |
| 292 |
}, |
| 293 |
$products |
| 294 |
); |
| 295 |
|
| 296 |
$context = [ |
| 297 |
'product_categories' => array_values( $cat_names ), |
| 298 |
'sample_products' => array_values( array_filter( $product_names ) ), |
| 299 |
'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', |
| 300 |
'currency_symbol' => function_exists( 'get_woocommerce_currency_symbol' ) ? html_entity_decode( get_woocommerce_currency_symbol() ) : '', |
| 301 |
]; |
| 302 |
|
| 303 |
// Store config used to ground store/product FAQs in real settings. Each |
| 304 |
// value is read defensively — anything missing simply drops out so the |
| 305 |
// FAQ copy can fall back to a generic-but-accurate answer. No PII. |
| 306 |
$context['store_location'] = $this->store_location(); |
| 307 |
$context['payment_methods'] = $this->payment_methods(); |
| 308 |
$context['shipping_regions'] = $this->shipping_regions(); |
| 309 |
$context['tax_enabled'] = function_exists( 'wc_tax_enabled' ) ? (bool) wc_tax_enabled() : false; |
| 310 |
$context['prices_include_tax'] = 'yes' === get_option( 'woocommerce_prices_include_tax' ); |
| 311 |
|
| 312 |
$terms_page = (int) get_option( 'woocommerce_terms_and_conditions_page_id', 0 ); |
| 313 |
if ( $terms_page > 0 ) { |
| 314 |
$context['terms_page_url'] = esc_url_raw( (string) get_permalink( $terms_page ) ); |
| 315 |
} |
| 316 |
|
| 317 |
$privacy_page = (int) get_option( 'wp_page_for_privacy_policy', 0 ); |
| 318 |
if ( $privacy_page > 0 ) { |
| 319 |
$context['privacy_page_url'] = esc_url_raw( (string) get_permalink( $privacy_page ) ); |
| 320 |
} |
| 321 |
|
| 322 |
// Return window only exists when a returns extension stores it; omit otherwise. |
| 323 |
$return_window = get_option( 'woocommerce_return_requests_window', '' ); |
| 324 |
if ( '' !== $return_window && null !== $return_window ) { |
| 325 |
$context['return_window'] = (int) $return_window; |
| 326 |
} |
| 327 |
|
| 328 |
// Key WooCommerce pages — so store FAQ answers can link the real account / |
| 329 |
// checkout flow instead of describing it generically. |
| 330 |
$this->add_wc_page( $context, 'account_page_url', 'myaccount' ); |
| 331 |
$this->add_wc_page( $context, 'checkout_page_url', 'checkout' ); |
| 332 |
$this->add_wc_page( $context, 'shop_page_url', 'shop' ); |
| 333 |
|
| 334 |
// Refund/returns policy page (WooCommerce has no canonical option — resolved |
| 335 |
// heuristically), used to link the real policy in returns/refunds answers. |
| 336 |
$refund = $this->refund_policy(); |
| 337 |
if ( ! empty( $refund['url'] ) ) { |
| 338 |
$context['refund_policy_url'] = $refund['url']; |
| 339 |
if ( ! empty( $refund['excerpt'] ) ) { |
| 340 |
$context['refund_policy_excerpt'] = $refund['excerpt']; |
| 341 |
} |
| 342 |
} |
| 343 |
|
| 344 |
// Free-shipping threshold, when a Free Shipping method defines a minimum. |
| 345 |
$free_min = $this->free_shipping_min(); |
| 346 |
if ( null !== $free_min ) { |
| 347 |
$context['free_shipping_min'] = $free_min; |
| 348 |
} |
| 349 |
|
| 350 |
return array_filter( |
| 351 |
$context, |
| 352 |
function ( $value ) { |
| 353 |
return ! ( is_array( $value ) && empty( $value ) ) && '' !== $value; |
| 354 |
} |
| 355 |
); |
| 356 |
} |
| 357 |
|
| 358 |
/** |
| 359 |
* Store base location label (city/region, country) for shipping/payment FAQs. |
| 360 |
* |
| 361 |
* @return string |
| 362 |
*/ |
| 363 |
private function store_location() { |
| 364 |
if ( ! function_exists( 'wc_get_base_location' ) ) { |
| 365 |
return ''; |
| 366 |
} |
| 367 |
|
| 368 |
$base = wc_get_base_location(); |
| 369 |
$country = isset( $base['country'] ) ? (string) $base['country'] : ''; |
| 370 |
$city = (string) get_option( 'woocommerce_store_city', '' ); |
| 371 |
|
| 372 |
if ( function_exists( 'WC' ) && WC()->countries && $country ) { |
| 373 |
$countries = WC()->countries->get_countries(); |
| 374 |
$country = isset( $countries[ $country ] ) ? $countries[ $country ] : $country; |
| 375 |
} |
| 376 |
|
| 377 |
$parts = array_filter( [ sanitize_text_field( $city ), sanitize_text_field( $country ) ] ); |
| 378 |
|
| 379 |
return implode( ', ', $parts ); |
| 380 |
} |
| 381 |
|
| 382 |
/** |
| 383 |
* Titles of the enabled payment gateways (e.g. "PayPal", "Credit/Debit Card"). |
| 384 |
* |
| 385 |
* @return array |
| 386 |
*/ |
| 387 |
private function payment_methods() { |
| 388 |
if ( ! function_exists( 'WC' ) || ! WC()->payment_gateways ) { |
| 389 |
return []; |
| 390 |
} |
| 391 |
|
| 392 |
$gateways = WC()->payment_gateways->get_available_payment_gateways(); |
| 393 |
$titles = []; |
| 394 |
foreach ( (array) $gateways as $gateway ) { |
| 395 |
$title = isset( $gateway->title ) ? wp_strip_all_tags( (string) $gateway->title ) : ''; |
| 396 |
if ( '' !== trim( $title ) ) { |
| 397 |
$titles[] = sanitize_text_field( $title ); |
| 398 |
} |
| 399 |
} |
| 400 |
|
| 401 |
return array_values( array_unique( $titles ) ); |
| 402 |
} |
| 403 |
|
| 404 |
/** |
| 405 |
* Shipping zone names (region labels the store ships to). |
| 406 |
* |
| 407 |
* @return array |
| 408 |
*/ |
| 409 |
private function shipping_regions() { |
| 410 |
if ( ! class_exists( '\WC_Shipping_Zones' ) ) { |
| 411 |
return []; |
| 412 |
} |
| 413 |
|
| 414 |
$zones = \WC_Shipping_Zones::get_zones(); |
| 415 |
$labels = []; |
| 416 |
foreach ( (array) $zones as $zone ) { |
| 417 |
$name = isset( $zone['zone_name'] ) ? sanitize_text_field( $zone['zone_name'] ) : ''; |
| 418 |
if ( '' !== trim( $name ) ) { |
| 419 |
$labels[] = $name; |
| 420 |
} |
| 421 |
} |
| 422 |
|
| 423 |
return array_values( array_unique( $labels ) ); |
| 424 |
} |
| 425 |
|
| 426 |
/** |
| 427 |
* Add a WooCommerce page's permalink to the context when the page exists. |
| 428 |
* |
| 429 |
* @param array $context Context array (by reference). |
| 430 |
* @param string $key Context key to set. |
| 431 |
* @param string $wc_page WooCommerce page id key (myaccount|checkout|shop|cart). |
| 432 |
* @return void |
| 433 |
*/ |
| 434 |
private function add_wc_page( array &$context, $key, $wc_page ) { |
| 435 |
if ( ! function_exists( 'wc_get_page_id' ) ) { |
| 436 |
return; |
| 437 |
} |
| 438 |
$page_id = (int) wc_get_page_id( $wc_page ); |
| 439 |
if ( $page_id > 0 ) { |
| 440 |
$url = get_permalink( $page_id ); |
| 441 |
if ( $url ) { |
| 442 |
$context[ $key ] = esc_url_raw( (string) $url ); |
| 443 |
} |
| 444 |
} |
| 445 |
} |
| 446 |
|
| 447 |
/** |
| 448 |
* Resolve the store's Refund/Returns policy page. WooCommerce has no canonical |
| 449 |
* option for it, so match common slugs; returns the URL + a short excerpt, or an |
| 450 |
* empty array when none is found (callers fall back to the terms page). |
| 451 |
* |
| 452 |
* @return array { url?: string, excerpt?: string } |
| 453 |
*/ |
| 454 |
private function refund_policy() { |
| 455 |
$slugs = [ 'refund_returns', 'refund-and-returns-policy', 'refund-policy', 'returns', 'return-policy', 'returns-policy' ]; |
| 456 |
$page = null; |
| 457 |
foreach ( $slugs as $slug ) { |
| 458 |
$found = get_page_by_path( $slug ); |
| 459 |
if ( $found instanceof \WP_Post && 'publish' === $found->post_status ) { |
| 460 |
$page = $found; |
| 461 |
break; |
| 462 |
} |
| 463 |
} |
| 464 |
|
| 465 |
if ( ! $page instanceof \WP_Post ) { |
| 466 |
return []; |
| 467 |
} |
| 468 |
|
| 469 |
return [ |
| 470 |
'url' => esc_url_raw( (string) get_permalink( $page->ID ) ), |
| 471 |
'excerpt' => sanitize_text_field( wp_trim_words( wp_strip_all_tags( (string) $page->post_content ), 40, '…' ) ), |
| 472 |
]; |
| 473 |
} |
| 474 |
|
| 475 |
/** |
| 476 |
* The lowest Free Shipping minimum-order amount configured across shipping zones, |
| 477 |
* or null when no Free Shipping method defines a positive threshold. |
| 478 |
* |
| 479 |
* @return float|null |
| 480 |
*/ |
| 481 |
private function free_shipping_min() { |
| 482 |
if ( ! class_exists( '\WC_Shipping_Zones' ) ) { |
| 483 |
return null; |
| 484 |
} |
| 485 |
|
| 486 |
// All real zones plus the catch-all "Rest of the World" zone (id 0). |
| 487 |
$zone_ids = [ 0 ]; |
| 488 |
foreach ( (array) \WC_Shipping_Zones::get_zones() as $zone ) { |
| 489 |
if ( isset( $zone['id'] ) ) { |
| 490 |
$zone_ids[] = (int) $zone['id']; |
| 491 |
} |
| 492 |
} |
| 493 |
|
| 494 |
$min = null; |
| 495 |
foreach ( array_unique( $zone_ids ) as $zone_id ) { |
| 496 |
$zone = \WC_Shipping_Zones::get_zone( $zone_id ); |
| 497 |
if ( ! $zone ) { |
| 498 |
continue; |
| 499 |
} |
| 500 |
foreach ( (array) $zone->get_shipping_methods( true ) as $method ) { |
| 501 |
if ( ! isset( $method->id ) || 'free_shipping' !== $method->id ) { |
| 502 |
continue; |
| 503 |
} |
| 504 |
$amount = method_exists( $method, 'get_option' ) ? $method->get_option( 'min_amount' ) : ( isset( $method->min_amount ) ? $method->min_amount : '' ); |
| 505 |
$amount = is_numeric( $amount ) ? (float) $amount : 0; |
| 506 |
if ( $amount > 0 && ( null === $min || $amount < $min ) ) { |
| 507 |
$min = $amount; |
| 508 |
} |
| 509 |
} |
| 510 |
} |
| 511 |
|
| 512 |
return $min; |
| 513 |
} |
| 514 |
|
| 515 |
/** |
| 516 |
* Topic hints — primary nav menu + key pages + top post categories. |
| 517 |
* |
| 518 |
* @return array |
| 519 |
*/ |
| 520 |
public function topic_hints() { |
| 521 |
// Primary nav menu item labels. |
| 522 |
$nav = []; |
| 523 |
$locations = get_nav_menu_locations(); |
| 524 |
if ( ! empty( $locations ) ) { |
| 525 |
$menu_id = reset( $locations ); |
| 526 |
$items = wp_get_nav_menu_items( $menu_id ); |
| 527 |
if ( $items ) { |
| 528 |
$nav = array_slice( |
| 529 |
array_map( |
| 530 |
function ( $item ) { |
| 531 |
return sanitize_text_field( $item->title ); |
| 532 |
}, |
| 533 |
$items |
| 534 |
), |
| 535 |
0, |
| 536 |
12 |
| 537 |
); |
| 538 |
} |
| 539 |
} |
| 540 |
|
| 541 |
// Key page titles (About, Pricing, Services, Contact, FAQ…). |
| 542 |
$pages = get_posts( |
| 543 |
[ |
| 544 |
'post_type' => 'page', |
| 545 |
'posts_per_page' => 10, |
| 546 |
'orderby' => 'menu_order', |
| 547 |
'order' => 'ASC', |
| 548 |
'fields' => 'ids', |
| 549 |
] |
| 550 |
); |
| 551 |
$page_titles = array_map( |
| 552 |
function ( $id ) { |
| 553 |
return sanitize_text_field( get_the_title( $id ) ); |
| 554 |
}, |
| 555 |
$pages |
| 556 |
); |
| 557 |
|
| 558 |
// Top post categories for blog topic hints. |
| 559 |
$post_cats = get_terms( |
| 560 |
[ |
| 561 |
'taxonomy' => 'category', |
| 562 |
'orderby' => 'count', |
| 563 |
'order' => 'DESC', |
| 564 |
'number' => 6, |
| 565 |
'hide_empty' => true, |
| 566 |
] |
| 567 |
); |
| 568 |
$post_cat_names = is_wp_error( $post_cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $post_cats, 'name' ) ); |
| 569 |
|
| 570 |
// Existing documentation — lets the proxy build content that extends what |
| 571 |
// the site already covers. In particular, FAQ generation derives questions |
| 572 |
// from these doc topics ("detect FAQs from the docs too"). |
| 573 |
$docs = get_posts( |
| 574 |
[ |
| 575 |
'post_type' => 'docs', |
| 576 |
'posts_per_page' => 15, |
| 577 |
'orderby' => 'date', |
| 578 |
'order' => 'DESC', |
| 579 |
'post_status' => 'publish', |
| 580 |
'fields' => 'ids', |
| 581 |
] |
| 582 |
); |
| 583 |
$doc_titles = array_map( |
| 584 |
function ( $id ) { |
| 585 |
return sanitize_text_field( get_the_title( $id ) ); |
| 586 |
}, |
| 587 |
$docs |
| 588 |
); |
| 589 |
|
| 590 |
$doc_cats = get_terms( |
| 591 |
[ |
| 592 |
'taxonomy' => 'doc_category', |
| 593 |
'orderby' => 'count', |
| 594 |
'order' => 'DESC', |
| 595 |
'number' => 6, |
| 596 |
'hide_empty' => true, |
| 597 |
] |
| 598 |
); |
| 599 |
$doc_cat_names = is_wp_error( $doc_cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $doc_cats, 'name' ) ); |
| 600 |
|
| 601 |
return [ |
| 602 |
'nav_items' => array_values( array_filter( $nav ) ), |
| 603 |
'page_titles' => array_values( array_filter( $page_titles ) ), |
| 604 |
'post_categories' => array_values( $post_cat_names ), |
| 605 |
'doc_titles' => array_values( array_filter( $doc_titles ) ), |
| 606 |
'doc_categories' => array_values( $doc_cat_names ), |
| 607 |
]; |
| 608 |
} |
| 609 |
|
| 610 |
/** |
| 611 |
* Clear the cached profile (call after big content/plugin changes). |
| 612 |
* |
| 613 |
* @return void |
| 614 |
*/ |
| 615 |
public function flush() { |
| 616 |
delete_transient( self::CACHE_KEY . '_' . get_locale() ); |
| 617 |
} |
| 618 |
|
| 619 |
/** |
| 620 |
* Active plugins on this site (network-active included). |
| 621 |
* |
| 622 |
* @return array |
| 623 |
*/ |
| 624 |
private function active_plugins() { |
| 625 |
$active = (array) get_option( 'active_plugins', [] ); |
| 626 |
|
| 627 |
if ( is_multisite() ) { |
| 628 |
$network = (array) get_site_option( 'active_sitewide_plugins', [] ); |
| 629 |
$active = array_merge( $active, array_keys( $network ) ); |
| 630 |
} |
| 631 |
|
| 632 |
return $active; |
| 633 |
} |
| 634 |
} |
| 635 |
|