<?php
|
namespace JVBase\utility;
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
/**
|
* FeatureFlags - Advanced feature detection and management
|
*
|
* Replaces repetitive jvbCheck() calls with a more elegant API
|
* Supports caching, bulk checks, and feature dependencies
|
*/
|
class Features
|
{
|
private array $config;
|
private string $type;
|
private string $slug;
|
private array $cache = [];
|
private static array $globalCache = [];
|
|
// Common feature groups for quick reference
|
const CONTENT_FEATURES = [
|
'hide_single', 'show_feed', 'show_directory', 'karma',
|
'favouritable', 'responses', 'is_calendar', 'single_image',
|
'redirectToAuthor', 'syncWithSquare', 'approve_new', 'is_gallery'
|
];
|
|
const TAXONOMY_FEATURES = [
|
'show_feed', 'show_directory', 'is_content', 'is_ownable',
|
'karma', 'verify_entry', 'approve_new', 'track_changes',
|
'invitable', 'associate_user_content', 'is_favouritable'
|
];
|
|
const USER_FEATURES = [
|
'has_dashboard', 'can_register', 'invitable', 'approve_new',
|
'keep_stats', 'can_favourite', 'member_verified'
|
];
|
|
const SITE_FEATURES = [
|
'dashboard', 'favourites', 'enthusiast', 'forum', 'notifications',
|
'has_support', 'has_membership', 'use_feed_block', 'square',
|
'gmb', 'umami', 'cloudflare', 'facebook', 'instagram', 'bluesky',
|
'social', 'limit_hours'
|
];
|
|
/**
|
* Constructor for type-specific feature flags
|
*/
|
public function __construct(array $config = [], string $type = '', string $slug = '')
|
{
|
$this->config = $config;
|
$this->type = $type;
|
$this->slug = $slug;
|
}
|
|
/**
|
* Create from a specific content type
|
*/
|
public static function forContent(string $slug): self
|
{
|
$slug = jvbNoBase($slug);
|
if (!isset(JVB_CONTENT[$slug])) {
|
return new self([], 'content', $slug);
|
}
|
return new self(JVB_CONTENT[$slug], 'content', $slug);
|
}
|
|
public static function hasIntegration(string $integration, string $type = 'site', ?string $subType = null):bool
|
{
|
$allowedTypes = ['site', 'content', 'taxonomy', 'user'];
|
if (!in_array($type, $allowedTypes)) {
|
return false;
|
}
|
if (in_array($type, ['content', 'taxonomy', 'user']) && !$subType) {
|
return false;
|
}
|
switch ($type) {
|
case 'site':
|
$feature = (array_key_exists('integrations', JVB_SITE)) ? new self(JVB_SITE['integrations'], 'integrations', 'site-integrations') : new self([], 'integrations', 'site-integrations');
|
break;
|
case 'content':
|
$feature = (!isset(JVB_CONTENT[$subType])|| !array_key_exists('integrations', JVB_CONTENT[$subType])) ? new self([], 'integrations', 'content-integrations') : new self(JVB_CONTENT[$subType]['integrations'], 'integrations', 'content-integrations');
|
break;
|
case 'taxonomy':
|
$feature = (!isset(JVB_TAXONOMY[$subType])|| !array_key_exists('integrations', JVB_TAXONOMY[$subType])) ? new self([], 'integrations', 'taxonomy-integrations') : new self(JVB_TAXONOMY[$subType]['integrations'], 'integrations', 'taxonomy-integrations');
|
break;
|
case 'user':
|
$feature = (!isset(JVB_USER[$subType])|| !array_key_exists('integrations', JVB_USER[$subType])) ? new self([], 'integrations', 'user-integrations') : new self(JVB_USER[$subType]['integrations'], 'integrations', 'user-integrations');
|
break;
|
default:
|
return false;
|
}
|
return $feature->has($integration);
|
}
|
|
public static function hasAnyIntegration(string $type = 'site', ?string $subType = null):bool
|
{
|
$allowedTypes = ['site', 'content', 'taxonomy', 'user'];
|
if (!in_array($type, $allowedTypes)) {
|
return false;
|
}
|
if (in_array($type, ['content', 'taxonomy', 'user']) && !$subType) {
|
return false;
|
}
|
switch ($type) {
|
case 'site':
|
return (array_key_exists('integrations', JVB_SITE) && !empty(JVB_SITE['integrations']));
|
case 'content':
|
return (array_key_exists($subType, JVB_CONTENT) && array_key_exists('integrations', JVB_CONTENT[$subType]) && !empty(JVB_CONTENT[$subType]['integrations']));
|
case 'taxonomy':
|
return (array_key_exists($subType, JVB_TAXONOMY) && array_key_exists('integrations', JVB_TAXONOMY[$subType]) && !empty(JVB_TAXONOMY[$subType]['integrations']));
|
case 'user':
|
return (array_key_exists($subType, JVB_USER) && array_key_exists('integrations', JVB_USER[$subType]) && !empty(JVB_USER[$subType]['integrations']));
|
default:
|
return false;
|
}
|
}
|
/**
|
* Create from a specific taxonomy
|
*/
|
public static function forTaxonomy(string $slug): self
|
{
|
$slug = jvbNoBase($slug);
|
if (!isset(JVB_TAXONOMY[$slug])) {
|
return new self([], 'taxonomy', $slug);
|
}
|
return new self(JVB_TAXONOMY[$slug], 'taxonomy', $slug);
|
}
|
|
/**
|
* Create from a specific user role
|
*/
|
public static function forUser(string $slug): self
|
{
|
$slug = jvbNoBase($slug);
|
if (!isset(JVB_USER[$slug])) {
|
return new self([], 'user', $slug);
|
}
|
return new self(JVB_USER[$slug], 'user', $slug);
|
}
|
|
/**
|
* Create for site-wide features
|
*/
|
public static function forSite(): self
|
{
|
return new self(JVB_SITE ?? [], 'site', 'site');
|
}
|
|
/**
|
* Create for membership features
|
*/
|
public static function forMembership(): self
|
{
|
return new self(JVB_MEMBERSHIP ?? [], 'membership', 'membership');
|
}
|
|
/**
|
* Check if a single feature is enabled
|
*/
|
public function has(string $feature): bool
|
{
|
// Check cache first
|
$cacheKey = $this->getCacheKey($feature);
|
if (isset($this->cache[$cacheKey])) {
|
return $this->cache[$cacheKey];
|
}
|
|
// Perform the check
|
$result = isset($this->config[$feature]) && $this->config[$feature] === true;
|
|
// Cache the result
|
$this->cache[$cacheKey] = $result;
|
|
return $result;
|
}
|
|
/**
|
* Check if ALL specified features are enabled
|
*/
|
public function hasAll(array $features): bool
|
{
|
foreach ($features as $feature) {
|
if (!$this->has($feature)) {
|
return false;
|
}
|
}
|
return true;
|
}
|
|
/**
|
* Check if ANY of the specified features are enabled
|
*/
|
public function hasAny(array $features): bool
|
{
|
foreach ($features as $feature) {
|
if ($this->has($feature)) {
|
return true;
|
}
|
}
|
return false;
|
}
|
|
/**
|
* Check if NONE of the specified features are enabled
|
*/
|
public function hasNone(array $features): bool
|
{
|
return !$this->hasAny($features);
|
}
|
|
/**
|
* Get all enabled features
|
*/
|
public function getEnabled(): array
|
{
|
$enabled = [];
|
|
// Determine which features to check based on type
|
$featuresToCheck = $this->getFeatureList();
|
|
foreach ($featuresToCheck as $feature) {
|
if ($this->has($feature)) {
|
$enabled[] = $feature;
|
}
|
}
|
|
return $enabled;
|
}
|
|
/**
|
* Get all disabled features
|
*/
|
public function getDisabled(): array
|
{
|
$disabled = [];
|
$featuresToCheck = $this->getFeatureList();
|
|
foreach ($featuresToCheck as $feature) {
|
if (!$this->has($feature)) {
|
$disabled[] = $feature;
|
}
|
}
|
|
return $disabled;
|
}
|
|
/**
|
* Check feature with a default value if not set
|
*/
|
public function get(string $feature, bool $default = false): bool
|
{
|
if (!isset($this->config[$feature])) {
|
return $default;
|
}
|
return $this->has($feature);
|
}
|
|
/**
|
* Check if a feature is explicitly set (regardless of value)
|
*/
|
public function isSet(string $feature): bool
|
{
|
return isset($this->config[$feature]);
|
}
|
|
/**
|
* Get the raw value of a feature (not just boolean)
|
*/
|
public function getValue(string $feature, $default = null)
|
{
|
return $this->config[$feature] ?? $default;
|
}
|
|
/**
|
* Check features with dependencies
|
*/
|
public function hasWithDependencies(string $feature): bool
|
{
|
if (!$this->has($feature)) {
|
return false;
|
}
|
|
// Check dependencies based on feature
|
$dependencies = $this->getFeatureDependencies($feature);
|
|
return $this->hasAll($dependencies);
|
}
|
|
/**
|
* Global feature checks across all types
|
*/
|
public static function anyContentHas(string $feature): bool
|
{
|
$cacheKey = "content_any_{$feature}";
|
|
if (isset(self::$globalCache[$cacheKey])) {
|
return self::$globalCache[$cacheKey];
|
}
|
foreach (JVB_CONTENT as $slug => $config) {
|
$flags = new self($config, 'content', $slug);
|
if ($flags->has($feature)) {
|
self::$globalCache[$cacheKey] = true;
|
return true;
|
}
|
}
|
|
self::$globalCache[$cacheKey] = false;
|
return false;
|
}
|
|
/**
|
* Check if any taxonomy has a feature
|
*/
|
public static function anyTaxonomyHas(string $feature): bool
|
{
|
$cacheKey = "taxonomy_any_{$feature}";
|
|
if (isset(self::$globalCache[$cacheKey])) {
|
return self::$globalCache[$cacheKey];
|
}
|
|
foreach (JVB_TAXONOMY as $slug => $config) {
|
$flags = new self($config, 'taxonomy', $slug);
|
if ($flags->has($feature)) {
|
self::$globalCache[$cacheKey] = true;
|
return true;
|
}
|
}
|
|
self::$globalCache[$cacheKey] = false;
|
return false;
|
}
|
|
/**
|
* Check if any user role has a feature
|
*/
|
public static function anyUserHas(string $feature): bool
|
{
|
$cacheKey = "user_any_{$feature}";
|
|
if (isset(self::$globalCache[$cacheKey])) {
|
return self::$globalCache[$cacheKey];
|
}
|
|
foreach (JVB_USER as $slug => $config) {
|
$flags = new self($config, 'user', $slug);
|
if ($flags->has($feature)) {
|
self::$globalCache[$cacheKey] = true;
|
return true;
|
}
|
}
|
|
self::$globalCache[$cacheKey] = false;
|
return false;
|
}
|
|
/**
|
* Get all types with a specific feature
|
*/
|
public static function getTypesWithFeature(string $feature, string $type = 'content'): array
|
{
|
$types = [];
|
|
$source = match ($type) {
|
'content' => JVB_CONTENT,
|
'taxonomy' => JVB_TAXONOMY,
|
'user' => JVB_USER,
|
default => []
|
};
|
|
foreach ($source as $slug => $config) {
|
$flags = new self($config, $type, $slug);
|
if ($flags->has($feature)) {
|
$types[] = $slug;
|
}
|
}
|
|
return $types;
|
}
|
|
/**
|
* Count types with a feature
|
*/
|
public static function countWithFeature(string $feature, string $type = 'content'): int
|
{
|
return count(self::getTypesWithFeature($feature, $type));
|
}
|
|
/**
|
* Check complex feature combinations
|
*/
|
public function meetsRequirements(array $requirements): bool
|
{
|
foreach ($requirements as $requirement => $expected) {
|
if (is_array($expected)) {
|
// Handle OR conditions
|
if (!$this->hasAny($expected)) {
|
return false;
|
}
|
} elseif (is_bool($expected)) {
|
// Handle boolean requirements
|
if ($this->has($requirement) !== $expected) {
|
return false;
|
}
|
} elseif (is_callable($expected)) {
|
// Handle custom validation
|
if (!$expected($this->getValue($requirement))) {
|
return false;
|
}
|
}
|
}
|
|
return true;
|
}
|
|
/**
|
* Get feature statistics
|
*/
|
public function getStats(): array
|
{
|
$all = $this->getFeatureList();
|
$enabled = $this->getEnabled();
|
|
return [
|
'total' => count($all),
|
'enabled' => count($enabled),
|
'disabled' => count($all) - count($enabled),
|
'percentage' => count($all) > 0 ? round((count($enabled) / count($all)) * 100, 2) : 0
|
];
|
}
|
|
/**
|
* Export configuration for debugging
|
*/
|
public function export(): array
|
{
|
return [
|
'type' => $this->type,
|
'slug' => $this->slug,
|
'features' => $this->getEnabled(),
|
'config' => $this->config
|
];
|
}
|
|
/**
|
* Check if configuration supports a specific workflow
|
*/
|
public function supportsWorkflow(string $workflow): bool
|
{
|
$workflows = [
|
'moderation' => ['approve_new', 'member_verified'],
|
'social' => ['karma', 'responses', 'favouritable'],
|
'calendar' => ['is_calendar'],
|
'directory' => ['show_directory'],
|
'ownership' => ['is_ownable', 'is_owned_by'],
|
'dashboard' => ['has_dashboard'],
|
'public_profile' => ['has_dashboard', 'profile'],
|
'content_creation' => ['can_create'],
|
'invitation' => ['invitable', 'can_invite'],
|
'feed' => ['show_feed', 'use_feed_block'],
|
'ecommerce' => ['square', 'syncWithSquare'],
|
'analytics' => ['keep_stats', 'umami'],
|
'forum' => ['forum', 'responses']
|
];
|
|
if (!isset($workflows[$workflow])) {
|
return false;
|
}
|
|
return $this->hasAny($workflows[$workflow]);
|
}
|
|
/**
|
* Get feature dependencies
|
*/
|
private function getFeatureDependencies(string $feature): array
|
{
|
$dependencies = [
|
'responses' => ['forum'],
|
'karma' => ['member_verified'],
|
'invitable' => ['can_invite'],
|
'keep_stats' => ['has_dashboard'],
|
'syncWithSquare' => ['square']
|
];
|
|
return $dependencies[$feature] ?? [];
|
}
|
|
/**
|
* Get list of features based on type
|
*/
|
private function getFeatureList(): array
|
{
|
return match ($this->type) {
|
'content' => self::CONTENT_FEATURES,
|
'taxonomy' => self::TAXONOMY_FEATURES,
|
'user' => self::USER_FEATURES,
|
'site' => self::SITE_FEATURES,
|
'membership' => array_keys($this->config),
|
default => array_keys($this->config)
|
};
|
}
|
|
/*****************************************************************
|
* Dashboard Utilitiies
|
*****************************************************************/
|
/**
|
* Get content types that a user role can create
|
* Extracts and flattens from 'can_create' config
|
*
|
* @return array Array of content type slugs
|
*
|
* Usage:
|
* Features::forUser('artist')->getCreatableContent()
|
* // Returns: ['tattoo', 'piercing', 'artwork']
|
*/
|
public function getCreatableContent(): array
|
{
|
if ($this->type !== 'user') {
|
return [];
|
}
|
|
$canCreate = $this->getValue('can_create', []);
|
|
if (empty($canCreate)) {
|
return [];
|
}
|
|
$content = [];
|
|
foreach ($canCreate as $item) {
|
if (is_array($item)) {
|
// Handle nested arrays like ['shop' => ['tattoo', 'piercing']]
|
foreach ($item as $type => $contents) {
|
$content = array_merge($content, $contents);
|
}
|
} else {
|
// Handle simple strings
|
$content[] = $item;
|
}
|
}
|
|
return array_unique($content);
|
}
|
|
/**
|
* Get all dashboard pages for a user role
|
* Includes profile, creatable content, and settings
|
*
|
* @return array Array of page slugs
|
*
|
* Usage:
|
* Features::forUser('artist')->getDashboardPages()
|
* // Returns: ['artist-profile', 'tattoo', 'piercing', 'settings']
|
*/
|
public function getDashboardPages(): array
|
{
|
if ($this->type !== 'user') {
|
return [];
|
}
|
|
$pages = [];
|
|
// Add profile page if configured
|
$profile = $this->getValue('profile');
|
if ($profile) {
|
$pages[] = $profile;
|
}
|
|
// Add creatable content types
|
$pages = array_merge($pages, $this->getCreatableContent());
|
|
// Add settings if user has dashboard
|
if ($this->has('has_dashboard')) {
|
$pages[] = 'settings';
|
}
|
|
return array_unique($pages);
|
}
|
|
/**
|
* Check if user role can create a specific content type
|
*
|
* @param string $contentType
|
* @return bool
|
*
|
* Usage:
|
* Features::forUser('artist')->canCreate('tattoo') // true/false
|
*/
|
public function canCreate(string $contentType): bool
|
{
|
return in_array($contentType, $this->getCreatableContent());
|
}
|
|
/**
|
* Get the profile type for a user role
|
*
|
* @return string|null Profile slug or null if none
|
*
|
* Usage:
|
* Features::forUser('artist')->getProfile() // 'artist-profile'
|
*/
|
public function getProfile(): ?string
|
{
|
if ($this->type !== 'user') {
|
return null;
|
}
|
|
return $this->getValue('profile');
|
}
|
|
/**
|
* Check if user role has a profile page
|
*
|
* @return bool
|
*
|
* Usage:
|
* Features::forUser('artist')->hasProfile() // true/false
|
*/
|
public function hasProfile(): bool
|
{
|
return $this->getProfile() !== null;
|
}
|
|
/**
|
* Get content types grouped by parent type (if nested)
|
*
|
* @return array Associative array with parent types as keys
|
*
|
* Usage:
|
* Features::forUser('artist')->getGroupedContent()
|
* // Returns: ['shop' => ['tattoo', 'piercing'], 'standalone' => ['artwork']]
|
*/
|
public function getGroupedContent(): array
|
{
|
if ($this->type !== 'user') {
|
return [];
|
}
|
|
$canCreate = $this->getValue('can_create', []);
|
|
if (empty($canCreate)) {
|
return [];
|
}
|
|
$grouped = [];
|
|
foreach ($canCreate as $item) {
|
if (is_array($item)) {
|
// Handle nested arrays like ['shop' => ['tattoo', 'piercing']]
|
foreach ($item as $parent => $contents) {
|
if (!isset($grouped[$parent])) {
|
$grouped[$parent] = [];
|
}
|
$grouped[$parent] = array_merge($grouped[$parent], $contents);
|
}
|
} else {
|
// Handle simple strings - add to 'standalone'
|
if (!isset($grouped['standalone'])) {
|
$grouped['standalone'] = [];
|
}
|
$grouped['standalone'][] = $item;
|
}
|
}
|
|
return $grouped;
|
}
|
|
/**
|
* Static method to get all content types across all user roles
|
*
|
* @return array Array of unique content type slugs
|
*
|
* Usage:
|
* Features::getAllUserContent()
|
* // Returns: ['tattoo', 'piercing', 'artwork', 'event', ...]
|
*/
|
public static function getAllUserContent(): array
|
{
|
$allContent = [];
|
|
foreach (JVB_USER as $slug => $config) {
|
$features = self::forUser($slug);
|
$allContent = array_merge($allContent, $features->getCreatableContent());
|
}
|
|
return array_unique($allContent);
|
}
|
|
/**
|
* Static method to get all user roles that can create specific content
|
*
|
* @param string $contentType
|
* @return array Array of role slugs
|
*
|
* Usage:
|
* Features::getRolesForContent('tattoo')
|
* // Returns: ['artist', 'shop']
|
*/
|
public static function getRolesForContent(string $contentType): array
|
{
|
$roles = [];
|
|
foreach (JVB_USER as $slug => $config) {
|
$features = self::forUser($slug);
|
if ($features->canCreate($contentType)) {
|
$roles[] = $slug;
|
}
|
}
|
|
return $roles;
|
}
|
|
/**
|
* Get all dashboard pages across all user roles
|
*
|
* @return array Array of unique page slugs
|
*
|
* Usage:
|
* Features::getAllDashboardPages()
|
* // Returns: ['artist-profile', 'shop-profile', 'tattoo', 'piercing', ...]
|
*/
|
public static function getAllDashboardPages(): array
|
{
|
$allPages = [];
|
|
foreach (JVB_USER as $slug => $config) {
|
$features = self::forUser($slug);
|
$allPages = array_merge($allPages, $features->getDashboardPages());
|
}
|
|
return array_unique($allPages);
|
}
|
|
public static function getType(string $slug):?string
|
{
|
if (array_key_exists($slug, JVB_CONTENT)) {
|
return 'content';
|
}
|
if (array_key_exists($slug, JVB_USER)) {
|
return 'user';
|
}
|
if (array_key_exists($slug, JVB_TAXONOMY)) {
|
return 'taxonomy';
|
}
|
if (array_key_exists($slug, JVB_OPTIONS)) {
|
return 'option';
|
}
|
return null;
|
}
|
|
public static function getConfig(string $slug, ?string $type = null): array
|
{
|
$slug = jvbNoBase($slug);
|
$all = ['post', 'content', 'taxonomy', 'user'];
|
$types = (!$type || !in_array($type, $all)) ? $all : [$type];
|
|
foreach($types as $type) {
|
switch($type) {
|
case 'post':
|
case 'content':
|
if (array_key_exists($slug, JVB_CONTENT)) {
|
return JVB_CONTENT[$slug];
|
}
|
break;
|
case 'taxonomy':
|
if (array_key_exists($slug, JVB_TAXONOMY)) {
|
return JVB_TAXONOMY[$slug];
|
}
|
break;
|
case 'user':
|
if (array_key_exists($slug, JVB_USER)) {
|
return JVB_USER[$slug];
|
}
|
break;
|
default:
|
return [];
|
}
|
}
|
error_log('No config found for: '.$slug);
|
return [];
|
}
|
/**
|
* Generate cache key
|
*/
|
private function getCacheKey(string $feature): string
|
{
|
return "{$this->type}_{$this->slug}_{$feature}";
|
}
|
|
/**
|
* Clear cache
|
*/
|
public function clearCache(): void
|
{
|
$this->cache = [];
|
}
|
|
/**
|
* Clear global cache
|
*/
|
public static function clearGlobalCache(): void
|
{
|
self::$globalCache = [];
|
}
|
|
/**
|
* Magic method for property-style access
|
*/
|
public function __get(string $name): bool
|
{
|
return $this->has($name);
|
}
|
|
/**
|
* Magic method for method-style checks
|
*/
|
public function __call(string $name, array $arguments): bool
|
{
|
// Support is*, has*, can* method calls
|
if (preg_match('/^(is|has|can)(.+)$/', $name, $matches)) {
|
$feature = lcfirst($matches[2]);
|
|
// Convert camelCase to snake_case
|
$feature = strtolower(preg_replace('/([A-Z])/', '_$1', $feature));
|
|
return $this->has($feature);
|
}
|
|
throw new \BadMethodCallException("Method {$name} does not exist");
|
}
|
}
|