Display conditions. Settings: * * _conditions repeater of rules { rule, operator, ... } (fields in widgets/_common/controls.json) * _conditions_relation 'and' (all rules match) | 'or' (any rule matches) * * Rules: login (logged_in / logged_out), role, date (from / to, site time zone), time (HH:MM range, may wrap * midnight), days (mon..sun), post (IDs or slugs), url_param (exists / not_exists / equals / not_equals / * contains), dynamic (a dynamic tag's value compared with equals / not_equals / contains / not_contains / empty / * not_empty / greater / less, resolved through Zadex\Dynamic\Resolver) and device. `operator: is_not` negates * role / date / time / days / post. Incomplete rules (no role, no dates ...) are ignored; no usable rule = visible. * * Device rules are not evaluated on the server (page caches serve one HTML to every device): they hide the element * with CSS classes `zx-cond-hide-` (media queries from the site breakpoints, printed once per request by * Renderer). Filter `zadex/conditions/device_hint` (bool) switches to a user-agent hint (wp_is_mobile) instead. * * Renderer::render_element() skips an element whose conditions fail; filter `zadex/element/visible` * ($visible, $element, $settings) has the last word. Never applied in the editor / REST previews. * Pages using conditions that vary per visitor should not be page-cached (like the user-info tag). */ final class ElementConditions { public const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; /** * Normalized rule list of an element (non-array items dropped). * * @return array> */ public static function rules(array $settings): array { $rules = $settings['_conditions'] ?? []; return is_array($rules) ? array_values(array_filter($rules, 'is_array')) : []; } public static function has_rules(array $settings): bool { return self::rules($settings) !== []; } /** * @param array $ctx see context() */ public static function visible(array $settings, array $ctx): bool { $relation = ($settings['_conditions_relation'] ?? 'and') === 'or' ? 'or' : 'and'; return self::evaluate(self::rules($settings), $relation, $ctx); } /** * @param array> $rules * @param array $ctx */ public static function evaluate(array $rules, string $relation, array $ctx): bool { $results = []; foreach ($rules as $rule) { $r = self::match($rule, $ctx); if ($r !== null) { $results[] = $r; } } if (!$results) { return true; } return $relation === 'or' ? in_array(true, $results, true) : !in_array(false, $results, true); } /** * One rule: true / false, or null when the rule is incomplete or not evaluated here (device). * * @param array $rule * @param array $ctx */ public static function match(array $rule, array $ctx): ?bool { $type = is_string($rule['rule'] ?? null) ? $rule['rule'] : 'login'; $result = null; switch ($type) { case 'login': $logged_in = !empty($ctx['logged_in']); return ($rule['login'] ?? 'logged_in') === 'logged_out' ? !$logged_in : $logged_in; case 'role': $role = is_string($rule['role'] ?? null) ? trim($rule['role']) : ''; $result = $role === '' ? null : in_array($role, (array) ($ctx['roles'] ?? []), true); break; case 'date': $result = self::match_date((string) ($rule['date_from'] ?? ''), (string) ($rule['date_to'] ?? ''), self::now($ctx)); break; case 'time': $result = self::match_time((string) ($rule['time_from'] ?? ''), (string) ($rule['time_to'] ?? ''), self::now($ctx)); break; case 'days': $days = array_values(array_intersect(self::DAYS, array_map('strval', (array) ($rule['days'] ?? [])))); $result = $days ? in_array(strtolower(self::now($ctx)->format('D')), $days, true) : null; break; case 'post': $result = self::match_post((string) ($rule['posts'] ?? ''), (int) ($ctx['post_id'] ?? 0), (string) ($ctx['post_slug'] ?? '')); break; case 'url_param': return self::match_param($rule, (array) ($ctx['query'] ?? [])); case 'dynamic': return self::match_dynamic($rule, $ctx); case 'device': $devices = MotionAttrs::devices($rule['devices'] ?? []); $device = $ctx['device'] ?? null; if (!is_string($device) || !$devices || !is_array($rule['devices'] ?? null)) { return null; } return in_array($device, $devices, true); default: return null; } if ($result === null) { return null; } return ($rule['operator'] ?? 'is') === 'is_not' ? !$result : $result; } /** Devices hidden by the element's device rules (CSS classes `zx-cond-hide-`). @return string[] */ public static function hidden_devices(array $settings): array { $shown = null; foreach (self::rules($settings) as $rule) { if (($rule['rule'] ?? '') !== 'device' || !is_array($rule['devices'] ?? null) || !$rule['devices']) { continue; } $devices = MotionAttrs::devices($rule['devices']); $shown = $shown === null ? $devices : array_values(array_intersect($shown, $devices)); } return $shown === null ? [] : array_values(array_diff(MotionAttrs::DEVICES, $shown)); } /** * Media queries hiding `.zx-cond-hide-` (desktop-first: tablet / mobile use max-width, desktop * min-width tablet + 1). * * @param array $breakpoints Settings::breakpoints() */ public static function device_css(array $breakpoints): string { $mobile = (int) ($breakpoints['mobile']['value'] ?? 767); $tablet = (int) ($breakpoints['tablet']['value'] ?? 1024); return '@media (min-width:' . ($tablet + 1) . 'px){.zx-cond-hide-desktop{display:none!important}}' . '@media (min-width:' . ($mobile + 1) . 'px) and (max-width:' . $tablet . 'px){.zx-cond-hide-tablet{display:none!important}}' . '@media (max-width:' . $mobile . 'px){.zx-cond-hide-mobile{display:none!important}}'; } /** * Request context for match(): now (site time zone), login state, roles, post, query string, device hint and * the dynamic-tag resolver. * * @return array */ public static function context(Context $dyn): array { $roles = []; if ($dyn->user_id > 0 && function_exists('get_userdata')) { $user = get_userdata($dyn->user_id); $roles = $user && isset($user->roles) ? array_values((array) $user->roles) : []; } $post = $dyn->post(); $device = null; if (apply_filters('zadex/conditions/device_hint', false) && function_exists('wp_is_mobile')) { $device = wp_is_mobile() ? 'mobile' : 'desktop'; } return [ 'now' => function_exists('current_datetime') ? current_datetime() : new \DateTimeImmutable('now'), 'logged_in' => $dyn->user_id > 0, 'roles' => $roles, 'post_id' => $dyn->post_id, 'post_slug' => $post && isset($post->post_name) ? (string) $post->post_name : '', 'query' => $dyn->query, 'device' => $device, 'dynamic' => static function (string $tag, string $key) use ($dyn): string { $settings = $key !== '' ? ['key' => $key, 'field' => $key] : []; $value = Resolver::resolve(Value::make($tag, $settings), $dyn, 'text'); return is_scalar($value) ? trim(wp_strip_all_tags((string) $value)) : ''; }, ]; } private static function now(array $ctx): \DateTimeImmutable { return isset($ctx['now']) && $ctx['now'] instanceof \DateTimeImmutable ? $ctx['now'] : new \DateTimeImmutable('now'); } private static function parse_date(string $value, \DateTimeZone $tz, bool $end): ?\DateTimeImmutable { $value = trim($value); if ($value === '') { return null; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) { $value .= $end ? ' 23:59:59' : ' 00:00:00'; } elseif (preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{1,2}:\d{2}$/', $value)) { $value .= $end ? ':59' : ':00'; } if (!preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{1,2}:\d{2}:\d{2}$/', $value)) { return null; } $date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', str_replace('T', ' ', $value), $tz); return $date ?: null; } private static function match_date(string $from, string $to, \DateTimeImmutable $now): ?bool { $tz = $now->getTimezone(); $start = self::parse_date($from, $tz, false); $end = self::parse_date($to, $tz, true); if (!$start && !$end) { return null; } return (!$start || $now >= $start) && (!$end || $now <= $end); } private static function minutes(string $hhmm): ?int { if (!preg_match('/^(\d{1,2}):(\d{2})$/', trim($hhmm), $m) || (int) $m[1] > 23 || (int) $m[2] > 59) { return null; } return (int) $m[1] * 60 + (int) $m[2]; } private static function match_time(string $from, string $to, \DateTimeImmutable $now): ?bool { $a = self::minutes($from); $b = self::minutes($to); if ($a === null && $b === null) { return null; } $n = (int) $now->format('G') * 60 + (int) $now->format('i'); $a ??= 0; $b ??= 24 * 60 - 1; return $a <= $b ? ($n >= $a && $n <= $b) : ($n >= $a || $n <= $b); // wraps midnight (22:00 - 06:00) } private static function match_post(string $list, int $post_id, string $slug): ?bool { $items = array_values(array_filter(array_map('trim', explode(',', $list)), static fn(string $s): bool => $s !== '')); if (!$items) { return null; } foreach ($items as $item) { if (ctype_digit($item) ? (int) $item === $post_id && $post_id > 0 : ($slug !== '' && $item === $slug)) { return true; } } return false; } private static function match_param(array $rule, array $query): ?bool { $name = is_string($rule['param'] ?? null) ? trim($rule['param']) : ''; if ($name === '') { return null; } $present = array_key_exists($name, $query); $value = $present && is_scalar($query[$name]) ? (string) $query[$name] : ''; $expected = is_scalar($rule['param_value'] ?? null) ? (string) $rule['param_value'] : ''; switch ($rule['param_compare'] ?? 'exists') { case 'not_exists': return !$present; case 'equals': return $present && $value === $expected; case 'not_equals': return !$present || $value !== $expected; case 'contains': return $present && $expected !== '' && str_contains($value, $expected); default: return $present; } } private static function match_dynamic(array $rule, array $ctx): ?bool { $tag = is_string($rule['dyn_tag'] ?? null) ? preg_replace('/[^a-z0-9_-]/', '', strtolower($rule['dyn_tag'])) : ''; $resolve = $ctx['dynamic'] ?? null; if ($tag === '' || !is_callable($resolve)) { return null; } $key = is_scalar($rule['dyn_key'] ?? null) ? trim((string) $rule['dyn_key']) : ''; $value = (string) $resolve($tag, $key); return self::compare($value, (string) ($rule['dyn_compare'] ?? 'equals'), is_scalar($rule['dyn_value'] ?? null) ? trim((string) $rule['dyn_value']) : ''); } public static function compare(string $value, string $op, string $expected): bool { switch ($op) { case 'not_equals': return $value !== $expected; case 'contains': return $expected !== '' && str_contains($value, $expected); case 'not_contains': return $expected === '' || !str_contains($value, $expected); case 'empty': return $value === ''; case 'not_empty': return $value !== ''; case 'greater': return is_numeric($value) && is_numeric($expected) && (float) $value > (float) $expected; case 'less': return is_numeric($value) && is_numeric($expected) && (float) $value < (float) $expected; default: return $value === $expected; } } }